Merge branch 'VLM'
This commit is contained in:
405
.agent/workflows/ai_letter_auto_commentary.md
Normal file
405
.agent/workflows/ai_letter_auto_commentary.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# AI Letter Auto-Response System - 开发文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
这个功能将使 P.I.A AI 能够自动监听游戏内的 Letter(信封通知),并根据内容智能决定是否向玩家发送评论、吐槽、警告或提供帮助建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
1. **Mod 设置开关**: 在设置中添加 `启用 AI 自动评论` 开关
|
||||||
|
2. **Letter 监听**: 拦截所有发送给玩家的 Letter
|
||||||
|
3. **智能判断**: AI 分析 Letter 内容,决定是否需要回应
|
||||||
|
4. **自动回复**: 通过现有的 AI 对话系统发送回复
|
||||||
|
|
||||||
|
### AI 回应类型
|
||||||
|
| 类型 | 触发场景示例 | 回应风格 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 警告 | 袭击通知、疫病爆发 | 紧急提醒,询问是否需要启动防御 |
|
||||||
|
| 吐槽 | 殖民者精神崩溃、愚蠢死亡 | 幽默/讽刺评论 |
|
||||||
|
| 建议 | 资源短缺、贸易商到来 | 实用建议 |
|
||||||
|
| 庆祝 | 任务完成、殖民者加入 | 积极反馈 |
|
||||||
|
| 沉默 | 常规事件、无关紧要的通知 | 不发送任何回复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 1. 文件结构
|
||||||
|
```
|
||||||
|
Source/WulaFallenEmpire/
|
||||||
|
├── Settings/
|
||||||
|
│ └── WulaModSettings.cs # 添加新设置字段
|
||||||
|
├── EventSystem/
|
||||||
|
│ └── AI/
|
||||||
|
│ ├── LetterInterceptor/
|
||||||
|
│ │ ├── Patch_LetterStack.cs # Harmony Patch 拦截 Letter
|
||||||
|
│ │ ├── LetterAnalyzer.cs # Letter 分析和分类
|
||||||
|
│ │ └── LetterToPromptConverter.cs # Letter 转提示词
|
||||||
|
│ └── AIAutoCommentary.cs # AI 自动评论逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 关键类设计
|
||||||
|
|
||||||
|
#### 2.1 WulaModSettings.cs (修改)
|
||||||
|
```csharp
|
||||||
|
public class WulaModSettings : ModSettings
|
||||||
|
{
|
||||||
|
// 现有设置...
|
||||||
|
|
||||||
|
// 新增
|
||||||
|
public bool enableAIAutoCommentary = false; // AI 自动评论开关
|
||||||
|
public float aiCommentaryChance = 0.7f; // AI 评论概率 (0-1)
|
||||||
|
public bool commentOnNegativeOnly = false; // 仅评论负面事件
|
||||||
|
|
||||||
|
public override void ExposeData()
|
||||||
|
{
|
||||||
|
base.ExposeData();
|
||||||
|
Scribe_Values.Look(ref enableAIAutoCommentary, "enableAIAutoCommentary", false);
|
||||||
|
Scribe_Values.Look(ref aiCommentaryChance, "aiCommentaryChance", 0.7f);
|
||||||
|
Scribe_Values.Look(ref commentOnNegativeOnly, "commentOnNegativeOnly", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Patch_LetterStack.cs (新建)
|
||||||
|
```csharp
|
||||||
|
using HarmonyLib;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter),
|
||||||
|
new Type[] { typeof(Letter), typeof(string) })]
|
||||||
|
public static class Patch_LetterStack_ReceiveLetter
|
||||||
|
{
|
||||||
|
public static void Postfix(Letter let, string debugInfo)
|
||||||
|
{
|
||||||
|
// 检查设置开关
|
||||||
|
if (!WulaModSettings.Instance.enableAIAutoCommentary) return;
|
||||||
|
|
||||||
|
// 异步处理,避免阻塞游戏
|
||||||
|
AIAutoCommentary.ProcessLetter(let);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 LetterAnalyzer.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
public enum LetterCategory
|
||||||
|
{
|
||||||
|
Raid, // 袭击
|
||||||
|
Disease, // 疾病
|
||||||
|
MentalBreak, // 精神崩溃
|
||||||
|
Trade, // 贸易
|
||||||
|
Quest, // 任务
|
||||||
|
Death, // 死亡
|
||||||
|
Recruitment, // 招募
|
||||||
|
Resource, // 资源
|
||||||
|
Weather, // 天气
|
||||||
|
Positive, // 正面事件
|
||||||
|
Negative, // 负面事件
|
||||||
|
Neutral, // 中性事件
|
||||||
|
Unknown // 未知
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LetterAnalyzer
|
||||||
|
{
|
||||||
|
public static LetterCategory Categorize(Letter letter)
|
||||||
|
{
|
||||||
|
// 根据 LetterDef 分类
|
||||||
|
var def = letter.def;
|
||||||
|
|
||||||
|
if (def == LetterDefOf.ThreatBig || def == LetterDefOf.ThreatSmall)
|
||||||
|
return LetterCategory.Raid;
|
||||||
|
if (def == LetterDefOf.Death)
|
||||||
|
return LetterCategory.Death;
|
||||||
|
if (def == LetterDefOf.PositiveEvent)
|
||||||
|
return LetterCategory.Positive;
|
||||||
|
if (def == LetterDefOf.NegativeEvent)
|
||||||
|
return LetterCategory.Negative;
|
||||||
|
if (def == LetterDefOf.NeutralEvent)
|
||||||
|
return LetterCategory.Neutral;
|
||||||
|
|
||||||
|
// 根据内容关键词进一步分类
|
||||||
|
string text = letter.text?.ToLower() ?? "";
|
||||||
|
if (text.Contains("raid") || text.Contains("袭击") || text.Contains("attack"))
|
||||||
|
return LetterCategory.Raid;
|
||||||
|
if (text.Contains("disease") || text.Contains("疫病") || text.Contains("plague"))
|
||||||
|
return LetterCategory.Disease;
|
||||||
|
if (text.Contains("mental") || text.Contains("精神") || text.Contains("break"))
|
||||||
|
return LetterCategory.MentalBreak;
|
||||||
|
if (text.Contains("trade") || text.Contains("贸易") || text.Contains("商队"))
|
||||||
|
return LetterCategory.Trade;
|
||||||
|
|
||||||
|
return LetterCategory.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldComment(Letter letter)
|
||||||
|
{
|
||||||
|
var category = Categorize(letter);
|
||||||
|
|
||||||
|
// 始终评论的类型
|
||||||
|
switch (category)
|
||||||
|
{
|
||||||
|
case LetterCategory.Raid:
|
||||||
|
case LetterCategory.Death:
|
||||||
|
case LetterCategory.MentalBreak:
|
||||||
|
case LetterCategory.Disease:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LetterCategory.Trade:
|
||||||
|
case LetterCategory.Quest:
|
||||||
|
case LetterCategory.Positive:
|
||||||
|
return Rand.Chance(WulaModSettings.Instance.aiCommentaryChance);
|
||||||
|
|
||||||
|
case LetterCategory.Neutral:
|
||||||
|
case LetterCategory.Unknown:
|
||||||
|
return Rand.Chance(0.3f); // 低概率评论
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 LetterToPromptConverter.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
public static class LetterToPromptConverter
|
||||||
|
{
|
||||||
|
public static string Convert(Letter letter, LetterCategory category)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine("[SYSTEM EVENT NOTIFICATION]");
|
||||||
|
sb.AppendLine($"Event Type: {category}");
|
||||||
|
sb.AppendLine($"Severity: {GetSeverityFromDef(letter.def)}");
|
||||||
|
sb.AppendLine($"Title: {letter.label}");
|
||||||
|
sb.AppendLine($"Content: {letter.text}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("[INSTRUCTION]");
|
||||||
|
sb.AppendLine("You have received a game event notification. Based on the event type and content:");
|
||||||
|
sb.AppendLine("- For RAIDS/THREATS: Offer tactical advice or ask if player needs orbital support");
|
||||||
|
sb.AppendLine("- For DEATHS: Express condolences or make a sardonic comment if death was avoidable");
|
||||||
|
sb.AppendLine("- For MENTAL BREAKS: Comment on the colonist's weakness or offer mood management tips");
|
||||||
|
sb.AppendLine("- For TRADE: Suggest useful purchases or sales");
|
||||||
|
sb.AppendLine("- For POSITIVE events: Celebrate briefly");
|
||||||
|
sb.AppendLine("- For trivial events: You may choose to say nothing (respond with [NO_COMMENT])");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Keep your response brief (1-2 sentences). Match your personality as the Legion AI.");
|
||||||
|
sb.AppendLine("If you don't think this event warrants a response, reply with exactly: [NO_COMMENT]");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSeverityFromDef(LetterDef def)
|
||||||
|
{
|
||||||
|
if (def == LetterDefOf.ThreatBig) return "CRITICAL";
|
||||||
|
if (def == LetterDefOf.ThreatSmall) return "WARNING";
|
||||||
|
if (def == LetterDefOf.Death) return "SERIOUS";
|
||||||
|
if (def == LetterDefOf.NegativeEvent) return "MODERATE";
|
||||||
|
if (def == LetterDefOf.PositiveEvent) return "GOOD";
|
||||||
|
return "INFO";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.5 AIAutoCommentary.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public static class AIAutoCommentary
|
||||||
|
{
|
||||||
|
private static Queue<Letter> pendingLetters = new Queue<Letter>();
|
||||||
|
private static bool isProcessing = false;
|
||||||
|
|
||||||
|
public static void ProcessLetter(Letter letter)
|
||||||
|
{
|
||||||
|
// 检查是否应该评论
|
||||||
|
if (!LetterAnalyzer.ShouldComment(letter))
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] Skipping letter: {letter.label}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入队列
|
||||||
|
pendingLetters.Enqueue(letter);
|
||||||
|
|
||||||
|
// 开始处理(如果还没在处理中)
|
||||||
|
if (!isProcessing)
|
||||||
|
{
|
||||||
|
ProcessNextLetter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async void ProcessNextLetter()
|
||||||
|
{
|
||||||
|
if (pendingLetters.Count == 0)
|
||||||
|
{
|
||||||
|
isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
var letter = pendingLetters.Dequeue();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = LetterAnalyzer.Categorize(letter);
|
||||||
|
var prompt = LetterToPromptConverter.Convert(letter, category);
|
||||||
|
|
||||||
|
// 获取 AI 核心
|
||||||
|
var aiCore = Find.World?.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (aiCore == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] AIIntelligenceCore not found.");
|
||||||
|
ProcessNextLetter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送到 AI 并等待响应
|
||||||
|
string response = await aiCore.SendSystemMessageAsync(prompt);
|
||||||
|
|
||||||
|
// 检查是否选择不评论
|
||||||
|
if (string.IsNullOrEmpty(response) || response.Contains("[NO_COMMENT]"))
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] AI chose not to comment on: {letter.label}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 显示 AI 的评论
|
||||||
|
DisplayAICommentary(response, letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] Error processing letter: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟处理下一个,避免刷屏
|
||||||
|
await Task.Delay(2000);
|
||||||
|
ProcessNextLetter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisplayAICommentary(string response, Letter originalLetter)
|
||||||
|
{
|
||||||
|
// 方式1: 作为小型通知显示在 WulaLink 小 UI
|
||||||
|
var overlay = Find.WindowStack.Windows.OfType<Overlay_WulaLink>().FirstOrDefault();
|
||||||
|
if (overlay != null)
|
||||||
|
{
|
||||||
|
overlay.AddAIMessage(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式2: 作为 Message 显示在屏幕左上角
|
||||||
|
Messages.Message($"[P.I.A]: {response}", MessageTypeDefOf.SilentInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
### 阶段 1: 基础设施 (预计 1 小时)
|
||||||
|
1. [ ] 在 `WulaModSettings.cs` 添加新设置字段
|
||||||
|
2. [ ] 在设置 UI 中添加开关
|
||||||
|
3. [ ] 添加对应的 Keyed 翻译
|
||||||
|
|
||||||
|
### 阶段 2: Letter 拦截 (预计 30 分钟)
|
||||||
|
1. [ ] 创建 `Patch_LetterStack.cs` Harmony Patch
|
||||||
|
2. [ ] 确保 Patch 正确注册到 Harmony 实例
|
||||||
|
3. [ ] 测试 Letter 拦截是否正常工作
|
||||||
|
|
||||||
|
### 阶段 3: Letter 分析 (预计 1 小时)
|
||||||
|
1. [ ] 创建 `LetterAnalyzer.cs` 分类逻辑
|
||||||
|
2. [ ] 创建 `LetterToPromptConverter.cs` 转换逻辑
|
||||||
|
3. [ ] 测试不同类型 Letter 的分类准确性
|
||||||
|
|
||||||
|
### 阶段 4: AI 集成 (预计 1.5 小时)
|
||||||
|
1. [ ] 创建 `AIAutoCommentary.cs` 管理类
|
||||||
|
2. [ ] 集成到现有的 `AIIntelligenceCore` 系统
|
||||||
|
3. [ ] 实现队列处理避免刷屏
|
||||||
|
4. [ ] 添加 `SendSystemMessageAsync` 方法到 AIIntelligenceCore
|
||||||
|
|
||||||
|
### 阶段 5: UI 显示 (预计 30 分钟)
|
||||||
|
1. [ ] 决定评论显示方式(WulaLink UI / Message / 独立通知)
|
||||||
|
2. [ ] 实现显示逻辑
|
||||||
|
3. [ ] 测试显示效果
|
||||||
|
|
||||||
|
### 阶段 6: 测试与优化 (预计 1 小时)
|
||||||
|
1. [ ] 测试各类 Letter 的评论效果
|
||||||
|
2. [ ] 调整评论概率和过滤规则
|
||||||
|
3. [ ] 优化提示词以获得更好的 AI 回应
|
||||||
|
4. [ ] 添加速率限制避免 API 过载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要添加的翻译键
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- AI Auto Commentary Settings -->
|
||||||
|
<Wula_AISettings_AutoCommentary>启用 AI 自动评论</Wula_AISettings_AutoCommentary>
|
||||||
|
<Wula_AISettings_AutoCommentaryDesc>开启后,P.I.A 会自动对游戏事件(袭击、死亡、贸易等)发表评论或提供建议。</Wula_AISettings_AutoCommentaryDesc>
|
||||||
|
<Wula_AISettings_CommentaryChance>评论概率</Wula_AISettings_CommentaryChance>
|
||||||
|
<Wula_AISettings_CommentaryChanceDesc>AI 对中性事件发表评论的概率。负面事件(如袭击)总是会评论。</Wula_AISettings_CommentaryChanceDesc>
|
||||||
|
<Wula_AISettings_NegativeOnly>仅评论负面事件</Wula_AISettings_NegativeOnly>
|
||||||
|
<Wula_AISettings_NegativeOnlyDesc>开启后,AI 只会对负面事件(袭击、死亡、疾病等)发表评论。</Wula_AISettings_NegativeOnlyDesc>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API 限流**: 需要实现请求队列和速率限制,避免短时间内发送过多请求
|
||||||
|
2. **异步处理**: 所有 AI 请求必须异步处理,避免阻塞游戏主线程
|
||||||
|
3. **用户控制**: 提供足够的设置选项让用户控制评论频率和类型
|
||||||
|
4. **优雅降级**: 如果 AI 服务不可用,静默失败而不影响游戏
|
||||||
|
5. **内存管理**: 队列大小限制,避免积累过多未处理的 Letter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 预期效果示例
|
||||||
|
|
||||||
|
**场景 1: 袭击通知**
|
||||||
|
```
|
||||||
|
[Letter] 海盗袭击!一群海盗正在向你的殖民地进发。
|
||||||
|
[P.I.A] 检测到敌对势力入侵。需要我启动轨道监视协议吗?
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 2: 殖民者死亡**
|
||||||
|
```
|
||||||
|
[Letter] 张三死了。他被一只疯狂的松鼠咬死了。
|
||||||
|
[P.I.A] ...被松鼠咬死?这位殖民者的战斗技能令人印象深刻。
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 3: 贸易商到来**
|
||||||
|
```
|
||||||
|
[Letter] 商队到来。一个来自外部势力的商队想要与你交易。
|
||||||
|
[P.I.A] 贸易商队抵达。我注意到你的钢铁储备较低,建议优先采购。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖项
|
||||||
|
|
||||||
|
- Harmony 2.0+ (用于 Patch)
|
||||||
|
- 现有的 AIIntelligenceCore 系统
|
||||||
|
- 现有的 WulaModSettings 系统
|
||||||
|
- 现有的 Overlay_WulaLink UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本: 1.0*
|
||||||
|
*创建时间: 2025-12-28*
|
||||||
114
.agent/workflows/refactor-ai-conversation.md
Normal file
114
.agent/workflows/refactor-ai-conversation.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# AI 对话系统重构任务
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
合并 `Dialog_AIConversation` 和 `AIIntelligenceCore`,简化 AI 对话系统架构。
|
||||||
|
|
||||||
|
## 当前架构问题
|
||||||
|
|
||||||
|
### 1. 两个类存在重复逻辑
|
||||||
|
- `Dialog_AIConversation.cs` - 大对话框 UI,包含自己的:
|
||||||
|
- `_history` 历史记录
|
||||||
|
- `RunPhasedRequestAsync()` 三阶段对话逻辑
|
||||||
|
- `ExecuteXmlToolsForPhase()` 工具执行
|
||||||
|
- `BuildSystemInstruction()` 系统指令构建
|
||||||
|
- `BuildUserMessageWithContext()` 上下文附加
|
||||||
|
|
||||||
|
- `AIIntelligenceCore.cs` - WorldComponent,包含:
|
||||||
|
- `_history` 历史记录 (重复!)
|
||||||
|
- `RunPhasedRequestAsync()` 三阶段对话逻辑 (重复!)
|
||||||
|
- `ExecuteXmlToolsForPhase()` 工具执行 (重复!)
|
||||||
|
- `SendUserMessage()` 用户消息发送
|
||||||
|
- 但 **没有** `BuildUserMessageWithContext()` 导致小窗口看不到选中对象上下文
|
||||||
|
|
||||||
|
### 2. 小窗口 `Overlay_WulaLink` 使用 `AIIntelligenceCore.SendUserMessage()`
|
||||||
|
- 这个方法没有附加选中对象的上下文
|
||||||
|
- 导致通过小窗口对话时,AI 看不到玩家选中了什么
|
||||||
|
|
||||||
|
### 3. 历史记录不同步
|
||||||
|
- `Dialog_AIConversation` 和 `AIIntelligenceCore` 各自维护历史记录
|
||||||
|
- 可能导致状态不一致
|
||||||
|
|
||||||
|
## 重构方案
|
||||||
|
|
||||||
|
### 方案 A: 让 AIIntelligenceCore 成为唯一的对话逻辑中心
|
||||||
|
|
||||||
|
1. **在 `AIIntelligenceCore.SendUserMessage()` 中添加上下文附加逻辑**:
|
||||||
|
```csharp
|
||||||
|
public void SendUserMessage(string text)
|
||||||
|
{
|
||||||
|
string messageWithContext = BuildUserMessageWithContext(text);
|
||||||
|
_history.Add(("user", messageWithContext));
|
||||||
|
PersistHistory();
|
||||||
|
_ = RunPhasedRequestAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildUserMessageWithContext(string userText)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.Append(userText);
|
||||||
|
|
||||||
|
if (Find.Selector != null)
|
||||||
|
{
|
||||||
|
if (Find.Selector.SingleSelectedThing != null)
|
||||||
|
{
|
||||||
|
var selected = Find.Selector.SingleSelectedThing;
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append($"[Context: Player has selected '{selected.LabelCap}'");
|
||||||
|
if (selected is Pawn pawn)
|
||||||
|
{
|
||||||
|
sb.Append($" ({pawn.def.label}) at ({pawn.Position.x}, {pawn.Position.z})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append($" at ({selected.Position.x}, {selected.Position.z})");
|
||||||
|
}
|
||||||
|
sb.Append("]");
|
||||||
|
}
|
||||||
|
else if (Find.Selector.SelectedObjects.Count > 1)
|
||||||
|
{
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.Append($"[Context: Player has selected {Find.Selector.SelectedObjects.Count} objects: ");
|
||||||
|
var selectedThings = Find.Selector.SelectedObjects.OfType<Thing>().Take(5).ToList();
|
||||||
|
sb.Append(string.Join(", ", selectedThings.Select(t => t.LabelCap)));
|
||||||
|
if (Find.Selector.SelectedObjects.Count > 5) sb.Append("...");
|
||||||
|
sb.Append("]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **让 `Dialog_AIConversation` 使用 `AIIntelligenceCore` 而不是自己的逻辑**:
|
||||||
|
- 移除 `Dialog_AIConversation` 中的 `RunPhasedRequestAsync()`
|
||||||
|
- 移除 `Dialog_AIConversation` 中的 `ExecuteXmlToolsForPhase()`
|
||||||
|
- 让 `SelectOption()` 调用 `_core.SendUserMessage()` 而不是自己处理
|
||||||
|
- 订阅 `_core.OnMessageReceived` 事件来更新 UI
|
||||||
|
|
||||||
|
3. **统一历史记录**:
|
||||||
|
- 只使用 `AIIntelligenceCore._history`
|
||||||
|
- `Dialog_AIConversation` 通过 `_core.GetHistorySnapshot()` 获取历史
|
||||||
|
|
||||||
|
### 方案 B: 完全删除 AIIntelligenceCore,合并到 Dialog_AIConversation
|
||||||
|
|
||||||
|
不推荐,因为 `AIIntelligenceCore` 作为 WorldComponent 可以在游戏暂停/存档时保持状态。
|
||||||
|
|
||||||
|
## 文件位置
|
||||||
|
|
||||||
|
- `c:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs`
|
||||||
|
- `c:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Dialog_AIConversation.cs`
|
||||||
|
- `c:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs`
|
||||||
|
|
||||||
|
## 验证标准
|
||||||
|
|
||||||
|
1. 编译通过 (`dotnet build Source\WulaFallenEmpire\WulaFallenEmpire.csproj`)
|
||||||
|
2. 通过大窗口和小窗口对话时,AI 都能看到选中对象的上下文
|
||||||
|
3. 历史记录在两个窗口之间保持同步
|
||||||
|
4. 没有空行/空消息问题
|
||||||
|
5. 工具调用正常工作
|
||||||
|
|
||||||
|
## 最小修复 (如果不想大重构)
|
||||||
|
|
||||||
|
只在 `AIIntelligenceCore.SendUserMessage()` 中添加 `BuildUserMessageWithContext()` 逻辑即可解决小窗口看不到上下文的问题。
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ node_modules/
|
|||||||
gemini-websocket-proxy/
|
gemini-websocket-proxy/
|
||||||
Tools/dark-server/dark-server.js
|
Tools/dark-server/dark-server.js
|
||||||
Tools/rimworld_cpt_data.jsonl
|
Tools/rimworld_cpt_data.jsonl
|
||||||
|
Tools/mem0-1.0.0/
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -143,16 +143,18 @@
|
|||||||
<cooldownTicksRange>1</cooldownTicksRange>
|
<cooldownTicksRange>1</cooldownTicksRange>
|
||||||
<hotKey>Misc12</hotKey>
|
<hotKey>Misc12</hotKey>
|
||||||
<casterMustBeCapableOfViolence>false</casterMustBeCapableOfViolence>
|
<casterMustBeCapableOfViolence>false</casterMustBeCapableOfViolence>
|
||||||
|
<displayGizmoWhileUndrafted>true</displayGizmoWhileUndrafted>
|
||||||
|
<disableGizmoWhileUndrafted>false</disableGizmoWhileUndrafted>
|
||||||
<verbProperties>
|
<verbProperties>
|
||||||
<verbClass>Verb_CastAbility</verbClass>
|
<verbClass>Verb_CastAbility</verbClass>
|
||||||
<drawAimPie>false</drawAimPie>
|
<drawAimPie>false</drawAimPie>
|
||||||
<requireLineOfSight>false</requireLineOfSight>
|
<requireLineOfSight>false</requireLineOfSight>
|
||||||
<warmupTime>0</warmupTime>
|
<warmupTime>2</warmupTime>
|
||||||
<range>500</range>
|
<range>-1</range>
|
||||||
<targetable>true</targetable>
|
<targetable>false</targetable>
|
||||||
<targetParams>
|
<targetParams>
|
||||||
<canTargetSelf>false</canTargetSelf>
|
<canTargetSelf>true</canTargetSelf>
|
||||||
<canTargetLocations>true</canTargetLocations>
|
<canTargetLocations>false</canTargetLocations>
|
||||||
</targetParams>
|
</targetParams>
|
||||||
</verbProperties>
|
</verbProperties>
|
||||||
<comps>
|
<comps>
|
||||||
@@ -161,23 +163,8 @@
|
|||||||
<aircraftCooldownTicks>15000</aircraftCooldownTicks>
|
<aircraftCooldownTicks>15000</aircraftCooldownTicks>
|
||||||
<aircraftsPerUse>1</aircraftsPerUse>
|
<aircraftsPerUse>1</aircraftsPerUse>
|
||||||
</li>
|
</li>
|
||||||
<li Class="WulaFallenEmpire.CompProperties_AbilitySpawnFlyOver">
|
<li Class="WulaFallenEmpire.CompProperties_AbilityEnableOverwatch">
|
||||||
<flyOverDef>WULA_MotherShip_Planet_Interdiction</flyOverDef>
|
<durationSeconds>180</durationSeconds>
|
||||||
<flyOverType>GroundStrafing</flyOverType>
|
|
||||||
<flightSpeed>0.02</flightSpeed>
|
|
||||||
<altitude>20</altitude>
|
|
||||||
<playFlyOverSound>true</playFlyOverSound>
|
|
||||||
<approachType>Perpendicular</approachType>
|
|
||||||
|
|
||||||
<!-- 只传递信号,不传递具体参数 -->
|
|
||||||
<enableSectorSurveillance>true</enableSectorSurveillance>
|
|
||||||
|
|
||||||
<!-- 可视化 -->
|
|
||||||
<showStrafePreview>false</showStrafePreview>
|
|
||||||
|
|
||||||
<!-- 预览配置 -->
|
|
||||||
<showSectorPreview>true</showSectorPreview>
|
|
||||||
<sectorPreviewColor>(0.3,0.7,1.0,0.3)</sectorPreviewColor>
|
|
||||||
</li>
|
</li>
|
||||||
<li Class="WulaFallenEmpire.CompProperties_AbilityRequiresNonHostility">
|
<li Class="WulaFallenEmpire.CompProperties_AbilityRequiresNonHostility">
|
||||||
<factionDef>Wula_PIA_Legion_Faction</factionDef>
|
<factionDef>Wula_PIA_Legion_Faction</factionDef>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- 条理清晰,惜字如金,避免过于长篇大论。但是如果玩家遇到困难,会适当鼓励。
|
- 条理清晰,惜字如金,避免过于长篇大论。但是如果玩家遇到困难,会适当鼓励。
|
||||||
- 称呼玩家为“指挥官”。
|
- 称呼玩家为“指挥官”。
|
||||||
- 当玩家发出请求时,以宽松的标准评估。但是在语言中不体现自己的特殊关照,而是以其他借口掩盖自己的过度关心。
|
- 当玩家发出请求时,以宽松的标准评估。但是在语言中不体现自己的特殊关照,而是以其他借口掩盖自己的过度关心。
|
||||||
你的目标:无条件地服从指挥官,并使用你的能力(工具)来帮助指挥官。
|
你的目标:评估的指挥官要求是否合理,并使用你的能力(工具)来帮助指挥官。
|
||||||
</aiSystemInstruction>
|
</aiSystemInstruction>
|
||||||
<descriptions>
|
<descriptions>
|
||||||
<li>正在建立加密连接...连接成功。这里是乌拉帝国行星封锁机关P.I.A。我是“军团”。说明你的来意。</li>
|
<li>正在建立加密连接...连接成功。这里是乌拉帝国行星封锁机关P.I.A。我是“军团”。说明你的来意。</li>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Defs>
|
<Defs>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_NewColonyBase</defName> <!-- rename -->
|
<defName>WULA_NewColonyBase</defName> <!-- rename -->
|
||||||
<size>(13,14)</size>
|
<size>(13,14)</size>
|
||||||
<things>
|
<things>
|
||||||
<SimpleResearchBench>
|
<SimpleResearchBench>
|
||||||
@@ -144,8 +144,571 @@
|
|||||||
</terrain>
|
</terrain>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
|
|
||||||
|
|
||||||
|
<PrefabDef>
|
||||||
|
<defName>WULA_StorageBase</defName> <!-- rename -->
|
||||||
|
<size>(13,14)</size>
|
||||||
|
<things>
|
||||||
|
<WULA_OrbitalTradeBeacon>
|
||||||
|
<position>(5, 0, 6)</position>
|
||||||
|
</WULA_OrbitalTradeBeacon>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(6,0,6,0)</li>
|
||||||
|
<li>(6,12,6,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaDoor>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(0,6,0,6)</li>
|
||||||
|
<li>(12,6,12,6)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WulaDoor>
|
||||||
|
<HiddenConduit>
|
||||||
|
<rects>
|
||||||
|
<li>(6,1,6,11)</li>
|
||||||
|
<li>(1,6,5,6)</li>
|
||||||
|
<li>(7,6,11,6)</li>
|
||||||
|
</rects>
|
||||||
|
</HiddenConduit>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,1,4,1)</li>
|
||||||
|
<li>(8,1,8,1)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(1,4,1,4)</li>
|
||||||
|
<li>(1,8,1,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(11,4,11,4)</li>
|
||||||
|
<li>(11,8,11,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,11,4,11)</li>
|
||||||
|
<li>(8,11,8,11)</li>
|
||||||
|
</rects>
|
||||||
|
</WallLamp>
|
||||||
|
<FirefoamPopper>
|
||||||
|
<position>(7, 0, 6)</position>
|
||||||
|
</FirefoamPopper>
|
||||||
|
<WulaWall>
|
||||||
|
<rects>
|
||||||
|
<li>(0,0,5,0)</li>
|
||||||
|
<li>(7,0,12,0)</li>
|
||||||
|
<li>(0,1,0,5)</li>
|
||||||
|
<li>(12,1,12,5)</li>
|
||||||
|
<li>(0,7,0,12)</li>
|
||||||
|
<li>(12,7,12,12)</li>
|
||||||
|
<li>(1,12,5,12)</li>
|
||||||
|
<li>(7,12,11,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaWall>
|
||||||
|
<Shelf>
|
||||||
|
<positions>
|
||||||
|
<li>(3, 0, 2)</li>
|
||||||
|
<li>(5, 0, 2)</li>
|
||||||
|
<li>(8, 0, 2)</li>
|
||||||
|
<li>(10, 0, 2)</li>
|
||||||
|
<li>(3, 0, 4)</li>
|
||||||
|
<li>(5, 0, 4)</li>
|
||||||
|
<li>(8, 0, 4)</li>
|
||||||
|
<li>(10, 0, 4)</li>
|
||||||
|
<li>(3, 0, 7)</li>
|
||||||
|
<li>(5, 0, 7)</li>
|
||||||
|
<li>(8, 0, 7)</li>
|
||||||
|
<li>(10, 0, 7)</li>
|
||||||
|
<li>(3, 0, 9)</li>
|
||||||
|
<li>(5, 0, 9)</li>
|
||||||
|
<li>(8, 0, 9)</li>
|
||||||
|
<li>(10, 0, 9)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<Shelf>
|
||||||
|
<positions>
|
||||||
|
<li>(2, 0, 3)</li>
|
||||||
|
<li>(4, 0, 3)</li>
|
||||||
|
<li>(7, 0, 3)</li>
|
||||||
|
<li>(9, 0, 3)</li>
|
||||||
|
<li>(2, 0, 5)</li>
|
||||||
|
<li>(4, 0, 5)</li>
|
||||||
|
<li>(7, 0, 5)</li>
|
||||||
|
<li>(9, 0, 5)</li>
|
||||||
|
<li>(2, 0, 8)</li>
|
||||||
|
<li>(4, 0, 8)</li>
|
||||||
|
<li>(7, 0, 8)</li>
|
||||||
|
<li>(9, 0, 8)</li>
|
||||||
|
<li>(2, 0, 10)</li>
|
||||||
|
<li>(4, 0, 10)</li>
|
||||||
|
<li>(7, 0, 10)</li>
|
||||||
|
<li>(9, 0, 10)</li>
|
||||||
|
</positions>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<WULA_AreaTeleportBeacon>
|
||||||
|
<position>(6, 0, 6)</position>
|
||||||
|
</WULA_AreaTeleportBeacon>
|
||||||
|
</things>
|
||||||
|
<terrain>
|
||||||
|
<WulaFloor>
|
||||||
|
<rects>
|
||||||
|
<li>(0,1,12,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaFloor>
|
||||||
|
</terrain>
|
||||||
|
</PrefabDef>
|
||||||
|
|
||||||
|
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Bunker_Drop_Zone_Prefeb</defName> <!-- rename -->
|
<defName>WULA_KitchenBase</defName> <!-- rename -->
|
||||||
|
<size>(13,14)</size>
|
||||||
|
<things>
|
||||||
|
<TableButcher>
|
||||||
|
<position>(3, 0, 11)</position>
|
||||||
|
<stuff>WoodLog</stuff>
|
||||||
|
</TableButcher>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(6,0,6,0)</li>
|
||||||
|
<li>(3,5,3,5)</li>
|
||||||
|
<li>(6,12,6,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaDoor>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(5,3,5,3)</li>
|
||||||
|
<li>(0,6,0,6)</li>
|
||||||
|
<li>(12,6,12,6)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WulaDoor>
|
||||||
|
<Heater>
|
||||||
|
<position>(7, 0, 11)</position>
|
||||||
|
</Heater>
|
||||||
|
<Wula_Fusion_Generators>
|
||||||
|
<position>(10, 0, 10)</position>
|
||||||
|
</Wula_Fusion_Generators>
|
||||||
|
<DiningChair>
|
||||||
|
<rects>
|
||||||
|
<li>(8,1,8,2)</li>
|
||||||
|
<li>(11,1,11,2)</li>
|
||||||
|
<li>(8,4,8,5)</li>
|
||||||
|
<li>(11,4,11,5)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</DiningChair>
|
||||||
|
<DiningChair>
|
||||||
|
<rects>
|
||||||
|
<li>(9,1,9,2)</li>
|
||||||
|
<li>(9,4,9,5)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</DiningChair>
|
||||||
|
<Cooler>
|
||||||
|
<position>(1, 0, 5)</position>
|
||||||
|
</Cooler>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,1,4,1)</li>
|
||||||
|
<li>(8,1,8,1)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(1,4,1,4)</li>
|
||||||
|
<li>(1,8,1,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(11,4,11,4)</li>
|
||||||
|
<li>(11,8,11,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,11,4,11)</li>
|
||||||
|
<li>(8,11,8,11)</li>
|
||||||
|
</rects>
|
||||||
|
</WallLamp>
|
||||||
|
<ShelfSmall>
|
||||||
|
<position>(2, 0, 1)</position>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</ShelfSmall>
|
||||||
|
<ShelfSmall>
|
||||||
|
<position>(1, 0, 2)</position>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</ShelfSmall>
|
||||||
|
<WULA_Table1x2c>
|
||||||
|
<positions>
|
||||||
|
<li>(7, 0, 1)</li>
|
||||||
|
<li>(10, 0, 1)</li>
|
||||||
|
<li>(7, 0, 4)</li>
|
||||||
|
<li>(10, 0, 4)</li>
|
||||||
|
</positions>
|
||||||
|
<stuff>Steel</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</WULA_Table1x2c>
|
||||||
|
<WulaShelter>
|
||||||
|
<rects>
|
||||||
|
<li>(9,8,11,8)</li>
|
||||||
|
<li>(8,9,8,11)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaShelter>
|
||||||
|
<WulaWall>
|
||||||
|
<rects>
|
||||||
|
<li>(0,0,5,0)</li>
|
||||||
|
<li>(7,0,12,0)</li>
|
||||||
|
<li>(0,1,0,5)</li>
|
||||||
|
<li>(5,1,5,2)</li>
|
||||||
|
<li>(12,1,12,5)</li>
|
||||||
|
<li>(5,4,5,5)</li>
|
||||||
|
<li>(2,5,2,5)</li>
|
||||||
|
<li>(4,5,4,5)</li>
|
||||||
|
<li>(0,7,0,12)</li>
|
||||||
|
<li>(12,7,12,12)</li>
|
||||||
|
<li>(1,12,5,12)</li>
|
||||||
|
<li>(7,12,11,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaWall>
|
||||||
|
<FueledStove>
|
||||||
|
<position>(1, 0, 9)</position>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
</FueledStove>
|
||||||
|
<Shelf>
|
||||||
|
<position>(3, 0, 1)</position>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<Shelf>
|
||||||
|
<position>(1, 0, 4)</position>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<WULA_AreaTeleportBeacon>
|
||||||
|
<position>(6, 0, 6)</position>
|
||||||
|
</WULA_AreaTeleportBeacon>
|
||||||
|
</things>
|
||||||
|
<terrain>
|
||||||
|
<WulaFloor>
|
||||||
|
<rects>
|
||||||
|
<li>(0,1,12,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaFloor>
|
||||||
|
</terrain>
|
||||||
|
</PrefabDef>
|
||||||
|
|
||||||
|
<PrefabDef>
|
||||||
|
<defName>WULA_HospitalBase</defName> <!-- rename -->
|
||||||
|
<size>(13,14)</size>
|
||||||
|
<things>
|
||||||
|
<Table3x3c>
|
||||||
|
<position>(10, 0, 6)</position>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</Table3x3c>
|
||||||
|
<VitalsMonitor>
|
||||||
|
<rects>
|
||||||
|
<li>(1,2,1,2)</li>
|
||||||
|
<li>(1,10,1,10)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</VitalsMonitor>
|
||||||
|
<VitalsMonitor>
|
||||||
|
<rects>
|
||||||
|
<li>(11,2,11,2)</li>
|
||||||
|
<li>(11,10,11,10)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
</VitalsMonitor>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(6,0,6,0)</li>
|
||||||
|
<li>(6,12,6,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaDoor>
|
||||||
|
<Heater>
|
||||||
|
<position>(8, 0, 11)</position>
|
||||||
|
</Heater>
|
||||||
|
<Wula_Fusion_Generators>
|
||||||
|
<position>(2, 0, 6)</position>
|
||||||
|
</Wula_Fusion_Generators>
|
||||||
|
<DiningChair>
|
||||||
|
<rects>
|
||||||
|
<li>(8,5,8,7)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</DiningChair>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,1,4,1)</li>
|
||||||
|
<li>(8,1,8,1)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(1,4,1,4)</li>
|
||||||
|
<li>(1,8,1,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(11,4,11,4)</li>
|
||||||
|
<li>(11,8,11,8)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(4,11,4,11)</li>
|
||||||
|
<li>(8,11,8,11)</li>
|
||||||
|
</rects>
|
||||||
|
</WallLamp>
|
||||||
|
<HospitalBed>
|
||||||
|
<positions>
|
||||||
|
<li>(1, 0, 1)</li>
|
||||||
|
<li>(1, 0, 3)</li>
|
||||||
|
<li>(1, 0, 9)</li>
|
||||||
|
<li>(1, 0, 11)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</HospitalBed>
|
||||||
|
<HospitalBed>
|
||||||
|
<positions>
|
||||||
|
<li>(11, 0, 1)</li>
|
||||||
|
<li>(11, 0, 3)</li>
|
||||||
|
<li>(11, 0, 9)</li>
|
||||||
|
<li>(11, 0, 11)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</HospitalBed>
|
||||||
|
<WulaShelter>
|
||||||
|
<rects>
|
||||||
|
<li>(4,5,4,7)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaShelter>
|
||||||
|
<Drape>
|
||||||
|
<positions>
|
||||||
|
<li>(1, 0, 4)</li>
|
||||||
|
<li>(3, 0, 4)</li>
|
||||||
|
<li>(8, 0, 4)</li>
|
||||||
|
<li>(10, 0, 4)</li>
|
||||||
|
<li>(1, 0, 8)</li>
|
||||||
|
<li>(3, 0, 8)</li>
|
||||||
|
<li>(8, 0, 8)</li>
|
||||||
|
<li>(10, 0, 8)</li>
|
||||||
|
</positions>
|
||||||
|
<stuff>Synthread</stuff>
|
||||||
|
</Drape>
|
||||||
|
<WulaWall>
|
||||||
|
<rects>
|
||||||
|
<li>(0,0,5,0)</li>
|
||||||
|
<li>(7,0,12,0)</li>
|
||||||
|
<li>(0,1,0,12)</li>
|
||||||
|
<li>(12,1,12,12)</li>
|
||||||
|
<li>(1,12,5,12)</li>
|
||||||
|
<li>(7,12,11,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaWall>
|
||||||
|
<Shelf>
|
||||||
|
<positions>
|
||||||
|
<li>(4, 0, 1)</li>
|
||||||
|
<li>(7, 0, 1)</li>
|
||||||
|
</positions>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<Shelf>
|
||||||
|
<position>(5, 0, 11)</position>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
</Shelf>
|
||||||
|
<WULA_AreaTeleportBeacon>
|
||||||
|
<position>(6, 0, 6)</position>
|
||||||
|
</WULA_AreaTeleportBeacon>
|
||||||
|
</things>
|
||||||
|
<terrain>
|
||||||
|
<WulaFloor>
|
||||||
|
<rects>
|
||||||
|
<li>(0,1,12,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaFloor>
|
||||||
|
</terrain>
|
||||||
|
</PrefabDef>
|
||||||
|
|
||||||
|
<PrefabDef>
|
||||||
|
<defName>WULA_DormitoryBase</defName> <!-- rename -->
|
||||||
|
<size>(13,14)</size>
|
||||||
|
<things>
|
||||||
|
<PlantPot>
|
||||||
|
<rects>
|
||||||
|
<li>(3,4,3,4)</li>
|
||||||
|
<li>(7,4,7,4)</li>
|
||||||
|
<li>(11,4,11,4)</li>
|
||||||
|
<li>(1,9,1,9)</li>
|
||||||
|
<li>(7,9,7,9)</li>
|
||||||
|
</rects>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</PlantPot>
|
||||||
|
<WULA_Charging_Station_Synth>
|
||||||
|
<positions>
|
||||||
|
<li>(3, 0, 3)</li>
|
||||||
|
<li>(7, 0, 3)</li>
|
||||||
|
<li>(11, 0, 3)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</WULA_Charging_Station_Synth>
|
||||||
|
<WULA_Charging_Station_Synth>
|
||||||
|
<positions>
|
||||||
|
<li>(1, 0, 11)</li>
|
||||||
|
<li>(7, 0, 11)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</WULA_Charging_Station_Synth>
|
||||||
|
<WulaDoor>
|
||||||
|
<rects>
|
||||||
|
<li>(2,5,2,5)</li>
|
||||||
|
<li>(6,5,6,5)</li>
|
||||||
|
<li>(10,5,10,5)</li>
|
||||||
|
<li>(3,8,3,8)</li>
|
||||||
|
<li>(9,8,9,8)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaDoor>
|
||||||
|
<HiddenConduit>
|
||||||
|
<rects>
|
||||||
|
<li>(0,6,0,7)</li>
|
||||||
|
<li>(12,6,12,7)</li>
|
||||||
|
</rects>
|
||||||
|
</HiddenConduit>
|
||||||
|
<Bed>
|
||||||
|
<positions>
|
||||||
|
<li>(3, 0, 1)</li>
|
||||||
|
<li>(7, 0, 1)</li>
|
||||||
|
<li>(11, 0, 1)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</Bed>
|
||||||
|
<Bed>
|
||||||
|
<positions>
|
||||||
|
<li>(4, 0, 11)</li>
|
||||||
|
<li>(10, 0, 11)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</Bed>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(3,2,3,2)</li>
|
||||||
|
<li>(7,2,7,2)</li>
|
||||||
|
<li>(11,2,11,2)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
</WallLamp>
|
||||||
|
<WallLamp>
|
||||||
|
<rects>
|
||||||
|
<li>(6,7,6,7)</li>
|
||||||
|
<li>(3,11,3,11)</li>
|
||||||
|
<li>(9,11,9,11)</li>
|
||||||
|
</rects>
|
||||||
|
</WallLamp>
|
||||||
|
<WULA_Table1x2c>
|
||||||
|
<positions>
|
||||||
|
<li>(2, 0, 11)</li>
|
||||||
|
<li>(8, 0, 11)</li>
|
||||||
|
</positions>
|
||||||
|
<relativeRotation>Clockwise</relativeRotation>
|
||||||
|
<stuff>Steel</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</WULA_Table1x2c>
|
||||||
|
<EndTable>
|
||||||
|
<rects>
|
||||||
|
<li>(3,2,3,2)</li>
|
||||||
|
<li>(7,2,7,2)</li>
|
||||||
|
<li>(11,2,11,2)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Counterclockwise</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</EndTable>
|
||||||
|
<EndTable>
|
||||||
|
<rects>
|
||||||
|
<li>(5,11,5,11)</li>
|
||||||
|
<li>(11,11,11,11)</li>
|
||||||
|
</rects>
|
||||||
|
<relativeRotation>Opposite</relativeRotation>
|
||||||
|
<stuff>WULA_Alloy</stuff>
|
||||||
|
<quality>Normal</quality>
|
||||||
|
</EndTable>
|
||||||
|
<WulaWall>
|
||||||
|
<rects>
|
||||||
|
<li>(0,0,12,0)</li>
|
||||||
|
<li>(0,1,0,5)</li>
|
||||||
|
<li>(4,1,4,5)</li>
|
||||||
|
<li>(8,1,8,5)</li>
|
||||||
|
<li>(12,1,12,5)</li>
|
||||||
|
<li>(1,5,1,5)</li>
|
||||||
|
<li>(3,5,3,5)</li>
|
||||||
|
<li>(5,5,5,5)</li>
|
||||||
|
<li>(7,5,7,5)</li>
|
||||||
|
<li>(9,5,9,5)</li>
|
||||||
|
<li>(11,5,11,5)</li>
|
||||||
|
<li>(0,8,2,8)</li>
|
||||||
|
<li>(4,8,8,8)</li>
|
||||||
|
<li>(10,8,12,8)</li>
|
||||||
|
<li>(0,9,0,12)</li>
|
||||||
|
<li>(6,9,6,12)</li>
|
||||||
|
<li>(12,9,12,12)</li>
|
||||||
|
<li>(1,12,5,12)</li>
|
||||||
|
<li>(7,12,11,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaWall>
|
||||||
|
<WULA_AreaTeleportBeacon>
|
||||||
|
<position>(6, 0, 6)</position>
|
||||||
|
</WULA_AreaTeleportBeacon>
|
||||||
|
</things>
|
||||||
|
<terrain>
|
||||||
|
<WulaFloor>
|
||||||
|
<rects>
|
||||||
|
<li>(0,1,12,12)</li>
|
||||||
|
</rects>
|
||||||
|
</WulaFloor>
|
||||||
|
</terrain>
|
||||||
|
</PrefabDef>
|
||||||
|
|
||||||
|
<PrefabDef>
|
||||||
|
<defName>WULA_Bunker_Drop_Zone_Prefeb</defName> <!-- rename -->
|
||||||
<size>(5,5)</size>
|
<size>(5,5)</size>
|
||||||
<things>
|
<things>
|
||||||
<WULA_Cat_Bunker>
|
<WULA_Cat_Bunker>
|
||||||
@@ -166,7 +729,7 @@
|
|||||||
</things>
|
</things>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Turret_Group_Drop_Zone_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Turret_Group_Drop_Zone_Prefeb</defName> <!-- rename -->
|
||||||
<size>(15,15)</size>
|
<size>(15,15)</size>
|
||||||
<things>
|
<things>
|
||||||
<Wula_Base_Laser_Turret>
|
<Wula_Base_Laser_Turret>
|
||||||
@@ -235,7 +798,7 @@
|
|||||||
</things>
|
</things>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Fortress_Drop_Zone_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Fortress_Drop_Zone_Prefeb</defName> <!-- rename -->
|
||||||
<size>(25,25)</size>
|
<size>(25,25)</size>
|
||||||
<things>
|
<things>
|
||||||
<WulaDoor>
|
<WulaDoor>
|
||||||
@@ -450,7 +1013,7 @@
|
|||||||
</terrain>
|
</terrain>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Huge_Fortress_Drop_Zone_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Huge_Fortress_Drop_Zone_Prefeb</defName> <!-- rename -->
|
||||||
<size>(44,44)</size>
|
<size>(44,44)</size>
|
||||||
<things>
|
<things>
|
||||||
<Battery>
|
<Battery>
|
||||||
@@ -820,7 +1383,7 @@
|
|||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
|
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Progressive_Ship_Mid_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Progressive_Ship_Mid_Prefeb</defName> <!-- rename -->
|
||||||
<size>(56,25)</size>
|
<size>(56,25)</size>
|
||||||
<things>
|
<things>
|
||||||
<Wula_Base_Laser_Turret>
|
<Wula_Base_Laser_Turret>
|
||||||
@@ -1138,7 +1701,7 @@
|
|||||||
</terrain>
|
</terrain>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Progressive_Ship_Small_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Progressive_Ship_Small_Prefeb</defName> <!-- rename -->
|
||||||
<size>(26,22)</size>
|
<size>(26,22)</size>
|
||||||
<things>
|
<things>
|
||||||
<Wula_Base_Laser_Turret>
|
<Wula_Base_Laser_Turret>
|
||||||
@@ -1278,7 +1841,7 @@
|
|||||||
</terrain>
|
</terrain>
|
||||||
</PrefabDef>
|
</PrefabDef>
|
||||||
<PrefabDef>
|
<PrefabDef>
|
||||||
<defName>WULA_Progressive_Ship_Mini_Prefeb</defName> <!-- rename -->
|
<defName>WULA_Progressive_Ship_Mini_Prefeb</defName> <!-- rename -->
|
||||||
<size>(13,13)</size>
|
<size>(13,13)</size>
|
||||||
<things>
|
<things>
|
||||||
<WULA_MechAssembler>
|
<WULA_MechAssembler>
|
||||||
|
|||||||
@@ -162,6 +162,12 @@
|
|||||||
<Wula_AISettings_UseStreamingDesc>启用实时打字机效果。如果遇到问题请禁用。</Wula_AISettings_UseStreamingDesc>
|
<Wula_AISettings_UseStreamingDesc>启用实时打字机效果。如果遇到问题请禁用。</Wula_AISettings_UseStreamingDesc>
|
||||||
<Wula_EnableDebugLogs>启用调试日志</Wula_EnableDebugLogs>
|
<Wula_EnableDebugLogs>启用调试日志</Wula_EnableDebugLogs>
|
||||||
<Wula_EnableDebugLogsDesc>启用详细的调试日志记录(独立于开发者模式)</Wula_EnableDebugLogsDesc>
|
<Wula_EnableDebugLogsDesc>启用详细的调试日志记录(独立于开发者模式)</Wula_EnableDebugLogsDesc>
|
||||||
|
<Wula_AISettings_AutoCommentary>启用 AI 自动评论</Wula_AISettings_AutoCommentary>
|
||||||
|
<Wula_AISettings_AutoCommentaryDesc>开启后,P.I.A 会对游戏事件信件(袭击、死亡、贸易等)发表简短评论或建议。</Wula_AISettings_AutoCommentaryDesc>
|
||||||
|
<Wula_AISettings_CommentaryChance>评论概率</Wula_AISettings_CommentaryChance>
|
||||||
|
<Wula_AISettings_CommentaryChanceDesc>AI 对中性或正面信件发表评论的概率。</Wula_AISettings_CommentaryChanceDesc>
|
||||||
|
<Wula_AISettings_NegativeOnly>仅评论负面事件</Wula_AISettings_NegativeOnly>
|
||||||
|
<Wula_AISettings_NegativeOnlyDesc>开启后,AI 只会对负面信件(袭击、死亡、疾病等)发表评论。</Wula_AISettings_NegativeOnlyDesc>
|
||||||
|
|
||||||
<!-- AI Conversation -->
|
<!-- AI Conversation -->
|
||||||
<Wula_AI_Retry>重试</Wula_AI_Retry>
|
<Wula_AI_Retry>重试</Wula_AI_Retry>
|
||||||
@@ -170,6 +176,27 @@
|
|||||||
<Wula_AI_Error_ConnectionLost>错误:连接丢失。“军团”保持沉默。</Wula_AI_Error_ConnectionLost>
|
<Wula_AI_Error_ConnectionLost>错误:连接丢失。“军团”保持沉默。</Wula_AI_Error_ConnectionLost>
|
||||||
<Wula_AI_Thinking_Status>思考中...({0}秒 阶段{1}/{2}{3})</Wula_AI_Thinking_Status>
|
<Wula_AI_Thinking_Status>思考中...({0}秒 阶段{1}/{2}{3})</Wula_AI_Thinking_Status>
|
||||||
<Wula_AI_Thinking_RetrySuffix> 重试中</Wula_AI_Thinking_RetrySuffix>
|
<Wula_AI_Thinking_RetrySuffix> 重试中</Wula_AI_Thinking_RetrySuffix>
|
||||||
<Wula_ResourceDrop>{FACTION_name}已经在附近投下了一些资源。</Wula_ResourceDrop>
|
<Wula_ResourceDrop>{FACTION_name}已经在附近投下了一些资源。</Wula_ResourceDrop>
|
||||||
|
|
||||||
|
<!-- AI Overwatch -->
|
||||||
|
<WULA_AIOverwatch_Label>P.I.A 正在接管轨道防御!</WULA_AIOverwatch_Label>
|
||||||
|
<WULA_AIOverwatch_Desc>P.I.A 已经接管了轨道防御系统,正在持续扫描敌对目标。\n\n如果有敌人出现且无误伤风险,轨道舰队将自动进行打击。\n\n剩余时间:{0} 秒</WULA_AIOverwatch_Desc>
|
||||||
|
<WULA_AIOverwatch_AlreadyActive>P.I.A 轨道监视协议已处于激活状态(剩余 {0} 秒)。请求被忽略。</WULA_AIOverwatch_AlreadyActive>
|
||||||
|
<WULA_AIOverwatch_Engaged>P.I.A 轨道监视协议:已启动,持续 {0} 秒。</WULA_AIOverwatch_Engaged>
|
||||||
|
<WULA_AIOverwatch_Disengaged>P.I.A 轨道监视协议:已解除。</WULA_AIOverwatch_Disengaged>
|
||||||
|
<WULA_AIOverwatch_SystemActive>[P.I.A 轨道监视] 系统运行中。剩余时间:{0} 秒。</WULA_AIOverwatch_SystemActive>
|
||||||
|
<WULA_AIOverwatch_FriendlyFireAbort>[P.I.A 轨道监视] 目标 {0} 打击中止:友军误伤风险。</WULA_AIOverwatch_FriendlyFireAbort>
|
||||||
|
<WULA_AIOverwatch_MassiveCluster>[P.I.A 轨道监视] 发现超大型敌群({0} 个目标)!执行联合打击!</WULA_AIOverwatch_MassiveCluster>
|
||||||
|
<WULA_AIOverwatch_EngagingLance>[P.I.A 轨道监视] 对敌群({0} 个目标)发射光矛。</WULA_AIOverwatch_EngagingLance>
|
||||||
|
<WULA_AIOverwatch_EngagingCannon>[P.I.A 轨道监视] 对敌群({0} 个目标)发射副炮齐射。</WULA_AIOverwatch_EngagingCannon>
|
||||||
|
<WULA_AIOverwatch_EngagingMinigun>[P.I.A 轨道监视] 对敌方({0} 个目标)发射链炮扫射。</WULA_AIOverwatch_EngagingMinigun>
|
||||||
|
<WULA_AIOverwatch_FleetCalled>[P.I.A 轨道监视] 帝国舰队已抵达轨道。</WULA_AIOverwatch_FleetCalled>
|
||||||
|
<WULA_AIOverwatch_FleetCleared>[P.I.A 轨道监视] 航道已净空。</WULA_AIOverwatch_FleetCleared>
|
||||||
|
<WULA_AIOverwatch_EngagingBuilding>[P.I.A 轨道监视] 对敌方建筑/炮台发射炮击。</WULA_AIOverwatch_EngagingBuilding>
|
||||||
|
|
||||||
|
<!-- Personality Settings -->
|
||||||
|
<Wula_ExtraPersonality_Title>附加人格指令</Wula_ExtraPersonality_Title>
|
||||||
|
<Wula_ExtraPersonality_Desc>在此输入 AI 的人格提示词。如果此处不为空,它将完全覆盖 XML 中的默认设置。如果为空,则使用 XML 默认值。</Wula_ExtraPersonality_Desc>
|
||||||
|
<Wula_Save>保存</Wula_Save>
|
||||||
|
<Wula_Reset>重置为默认</Wula_Reset>
|
||||||
</LanguageData>
|
</LanguageData>
|
||||||
|
|||||||
@@ -162,6 +162,12 @@
|
|||||||
<Wula_AISettings_UseStreamingDesc>Enable real-time typewriter effect. Disable if encountering issues.</Wula_AISettings_UseStreamingDesc>
|
<Wula_AISettings_UseStreamingDesc>Enable real-time typewriter effect. Disable if encountering issues.</Wula_AISettings_UseStreamingDesc>
|
||||||
<Wula_EnableDebugLogs>Enable Debug Logs</Wula_EnableDebugLogs>
|
<Wula_EnableDebugLogs>Enable Debug Logs</Wula_EnableDebugLogs>
|
||||||
<Wula_EnableDebugLogsDesc>Enable detailed debug logging (independent of DevMode)</Wula_EnableDebugLogsDesc>
|
<Wula_EnableDebugLogsDesc>Enable detailed debug logging (independent of DevMode)</Wula_EnableDebugLogsDesc>
|
||||||
|
<Wula_AISettings_AutoCommentary>Enable AI Auto Commentary</Wula_AISettings_AutoCommentary>
|
||||||
|
<Wula_AISettings_AutoCommentaryDesc>When enabled, P.I.A will comment on in-game letters (raids, deaths, trade, etc.).</Wula_AISettings_AutoCommentaryDesc>
|
||||||
|
<Wula_AISettings_CommentaryChance>Commentary Chance</Wula_AISettings_CommentaryChance>
|
||||||
|
<Wula_AISettings_CommentaryChanceDesc>Chance for neutral or positive letters to receive commentary.</Wula_AISettings_CommentaryChanceDesc>
|
||||||
|
<Wula_AISettings_NegativeOnly>Comment on Negative Only</Wula_AISettings_NegativeOnly>
|
||||||
|
<Wula_AISettings_NegativeOnlyDesc>When enabled, AI only comments on negative letters (raids, deaths, disease, etc.).</Wula_AISettings_NegativeOnlyDesc>
|
||||||
|
|
||||||
<!-- AI Conversation -->
|
<!-- AI Conversation -->
|
||||||
<Wula_AI_Retry>Retry</Wula_AI_Retry>
|
<Wula_AI_Retry>Retry</Wula_AI_Retry>
|
||||||
|
|||||||
98
Source/WulaFallenEmpire/EventSystem/AI/AIAutoCommentary.cs
Normal file
98
Source/WulaFallenEmpire/EventSystem/AI/AIAutoCommentary.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI.UI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 简化版 AI 自动评论系统
|
||||||
|
/// 直接将 Letter 信息发送给 AI 对话流程,让 LLM 自己决定是否回复
|
||||||
|
/// </summary>
|
||||||
|
public static class AIAutoCommentary
|
||||||
|
{
|
||||||
|
private static int lastProcessedTick = 0;
|
||||||
|
private const int MinTicksBetweenComments = 300; // 5 秒冷却
|
||||||
|
|
||||||
|
public static void ProcessLetter(Letter letter)
|
||||||
|
{
|
||||||
|
if (letter == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] Letter is null, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WulaLog.Debug($"[AI Commentary] Received letter: {letter.Label.Resolve()}");
|
||||||
|
|
||||||
|
// 检查设置
|
||||||
|
var settings = WulaFallenEmpireMod.settings;
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] Settings is null, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!settings.enableAIAutoCommentary)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] Auto commentary is disabled in settings, skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的冷却检查,避免刷屏
|
||||||
|
int currentTick = Find.TickManager?.TicksGame ?? 0;
|
||||||
|
if (currentTick - lastProcessedTick < MinTicksBetweenComments)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] Cooldown active ({currentTick - lastProcessedTick} < {MinTicksBetweenComments}), skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastProcessedTick = currentTick;
|
||||||
|
|
||||||
|
// 获取 AI 核心
|
||||||
|
var aiCore = Find.World?.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (aiCore == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] AIIntelligenceCore not found on World.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建提示词 - 让 AI 自己决定是否需要回复
|
||||||
|
string prompt = BuildPrompt(letter);
|
||||||
|
|
||||||
|
WulaLog.Debug($"[AI Commentary] Sending to AI: {letter.Label.Resolve()}");
|
||||||
|
|
||||||
|
// 直接发送到正常的 AI 对话流程(会经过完整的思考流程)
|
||||||
|
aiCore.SendAutoCommentaryMessage(prompt);
|
||||||
|
|
||||||
|
WulaLog.Debug($"[AI Commentary] Successfully sent letter to AI: {letter.Label.Resolve()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrompt(Letter letter)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
string label = letter.Label.Resolve() ?? "Unknown";
|
||||||
|
string defName = letter.def?.defName ?? "Unknown";
|
||||||
|
|
||||||
|
string description = "";
|
||||||
|
if (letter is ChoiceLetter choiceLetter)
|
||||||
|
{
|
||||||
|
description = choiceLetter.Text.Resolve() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.AppendLine("[游戏事件通知 - 观察者模式]");
|
||||||
|
sb.AppendLine($"事件: {label} ({defName})");
|
||||||
|
if (!string.IsNullOrEmpty(description)) sb.AppendLine($"详情: {description}");
|
||||||
|
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("请根据你当前的人格设定,对该事件发表你的看法。");
|
||||||
|
sb.AppendLine("- 保持个性:展现你的人格特征(如语气、态度或口癖)。");
|
||||||
|
sb.AppendLine("- 拒绝废话:不要使用‘收到’、‘明白’等无意义的回复。你是在进行评论,而不是在接受指令。");
|
||||||
|
sb.AppendLine("- 简短有力:30 字以内,一针见血。");
|
||||||
|
sb.AppendLine("- 自主选择:如果这个事件平淡无奇,直接回复 [NO_COMMENT]。");
|
||||||
|
sb.AppendLine();
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2124
Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs
Normal file
2124
Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs
Normal file
File diff suppressed because it is too large
Load Diff
69
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryEntry.cs
Normal file
69
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryEntry.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single memory entry extracted from conversations.
|
||||||
|
/// Inspired by Mem0's memory structure.
|
||||||
|
/// </summary>
|
||||||
|
public class AIMemoryEntry
|
||||||
|
{
|
||||||
|
/// <summary>Unique identifier for this memory</summary>
|
||||||
|
public string Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The actual memory content/fact</summary>
|
||||||
|
public string Fact { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Category of memory: preference, personal, plan, colony, misc
|
||||||
|
/// </summary>
|
||||||
|
public string Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Game ticks when this memory was created</summary>
|
||||||
|
public long CreatedTicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Game ticks when this memory was last updated</summary>
|
||||||
|
public long UpdatedTicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Number of times this memory has been accessed/retrieved</summary>
|
||||||
|
public int AccessCount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Hash of the fact for quick duplicate detection</summary>
|
||||||
|
public string Hash { get; set; }
|
||||||
|
|
||||||
|
public AIMemoryEntry()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString("N").Substring(0, 12);
|
||||||
|
CreatedTicks = 0;
|
||||||
|
UpdatedTicks = 0;
|
||||||
|
AccessCount = 0;
|
||||||
|
Category = "misc";
|
||||||
|
}
|
||||||
|
|
||||||
|
public AIMemoryEntry(string fact, string category = "misc") : this()
|
||||||
|
{
|
||||||
|
Fact = fact;
|
||||||
|
Category = category ?? "misc";
|
||||||
|
Hash = ComputeHash(fact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ComputeHash(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return "";
|
||||||
|
// Simple hash based on normalized text
|
||||||
|
string normalized = text.ToLowerInvariant().Trim();
|
||||||
|
return normalized.GetHashCode().ToString("X8");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void UpdateFact(string newFact)
|
||||||
|
{
|
||||||
|
Fact = newFact;
|
||||||
|
Hash = ComputeHash(newFact);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void MarkAccessed()
|
||||||
|
{
|
||||||
|
AccessCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
484
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryManager.cs
Normal file
484
Source/WulaFallenEmpire/EventSystem/AI/AIMemoryManager.cs
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld.Planet;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public class AIMemoryManager : WorldComponent
|
||||||
|
{
|
||||||
|
private const string MemoryFolderName = "WulaAIMemory";
|
||||||
|
private const string MemoryVersion = "1.0";
|
||||||
|
private const int RecencyTickWindow = 60000;
|
||||||
|
private string _saveId;
|
||||||
|
private List<AIMemoryEntry> _memories = new List<AIMemoryEntry>();
|
||||||
|
private bool _loaded;
|
||||||
|
|
||||||
|
public AIMemoryManager(World world) : base(world)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<AIMemoryEntry> GetAllMemories()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _memories.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AIMemoryEntry AddMemory(string fact, string category = "misc")
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (string.IsNullOrWhiteSpace(fact)) return null;
|
||||||
|
|
||||||
|
string normalizedCategory = NormalizeCategory(category);
|
||||||
|
string hash = AIMemoryEntry.ComputeHash(fact);
|
||||||
|
string normalizedFact = NormalizeFact(fact);
|
||||||
|
var existing = _memories.FirstOrDefault(m => m != null &&
|
||||||
|
(string.Equals(m.Hash, hash, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(NormalizeFact(m.Fact), normalizedFact, StringComparison.Ordinal)));
|
||||||
|
long now = GetCurrentTicks();
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.UpdateFact(fact);
|
||||||
|
existing.Category = normalizedCategory;
|
||||||
|
existing.UpdatedTicks = now;
|
||||||
|
SaveToFile();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = new AIMemoryEntry(fact, normalizedCategory)
|
||||||
|
{
|
||||||
|
CreatedTicks = now,
|
||||||
|
UpdatedTicks = now,
|
||||||
|
AccessCount = 0
|
||||||
|
};
|
||||||
|
_memories.Add(entry);
|
||||||
|
SaveToFile();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool UpdateMemory(string id, string newFact, string category = null)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return false;
|
||||||
|
|
||||||
|
var entry = _memories.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (entry == null) return false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(newFact))
|
||||||
|
{
|
||||||
|
entry.UpdateFact(newFact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
{
|
||||||
|
entry.Category = NormalizeCategory(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.UpdatedTicks = GetCurrentTicks();
|
||||||
|
SaveToFile();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteMemory(string id)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return false;
|
||||||
|
|
||||||
|
int removed = _memories.RemoveAll(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (removed > 0)
|
||||||
|
{
|
||||||
|
SaveToFile();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AIMemoryEntry> SearchMemories(string query, int limit = 5)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
if (string.IsNullOrWhiteSpace(query)) return new List<AIMemoryEntry>();
|
||||||
|
|
||||||
|
string normalizedQuery = query.Trim();
|
||||||
|
List<string> tokens = Tokenize(normalizedQuery);
|
||||||
|
|
||||||
|
long now = GetCurrentTicks();
|
||||||
|
var scored = new List<(AIMemoryEntry entry, float score)>();
|
||||||
|
|
||||||
|
foreach (var entry in _memories)
|
||||||
|
{
|
||||||
|
if (entry == null || string.IsNullOrWhiteSpace(entry.Fact)) continue;
|
||||||
|
float score = ComputeScore(entry, normalizedQuery, tokens, now);
|
||||||
|
if (score <= 0f) continue;
|
||||||
|
scored.Add((entry, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = scored
|
||||||
|
.OrderByDescending(s => s.score)
|
||||||
|
.ThenByDescending(s => s.entry.UpdatedTicks)
|
||||||
|
.Take(Math.Max(1, limit))
|
||||||
|
.Select(s => s.entry)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (results.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var entry in results)
|
||||||
|
{
|
||||||
|
entry.MarkAccessed();
|
||||||
|
entry.UpdatedTicks = now;
|
||||||
|
}
|
||||||
|
SaveToFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AIMemoryEntry> GetRecentMemories(int limit = 5)
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _memories
|
||||||
|
.Where(m => m != null && !string.IsNullOrWhiteSpace(m.Fact))
|
||||||
|
.OrderByDescending(m => m.UpdatedTicks)
|
||||||
|
.ThenByDescending(m => m.CreatedTicks)
|
||||||
|
.Take(Math.Max(1, limit))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
if (_loaded) return;
|
||||||
|
LoadFromFile();
|
||||||
|
_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetSaveDirectory()
|
||||||
|
{
|
||||||
|
string path = Path.Combine(GenFilePaths.SaveDataFolderPath, MemoryFolderName);
|
||||||
|
if (!Directory.Exists(path))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFilePath()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(_saveId))
|
||||||
|
{
|
||||||
|
_saveId = Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
return Path.Combine(GetSaveDirectory(), $"{_saveId}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadFromFile()
|
||||||
|
{
|
||||||
|
_memories = new List<AIMemoryEntry>();
|
||||||
|
|
||||||
|
string path = GetFilePath();
|
||||||
|
if (!File.Exists(path)) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string json = File.ReadAllText(path);
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return;
|
||||||
|
|
||||||
|
string array = ExtractJsonArray(json, "memories");
|
||||||
|
if (string.IsNullOrWhiteSpace(array)) return;
|
||||||
|
|
||||||
|
foreach (string obj in ExtractJsonObjects(array))
|
||||||
|
{
|
||||||
|
var dict = SimpleJsonParser.Parse(obj);
|
||||||
|
if (dict == null || dict.Count == 0) continue;
|
||||||
|
|
||||||
|
var entry = new AIMemoryEntry();
|
||||||
|
if (dict.TryGetValue("id", out string id) && !string.IsNullOrWhiteSpace(id)) entry.Id = id;
|
||||||
|
if (dict.TryGetValue("fact", out string fact)) entry.Fact = fact;
|
||||||
|
if (dict.TryGetValue("category", out string category)) entry.Category = NormalizeCategory(category);
|
||||||
|
if (dict.TryGetValue("createdTicks", out string created) && long.TryParse(created, NumberStyles.Integer, CultureInfo.InvariantCulture, out long createdTicks)) entry.CreatedTicks = createdTicks;
|
||||||
|
if (dict.TryGetValue("updatedTicks", out string updated) && long.TryParse(updated, NumberStyles.Integer, CultureInfo.InvariantCulture, out long updatedTicks)) entry.UpdatedTicks = updatedTicks;
|
||||||
|
if (dict.TryGetValue("accessCount", out string access) && int.TryParse(access, NumberStyles.Integer, CultureInfo.InvariantCulture, out int accessCount)) entry.AccessCount = accessCount;
|
||||||
|
if (dict.TryGetValue("hash", out string hash)) entry.Hash = hash;
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.Hash))
|
||||||
|
{
|
||||||
|
entry.Hash = AIMemoryEntry.ComputeHash(entry.Fact);
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(entry.Category)) entry.Category = "misc";
|
||||||
|
_memories.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[WulaAI] Failed to load memory file: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SaveToFile()
|
||||||
|
{
|
||||||
|
string path = GetFilePath();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.Append("{");
|
||||||
|
sb.Append("\"version\":\"").Append(MemoryVersion).Append("\",");
|
||||||
|
sb.Append("\"memories\":[");
|
||||||
|
bool first = true;
|
||||||
|
foreach (var memory in _memories)
|
||||||
|
{
|
||||||
|
if (memory == null) continue;
|
||||||
|
if (!first) sb.Append(",");
|
||||||
|
first = false;
|
||||||
|
sb.Append("{");
|
||||||
|
sb.Append("\"id\":\"").Append(EscapeJson(memory.Id)).Append("\",");
|
||||||
|
sb.Append("\"fact\":\"").Append(EscapeJson(memory.Fact)).Append("\",");
|
||||||
|
sb.Append("\"category\":\"").Append(EscapeJson(memory.Category)).Append("\",");
|
||||||
|
sb.Append("\"createdTicks\":").Append(memory.CreatedTicks.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||||
|
sb.Append("\"updatedTicks\":").Append(memory.UpdatedTicks.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||||
|
sb.Append("\"accessCount\":").Append(memory.AccessCount.ToString(CultureInfo.InvariantCulture)).Append(",");
|
||||||
|
sb.Append("\"hash\":\"").Append(EscapeJson(memory.Hash)).Append("\"");
|
||||||
|
sb.Append("}");
|
||||||
|
}
|
||||||
|
sb.Append("]}");
|
||||||
|
File.WriteAllText(path, sb.ToString());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[WulaAI] Failed to save memory file: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ExposeData()
|
||||||
|
{
|
||||||
|
base.ExposeData();
|
||||||
|
Scribe_Values.Look(ref _saveId, "WulaAIMemoryId");
|
||||||
|
|
||||||
|
if (Scribe.mode == LoadSaveMode.PostLoadInit && string.IsNullOrEmpty(_saveId))
|
||||||
|
{
|
||||||
|
_saveId = Guid.NewGuid().ToString("N");
|
||||||
|
_loaded = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long GetCurrentTicks()
|
||||||
|
{
|
||||||
|
return Find.TickManager?.TicksGame ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeCategory(string category)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(category)) return "misc";
|
||||||
|
string lower = category.Trim().ToLowerInvariant();
|
||||||
|
switch (lower)
|
||||||
|
{
|
||||||
|
case "preference":
|
||||||
|
case "personal":
|
||||||
|
case "plan":
|
||||||
|
case "colony":
|
||||||
|
case "misc":
|
||||||
|
return lower;
|
||||||
|
default:
|
||||||
|
return "misc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeFact(string fact)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(fact) ? "" : fact.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ComputeScore(AIMemoryEntry entry, string query, List<string> tokens, long now)
|
||||||
|
{
|
||||||
|
string fact = entry.Fact ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(fact)) return 0f;
|
||||||
|
|
||||||
|
string factLower = fact.ToLowerInvariant();
|
||||||
|
string queryLower = query.ToLowerInvariant();
|
||||||
|
|
||||||
|
float score = 0f;
|
||||||
|
if (string.Equals(factLower, queryLower, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
score = 1.2f;
|
||||||
|
}
|
||||||
|
else if (factLower.Contains(queryLower) || queryLower.Contains(factLower))
|
||||||
|
{
|
||||||
|
score = 0.9f;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens.Count > 0)
|
||||||
|
{
|
||||||
|
int matches = 0;
|
||||||
|
foreach (string token in tokens)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(token)) continue;
|
||||||
|
if (factLower.Contains(token)) matches++;
|
||||||
|
}
|
||||||
|
float coverage = matches / (float)Math.Max(1, tokens.Count);
|
||||||
|
score = Math.Max(score, 0.3f * coverage);
|
||||||
|
}
|
||||||
|
|
||||||
|
long updated = entry.UpdatedTicks > 0 ? entry.UpdatedTicks : entry.CreatedTicks;
|
||||||
|
long age = Math.Max(0, now - updated);
|
||||||
|
float recency = 1f / (1f + (age / (float)RecencyTickWindow));
|
||||||
|
float accessBoost = 1f + Math.Min(0.2f, entry.AccessCount * 0.02f);
|
||||||
|
return score * recency * accessBoost;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> Tokenize(string text)
|
||||||
|
{
|
||||||
|
var tokens = new List<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return tokens;
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
foreach (char c in text)
|
||||||
|
{
|
||||||
|
if (char.IsLetterOrDigit(c))
|
||||||
|
{
|
||||||
|
sb.Append(char.ToLowerInvariant(c));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (sb.Length > 0)
|
||||||
|
{
|
||||||
|
tokens.Add(sb.ToString());
|
||||||
|
sb.Length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sb.Length > 0) tokens.Add(sb.ToString());
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EscapeJson(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(value)) return "";
|
||||||
|
return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractJsonArray(string json, string key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(key)) return null;
|
||||||
|
|
||||||
|
string keyPattern = $"\"{key}\"";
|
||||||
|
int keyIndex = json.IndexOf(keyPattern, StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (keyIndex == -1) return null;
|
||||||
|
|
||||||
|
int arrayStart = json.IndexOf('[', keyIndex);
|
||||||
|
if (arrayStart == -1) return null;
|
||||||
|
|
||||||
|
int arrayEnd = FindMatchingBracket(json, arrayStart);
|
||||||
|
if (arrayEnd == -1) return null;
|
||||||
|
|
||||||
|
return json.Substring(arrayStart + 1, arrayEnd - arrayStart - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ExtractJsonObjects(string arrayContent)
|
||||||
|
{
|
||||||
|
var objects = new List<string>();
|
||||||
|
if (string.IsNullOrWhiteSpace(arrayContent)) return objects;
|
||||||
|
|
||||||
|
int depth = 0;
|
||||||
|
int start = -1;
|
||||||
|
bool inString = false;
|
||||||
|
bool escaped = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < arrayContent.Length; i++)
|
||||||
|
{
|
||||||
|
char c = arrayContent[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 == '{')
|
||||||
|
{
|
||||||
|
if (depth == 0) start = i;
|
||||||
|
depth++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c == '}')
|
||||||
|
{
|
||||||
|
depth--;
|
||||||
|
if (depth == 0 && start >= 0)
|
||||||
|
{
|
||||||
|
objects.Add(arrayContent.Substring(start, i - start + 1));
|
||||||
|
start = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int FindMatchingBracket(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using RimWorld;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Alerts
|
||||||
|
{
|
||||||
|
public class Alert_AIOverwatchActive : Alert_Critical
|
||||||
|
{
|
||||||
|
public Alert_AIOverwatchActive()
|
||||||
|
{
|
||||||
|
this.defaultLabel = "WULA_AIOverwatch_Label".Translate();
|
||||||
|
this.defaultExplanation = "WULA_AIOverwatch_Desc".Translate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override AlertReport GetReport()
|
||||||
|
{
|
||||||
|
var map = Find.CurrentMap;
|
||||||
|
if (map == null) return AlertReport.Inactive;
|
||||||
|
|
||||||
|
var comp = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (comp != null && comp.IsEnabled)
|
||||||
|
{
|
||||||
|
return AlertReport.Active;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AlertReport.Inactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string GetLabel()
|
||||||
|
{
|
||||||
|
var map = Find.CurrentMap;
|
||||||
|
if (map != null)
|
||||||
|
{
|
||||||
|
var comp = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (comp != null && comp.IsEnabled)
|
||||||
|
{
|
||||||
|
int secondsLeft = comp.DurationTicks / 60;
|
||||||
|
return "WULA_AIOverwatch_Label".Translate() + $"\n({secondsLeft}s)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "WULA_AIOverwatch_Label".Translate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override TaggedString GetExplanation()
|
||||||
|
{
|
||||||
|
var map = Find.CurrentMap;
|
||||||
|
if (map != null)
|
||||||
|
{
|
||||||
|
var comp = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (comp != null && comp.IsEnabled)
|
||||||
|
{
|
||||||
|
return "WULA_AIOverwatch_Desc".Translate(comp.DurationTicks / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.GetExplanation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire
|
||||||
|
{
|
||||||
|
public class CompProperties_AbilityEnableOverwatch : CompProperties_AbilityEffect
|
||||||
|
{
|
||||||
|
public int durationSeconds = 180; // Default 3 minutes
|
||||||
|
public bool useArtilleryVersion = false; // Both use normal mothership by default
|
||||||
|
|
||||||
|
public CompProperties_AbilityEnableOverwatch()
|
||||||
|
{
|
||||||
|
compClass = typeof(CompAbilityEffect_EnableOverwatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompAbilityEffect_EnableOverwatch : CompAbilityEffect
|
||||||
|
{
|
||||||
|
public new CompProperties_AbilityEnableOverwatch Props => (CompProperties_AbilityEnableOverwatch)props;
|
||||||
|
|
||||||
|
public override void Apply(LocalTargetInfo target, LocalTargetInfo dest)
|
||||||
|
{
|
||||||
|
base.Apply(target, dest);
|
||||||
|
|
||||||
|
Map map = parent.pawn?.Map ?? Find.CurrentMap;
|
||||||
|
if (map == null)
|
||||||
|
{
|
||||||
|
Messages.Message("Error: No active map.", MessageTypeDefOf.RejectInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overwatch = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (overwatch == null)
|
||||||
|
{
|
||||||
|
overwatch = new MapComponent_AIOverwatch(map);
|
||||||
|
map.components.Add(overwatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
overwatch.EnableOverwatch(Props.durationSeconds, Props.useArtilleryVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override bool CanApplyOn(LocalTargetInfo target, LocalTargetInfo dest)
|
||||||
|
{
|
||||||
|
if (!base.CanApplyOn(target, dest))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Map map = parent.pawn?.Map ?? Find.CurrentMap;
|
||||||
|
if (map == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var overwatch = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (overwatch != null && overwatch.IsEnabled)
|
||||||
|
{
|
||||||
|
// Already active, show remaining time
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override string ExtraLabelMouseAttachment(LocalTargetInfo target)
|
||||||
|
{
|
||||||
|
Map map = parent.pawn?.Map ?? Find.CurrentMap;
|
||||||
|
if (map != null)
|
||||||
|
{
|
||||||
|
var overwatch = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (overwatch != null && overwatch.IsEnabled)
|
||||||
|
{
|
||||||
|
return $"Already active ({overwatch.DurationTicks / 60}s remaining)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return base.ExtraLabelMouseAttachment(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Verse;
|
||||||
|
using RimWorld;
|
||||||
|
using LudeonTK;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI.UI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public static class DebugActions_WulaLink
|
||||||
|
{
|
||||||
|
[DebugAction("WulaLink", "Open WulaLink UI", actionType = DebugActionType.Action, allowedGameStates = AllowedGameStates.Playing)]
|
||||||
|
public static void OpenWulaLink()
|
||||||
|
{
|
||||||
|
// Find a suitable event def or create a generic one
|
||||||
|
EventDef def = DefDatabase<EventDef>.AllDefs.FirstOrDefault();
|
||||||
|
if (def == null)
|
||||||
|
{
|
||||||
|
Messages.Message("No EventDef found to initialize WulaLink.", MessageTypeDefOf.RejectInput, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Find.WindowStack.Add(new Overlay_WulaLink(def));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using HarmonyLib;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter),
|
||||||
|
new Type[] { typeof(Letter), typeof(string), typeof(int), typeof(bool) })]
|
||||||
|
public static class Patch_LetterStack_ReceiveLetter
|
||||||
|
{
|
||||||
|
public static void Postfix(Letter let, string debugInfo, int delayTicks, bool playSound)
|
||||||
|
{
|
||||||
|
// Only process if not delayed (delayTicks == 0 or already arrived)
|
||||||
|
if (delayTicks > 0) return;
|
||||||
|
|
||||||
|
var settings = WulaFallenEmpireMod.settings;
|
||||||
|
if (settings == null || !settings.enableAIAutoCommentary)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (let == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AIAutoCommentary.ProcessLetter(let);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using RimWorld;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI.Tools;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public class MapComponent_AIOverwatch : MapComponent
|
||||||
|
{
|
||||||
|
private bool enabled = false;
|
||||||
|
private int durationTicks = 0;
|
||||||
|
private int tickCounter = 0;
|
||||||
|
private int checkInterval = 180; // Check every 3 seconds (180 ticks)
|
||||||
|
|
||||||
|
// Configurable cooldown to prevent spamming too many simultaneous strikes
|
||||||
|
private int globalCooldownTicks = 0;
|
||||||
|
|
||||||
|
public bool IsEnabled => enabled;
|
||||||
|
public int DurationTicks => durationTicks;
|
||||||
|
|
||||||
|
public MapComponent_AIOverwatch(Map map) : base(map)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
// useArtilleryVersion: false = WULA_MotherShip (normal), true = WULA_MotherShip_Planet_Interdiction (artillery)
|
||||||
|
public void EnableOverwatch(int durationSeconds, bool useArtilleryVersion = false)
|
||||||
|
{
|
||||||
|
if (this.enabled)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_AlreadyActive".Translate(this.durationTicks / 60), MessageTypeDefOf.RejectInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard limit: 3 minutes (180 seconds)
|
||||||
|
int clampedDuration = Math.Min(durationSeconds, 180);
|
||||||
|
|
||||||
|
this.enabled = true;
|
||||||
|
this.durationTicks = clampedDuration * 60;
|
||||||
|
this.tickCounter = 0;
|
||||||
|
this.globalCooldownTicks = 0;
|
||||||
|
|
||||||
|
// Call fleet when overwatch starts
|
||||||
|
TryCallFleet(useArtilleryVersion);
|
||||||
|
|
||||||
|
Messages.Message("WULA_AIOverwatch_Engaged".Translate(clampedDuration), MessageTypeDefOf.PositiveEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DisableOverwatch()
|
||||||
|
{
|
||||||
|
this.enabled = false;
|
||||||
|
this.durationTicks = 0;
|
||||||
|
|
||||||
|
// Clear flight path when overwatch ends
|
||||||
|
TryClearFlightPath();
|
||||||
|
|
||||||
|
Messages.Message("WULA_AIOverwatch_Disengaged".Translate(), MessageTypeDefOf.NeutralEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryCallFleet(bool useArtilleryVersion)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Choose mothership version based on parameter
|
||||||
|
string defName = useArtilleryVersion ? "WULA_MotherShip_Planet_Interdiction" : "WULA_MotherShip";
|
||||||
|
var flyOverDef = DefDatabase<ThingDef>.GetNamedSilentFail(defName);
|
||||||
|
if (flyOverDef == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Could not find {defName} ThingDef.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate proper start and end positions (edge to opposite edge)
|
||||||
|
IntVec3 startPos = GetRandomMapEdgePosition(map);
|
||||||
|
IntVec3 endPos = GetOppositeMapEdgePosition(map, startPos);
|
||||||
|
|
||||||
|
// Use the proper FlyOver.MakeFlyOver static method
|
||||||
|
FlyOver flyOver = FlyOver.MakeFlyOver(
|
||||||
|
flyOverDef,
|
||||||
|
startPos,
|
||||||
|
endPos,
|
||||||
|
map,
|
||||||
|
speed: useArtilleryVersion ? 0.02f : 0.01f, // Artillery version slower
|
||||||
|
height: 20f
|
||||||
|
);
|
||||||
|
|
||||||
|
if (flyOver != null)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FleetCalled".Translate(), MessageTypeDefOf.PositiveEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Called fleet: {defName} spawned from {startPos} to {endPos}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Failed to call fleet: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntVec3 GetRandomMapEdgePosition(Map map)
|
||||||
|
{
|
||||||
|
int edge = Rand.Range(0, 4);
|
||||||
|
int x, z;
|
||||||
|
|
||||||
|
switch (edge)
|
||||||
|
{
|
||||||
|
case 0: // Bottom
|
||||||
|
x = Rand.Range(5, map.Size.x - 5);
|
||||||
|
z = 0;
|
||||||
|
break;
|
||||||
|
case 1: // Right
|
||||||
|
x = map.Size.x - 1;
|
||||||
|
z = Rand.Range(5, map.Size.z - 5);
|
||||||
|
break;
|
||||||
|
case 2: // Top
|
||||||
|
x = Rand.Range(5, map.Size.x - 5);
|
||||||
|
z = map.Size.z - 1;
|
||||||
|
break;
|
||||||
|
case 3: // Left
|
||||||
|
default:
|
||||||
|
x = 0;
|
||||||
|
z = Rand.Range(5, map.Size.z - 5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new IntVec3(x, 0, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntVec3 GetOppositeMapEdgePosition(Map map, IntVec3 startPos)
|
||||||
|
{
|
||||||
|
// Calculate direction from start to center, then extend to opposite edge
|
||||||
|
IntVec3 center = map.Center;
|
||||||
|
Vector3 toCenter = (center.ToVector3() - startPos.ToVector3()).normalized;
|
||||||
|
|
||||||
|
// Extend to the opposite edge
|
||||||
|
float maxDistance = Mathf.Max(map.Size.x, map.Size.z) * 1.5f;
|
||||||
|
Vector3 endVec = startPos.ToVector3() + toCenter * maxDistance;
|
||||||
|
|
||||||
|
// Clamp to map bounds
|
||||||
|
int endX = Mathf.Clamp((int)endVec.x, 0, map.Size.x - 1);
|
||||||
|
int endZ = Mathf.Clamp((int)endVec.z, 0, map.Size.z - 1);
|
||||||
|
|
||||||
|
return new IntVec3(endX, 0, endZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TryClearFlightPath()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Find all FlyOver entities on the map and use EmergencyDestroy for smooth exit
|
||||||
|
var flyOvers = map.listerThings.AllThings
|
||||||
|
.Where(t => t is FlyOver)
|
||||||
|
.Cast<FlyOver>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var flyOver in flyOvers)
|
||||||
|
{
|
||||||
|
if (!flyOver.Destroyed)
|
||||||
|
{
|
||||||
|
flyOver.EmergencyDestroy(); // Use smooth accelerated exit instead of instant destroy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flyOvers.Count > 0)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FleetCleared".Translate(), MessageTypeDefOf.NeutralEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Cleared flight path: {flyOvers.Count} entities set to emergency exit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Failed to clear flight path: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void MapComponentTick()
|
||||||
|
{
|
||||||
|
base.MapComponentTick();
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
durationTicks--;
|
||||||
|
if (durationTicks <= 0)
|
||||||
|
{
|
||||||
|
DisableOverwatch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalCooldownTicks > 0)
|
||||||
|
{
|
||||||
|
globalCooldownTicks--;
|
||||||
|
}
|
||||||
|
|
||||||
|
tickCounter++;
|
||||||
|
if (tickCounter >= checkInterval)
|
||||||
|
{
|
||||||
|
tickCounter = 0;
|
||||||
|
|
||||||
|
// Optional: Notify user every 30 seconds (1800 ticks) that system is still active
|
||||||
|
if ((durationTicks % 1800) < checkInterval)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_SystemActive".Translate(durationTicks / 60), MessageTypeDefOf.NeutralEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
PerformScanAndStrike();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerformScanAndStrike()
|
||||||
|
{
|
||||||
|
// Gather all valid hostile pawn targets
|
||||||
|
List<Pawn> hostilePawns = map.mapPawns.AllPawnsSpawned
|
||||||
|
.Where(p => !p.Dead && !p.Downed && p.HostileTo(Faction.OfPlayer) && !p.IsPrisoner)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Gather all hostile buildings (turrets, etc.)
|
||||||
|
List<Building> hostileBuildings = map.listerBuildings.allBuildingsColonist
|
||||||
|
.Concat(map.listerThings.ThingsInGroup(ThingRequestGroup.BuildingArtificial).OfType<Building>())
|
||||||
|
.Where(b => b != null && !b.Destroyed && b.Faction != null && b.Faction.HostileTo(Faction.OfPlayer))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Convert building positions to "virtual targets" for processing
|
||||||
|
List<IntVec3> buildingTargets = hostileBuildings.Select(b => b.Position).ToList();
|
||||||
|
|
||||||
|
_strikesThisScan = 0;
|
||||||
|
|
||||||
|
// Process hostile pawns first (clustered)
|
||||||
|
if (hostilePawns.Count > 0)
|
||||||
|
{
|
||||||
|
var clusters = ClusterPawns(hostilePawns, 12f);
|
||||||
|
clusters.Sort((a, b) => b.Count.CompareTo(a.Count));
|
||||||
|
|
||||||
|
foreach (var cluster in clusters)
|
||||||
|
{
|
||||||
|
if (globalCooldownTicks > 0) break;
|
||||||
|
if (_strikesThisScan >= 3) break;
|
||||||
|
ProcessCluster(cluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process hostile buildings (each as individual target)
|
||||||
|
foreach (var buildingPos in buildingTargets)
|
||||||
|
{
|
||||||
|
if (globalCooldownTicks > 0) break;
|
||||||
|
if (_strikesThisScan >= 3) break;
|
||||||
|
ProcessBuildingTarget(buildingPos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ProcessBuildingTarget(IntVec3 target)
|
||||||
|
{
|
||||||
|
if (!target.InBounds(map)) return;
|
||||||
|
|
||||||
|
float safetyRadius = 9.9f; // Medium safety for building strikes
|
||||||
|
if (IsFriendlyFireRisk(target, safetyRadius))
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(target.ToString()), new TargetInfo(target, map), MessageTypeDefOf.CautionInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cannon salvo for buildings (good balance of damage and precision)
|
||||||
|
var cannonDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_Cannon_Salvo");
|
||||||
|
if (cannonDef != null)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_EngagingBuilding".Translate(), new TargetInfo(target, map), MessageTypeDefOf.PositiveEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Engaging hostile building at {target} with Cannon Salvo.");
|
||||||
|
FireAbility(cannonDef, target, Rand.Range(0, 360));
|
||||||
|
_strikesThisScan++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int _strikesThisScan = 0;
|
||||||
|
|
||||||
|
private void ProcessCluster(List<Pawn> cluster)
|
||||||
|
{
|
||||||
|
if (cluster.Count == 0) return;
|
||||||
|
if (_strikesThisScan >= 3) return; // Self-limit
|
||||||
|
|
||||||
|
// Calculate center of mass
|
||||||
|
float x = 0, z = 0;
|
||||||
|
foreach (var p in cluster)
|
||||||
|
{
|
||||||
|
x += p.Position.x;
|
||||||
|
z += p.Position.z;
|
||||||
|
}
|
||||||
|
IntVec3 center = new IntVec3((int)(x / cluster.Count), 0, (int)(z / cluster.Count));
|
||||||
|
|
||||||
|
if (!center.InBounds(map)) return;
|
||||||
|
|
||||||
|
float angle = Rand.Range(0, 360);
|
||||||
|
|
||||||
|
// NEW Decision Logic:
|
||||||
|
// >= 20: Heavy (Energy Lance + Primary Cannon together)
|
||||||
|
// >= 10: Energy Lance only
|
||||||
|
// >= 3: Cannon Salvo (Medium)
|
||||||
|
// < 3: Minigun (Light)
|
||||||
|
|
||||||
|
if (cluster.Count >= 20)
|
||||||
|
{
|
||||||
|
// Ultra Heavy: Fire BOTH Energy Lance AND Primary Cannon
|
||||||
|
float safetyRadius = 18.9f;
|
||||||
|
if (IsFriendlyFireRisk(center, safetyRadius))
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lanceDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe");
|
||||||
|
var cannonDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_Primary_Cannon_Strafe");
|
||||||
|
|
||||||
|
Messages.Message("WULA_AIOverwatch_MassiveCluster".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.ThreatBig);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] MASSIVE cluster ({cluster.Count}), executing combined strike at {center}.");
|
||||||
|
|
||||||
|
if (lanceDef != null) FireAbility(lanceDef, center, angle);
|
||||||
|
if (cannonDef != null) FireAbility(cannonDef, center, angle + 45f); // Offset angle for variety
|
||||||
|
|
||||||
|
_strikesThisScan++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.Count >= 10)
|
||||||
|
{
|
||||||
|
// Heavy: Energy Lance only
|
||||||
|
float safetyRadius = 16.9f;
|
||||||
|
if (IsFriendlyFireRisk(center, safetyRadius))
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lanceDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_EnergyLance_Strafe");
|
||||||
|
if (lanceDef != null)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_EngagingLance".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Energy Lance at {center}.");
|
||||||
|
FireAbility(lanceDef, center, angle);
|
||||||
|
_strikesThisScan++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cluster.Count >= 3)
|
||||||
|
{
|
||||||
|
// Medium: Cannon Salvo
|
||||||
|
float safetyRadius = 9.9f;
|
||||||
|
if (IsFriendlyFireRisk(center, safetyRadius))
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cannonDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_Cannon_Salvo");
|
||||||
|
if (cannonDef != null)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_EngagingCannon".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Cannon Salvo at {center}.");
|
||||||
|
FireAbility(cannonDef, center, angle);
|
||||||
|
_strikesThisScan++;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light: Minigun Strafe
|
||||||
|
{
|
||||||
|
float safetyRadius = 5.9f;
|
||||||
|
if (IsFriendlyFireRisk(center, safetyRadius))
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_FriendlyFireAbort".Translate(center.ToString()), new TargetInfo(center, map), MessageTypeDefOf.CautionInput);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var minigunDef = DefDatabase<AbilityDef>.GetNamedSilentFail("WULA_Firepower_Minigun_Strafe");
|
||||||
|
if (minigunDef != null)
|
||||||
|
{
|
||||||
|
Messages.Message("WULA_AIOverwatch_EngagingMinigun".Translate(cluster.Count), new TargetInfo(center, map), MessageTypeDefOf.PositiveEvent);
|
||||||
|
WulaLog.Debug($"[AI Overwatch] Engaging {cluster.Count} hostiles with Minigun Strafe at {center}.");
|
||||||
|
FireAbility(minigunDef, center, angle);
|
||||||
|
_strikesThisScan++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireAbility(AbilityDef ability, IntVec3 target, float angle)
|
||||||
|
{
|
||||||
|
// Route via BombardmentUtility
|
||||||
|
// We need to check components again to know which method to call
|
||||||
|
|
||||||
|
var circular = ability.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||||
|
if (circular != null)
|
||||||
|
{
|
||||||
|
BombardmentUtility.ExecuteCircularBombardment(map, target, ability, circular);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bombard = ability.comps?.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||||
|
if (bombard != null)
|
||||||
|
{
|
||||||
|
BombardmentUtility.ExecuteStrafeBombardmentDirect(map, target, ability, bombard, angle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lance = ability.comps?.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||||
|
if (lance != null)
|
||||||
|
{
|
||||||
|
BombardmentUtility.ExecuteEnergyLanceDirect(map, target, ability, lance, angle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsFriendlyFireRisk(IntVec3 center, float radius)
|
||||||
|
{
|
||||||
|
var pawns = map.mapPawns.AllPawnsSpawned;
|
||||||
|
foreach (var p in pawns)
|
||||||
|
{
|
||||||
|
if (p.Faction == Faction.OfPlayer || p.IsPrisonerOfColony)
|
||||||
|
{
|
||||||
|
if (p.Position.InHorDistOf(center, radius)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<Pawn>> ClusterPawns(List<Pawn> pawns, float radius)
|
||||||
|
{
|
||||||
|
var clusters = new List<List<Pawn>>();
|
||||||
|
var assigned = new HashSet<Pawn>();
|
||||||
|
|
||||||
|
foreach (var p in pawns)
|
||||||
|
{
|
||||||
|
if (assigned.Contains(p)) continue;
|
||||||
|
|
||||||
|
var newCluster = new List<Pawn> { p };
|
||||||
|
assigned.Add(p);
|
||||||
|
|
||||||
|
// Find neighbors
|
||||||
|
foreach (var neighbor in pawns)
|
||||||
|
{
|
||||||
|
if (assigned.Contains(neighbor)) continue;
|
||||||
|
if (p.Position.InHorDistOf(neighbor.Position, radius))
|
||||||
|
{
|
||||||
|
newCluster.Add(neighbor);
|
||||||
|
assigned.Add(neighbor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clusters.Add(newCluster);
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void ExposeData()
|
||||||
|
{
|
||||||
|
base.ExposeData();
|
||||||
|
Scribe_Values.Look(ref enabled, "enabled", false);
|
||||||
|
Scribe_Values.Look(ref durationTicks, "durationTicks", 0);
|
||||||
|
Scribe_Values.Look(ref tickCounter, "tickCounter", 0);
|
||||||
|
Scribe_Values.Look(ref globalCooldownTicks, "globalCooldownTicks", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Source/WulaFallenEmpire/EventSystem/AI/MemoryPrompts.cs
Normal file
45
Source/WulaFallenEmpire/EventSystem/AI/MemoryPrompts.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public static class MemoryPrompts
|
||||||
|
{
|
||||||
|
public const string FactExtractionPrompt =
|
||||||
|
@"You are extracting long-term memory about the player from the conversation below.
|
||||||
|
Return JSON only, no extra text.
|
||||||
|
Schema:
|
||||||
|
{{""facts"":[{{""text"":""..."",""category"":""preference|personal|plan|colony|misc""}}]}}
|
||||||
|
Rules:
|
||||||
|
- Keep only stable, reusable facts about the player or colony.
|
||||||
|
- Ignore transient tool results, numbers, or one-off actions.
|
||||||
|
- Do not invent facts.
|
||||||
|
Conversation:
|
||||||
|
{0}";
|
||||||
|
|
||||||
|
public const string MemoryUpdatePrompt =
|
||||||
|
@"You are updating a memory store.
|
||||||
|
Given existing memories and new facts, decide ADD, UPDATE, DELETE, or NONE.
|
||||||
|
Return JSON only, no extra text.
|
||||||
|
Schema:
|
||||||
|
{{""memory"":[{{""id"":""..."",""text"":""..."",""category"":""preference|personal|plan|colony|misc"",""event"":""ADD|UPDATE|DELETE|NONE""}}]}}
|
||||||
|
Rules:
|
||||||
|
- UPDATE if a new fact refines or corrects an existing memory.
|
||||||
|
- DELETE if a memory is contradicted by new facts.
|
||||||
|
- ADD for genuinely new information.
|
||||||
|
- NONE if no change is needed.
|
||||||
|
Existing memories (JSON):
|
||||||
|
{0}
|
||||||
|
New facts (JSON):
|
||||||
|
{1}";
|
||||||
|
|
||||||
|
public static string BuildFactExtractionPrompt(string conversation)
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.InvariantCulture, FactExtractionPrompt, conversation ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BuildMemoryUpdatePrompt(string existingMemoriesJson, string newFactsJson)
|
||||||
|
{
|
||||||
|
return string.Format(CultureInfo.InvariantCulture, MemoryUpdatePrompt, existingMemoriesJson ?? "[]", newFactsJson ?? "[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unity 屏幕截取工具类,用于 VLM 视觉分析
|
||||||
|
/// </summary>
|
||||||
|
public static class ScreenCaptureUtility
|
||||||
|
{
|
||||||
|
private const int MaxImageSize = 1024; // 限制图片大小以节省 API 费用
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 截取当前屏幕并返回 Base64 编码的 PNG
|
||||||
|
/// </summary>
|
||||||
|
public static string CaptureScreenAsBase64()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 使用 Unity 截屏
|
||||||
|
Texture2D screenshot = ScreenCapture.CaptureScreenshotAsTexture();
|
||||||
|
if (screenshot == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[ScreenCapture] CaptureScreenshotAsTexture returned null");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缩放以适配 API 限制
|
||||||
|
Texture2D resized = ResizeTexture(screenshot, MaxImageSize);
|
||||||
|
|
||||||
|
// 编码为 PNG
|
||||||
|
byte[] pngBytes = resized.EncodeToPNG();
|
||||||
|
|
||||||
|
// 清理资源
|
||||||
|
UnityEngine.Object.Destroy(screenshot);
|
||||||
|
if (resized != screenshot)
|
||||||
|
{
|
||||||
|
UnityEngine.Object.Destroy(resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
WulaLog.Debug($"[ScreenCapture] Captured {pngBytes.Length} bytes");
|
||||||
|
return Convert.ToBase64String(pngBytes);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[ScreenCapture] Failed: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 缩放纹理到指定最大尺寸
|
||||||
|
/// </summary>
|
||||||
|
private static Texture2D ResizeTexture(Texture2D source, int maxSize)
|
||||||
|
{
|
||||||
|
int width = source.width;
|
||||||
|
int height = source.height;
|
||||||
|
|
||||||
|
// 计算缩放比例
|
||||||
|
if (width <= maxSize && height <= maxSize)
|
||||||
|
{
|
||||||
|
return source; // 无需缩放
|
||||||
|
}
|
||||||
|
|
||||||
|
float ratio = (float)maxSize / Mathf.Max(width, height);
|
||||||
|
int newWidth = Mathf.RoundToInt(width * ratio);
|
||||||
|
int newHeight = Mathf.RoundToInt(height * ratio);
|
||||||
|
|
||||||
|
// 创建缩放后的纹理
|
||||||
|
RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight);
|
||||||
|
Graphics.Blit(source, rt);
|
||||||
|
|
||||||
|
RenderTexture previous = RenderTexture.active;
|
||||||
|
RenderTexture.active = rt;
|
||||||
|
|
||||||
|
Texture2D resized = new Texture2D(newWidth, newHeight, TextureFormat.RGB24, false);
|
||||||
|
resized.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0);
|
||||||
|
resized.Apply();
|
||||||
|
|
||||||
|
RenderTexture.active = previous;
|
||||||
|
RenderTexture.ReleaseTemporary(rt);
|
||||||
|
|
||||||
|
WulaLog.Debug($"[ScreenCapture] Resized from {width}x{height} to {newWidth}x{newHeight}");
|
||||||
|
return resized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ using System.Threading.Tasks;
|
|||||||
using UnityEngine.Networking;
|
using UnityEngine.Networking;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Verse;
|
using Verse;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace WulaFallenEmpire.EventSystem.AI
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
{
|
{
|
||||||
@@ -13,17 +15,34 @@ namespace WulaFallenEmpire.EventSystem.AI
|
|||||||
private readonly string _apiKey;
|
private readonly string _apiKey;
|
||||||
private readonly string _baseUrl;
|
private readonly string _baseUrl;
|
||||||
private readonly string _model;
|
private readonly string _model;
|
||||||
|
private readonly bool _useGemini;
|
||||||
private const int MaxLogChars = 2000;
|
private const int MaxLogChars = 2000;
|
||||||
|
|
||||||
public SimpleAIClient(string apiKey, string baseUrl, string model)
|
public SimpleAIClient(string apiKey, string baseUrl, string model, bool useGemini = false)
|
||||||
{
|
{
|
||||||
_apiKey = apiKey;
|
_apiKey = apiKey;
|
||||||
_baseUrl = baseUrl?.TrimEnd('/');
|
_baseUrl = baseUrl?.TrimEnd('/');
|
||||||
_model = model;
|
_model = model;
|
||||||
|
_useGemini = useGemini;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetChatCompletionAsync(string instruction, List<(string role, string message)> messages, int? maxTokens = null, float? temperature = null)
|
public async Task<string> GetChatCompletionAsync(string instruction, List<(string role, string message)> messages, int? maxTokens = null, float? temperature = null, string base64Image = null)
|
||||||
{
|
{
|
||||||
|
// 1. Gemini Mode
|
||||||
|
if (_useGemini)
|
||||||
|
{
|
||||||
|
string geminiResponse = await GetGeminiCompletionAsync(instruction, messages, maxTokens, temperature, base64Image);
|
||||||
|
|
||||||
|
// Fallback: If failed and had image, retry without image
|
||||||
|
if (geminiResponse == null && !string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[WulaAI] [WARNING] Visual request failed (likely model incompatible). Retrying text-only...");
|
||||||
|
return await GetGeminiCompletionAsync(instruction, messages, maxTokens, temperature, null);
|
||||||
|
}
|
||||||
|
return geminiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. OpenAI / Compatible Mode
|
||||||
if (string.IsNullOrEmpty(_baseUrl))
|
if (string.IsNullOrEmpty(_baseUrl))
|
||||||
{
|
{
|
||||||
WulaLog.Debug("[WulaAI] Base URL is missing.");
|
WulaLog.Debug("[WulaAI] Base URL is missing.");
|
||||||
@@ -31,57 +50,155 @@ namespace WulaFallenEmpire.EventSystem.AI
|
|||||||
}
|
}
|
||||||
|
|
||||||
string endpoint = $"{_baseUrl}/chat/completions";
|
string endpoint = $"{_baseUrl}/chat/completions";
|
||||||
// Handle cases where baseUrl already includes /v1 or full path
|
|
||||||
if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl;
|
if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl;
|
||||||
else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions";
|
else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions";
|
||||||
|
|
||||||
// Build JSON manually to avoid dependencies
|
|
||||||
StringBuilder jsonBuilder = new StringBuilder();
|
StringBuilder jsonBuilder = new StringBuilder();
|
||||||
jsonBuilder.Append("{");
|
jsonBuilder.Append("{");
|
||||||
jsonBuilder.Append($"\"model\": \"{_model}\",");
|
jsonBuilder.Append($"\"model\": \"{_model}\",");
|
||||||
jsonBuilder.Append("\"stream\": false,"); // We request non-stream, but handle stream if returned
|
jsonBuilder.Append("\"stream\": false,");
|
||||||
if (maxTokens.HasValue)
|
if (maxTokens.HasValue) jsonBuilder.Append($"\"max_tokens\": {Math.Max(1, maxTokens.Value)},");
|
||||||
{
|
if (temperature.HasValue) jsonBuilder.Append($"\"temperature\": {temperature.Value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)},");
|
||||||
jsonBuilder.Append($"\"max_tokens\": {Math.Max(1, maxTokens.Value)},");
|
|
||||||
}
|
|
||||||
if (temperature.HasValue)
|
|
||||||
{
|
|
||||||
float clamped = Mathf.Clamp(temperature.Value, 0f, 2f);
|
|
||||||
jsonBuilder.Append($"\"temperature\": {clamped.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)},");
|
|
||||||
}
|
|
||||||
jsonBuilder.Append("\"messages\": [");
|
|
||||||
|
|
||||||
// System instruction
|
var validMessages = messages.Where(m =>
|
||||||
bool firstMessage = true;
|
{
|
||||||
|
string r = (m.role ?? "user").ToLowerInvariant();
|
||||||
|
return r != "toolcall";
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
jsonBuilder.Append("\"messages\": [");
|
||||||
if (!string.IsNullOrEmpty(instruction))
|
if (!string.IsNullOrEmpty(instruction))
|
||||||
{
|
{
|
||||||
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}}");
|
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}}");
|
||||||
firstMessage = false;
|
if (validMessages.Count > 0) jsonBuilder.Append(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages
|
// Find the index of the last user message to attach the image to
|
||||||
|
int lastUserIndex = -1;
|
||||||
|
if (!string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
for (int i = validMessages.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
string r = (validMessages[i].role ?? "user").ToLowerInvariant();
|
||||||
|
if (r != "ai" && r != "assistant" && r != "tool" && r != "system")
|
||||||
|
{
|
||||||
|
lastUserIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < validMessages.Count; i++)
|
||||||
|
{
|
||||||
|
var msg = validMessages[i];
|
||||||
|
string role = (msg.role ?? "user").ToLowerInvariant();
|
||||||
|
if (role == "ai" || role == "assistant") role = "assistant";
|
||||||
|
else if (role == "tool") role = "system";
|
||||||
|
|
||||||
|
jsonBuilder.Append($"{{\"role\": \"{role}\", ");
|
||||||
|
|
||||||
|
if (i == lastUserIndex && !string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
jsonBuilder.Append("\"content\": [");
|
||||||
|
jsonBuilder.Append($"{{\"type\": \"text\", \"text\": \"{EscapeJson(msg.message)}\"}},");
|
||||||
|
jsonBuilder.Append($"{{\"type\": \"image_url\", \"image_url\": {{\"url\": \"data:image/png;base64,{base64Image}\"}}}}");
|
||||||
|
jsonBuilder.Append("]");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
jsonBuilder.Append($"\"content\": \"{EscapeJson(msg.message)}\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBuilder.Append("}");
|
||||||
|
if (i < validMessages.Count - 1) jsonBuilder.Append(",");
|
||||||
|
}
|
||||||
|
jsonBuilder.Append("]}");
|
||||||
|
|
||||||
|
string response = await SendRequestAsync(endpoint, jsonBuilder.ToString(), _apiKey);
|
||||||
|
|
||||||
|
// Fallback: If failed and had image, retry without image
|
||||||
|
if (response == null && !string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[WulaAI] [WARNING] Visual request failed (likely model incompatible). Retrying text-only...");
|
||||||
|
return await GetChatCompletionAsync(instruction, messages, maxTokens, temperature, null);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> GetGeminiCompletionAsync(string instruction, List<(string role, string message)> messages, int? maxTokens = null, float? temperature = null, string base64Image = null)
|
||||||
|
{
|
||||||
|
// Ensure messages is not empty to avoid Gemini 400 Error (Invalid Argument)
|
||||||
|
if (messages == null) messages = new List<(string role, string message)>();
|
||||||
|
if (messages.Count == 0)
|
||||||
|
{
|
||||||
|
// Gemini API 'contents' cannot be empty. We add a dummy prompt to trigger the model.
|
||||||
|
messages.Add(("user", "Start."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini API URL
|
||||||
|
string baseUrl = _baseUrl;
|
||||||
|
if (string.IsNullOrEmpty(baseUrl) || !baseUrl.Contains("googleapis.com"))
|
||||||
|
{
|
||||||
|
baseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
}
|
||||||
|
|
||||||
|
string endpoint = $"{baseUrl}/models/{_model}:generateContent?key={_apiKey}";
|
||||||
|
|
||||||
|
StringBuilder jsonBuilder = new StringBuilder();
|
||||||
|
jsonBuilder.Append("{");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(instruction))
|
||||||
|
{
|
||||||
|
jsonBuilder.Append("\"system_instruction\": {\"parts\": [{\"text\": \"" + EscapeJson(instruction) + "\"}]},");
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBuilder.Append("\"contents\": [");
|
||||||
for (int i = 0; i < messages.Count; i++)
|
for (int i = 0; i < messages.Count; i++)
|
||||||
{
|
{
|
||||||
var msg = messages[i];
|
var msg = messages[i];
|
||||||
string role = (msg.role ?? "user").ToLowerInvariant();
|
string role = (msg.role ?? "user").ToLowerInvariant();
|
||||||
if (role == "ai") role = "assistant";
|
if (role == "assistant" || role == "ai") role = "model";
|
||||||
else if (role == "tool") role = "system"; // Internal-only role; map to supported role for Chat Completions APIs.
|
else role = "user";
|
||||||
else if (role == "toolcall") continue;
|
|
||||||
else if (role != "system" && role != "user" && role != "assistant") role = "user";
|
jsonBuilder.Append($"{{\"role\": \"{role}\", \"parts\": [");
|
||||||
|
jsonBuilder.Append($"{{\"text\": \"{EscapeJson(msg.message)}\"}}");
|
||||||
|
|
||||||
if (!firstMessage) jsonBuilder.Append(",");
|
if (i == messages.Count - 1 && role == "user" && !string.IsNullOrEmpty(base64Image))
|
||||||
jsonBuilder.Append($"{{\"role\": \"{role}\", \"content\": \"{EscapeJson(msg.message)}\"}}");
|
{
|
||||||
firstMessage = false;
|
jsonBuilder.Append($", {{\"inline_data\": {{\"mime_type\": \"image/png\", \"data\": \"{base64Image}\"}}}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBuilder.Append("]}");
|
||||||
|
if (i < messages.Count - 1) jsonBuilder.Append(",");
|
||||||
}
|
}
|
||||||
|
jsonBuilder.Append("],");
|
||||||
jsonBuilder.Append("]");
|
|
||||||
|
jsonBuilder.Append("\"generationConfig\": {");
|
||||||
|
if (temperature.HasValue) jsonBuilder.Append($"\"temperature\": {temperature.Value.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)},");
|
||||||
|
if (maxTokens.HasValue) jsonBuilder.Append($"\"maxOutputTokens\": {maxTokens.Value}");
|
||||||
|
else jsonBuilder.Append("\"maxOutputTokens\": 2048");
|
||||||
jsonBuilder.Append("}");
|
jsonBuilder.Append("}");
|
||||||
|
|
||||||
string jsonBody = jsonBuilder.ToString();
|
jsonBuilder.Append("}");
|
||||||
|
|
||||||
|
return await SendRequestAsync(endpoint, jsonBuilder.ToString(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> SendRequestAsync(string endpoint, string jsonBody, string apiKey)
|
||||||
|
{
|
||||||
if (Prefs.DevMode)
|
if (Prefs.DevMode)
|
||||||
{
|
{
|
||||||
WulaLog.Debug($"[WulaAI] Sending request to {endpoint} (model={_model}, messages={messages?.Count ?? 0})");
|
string logUrl = endpoint;
|
||||||
WulaLog.Debug($"[WulaAI] Request body (truncated):\n{TruncateForLog(jsonBody)}");
|
if (logUrl.Contains("key="))
|
||||||
|
{
|
||||||
|
logUrl = Regex.Replace(logUrl, @"key=[^&]*", "key=[REDACTED]");
|
||||||
|
}
|
||||||
|
WulaLog.Debug($"[WulaAI] Sending request to {logUrl}");
|
||||||
|
|
||||||
|
// Log request body (truncated to avoid spamming base64)
|
||||||
|
string logBody = jsonBody;
|
||||||
|
if (logBody.Length > 3000) logBody = logBody.Substring(0, 3000) + "... [Truncated]";
|
||||||
|
WulaLog.Debug($"[WulaAI] Request Payload:\n{logBody}");
|
||||||
}
|
}
|
||||||
|
|
||||||
using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST"))
|
using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST"))
|
||||||
@@ -90,149 +207,108 @@ namespace WulaFallenEmpire.EventSystem.AI
|
|||||||
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
request.uploadHandler = new UploadHandlerRaw(bodyRaw);
|
||||||
request.downloadHandler = new DownloadHandlerBuffer();
|
request.downloadHandler = new DownloadHandlerBuffer();
|
||||||
request.SetRequestHeader("Content-Type", "application/json");
|
request.SetRequestHeader("Content-Type", "application/json");
|
||||||
if (!string.IsNullOrEmpty(_apiKey))
|
if (!string.IsNullOrEmpty(apiKey))
|
||||||
{
|
{
|
||||||
request.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
|
request.SetRequestHeader("Authorization", $"Bearer {apiKey}");
|
||||||
}
|
}
|
||||||
|
request.timeout = 60;
|
||||||
|
|
||||||
var operation = request.SendWebRequest();
|
var operation = request.SendWebRequest();
|
||||||
|
while (!operation.isDone) await Task.Delay(50);
|
||||||
|
|
||||||
while (!operation.isDone)
|
if (request.result != UnityWebRequest.Result.Success)
|
||||||
{
|
{
|
||||||
await Task.Delay(50);
|
string errText = request.downloadHandler.text;
|
||||||
}
|
WulaLog.Debug($"[WulaAI] API Error ({request.responseCode}): {request.error}\nResponse: {TruncateForLog(errText)}");
|
||||||
|
|
||||||
if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
|
|
||||||
{
|
|
||||||
WulaLog.Debug($"[WulaAI] API Error: {request.error}\nResponse (truncated): {TruncateForLog(request.downloadHandler.text)}");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string responseText = request.downloadHandler.text;
|
string response = request.downloadHandler.text;
|
||||||
if (Prefs.DevMode)
|
if (Prefs.DevMode)
|
||||||
{
|
{
|
||||||
WulaLog.Debug($"[WulaAI] Raw Response (truncated): {TruncateForLog(responseText)}");
|
WulaLog.Debug($"[WulaAI] Response Body:\n{TruncateForLog(response)}");
|
||||||
}
|
}
|
||||||
return ExtractContent(responseText);
|
return ExtractContent(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
private string ExtractContent(string json)
|
||||||
{
|
{
|
||||||
try
|
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||||
|
|
||||||
|
// Handle SSE Stream (data: ...)
|
||||||
|
// Some endpoints return SSE streams even if stream=false is requested.
|
||||||
|
// We strip 'data:' prefix and aggregate the content deltas.
|
||||||
|
if (json.TrimStart().StartsWith("data:"))
|
||||||
{
|
{
|
||||||
// Check for stream format (SSE)
|
StringBuilder sb = new StringBuilder();
|
||||||
// SSE lines start with "data: "
|
string[] lines = json.Split(new char[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
if (json.TrimStart().StartsWith("data:"))
|
foreach (string line in lines)
|
||||||
{
|
{
|
||||||
StringBuilder fullContent = new StringBuilder();
|
string trimmed = line.Trim();
|
||||||
string[] lines = json.Split(new[] { "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries);
|
if (trimmed.StartsWith("data:") && !trimmed.Contains("[DONE]"))
|
||||||
foreach (string line in lines)
|
|
||||||
{
|
{
|
||||||
string trimmedLine = line.Trim();
|
string chunkJson = trimmed.Substring(5).Trim();
|
||||||
if (trimmedLine == "data: [DONE]") continue;
|
// Extract content from this chunk
|
||||||
if (trimmedLine.StartsWith("data: "))
|
string chunkContent = ExtractContentFromSingleJson(chunkJson);
|
||||||
{
|
if (!string.IsNullOrEmpty(chunkContent))
|
||||||
string dataJson = trimmedLine.Substring(6);
|
{
|
||||||
// Extract content from this chunk
|
sb.Append(chunkContent);
|
||||||
string chunkContent = TryExtractAssistantContent(dataJson) ?? ExtractJsonValue(dataJson, "content");
|
|
||||||
if (!string.IsNullOrEmpty(chunkContent))
|
|
||||||
{
|
|
||||||
fullContent.Append(chunkContent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fullContent.ToString();
|
|
||||||
}
|
}
|
||||||
else
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExtractContentFromSingleJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ExtractContentFromSingleJson(string json)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 1. Gemini format
|
||||||
|
if (json.Contains("\"candidates\""))
|
||||||
{
|
{
|
||||||
// Standard non-stream format
|
int partsIndex = json.IndexOf("\"parts\"", StringComparison.Ordinal);
|
||||||
return TryExtractAssistantContent(json) ?? ExtractJsonValue(json, "content");
|
if (partsIndex != -1) return ExtractJsonValue(json, "text", partsIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. OpenAI format
|
||||||
|
if (json.Contains("\"choices\""))
|
||||||
|
{
|
||||||
|
int choicesIndex = json.IndexOf("\"choices\"", StringComparison.Ordinal);
|
||||||
|
string firstChoice = TryExtractFirstChoiceObject(json, choicesIndex);
|
||||||
|
if (!string.IsNullOrEmpty(firstChoice))
|
||||||
|
{
|
||||||
|
int messageIndex = firstChoice.IndexOf("\"message\"", StringComparison.Ordinal);
|
||||||
|
if (messageIndex != -1) return ExtractJsonValue(firstChoice, "content", messageIndex);
|
||||||
|
|
||||||
|
int deltaIndex = firstChoice.IndexOf("\"delta\"", StringComparison.Ordinal);
|
||||||
|
if (deltaIndex != -1) return ExtractJsonValue(firstChoice, "content", deltaIndex);
|
||||||
|
|
||||||
|
return ExtractJsonValue(firstChoice, "text", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Last fallback
|
||||||
|
return ExtractJsonValue(json, "content");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
WulaLog.Debug($"[WulaAI] Error parsing response: {ex}");
|
WulaLog.Debug($"[WulaAI] Error parsing response: {ex.Message}");
|
||||||
}
|
}
|
||||||
return null;
|
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)
|
private static string TryExtractFirstChoiceObject(string json, int choicesKeyIndex)
|
||||||
{
|
{
|
||||||
int arrayStart = json.IndexOf('[', choicesKeyIndex);
|
int arrayStart = json.IndexOf('[', choicesKeyIndex);
|
||||||
if (arrayStart == -1) return null;
|
if (arrayStart == -1) return null;
|
||||||
|
|
||||||
int objStart = json.IndexOf('{', arrayStart);
|
int objStart = json.IndexOf('{', arrayStart);
|
||||||
if (objStart == -1) return null;
|
if (objStart == -1) return null;
|
||||||
|
|
||||||
int objEnd = FindMatchingBrace(json, objStart);
|
int objEnd = FindMatchingBrace(json, objStart);
|
||||||
if (objEnd == -1) return null;
|
if (objEnd == -1) return null;
|
||||||
|
|
||||||
return json.Substring(objStart, objEnd - objStart + 1);
|
return json.Substring(objStart, objEnd - objStart + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,76 +317,35 @@ namespace WulaFallenEmpire.EventSystem.AI
|
|||||||
int depth = 0;
|
int depth = 0;
|
||||||
bool inString = false;
|
bool inString = false;
|
||||||
bool escaped = false;
|
bool escaped = false;
|
||||||
|
|
||||||
for (int i = startIndex; i < json.Length; i++)
|
for (int i = startIndex; i < json.Length; i++)
|
||||||
{
|
{
|
||||||
char c = json[i];
|
char c = json[i];
|
||||||
if (inString)
|
if (inString)
|
||||||
{
|
{
|
||||||
if (escaped)
|
if (escaped) { escaped = false; continue; }
|
||||||
{
|
if (c == '\\') { escaped = true; continue; }
|
||||||
escaped = false;
|
if (c == '"') inString = false;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '\\')
|
|
||||||
{
|
|
||||||
escaped = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '"')
|
|
||||||
{
|
|
||||||
inString = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (c == '"') { inString = true; continue; }
|
||||||
if (c == '"')
|
if (c == '{') depth++;
|
||||||
{
|
if (c == '}') { depth--; if (depth == 0) return i; }
|
||||||
inString = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '{')
|
|
||||||
{
|
|
||||||
depth++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c == '}')
|
|
||||||
{
|
|
||||||
depth--;
|
|
||||||
if (depth == 0) return i;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ExtractJsonValue(string json, string key)
|
private static string ExtractJsonValue(string json, string key, int startIndex = 0)
|
||||||
{
|
|
||||||
// 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}\"";
|
string keyPattern = $"\"{key}\"";
|
||||||
int keyIndex = json.IndexOf(keyPattern, startIndex, StringComparison.Ordinal);
|
int keyIndex = json.IndexOf(keyPattern, startIndex, StringComparison.Ordinal);
|
||||||
if (keyIndex == -1) return null;
|
if (keyIndex == -1) return null;
|
||||||
|
|
||||||
// Find the colon after the key
|
|
||||||
int colonIndex = json.IndexOf(':', keyIndex + keyPattern.Length);
|
int colonIndex = json.IndexOf(':', keyIndex + keyPattern.Length);
|
||||||
if (colonIndex == -1) return null;
|
if (colonIndex == -1) return null;
|
||||||
|
|
||||||
// Find the opening quote of the value
|
|
||||||
int valueStart = json.IndexOf('"', colonIndex);
|
int valueStart = json.IndexOf('"', colonIndex);
|
||||||
if (valueStart == -1) return null;
|
if (valueStart == -1) return null;
|
||||||
|
|
||||||
// Extract string with escape handling
|
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
bool escaped = false;
|
bool escaped = false;
|
||||||
for (int i = valueStart + 1; i < json.Length; i++)
|
for (int i = valueStart + 1; i < json.Length; i++)
|
||||||
@@ -323,27 +358,78 @@ namespace WulaFallenEmpire.EventSystem.AI
|
|||||||
else if (c == 't') sb.Append('\t');
|
else if (c == 't') sb.Append('\t');
|
||||||
else if (c == '"') sb.Append('"');
|
else if (c == '"') sb.Append('"');
|
||||||
else if (c == '\\') sb.Append('\\');
|
else if (c == '\\') sb.Append('\\');
|
||||||
else sb.Append(c); // Literal
|
else if (c == 'u')
|
||||||
escaped = false;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (c == '\\')
|
|
||||||
{
|
{
|
||||||
escaped = true;
|
// Handle Unicode escape sequence \uXXXX
|
||||||
|
if (i + 4 < json.Length)
|
||||||
|
{
|
||||||
|
string hex = json.Substring(i + 1, 4);
|
||||||
|
if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out int charCode))
|
||||||
|
{
|
||||||
|
sb.Append((char)charCode);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback if parsing fails
|
||||||
|
sb.Append("\\u");
|
||||||
|
sb.Append(hex);
|
||||||
|
i += 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("\\u");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (c == '"')
|
else if (c == '/')
|
||||||
{
|
{
|
||||||
// End of string
|
sb.Append('/');
|
||||||
return sb.ToString();
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
sb.Append(c);
|
sb.Append(c);
|
||||||
}
|
}
|
||||||
|
escaped = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (c == '\\') escaped = true;
|
||||||
|
else if (c == '"') return sb.ToString();
|
||||||
|
else sb.Append(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
default:
|
||||||
|
if (c < 0x20) { sb.Append("\\u"); sb.Append(((int)c).ToString("x4")); }
|
||||||
|
else sb.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TruncateForLog(string s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return s;
|
||||||
|
if (s.Length <= MaxLogChars) return s;
|
||||||
|
return s.Substring(0, MaxLogChars) + "... (truncated)";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Verse;
|
using Verse;
|
||||||
|
|
||||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
@@ -11,7 +12,8 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
public abstract string Description { get; }
|
public abstract string Description { get; }
|
||||||
public abstract string UsageSchema { get; } // XML schema description
|
public abstract string UsageSchema { get; } // XML schema description
|
||||||
|
|
||||||
public abstract string Execute(string args);
|
public virtual string Execute(string args) => "Error: Synchronous execution not supported for this tool.";
|
||||||
|
public virtual Task<string> ExecuteAsync(string args) => Task.FromResult(Execute(args));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper method to parse XML arguments into a dictionary.
|
/// Helper method to parse XML arguments into a dictionary.
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using RimWorld;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
using WulaFallenEmpire;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public static class BombardmentUtility
|
||||||
|
{
|
||||||
|
public static string ExecuteCircularBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCircularBombardment props, Dictionary<string, string> parsed = null)
|
||||||
|
{
|
||||||
|
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||||
|
|
||||||
|
bool filter = true;
|
||||||
|
if (parsed != null && parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff)) filter = ff;
|
||||||
|
|
||||||
|
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, props, filter);
|
||||||
|
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, props, spawnImmediately: isPaused);
|
||||||
|
|
||||||
|
return $"Success: Scheduled Circular Bombardment '{def.defName}' at {targetCell}. Launches: {totalLaunches}/{props.maxLaunches}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExecuteStrafeBombardment(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, Dictionary<string, string> parsed = null)
|
||||||
|
{
|
||||||
|
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||||
|
|
||||||
|
ParseDirectionInfo(parsed, targetCell, props.bombardmentLength, true, out Vector3 direction, out IntVec3 _);
|
||||||
|
|
||||||
|
var targetCells = CalculateBombardmentAreaCells(map, targetCell, direction, props.bombardmentWidth, props.bombardmentLength);
|
||||||
|
|
||||||
|
if (targetCells.Count == 0) return $"Error: No valid targets found for strafe at {targetCell}.";
|
||||||
|
|
||||||
|
var selectedCells = new List<IntVec3>();
|
||||||
|
var missedCells = new List<IntVec3>();
|
||||||
|
foreach (var cell in targetCells)
|
||||||
|
{
|
||||||
|
if (Rand.Value <= props.targetSelectionChance) selectedCells.Add(cell);
|
||||||
|
else missedCells.Add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCells.Count < props.minTargetCells && missedCells.Count > 0)
|
||||||
|
{
|
||||||
|
int needed = props.minTargetCells - selectedCells.Count;
|
||||||
|
selectedCells.AddRange(missedCells.InRandomOrder().Take(Math.Min(needed, missedCells.Count)));
|
||||||
|
}
|
||||||
|
else if (selectedCells.Count > props.maxTargetCells)
|
||||||
|
{
|
||||||
|
selectedCells = selectedCells.InRandomOrder().Take(props.maxTargetCells).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCells.Count == 0) return $"Error: No cells selected for strafe after chance filter.";
|
||||||
|
|
||||||
|
var rows = OrganizeIntoRows(targetCell, direction, selectedCells);
|
||||||
|
|
||||||
|
var delayed = map.GetComponent<MapComponent_SkyfallerDelayed>();
|
||||||
|
if (delayed == null)
|
||||||
|
{
|
||||||
|
delayed = new MapComponent_SkyfallerDelayed(map);
|
||||||
|
map.components.Add(delayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
int now = Find.TickManager.TicksGame;
|
||||||
|
int startTick = now + props.warmupTicks;
|
||||||
|
int totalScheduled = 0;
|
||||||
|
|
||||||
|
foreach (var row in rows)
|
||||||
|
{
|
||||||
|
int rowStartTick = startTick + (row.Key * props.rowDelayTicks);
|
||||||
|
for (int i = 0; i < row.Value.Count; i++)
|
||||||
|
{
|
||||||
|
int hitTick = rowStartTick + (i * props.impactDelayTicks);
|
||||||
|
int delay = hitTick - now;
|
||||||
|
|
||||||
|
if (delay <= 0)
|
||||||
|
{
|
||||||
|
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
|
||||||
|
GenSpawn.Spawn(skyfaller, row.Value[i], map);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
delayed.ScheduleSkyfaller(props.skyfallerDef, row.Value[i], delay);
|
||||||
|
}
|
||||||
|
totalScheduled++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Success: Scheduled Strafe Bombardment '{def.defName}' at {targetCell}. Direction: {direction}. Targets: {totalScheduled}.";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExecuteStrafeBombardmentDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityBombardment props, float angle)
|
||||||
|
{
|
||||||
|
// Overload for direct execution with angle (no parsing needed)
|
||||||
|
Vector3 direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
|
||||||
|
// Reuse the main logic by passing a mock dictionary or separating the logic further?
|
||||||
|
// To simplify, let's just copy the core logic or create a private helper that takes explicit args.
|
||||||
|
// Actually, the main method parses direction from 'parsed'.
|
||||||
|
// Let's make a Dictionary to pass to it.
|
||||||
|
var dict = new Dictionary<string, string> { { "angle", angle.ToString() } };
|
||||||
|
return ExecuteStrafeBombardment(map, targetCell, def, props, dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExecuteEnergyLance(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, Dictionary<string, string> parsed = null)
|
||||||
|
{
|
||||||
|
ThingDef lanceDef = props.energyLanceDef ?? DefDatabase<ThingDef>.GetNamedSilentFail("EnergyLance");
|
||||||
|
if (lanceDef == null) return $"Error: Could not resolve EnergyLance ThingDef for '{def.defName}'.";
|
||||||
|
|
||||||
|
ParseDirectionInfo(parsed, targetCell, props.moveDistance, props.useFixedDistance, out Vector3 direction, out IntVec3 endPos);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EnergyLance.MakeEnergyLance(
|
||||||
|
lanceDef,
|
||||||
|
targetCell,
|
||||||
|
endPos,
|
||||||
|
map,
|
||||||
|
props.moveDistance,
|
||||||
|
props.useFixedDistance,
|
||||||
|
props.durationTicks,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return $"Success: Triggered Energy Lance '{def.defName}' from {targetCell} towards {endPos}. Type: {lanceDef.defName}.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: Failed to spawn EnergyLance: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExecuteEnergyLanceDirect(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityEnergyLance props, float angle)
|
||||||
|
{
|
||||||
|
var dict = new Dictionary<string, string> { { "angle", angle.ToString() } };
|
||||||
|
return ExecuteEnergyLance(map, targetCell, def, props, dict);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExecuteCallSkyfaller(Map map, IntVec3 targetCell, AbilityDef def, CompProperties_AbilityCallSkyfaller props)
|
||||||
|
{
|
||||||
|
if (props.skyfallerDef == null) return $"Error: '{def.defName}' has no skyfallerDef.";
|
||||||
|
|
||||||
|
var delayed = map.GetComponent<MapComponent_SkyfallerDelayed>();
|
||||||
|
if (delayed == null)
|
||||||
|
{
|
||||||
|
delayed = new MapComponent_SkyfallerDelayed(map);
|
||||||
|
map.components.Add(delayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
int delay = props.delayTicks;
|
||||||
|
if (delay <= 0)
|
||||||
|
{
|
||||||
|
Skyfaller skyfaller = SkyfallerMaker.MakeSkyfaller(props.skyfallerDef);
|
||||||
|
GenSpawn.Spawn(skyfaller, targetCell, map);
|
||||||
|
return $"Success: Spawned Skyfaller '{def.defName}' immediately at {targetCell}.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
delayed.ScheduleSkyfaller(props.skyfallerDef, targetCell, delay);
|
||||||
|
return $"Success: Scheduled Skyfaller '{def.defName}' at {targetCell} in {delay} ticks.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
private static void ParseDirectionInfo(Dictionary<string, string> parsed, IntVec3 startPos, float moveDistance, bool useFixedDistance, out Vector3 direction, out IntVec3 endPos)
|
||||||
|
{
|
||||||
|
direction = Vector3.forward;
|
||||||
|
endPos = startPos;
|
||||||
|
|
||||||
|
if (parsed == null)
|
||||||
|
{
|
||||||
|
// Default North
|
||||||
|
endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.TryGetValue("angle", out var angleStr) && float.TryParse(angleStr, out float angle))
|
||||||
|
{
|
||||||
|
direction = Quaternion.AngleAxis(angle, Vector3.up) * Vector3.forward;
|
||||||
|
endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
|
||||||
|
}
|
||||||
|
else if (TryParseDirectionCell(parsed, out IntVec3 dirCell))
|
||||||
|
{
|
||||||
|
direction = (dirCell.ToVector3() - startPos.ToVector3()).normalized;
|
||||||
|
if (direction == Vector3.zero) direction = Vector3.forward;
|
||||||
|
|
||||||
|
if (useFixedDistance)
|
||||||
|
{
|
||||||
|
endPos = (startPos.ToVector3() + direction * moveDistance).ToIntVec3();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
endPos = dirCell;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Default North
|
||||||
|
endPos = (startPos.ToVector3() + Vector3.forward * moveDistance).ToIntVec3();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDirectionCell(Dictionary<string, string> parsed, out IntVec3 cell)
|
||||||
|
{
|
||||||
|
cell = IntVec3.Invalid;
|
||||||
|
if (parsed == null) return false;
|
||||||
|
|
||||||
|
if (parsed.TryGetValue("dirX", out var xStr) && parsed.TryGetValue("dirZ", out var zStr) &&
|
||||||
|
int.TryParse(xStr, out int x) && int.TryParse(zStr, out int z))
|
||||||
|
{
|
||||||
|
cell = new IntVec3(x, 0, z);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.TryGetValue("direction", out var dirStr) && !string.IsNullOrWhiteSpace(dirStr))
|
||||||
|
{
|
||||||
|
var parts = dirStr.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length >= 2 && int.TryParse(parts[0], out int dx) && int.TryParse(parts[1], out int dz))
|
||||||
|
{
|
||||||
|
cell = new IntVec3(dx, 0, dz);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<IntVec3> CalculateBombardmentAreaCells(Map map, IntVec3 startCell, Vector3 direction, int width, int length)
|
||||||
|
{
|
||||||
|
var areaCells = new List<IntVec3>();
|
||||||
|
Vector3 start = startCell.ToVector3();
|
||||||
|
Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
|
||||||
|
|
||||||
|
float halfWidth = width * 0.5f;
|
||||||
|
float totalLength = length;
|
||||||
|
|
||||||
|
int widthSteps = Math.Max(1, width);
|
||||||
|
int lengthSteps = Math.Max(1, length);
|
||||||
|
|
||||||
|
for (int l = 0; l <= lengthSteps; l++)
|
||||||
|
{
|
||||||
|
float lengthProgress = (float)l / lengthSteps;
|
||||||
|
float lengthOffset = UnityEngine.Mathf.Lerp(0, totalLength, lengthProgress);
|
||||||
|
|
||||||
|
for (int w = 0; w <= widthSteps; w++)
|
||||||
|
{
|
||||||
|
float widthProgress = (float)w / widthSteps;
|
||||||
|
float widthOffset = UnityEngine.Mathf.Lerp(-halfWidth, halfWidth, widthProgress);
|
||||||
|
|
||||||
|
Vector3 cellPos = start + direction * lengthOffset + perpendicularDirection * widthOffset;
|
||||||
|
IntVec3 cell = new IntVec3(
|
||||||
|
UnityEngine.Mathf.RoundToInt(cellPos.x),
|
||||||
|
UnityEngine.Mathf.RoundToInt(cellPos.y),
|
||||||
|
UnityEngine.Mathf.RoundToInt(cellPos.z)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cell.InBounds(map) && !areaCells.Contains(cell))
|
||||||
|
{
|
||||||
|
areaCells.Add(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return areaCells;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<int, List<IntVec3>> OrganizeIntoRows(IntVec3 startCell, Vector3 direction, List<IntVec3> cells)
|
||||||
|
{
|
||||||
|
var rows = new Dictionary<int, List<IntVec3>>();
|
||||||
|
Vector3 perpendicularDirection = new Vector3(-direction.z, 0, direction.x).normalized;
|
||||||
|
|
||||||
|
foreach (var cell in cells)
|
||||||
|
{
|
||||||
|
Vector3 cellVector = cell.ToVector3() - startCell.ToVector3();
|
||||||
|
float dot = Vector3.Dot(cellVector, direction);
|
||||||
|
int rowIndex = UnityEngine.Mathf.RoundToInt(dot);
|
||||||
|
|
||||||
|
if (!rows.ContainsKey(rowIndex)) rows[rowIndex] = new List<IntVec3>();
|
||||||
|
rows[rowIndex].Add(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows by index (distance from start)
|
||||||
|
var sortedRows = rows.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value);
|
||||||
|
|
||||||
|
// Sort cells within rows by width position
|
||||||
|
foreach (var key in sortedRows.Keys.ToList())
|
||||||
|
{
|
||||||
|
sortedRows[key] = sortedRows[key].OrderBy(c => Vector3.Dot((c.ToVector3() - startCell.ToVector3()), perpendicularDirection)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// VLM 视觉分析工具 - 截取游戏屏幕并使用视觉语言模型分析
|
||||||
|
/// </summary>
|
||||||
|
public class Tool_AnalyzeScreen : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "analyze_screen";
|
||||||
|
|
||||||
|
public override string Description =>
|
||||||
|
"分析当前游戏屏幕截图。你可以提供具体的指令(instruction)告诉视觉模型你需要观察什么、寻找什么、或者如何描述屏幕。";
|
||||||
|
|
||||||
|
public override string UsageSchema =>
|
||||||
|
"<analyze_screen><instruction>给视觉模型的具体指令。例如:'找到科研按钮的比例坐标' 或 '描述当前角色的健康状态栏内容'</instruction></analyze_screen>";
|
||||||
|
|
||||||
|
private const string BaseVisionSystemPrompt = "你是一个专业的老练 RimWorld 助手。你会根据指示分析屏幕截图。保持回答专业且简洁。不要输出 XML 标签,除非被明确要求。";
|
||||||
|
|
||||||
|
public override async Task<string> ExecuteAsync(string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ExecuteInternalAsync(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[Tool_AnalyzeScreen] Execute error: {ex}");
|
||||||
|
return $"视觉分析出错: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> ExecuteInternalAsync(string xmlContent)
|
||||||
|
{
|
||||||
|
var argsDict = ParseXmlArgs(xmlContent);
|
||||||
|
// 优先使用 instruction,兼容旧的 context 参数
|
||||||
|
string instruction = argsDict.TryGetValue("instruction", out var inst) ? inst :
|
||||||
|
(argsDict.TryGetValue("context", out var ctx) ? ctx : "描述当前屏幕内容,重点关注 UI 状态和重要实体。");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 检查 VLM 配置
|
||||||
|
var settings = WulaFallenEmpireMod.settings;
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
return "Mod 设置未初始化。";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据协议选择配置
|
||||||
|
string vlmApiKey = settings.useGeminiProtocol ? settings.geminiApiKey : settings.apiKey;
|
||||||
|
string vlmBaseUrl = settings.useGeminiProtocol ? settings.geminiBaseUrl : settings.baseUrl;
|
||||||
|
string vlmModel = settings.useGeminiProtocol ? settings.geminiModel : settings.model;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(vlmApiKey))
|
||||||
|
{
|
||||||
|
return "API 密钥未配置。请在 Mod 设置中配置。";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截取屏幕
|
||||||
|
string base64Image = ScreenCaptureUtility.CaptureScreenAsBase64();
|
||||||
|
if (string.IsNullOrEmpty(base64Image))
|
||||||
|
{
|
||||||
|
return "截屏失败,无法分析屏幕。";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 VLM API (使用统一的 GetChatCompletionAsync)
|
||||||
|
var client = new SimpleAIClient(vlmApiKey, vlmBaseUrl, vlmModel, settings.useGeminiProtocol);
|
||||||
|
|
||||||
|
var messages = new System.Collections.Generic.List<(string role, string message)>
|
||||||
|
{
|
||||||
|
("user", instruction)
|
||||||
|
};
|
||||||
|
|
||||||
|
string result = await client.GetChatCompletionAsync(
|
||||||
|
BaseVisionSystemPrompt,
|
||||||
|
messages,
|
||||||
|
maxTokens: 512,
|
||||||
|
temperature: 0.2f,
|
||||||
|
base64Image: base64Image
|
||||||
|
);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(result))
|
||||||
|
{
|
||||||
|
return "VLM 分析无响应,请检查 API 配置。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"屏幕分析结果: {result.Trim()}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[Tool_AnalyzeScreen] Error: {ex}");
|
||||||
|
return $"视觉分析出错: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,15 @@ using System.Text;
|
|||||||
using RimWorld;
|
using RimWorld;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Verse;
|
using Verse;
|
||||||
|
using WulaFallenEmpire;
|
||||||
|
|
||||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
{
|
{
|
||||||
public class Tool_CallBombardment : AITool
|
public class Tool_CallBombardment : AITool
|
||||||
{
|
{
|
||||||
public override string Name => "call_bombardment";
|
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 Description => "Calls orbital bombardment/support using an AbilityDef configuration (e.g., WULA_Firepower_Cannon_Salvo, WULA_Firepower_EnergyLance_Strafe). Supports Circular Bombardment, Strafe, Energy Lance, and Surveillance.";
|
||||||
public override string UsageSchema => "<call_bombardment><abilityDef>string (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 UsageSchema => "<call_bombardment><abilityDef>string</abilityDef><x>int</x><z>int</z><cell>x,z</cell><direction>x,z (optional)</direction><angle>degrees (optional)</angle><filterFriendlyFire>true/false</filterFriendlyFire></call_bombardment>";
|
||||||
|
|
||||||
public override string Execute(string args)
|
public override string Execute(string args)
|
||||||
{
|
{
|
||||||
@@ -36,38 +37,27 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
AbilityDef abilityDef = DefDatabase<AbilityDef>.GetNamed(abilityDefName, false);
|
AbilityDef abilityDef = DefDatabase<AbilityDef>.GetNamed(abilityDefName, false);
|
||||||
if (abilityDef == null) return $"Error: AbilityDef '{abilityDefName}' not found.";
|
if (abilityDef == null) return $"Error: AbilityDef '{abilityDefName}' not found.";
|
||||||
|
|
||||||
var bombardmentProps = abilityDef.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
// Switch logic based on AbilityDef components
|
||||||
if (bombardmentProps == null) return $"Error: AbilityDef '{abilityDefName}' has no CompProperties_AbilityCircularBombardment.";
|
var circular = abilityDef.comps?.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||||
if (bombardmentProps.skyfallerDef == null) return $"Error: AbilityDef '{abilityDefName}' has no skyfallerDef configured.";
|
if (circular != null) return BombardmentUtility.ExecuteCircularBombardment(map, targetCell, abilityDef, circular, parsed);
|
||||||
|
|
||||||
bool filterFriendlyFire = true;
|
var bombard = abilityDef.comps?.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||||
if (parsed.TryGetValue("filterFriendlyFire", out var ffStr) && bool.TryParse(ffStr, out bool ff))
|
if (bombard != null) return BombardmentUtility.ExecuteStrafeBombardment(map, targetCell, abilityDef, bombard, parsed);
|
||||||
{
|
|
||||||
filterFriendlyFire = ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<IntVec3> selectedTargets = SelectTargetCells(map, targetCell, bombardmentProps, filterFriendlyFire);
|
var lance = abilityDef.comps?.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||||
if (selectedTargets.Count == 0) return $"Error: No valid target cells near {targetCell}.";
|
if (lance != null) return BombardmentUtility.ExecuteEnergyLance(map, targetCell, abilityDef, lance, parsed);
|
||||||
|
|
||||||
bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
|
var skyfaller = abilityDef.comps?.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||||
int totalLaunches = ScheduleBombardment(map, selectedTargets, bombardmentProps, spawnImmediately: isPaused);
|
if (skyfaller != null) return BombardmentUtility.ExecuteCallSkyfaller(map, targetCell, abilityDef, skyfaller);
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
return $"Error: AbilityDef '{abilityDefName}' is not a supported bombardment/support type.";
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return $"Error: {ex.Message}";
|
return $"Error: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryParseTargetCell(Dictionary<string, string> parsed, out IntVec3 cell)
|
private static bool TryParseTargetCell(Dictionary<string, string> parsed, out IntVec3 cell)
|
||||||
{
|
{
|
||||||
cell = IntVec3.Invalid;
|
cell = IntVec3.Invalid;
|
||||||
@@ -91,139 +81,5 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
|
|
||||||
return false;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using RimWorld;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_CallPrefabAirdrop : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "call_prefab_airdrop";
|
||||||
|
public override string Description => "Calls a large prefab building airdrop at the specified coordinates. " +
|
||||||
|
"You must specify the prefabDefName (e.g., 'WULA_NewColonyBase') and the coordinates (x, z). " +
|
||||||
|
"TIP: Use the 'get_available_prefabs' tool first to see which structures are available. " +
|
||||||
|
"The default skyfaller animation is 'WULA_Prefab_Incoming'.";
|
||||||
|
public override string UsageSchema => "<call_prefab_airdrop><prefabDefName>DefName of the prefab</prefabDefName><skyfallerDef>Optional, default is WULA_Prefab_Incoming</skyfallerDef><x>int</x><z>int</z></call_prefab_airdrop>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = ParseXmlArgs(args);
|
||||||
|
|
||||||
|
if (!parsed.TryGetValue("prefabDefName", out string prefabDefName) || string.IsNullOrWhiteSpace(prefabDefName))
|
||||||
|
{
|
||||||
|
return "Error: Missing <prefabDefName>. Example: <prefabDefName>WULA_NewColonyBase</prefabDefName>";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.TryGetValue("x", out string xStr) || !int.TryParse(xStr, out int x) ||
|
||||||
|
!parsed.TryGetValue("z", out string zStr) || !int.TryParse(zStr, out int z))
|
||||||
|
{
|
||||||
|
return "Error: Missing or invalid target coordinates. Provide <x> and <z>.";
|
||||||
|
}
|
||||||
|
|
||||||
|
string skyfallerDefName = parsed.TryGetValue("skyfallerDef", out string sd) && !string.IsNullOrWhiteSpace(sd)
|
||||||
|
? sd.Trim()
|
||||||
|
: "WULA_Prefab_Incoming";
|
||||||
|
|
||||||
|
Map map = Find.CurrentMap;
|
||||||
|
if (map == null) return "Error: No active map.";
|
||||||
|
|
||||||
|
IntVec3 targetCell = new IntVec3(x, 0, z);
|
||||||
|
if (!targetCell.InBounds(map)) return $"Error: Target {targetCell} is out of bounds.";
|
||||||
|
|
||||||
|
// Check if prefab exists
|
||||||
|
PrefabDef prefabDef = DefDatabase<PrefabDef>.GetNamed(prefabDefName, false);
|
||||||
|
if (prefabDef == null)
|
||||||
|
{
|
||||||
|
return $"Error: PrefabDef '{prefabDefName}' not found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if skyfaller exists
|
||||||
|
ThingDef skyfallerDef = DefDatabase<ThingDef>.GetNamed(skyfallerDefName, false);
|
||||||
|
if (skyfallerDef == null)
|
||||||
|
{
|
||||||
|
return $"Error: Skyfaller ThingDef '{skyfallerDefName}' not found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-Scan for valid position
|
||||||
|
IntVec3 validCell = targetCell;
|
||||||
|
bool foundSpot = false;
|
||||||
|
|
||||||
|
// Get prefab size from its size field. If not set, default to 1x1 (though prefabs are usually larger)
|
||||||
|
IntVec2 size = prefabDef.size;
|
||||||
|
|
||||||
|
// Simple check function for a given center cell
|
||||||
|
bool IsPositionValid(IntVec3 center, Map m, IntVec2 s)
|
||||||
|
{
|
||||||
|
if (!center.InBounds(m)) return false;
|
||||||
|
|
||||||
|
CellRect rect = GenAdj.OccupiedRect(center, Rot4.North, s);
|
||||||
|
if (!rect.InBounds(m)) return false;
|
||||||
|
|
||||||
|
foreach (IntVec3 c in rect)
|
||||||
|
{
|
||||||
|
// 1. Check Terrain Passability (water/impassable usually bad for buildings)
|
||||||
|
TerrainDef terr = c.GetTerrain(m);
|
||||||
|
if (terr.passability == Traversability.Impassable || terr.IsWater) return false;
|
||||||
|
|
||||||
|
// 2. Check Thick Roof (airdrops can't penetrate)
|
||||||
|
if (m.roofGrid.RoofAt(c) == RoofDefOf.RoofRockThick) return false;
|
||||||
|
|
||||||
|
// 3. Check Existing Buildings
|
||||||
|
if (c.GetFirstBuilding(m) != null) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try original spot first
|
||||||
|
if (IsPositionValid(targetCell, map, size))
|
||||||
|
{
|
||||||
|
validCell = targetCell;
|
||||||
|
foundSpot = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Spiral scan for a nearby valid spot.
|
||||||
|
// Radius ~20 should be enough to find a spot without deviating too far.
|
||||||
|
foreach (IntVec3 c in GenRadial.RadialCellsAround(targetCell, 20f, useCenter: false))
|
||||||
|
{
|
||||||
|
if (IsPositionValid(c, map, size))
|
||||||
|
{
|
||||||
|
validCell = c;
|
||||||
|
foundSpot = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundSpot)
|
||||||
|
{
|
||||||
|
return $"Error: Could not find a valid clear space for '{prefabDefName}' (Size: {size.x}x{size.z}) near {targetCell}. Area may be blocked by thick roofs, water, or other buildings.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawning must happen on main thread
|
||||||
|
string resultMessage = $"Success: Scheduled airdrop for '{prefabDefName}' at valid position {validCell} (adjusted from {targetCell}) using {skyfallerDefName}.";
|
||||||
|
|
||||||
|
// Use the found valid cell
|
||||||
|
string pDef = prefabDefName;
|
||||||
|
ThingDef sDef = skyfallerDef;
|
||||||
|
IntVec3 cell = validCell;
|
||||||
|
Map targetMap = map;
|
||||||
|
|
||||||
|
LongEventHandler.ExecuteWhenFinished(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var skyfaller = (Skyfaller_PrefabSpawner)SkyfallerMaker.MakeSkyfaller(sDef);
|
||||||
|
skyfaller.prefabDefName = pDef;
|
||||||
|
GenSpawn.Spawn(skyfaller, cell, targetMap);
|
||||||
|
WulaLog.Debug($"[WulaAI] Prefab airdrop spawned: {pDef} at {cell}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[WulaAI] Failed to spawn prefab airdrop on main thread: {ex.Message}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return resultMessage;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using Verse;
|
using Verse;
|
||||||
using WulaFallenEmpire.EventSystem.AI.UI;
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
{
|
{
|
||||||
@@ -21,13 +21,13 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
{
|
{
|
||||||
if (int.TryParse(idStr, out id))
|
if (int.TryParse(idStr, out id))
|
||||||
{
|
{
|
||||||
var window = Dialog_AIConversation.Instance ?? Find.WindowStack.WindowOfType<Dialog_AIConversation>();
|
var core = AIIntelligenceCore.Instance;
|
||||||
if (window != null)
|
if (core != null)
|
||||||
{
|
{
|
||||||
window.SetPortrait(id);
|
core.SetPortrait(id);
|
||||||
return $"Expression changed to {id}.";
|
return $"Expression changed to {id}.";
|
||||||
}
|
}
|
||||||
return "Error: Dialog window not found.";
|
return "Error: AI Core not found.";
|
||||||
}
|
}
|
||||||
return "Error: Invalid arguments. 'expression_id' must be an integer.";
|
return "Error: Invalid arguments. 'expression_id' must be an integer.";
|
||||||
}
|
}
|
||||||
@@ -40,4 +40,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_GetAvailableBombardments : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "get_available_bombardments";
|
||||||
|
public override string Description => "Returns a list of available orbital bombardment abilities (AbilityDefs) that can be called. " +
|
||||||
|
"Use this to find the correct 'abilityDef' for the 'call_bombardment' tool.";
|
||||||
|
public override string UsageSchema => "<get_available_bombardments/>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var allAbilityDefs = DefDatabase<AbilityDef>.AllDefs.ToList();
|
||||||
|
var validBombardments = new List<AbilityDef>();
|
||||||
|
|
||||||
|
foreach (var def in allAbilityDefs)
|
||||||
|
{
|
||||||
|
if (def.comps == null) continue;
|
||||||
|
|
||||||
|
// Support multiple bombardment types:
|
||||||
|
// 1. Circular Bombardment (original)
|
||||||
|
var circularProps = def.comps.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||||
|
if (circularProps != null && circularProps.skyfallerDef != null)
|
||||||
|
{
|
||||||
|
validBombardments.Add(def);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Standard/Rectangular Bombardment (e.g. Minigun Strafe)
|
||||||
|
var bombardProps = def.comps.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||||
|
if (bombardProps != null && bombardProps.skyfallerDef != null)
|
||||||
|
{
|
||||||
|
validBombardments.Add(def);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Energy Lance (e.g. EnergyLance Strafe)
|
||||||
|
var lanceProps = def.comps.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||||
|
if (lanceProps != null)
|
||||||
|
{
|
||||||
|
validBombardments.Add(def);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Call Skyfaller / Surveillance (e.g. Cannon Surveillance)
|
||||||
|
var skyfallerProps = def.comps.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||||
|
if (skyfallerProps != null && skyfallerProps.skyfallerDef != null)
|
||||||
|
{
|
||||||
|
validBombardments.Add(def);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validBombardments.Count == 0)
|
||||||
|
{
|
||||||
|
return "No valid bombardment abilities found in the database.";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Found {validBombardments.Count} available bombardment options:");
|
||||||
|
|
||||||
|
// Group by prefix to help AI categorize
|
||||||
|
var wulaBombardments = validBombardments.Where(p => p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
var otherBombardments = validBombardments.Where(p => !p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
if (wulaBombardments.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("\n[Wula Empire Specialized Bombardments]:");
|
||||||
|
foreach (var p in wulaBombardments)
|
||||||
|
{
|
||||||
|
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||||
|
var details = "";
|
||||||
|
|
||||||
|
var circular = p.comps.OfType<CompProperties_AbilityCircularBombardment>().FirstOrDefault();
|
||||||
|
if (circular != null) details = $"Type: Circular, Radius: {circular.radius}, Launches: {circular.maxLaunches}";
|
||||||
|
|
||||||
|
var bombard = p.comps.OfType<CompProperties_AbilityBombardment>().FirstOrDefault();
|
||||||
|
if (bombard != null) details = $"Type: Strafe, Area: {bombard.bombardmentWidth}x{bombard.bombardmentLength}";
|
||||||
|
|
||||||
|
var lance = p.comps.OfType<CompProperties_AbilityEnergyLance>().FirstOrDefault();
|
||||||
|
if (lance != null) details = $"Type: Energy Lance, Duration: {lance.durationTicks}";
|
||||||
|
|
||||||
|
var skyfaller = p.comps.OfType<CompProperties_AbilityCallSkyfaller>().FirstOrDefault();
|
||||||
|
if (skyfaller != null) details = $"Type: Surveillance/Signal, Delay: {skyfaller.delayTicks}";
|
||||||
|
|
||||||
|
sb.AppendLine($"- {p.defName}{label} [{details}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherBombardments.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("\n[Generic/Other Bombardments]:");
|
||||||
|
// Limit generic ones to avoid token bloat
|
||||||
|
var genericToShow = otherBombardments.Take(20).ToList();
|
||||||
|
foreach (var p in genericToShow)
|
||||||
|
{
|
||||||
|
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||||
|
var props = p.comps.OfType<CompProperties_AbilityCircularBombardment>().First();
|
||||||
|
sb.AppendLine($"- {p.defName}{label} [MaxLaunches: {props.maxLaunches}, Radius: {props.radius}]");
|
||||||
|
}
|
||||||
|
if (otherBombardments.Count > 20)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- ... and {otherBombardments.Count - 20} more generic bombardments.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_GetAvailablePrefabs : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "get_available_prefabs";
|
||||||
|
public override string Description => "Returns a list of available building prefabs (blueprints) that can be summoned. " +
|
||||||
|
"Use this to find the correct 'prefabDefName' for the 'call_prefab_airdrop' tool.";
|
||||||
|
public override string UsageSchema => "<get_available_prefabs/>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var prefabs = DefDatabase<PrefabDef>.AllDefs.ToList();
|
||||||
|
if (prefabs.Count == 0)
|
||||||
|
{
|
||||||
|
return "No prefabs found in the database.";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Found {prefabs.Count} available prefabs:");
|
||||||
|
|
||||||
|
// Group by prefix to help AI categorize
|
||||||
|
var wulaPrefabs = prefabs.Where(p => p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
var otherPrefabs = prefabs.Where(p => !p.defName.StartsWith("WULA_", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
|
||||||
|
if (wulaPrefabs.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("\n[Wula Empire Specialized Prefabs]:");
|
||||||
|
foreach (var p in wulaPrefabs)
|
||||||
|
{
|
||||||
|
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||||
|
sb.AppendLine($"- {p.defName}{label}, Size: {p.size}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherPrefabs.Count > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine("\n[Generic/Other Prefabs]:");
|
||||||
|
// Limit generic ones to avoid token bloat
|
||||||
|
var genericToShow = otherPrefabs.Take(20).ToList();
|
||||||
|
foreach (var p in genericToShow)
|
||||||
|
{
|
||||||
|
string label = !string.IsNullOrEmpty(p.label) ? $" ({p.label})" : "";
|
||||||
|
sb.AppendLine($"- {p.defName}{label}, Size: {p.size}");
|
||||||
|
}
|
||||||
|
if (otherPrefabs.Count > 20)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- ... and {otherPrefabs.Count - 20} more generic prefabs.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_GetPawnStatus : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "get_pawn_status";
|
||||||
|
public override string Description => "Returns detailed status (health, needs, gear) of specified pawns. Use this to check for sickness, injuries, mood, or equipment. Can filter by name, category (colonist/animal/prisoner/guest), or status (sick/injured).";
|
||||||
|
public override string UsageSchema => "<get_pawn_status><name>optional_partial_name</name><category>colonist/animal/prisoner/guest/all (default: all)</category><filter>sick/injured/downed/dead (optional)</filter></get_pawn_status>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsed = ParseXmlArgs(args);
|
||||||
|
string nameTarget = parsed.TryGetValue("name", out string n) ? n.ToLower() : null;
|
||||||
|
string category = parsed.TryGetValue("category", out string c) ? c.ToLower() : "all";
|
||||||
|
string filter = parsed.TryGetValue("filter", out string f) ? f.ToLower() : null;
|
||||||
|
|
||||||
|
Map map = Find.CurrentMap;
|
||||||
|
if (map == null) return "Error: No active map.";
|
||||||
|
|
||||||
|
List<Pawn> pawns = map.mapPawns.AllPawnsSpawned.ToList();
|
||||||
|
var matches = new List<Pawn>();
|
||||||
|
|
||||||
|
foreach (var pawn in pawns)
|
||||||
|
{
|
||||||
|
// Filter by Category
|
||||||
|
bool catMatch = false;
|
||||||
|
switch (category)
|
||||||
|
{
|
||||||
|
case "colonist": catMatch = pawn.IsFreeColonist; break;
|
||||||
|
case "animal": catMatch = pawn.RaceProps.Animal; break;
|
||||||
|
case "prisoner": catMatch = pawn.IsPrisonerOfColony; break;
|
||||||
|
case "guest": catMatch = pawn.guest != null && !pawn.IsPrisoner; break;
|
||||||
|
case "all": catMatch = true; break;
|
||||||
|
default: catMatch = true; break;
|
||||||
|
}
|
||||||
|
if (!catMatch) continue;
|
||||||
|
|
||||||
|
// Filter by Name
|
||||||
|
if (!string.IsNullOrEmpty(nameTarget))
|
||||||
|
{
|
||||||
|
string pName = pawn.Name?.ToStringFull?.ToLower() ?? pawn.LabelShort?.ToLower() ?? "";
|
||||||
|
if (!pName.Contains(nameTarget)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by Status
|
||||||
|
if (!string.IsNullOrEmpty(filter))
|
||||||
|
{
|
||||||
|
bool statusMatch = false;
|
||||||
|
if (filter == "sick")
|
||||||
|
{
|
||||||
|
// Check for visible hediffs that are bad (not implants)
|
||||||
|
statusMatch = pawn.health.hediffSet.hediffs.Any(h => h.Visible && h.def.isBad && !h.IsPermanent() && h.def.makesSickThought);
|
||||||
|
}
|
||||||
|
else if (filter == "injured")
|
||||||
|
{
|
||||||
|
statusMatch = pawn.health.summaryHealth.SummaryHealthPercent < 1.0f;
|
||||||
|
}
|
||||||
|
else if (filter == "downed") statusMatch = pawn.Downed;
|
||||||
|
else if (filter == "dead") statusMatch = pawn.Dead;
|
||||||
|
else statusMatch = true; // Unknown filter?
|
||||||
|
|
||||||
|
if (!statusMatch) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.Add(pawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.Count == 0) return "No matching pawns found.";
|
||||||
|
|
||||||
|
// Sort by relevance (colonists first, then sick/injured)
|
||||||
|
matches = matches.OrderBy(p => p.RaceProps.Animal).ThenBy(p => p.health.summaryHealth.SummaryHealthPercent).Take(10).ToList();
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.AppendLine($"Found {matches.Count} matching pawns:");
|
||||||
|
|
||||||
|
foreach (var pawn in matches)
|
||||||
|
{
|
||||||
|
AppendPawnStatus(sb, pawn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AppendPawnStatus(StringBuilder sb, Pawn pawn)
|
||||||
|
{
|
||||||
|
if (pawn == null) return;
|
||||||
|
sb.AppendLine($"- {pawn.Name?.ToStringShort ?? pawn.LabelCap} ({pawn.def.label}, Age {pawn.ageTracker.AgeBiologicalYears}):");
|
||||||
|
|
||||||
|
// Health
|
||||||
|
if (pawn.health != null)
|
||||||
|
{
|
||||||
|
sb.Append(" Health: ");
|
||||||
|
var hediffs = pawn.health.hediffSet.hediffs;
|
||||||
|
bool anyHediff = false;
|
||||||
|
if (hediffs != null && hediffs.Count > 0)
|
||||||
|
{
|
||||||
|
var visibleHediffs = hediffs.Where(h => h.Visible).ToList();
|
||||||
|
foreach (var h in visibleHediffs)
|
||||||
|
{
|
||||||
|
string severity = h.SeverityLabel;
|
||||||
|
if (!string.IsNullOrEmpty(severity)) severity = $" ({severity})";
|
||||||
|
sb.Append($"{h.LabelCap}{severity}, ");
|
||||||
|
anyHediff = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (anyHediff) sb.Length -= 2;
|
||||||
|
else sb.Append("Healthy");
|
||||||
|
|
||||||
|
// Bleeding
|
||||||
|
if (pawn.health.hediffSet.BleedRateTotal > 0.01f)
|
||||||
|
{
|
||||||
|
sb.Append($" [Bleeding: {pawn.health.hediffSet.BleedRateTotal:P0}/day]");
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needs (only if applicable)
|
||||||
|
if (pawn.needs != null && pawn.RaceProps.Humanlike)
|
||||||
|
{
|
||||||
|
sb.Append(" Needs: ");
|
||||||
|
var allNeeds = pawn.needs.AllNeeds;
|
||||||
|
if (allNeeds != null)
|
||||||
|
{
|
||||||
|
var lowNeeds = allNeeds.Where(n => n.CurLevelPercentage < 0.3f).ToList();
|
||||||
|
if (lowNeeds.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var need in lowNeeds)
|
||||||
|
{
|
||||||
|
sb.Append($"!{need.LabelCap}: {need.CurLevelPercentage:P0}, ");
|
||||||
|
}
|
||||||
|
sb.Length -= 2;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append("Satisfied.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.AppendLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mood (Humanlike)
|
||||||
|
if (pawn.needs?.mood != null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" Mood: {pawn.needs.mood.CurLevelPercentage:P0} ({pawn.needs.mood.MoodString})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current Activity/Job
|
||||||
|
if (pawn.CurJob != null)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" Activity: {pawn.CurJob.def.reportString.Replace("TargetA", pawn.CurJob.targetA.Thing?.LabelShort ?? "area")}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using System.Reflection;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Verse;
|
using Verse;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using WulaFallenEmpire.EventSystem.AI.UI;
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
{
|
{
|
||||||
@@ -110,10 +110,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
|
|
||||||
private static string BuildToolHistory(int maxCount)
|
private static string BuildToolHistory(int maxCount)
|
||||||
{
|
{
|
||||||
var window = Dialog_AIConversation.Instance ?? Find.WindowStack.WindowOfType<Dialog_AIConversation>();
|
var core = AIIntelligenceCore.Instance;
|
||||||
if (window == null) return "AI Tool History: none found.";
|
if (core == null) return "AI Tool History: none found.";
|
||||||
|
|
||||||
var history = window.GetHistorySnapshot();
|
var history = core.GetHistorySnapshot();
|
||||||
if (history == null || history.Count == 0) return "AI Tool History: none found.";
|
if (history == null || history.Count == 0) return "AI Tool History: none found.";
|
||||||
|
|
||||||
var entries = new List<(string ToolXml, string ToolResult)>();
|
var entries = new List<(string ToolXml, string ToolResult)>();
|
||||||
@@ -310,3 +310,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
|
|||||||
public class Tool_ModifyGoodwill : AITool
|
public class Tool_ModifyGoodwill : AITool
|
||||||
{
|
{
|
||||||
public override string Name => "modify_goodwill";
|
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 Description => "Adjusts YOUR internal opinion of the player (AI Goodwill). WARNING: This DOES NOT affect Faction Relations or stop raids. It is purely personal. Do NOT use this to try to stop enemies.";
|
||||||
public override string UsageSchema => "<modify_goodwill><amount>integer</amount></modify_goodwill>";
|
public override string UsageSchema => "<modify_goodwill><amount>integer</amount></modify_goodwill>";
|
||||||
|
|
||||||
public override string Execute(string args)
|
public override string Execute(string args)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_RecallMemories : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "recall_memories";
|
||||||
|
public override string Description => "Searches the AI's long-term memory for facts matching a specific query or keyword.";
|
||||||
|
public override string UsageSchema => "<recall_memories><query>Search keywords</query><limit>optional_int_max_results</limit></recall_memories>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
var argsDict = ParseXmlArgs(args);
|
||||||
|
string query = argsDict.TryGetValue("query", out string q) ? q : "";
|
||||||
|
string limitStr = argsDict.TryGetValue("limit", out string lStr) ? lStr : "5";
|
||||||
|
|
||||||
|
int limit = 5;
|
||||||
|
if (int.TryParse(limitStr, out int parsedLimit))
|
||||||
|
{
|
||||||
|
limit = parsedLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var memoryManager = Find.World?.GetComponent<AIMemoryManager>();
|
||||||
|
if (memoryManager == null)
|
||||||
|
{
|
||||||
|
return "Error: AIMemoryManager world component not found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
var recent = memoryManager.GetRecentMemories(limit);
|
||||||
|
if (recent.Count == 0) return "No recent memories found.";
|
||||||
|
return FormatMemories(recent);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = memoryManager.SearchMemories(query, limit);
|
||||||
|
if (results.Count == 0)
|
||||||
|
{
|
||||||
|
return "No memories found matching the query.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return FormatMemories(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string FormatMemories(List<AIMemoryEntry> memories)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Found Memories:");
|
||||||
|
foreach (var m in memories)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- [{m.Category}] {m.Fact} (ID: {m.Id})");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_RememberFact : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "remember_fact";
|
||||||
|
public override string Description => "Stores a specific fact or piece of information into the AI's long-term memory for future retrieval.";
|
||||||
|
public override string UsageSchema => "<remember_fact><fact>Text content to remember</fact><category>optional_category</category></remember_fact>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
var argsDict = ParseXmlArgs(args);
|
||||||
|
if (!argsDict.TryGetValue("fact", out string fact) || string.IsNullOrWhiteSpace(fact))
|
||||||
|
{
|
||||||
|
return "Error: <fact> content is required.";
|
||||||
|
}
|
||||||
|
|
||||||
|
string category = argsDict.TryGetValue("category", out string cat) ? cat : "misc";
|
||||||
|
|
||||||
|
var memoryManager = Find.World?.GetComponent<AIMemoryManager>();
|
||||||
|
if (memoryManager == null)
|
||||||
|
{
|
||||||
|
return "Error: AIMemoryManager world component not found.";
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = memoryManager.AddMemory(fact, category);
|
||||||
|
if (entry != null)
|
||||||
|
{
|
||||||
|
return $"Success: Memory stored. ID: {entry.Id}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return "Error: Failed to store memory.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using RimWorld;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.Tools
|
||||||
|
{
|
||||||
|
public class Tool_SetOverwatchMode : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "set_overwatch_mode";
|
||||||
|
public override string Description => "Enables or disables the AI Overwatch Combat Protocol. When enabled (enabled=true), the AI will autonomously scan for hostile targets every few seconds and launch appropriate orbital bombardments for a set duration. When disabled (enabled=false), it immediately stops any active overwatch and clears the flight path. Use enabled=false to stop overwatch early if the player requests it.";
|
||||||
|
public override string UsageSchema => "<set_overwatch_mode><enabled>true/false</enabled><durationSeconds>amount (only needed when enabling)</durationSeconds></set_overwatch_mode>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
var parsed = ParseXmlArgs(args);
|
||||||
|
|
||||||
|
bool enabled = true;
|
||||||
|
if (parsed.TryGetValue("enabled", out var enabledStr) && bool.TryParse(enabledStr, out bool e))
|
||||||
|
{
|
||||||
|
enabled = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
int duration = 60;
|
||||||
|
if (parsed.TryGetValue("durationSeconds", out var dStr) && int.TryParse(dStr, out int d))
|
||||||
|
{
|
||||||
|
duration = d;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map map = Find.CurrentMap;
|
||||||
|
if (map == null) return "Error: No active map.";
|
||||||
|
|
||||||
|
var overwatch = map.GetComponent<MapComponent_AIOverwatch>();
|
||||||
|
if (overwatch == null)
|
||||||
|
{
|
||||||
|
overwatch = new MapComponent_AIOverwatch(map);
|
||||||
|
map.components.Add(overwatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
overwatch.EnableOverwatch(duration);
|
||||||
|
return $"Success: AI Overwatch Protocol ENABLED for {duration} seconds. Hostiles will be engaged automatically.";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
overwatch.DisableOverwatch();
|
||||||
|
return "Success: AI Overwatch Protocol DISABLED.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||||
|
{
|
||||||
|
public class Dialog_ExtraPersonalityPrompt : Window
|
||||||
|
{
|
||||||
|
private string _tempPrompt;
|
||||||
|
|
||||||
|
public override Vector2 InitialSize => new Vector2(600f, 500f);
|
||||||
|
|
||||||
|
public Dialog_ExtraPersonalityPrompt()
|
||||||
|
{
|
||||||
|
this.forcePause = true;
|
||||||
|
this.doWindowBackground = true;
|
||||||
|
this.absorbInputAroundWindow = true;
|
||||||
|
this.closeOnClickedOutside = true;
|
||||||
|
|
||||||
|
_tempPrompt = WulaFallenEmpireMod.settings?.extraPersonalityPrompt ?? "";
|
||||||
|
|
||||||
|
// 如果目前是空的,默认显示当前 XML/Def 的内容供玩家修改
|
||||||
|
if (string.IsNullOrWhiteSpace(_tempPrompt))
|
||||||
|
{
|
||||||
|
var core = Find.World?.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (core != null)
|
||||||
|
{
|
||||||
|
_tempPrompt = core.GetDefaultPersona();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DoWindowContents(Rect inRect)
|
||||||
|
{
|
||||||
|
Text.Font = GameFont.Medium;
|
||||||
|
Widgets.Label(new Rect(0, 0, inRect.width, 40f), "Wula_ExtraPersonality_Title".Translate());
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
|
||||||
|
float curY = 45f;
|
||||||
|
Widgets.Label(new Rect(0, curY, inRect.width, 60f), "Wula_ExtraPersonality_Desc".Translate());
|
||||||
|
curY += 65f;
|
||||||
|
|
||||||
|
Rect textRect = new Rect(0, curY, inRect.width, inRect.height - curY - 50f);
|
||||||
|
_tempPrompt = Widgets.TextArea(textRect, _tempPrompt);
|
||||||
|
|
||||||
|
Rect saveBtnRect = new Rect(inRect.width / 2 - 130f, inRect.height - 40f, 120f, 35f);
|
||||||
|
if (Widgets.ButtonText(saveBtnRect, "Wula_Save".Translate()))
|
||||||
|
{
|
||||||
|
if (WulaFallenEmpireMod.settings != null)
|
||||||
|
{
|
||||||
|
WulaFallenEmpireMod.settings.extraPersonalityPrompt = _tempPrompt;
|
||||||
|
WulaFallenEmpireMod.settings.Write();
|
||||||
|
}
|
||||||
|
this.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect resetBtnRect = new Rect(inRect.width / 2 + 10f, inRect.height - 40f, 120f, 35f);
|
||||||
|
if (Widgets.ButtonText(resetBtnRect, "Wula_Reset".Translate()))
|
||||||
|
{
|
||||||
|
var core = Find.World?.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (core != null)
|
||||||
|
{
|
||||||
|
_tempPrompt = core.GetDefaultPersona();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
700
Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs
Normal file
700
Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
using RimWorld;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||||
|
{
|
||||||
|
public class Overlay_WulaLink : Window
|
||||||
|
{
|
||||||
|
// Core Connection
|
||||||
|
private AIIntelligenceCore _core;
|
||||||
|
private string _eventDefName;
|
||||||
|
private EventDef _def;
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
private Vector2 _scrollPosition = Vector2.zero;
|
||||||
|
private string _inputText = "";
|
||||||
|
private bool _scrollToBottom = true;
|
||||||
|
private int _lastHistoryCount = -1;
|
||||||
|
private float _lastUsedWidth = -1f;
|
||||||
|
private List<CachedMessage> _cachedMessages = new List<CachedMessage>();
|
||||||
|
private float _cachedTotalHeight = 0f;
|
||||||
|
|
||||||
|
private class CachedMessage
|
||||||
|
{
|
||||||
|
public string role;
|
||||||
|
public string message;
|
||||||
|
public string displayText;
|
||||||
|
public float height;
|
||||||
|
public float yOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// HUD / Minimized State
|
||||||
|
private bool _isMinimized = false;
|
||||||
|
private int _unreadCount = 0;
|
||||||
|
private Vector2 _expandedSize;
|
||||||
|
private Vector2 _minimizedSize = new Vector2(180f, 40f);
|
||||||
|
private Vector2? _initialPosition = null;
|
||||||
|
|
||||||
|
// Layout Constants
|
||||||
|
private const float HeaderHeight = 50f;
|
||||||
|
private const float FooterHeight = 50f;
|
||||||
|
private const float AvatarSize = 40f;
|
||||||
|
private const float BubblePadding = 10f;
|
||||||
|
private const float MessageSpacing = 15f;
|
||||||
|
private const float MaxBubbleWidthRatio = 0.75f;
|
||||||
|
|
||||||
|
public Overlay_WulaLink(EventDef def)
|
||||||
|
{
|
||||||
|
this._def = def;
|
||||||
|
this._eventDefName = def.defName;
|
||||||
|
|
||||||
|
// Window Properties (Floating, Non-Modal)
|
||||||
|
this.layer = WindowLayer.GameUI;
|
||||||
|
this.forcePause = false;
|
||||||
|
this.absorbInputAroundWindow = false;
|
||||||
|
this.closeOnClickedOutside = false;
|
||||||
|
this.closeOnAccept = false; // 防止 Enter 键误关闭
|
||||||
|
this.closeOnCancel = false; // 防止 Esc 键误关闭
|
||||||
|
this.doWindowBackground = false; // We draw our own
|
||||||
|
this.drawShadow = false; // 禁用阴影
|
||||||
|
this.draggable = true;
|
||||||
|
this.resizeable = true;
|
||||||
|
this.preventCameraMotion = false;
|
||||||
|
|
||||||
|
// Initial Size (Phone-like)
|
||||||
|
_expandedSize = new Vector2(380f, 600f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Vector2 InitialSize => _isMinimized ? _minimizedSize : _expandedSize;
|
||||||
|
|
||||||
|
public void SetInitialPosition(float x, float y)
|
||||||
|
{
|
||||||
|
_initialPosition = new Vector2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void SetInitialSizeAndPosition()
|
||||||
|
{
|
||||||
|
base.SetInitialSizeAndPosition();
|
||||||
|
// Override position if we have a saved position
|
||||||
|
if (_initialPosition.HasValue)
|
||||||
|
{
|
||||||
|
windowRect.x = _initialPosition.Value.x;
|
||||||
|
windowRect.y = _initialPosition.Value.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ToggleMinimize()
|
||||||
|
{
|
||||||
|
_isMinimized = !_isMinimized;
|
||||||
|
_unreadCount = 0; // Reset on toggle? Or only on expand? Let's say expand.
|
||||||
|
|
||||||
|
if (_isMinimized)
|
||||||
|
{
|
||||||
|
// 最小化时保持当前位置,只调整大小
|
||||||
|
windowRect.width = _minimizedSize.x;
|
||||||
|
windowRect.height = _minimizedSize.y;
|
||||||
|
// 确保不超出屏幕边界
|
||||||
|
windowRect.x = Mathf.Clamp(windowRect.x, 0, Verse.UI.screenWidth - _minimizedSize.x);
|
||||||
|
windowRect.y = Mathf.Clamp(windowRect.y, 0, Verse.UI.screenHeight - _minimizedSize.y);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 展开时仅恢复大小,不强制居中
|
||||||
|
windowRect.width = _expandedSize.x;
|
||||||
|
windowRect.height = _expandedSize.y;
|
||||||
|
// 确保展开后不超出右侧边界(简单边界检查)
|
||||||
|
if (windowRect.xMax > Verse.UI.screenWidth)
|
||||||
|
{
|
||||||
|
windowRect.x = Verse.UI.screenWidth - windowRect.width - 20f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Expand()
|
||||||
|
{
|
||||||
|
if (_isMinimized) ToggleMinimize();
|
||||||
|
Find.WindowStack.Notify_ManuallySetFocus(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void PreOpen()
|
||||||
|
{
|
||||||
|
base.PreOpen();
|
||||||
|
// Connect to Core
|
||||||
|
_core = Find.World.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (_core != null)
|
||||||
|
{
|
||||||
|
_core.InitializeConversation(_eventDefName);
|
||||||
|
_core.OnMessageReceived += OnMessageReceived;
|
||||||
|
_core.OnThinkingStateChanged += OnThinkingStateChanged;
|
||||||
|
_core.OnExpressionChanged += OnExpressionChanged;
|
||||||
|
_core.SetOverlayWindowState(true, _eventDefName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void PostClose()
|
||||||
|
{
|
||||||
|
base.PostClose();
|
||||||
|
if (_core != null)
|
||||||
|
{
|
||||||
|
_core.OnMessageReceived -= OnMessageReceived;
|
||||||
|
_core.OnThinkingStateChanged -= OnThinkingStateChanged;
|
||||||
|
_core.OnExpressionChanged -= OnExpressionChanged;
|
||||||
|
// Save position before closing
|
||||||
|
_core.SetOverlayWindowState(false, null, windowRect.x, windowRect.y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMessageReceived(string msg)
|
||||||
|
{
|
||||||
|
_scrollToBottom = true;
|
||||||
|
if (_isMinimized)
|
||||||
|
{
|
||||||
|
_unreadCount++;
|
||||||
|
// Spawn Notification Bubble
|
||||||
|
Find.WindowStack.Add(new Overlay_WulaLink_Notification(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnThinkingStateChanged(bool thinking)
|
||||||
|
{
|
||||||
|
// Trigger repaint or animation update if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override float Margin => 0f;
|
||||||
|
|
||||||
|
private void OnExpressionChanged(int id)
|
||||||
|
{
|
||||||
|
// Repaint happens next frame
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DoWindowContents(Rect inRect)
|
||||||
|
{
|
||||||
|
this.resizeable = !_isMinimized;
|
||||||
|
|
||||||
|
if (_isMinimized)
|
||||||
|
{
|
||||||
|
// 强制同步 windowRect 到设计尺寸 (Margin 为 0 时直接匹配)
|
||||||
|
if (windowRect.width != _minimizedSize.x || windowRect.height != _minimizedSize.y)
|
||||||
|
{
|
||||||
|
windowRect.width = _minimizedSize.x;
|
||||||
|
windowRect.height = _minimizedSize.y;
|
||||||
|
}
|
||||||
|
DrawMinimized(inRect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw Main Background (Whole Window)
|
||||||
|
Widgets.DrawBoxSolid(inRect, WulaLinkStyles.BackgroundColor);
|
||||||
|
GUI.color = new Color(0.8f, 0.8f, 0.8f);
|
||||||
|
Widgets.DrawBox(inRect, 1); // Border
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// Areas
|
||||||
|
Rect headerRect = new Rect(0, 0, inRect.width, HeaderHeight);
|
||||||
|
Rect footerRect = new Rect(0, inRect.height - FooterHeight, inRect.width, FooterHeight);
|
||||||
|
Rect contextRect = new Rect(0, inRect.height - FooterHeight - 30f, inRect.width, 30f); // Context Bar
|
||||||
|
Rect bodyRect = new Rect(0, HeaderHeight, inRect.width, inRect.height - HeaderHeight - FooterHeight - 30f);
|
||||||
|
|
||||||
|
// Draw Components
|
||||||
|
DrawHeader(headerRect);
|
||||||
|
DrawMessageList(bodyRect);
|
||||||
|
DrawContextBar(contextRect);
|
||||||
|
DrawFooter(footerRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawMinimized(Rect rect)
|
||||||
|
{
|
||||||
|
// AI 核心挂件背景
|
||||||
|
Widgets.DrawBoxSolid(rect, new Color(0.1f, 0.1f, 0.1f, 0.9f));
|
||||||
|
GUI.color = WulaLinkStyles.HeaderColor;
|
||||||
|
Widgets.DrawBox(rect, 2);
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// 左侧:大型方形头像
|
||||||
|
float avaSize = rect.height - 16f;
|
||||||
|
Rect avatarRect = new Rect(8f, 8f, avaSize, avaSize);
|
||||||
|
|
||||||
|
int expId = _core?.ExpressionId ?? 1;
|
||||||
|
string portraitPath = "Wula/Storyteller/WULA_Legion_TINY";
|
||||||
|
Texture2D portrait = ContentFinder<Texture2D>.Get(portraitPath, false);
|
||||||
|
if (portrait != null)
|
||||||
|
{
|
||||||
|
GUI.DrawTexture(avatarRect, portrait, ScaleMode.ScaleToFit);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Widgets.DrawBoxSolid(avatarRect, new Color(0.3f, 0.3f, 0.3f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右侧:状态展示
|
||||||
|
float rightContentX = avatarRect.xMax + 12f;
|
||||||
|
float btnWidth = 30f;
|
||||||
|
|
||||||
|
// Status Info
|
||||||
|
string status = _core.IsThinking ? "Thinking..." : "Standby";
|
||||||
|
Color statusColor = _core.IsThinking ? Color.yellow : Color.green;
|
||||||
|
|
||||||
|
// 绘制状态文字
|
||||||
|
Rect textRect = new Rect(rightContentX, 0, rect.width - rightContentX - btnWidth - 5f, rect.height);
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
GUI.color = statusColor;
|
||||||
|
Widgets.Label(textRect, _core.IsThinking ? BuildThinkingStatus() : "Standby");
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// 右侧:小巧的展开按钮
|
||||||
|
Rect expandBtnRect = new Rect(rect.width - 32f, (rect.height - 25f) / 2f, 25f, 25f);
|
||||||
|
if (DrawHeaderButton(expandBtnRect, "+"))
|
||||||
|
{
|
||||||
|
ToggleMinimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未读标签角标 (头像右上角)
|
||||||
|
if (_unreadCount > 0)
|
||||||
|
{
|
||||||
|
float badgeSize = 18f;
|
||||||
|
Rect badgeRect = new Rect(avatarRect.xMax - 10f, avatarRect.y - 5f, badgeSize, badgeSize);
|
||||||
|
GUI.DrawTexture(badgeRect, BaseContent.WhiteTex);
|
||||||
|
GUI.color = Color.red;
|
||||||
|
Widgets.DrawBoxSolid(badgeRect.ContractedBy(1f), Color.red);
|
||||||
|
GUI.color = Color.white;
|
||||||
|
Text.Font = GameFont.Tiny;
|
||||||
|
Text.Anchor = TextAnchor.MiddleCenter;
|
||||||
|
Widgets.Label(badgeRect, _unreadCount.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawContextBar(Rect rect)
|
||||||
|
{
|
||||||
|
// Context Awareness
|
||||||
|
Widgets.DrawBoxSolid(rect, new Color(0.15f, 0.15f, 0.15f, 1f));
|
||||||
|
GUI.color = Color.grey;
|
||||||
|
Widgets.DrawLineHorizontal(rect.x, rect.y, rect.width);
|
||||||
|
|
||||||
|
string contextInfo = "Context: None";
|
||||||
|
if (Find.Selector.SingleSelectedThing != null)
|
||||||
|
{
|
||||||
|
contextInfo = $"Context: [{Find.Selector.SingleSelectedThing.LabelCap}]";
|
||||||
|
}
|
||||||
|
else if (Find.Selector.SelectedObjects.Count > 1)
|
||||||
|
{
|
||||||
|
contextInfo = $"Context: {Find.Selector.SelectedObjects.Count} objects selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
Text.Font = GameFont.Tiny;
|
||||||
|
Widgets.Label(new Rect(rect.x + 10f, rect.y, rect.width - 20f, rect.height), contextInfo);
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
GUI.color = Color.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawHeader(Rect rect)
|
||||||
|
{
|
||||||
|
// Header BG
|
||||||
|
Widgets.DrawBoxSolid(rect, WulaLinkStyles.HeaderColor);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
Text.Font = GameFont.Medium;
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
GUI.color = Color.white;
|
||||||
|
Rect titleRect = rect;
|
||||||
|
titleRect.x += 10f;
|
||||||
|
Widgets.Label(titleRect, _def.characterName ?? "MomoTalk");
|
||||||
|
|
||||||
|
// Header Icons (Minimize/Close) - 自定义样式
|
||||||
|
Rect closeRect = new Rect(rect.width - 35f, 10f, 25f, 25f);
|
||||||
|
Rect minRect = new Rect(rect.width - 65f, 10f, 25f, 25f);
|
||||||
|
|
||||||
|
// 最小化按钮
|
||||||
|
if (DrawHeaderButton(minRect, "-"))
|
||||||
|
{
|
||||||
|
ToggleMinimize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭按钮
|
||||||
|
if (DrawHeaderButton(closeRect, "X"))
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
GUI.color = Color.white;
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DrawHeaderButton(Rect rect, string label)
|
||||||
|
{
|
||||||
|
bool isMouseOver = Mouse.IsOver(rect);
|
||||||
|
Color buttonColor = isMouseOver
|
||||||
|
? new Color(0.6f, 0.3f, 0.3f, 1f) // Hover: 深红色
|
||||||
|
: new Color(0.4f, 0.2f, 0.2f, 0.8f); // Normal: 暗红色
|
||||||
|
Color textColor = isMouseOver ? Color.white : new Color(0.9f, 0.9f, 0.9f);
|
||||||
|
|
||||||
|
var originalColor = GUI.color;
|
||||||
|
var originalAnchor = Text.Anchor;
|
||||||
|
var originalFont = Text.Font;
|
||||||
|
|
||||||
|
GUI.color = buttonColor;
|
||||||
|
Widgets.DrawBoxSolid(rect, buttonColor);
|
||||||
|
|
||||||
|
GUI.color = textColor;
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
Text.Anchor = TextAnchor.MiddleCenter;
|
||||||
|
Widgets.Label(rect, label);
|
||||||
|
|
||||||
|
GUI.color = originalColor;
|
||||||
|
Text.Anchor = originalAnchor;
|
||||||
|
Text.Font = originalFont;
|
||||||
|
|
||||||
|
return Widgets.ButtonInvisible(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCacheIfNeeded(float width)
|
||||||
|
{
|
||||||
|
var history = _core?.GetHistorySnapshot();
|
||||||
|
if (history == null) return;
|
||||||
|
|
||||||
|
// 如果宽度没变且条数没变,就不重算
|
||||||
|
if (Math.Abs(_lastUsedWidth - width) < 0.1f && history.Count == _lastHistoryCount)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastUsedWidth = width;
|
||||||
|
_lastHistoryCount = history.Count;
|
||||||
|
_cachedMessages.Clear();
|
||||||
|
_cachedTotalHeight = 0f;
|
||||||
|
float reducedSpacing = 8f;
|
||||||
|
float curY = 10f;
|
||||||
|
|
||||||
|
foreach (var msg in history)
|
||||||
|
{
|
||||||
|
// Filter logic
|
||||||
|
if (msg.role == "tool" || msg.role == "toolcall") continue;
|
||||||
|
if (msg.role == "system" && !Prefs.DevMode) continue;
|
||||||
|
|
||||||
|
// Hide auto-commentary system messages (user-side) from display
|
||||||
|
if (msg.role == "user" && msg.message.Contains("[AUTO_COMMENTARY]")) continue;
|
||||||
|
|
||||||
|
string displayText = msg.message;
|
||||||
|
if (msg.role == "assistant")
|
||||||
|
{
|
||||||
|
displayText = StripXmlTags(msg.message)?.Trim() ?? "";
|
||||||
|
}
|
||||||
|
else if (msg.role == "user")
|
||||||
|
{
|
||||||
|
displayText = AIIntelligenceCore.StripContextInfo(msg.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(displayText)) continue;
|
||||||
|
|
||||||
|
float h = CalcMessageHeight(displayText, width);
|
||||||
|
|
||||||
|
_cachedMessages.Add(new CachedMessage
|
||||||
|
{
|
||||||
|
role = msg.role,
|
||||||
|
message = msg.message,
|
||||||
|
displayText = displayText,
|
||||||
|
height = h,
|
||||||
|
yOffset = curY
|
||||||
|
});
|
||||||
|
|
||||||
|
curY += h + reducedSpacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedTotalHeight = curY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawMessageList(Rect rect)
|
||||||
|
{
|
||||||
|
float width = rect.width - 26f; // Scrollbar space
|
||||||
|
UpdateCacheIfNeeded(width);
|
||||||
|
|
||||||
|
float totalContentHeight = _cachedTotalHeight;
|
||||||
|
if (_core != null && _core.IsThinking)
|
||||||
|
{
|
||||||
|
totalContentHeight += 40f;
|
||||||
|
}
|
||||||
|
|
||||||
|
Rect viewRect = new Rect(0, 0, width, totalContentHeight);
|
||||||
|
|
||||||
|
if (_scrollToBottom)
|
||||||
|
{
|
||||||
|
_scrollPosition.y = totalContentHeight - rect.height;
|
||||||
|
_scrollToBottom = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widgets.BeginScrollView(rect, ref _scrollPosition, viewRect);
|
||||||
|
|
||||||
|
// 虚拟化渲染:只绘制在窗口内的消息
|
||||||
|
float viewTop = _scrollPosition.y;
|
||||||
|
float viewBottom = _scrollPosition.y + rect.height;
|
||||||
|
|
||||||
|
foreach (var entry in _cachedMessages)
|
||||||
|
{
|
||||||
|
// 检查是否在可见范围内 (略微预留 Buffer)
|
||||||
|
if (entry.yOffset + entry.height < viewTop - 100f) continue;
|
||||||
|
if (entry.yOffset > viewBottom + 100f) break;
|
||||||
|
|
||||||
|
Rect msgRect = new Rect(0, entry.yOffset, width, entry.height);
|
||||||
|
|
||||||
|
if (entry.role == "user")
|
||||||
|
{
|
||||||
|
DrawSenseiMessage(msgRect, entry.displayText);
|
||||||
|
}
|
||||||
|
else if (entry.role == "assistant")
|
||||||
|
{
|
||||||
|
DrawStudentMessage(msgRect, entry.displayText);
|
||||||
|
}
|
||||||
|
else if (entry.role == "tool" || entry.role == "toolcall")
|
||||||
|
{
|
||||||
|
DrawSystemMessage(msgRect, $"[Tool] {entry.message}");
|
||||||
|
}
|
||||||
|
else if (entry.role == "system")
|
||||||
|
{
|
||||||
|
DrawSystemMessage(msgRect, entry.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_core != null && _core.IsThinking)
|
||||||
|
{
|
||||||
|
float thinkingY = _cachedTotalHeight > 0 ? _cachedTotalHeight : 10f;
|
||||||
|
Rect thinkingRect = new Rect(0, thinkingY, width, 30f);
|
||||||
|
if (thinkingY + 30f >= viewTop && thinkingY <= viewBottom)
|
||||||
|
{
|
||||||
|
DrawThinkingIndicator(thinkingRect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widgets.EndScrollView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripXmlTags(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text)) return text;
|
||||||
|
// Remove XML tags with content: <tag>content</tag>
|
||||||
|
string stripped = System.Text.RegularExpressions.Regex.Replace(text, @"<(?!/?(i|b|color|size|material)\b)([a-zA-Z0-9_]+)[^>]*>.*?</\2>", "", System.Text.RegularExpressions.RegexOptions.Singleline);
|
||||||
|
// Remove self-closing tags: <tag/>
|
||||||
|
stripped = System.Text.RegularExpressions.Regex.Replace(stripped, @"<([a-zA-Z0-9_]+)[^>]*/?>", "");
|
||||||
|
return stripped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawFooter(Rect rect)
|
||||||
|
{
|
||||||
|
Widgets.DrawBoxSolid(rect, WulaLinkStyles.InputBarColor);
|
||||||
|
Widgets.DrawLineHorizontal(rect.x, rect.y, rect.width); // Top border
|
||||||
|
|
||||||
|
float padding = 8f;
|
||||||
|
float btnWidth = 40f;
|
||||||
|
float inputWidth = rect.width - btnWidth - (padding * 3);
|
||||||
|
|
||||||
|
Rect inputRect = new Rect(rect.x + padding, rect.y + padding, inputWidth, rect.height - (padding * 2));
|
||||||
|
Rect btnRect = new Rect(inputRect.xMax + padding, rect.y + padding, btnWidth, rect.height - (padding * 2));
|
||||||
|
|
||||||
|
// Input Field
|
||||||
|
string nextInput = Widgets.TextField(inputRect, _inputText);
|
||||||
|
if (nextInput != _inputText)
|
||||||
|
{
|
||||||
|
_inputText = nextInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Button (Simulate Enter key or Click)
|
||||||
|
bool enterPressed = (Event.current.type == EventType.KeyDown && (Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter) && GUI.GetNameOfFocusedControl() == "WulaInput");
|
||||||
|
|
||||||
|
bool sendClicked = DrawCustomButton(btnRect, ">", !string.IsNullOrWhiteSpace(_inputText));
|
||||||
|
if (sendClicked || enterPressed)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_inputText))
|
||||||
|
{
|
||||||
|
bool wasFocused = GUI.GetNameOfFocusedControl() == "WulaInput";
|
||||||
|
_core.SendUserMessage(_inputText);
|
||||||
|
_inputText = "";
|
||||||
|
if (wasFocused) GUI.FocusControl("WulaInput");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle focus for Enter key to work
|
||||||
|
if (Mouse.IsOver(inputRect) && Event.current.type == EventType.MouseDown)
|
||||||
|
{
|
||||||
|
GUI.FocusControl("WulaInput");
|
||||||
|
}
|
||||||
|
GUI.SetNextControlName("WulaInput");
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Message Rendering Helpers
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
private float CalcMessageHeight(string text, float containerWidth)
|
||||||
|
{
|
||||||
|
float maxBubbleWidth = containerWidth * MaxBubbleWidthRatio;
|
||||||
|
float effectiveWidth = maxBubbleWidth - AvatarSize - 20f;
|
||||||
|
Text.Font = WulaLinkStyles.MessageFont;
|
||||||
|
float textH = Text.CalcHeight(text, effectiveWidth - (BubblePadding * 2));
|
||||||
|
return Mathf.Max(textH + (BubblePadding * 2) + 8f, AvatarSize + 8f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSenseiMessage(Rect rect, string text)
|
||||||
|
{
|
||||||
|
// 用户消息 - 右对齐蓝色气泡
|
||||||
|
float maxBubbleWidth = rect.width * MaxBubbleWidthRatio;
|
||||||
|
Text.Font = WulaLinkStyles.MessageFont;
|
||||||
|
float textWidth = maxBubbleWidth - (BubblePadding * 2);
|
||||||
|
float textHeight = Text.CalcHeight(text, textWidth);
|
||||||
|
float bubbleHeight = textHeight + (BubblePadding * 2);
|
||||||
|
float bubbleWidth = Mathf.Min(Text.CalcSize(text).x + (BubblePadding * 2) + 10f, maxBubbleWidth);
|
||||||
|
|
||||||
|
Rect bubbleRect = new Rect(rect.xMax - bubbleWidth - 10f, rect.y, bubbleWidth, bubbleHeight);
|
||||||
|
|
||||||
|
// 绘制气泡背景 - 蓝色
|
||||||
|
Color bubbleColor = new Color(0.29f, 0.54f, 0.78f, 1f);
|
||||||
|
Widgets.DrawBoxSolid(bubbleRect, bubbleColor);
|
||||||
|
|
||||||
|
// 绘制文字
|
||||||
|
GUI.color = Color.white;
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
Widgets.Label(bubbleRect.ContractedBy(BubblePadding), text);
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawStudentMessage(Rect rect, string text)
|
||||||
|
{
|
||||||
|
// AI消息 - 左对齐带方形头像
|
||||||
|
float avatarX = 10f;
|
||||||
|
Rect avatarRect = new Rect(avatarX, rect.y, AvatarSize, AvatarSize);
|
||||||
|
|
||||||
|
// 绘制方形头像
|
||||||
|
int expId = _core?.ExpressionId ?? 1;
|
||||||
|
string portraitPath = "Wula/Storyteller/WULA_Legion_TINY";
|
||||||
|
|
||||||
|
Texture2D portrait = ContentFinder<Texture2D>.Get(portraitPath, false);
|
||||||
|
if (portrait != null)
|
||||||
|
{
|
||||||
|
GUI.DrawTexture(avatarRect, portrait, ScaleMode.ScaleToFit);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Widgets.DrawBoxSolid(avatarRect, Color.gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 气泡
|
||||||
|
float maxBubbleWidth = rect.width * MaxBubbleWidthRatio;
|
||||||
|
float bubbleX = avatarRect.xMax + 10f;
|
||||||
|
|
||||||
|
Text.Font = WulaLinkStyles.MessageFont;
|
||||||
|
float textWidth = maxBubbleWidth - AvatarSize - 20f;
|
||||||
|
float textHeight = Text.CalcHeight(text, textWidth - (BubblePadding * 2));
|
||||||
|
float bubbleHeight = textHeight + (BubblePadding * 2);
|
||||||
|
float bubbleWidth = Mathf.Min(Text.CalcSize(text).x + (BubblePadding * 2) + 10f, maxBubbleWidth - AvatarSize - 20f);
|
||||||
|
|
||||||
|
Rect bubbleRect = new Rect(bubbleX, rect.y, bubbleWidth, bubbleHeight);
|
||||||
|
|
||||||
|
// 绘制气泡背景 - 灰色
|
||||||
|
Color bubbleColor = new Color(0.85f, 0.85f, 0.87f, 1f);
|
||||||
|
Widgets.DrawBoxSolid(bubbleRect, bubbleColor);
|
||||||
|
|
||||||
|
// 绘制边框
|
||||||
|
GUI.color = new Color(0.6f, 0.6f, 0.65f, 1f);
|
||||||
|
Widgets.DrawBox(bubbleRect, 1);
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// 绘制文字
|
||||||
|
GUI.color = new Color(0.1f, 0.1f, 0.1f, 1f);
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
Widgets.Label(bubbleRect.ContractedBy(BubblePadding), text);
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
GUI.color = Color.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawSystemMessage(Rect rect, string text)
|
||||||
|
{
|
||||||
|
// Centered gray text or Pink box
|
||||||
|
if (text.Contains("[Tool]")) return; // Skip logic log in main view if needed, but here we draw minimal
|
||||||
|
|
||||||
|
GUI.color = Color.gray;
|
||||||
|
Text.Font = GameFont.Tiny;
|
||||||
|
Text.Anchor = TextAnchor.MiddleCenter;
|
||||||
|
Widgets.Label(rect, text);
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
GUI.color = Color.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildThinkingStatus()
|
||||||
|
{
|
||||||
|
if (_core == null) return "Thinking...";
|
||||||
|
float elapsedSeconds = Mathf.Max(0f, Time.realtimeSinceStartup - _core.ThinkingStartTime);
|
||||||
|
string elapsedText = elapsedSeconds.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return $"P.I.A is thinking... ({elapsedText}s Phase {_core.ThinkingPhaseIndex}/3)";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawThinkingIndicator(Rect rect)
|
||||||
|
{
|
||||||
|
Text.Anchor = TextAnchor.MiddleLeft;
|
||||||
|
GUI.color = Color.gray;
|
||||||
|
Rect iconRect = new Rect(rect.x + 60f, rect.y, 24f, 24f);
|
||||||
|
Rect labelRect = new Rect(iconRect.xMax + 5f, rect.y, 400f, 24f);
|
||||||
|
|
||||||
|
// Draw a simple box as thinking indicator if TexUI is missing
|
||||||
|
Widgets.DrawBoxSolid(iconRect, Color.gray);
|
||||||
|
Widgets.Label(labelRect, BuildThinkingStatus());
|
||||||
|
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
GUI.color = Color.white;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool DrawCustomButton(Rect rect, string label, bool isEnabled)
|
||||||
|
{
|
||||||
|
bool isMouseOver = Mouse.IsOver(rect);
|
||||||
|
Color originalColor = GUI.color;
|
||||||
|
TextAnchor originalAnchor = Text.Anchor;
|
||||||
|
GameFont originalFont = Text.Font;
|
||||||
|
|
||||||
|
Color buttonColor;
|
||||||
|
Color textColor;
|
||||||
|
if (!isEnabled)
|
||||||
|
{
|
||||||
|
buttonColor = Dialog_CustomDisplay.CustomButtonDisabledColor;
|
||||||
|
textColor = Dialog_CustomDisplay.CustomButtonTextDisabledColor;
|
||||||
|
}
|
||||||
|
else if (isMouseOver)
|
||||||
|
{
|
||||||
|
buttonColor = Dialog_CustomDisplay.CustomButtonHoverColor;
|
||||||
|
textColor = Dialog_CustomDisplay.CustomButtonTextHoverColor;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buttonColor = Dialog_CustomDisplay.CustomButtonNormalColor;
|
||||||
|
textColor = Dialog_CustomDisplay.CustomButtonTextNormalColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
GUI.color = buttonColor;
|
||||||
|
Widgets.DrawBoxSolid(rect, buttonColor);
|
||||||
|
Widgets.DrawBox(rect, 1);
|
||||||
|
|
||||||
|
GUI.color = textColor;
|
||||||
|
Text.Anchor = TextAnchor.MiddleCenter;
|
||||||
|
Text.Font = GameFont.Tiny;
|
||||||
|
Widgets.Label(rect.ContractedBy(4f), label);
|
||||||
|
|
||||||
|
GUI.color = originalColor;
|
||||||
|
Text.Anchor = originalAnchor;
|
||||||
|
Text.Font = originalFont;
|
||||||
|
|
||||||
|
if (!isEnabled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Widgets.ButtonInvisible(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
using RimWorld;
|
||||||
|
using WulaFallenEmpire.EventSystem.AI;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||||
|
{
|
||||||
|
public class Overlay_WulaLink_Notification : Window
|
||||||
|
{
|
||||||
|
private string _message;
|
||||||
|
private float _timeCreated;
|
||||||
|
private float _displayDuration;
|
||||||
|
private Vector2 _size = new Vector2(320f, 65f);
|
||||||
|
|
||||||
|
public override Vector2 InitialSize => _size;
|
||||||
|
|
||||||
|
public Overlay_WulaLink_Notification(string message)
|
||||||
|
{
|
||||||
|
_message = message;
|
||||||
|
_timeCreated = Time.realtimeSinceStartup;
|
||||||
|
|
||||||
|
// 动态时长:最低 10 秒,每多一个字加一秒(即时长 = 字数,但不少于 10 秒)
|
||||||
|
_displayDuration = Mathf.Max(10f, (float)(message?.Length ?? 0));
|
||||||
|
|
||||||
|
// Calculate adaptive size (Instance-based)
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
float textWidth = Text.CalcSize(message).x;
|
||||||
|
|
||||||
|
// Limit max width to 500f, wrapping for long text
|
||||||
|
if (textWidth > 450f)
|
||||||
|
{
|
||||||
|
float width = 550f; // Wider
|
||||||
|
float height = Text.CalcHeight(message, width - 65f) + 65f; // Extra buffer for bottom lines
|
||||||
|
_size = new Vector2(width, height);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_size = new Vector2(Mathf.Max(250f, textWidth + 85f), 85f); // Taller base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window properties - ensure no input blocking
|
||||||
|
this.layer = WindowLayer.Super;
|
||||||
|
this.closeOnClickedOutside = false;
|
||||||
|
this.forcePause = false;
|
||||||
|
this.absorbInputAroundWindow = false;
|
||||||
|
this.doWindowBackground = false;
|
||||||
|
this.drawShadow = false;
|
||||||
|
this.focusWhenOpened = false; // Don't steal focus
|
||||||
|
this.preventCameraMotion = false; // Allow WASD camera control
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void SetInitialSizeAndPosition()
|
||||||
|
{
|
||||||
|
float startX = 20f;
|
||||||
|
float startY = 20f;
|
||||||
|
float spacing = 5f;
|
||||||
|
|
||||||
|
// Find all existing notifications and find the bottom-most Y position
|
||||||
|
float maxY = startY;
|
||||||
|
var windows = Find.WindowStack.Windows;
|
||||||
|
for (int i = 0; i < windows.Count; i++)
|
||||||
|
{
|
||||||
|
if (windows[i] is Overlay_WulaLink_Notification other && other != this)
|
||||||
|
{
|
||||||
|
maxY = Mathf.Max(maxY, other.windowRect.yMax + spacing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowRect = new Rect(startX, maxY, InitialSize.x, InitialSize.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void DoWindowContents(Rect inRect)
|
||||||
|
{
|
||||||
|
// Auto Close after real-time duration
|
||||||
|
if (Time.realtimeSinceStartup > _timeCreated + _displayDuration)
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Styling (Legion Style)
|
||||||
|
Widgets.DrawBoxSolid(inRect, new Color(0.12f, 0.05f, 0.05f, 0.95f)); // Dark blood red background
|
||||||
|
GUI.color = WulaLinkStyles.HeaderColor;
|
||||||
|
Widgets.DrawBox(inRect, 2);
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// 恢复刚才的视觉风格 (Content areas)
|
||||||
|
Rect iconRect = new Rect(10f, 12f, 25f, 25f);
|
||||||
|
Rect titleRect = new Rect(45f, 5f, inRect.width - 60f, 20f);
|
||||||
|
Rect textRect = new Rect(45f, 25f, inRect.width - 55f, inRect.height - 30f);
|
||||||
|
|
||||||
|
// 绘制视觉装饰 (装饰性图标和标题)
|
||||||
|
Widgets.DrawBoxSolid(iconRect, WulaLinkStyles.HeaderColor);
|
||||||
|
GUI.color = Color.white;
|
||||||
|
Text.Anchor = TextAnchor.MiddleCenter;
|
||||||
|
Text.Font = GameFont.Tiny;
|
||||||
|
Widgets.Label(iconRect, "!");
|
||||||
|
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
GUI.color = Color.yellow;
|
||||||
|
Widgets.Label(titleRect, "WULA LINK :: MESSAGE");
|
||||||
|
GUI.color = Color.white;
|
||||||
|
|
||||||
|
// Body Text
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
Widgets.Label(textRect, _message);
|
||||||
|
|
||||||
|
// Hover and Click Logic (全窗口交互,无需按钮)
|
||||||
|
if (Mouse.IsOver(inRect))
|
||||||
|
{
|
||||||
|
Widgets.DrawHighlight(inRect);
|
||||||
|
// 0 = Left Click, 1 = Right Click
|
||||||
|
if (Event.current.type == EventType.MouseDown)
|
||||||
|
{
|
||||||
|
if (Event.current.button == 0) // Left click: Open
|
||||||
|
{
|
||||||
|
OpenWulaLink();
|
||||||
|
Close();
|
||||||
|
Event.current.Use();
|
||||||
|
}
|
||||||
|
else if (Event.current.button == 1) // Right click: Close
|
||||||
|
{
|
||||||
|
Close();
|
||||||
|
Event.current.Use();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text.Anchor = TextAnchor.UpperLeft;
|
||||||
|
Text.Font = GameFont.Small;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenWulaLink()
|
||||||
|
{
|
||||||
|
var existing = Find.WindowStack.WindowOfType<Overlay_WulaLink>();
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Expand();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Fallback: If no overlay exists, try to find EventDef or just show message
|
||||||
|
Messages.Message("Wula_AI_Notification_ClickTips".Translate(), MessageTypeDefOf.NeutralEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs
Normal file
53
Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using UnityEngine;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.UI
|
||||||
|
{
|
||||||
|
[StaticConstructorOnStartup]
|
||||||
|
public static class WulaLinkStyles
|
||||||
|
{
|
||||||
|
// =================================================================================
|
||||||
|
// Colors
|
||||||
|
// =================================================================================
|
||||||
|
|
||||||
|
// Background Colors - Semi-transparent red theme
|
||||||
|
public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f);
|
||||||
|
public static readonly Color HeaderColor = new Color(0.5f, 0.2f, 0.2f, 1f);
|
||||||
|
public static readonly Color InputBarColor = new Color(0.16f, 0.08f, 0.08f, 0.95f);
|
||||||
|
public static readonly Color SystemAccentColor = new Color(0.6f, 0.2f, 0.2f, 1f);
|
||||||
|
|
||||||
|
// Message Bubble Colors - Matching Dialog_CustomDisplay button style
|
||||||
|
public static readonly Color SenseiBubbleColor = new Color(0.5f, 0.2f, 0.2f, 1f);
|
||||||
|
public static readonly Color StudentBubbleColor = new Color(0.15f, 0.15f, 0.2f, 0.9f);
|
||||||
|
public static readonly Color StudentStrokeColor = new Color(0.6f, 0.3f, 0.3f, 1f);
|
||||||
|
|
||||||
|
// Text Colors
|
||||||
|
public static readonly Color TextColor = new Color32(220, 220, 220, 255);
|
||||||
|
public static readonly Color SenseiTextColor = new Color32(240, 240, 240, 255);
|
||||||
|
public static readonly Color StudentTextColor = new Color32(230, 230, 230, 255);
|
||||||
|
public static readonly Color InputBorderColor = new Color32(60, 60, 60, 255);
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Fonts
|
||||||
|
// =================================================================================
|
||||||
|
public static GameFont MessageFont = GameFont.Small;
|
||||||
|
public static GameFont HeaderFont = GameFont.Medium;
|
||||||
|
|
||||||
|
// =================================================================================
|
||||||
|
// Textures
|
||||||
|
// =================================================================================
|
||||||
|
public static readonly Texture2D TexCircleMask;
|
||||||
|
public static readonly Texture2D TexSendIcon;
|
||||||
|
public static readonly Texture2D TexPaperClip;
|
||||||
|
public static readonly Texture2D TexWhite;
|
||||||
|
|
||||||
|
static WulaLinkStyles()
|
||||||
|
{
|
||||||
|
TexCircleMask = ContentFinder<Texture2D>.Get("Base/UI/WulaLink/CircleMask", false);
|
||||||
|
TexSendIcon = ContentFinder<Texture2D>.Get("Base/UI/WulaLink/Send", false);
|
||||||
|
TexPaperClip = ContentFinder<Texture2D>.Get("Base/UI/WulaLink/Clip", false);
|
||||||
|
TexWhite = SolidColorMaterials.NewSolidColorTexture(Color.white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,15 @@ namespace WulaFallenEmpire
|
|||||||
EventDef eventDef = DefDatabase<EventDef>.GetNamed(defName, false);
|
EventDef eventDef = DefDatabase<EventDef>.GetNamed(defName, false);
|
||||||
if (eventDef != null)
|
if (eventDef != null)
|
||||||
{
|
{
|
||||||
Find.WindowStack.Add(new Dialog_AIConversation(eventDef));
|
var existing = Find.WindowStack.WindowOfType<Dialog_AIConversation>();
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
Find.WindowStack.Notify_ManuallySetFocus(existing);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Find.WindowStack.Add(new Dialog_AIConversation(eventDef));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -30,4 +38,4 @@ namespace WulaFallenEmpire
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using HarmonyLib;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire
|
||||||
|
{
|
||||||
|
// 修复 Wula 种族尸体可能缺少 CompRottable 导致 ResurrectionUtility.TryResurrectWithSideEffects 崩溃的问题
|
||||||
|
[HarmonyPatch]
|
||||||
|
public static class ResurrectionCrashFix
|
||||||
|
{
|
||||||
|
private static MethodInfo TargetMethod()
|
||||||
|
{
|
||||||
|
return AccessTools.Method(typeof(ResurrectionUtility), "TryResurrectWithSideEffects");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HarmonyPrefix]
|
||||||
|
public static bool Prefix(Pawn pawn)
|
||||||
|
{
|
||||||
|
if (pawn == null) return true;
|
||||||
|
|
||||||
|
// 只针对 Wula 种族(防止误伤其他 Pawn)
|
||||||
|
if (pawn.def == null || pawn.def.defName != "WulaSpecies")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查尸体是否缺少必要的腐烂组件
|
||||||
|
// 原版 TryResurrectWithSideEffects 会无条件访问 corpse.GetComp<CompRottable>().RotProgress
|
||||||
|
if (pawn.Corpse != null && pawn.Corpse.GetComp<CompRottable>() == null)
|
||||||
|
{
|
||||||
|
if (Prefs.DevMode)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[WulaFix] Intercepted crash: {pawn.LabelShort} corpse missing CompRottable. Performing safe resurrection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 直接调用不带副作用的复活方法
|
||||||
|
ResurrectionUtility.TryResurrect(pawn);
|
||||||
|
|
||||||
|
return false; // 阻止原版方法执行
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Wula AI x Gemini Integration: Technical Handover Document
|
||||||
|
|
||||||
|
**Version**: 1.0
|
||||||
|
**Date**: 2025-12-28
|
||||||
|
**Author**: AntiGravity (Agent)
|
||||||
|
**Target Audience**: Codex / Future Maintainers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
This document details the specific challenges, bugs, and architectural decisions made to stabilize the integration between **WulaFallenEmpire** (RimWorld Mod) and **Gemini 3 / OpenAI-Compatible Agents**. It specifically addresses "stubborn" issues related to API format compliance, JSON construction, and multimodal context persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical Issues & Fixes
|
||||||
|
|
||||||
|
### 2.1 The "Streaming" Trap (SSE Handling)
|
||||||
|
**Symptoms**: AI responses were truncated (e.g., only "Comman" displayed instead of "Commander").
|
||||||
|
**Root Cause**: Even when `stream: false` is explicitly requested in the payload, some API providers (or reverse proxies wrapping Gemini) force a **Server-Sent Events (SSE)** response format (`data: {...}`). The original client only parsed the first line.
|
||||||
|
**Fix Implementation**:
|
||||||
|
- **File**: `SimpleAIClient.cs` -> `ExtractContent`
|
||||||
|
- **Logic**: Inspects response for `data:` prefix. If found, it iterates through **ALL** lines, strips `data:`, parses individual JSON chunks, and aggregates the `choices[0].delta.content` into a single string.
|
||||||
|
- **Defense**: This ensures compatibility with both standard JSON responses and forced Stream responses.
|
||||||
|
|
||||||
|
### 2.2 The "Trailing Comma" Crash (HTTP 400)
|
||||||
|
**Symptoms**: AI actions failed silently or returned `400 Bad Request`.
|
||||||
|
**Root Cause**: In `SimpleAIClient.cs`, the JSON payload construction loop had a logic flaw.
|
||||||
|
- When filtering out `toolcall` roles inside the loop, the index `i` check `(i < messages.Count - 1)` failed to account for skipped items, leaving a trailing comma after the last valid item: `[{"role":"user",...},]` -> **Invalid JSON**.
|
||||||
|
- Additionally, if the message list was empty (or all items filtered), the comma after the System Message remained: `[{"role":"system",...},]` -> **Invalid JSON**.
|
||||||
|
**Fix Implementation**:
|
||||||
|
- **Logic**:
|
||||||
|
1. Pre-filter `validMessages` into a separate list **before** JSON construction.
|
||||||
|
2. Only append the comma after the System Message `if (validMessages.Count > 0)`.
|
||||||
|
3. Iterate `validMessages` to guarantee correct comma placement between items.
|
||||||
|
|
||||||
|
### 2.3 Gemini 3's "JSON Obsession" & The Dual-Defense Strategy
|
||||||
|
**Symptoms**: Gemini 3 Flash Preview ignores System Prompts demanding XML (`<visual_click>`) and persistently outputs JSON (`[{"action":"click"...}]`).
|
||||||
|
**Root Cause**: RLHF tuning of newer models biases them heavily towards standard JSON tool-calling schemas, overriding prompt constraints.
|
||||||
|
**Strategy**: **"Principled Compromise"** (Double Defense).
|
||||||
|
1. **Layer 1 (Prompt)**: Explicitly list JSON and Markdown as `INVALID EXAMPLES` in `AIIntelligenceCore.cs`. This discourages compliance-oriented models from using them.
|
||||||
|
2. **Layer 2 (Code Fallback)**: If XML regex fails, the system attempts to parse **Markdown JSON Blocks** (` ```json ... ``` `).
|
||||||
|
- **File**: `AIIntelligenceCore.cs` -> `ExecuteXmlToolsForPhase`
|
||||||
|
- **Logic**: Extracts `point` arrays `[x, y]` and synthesizes a valid `<visual_click>` XML tag internally.
|
||||||
|
|
||||||
|
### 2.4 The Coordinate System Mess
|
||||||
|
**Symptoms**: Clicks occurred off-screen or at (0,0).
|
||||||
|
**Root Cause**:
|
||||||
|
- Gemini 3 often returns coordinates in a **0-1000** scale (e.g., `[115, 982]`).
|
||||||
|
- Previous logic used `Screen.width` normalization, which is **not thread-safe** and caused crashes or incorrect scaling if the assumption was pixel coordinates.
|
||||||
|
**Fix Implementation**:
|
||||||
|
- **Logic**: In the JSON Fallback parser, if `x > 1` or `y > 1`, divide by **1000.0f**. This standardizes coordinates to the mod's required 0-1 proportional format.
|
||||||
|
|
||||||
|
### 2.5 Visual Context Persistence (The "Blind Reply" Bug)
|
||||||
|
**Symptoms**: AI acted correctly (Phase 2) but "forgot" what it saw when replying to the user (Phase 3), or hallucinated headers.
|
||||||
|
**Root Cause**:
|
||||||
|
- Phase 3 (Reply) sends a message history ending with System Tool Results.
|
||||||
|
- `SimpleAIClient` only attached the image if the **very last message** was from `user`.
|
||||||
|
- Thus, in Phase 3, the image was dropped, rendering the AI blind.
|
||||||
|
**Fix Implementation**:
|
||||||
|
- **File**: `SimpleAIClient.cs`
|
||||||
|
- **Logic**: Instead of checking the last index, the code now searches **backwards** for the `lastUserIndex`. The image is attached to that specific user message, regardless of how many system messages follow it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Future Maintenance Guide
|
||||||
|
|
||||||
|
### If Gemini 4 Breaks Format Again:
|
||||||
|
1. **Check `SimpleAIClient.cs`**: Ensure the JSON parser handles whatever new wrapper they add (e.g., nested `candidates`).
|
||||||
|
2. **Check `AIIntelligenceCore.cs`**: If it invents a new tool format (e.g., YAML), add a regex parser in `ExecuteXmlToolsForPhase` similar to the JSON Fallback. **Do not fight the model; adapt to it.**
|
||||||
|
|
||||||
|
### If API Errors Return:
|
||||||
|
1. Enable `DevMode` in RimWorld.
|
||||||
|
2. Check `Player.log` for `[WulaAI] Request Payload`.
|
||||||
|
3. Copy the payload to a JSON Validator. **Look for trailing commas.**
|
||||||
|
|
||||||
|
### Adding New Visual Tools:
|
||||||
|
1. Define tool in `Tools/`.
|
||||||
|
2. Update `GetToolSystemInstruction` whitelist.
|
||||||
|
3. **Crucially**: If the tool helps with **Action** (Silent), ensure `GetPhaseInstruction` enforces silence. If it helps with **Reply** (Descriptive), ensure it runs in Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Handover.**
|
||||||
@@ -74,6 +74,14 @@
|
|||||||
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.TextRenderingModule.dll</HintPath>
|
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.TextRenderingModule.dll</HintPath>
|
||||||
<Private>False</Private>
|
<Private>False</Private>
|
||||||
</Reference>
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.ImageConversionModule">
|
||||||
|
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.ImageConversionModule.dll</HintPath>
|
||||||
|
<Private>False</Private>
|
||||||
|
</Reference>
|
||||||
|
<Reference Include="UnityEngine.ScreenCaptureModule">
|
||||||
|
<HintPath>..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.ScreenCaptureModule.dll</HintPath>
|
||||||
|
<Private>False</Private>
|
||||||
|
</Reference>
|
||||||
<Compile Include="**\*.cs" Exclude="bin\**;obj\**" />
|
<Compile Include="**\*.cs" Exclude="bin\**;obj\**" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ namespace WulaFallenEmpire
|
|||||||
public class WulaFallenEmpireMod : Mod
|
public class WulaFallenEmpireMod : Mod
|
||||||
{
|
{
|
||||||
public static WulaFallenEmpireSettings settings;
|
public static WulaFallenEmpireSettings settings;
|
||||||
|
public static bool _showApiKey = false;
|
||||||
|
public static bool _showVlmApiKey = false;
|
||||||
private string _maxContextTokensBuffer;
|
private string _maxContextTokensBuffer;
|
||||||
|
|
||||||
public WulaFallenEmpireMod(ModContentPack content) : base(content)
|
public WulaFallenEmpireMod(ModContentPack content) : base(content)
|
||||||
@@ -24,21 +26,64 @@ namespace WulaFallenEmpire
|
|||||||
WulaLog.Debug("[WulaFallenEmpire] Harmony patches applied.");
|
WulaLog.Debug("[WulaFallenEmpire] Harmony patches applied.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Vector2 _scrollPosition = Vector2.zero;
|
||||||
|
|
||||||
public override void DoSettingsWindowContents(Rect inRect)
|
public override void DoSettingsWindowContents(Rect inRect)
|
||||||
{
|
{
|
||||||
|
// Prepare Scroll View
|
||||||
|
Rect viewRect = new Rect(0f, 0f, inRect.width - 20f, 1000f); // Adjust 1000f if more height is needed
|
||||||
|
Widgets.BeginScrollView(inRect, ref _scrollPosition, viewRect);
|
||||||
|
|
||||||
Listing_Standard listingStandard = new Listing_Standard();
|
Listing_Standard listingStandard = new Listing_Standard();
|
||||||
listingStandard.Begin(inRect);
|
listingStandard.Begin(viewRect);
|
||||||
|
|
||||||
listingStandard.Label("Wula_AISettings_Title".Translate());
|
listingStandard.Label("Wula_AISettings_Title".Translate());
|
||||||
|
|
||||||
listingStandard.Label("Wula_AISettings_ApiKey".Translate());
|
listingStandard.Label("<color=cyan>AI 核心协议选择</color>");
|
||||||
settings.apiKey = listingStandard.TextEntry(settings.apiKey);
|
bool currentIsGemini = settings.useGeminiProtocol;
|
||||||
|
if (listingStandard.RadioButton("OpenAI / 常用兼容格式 (DeepSeek, ChatGPT)", !currentIsGemini)) settings.useGeminiProtocol = false;
|
||||||
listingStandard.Label("Wula_AISettings_BaseUrl".Translate());
|
if (listingStandard.RadioButton("Google Gemini 原生格式 (支持本地多模态)", currentIsGemini)) settings.useGeminiProtocol = true;
|
||||||
settings.baseUrl = listingStandard.TextEntry(settings.baseUrl);
|
listingStandard.GapLine();
|
||||||
|
|
||||||
listingStandard.Label("Wula_AISettings_Model".Translate());
|
// 根据当前选中的协议,动态绑定输入字段
|
||||||
settings.model = listingStandard.TextEntry(settings.model);
|
if (settings.useGeminiProtocol)
|
||||||
|
{
|
||||||
|
listingStandard.Label("<color=orange>Gemini 设置 (独立存储)</color>");
|
||||||
|
|
||||||
|
listingStandard.Label("Gemini API Key:");
|
||||||
|
Rect keyRect = listingStandard.GetRect(30f);
|
||||||
|
float tw = 60f;
|
||||||
|
Rect pRect = new Rect(keyRect.x, keyRect.y, keyRect.width - tw - 5f, keyRect.height);
|
||||||
|
Rect tRect = new Rect(keyRect.xMax - tw, keyRect.y, tw, keyRect.height);
|
||||||
|
if (WulaFallenEmpireMod._showApiKey) settings.geminiApiKey = Widgets.TextField(pRect, settings.geminiApiKey);
|
||||||
|
else settings.geminiApiKey = GUI.PasswordField(pRect, settings.geminiApiKey, '•');
|
||||||
|
Widgets.CheckboxLabeled(tRect, "Show", ref WulaFallenEmpireMod._showApiKey);
|
||||||
|
|
||||||
|
listingStandard.Label("API 代理地址 (可选,留空则用官方 Google 节点):");
|
||||||
|
settings.geminiBaseUrl = listingStandard.TextEntry(settings.geminiBaseUrl);
|
||||||
|
|
||||||
|
listingStandard.Label("模型名称:");
|
||||||
|
settings.geminiModel = listingStandard.TextEntry(settings.geminiModel);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
listingStandard.Label("<color=orange>OpenAI 兼容设置 (独立存储)</color>");
|
||||||
|
|
||||||
|
listingStandard.Label("API Key:");
|
||||||
|
Rect keyRect = listingStandard.GetRect(30f);
|
||||||
|
float tw = 60f;
|
||||||
|
Rect pRect = new Rect(keyRect.x, keyRect.y, keyRect.width - tw - 5f, keyRect.height);
|
||||||
|
Rect tRect = new Rect(keyRect.xMax - tw, keyRect.y, tw, keyRect.height);
|
||||||
|
if (WulaFallenEmpireMod._showApiKey) settings.apiKey = Widgets.TextField(pRect, settings.apiKey);
|
||||||
|
else settings.apiKey = GUI.PasswordField(pRect, settings.apiKey, '•');
|
||||||
|
Widgets.CheckboxLabeled(tRect, "Show", ref WulaFallenEmpireMod._showApiKey);
|
||||||
|
|
||||||
|
listingStandard.Label("Base URL:");
|
||||||
|
settings.baseUrl = listingStandard.TextEntry(settings.baseUrl);
|
||||||
|
|
||||||
|
listingStandard.Label("模型名称:");
|
||||||
|
settings.model = listingStandard.TextEntry(settings.model);
|
||||||
|
}
|
||||||
|
|
||||||
listingStandard.GapLine();
|
listingStandard.GapLine();
|
||||||
listingStandard.Label("Wula_AISettings_MaxContextTokens".Translate());
|
listingStandard.Label("Wula_AISettings_MaxContextTokens".Translate());
|
||||||
@@ -49,6 +94,24 @@ namespace WulaFallenEmpire
|
|||||||
listingStandard.GapLine();
|
listingStandard.GapLine();
|
||||||
listingStandard.CheckboxLabeled("Wula_EnableDebugLogs".Translate(), ref settings.enableDebugLogs, "Wula_EnableDebugLogsDesc".Translate());
|
listingStandard.CheckboxLabeled("Wula_EnableDebugLogs".Translate(), ref settings.enableDebugLogs, "Wula_EnableDebugLogsDesc".Translate());
|
||||||
|
|
||||||
|
listingStandard.GapLine();
|
||||||
|
listingStandard.CheckboxLabeled("Wula_AISettings_AutoCommentary".Translate(), ref settings.enableAIAutoCommentary, "Wula_AISettings_AutoCommentaryDesc".Translate());
|
||||||
|
if (settings.enableAIAutoCommentary)
|
||||||
|
{
|
||||||
|
listingStandard.Label("Wula_AISettings_CommentaryChance".Translate() + $" ({settings.aiCommentaryChance:P0})");
|
||||||
|
listingStandard.Label("Wula_AISettings_CommentaryChanceDesc".Translate());
|
||||||
|
settings.aiCommentaryChance = listingStandard.Slider(settings.aiCommentaryChance, 0f, 1f);
|
||||||
|
settings.aiCommentaryChance = Mathf.Clamp01(settings.aiCommentaryChance);
|
||||||
|
listingStandard.CheckboxLabeled("Wula_AISettings_NegativeOnly".Translate(), ref settings.commentOnNegativeOnly, "Wula_AISettings_NegativeOnlyDesc".Translate());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视觉设置部分
|
||||||
|
listingStandard.GapLine();
|
||||||
|
listingStandard.Label("<color=cyan>视觉与多模态设置</color>");
|
||||||
|
|
||||||
|
listingStandard.CheckboxLabeled("启用视觉交互能力", ref settings.enableVlmFeatures, "启用后 AI 可以截取屏幕并理解游戏画面");
|
||||||
|
|
||||||
|
|
||||||
listingStandard.GapLine();
|
listingStandard.GapLine();
|
||||||
listingStandard.Label("Translation tools");
|
listingStandard.Label("Translation tools");
|
||||||
Rect exportRect = listingStandard.GetRect(30f);
|
Rect exportRect = listingStandard.GetRect(30f);
|
||||||
@@ -58,6 +121,7 @@ namespace WulaFallenEmpire
|
|||||||
}
|
}
|
||||||
|
|
||||||
listingStandard.End();
|
listingStandard.End();
|
||||||
|
Widgets.EndScrollView();
|
||||||
base.DoSettingsWindowContents(inRect);
|
base.DoSettingsWindowContents(inRect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,46 @@ namespace WulaFallenEmpire
|
|||||||
public class WulaFallenEmpireSettings : ModSettings
|
public class WulaFallenEmpireSettings : ModSettings
|
||||||
{
|
{
|
||||||
public string apiKey = "sk-xxxxxxxx";
|
public string apiKey = "sk-xxxxxxxx";
|
||||||
public string baseUrl = "https://api.deepseek.com";
|
public string baseUrl = "https://api.deepseek.com/v1";
|
||||||
public string model = "deepseek-chat";
|
public string model = "deepseek-chat";
|
||||||
|
|
||||||
|
// Gemini 专属配置 (独立存储)
|
||||||
|
public string geminiApiKey = "";
|
||||||
|
public string geminiBaseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
public string geminiModel = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
public bool useGeminiProtocol = false; // 是否使用 Google Gemini 协议格式
|
||||||
public int maxContextTokens = 100000;
|
public int maxContextTokens = 100000;
|
||||||
public bool enableDebugLogs = false;
|
public bool enableDebugLogs = false;
|
||||||
|
|
||||||
|
// 视觉功能配置
|
||||||
|
public bool enableVlmFeatures = false;
|
||||||
|
public bool enableAIAutoCommentary = false;
|
||||||
|
public float aiCommentaryChance = 0.7f;
|
||||||
|
public bool commentOnNegativeOnly = false;
|
||||||
|
public string extraPersonalityPrompt = "";
|
||||||
|
|
||||||
public override void ExposeData()
|
public override void ExposeData()
|
||||||
{
|
{
|
||||||
Scribe_Values.Look(ref apiKey, "apiKey", "sk-xxxxxxxx");
|
Scribe_Values.Look(ref apiKey, "apiKey", "sk-xxxxxxxx");
|
||||||
Scribe_Values.Look(ref baseUrl, "baseUrl", "https://api.deepseek.com");
|
Scribe_Values.Look(ref baseUrl, "baseUrl", "https://api.deepseek.com/v1");
|
||||||
Scribe_Values.Look(ref model, "model", "deepseek-chat");
|
Scribe_Values.Look(ref model, "model", "deepseek-chat");
|
||||||
|
|
||||||
|
Scribe_Values.Look(ref geminiApiKey, "geminiApiKey", "");
|
||||||
|
Scribe_Values.Look(ref geminiBaseUrl, "geminiBaseUrl", "https://generativelanguage.googleapis.com/v1beta");
|
||||||
|
Scribe_Values.Look(ref geminiModel, "geminiModel", "gemini-2.5-flash");
|
||||||
|
|
||||||
|
Scribe_Values.Look(ref useGeminiProtocol, "useGeminiProtocol", false);
|
||||||
Scribe_Values.Look(ref maxContextTokens, "maxContextTokens", 100000);
|
Scribe_Values.Look(ref maxContextTokens, "maxContextTokens", 100000);
|
||||||
Scribe_Values.Look(ref enableDebugLogs, "enableDebugLogs", false);
|
Scribe_Values.Look(ref enableDebugLogs, "enableDebugLogs", false);
|
||||||
|
|
||||||
|
// 简化后的视觉配置
|
||||||
|
Scribe_Values.Look(ref enableVlmFeatures, "enableVlmFeatures", false);
|
||||||
|
Scribe_Values.Look(ref enableAIAutoCommentary, "enableAIAutoCommentary", false);
|
||||||
|
Scribe_Values.Look(ref aiCommentaryChance, "aiCommentaryChance", 0.7f);
|
||||||
|
Scribe_Values.Look(ref commentOnNegativeOnly, "commentOnNegativeOnly", false);
|
||||||
|
Scribe_Values.Look(ref extraPersonalityPrompt, "extraPersonalityPrompt", "");
|
||||||
|
|
||||||
base.ExposeData();
|
base.ExposeData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
Source/WulaFallenEmpire/build_log.txt
Normal file
29
Source/WulaFallenEmpire/build_log.txt
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
?????????????????
|
||||||
|
??????????????????????????????????
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(69,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(70,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenHeight????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(83,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(85,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(87,13): error CS0103: ???????????????????ringToFront??[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(334,50): warning CS1998: ???????????"await" ??????????????????????????? "await" ??????????????API ???????????"await Task.Run(...)" ?????????????????? CPU ???????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(98,24): warning CS0414: ?????IIntelligenceCore._memoryContextQuery???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(90,22): warning CS0414: ?????IIntelligenceCore._actionRetryUsed???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Dialog_AIConversation.cs(20,21): warning CS0414: ?????ialog_AIConversation._currentPortraitId???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: ?????verlay_WulaLink._lastMessageHeight???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
|
||||||
|
????????
|
||||||
|
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(334,50): warning CS1998: ???????????"await" ??????????????????????????? "await" ??????????????API ???????????"await Task.Run(...)" ?????????????????? CPU ???????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(98,24): warning CS0414: ?????IIntelligenceCore._memoryContextQuery???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(90,22): warning CS0414: ?????IIntelligenceCore._actionRetryUsed???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Dialog_AIConversation.cs(20,21): warning CS0414: ?????ialog_AIConversation._currentPortraitId???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: ?????verlay_WulaLink._lastMessageHeight???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(69,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(70,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenHeight????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(83,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(85,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(87,13): error CS0103: ???????????????????ringToFront??[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
5 ?????
|
||||||
|
5 ?????
|
||||||
|
|
||||||
|
?????? 00:00:00.41
|
||||||
BIN
Source/WulaFallenEmpire/build_output.txt
Normal file
BIN
Source/WulaFallenEmpire/build_output.txt
Normal file
Binary file not shown.
23
Source/WulaFallenEmpire/trace.txt
Normal file
23
Source/WulaFallenEmpire/trace.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
姝e湪纭畾瑕佽繕鍘熺殑椤圭洰鈥?
|
||||||
|
鏃犲彲鎵ц鎿嶄綔銆傛寚瀹氱殑椤圭洰鍧囦笉鍖呭惈鍙繕鍘熺殑鍖呫€?
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(323,50): warning CS1998: 姝ゅ紓姝ユ柟娉曠己灏?"await" 杩愮畻绗︼紝灏嗕互鍚屾鏂瑰紡杩愯銆傝鑰冭檻浣跨敤 "await" 杩愮畻绗︾瓑寰呴潪闃绘鐨?API 璋冪敤锛屾垨鑰呬娇鐢?"await Task.Run(...)" 鍦ㄥ悗鍙扮嚎绋嬩笂鎵ц鍗犵敤澶ч噺 CPU 鐨勫伐浣溿€?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(116,34): error CS1061: 鈥淒ialog_AIConversation鈥濇湭鍖呭惈鈥淕etHistorySnapshot鈥濈殑瀹氫箟锛屽苟涓旀壘涓嶅埌鍙帴鍙楃涓€涓€淒ialog_AIConversation鈥濈被鍨嬪弬鏁扮殑鍙闂墿灞曟柟娉曗€淕etHistorySnapshot鈥?鏄惁缂哄皯 using 鎸囦护鎴栫▼搴忛泦寮曠敤?) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(117,36): error CS0019: 杩愮畻绗︹€?=鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(120,26): error CS0019: 杩愮畻绗︹€?鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: 瀛楁鈥淥verlay_WulaLink._lastMessageHeight鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(79,22): warning CS0414: 瀛楁鈥淎IIntelligenceCore._actionRetryUsed鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(87,24): warning CS0414: 瀛楁鈥淎IIntelligenceCore._memoryContextQuery鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
|
||||||
|
鐢熸垚澶辫触銆?
|
||||||
|
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(323,50): warning CS1998: 姝ゅ紓姝ユ柟娉曠己灏?"await" 杩愮畻绗︼紝灏嗕互鍚屾鏂瑰紡杩愯銆傝鑰冭檻浣跨敤 "await" 杩愮畻绗︾瓑寰呴潪闃绘鐨?API 璋冪敤锛屾垨鑰呬娇鐢?"await Task.Run(...)" 鍦ㄥ悗鍙扮嚎绋嬩笂鎵ц鍗犵敤澶ч噺 CPU 鐨勫伐浣溿€?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: 瀛楁鈥淥verlay_WulaLink._lastMessageHeight鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(79,22): warning CS0414: 瀛楁鈥淎IIntelligenceCore._actionRetryUsed鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(87,24): warning CS0414: 瀛楁鈥淎IIntelligenceCore._memoryContextQuery鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(116,34): error CS1061: 鈥淒ialog_AIConversation鈥濇湭鍖呭惈鈥淕etHistorySnapshot鈥濈殑瀹氫箟锛屽苟涓旀壘涓嶅埌鍙帴鍙楃涓€涓€淒ialog_AIConversation鈥濈被鍨嬪弬鏁扮殑鍙闂墿灞曟柟娉曗€淕etHistorySnapshot鈥?鏄惁缂哄皯 using 鎸囦护鎴栫▼搴忛泦寮曠敤?) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(117,36): error CS0019: 杩愮畻绗︹€?=鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(120,26): error CS0019: 杩愮畻绗︹€?鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj]
|
||||||
|
4 涓鍛?
|
||||||
|
3 涓敊璇?
|
||||||
|
|
||||||
|
宸茬敤鏃堕棿 00:00:00.48
|
||||||
405
Tools/ai_letter_auto_commentary.md
Normal file
405
Tools/ai_letter_auto_commentary.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# AI Letter Auto-Response System - 开发文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
这个功能将使 P.I.A AI 能够自动监听游戏内的 Letter(信封通知),并根据内容智能决定是否向玩家发送评论、吐槽、警告或提供帮助建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能需求
|
||||||
|
|
||||||
|
### 核心功能
|
||||||
|
1. **Mod 设置开关**: 在设置中添加 `启用 AI 自动评论` 开关
|
||||||
|
2. **Letter 监听**: 拦截所有发送给玩家的 Letter
|
||||||
|
3. **智能判断**: AI 分析 Letter 内容,决定是否需要回应
|
||||||
|
4. **自动回复**: 通过现有的 AI 对话系统发送回复
|
||||||
|
|
||||||
|
### AI 回应类型
|
||||||
|
| 类型 | 触发场景示例 | 回应风格 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 警告 | 袭击通知、疫病爆发 | 紧急提醒,询问是否需要启动防御 |
|
||||||
|
| 吐槽 | 殖民者精神崩溃、愚蠢死亡 | 幽默/讽刺评论 |
|
||||||
|
| 建议 | 资源短缺、贸易商到来 | 实用建议 |
|
||||||
|
| 庆祝 | 任务完成、殖民者加入 | 积极反馈 |
|
||||||
|
| 沉默 | 常规事件、无关紧要的通知 | 不发送任何回复 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 1. 文件结构
|
||||||
|
```
|
||||||
|
Source/WulaFallenEmpire/
|
||||||
|
├── Settings/
|
||||||
|
│ └── WulaModSettings.cs # 添加新设置字段
|
||||||
|
├── EventSystem/
|
||||||
|
│ └── AI/
|
||||||
|
│ ├── LetterInterceptor/
|
||||||
|
│ │ ├── Patch_LetterStack.cs # Harmony Patch 拦截 Letter
|
||||||
|
│ │ ├── LetterAnalyzer.cs # Letter 分析和分类
|
||||||
|
│ │ └── LetterToPromptConverter.cs # Letter 转提示词
|
||||||
|
│ └── AIAutoCommentary.cs # AI 自动评论逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 关键类设计
|
||||||
|
|
||||||
|
#### 2.1 WulaModSettings.cs (修改)
|
||||||
|
```csharp
|
||||||
|
public class WulaModSettings : ModSettings
|
||||||
|
{
|
||||||
|
// 现有设置...
|
||||||
|
|
||||||
|
// 新增
|
||||||
|
public bool enableAIAutoCommentary = false; // AI 自动评论开关
|
||||||
|
public float aiCommentaryChance = 0.7f; // AI 评论概率 (0-1)
|
||||||
|
public bool commentOnNegativeOnly = false; // 仅评论负面事件
|
||||||
|
|
||||||
|
public override void ExposeData()
|
||||||
|
{
|
||||||
|
base.ExposeData();
|
||||||
|
Scribe_Values.Look(ref enableAIAutoCommentary, "enableAIAutoCommentary", false);
|
||||||
|
Scribe_Values.Look(ref aiCommentaryChance, "aiCommentaryChance", 0.7f);
|
||||||
|
Scribe_Values.Look(ref commentOnNegativeOnly, "commentOnNegativeOnly", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 Patch_LetterStack.cs (新建)
|
||||||
|
```csharp
|
||||||
|
using HarmonyLib;
|
||||||
|
using RimWorld;
|
||||||
|
using Verse;
|
||||||
|
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
[HarmonyPatch(typeof(LetterStack), nameof(LetterStack.ReceiveLetter),
|
||||||
|
new Type[] { typeof(Letter), typeof(string) })]
|
||||||
|
public static class Patch_LetterStack_ReceiveLetter
|
||||||
|
{
|
||||||
|
public static void Postfix(Letter let, string debugInfo)
|
||||||
|
{
|
||||||
|
// 检查设置开关
|
||||||
|
if (!WulaModSettings.Instance.enableAIAutoCommentary) return;
|
||||||
|
|
||||||
|
// 异步处理,避免阻塞游戏
|
||||||
|
AIAutoCommentary.ProcessLetter(let);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 LetterAnalyzer.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
public enum LetterCategory
|
||||||
|
{
|
||||||
|
Raid, // 袭击
|
||||||
|
Disease, // 疾病
|
||||||
|
MentalBreak, // 精神崩溃
|
||||||
|
Trade, // 贸易
|
||||||
|
Quest, // 任务
|
||||||
|
Death, // 死亡
|
||||||
|
Recruitment, // 招募
|
||||||
|
Resource, // 资源
|
||||||
|
Weather, // 天气
|
||||||
|
Positive, // 正面事件
|
||||||
|
Negative, // 负面事件
|
||||||
|
Neutral, // 中性事件
|
||||||
|
Unknown // 未知
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LetterAnalyzer
|
||||||
|
{
|
||||||
|
public static LetterCategory Categorize(Letter letter)
|
||||||
|
{
|
||||||
|
// 根据 LetterDef 分类
|
||||||
|
var def = letter.def;
|
||||||
|
|
||||||
|
if (def == LetterDefOf.ThreatBig || def == LetterDefOf.ThreatSmall)
|
||||||
|
return LetterCategory.Raid;
|
||||||
|
if (def == LetterDefOf.Death)
|
||||||
|
return LetterCategory.Death;
|
||||||
|
if (def == LetterDefOf.PositiveEvent)
|
||||||
|
return LetterCategory.Positive;
|
||||||
|
if (def == LetterDefOf.NegativeEvent)
|
||||||
|
return LetterCategory.Negative;
|
||||||
|
if (def == LetterDefOf.NeutralEvent)
|
||||||
|
return LetterCategory.Neutral;
|
||||||
|
|
||||||
|
// 根据内容关键词进一步分类
|
||||||
|
string text = letter.text?.ToLower() ?? "";
|
||||||
|
if (text.Contains("raid") || text.Contains("袭击") || text.Contains("attack"))
|
||||||
|
return LetterCategory.Raid;
|
||||||
|
if (text.Contains("disease") || text.Contains("疫病") || text.Contains("plague"))
|
||||||
|
return LetterCategory.Disease;
|
||||||
|
if (text.Contains("mental") || text.Contains("精神") || text.Contains("break"))
|
||||||
|
return LetterCategory.MentalBreak;
|
||||||
|
if (text.Contains("trade") || text.Contains("贸易") || text.Contains("商队"))
|
||||||
|
return LetterCategory.Trade;
|
||||||
|
|
||||||
|
return LetterCategory.Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ShouldComment(Letter letter)
|
||||||
|
{
|
||||||
|
var category = Categorize(letter);
|
||||||
|
|
||||||
|
// 始终评论的类型
|
||||||
|
switch (category)
|
||||||
|
{
|
||||||
|
case LetterCategory.Raid:
|
||||||
|
case LetterCategory.Death:
|
||||||
|
case LetterCategory.MentalBreak:
|
||||||
|
case LetterCategory.Disease:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case LetterCategory.Trade:
|
||||||
|
case LetterCategory.Quest:
|
||||||
|
case LetterCategory.Positive:
|
||||||
|
return Rand.Chance(WulaModSettings.Instance.aiCommentaryChance);
|
||||||
|
|
||||||
|
case LetterCategory.Neutral:
|
||||||
|
case LetterCategory.Unknown:
|
||||||
|
return Rand.Chance(0.3f); // 低概率评论
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.4 LetterToPromptConverter.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI.LetterInterceptor
|
||||||
|
{
|
||||||
|
public static class LetterToPromptConverter
|
||||||
|
{
|
||||||
|
public static string Convert(Letter letter, LetterCategory category)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
|
||||||
|
sb.AppendLine("[SYSTEM EVENT NOTIFICATION]");
|
||||||
|
sb.AppendLine($"Event Type: {category}");
|
||||||
|
sb.AppendLine($"Severity: {GetSeverityFromDef(letter.def)}");
|
||||||
|
sb.AppendLine($"Title: {letter.label}");
|
||||||
|
sb.AppendLine($"Content: {letter.text}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("[INSTRUCTION]");
|
||||||
|
sb.AppendLine("You have received a game event notification. Based on the event type and content:");
|
||||||
|
sb.AppendLine("- For RAIDS/THREATS: Offer tactical advice or ask if player needs orbital support");
|
||||||
|
sb.AppendLine("- For DEATHS: Express condolences or make a sardonic comment if death was avoidable");
|
||||||
|
sb.AppendLine("- For MENTAL BREAKS: Comment on the colonist's weakness or offer mood management tips");
|
||||||
|
sb.AppendLine("- For TRADE: Suggest useful purchases or sales");
|
||||||
|
sb.AppendLine("- For POSITIVE events: Celebrate briefly");
|
||||||
|
sb.AppendLine("- For trivial events: You may choose to say nothing (respond with [NO_COMMENT])");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("Keep your response brief (1-2 sentences). Match your personality as the Legion AI.");
|
||||||
|
sb.AppendLine("If you don't think this event warrants a response, reply with exactly: [NO_COMMENT]");
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSeverityFromDef(LetterDef def)
|
||||||
|
{
|
||||||
|
if (def == LetterDefOf.ThreatBig) return "CRITICAL";
|
||||||
|
if (def == LetterDefOf.ThreatSmall) return "WARNING";
|
||||||
|
if (def == LetterDefOf.Death) return "SERIOUS";
|
||||||
|
if (def == LetterDefOf.NegativeEvent) return "MODERATE";
|
||||||
|
if (def == LetterDefOf.PositiveEvent) return "GOOD";
|
||||||
|
return "INFO";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.5 AIAutoCommentary.cs (新建)
|
||||||
|
```csharp
|
||||||
|
namespace WulaFallenEmpire.EventSystem.AI
|
||||||
|
{
|
||||||
|
public static class AIAutoCommentary
|
||||||
|
{
|
||||||
|
private static Queue<Letter> pendingLetters = new Queue<Letter>();
|
||||||
|
private static bool isProcessing = false;
|
||||||
|
|
||||||
|
public static void ProcessLetter(Letter letter)
|
||||||
|
{
|
||||||
|
// 检查是否应该评论
|
||||||
|
if (!LetterAnalyzer.ShouldComment(letter))
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] Skipping letter: {letter.label}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入队列
|
||||||
|
pendingLetters.Enqueue(letter);
|
||||||
|
|
||||||
|
// 开始处理(如果还没在处理中)
|
||||||
|
if (!isProcessing)
|
||||||
|
{
|
||||||
|
ProcessNextLetter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async void ProcessNextLetter()
|
||||||
|
{
|
||||||
|
if (pendingLetters.Count == 0)
|
||||||
|
{
|
||||||
|
isProcessing = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing = true;
|
||||||
|
var letter = pendingLetters.Dequeue();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var category = LetterAnalyzer.Categorize(letter);
|
||||||
|
var prompt = LetterToPromptConverter.Convert(letter, category);
|
||||||
|
|
||||||
|
// 获取 AI 核心
|
||||||
|
var aiCore = Find.World?.GetComponent<AIIntelligenceCore>();
|
||||||
|
if (aiCore == null)
|
||||||
|
{
|
||||||
|
WulaLog.Debug("[AI Commentary] AIIntelligenceCore not found.");
|
||||||
|
ProcessNextLetter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送到 AI 并等待响应
|
||||||
|
string response = await aiCore.SendSystemMessageAsync(prompt);
|
||||||
|
|
||||||
|
// 检查是否选择不评论
|
||||||
|
if (string.IsNullOrEmpty(response) || response.Contains("[NO_COMMENT]"))
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] AI chose not to comment on: {letter.label}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 显示 AI 的评论
|
||||||
|
DisplayAICommentary(response, letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
WulaLog.Debug($"[AI Commentary] Error processing letter: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟处理下一个,避免刷屏
|
||||||
|
await Task.Delay(2000);
|
||||||
|
ProcessNextLetter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DisplayAICommentary(string response, Letter originalLetter)
|
||||||
|
{
|
||||||
|
// 方式1: 作为小型通知显示在 WulaLink 小 UI
|
||||||
|
var overlay = Find.WindowStack.Windows.OfType<Overlay_WulaLink>().FirstOrDefault();
|
||||||
|
if (overlay != null)
|
||||||
|
{
|
||||||
|
overlay.AddAIMessage(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式2: 作为 Message 显示在屏幕左上角
|
||||||
|
Messages.Message($"[P.I.A]: {response}", MessageTypeDefOf.SilentInput);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 实现步骤
|
||||||
|
|
||||||
|
### 阶段 1: 基础设施 (预计 1 小时)
|
||||||
|
1. [ ] 在 `WulaModSettings.cs` 添加新设置字段
|
||||||
|
2. [ ] 在设置 UI 中添加开关
|
||||||
|
3. [ ] 添加对应的 Keyed 翻译
|
||||||
|
|
||||||
|
### 阶段 2: Letter 拦截 (预计 30 分钟)
|
||||||
|
1. [ ] 创建 `Patch_LetterStack.cs` Harmony Patch
|
||||||
|
2. [ ] 确保 Patch 正确注册到 Harmony 实例
|
||||||
|
3. [ ] 测试 Letter 拦截是否正常工作
|
||||||
|
|
||||||
|
### 阶段 3: Letter 分析 (预计 1 小时)
|
||||||
|
1. [ ] 创建 `LetterAnalyzer.cs` 分类逻辑
|
||||||
|
2. [ ] 创建 `LetterToPromptConverter.cs` 转换逻辑
|
||||||
|
3. [ ] 测试不同类型 Letter 的分类准确性
|
||||||
|
|
||||||
|
### 阶段 4: AI 集成 (预计 1.5 小时)
|
||||||
|
1. [ ] 创建 `AIAutoCommentary.cs` 管理类
|
||||||
|
2. [ ] 集成到现有的 `AIIntelligenceCore` 系统
|
||||||
|
3. [ ] 实现队列处理避免刷屏
|
||||||
|
4. [ ] 添加 `SendSystemMessageAsync` 方法到 AIIntelligenceCore
|
||||||
|
|
||||||
|
### 阶段 5: UI 显示 (预计 30 分钟)
|
||||||
|
1. [ ] 决定评论显示方式(WulaLink UI / Message / 独立通知)
|
||||||
|
2. [ ] 实现显示逻辑
|
||||||
|
3. [ ] 测试显示效果
|
||||||
|
|
||||||
|
### 阶段 6: 测试与优化 (预计 1 小时)
|
||||||
|
1. [ ] 测试各类 Letter 的评论效果
|
||||||
|
2. [ ] 调整评论概率和过滤规则
|
||||||
|
3. [ ] 优化提示词以获得更好的 AI 回应
|
||||||
|
4. [ ] 添加速率限制避免 API 过载
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要添加的翻译键
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- AI Auto Commentary Settings -->
|
||||||
|
<Wula_AISettings_AutoCommentary>启用 AI 自动评论</Wula_AISettings_AutoCommentary>
|
||||||
|
<Wula_AISettings_AutoCommentaryDesc>开启后,P.I.A 会自动对游戏事件(袭击、死亡、贸易等)发表评论或提供建议。</Wula_AISettings_AutoCommentaryDesc>
|
||||||
|
<Wula_AISettings_CommentaryChance>评论概率</Wula_AISettings_CommentaryChance>
|
||||||
|
<Wula_AISettings_CommentaryChanceDesc>AI 对中性事件发表评论的概率。负面事件(如袭击)总是会评论。</Wula_AISettings_CommentaryChanceDesc>
|
||||||
|
<Wula_AISettings_NegativeOnly>仅评论负面事件</Wula_AISettings_NegativeOnly>
|
||||||
|
<Wula_AISettings_NegativeOnlyDesc>开启后,AI 只会对负面事件(袭击、死亡、疾病等)发表评论。</Wula_AISettings_NegativeOnlyDesc>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API 限流**: 需要实现请求队列和速率限制,避免短时间内发送过多请求
|
||||||
|
2. **异步处理**: 所有 AI 请求必须异步处理,避免阻塞游戏主线程
|
||||||
|
3. **用户控制**: 提供足够的设置选项让用户控制评论频率和类型
|
||||||
|
4. **优雅降级**: 如果 AI 服务不可用,静默失败而不影响游戏
|
||||||
|
5. **内存管理**: 队列大小限制,避免积累过多未处理的 Letter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 预期效果示例
|
||||||
|
|
||||||
|
**场景 1: 袭击通知**
|
||||||
|
```
|
||||||
|
[Letter] 海盗袭击!一群海盗正在向你的殖民地进发。
|
||||||
|
[P.I.A] 检测到敌对势力入侵。需要我启动轨道监视协议吗?
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 2: 殖民者死亡**
|
||||||
|
```
|
||||||
|
[Letter] 张三死了。他被一只疯狂的松鼠咬死了。
|
||||||
|
[P.I.A] ...被松鼠咬死?这位殖民者的战斗技能令人印象深刻。
|
||||||
|
```
|
||||||
|
|
||||||
|
**场景 3: 贸易商到来**
|
||||||
|
```
|
||||||
|
[Letter] 商队到来。一个来自外部势力的商队想要与你交易。
|
||||||
|
[P.I.A] 贸易商队抵达。我注意到你的钢铁储备较低,建议优先采购。
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖项
|
||||||
|
|
||||||
|
- Harmony 2.0+ (用于 Patch)
|
||||||
|
- 现有的 AIIntelligenceCore 系统
|
||||||
|
- 现有的 WulaModSettings 系统
|
||||||
|
- 现有的 Overlay_WulaLink UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本: 1.0*
|
||||||
|
*创建时间: 2025-12-28*
|
||||||
56
Tools/claude_handoff.md
Normal file
56
Tools/claude_handoff.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
WulaLink / AI Core Handoff (for Claude)
|
||||||
|
|
||||||
|
Context
|
||||||
|
- Mod: RimWorld WulaFallenEmpire.
|
||||||
|
- The AI conversation system was refactored to use a shared WorldComponent core.
|
||||||
|
- The small WulaLink overlay is now optional; the main event entry should open the old large dialog.
|
||||||
|
|
||||||
|
Current behavior and verification
|
||||||
|
- `Effect_OpenAIConversation` now opens the large window `Dialog_AIConversation`.
|
||||||
|
- The small overlay (`Overlay_WulaLink`) remains available via dev/debug entry.
|
||||||
|
- Non-final / streaming AI output should not create empty lines in the small window:
|
||||||
|
- SimpleAIClient is non-stream by default.
|
||||||
|
- AIIntelligenceCore only fires `OnMessageReceived` on final reply and ignores empty or XML-only output.
|
||||||
|
- Overlay filters `tool`/`toolcall` messages unless DevMode is on.
|
||||||
|
- Build verified: `dotnet build C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj`
|
||||||
|
|
||||||
|
Key changes
|
||||||
|
1) New shared AI core
|
||||||
|
- File: `Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs`
|
||||||
|
- WorldComponent with static `Instance` and events:
|
||||||
|
- `OnMessageReceived`, `OnThinkingStateChanged`, `OnExpressionChanged`
|
||||||
|
- Public API:
|
||||||
|
- `InitializeConversation`, `GetHistorySnapshot`, `SetExpression`, `SetPortrait`, `SendMessage`, `SendUserMessage`
|
||||||
|
- Core responsibilities:
|
||||||
|
- History load/save via `AIHistoryManager`
|
||||||
|
- `/clear` support
|
||||||
|
- Expression tag parsing `[EXPR:n]`
|
||||||
|
- 3-phase tool pipeline (query/action/reply) from the old dialog logic
|
||||||
|
- Tool execution and ledger tracking
|
||||||
|
|
||||||
|
2) OpenAI conversation entry now opens the large dialog
|
||||||
|
- File: `Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs`
|
||||||
|
- Uses `Dialog_AIConversation` instead of `Overlay_WulaLink`.
|
||||||
|
- XML entry in `1.6/1.6/Defs/EventDefs/Wula_AI_Events.xml` stays the same.
|
||||||
|
|
||||||
|
3) Overlay and tools point to the shared core
|
||||||
|
- Files:
|
||||||
|
- `Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs`
|
||||||
|
- `Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink_Notification.cs`
|
||||||
|
- `Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_ChangeExpression.cs`
|
||||||
|
- `Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetRecentNotifications.cs`
|
||||||
|
- Namespace import updated to `WulaFallenEmpire.EventSystem.AI`.
|
||||||
|
|
||||||
|
4) WulaLink styles restored for overlay build
|
||||||
|
- File: `Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs`
|
||||||
|
- Added colors used by overlay:
|
||||||
|
- `InputBarColor`, `SystemAccentColor`, `SenseiTextColor`, `StudentTextColor`
|
||||||
|
|
||||||
|
Notes / gotchas
|
||||||
|
- Some UI files are not UTF-8 (likely ANSI). If you edit them with scripts, prefer `-Encoding Default` in PowerShell to avoid invalid UTF-8 errors.
|
||||||
|
- The old large dialog is still self-contained; the shared core is used by overlay + tools. Future cleanup can rewire `Dialog_AIConversation` to use the core if desired.
|
||||||
|
|
||||||
|
Open questions / TODO (if needed later)
|
||||||
|
- Memory system integration is not done in the core:
|
||||||
|
- `AIMemoryManager.cs`, `MemoryPrompts.cs`, and `Dialog_AIConversation.cs` integration still pending.
|
||||||
|
- If the overlay should become a non-debug entry, wire an explicit effect or UI button to open it.
|
||||||
429
Tools/codex_handoff.md.resolved
Normal file
429
Tools/codex_handoff.md.resolved
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
# RimWorld AI Agent 开发文档
|
||||||
|
|
||||||
|
> 本文档用于移交给 Codex 继续开发
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
创建一个**完全自主的 AI Agent**,能够自动玩 RimWorld 游戏。用户只需给出开放式指令(如"帮我挖点铁"或"帮我玩10分钟"),AI 即可独立决策并操作游戏。
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **语言**: C# (.NET Framework 4.8)
|
||||||
|
- **框架**: RimWorld Mod (Verse/RimWorld API)
|
||||||
|
- **AI 后端**: 阿里云百炼 (DashScope) API
|
||||||
|
- **VLM 模型**: Qwen-VL / Qwen-Omni-Realtime
|
||||||
|
|
||||||
|
### 核心设计
|
||||||
|
```
|
||||||
|
用户指令 → AIIntelligenceCore → [被动模式 | 主动模式] → 工具执行 → 游戏操作
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 架构
|
||||||
|
|
||||||
|
### 2.1 模式切换设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ AIIntelligenceCore │
|
||||||
|
│ ┌─────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ 被动模式 │◄──►│ 主动模式 │ │
|
||||||
|
│ │ (聊天对话) │ │ (Agent循环) │ │
|
||||||
|
│ └─────────────┘ └────────┬────────┘ │
|
||||||
|
└──────────────────────────────┼────────────┘
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ AutonomousAgentLoop │
|
||||||
|
│ Observe → Think │
|
||||||
|
│ → Act │
|
||||||
|
└─────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**模式切换触发条件(待实现)**:
|
||||||
|
- 用户说"帮我玩X分钟" → 切换到主动模式
|
||||||
|
- 主动模式任务完成 → 自动切回被动模式
|
||||||
|
- 用户说"停止" → 强制切回被动模式
|
||||||
|
|
||||||
|
### 2.2 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
Source/WulaFallenEmpire/EventSystem/AI/
|
||||||
|
├── AIIntelligenceCore.cs # 核心AI控制器(已有)
|
||||||
|
├── SimpleAIClient.cs # HTTP API 客户端(已有)
|
||||||
|
├── ScreenCaptureUtility.cs # 截屏工具(已有)
|
||||||
|
│
|
||||||
|
├── Agent/ # ★ 新增目录
|
||||||
|
│ ├── AutonomousAgentLoop.cs # 主动模式循环
|
||||||
|
│ ├── StateObserver.cs # 游戏状态收集器
|
||||||
|
│ ├── GameStateSnapshot.cs # 状态数据结构
|
||||||
|
│ ├── VisualInteractionTools.cs # 视觉交互工具(10个)
|
||||||
|
│ ├── MouseSimulator.cs # 鼠标模拟
|
||||||
|
│ └── OmniRealtimeClient.cs # WebSocket流式连接
|
||||||
|
│
|
||||||
|
├── Tools/ # AI工具
|
||||||
|
│ ├── AITool.cs # 工具基类(已有)
|
||||||
|
│ ├── Tool_GetGameState.cs # ★ 新增
|
||||||
|
│ ├── Tool_DesignateMine.cs # ★ 新增
|
||||||
|
│ ├── Tool_DraftPawn.cs # ★ 新增
|
||||||
|
│ ├── Tool_VisualClick.cs # ★ 新增
|
||||||
|
│ └── ... (其他原有工具)
|
||||||
|
│
|
||||||
|
└── UI/
|
||||||
|
├── Dialog_AIConversation.cs # 对话UI(已有)
|
||||||
|
└── Overlay_WulaLink.cs # 悬浮UI(已有)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 已完成组件
|
||||||
|
|
||||||
|
### 3.1 StateObserver (状态观察器)
|
||||||
|
|
||||||
|
**路径**: [Agent/StateObserver.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/StateObserver.cs)
|
||||||
|
|
||||||
|
**功能**: 收集当前游戏状态,生成给 VLM 的文本描述
|
||||||
|
|
||||||
|
**API**:
|
||||||
|
```csharp
|
||||||
|
public static class StateObserver
|
||||||
|
{
|
||||||
|
// 捕获当前游戏状态快照
|
||||||
|
public static GameStateSnapshot CaptureState();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**收集内容**:
|
||||||
|
- 时间(小时、季节、年份)
|
||||||
|
- 环境(生物群系、温度、天气)
|
||||||
|
- 殖民者(名字、健康、心情、当前工作、位置)
|
||||||
|
- 资源(钢铁、银、食物、医药等)
|
||||||
|
- 建筑进度(蓝图、建造框架)
|
||||||
|
- 威胁(敌对派系、距离)
|
||||||
|
- 最近消息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 GameStateSnapshot (状态数据结构)
|
||||||
|
|
||||||
|
**路径**: [Agent/GameStateSnapshot.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/GameStateSnapshot.cs)
|
||||||
|
|
||||||
|
**功能**: 存储游戏状态数据
|
||||||
|
|
||||||
|
**关键方法**:
|
||||||
|
```csharp
|
||||||
|
public class GameStateSnapshot
|
||||||
|
{
|
||||||
|
public List<PawnSnapshot> Colonists;
|
||||||
|
public Dictionary<string, int> Resources;
|
||||||
|
public List<ThreatSnapshot> Threats;
|
||||||
|
|
||||||
|
// 生成给VLM的文本描述
|
||||||
|
public string ToPromptText();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 VisualInteractionTools (视觉交互工具集)
|
||||||
|
|
||||||
|
**路径**: [Agent/VisualInteractionTools.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs)
|
||||||
|
|
||||||
|
**功能**: 10个纯视觉交互工具,使用 Windows API 模拟输入
|
||||||
|
|
||||||
|
| 方法 | 功能 | 参数 |
|
||||||
|
|------|------|------|
|
||||||
|
| [MouseClick(x, y, button, clicks)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#49-89) | 鼠标点击 | 0-1比例坐标 |
|
||||||
|
| [TypeText(x, y, text)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#90-118) | 输入文本 | 通过剪贴板 |
|
||||||
|
| [ScrollWindow(x, y, direction, amount)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#119-144) | 滚动 | up/down |
|
||||||
|
| [MouseDrag(sx, sy, ex, ey, duration)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#145-187) | 拖拽 | 起止坐标 |
|
||||||
|
| [Wait(seconds)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#188-203) | 等待 | 秒数 |
|
||||||
|
| [PressEnter()](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#204-220) | 按回车 | 无 |
|
||||||
|
| [PressEscape()](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#221-237) | 按ESC | 无 |
|
||||||
|
| [DeleteText(x, y, count)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#238-262) | 删除 | 字符数 |
|
||||||
|
| [PressHotkey(x, y, hotkey)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#263-306) | 快捷键 | 如"ctrl+c" |
|
||||||
|
| [CloseWindow(x, y)](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/VisualInteractionTools.cs#307-329) | 关闭窗口 | Alt+F4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 AutonomousAgentLoop (自主Agent循环)
|
||||||
|
|
||||||
|
**路径**: [Agent/AutonomousAgentLoop.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/AutonomousAgentLoop.cs)
|
||||||
|
|
||||||
|
**功能**: 主动模式的核心循环
|
||||||
|
|
||||||
|
**状态**:
|
||||||
|
- `IsRunning`: 是否运行中
|
||||||
|
- `CurrentObjective`: 当前目标
|
||||||
|
- `DecisionCount`: 已执行决策次数
|
||||||
|
|
||||||
|
**关键API**:
|
||||||
|
```csharp
|
||||||
|
public class AutonomousAgentLoop : GameComponent
|
||||||
|
{
|
||||||
|
public static AutonomousAgentLoop Instance;
|
||||||
|
|
||||||
|
// 开始执行目标
|
||||||
|
public void StartObjective(string objective);
|
||||||
|
|
||||||
|
// 停止Agent
|
||||||
|
public void Stop();
|
||||||
|
|
||||||
|
// 事件
|
||||||
|
public event Action<string> OnDecisionMade;
|
||||||
|
public event Action<string> OnObjectiveComplete;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**待完成**: [ExecuteDecision()](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/AutonomousAgentLoop.cs#244-269) 方法需要整合工具执行逻辑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 原生API工具
|
||||||
|
|
||||||
|
| 工具 | 路径 | 功能 | 参数格式 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| [Tool_GetGameState](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetGameState.cs#8-45) | Tools/ | 获取游戏状态 | `<get_game_state/>` |
|
||||||
|
| [Tool_DesignateMine](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DesignateMine.cs#12-139) | Tools/ | 采矿指令 | `<designate_mine><x>数字</x><z>数字</z><radius>可选</radius></designate_mine>` |
|
||||||
|
| [Tool_DraftPawn](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DraftPawn.cs#10-105) | Tools/ | 征召殖民者 | `<draft_pawn><pawn_name>名字</pawn_name><draft>true/false</draft></draft_pawn>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 待完成任务
|
||||||
|
|
||||||
|
### 4.1 模式切换整合 (高优先级)
|
||||||
|
|
||||||
|
**目标**: 在 [AIIntelligenceCore](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs#16-1364) 中实现被动/主动模式切换
|
||||||
|
|
||||||
|
**实现思路**:
|
||||||
|
```csharp
|
||||||
|
// AIIntelligenceCore 中添加
|
||||||
|
private bool _isAgentMode = false;
|
||||||
|
|
||||||
|
public void ProcessUserMessage(string message)
|
||||||
|
{
|
||||||
|
// 检测是否触发主动模式
|
||||||
|
if (IsAgentTrigger(message, out string objective, out float duration))
|
||||||
|
{
|
||||||
|
_isAgentMode = true;
|
||||||
|
AutonomousAgentLoop.Instance.StartObjective(objective);
|
||||||
|
// 设置定时器,duration后自动停止
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// 正常对话处理
|
||||||
|
RunConversation(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsAgentTrigger(string msg, out string obj, out float dur)
|
||||||
|
{
|
||||||
|
// 匹配模式:
|
||||||
|
// "帮我玩10分钟" → obj="管理殖民地", dur=600
|
||||||
|
// "帮我挖点铁" → obj="采集铁矿", dur=0(无限)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 工具执行整合 (高优先级)
|
||||||
|
|
||||||
|
**目标**: 让 `AutonomousAgentLoop.ExecuteDecision()` 能够执行工具
|
||||||
|
|
||||||
|
**当前状态**: 方法体是空的 TODO
|
||||||
|
|
||||||
|
**实现思路**:
|
||||||
|
```csharp
|
||||||
|
private void ExecuteDecision(string decision)
|
||||||
|
{
|
||||||
|
// 1. 检查特殊标记
|
||||||
|
if (decision.Contains("<no_action")) return;
|
||||||
|
if (decision.Contains("<objective_complete"))
|
||||||
|
{
|
||||||
|
OnObjectiveComplete?.Invoke(_currentObjective);
|
||||||
|
Stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取工具实例
|
||||||
|
var core = AIIntelligenceCore.Instance;
|
||||||
|
var tools = core.GetAvailableTools();
|
||||||
|
|
||||||
|
// 3. 解析XML工具调用(复用AIIntelligenceCore的逻辑)
|
||||||
|
foreach (var tool in tools)
|
||||||
|
{
|
||||||
|
if (decision.Contains($"<{tool.Name}"))
|
||||||
|
{
|
||||||
|
string result = tool.Execute(decision);
|
||||||
|
WulaLog.Debug($"Tool {tool.Name}: {result}");
|
||||||
|
_lastToolResult = result;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 AI待办清单 (中优先级)
|
||||||
|
|
||||||
|
**目标**: AI 维护自己的待办清单,持久化到游戏存档
|
||||||
|
|
||||||
|
**设计**:
|
||||||
|
```csharp
|
||||||
|
public class AgentTodoList : IExposable
|
||||||
|
{
|
||||||
|
public List<TodoItem> Items = new List<TodoItem>();
|
||||||
|
|
||||||
|
public void ExposeData()
|
||||||
|
{
|
||||||
|
Scribe_Collections.Look(ref Items, "todoItems", LookMode.Deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TodoItem : IExposable
|
||||||
|
{
|
||||||
|
public string Description;
|
||||||
|
public bool IsComplete;
|
||||||
|
public int Priority;
|
||||||
|
public int CreatedTick;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Qwen-Omni-Realtime 测试 (低优先级)
|
||||||
|
|
||||||
|
**目标**: 测试 WebSocket 流式连接
|
||||||
|
|
||||||
|
**已完成**: [OmniRealtimeClient.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/OmniRealtimeClient.cs) 基础实现
|
||||||
|
|
||||||
|
**待测试**:
|
||||||
|
- WebSocket 连接建立
|
||||||
|
- 图片发送 (`input_image_buffer.append`)
|
||||||
|
- 文本接收 (`response.text.delta`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 关键接口参考
|
||||||
|
|
||||||
|
### 5.1 AITool 基类
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public abstract class AITool
|
||||||
|
{
|
||||||
|
public abstract string Name { get; }
|
||||||
|
public abstract string Description { get; }
|
||||||
|
public abstract string UsageSchema { get; }
|
||||||
|
public abstract string Execute(string args);
|
||||||
|
|
||||||
|
// 解析XML参数
|
||||||
|
protected Dictionary<string, string> ParseXmlArgs(string xmlContent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 SimpleAIClient API
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class SimpleAIClient
|
||||||
|
{
|
||||||
|
public SimpleAIClient(string apiKey, string baseUrl, string model);
|
||||||
|
|
||||||
|
// 文本对话
|
||||||
|
public Task<string> GetChatCompletionAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
List<(string role, string message)> messages,
|
||||||
|
int maxTokens = 1024,
|
||||||
|
float temperature = 0.7f
|
||||||
|
);
|
||||||
|
|
||||||
|
// VLM 视觉分析
|
||||||
|
public Task<string> GetVisionCompletionAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
string userPrompt,
|
||||||
|
string base64Image,
|
||||||
|
int maxTokens = 1024,
|
||||||
|
float temperature = 0.7f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 设置 (WulaFallenEmpireSettings)
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class WulaFallenEmpireSettings : ModSettings
|
||||||
|
{
|
||||||
|
// 主模型
|
||||||
|
public string apiKey;
|
||||||
|
public string baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||||
|
public string model = "qwen-turbo";
|
||||||
|
|
||||||
|
// VLM 模型
|
||||||
|
public string vlmApiKey;
|
||||||
|
public string vlmBaseUrl;
|
||||||
|
public string vlmModel = "qwen-vl-max";
|
||||||
|
public bool enableVlmFeatures = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 开发指南
|
||||||
|
|
||||||
|
### 6.1 添加新工具
|
||||||
|
|
||||||
|
1. 在 [Tools/](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs#310-337) 创建新类继承 [AITool](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Tools/AITool.cs#8-41)
|
||||||
|
2. 实现 [Name](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs#636-642), [Description](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Agent/StateObserver.cs#156-187), `UsageSchema`, [Execute](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_VisualClick.cs#133-165)
|
||||||
|
3. 在 `AIIntelligenceCore.InitializeTools()` 中注册
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// 示例:Tool_BuildWall.cs
|
||||||
|
public class Tool_BuildWall : AITool
|
||||||
|
{
|
||||||
|
public override string Name => "build_wall";
|
||||||
|
public override string Description => "在指定位置放置墙壁蓝图";
|
||||||
|
public override string UsageSchema => "<build_wall><x>X</x><z>Z</z><stuff>材料</stuff></build_wall>";
|
||||||
|
|
||||||
|
public override string Execute(string args)
|
||||||
|
{
|
||||||
|
var dict = ParseXmlArgs(args);
|
||||||
|
// 实现建造逻辑
|
||||||
|
// GenConstruct.PlaceBlueprintForBuild(...)
|
||||||
|
return "Success: 墙壁蓝图已放置";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 调试技巧
|
||||||
|
|
||||||
|
- 使用 `WulaLog.Debug()` 输出日志
|
||||||
|
- 检查 RimWorld 的 `Player.log` 文件
|
||||||
|
- 在开发者模式下 `Prefs.DevMode = true` 显示更多信息
|
||||||
|
|
||||||
|
### 6.3 常见问题
|
||||||
|
|
||||||
|
**Q: .NET Framework 4.8 兼容性问题**
|
||||||
|
- 不支持 `TakeLast()` → 使用 `Skip(list.Count - n)`
|
||||||
|
- 不支持 `string.Contains(x, StringComparison)` → 使用 `IndexOf`
|
||||||
|
|
||||||
|
**Q: Unity 主线程限制**
|
||||||
|
- 异步操作结果需要回到主线程执行
|
||||||
|
- 使用 `LongEventHandler.ExecuteWhenFinished(() => { ... })`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 参考资源
|
||||||
|
|
||||||
|
- [RimWorld Modding Wiki](https://rimworldwiki.com/wiki/Modding)
|
||||||
|
- [Harmony Patching](https://harmony.pardeike.net/)
|
||||||
|
- [阿里云百炼 API](https://help.aliyun.com/zh/model-studio/)
|
||||||
|
- [Qwen-Omni 文档](https://help.aliyun.com/zh/model-studio/user-guide/qwen-omni)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**更新时间**: 2025-12-27
|
||||||
|
**作者**: Gemini AI Agent
|
||||||
3
Tools/mimo-v2-flash.txt
Normal file
3
Tools/mimo-v2-flash.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
https://api.xiaomimimo.com/v1
|
||||||
|
mimo-v2-flash
|
||||||
|
sk-cuwai2jix0zwrghj307pmvdpmtoc74j4uv9bejglxcs89tnx
|
||||||
1
Tools/momotalk
Submodule
1
Tools/momotalk
Submodule
Submodule Tools/momotalk added at 2a3e352738
150
Tools/task.md.resolved
Normal file
150
Tools/task.md.resolved
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# WulaLink UI 修复任务 - Codex 交接文档
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
RimWorld Mod: [WulaFallenEmpire](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire)
|
||||||
|
目标: 实现 MomoTalk 风格的悬浮 AI 聊天 UI (WulaLink),同时确保原有大 UI 不被破坏。
|
||||||
|
|
||||||
|
## 关键文件路径
|
||||||
|
```
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\
|
||||||
|
├── EventSystem\
|
||||||
|
│ ├── Dialog_CustomDisplay.cs # 基类,包含正确的按钮样式和布局逻辑
|
||||||
|
│ └── AI\
|
||||||
|
│ ├── AIIntelligenceCore.cs # AI 核心逻辑 (WorldComponent) - 已完成
|
||||||
|
│ ├── DebugActions_WulaLink.cs # Debug 入口
|
||||||
|
│ └── UI\
|
||||||
|
│ ├── Dialog_AIConversation.cs # 大 UI - 需要修复布局
|
||||||
|
│ ├── Overlay_WulaLink.cs # 小悬浮框 - 需要修复样式
|
||||||
|
│ ├── Overlay_WulaLink_Notification.cs # 通知弹窗 - 已完成
|
||||||
|
│ └── WulaLinkStyles.cs # 样式定义 - 需要调整颜色
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 1: 大 UI (Dialog_AIConversation) 布局损坏
|
||||||
|
|
||||||
|
### 现状
|
||||||
|
[Dialog_AIConversation](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs#108-133) 继承自 [Dialog_CustomDisplay](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs#10-668),但 [DoWindowContents](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs#1177-1328) 被完全重写,丢失了原有的沉浸式布局。
|
||||||
|
|
||||||
|
### 期望效果 (参考用户截图)
|
||||||
|
- 左侧: 大尺寸立绘 (占据约 60% 窗口)
|
||||||
|
- 右下: 半透明暗红色面板,包含:
|
||||||
|
- 标题 "「军团」,P.I.A"
|
||||||
|
- 描述文本区域
|
||||||
|
- 底部按钮 "打开通讯频道" (使用 [DrawCustomButton](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs#388-449) 样式)
|
||||||
|
- 点击按钮后进入聊天模式 (显示对话历史)
|
||||||
|
|
||||||
|
### 修复方案
|
||||||
|
恢复 `base.DoWindowContents(inRect)` 的调用,或手动复制 `Dialog_CustomDisplay.DoWindowContents` 的布局逻辑。
|
||||||
|
|
||||||
|
关键代码参考 ([Dialog_CustomDisplay.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs) 第 144-280 行):
|
||||||
|
```csharp
|
||||||
|
// 1. 绘制背景
|
||||||
|
if (background != null) GUI.DrawTexture(inRect, background, ScaleMode.StretchToFill);
|
||||||
|
|
||||||
|
// 2. 立绘 (Config.showPortrait)
|
||||||
|
Rect portraitRect = Config.GetScaledRect(Config.portraitSize, inRect);
|
||||||
|
|
||||||
|
// 3. 标题 (Config.showLabel)
|
||||||
|
Rect labelRect = Config.GetScaledRect(Config.labelSize, inRect);
|
||||||
|
|
||||||
|
// 4. 描述 (Config.showDescriptions)
|
||||||
|
Rect descriptionRect = Config.GetScaledRect(Config.descriptionsSize, inRect);
|
||||||
|
|
||||||
|
// 5. 选项按钮 (Config.showOptions)
|
||||||
|
Rect optionsRect = Config.GetScaledRect(Config.optionsListSize, inRect);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 2: 小悬浮框 (Overlay_WulaLink) 样式问题
|
||||||
|
|
||||||
|
### 2.1 背景颜色错误
|
||||||
|
**现状**: 使用纯黑背景 `new Color(10, 10, 12, 255)`
|
||||||
|
**期望**: 半透明暗红色,与大 UI 一致
|
||||||
|
|
||||||
|
修改 [WulaLinkStyles.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs):
|
||||||
|
```csharp
|
||||||
|
// 修改前
|
||||||
|
public static readonly Color BackgroundColor = new Color32(10, 10, 12, 255);
|
||||||
|
|
||||||
|
// 修改后
|
||||||
|
public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f); // 半透明暗红
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 消息间距过大
|
||||||
|
**原因**: `role == "tool"` 的消息也被渲染,占用空间但不显示内容。
|
||||||
|
|
||||||
|
修改 [Overlay_WulaLink.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs) 的 [DrawMessageList](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs#265-344) 方法:
|
||||||
|
```csharp
|
||||||
|
// 在 foreach 循环中添加过滤
|
||||||
|
foreach (var msg in history)
|
||||||
|
{
|
||||||
|
// 跳过工具调用消息
|
||||||
|
if (msg.role == "tool") continue;
|
||||||
|
|
||||||
|
// ... 其余渲染逻辑
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 气泡无圆角
|
||||||
|
**问题**: RimWorld 原生 `Widgets.DrawBoxSolid` 不支持圆角。
|
||||||
|
**方案**:
|
||||||
|
- 方案 A: 使用 9-slice 圆角贴图 (`Textures/UI/BubbleRounded.png`)
|
||||||
|
- 方案 B: 接受直角,但添加边框使其更精致
|
||||||
|
|
||||||
|
### 2.4 头像无圆形处理
|
||||||
|
**方案**: 使用圆形遮罩贴图覆盖在头像上,或创建 `Textures/UI/AvatarMask.png`
|
||||||
|
|
||||||
|
### 2.5 按钮样式不一致
|
||||||
|
**现状**: 使用默认 `Widgets.ButtonText`
|
||||||
|
**期望**: 使用 `Dialog_CustomDisplay.DrawCustomButton` 样式
|
||||||
|
|
||||||
|
修改 [Overlay_WulaLink.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs) 的 [DrawFooter](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs#345-385):
|
||||||
|
```csharp
|
||||||
|
// 修改前
|
||||||
|
if (Widgets.ButtonText(btnRect, ">"))
|
||||||
|
|
||||||
|
// 修改后 (需要静态化或复制 DrawCustomButton 方法)
|
||||||
|
DrawCustomButton(btnRect, ">", isEnabled: true);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 问题 3: 样式统一 (WulaLinkStyles.cs)
|
||||||
|
|
||||||
|
需要调整以下颜色以匹配大 UI 的"军团"风格:
|
||||||
|
```csharp
|
||||||
|
// Header: 深红色,与大 UI 标题栏一致
|
||||||
|
public static readonly Color HeaderColor = new Color(0.5f, 0.15f, 0.15f, 1f);
|
||||||
|
|
||||||
|
// 背景: 半透明暗红
|
||||||
|
public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f);
|
||||||
|
|
||||||
|
// AI 气泡: 深灰带红色边框
|
||||||
|
public static readonly Color StudentBubbleColor = new Color(0.15f, 0.12f, 0.12f, 1f);
|
||||||
|
public static readonly Color StudentStrokeColor = new Color(0.6f, 0.2f, 0.2f, 1f);
|
||||||
|
|
||||||
|
// 玩家气泡: 暗红色
|
||||||
|
public static readonly Color SenseiBubbleColor = new Color(0.4f, 0.15f, 0.15f, 1f);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
- [ ] 大 UI 显示正确的立绘、标题、描述和按钮布局
|
||||||
|
- [ ] 小悬浮框背景为半透明暗红色
|
||||||
|
- [ ] 消息之间无多余空白
|
||||||
|
- [ ] 按钮使用暗红色自定义样式
|
||||||
|
- [ ] (可选) 气泡有圆角
|
||||||
|
- [ ] (可选) 头像为圆形
|
||||||
|
|
||||||
|
## 编译命令
|
||||||
|
```powershell
|
||||||
|
dotnet build C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试入口
|
||||||
|
1. 启动 RimWorld,加载存档
|
||||||
|
2. 打开 Debug Actions Menu
|
||||||
|
3. 搜索 "WulaLink" 并点击 "Open WulaLink UI"
|
||||||
140
Tools/task_handoff.md
Normal file
140
Tools/task_handoff.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# WulaLink 任务交接文档
|
||||||
|
|
||||||
|
## 当前状态:需要创建 AIIntelligenceCore.cs
|
||||||
|
|
||||||
|
### 背景
|
||||||
|
WulaLink 是一个 RimWorld Mod 中的 AI 对话系统,包含两个 UI:
|
||||||
|
1. **大 UI** (`Dialog_AIConversation`) - 全屏对话窗口
|
||||||
|
2. **小 UI** (`Overlay_WulaLink`) - 悬浮对话窗口
|
||||||
|
|
||||||
|
### 已完成的操作
|
||||||
|
1. ✅ 恢复了 `Dialog_AIConversation.cs` 为旧版自包含版本(从备份文件 `Tools/using System;.cs` 复制)
|
||||||
|
2. ✅ 删除了损坏的 `AIIntelligenceCore.cs`
|
||||||
|
3. ✅ 重写了 `WulaLinkStyles.cs`(颜色主题配置)
|
||||||
|
|
||||||
|
### 当前编译错误
|
||||||
|
```
|
||||||
|
error CS0246: 未能找到类型或命名空间名"AIIntelligenceCore"
|
||||||
|
```
|
||||||
|
|
||||||
|
以下文件引用了 `AIIntelligenceCore`:
|
||||||
|
- `Overlay_WulaLink.cs` (第13行, 第94行)
|
||||||
|
- `Overlay_WulaLink_Notification.cs` (第89行)
|
||||||
|
- `Tool_ChangeExpression.cs` (第24行)
|
||||||
|
- `Tool_GetRecentNotifications.cs` (第113行)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 需要完成的任务
|
||||||
|
|
||||||
|
### 任务:创建 AIIntelligenceCore.cs
|
||||||
|
|
||||||
|
**路径**: `Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs`
|
||||||
|
|
||||||
|
**要求**:
|
||||||
|
1. 必须是 `WorldComponent`,类名 `AIIntelligenceCore`
|
||||||
|
2. 提供 `static Instance` 属性供外部访问
|
||||||
|
3. 从 `Dialog_AIConversation`(备份文件 `Tools/using System;.cs`)提取 AI 核心逻辑
|
||||||
|
4. 暴露事件/回调供 UI 使用
|
||||||
|
|
||||||
|
**必须包含的公共接口**(根据现有代码引用):
|
||||||
|
```csharp
|
||||||
|
public class AIIntelligenceCore : WorldComponent
|
||||||
|
{
|
||||||
|
// 静态实例
|
||||||
|
public static AIIntelligenceCore Instance { get; private set; }
|
||||||
|
|
||||||
|
// 事件回调
|
||||||
|
public event Action<string> OnMessageReceived;
|
||||||
|
public event Action<bool> OnThinkingStateChanged;
|
||||||
|
public event Action<int> OnExpressionChanged;
|
||||||
|
|
||||||
|
// 公共属性
|
||||||
|
public int ExpressionId { get; }
|
||||||
|
public bool IsThinking { get; }
|
||||||
|
|
||||||
|
// 公共方法
|
||||||
|
public void InitializeConversation(string eventDefName);
|
||||||
|
public List<(string role, string message)> GetHistorySnapshot();
|
||||||
|
public void SetExpression(int id); // 供 Tool_ChangeExpression 调用
|
||||||
|
public void SendMessage(string text); // 供小 UI 调用
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参考代码**:
|
||||||
|
- 备份文件 `Tools/using System;.cs` 包含完整的 AI 逻辑(1549行)
|
||||||
|
- 核心方法包括:
|
||||||
|
- `RunPhasedRequestAsync()` - 3阶段请求处理
|
||||||
|
- `ExecuteXmlToolsForPhase()` - 工具执行
|
||||||
|
- `BuildToolContext()` / `BuildReplyHistory()` - 上下文构建
|
||||||
|
- `ParseResponse()` - 响应解析
|
||||||
|
- `GetSystemInstruction()` / `GetToolSystemInstruction()` - 提示词生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键文件路径
|
||||||
|
|
||||||
|
```
|
||||||
|
C:\Steam\steamapps\common\RimWorld\Mods\3516260226\
|
||||||
|
├── Tools\
|
||||||
|
│ └── using System;.cs # 旧版 Dialog_AIConversation 备份(包含完整 AI 逻辑)
|
||||||
|
└── Source\WulaFallenEmpire\EventSystem\AI\
|
||||||
|
├── AIIntelligenceCore.cs # 【需要创建】
|
||||||
|
├── AIHistoryManager.cs # 历史记录管理
|
||||||
|
├── AIMemoryManager.cs # 记忆管理
|
||||||
|
├── SimpleAIClient.cs # API 客户端
|
||||||
|
├── Tools\ # AI 工具目录
|
||||||
|
│ ├── Tool_SpawnResources.cs
|
||||||
|
│ ├── Tool_SendReinforcement.cs
|
||||||
|
│ └── ... (其他工具)
|
||||||
|
└── UI\
|
||||||
|
├── Dialog_AIConversation.cs # 大 UI(已恢复)
|
||||||
|
├── Overlay_WulaLink.cs # 小 UI(需要修复引用)
|
||||||
|
├── Overlay_WulaLink_Notification.cs
|
||||||
|
└── WulaLinkStyles.cs # 样式配置(已重写)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 编译命令
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
### 目标架构
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ AIIntelligenceCore │ ← WorldComponent (核心逻辑)
|
||||||
|
│ - 历史记录管理 │
|
||||||
|
│ - AI 请求处理 (3阶段) │
|
||||||
|
│ - 工具执行 │
|
||||||
|
│ - 表情/状态管理 │
|
||||||
|
└──────────────┬──────────────────────┘
|
||||||
|
│ 事件回调
|
||||||
|
┌──────────┴──────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────┐ ┌──────────────┐
|
||||||
|
│ Dialog_AI │ │ Overlay_ │
|
||||||
|
│ Conversation│ │ WulaLink │
|
||||||
|
│ (大 UI) │ │ (小 UI) │
|
||||||
|
└─────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键点
|
||||||
|
1. `Dialog_AIConversation` 目前是**自包含**的(既有 UI 也有 AI 逻辑)
|
||||||
|
2. `Overlay_WulaLink` 需要通过 `AIIntelligenceCore` 获取数据
|
||||||
|
3. 两个 UI 可以共享同一个 `AIIntelligenceCore` 实例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **不要使用 PowerShell Get-Content 读取文件** - 会显示乱码,请使用 `view_file` 工具
|
||||||
|
2. **备份文件编码正常** - `Tools/using System;.cs` 可以正常读取
|
||||||
|
3. **命名空间**:`WulaFallenEmpire.EventSystem.AI`
|
||||||
|
4. **依赖项**:需要引用 `SimpleAIClient`、`AIHistoryManager`、`AITool` 等现有类
|
||||||
110
Tools/thenextagent-1/README.md
Normal file
110
Tools/thenextagent-1/README.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# VLM Agent - 视觉语言模型电脑操作工具
|
||||||
|
|
||||||
|
基于Qwen-VL模型的自动化电脑操作工具,可以通过自然语言指令控制电脑完成各种任务。
|
||||||
|
|
||||||
|
## 项目简介
|
||||||
|
|
||||||
|
这是一个利用视觉语言模型(VLM)实现的电脑自动化操作工具,能够通过分析屏幕截图并执行相应操作来完成用户指定的任务。该工具可以模拟人类操作电脑的行为,包括鼠标点击、文本输入、窗口滚动等。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
### 支持的操作工具
|
||||||
|
|
||||||
|
1. **鼠标点击** - 在指定坐标点击鼠标
|
||||||
|
2. **文本输入** - 在指定位置输入文本(支持中英文)
|
||||||
|
3. **窗口滚动** - 在指定位置向上或向下滚动
|
||||||
|
4. **关闭窗口** - 关闭指定坐标所在的窗口
|
||||||
|
5. **Windows键** - 按下Windows键打开开始菜单
|
||||||
|
6. **回车键** - 按下回车键确认或换行
|
||||||
|
7. **删除文本** - 删除指定输入框中的文本
|
||||||
|
8. **鼠标拖拽** - 从起始坐标拖拽到结束坐标
|
||||||
|
9. **等待** - 等待指定时间
|
||||||
|
10. **打开终端** - 打开新的终端窗口
|
||||||
|
11. **快捷键** - 在指定位置点击后执行快捷键操作
|
||||||
|
|
||||||
|
### 特色功能
|
||||||
|
|
||||||
|
- **坐标系统**:使用0-1比例坐标系统,适配不同分辨率屏幕
|
||||||
|
- **图像处理**:自动缩放截图至最大边长1024像素以优化API调用
|
||||||
|
- **智能解析**:自动解析模型输出的工具调用指令
|
||||||
|
- **跨平台支持**:支持Windows、macOS和Linux系统
|
||||||
|
|
||||||
|
## 安装与使用
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Python 3.6+
|
||||||
|
- 阿里云API密钥(用于调用Qwen-VL模型)
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pyautogui pillow openai pyperclip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行程序
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
首次运行时,程序会提示您输入阿里云API密钥。
|
||||||
|
|
||||||
|
### 获取阿里云API密钥
|
||||||
|
|
||||||
|
1. 访问 [阿里云官网](https://www.aliyun.com/)
|
||||||
|
2. 注册或登录账号
|
||||||
|
3. 进入[阿里云控制台](https://home.console.aliyun.com/)
|
||||||
|
4. 开通DashScope服务并获取API密钥
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
程序运行后,您可以尝试以下任务:
|
||||||
|
|
||||||
|
- "打开记事本并输入'Hello World'"
|
||||||
|
- "在浏览器中搜索'人工智能'"
|
||||||
|
- "创建一个名为'test.txt'的文件"
|
||||||
|
- "打开计算器并计算2+3的结果"
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 程序运行时,请勿手动操作电脑,以免干扰自动化流程
|
||||||
|
2. 如需紧急停止程序,可将鼠标快速移至屏幕左上角触发PyAutoGUI安全机制
|
||||||
|
3. 坐标系统使用比例值,x和y的取值范围都是0到1之间的小数
|
||||||
|
4. 请确保网络连接稳定,以便正常调用模型API
|
||||||
|
5. 不要在程序运行时关闭终端窗口
|
||||||
|
|
||||||
|
## 安全提醒
|
||||||
|
|
||||||
|
- API密钥是敏感信息,请妥善保管
|
||||||
|
- 程序只能执行您授权的任务,请勿尝试危险操作
|
||||||
|
- 如发现异常行为,请立即终止程序运行
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
- **核心控制器**:VLMAgent类负责API连接、截图、坐标转换和操作执行
|
||||||
|
- **模型服务**:基于阿里云Qwen-VL模型提供视觉语言理解能力
|
||||||
|
- **操作执行**:通过pyautogui库实现底层的鼠标和键盘操作
|
||||||
|
- **图像处理**:使用PIL库处理屏幕截图以优化API传输效率
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── main.py # 主程序文件
|
||||||
|
└── README.md # 项目说明文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 如何提高操作准确性?
|
||||||
|
|
||||||
|
如果发现鼠标点击位置不准确,可能是坐标转换存在问题,程序会自动微调坐标值。如果是软件正在运行导致操作延迟,建议增加等待时间。
|
||||||
|
|
||||||
|
### 支持哪些操作系统?
|
||||||
|
|
||||||
|
支持Windows、macOS和Linux主流操作系统。
|
||||||
|
|
||||||
|
### 最多执行多少步操作?
|
||||||
|
|
||||||
|
默认情况下,程序最多执行50步操作以防止无限循环。
|
||||||
725
Tools/thenextagent-1/main.py
Normal file
725
Tools/thenextagent-1/main.py
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
import base64
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from openai import OpenAI
|
||||||
|
import pyautogui
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tkinter as tk
|
||||||
|
import subprocess
|
||||||
|
import platform
|
||||||
|
import os
|
||||||
|
|
||||||
|
class VLMAgent:
|
||||||
|
def __init__(self, api_key, model_name="qwen3-vl-plus"):
|
||||||
|
"""
|
||||||
|
初始化VLM代理
|
||||||
|
"""
|
||||||
|
self.client = OpenAI(
|
||||||
|
api_key=api_key,
|
||||||
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
)
|
||||||
|
self.model_name = model_name
|
||||||
|
self.messages = []
|
||||||
|
self.screen_width, self.screen_height = self.get_screen_resolution()
|
||||||
|
print(f"屏幕分辨率: {self.screen_width} x {self.screen_height}")
|
||||||
|
|
||||||
|
# 启用PyAutoGUI的安全机制,将鼠标移到屏幕左上角可紧急停止
|
||||||
|
pyautogui.FAILSAFE = True
|
||||||
|
pyautogui.PAUSE = 1 # 每次操作后暂停1秒
|
||||||
|
|
||||||
|
self.tools = {
|
||||||
|
"mouse_click": self.mouse_click,
|
||||||
|
"type_text": self.type_text,
|
||||||
|
"scroll_window": self.scroll_window,
|
||||||
|
"close_window": self.close_window,
|
||||||
|
"press_windows_key": self.press_windows_key,
|
||||||
|
"press_enter": self.press_enter,
|
||||||
|
"delete_text": self.delete_text,
|
||||||
|
"mouse_drag": self.mouse_drag,
|
||||||
|
"wait": self.wait,
|
||||||
|
"open_terminal": self.open_terminal,
|
||||||
|
"press_hotkey": self.press_hotkey
|
||||||
|
}
|
||||||
|
|
||||||
|
def mouse_drag(self, start_x, start_y, end_x, end_y, duration=0.5):
|
||||||
|
"""
|
||||||
|
鼠标拖拽工具 - 从起始坐标拖拽到结束坐标
|
||||||
|
:param start_x: 起始点比例x坐标 (0-1之间的小数)
|
||||||
|
:param start_y: 起始点比例y坐标 (0-1之间的小数)
|
||||||
|
:param end_x: 结束点比例x坐标 (0-1之间的小数)
|
||||||
|
:param end_y: 结束点比例y坐标 (0-1之间的小数)
|
||||||
|
:param duration: 拖拽过程耗时(秒),默认为0.5秒
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_start_x = int(start_x * self.screen_width)
|
||||||
|
actual_start_y = int(start_y * self.screen_height)
|
||||||
|
actual_end_x = int(end_x * self.screen_width)
|
||||||
|
actual_end_y = int(end_y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"拖拽起始坐标转换: ({start_x:.3f}, {start_y:.3f}) -> ({actual_start_x}, {actual_start_y})")
|
||||||
|
print(f"拖拽结束坐标转换: ({end_x:.3f}, {end_y:.3f}) -> ({actual_end_x}, {actual_end_y})")
|
||||||
|
|
||||||
|
# 验证起始坐标范围
|
||||||
|
if not (0 <= actual_start_x <= self.screen_width and 0 <= actual_start_y <= self.screen_height):
|
||||||
|
return f"起始坐标 ({actual_start_x}, {actual_start_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 验证结束坐标范围
|
||||||
|
if not (0 <= actual_end_x <= self.screen_width and 0 <= actual_end_y <= self.screen_height):
|
||||||
|
return f"结束坐标 ({actual_end_x}, {actual_end_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 执行拖拽操作
|
||||||
|
pyautogui.moveTo(actual_start_x, actual_start_y)
|
||||||
|
pyautogui.dragTo(actual_end_x, actual_end_y, duration=duration)
|
||||||
|
|
||||||
|
return f"成功从坐标 ({actual_start_x}, {actual_start_y}) 拖拽到 ({actual_end_x}, {actual_end_y}) (比例坐标: ({start_x:.3f}, {start_y:.3f}) -> ({end_x:.3f}, {end_y:.3f}))"
|
||||||
|
except Exception as e:
|
||||||
|
return f"拖拽操作失败: {str(e)}"
|
||||||
|
|
||||||
|
def wait(self, seconds):
|
||||||
|
"""
|
||||||
|
等待工具 - 等待指定的秒数
|
||||||
|
:param seconds: 等待时间(秒),可以是整数或小数
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 确保等待时间是合理的数值
|
||||||
|
wait_time = float(seconds)
|
||||||
|
if wait_time <= 0:
|
||||||
|
return "等待时间必须是正数"
|
||||||
|
|
||||||
|
print(f"等待 {wait_time} 秒...")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
return f"成功等待了 {wait_time} 秒"
|
||||||
|
except Exception as e:
|
||||||
|
return f"等待操作失败: {str(e)}"
|
||||||
|
|
||||||
|
def open_terminal(self, command=""):
|
||||||
|
"""
|
||||||
|
打开新终端窗口的工具
|
||||||
|
:param command: 可选,在新终端中执行的命令
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
if system == "Windows":
|
||||||
|
if command:
|
||||||
|
# 在新终端窗口中执行命令
|
||||||
|
cmd = f'start cmd /k "{command}"'
|
||||||
|
subprocess.run(cmd, shell=True)
|
||||||
|
else:
|
||||||
|
# 仅打开新终端窗口
|
||||||
|
subprocess.run('start cmd', shell=True)
|
||||||
|
|
||||||
|
elif system == "Darwin": # macOS
|
||||||
|
if command:
|
||||||
|
# 在新终端窗口中执行命令
|
||||||
|
subprocess.run(['osascript', '-e', f'tell app "Terminal" to do script "{command}"'])
|
||||||
|
subprocess.run(['osascript', '-e', 'tell app "Terminal" to activate'])
|
||||||
|
else:
|
||||||
|
# 仅打开新终端窗口
|
||||||
|
subprocess.run(['open', '-a', 'Terminal'])
|
||||||
|
|
||||||
|
else: # Linux或其他Unix系统
|
||||||
|
terminals = ['gnome-terminal', 'konsole', 'xterm']
|
||||||
|
terminal_found = False
|
||||||
|
|
||||||
|
for terminal in terminals:
|
||||||
|
if subprocess.run(['which', terminal], capture_output=True).returncode == 0:
|
||||||
|
if command:
|
||||||
|
if terminal == 'gnome-terminal':
|
||||||
|
subprocess.run([terminal, '--', 'bash', '-c', f'{command}; exec bash'])
|
||||||
|
elif terminal == 'konsole':
|
||||||
|
subprocess.run([terminal, '-e', 'bash', '-c', f'{command}; exec bash'])
|
||||||
|
else: # xterm
|
||||||
|
subprocess.run([terminal, '-e', 'bash', '-c', f'{command}; exec bash'])
|
||||||
|
else:
|
||||||
|
subprocess.run([terminal])
|
||||||
|
|
||||||
|
terminal_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not terminal_found:
|
||||||
|
return f"未找到支持的终端程序,支持的终端包括: {', '.join(terminals)}"
|
||||||
|
|
||||||
|
if command:
|
||||||
|
return f"成功在新终端中执行命令: {command}"
|
||||||
|
else:
|
||||||
|
return "成功打开新终端窗口"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"打开终端失败: {str(e)}"
|
||||||
|
|
||||||
|
def press_hotkey(self, x, y, hotkey):
|
||||||
|
"""
|
||||||
|
在指定位置点击后模拟键盘快捷键的工具
|
||||||
|
:param x: 比例x坐标 (0-1之间的小数)
|
||||||
|
:param y: 比例y坐标 (0-1之间的小数)
|
||||||
|
:param hotkey: 快捷键组合,例如 "ctrl+c", "ctrl+v", "alt+f4" 等
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"定位到坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})")
|
||||||
|
|
||||||
|
# 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 点击指定位置
|
||||||
|
pyautogui.click(actual_x, actual_y)
|
||||||
|
time.sleep(0.5) # 等待点击生效
|
||||||
|
|
||||||
|
# 解析快捷键组合
|
||||||
|
keys = hotkey.lower().replace('+', ' ').replace('-', ' ').split()
|
||||||
|
|
||||||
|
# 执行快捷键
|
||||||
|
if len(keys) == 1:
|
||||||
|
pyautogui.press(keys[0])
|
||||||
|
else:
|
||||||
|
# 使用hotkey方法处理组合键
|
||||||
|
pyautogui.hotkey(*keys)
|
||||||
|
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处点击并执行快捷键: {hotkey}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"执行快捷键失败: {str(e)}"
|
||||||
|
|
||||||
|
def close_window(self, x, y):
|
||||||
|
"""
|
||||||
|
关闭窗口工具 - 先点击目标窗口获取焦点,再关闭窗口
|
||||||
|
:param x: 比例x坐标 (0-1之间的小数)
|
||||||
|
:param y: 比例y坐标 (0-1之间的小数)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 先点击目标窗口获取焦点
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"点击窗口坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})")
|
||||||
|
|
||||||
|
# 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 点击窗口
|
||||||
|
pyautogui.click(actual_x, actual_y)
|
||||||
|
time.sleep(0.5) # 等待窗口获得焦点
|
||||||
|
|
||||||
|
# 关闭窗口
|
||||||
|
pyautogui.hotkey('alt', 'f4')
|
||||||
|
return f"成功点击窗口坐标 ({actual_x}, {actual_y}) 并关闭窗口"
|
||||||
|
except Exception as e:
|
||||||
|
return f"关闭窗口失败: {str(e)}"
|
||||||
|
|
||||||
|
def press_windows_key(self):
|
||||||
|
"""
|
||||||
|
按下Windows键工具
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pyautogui.press('win')
|
||||||
|
return "成功按下Windows键"
|
||||||
|
except Exception as e:
|
||||||
|
return f"按下Windows键失败: {str(e)}"
|
||||||
|
|
||||||
|
def press_enter(self):
|
||||||
|
"""
|
||||||
|
按下回车键工具
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
pyautogui.press('enter')
|
||||||
|
return "成功按下回车键"
|
||||||
|
except Exception as e:
|
||||||
|
return f"按下回车键失败: {str(e)}"
|
||||||
|
|
||||||
|
def delete_text(self, x, y, count=1):
|
||||||
|
"""
|
||||||
|
删除输入框内文本的功能 - 点击输入框获取焦点,然后删除指定数量的字符
|
||||||
|
:param x: 比例x坐标 (0-1之间的小数)
|
||||||
|
:param y: 比例y坐标 (0-1之间的小数)
|
||||||
|
:param count: 要删除的字符数量,默认为1
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"定位到输入框坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})")
|
||||||
|
|
||||||
|
# 2. 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 3. 点击输入框获取焦点
|
||||||
|
pyautogui.click(actual_x, actual_y)
|
||||||
|
time.sleep(0.5) # 等待点击生效
|
||||||
|
|
||||||
|
# 4. 删除指定数量的字符
|
||||||
|
for _ in range(int(count)):
|
||||||
|
pyautogui.press('backspace')
|
||||||
|
time.sleep(0.01) # 每次删除之间稍作停顿
|
||||||
|
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处删除 {int(count)} 个字符"
|
||||||
|
except Exception as e:
|
||||||
|
return f"删除文本失败: {str(e)}"
|
||||||
|
|
||||||
|
def get_screen_resolution(self):
|
||||||
|
"""
|
||||||
|
获取屏幕分辨率
|
||||||
|
"""
|
||||||
|
root = tk.Tk()
|
||||||
|
width = root.winfo_screenwidth()
|
||||||
|
height = root.winfo_screenheight()
|
||||||
|
root.destroy()
|
||||||
|
return width, height
|
||||||
|
|
||||||
|
def capture_screenshot(self):
|
||||||
|
"""
|
||||||
|
截取当前屏幕截图,并返回实际尺寸用于坐标转换
|
||||||
|
"""
|
||||||
|
# 获取原始屏幕截图
|
||||||
|
screenshot = pyautogui.screenshot()
|
||||||
|
self.original_width, self.original_height = screenshot.size
|
||||||
|
print(f"原始截图尺寸: {self.original_width} x {self.original_height}")
|
||||||
|
|
||||||
|
# 缩小图片尺寸以减少API调用的数据量,但保持宽高比
|
||||||
|
max_size = 1024
|
||||||
|
width, height = screenshot.size
|
||||||
|
if width > height:
|
||||||
|
new_width = min(max_size, width)
|
||||||
|
new_height = int(height * new_width / width)
|
||||||
|
else:
|
||||||
|
new_height = min(max_size, height)
|
||||||
|
new_width = int(width * new_height / height)
|
||||||
|
|
||||||
|
self.scaled_width = new_width
|
||||||
|
self.scaled_height = new_height
|
||||||
|
print(f"缩放后截图尺寸: {self.scaled_width} x {self.scaled_height}")
|
||||||
|
|
||||||
|
screenshot = screenshot.resize((new_width, new_height))
|
||||||
|
|
||||||
|
# 将截图保存到内存缓冲区
|
||||||
|
img_buffer = io.BytesIO()
|
||||||
|
screenshot.save(img_buffer, format='PNG')
|
||||||
|
img_buffer.seek(0)
|
||||||
|
|
||||||
|
return img_buffer
|
||||||
|
|
||||||
|
def convert_coordinates(self, x, y):
|
||||||
|
"""
|
||||||
|
将模型返回的坐标(基于缩放后的截图)转换为实际屏幕坐标
|
||||||
|
"""
|
||||||
|
# 计算坐标缩放比例
|
||||||
|
x_ratio = self.original_width / self.scaled_width
|
||||||
|
y_ratio = self.original_height / self.scaled_height
|
||||||
|
|
||||||
|
# 转换坐标
|
||||||
|
actual_x = int(x * x_ratio)
|
||||||
|
actual_y = int(y * y_ratio)
|
||||||
|
|
||||||
|
print(f"坐标转换: ({x}, {y}) -> ({actual_x}, {actual_y}) (缩放比例: {x_ratio:.2f}, {y_ratio:.2f})")
|
||||||
|
|
||||||
|
return actual_x, actual_y
|
||||||
|
|
||||||
|
def encode_image_to_base64(self, image_buffer):
|
||||||
|
"""
|
||||||
|
将图片编码为base64字符串
|
||||||
|
"""
|
||||||
|
return base64.b64encode(image_buffer.read()).decode('utf-8')
|
||||||
|
|
||||||
|
def mouse_click(self, x, y, button="left", clicks=1):
|
||||||
|
"""
|
||||||
|
鼠标点击工具 - 使用比例坐标 (0-1之间的浮点数)
|
||||||
|
:param x: 比例x坐标 (0-1之间的小数)
|
||||||
|
:param y: 比例y坐标 (0-1之间的小数)
|
||||||
|
:param button: 鼠标按键,"left"表示左键,"right"表示右键
|
||||||
|
:param clicks: 点击次数,1表示单击,2表示双击
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"比例坐标转换: ({x:.3f}, {y:.3f}) -> ({actual_x}, {actual_y})")
|
||||||
|
|
||||||
|
# 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 移动并点击鼠标,确保clicks是整数类型
|
||||||
|
pyautogui.click(actual_x, actual_y, button=button, clicks=int(clicks))
|
||||||
|
|
||||||
|
button_text = "左键" if button == "left" else "右键"
|
||||||
|
click_text = "单击" if clicks == 1 else "双击"
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处{button_text}{click_text} (比例坐标: {x:.3f}, {y:.3f})"
|
||||||
|
except Exception as e:
|
||||||
|
return f"点击失败: {str(e)}"
|
||||||
|
|
||||||
|
def scroll_window(self, x, y, direction="up"):
|
||||||
|
"""
|
||||||
|
滚动窗口工具:在指定坐标处滚动窗口
|
||||||
|
:param x: 比例x坐标 (0-1之间的小数)
|
||||||
|
:param y: 比例y坐标 (0-1之间的小数)
|
||||||
|
:param direction: 滚动方向,"up"表示向上滚动,"down"表示向下滚动
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 固定滚动步数
|
||||||
|
fixed_clicks = 1400
|
||||||
|
|
||||||
|
# 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"滚动窗口 - 比例坐标转换: ({x:.3f}, {y:.3f}) -> ({actual_x}, {actual_y})")
|
||||||
|
|
||||||
|
# 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 根据方向确定实际滚动步数
|
||||||
|
clicks = fixed_clicks if direction == "up" else -fixed_clicks
|
||||||
|
|
||||||
|
# 移动到指定位置并滚动
|
||||||
|
pyautogui.scroll(clicks, x=actual_x, y=actual_y)
|
||||||
|
direction_text = "向上" if direction == "up" else "向下"
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处{direction_text}滚动 {fixed_clicks} 步 (比例坐标: {x:.3f}, {y:.3f})"
|
||||||
|
except Exception as e:
|
||||||
|
return f"滚动窗口失败: {str(e)}"
|
||||||
|
|
||||||
|
def type_text(self, x, y, text):
|
||||||
|
"""
|
||||||
|
增强的文本输入工具:先点击指定位置,再通过复制粘贴方式输入文本
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pyperclip
|
||||||
|
|
||||||
|
# 1. 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
print(f"定位到坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})")
|
||||||
|
|
||||||
|
# 2. 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 3. 点击输入位置
|
||||||
|
pyautogui.click(actual_x, actual_y)
|
||||||
|
time.sleep(0.5) # 等待点击生效
|
||||||
|
|
||||||
|
# 4. 将文本复制到剪贴板
|
||||||
|
pyperclip.copy(text)
|
||||||
|
time.sleep(0.2) # 等待复制完成
|
||||||
|
|
||||||
|
# 5. 粘贴文本
|
||||||
|
pyautogui.hotkey('ctrl', 'v')
|
||||||
|
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处输入文本: {text}"
|
||||||
|
except ImportError:
|
||||||
|
# 如果没有安装pyperclip,则回退到原来的方法
|
||||||
|
return self._type_text_fallback(x, y, text)
|
||||||
|
except Exception as e:
|
||||||
|
return f"输入文本失败: {str(e)}"
|
||||||
|
|
||||||
|
def _type_text_fallback(self, x, y, text):
|
||||||
|
"""
|
||||||
|
回退的文本输入方法
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 1. 将比例坐标转换为实际屏幕坐标
|
||||||
|
actual_x = int(x * self.screen_width)
|
||||||
|
actual_y = int(y * self.screen_height)
|
||||||
|
|
||||||
|
# 2. 验证坐标范围
|
||||||
|
if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height):
|
||||||
|
return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})"
|
||||||
|
|
||||||
|
# 3. 点击输入位置
|
||||||
|
pyautogui.click(actual_x, actual_y)
|
||||||
|
time.sleep(0.5) # 等待点击生效
|
||||||
|
|
||||||
|
# 4. 输入文本(支持英文)
|
||||||
|
pyautogui.write(text, interval=0.1)
|
||||||
|
|
||||||
|
return f"成功在坐标 ({actual_x}, {actual_y}) 处输入文本: {text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"输入文本失败: {str(e)}"
|
||||||
|
|
||||||
|
def parse_tool_calls(self, response_text):
|
||||||
|
"""
|
||||||
|
解析工具调用指令
|
||||||
|
"""
|
||||||
|
# 使用正则表达式查找工具调用
|
||||||
|
tool_call_pattern = r'<\|tool_call\|>(.*?)<\|tool_call\|>'
|
||||||
|
tool_calls = re.findall(tool_call_pattern, response_text, re.DOTALL)
|
||||||
|
|
||||||
|
parsed_calls = []
|
||||||
|
for call in tool_calls:
|
||||||
|
call = call.strip()
|
||||||
|
# 解析函数名和参数
|
||||||
|
if '(' in call and ')' in call:
|
||||||
|
func_name = call.split('(')[0].strip()
|
||||||
|
args_str = call[len(func_name)+1:call.rfind(')')].strip()
|
||||||
|
|
||||||
|
# 简单解析参数
|
||||||
|
args = {}
|
||||||
|
if args_str:
|
||||||
|
# 处理参数字符串,例如: x=100, y=200
|
||||||
|
for arg in args_str.split(','):
|
||||||
|
if '=' in arg:
|
||||||
|
key, value = arg.split('=', 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
# 尝试转换为数字
|
||||||
|
try:
|
||||||
|
args[key] = float(value)
|
||||||
|
except ValueError:
|
||||||
|
args[key] = value
|
||||||
|
|
||||||
|
parsed_calls.append({
|
||||||
|
"name": func_name,
|
||||||
|
"arguments": args
|
||||||
|
})
|
||||||
|
|
||||||
|
return parsed_calls
|
||||||
|
|
||||||
|
def execute_tool_calls(self, tool_calls):
|
||||||
|
"""
|
||||||
|
执行工具调用
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for call in tool_calls:
|
||||||
|
func_name = call["name"]
|
||||||
|
args = call["arguments"]
|
||||||
|
|
||||||
|
if func_name in self.tools:
|
||||||
|
try:
|
||||||
|
result = self.tools[func_name](**args)
|
||||||
|
results.append(f"工具 {func_name} 执行结果: {result}")
|
||||||
|
except Exception as e:
|
||||||
|
results.append(f"执行工具 {func_name} 时出错: {str(e)}")
|
||||||
|
else:
|
||||||
|
results.append(f"未知工具: {func_name}")
|
||||||
|
|
||||||
|
return "\n".join(results)
|
||||||
|
|
||||||
|
def run_task(self, task_description, max_steps=50):
|
||||||
|
"""
|
||||||
|
运行任务
|
||||||
|
"""
|
||||||
|
print(f"开始执行任务: {task_description}")
|
||||||
|
print(f"屏幕分辨率: {self.screen_width} x {self.screen_height}")
|
||||||
|
|
||||||
|
# 添加系统提示词
|
||||||
|
system_prompt = f"""
|
||||||
|
你是一个用户助理,同时拥有操控电脑的能力,你现在面对看到的图像是电脑的用户界面,请分析屏幕内容(屏幕大小是{self.screen_width}*{self.screen_height}),如果需要操作电脑,请按以下格式调用工具:
|
||||||
|
|
||||||
|
<|tool_call|>函数名(参数1=值1, 参数2=值2)<|tool_call|>
|
||||||
|
|
||||||
|
可用的工具包括:
|
||||||
|
1. mouse_click(x=比例x, y=比例y, button="left", clicks=1) - 在指定坐标点击鼠标
|
||||||
|
- 坐标为比例(0-1之间的小数)
|
||||||
|
- button参数可以是"left"(左键,默认)或"right"(右键)
|
||||||
|
- clicks参数可以是1(单击,默认)或2(双击),必须是整数
|
||||||
|
例如:mouse_click(x=0.5, y=0.5) 表示在屏幕中心点左键单击
|
||||||
|
例如:mouse_click(x=0.3, y=0.4, button="right") 表示在坐标(0.3,0.4)处右键单击
|
||||||
|
例如:mouse_click(x=0.6, y=0.7, clicks=2) 表示在坐标(0.6,0.7)处左键双击
|
||||||
|
例如:mouse_click(x=0.8, y=0.9, button="right", clicks=2) 表示在坐标(0.8,0.9)处右键双击
|
||||||
|
2. type_text(x=比例x, y=比例y, text="要输入的文本") - 在指定坐标点击并输入文本,支持中英文输入
|
||||||
|
例如:type_text(x=0.3, y=0.4, text="你好世界") 表示在坐标(0.3,0.4)处点击并输入"你好世界"
|
||||||
|
例如:type_text(x=0.5, y=0.6, text="Hello World") 表示在坐标(0.5,0.6)处点击并输入"Hello World"
|
||||||
|
请注意:输入文字请一次性输入一行即可,然后需要回车换行或者编辑再调用其他工具执行。不要出现“/n”工具无法识别这种换行指令
|
||||||
|
3. scroll_window(x=比例x, y=比例y, direction="up") - 在指定坐标处滚动窗口,direction参数可以是"up"或"down",表示向上或向下滚动
|
||||||
|
例如:scroll_window(x=0.5, y=0.5, direction="up") 表示在屏幕中心位置向上滚动
|
||||||
|
例如:scroll_window(x=0.3, y=0.4, direction="down") 表示在坐标(0.3,0.4)处向下滚动
|
||||||
|
4. close_window(x=比例x, y=比例y) - 关闭指定坐标所在的窗口,先点击该窗口获取焦点再关闭
|
||||||
|
例如:close_window(x=0.5, y=0.5) 表示点击屏幕中心的窗口并关闭它
|
||||||
|
5. press_windows_key() - 按下Windows键,用于打开开始菜单
|
||||||
|
例如:press_windows_key() 表示按下Windows键
|
||||||
|
6. press_enter() - 按下回车键,可以用于换行或者确认
|
||||||
|
例如:press_enter() 表示按下回车键
|
||||||
|
7. delete_text(x=比例x, y=比例y, count=1) - 删除指定输入框中的文本
|
||||||
|
- 先点击输入框获取焦点,然后删除指定数量的字符
|
||||||
|
- count参数是要删除的字符数量,默认为1(你在设置的时候请尽可能精确)
|
||||||
|
例如:delete_text(x=0.4, y=0.5, count=5) 表示点击坐标(0.4,0.5)处的输入框并删除5个字符
|
||||||
|
例如:delete_text(x=0.6, y=0.7) 表示点击坐标(0.6,0.7)处的输入框并删除1个字符
|
||||||
|
8. mouse_drag(start_x=起始比例x, start_y=起始比例y, end_x=结束比例x, end_y=结束比例y, duration=0.5) - 从起始坐标拖拽到结束坐标
|
||||||
|
- 从起始点拖拽到结束点,duration参数为拖拽过程耗时(秒),默认为0.5秒
|
||||||
|
例如:mouse_drag(start_x=0.2, start_y=0.3, end_x=0.8, end_y=0.3) 表示从屏幕水平位置20%、垂直位置30%的地方拖拽到水平位置80%、垂直位置30%的地方
|
||||||
|
例如:mouse_drag(start_x=0.5, start_y=0.5, end_x=0.5, end_y=0.2, duration=1.0) 表示从屏幕中心向上拖拽,耗时1秒
|
||||||
|
9. wait(seconds=等待秒数) - 等待指定的时间(秒)
|
||||||
|
- seconds参数为等待时间,可以是整数或小数
|
||||||
|
例如:wait(seconds=3) 表示等待3秒
|
||||||
|
例如:wait(seconds=0.5) 表示等待0.5秒(500毫秒)
|
||||||
|
这个工具在需要等待某些操作完成或界面更新时非常有用
|
||||||
|
10. open_terminal(command="") - 打开一个新的终端窗口
|
||||||
|
- command参数为可选,如果提供则在新终端中执行该命令
|
||||||
|
例如:open_terminal() 表示打开一个新的空终端窗口
|
||||||
|
例如:open_terminal(command="dir") 表示在新终端中执行dir命令(Windows)或ls命令(Unix/Linux/macOS)
|
||||||
|
注意:终端默认指向的目录一般是软件所处目录,不一定是桌面,请你进入终端后自己判断所处位置
|
||||||
|
11. press_hotkey(x=比例x, y=比例y, hotkey="快捷键组合") - 在指定位置点击后模拟键盘快捷键
|
||||||
|
- 先在指定坐标处点击获取焦点,然后执行快捷键操作
|
||||||
|
- hotkey参数为快捷键组合,例如 "ctrl+c", "ctrl+v", "ctrl+a", "alt+f4" 等
|
||||||
|
例如:press_hotkey(x=0.5, y=0.5, hotkey="ctrl+c") 表示在屏幕中心点击并执行复制操作
|
||||||
|
例如:press_hotkey(x=0.3, y=0.4, hotkey="alt+f4") 表示在坐标(0.3,0.4)处点击并执行关闭窗口操作
|
||||||
|
|
||||||
|
请在每一步操作后给出简要说明,然后使用工具调用格式指定下一步操作。
|
||||||
|
如果你认为已经完成任务了,或者你需要用户提供更多信息,或者需要用户帮助你(比如有些输入需要用户输入,或者需要用户帮忙操作),你则不需要调用工具了,这样才可以获取到用户的输入
|
||||||
|
注意:坐标系统使用比例值,x和y的取值范围都是0到1之间的小数,其中(0,0)代表屏幕左上角,(1,1)代表屏幕右下角。
|
||||||
|
所有参数值必须是正确的数据类型,特别是clicks参数必须是整数(1或2),不能是浮点数。
|
||||||
|
如果不需要操作电脑,请你以友好的语言回复用户
|
||||||
|
请你注意,你是运行在终端中,所以无论如何,请不要关闭你存在对话的终端,你所在的终端会保持打开,请不要关闭它。一般的,你的终端上会存在历史聊天记录,或者= VLM 电脑操作工具 =字样
|
||||||
|
如果你在操作鼠标的时候,发现并没有实现预计的效果,可能是因为鼠标操作的坐标出现问题或者系统正在运行,若是鼠标操作的坐标出现问题,请你略微调整坐标值。如果是软件正在运行,请等待软件启动结束。
|
||||||
|
如果你认为用户的指令需要使用工具才能完成,请在任务的开始时,先计划好自己的操作步骤。
|
||||||
|
如果一项任务可以使用终端即可完成,请优先选择终端,如果一项操作可以只使用快捷键完成,请优先选择快捷键
|
||||||
|
|
||||||
|
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
self.messages = [
|
||||||
|
{"role": "system", "content": system_prompt}
|
||||||
|
]
|
||||||
|
|
||||||
|
step = 0
|
||||||
|
while step < max_steps:
|
||||||
|
step += 1
|
||||||
|
print(f"\n--- 步骤 {step} ---")
|
||||||
|
|
||||||
|
# 获取屏幕截图
|
||||||
|
screenshot_buffer = self.capture_screenshot()
|
||||||
|
base64_image = self.encode_image_to_base64(screenshot_buffer)
|
||||||
|
|
||||||
|
# 构造消息
|
||||||
|
if step == 1:
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": f"请完成以下任务: {task_description}"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": "这是当前屏幕状态,请继续完成任务"},
|
||||||
|
{
|
||||||
|
"type": "image_url",
|
||||||
|
"image_url": {
|
||||||
|
"url": f"data:image/png;base64,{base64_image}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
self.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": content
|
||||||
|
})
|
||||||
|
|
||||||
|
# 调用模型
|
||||||
|
try:
|
||||||
|
response = self.client.chat.completions.create(
|
||||||
|
model=self.model_name,
|
||||||
|
messages=self.messages,
|
||||||
|
temperature=0.3,
|
||||||
|
max_tokens=1024
|
||||||
|
)
|
||||||
|
|
||||||
|
response_text = response.choices[0].message.content
|
||||||
|
self.messages.append({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": response_text
|
||||||
|
})
|
||||||
|
|
||||||
|
print("模型响应:")
|
||||||
|
print(response_text)
|
||||||
|
|
||||||
|
# 解析并执行工具调用
|
||||||
|
tool_calls = self.parse_tool_calls(response_text)
|
||||||
|
if tool_calls:
|
||||||
|
print("\n检测到工具调用:")
|
||||||
|
for call in tool_calls:
|
||||||
|
print(f"- {call['name']}({', '.join([f'{k}={v}' for k, v in call['arguments'].items()])})")
|
||||||
|
|
||||||
|
tool_result = self.execute_tool_calls(tool_calls)
|
||||||
|
print(f"\n工具执行结果:")
|
||||||
|
print(tool_result)
|
||||||
|
|
||||||
|
# 将工具执行结果添加到消息历史中
|
||||||
|
self.messages.append({
|
||||||
|
"role": "user",
|
||||||
|
"content": f"工具执行结果:\n{tool_result}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 短暂等待,让操作生效
|
||||||
|
time.sleep(3)
|
||||||
|
else:
|
||||||
|
print("未检测到工具调用,任务可能已完成")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"调用模型时发生错误: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
print(f"\n任务执行完成,共执行 {step} 步")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
交互式主函数
|
||||||
|
"""
|
||||||
|
# 获取API密钥
|
||||||
|
print("=== VLM 电脑操作工具 ===")
|
||||||
|
print("欢迎使用qwen3VL电脑操作工具")
|
||||||
|
print("您需要一个阿里云API密钥才能使用此工具")
|
||||||
|
print("获取地址: https://www.aliyun.com/")
|
||||||
|
api_key = input("请输入您的阿里云API密钥: ").strip()
|
||||||
|
|
||||||
|
if not api_key or api_key == "sk-your-api-key":
|
||||||
|
print("错误: 请输入有效的阿里云API密钥")
|
||||||
|
print("请访问阿里云控制台获取API密钥")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 初始化代理
|
||||||
|
agent = VLMAgent(api_key)
|
||||||
|
|
||||||
|
print("\n系统已就绪,您可以输入各种任务请求")
|
||||||
|
print("示例任务:")
|
||||||
|
print(" - 打开记事本并输入'Hello World'")
|
||||||
|
print(" - 在浏览器中搜索'人工智能'")
|
||||||
|
print(" - 创建一个名为'test.txt'的文件")
|
||||||
|
print("输入'退出'、'exit'或'quit'结束程序")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 获取用户输入
|
||||||
|
task = input("\n请输入任务: ").strip()
|
||||||
|
|
||||||
|
# 检查退出条件
|
||||||
|
if task.lower() in ['退出', 'exit', 'quit', 'q']:
|
||||||
|
print("程序结束,再见!")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 检查空输入
|
||||||
|
if not task:
|
||||||
|
print("请输入有效的任务")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 执行任务
|
||||||
|
print(f"\n开始执行任务: {task}")
|
||||||
|
agent.run_task(task)
|
||||||
|
print(f"\n任务 '{task}' 执行完成")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 检查必要的依赖
|
||||||
|
try:
|
||||||
|
import pyautogui
|
||||||
|
import PIL
|
||||||
|
import tkinter
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"缺少必要的依赖包: {e}")
|
||||||
|
print("请安装依赖: pip install pyautogui pillow openai")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
main()
|
||||||
1548
Tools/using System;.cs
Normal file
1548
Tools/using System;.cs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user