diff --git a/.gitignore b/.gitignore index c768af11..32b64f4e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ .gradle/ bin/ out/ +logs # IDE .idea/ @@ -16,7 +17,7 @@ out/ run/ # Config with API keys -config/steve-common.toml +run/config/steve-common.toml run/config/steve-common.toml # Media files (large binaries) @@ -25,9 +26,6 @@ run/config/steve-common.toml *.avi *.webm -# Structure files (if regenerable) -*.nbt - # macOS .DS_Store .AppleDouble @@ -42,3 +40,5 @@ Desktop.ini # JDK jdk-17.0.2.jdk/ + +Amulet \ No newline at end of file diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..73b3fb4a --- /dev/null +++ b/README_zh.md @@ -0,0 +1,298 @@ +# Steve AI - 我的世界自主AI智能体 + +为我的世界打造的Cursor。和帮助你写代码的AI不同,这是一个真正陪你玩游戏AI智能体。 + +https://github.com/user-attachments/assets/23f0ccdd-7a7a-4d49-9dd9-215ebf67265a + +## 功能介绍 + +Steve作为一个智能体运行,你可以根据需要部署一个或多个智能体。你描述需求,Steve理解上下文并执行。同样的概念,只不过不是代码编辑,而是在你的我的世界游戏中运行的具身智能体。 + +界面很简单:按K打开面板,输入你的需求。智能体负责理解、规划和执行。说"挖点铁",智能体会推理铁的生成位置、导航到合适的深度、找到矿石矿脉并提取资源。想要一座房子,它会考虑可用材料、生成合适的结构,然后一块一块地建造。 + +有趣的是多智能体协作。当多个Steve一起完成同一个任务时,他们不是独立执行,而是主动协调以避免冲突和优化工作分配。让三个智能体建造一座城堡,他们会自动划分结构、在他们之间分配区域、并行进行建造。 + +这些智能体不是按照预定义脚本运行的。它们根据自然语言指令操作,这意味着: +- **资源开采**:智能体确定最佳开采位置和策略 +- **自主建造**:智能体规划布局和材料使用 +- **战斗与防御**:智能体评估威胁并协调响应 +- **探索与采集**:路径规划和资源定位 +- **协作执行**:自动工作负载平衡和冲突解决 + +## 快速开始 + +**你需要:** +- Minecraft 1.20.1 含Forge +- Java 17 +- OpenAI API密钥(或者如果你喜欢也可以用Groq/Gemini) + +**安装步骤:** +1. 从 releases 下载JAR文件 +2. 放入你的 `mods` 文件夹 +3. 启动Minecraft +4. 将 `config/steve-common.toml.example` 复制为 `config/steve-common.toml` +5. 将你的API密钥添加到配置文件中 + +**配置示例:** +```toml +[openai] +apiKey = "your-api-key-here" +model = "gpt-3.5-turbo" +maxTokens = 1000 +temperature = 0.7 +``` + +然后用 `/steve spawn Bob` 生成一个Steve,按K开始下达指令。 + +## 使用示例 + +``` +"挖20个铁矿石" +"在我附近建一座房子" +"帮助Alex建塔" +"保护我免受僵尸攻击" +"跟着我" +"从那片森林砍木头" +"在这里建一个圆石平台" +"攻击那个苦力怕" +``` + +智能体很擅长理解你的意思。不需要特别具体。 + +## 技术架构 + +### 系统概述 + +每个Steve运行一个自主智能体循环,通过LLM处理自然语言命令,将其转换为结构化动作,并使用我的世界的游戏机制执行。系统使用直接动作执行模型,针对实时游戏玩法进行了优化,而非传统的ReAct框架。 + +**核心执行流程:** +1. 通过GUI捕获用户输入(按K) +2. 将任务连同对话上下文发送到TaskPlanner +3. LLM(Groq/OpenAI/Gemini)生成结构化动作计划 +4. ResponseParser从LLM响应中提取动作 +5. ActionExecutor通过专业动作类处理动作 +6. 动作逐tick执行以避免游戏冻结 +7. 结果反馈到对话记忆中以提供上下文 + +### 核心组件 + +**LLM集成** (`com.steve.ai.llm`) +- **GeminiClient, GroqClient, OpenAIClient**:可插拔的LLM提供商用于智能体推理 +- **TaskPlanner**:协调LLM调用,包含上下文(对话历史、世界状态、Steve能力) +- **PromptBuilder**:构建包含可用动作、示例和格式说明的提示 +- **ResponseParser**:从LLM响应中提取结构化动作序列 + +**动作系统** (`com.steve.ai.action`) +- **ActionExecutor**:基于tick的动作执行引擎(防止游戏冻结) +- **BaseAction**:所有动作的抽象类(采矿、建造、移动、战斗等) +- **Task**:动作参数和元数据的数据模型 +- **可用动作**: + - MineBlockAction:智能矿石/方块开采与寻路 + - BuildStructureAction:程序化及模板化建造 + - PlaceBlockAction:单个方块放置与验证 + - MoveToAction:基于寻路的移动 + - AttackAction:目标选择的战斗 + - FollowAction:玩家/实体跟随 + - WaitAction:受控延迟和同步 + +**结构生成** (`com.steve.ai.structure`) +- **StructureGenerators**:程序化生成算法(房屋、城堡、塔楼、谷仓) +- **StructureTemplateLoader**:从资源加载NBT文件 +- **BlockPlacement**:方块定位的共享数据结构 + +**多智能体协作** (`com.steve.ai.action`) +- **CollaborativeBuildManager**:并行建造的服务器端协调 +- **空间分区**:自动将结构划分为非重叠区域 +- **工作分配**:将区域分配给可用的Steve +- **冲突预防**:使用位置跟踪的原子方块放置 +- **动态再平衡**:智能体提前完成时重新分配工作 + +**记忆与上下文** (`com.steve.ai.memory`) +- **SteveMemory**:每个智能体的对话历史和任务上下文 +- **WorldKnowledge**:跟踪已发现的资源、地标和空间数据 +- **StructureRegistry**:已建结构的目录以供参考和避让 + +**代码执行** (`com.steve.ai.execution`) +- **CodeExecutionEngine**:用于LLM生成脚本的GraalVM JavaScript引擎 +- **SteveAPI**:将Minecraft动作暴露给脚本的安全API桥接 +- **沙箱**:阻止有害操作的受限环境 + +### 关键设计决策 + +**基于Tick的执行** +动作在多个游戏tick中增量运行,而非阻塞。这防止了服务器冻结并保持响应性。每个动作的 `tick()` 方法每帧做最少的工作,并在内部跟踪进度。 + +**直接动作执行(非传统ReAct)** +虽然受ReAct启发,我们使用直接动作执行用于实时游戏。LLM预先生成完整的动作序列,而非迭代的观察-思考-动作循环。这减少了API调用和延迟,对游戏响应至关重要。 + +**多智能体协调** +协作建造使用确定性空间分区。结构根据智能体数量划分为矩形区域。每个Steve原子性地声明一个区域,防止冲突。管理器完全在服务器端使用ConcurrentHashMap实现线程安全。 + +**记忆管理** +上下文窗口通过修剪旧消息来管理,同时保留最近的对话和关键世界状态。每次LLM调用包括:对话历史(最近10次交换)、当前任务详情、Steve的位置/背包、以及已知的世界特征。 + +### 与Minecraft的集成 + +**实体注册** +Steve是通过Forge延迟注册系统注册的自定义EntityType。它们扩展PathfinderMob以集成 vanilla寻路,并实现自定义目标用于AI行为。 + +**事件钩子** +- ServerStarting:初始化协作建造管理器 +- ServerStopping:清理活动任务并保存状态 +- ClientTick:GUI渲染和输入处理 + +**GUI实现** +使用K键激活的自定义覆盖GUI。使用Minecraft的Screen类和自定义渲染。提交时将文本输入转发到TaskPlanner。 + +## 从源码构建 + +标准Gradle工作流程: + +```bash +git clone https://github.com/YuvDwi/Steve.git +cd Steve +./gradlew build +``` + +输出JAR将在 `build/libs/`。要在开发环境测试: + +```bash +./gradlew runClient +``` + +**项目结构:** +``` +src/main/java/com/steve/ai/ +├── entity/ # Steve实体、生成、生命周期 +├── llm/ # LLM客户端、提示构建、响应解析 +├── action/ # 动作类和协作建造管理器 +├── structure/ # 程序化生成和模板加载 +├── memory/ # 上下文管理和世界知识 +├── execution/ # JavaScript代码执行引擎 +├── client/ # GUI覆盖层 +└── command/ # Minecraft命令(/steve spawn等) +``` + +## 贡献 + +欢迎贡献!以下是入门方法: + +### 报告Bug + +1. 首先检查[现有issue](https://github.com/YuvDwi/Steve/issues) +2. 包括: + - Minecraft/Forge/Steve AI版本 + - 复现步骤 + - 预期与实际行为 + - 来自 `logs/latest.log` 的日志 + +### 提交代码 + +1. **Fork并克隆** + ```bash + git clone https://github.com/YourUsername/Steve.git + cd Steve + ``` + +2. **创建功能分支** + ```bash + git checkout -b feature/your-feature-name + ``` + +3. **进行更改** + - 遵循代码风格(4空格缩进,公共API用JavaDoc) + - 用 `./gradlew build && ./gradlew runClient` 测试 + +4. **提交PR** + - 清晰的提交信息 + - 描述更改内容和原因 + - 关联相关issue + +### 代码风格 + +- **类名**:PascalCase +- **方法/变量**:camelCase +- **常量**:UPPER_SNAKE_CASE +- **缩进**:4空格 +- **行长度**:最多120字符 +- **注释**:公共方法用JavaDoc + +**添加新动作:** +1. 在 `com.steve.ai.action.actions` 中扩展 `BaseAction` +2. 实现 `tick()`、`isComplete()`、`onCancel()` +3. 更新 `PromptBuilder.java` 告知LLM新动作 +4. 在提示模板中添加使用示例 + +## 配置 + +编辑 `config/steve-common.toml`: + +```toml +[llm] +provider = "groq" # 选项:openai, groq, gemini + +[openai] +apiKey = "sk-..." +model = "gpt-3.5-turbo" +maxTokens = 1000 +temperature = 0.7 + +[groq] +apiKey = "gsk_..." +model = "llama3-70b-8192" +maxTokens = 1000 + +[gemini] +apiKey = "AI..." +model = "gemini-1.5-flash" +maxTokens = 1000 +``` + +**性能提示:** +- 使用Groq获得最快推理(推荐用于游戏) +- GPT-4更好的规划但延迟更高 +- 较低温度(0.5-0.7)使动作更确定性 + +## 已知问题 + +**智能体只和LLM一样聪明。** GPT-3.5能用但偶尔会有奇怪的决定。GPT-4在多步规划上明显更好。 + +**还没有合成系统。** 智能体能挖矿和放置方块但还不能合成工具。正在努力开发中。 + +**动作是同步的。** 如果Steve正在挖矿,它在完成前什么都做不了。计划添加正确的异步执行。 + +**记忆在重启时重置。** 目前上下文只在游戏会话期间持续。正在添加带有向量数据库的持久化记忆。 + +## 后续计划 + +计划中的功能: +- 合成系统(智能体自己制作工具) +- 通过Whisper API实现语音命令 +- 用于长期记忆的向量数据库 +- 用于多任务的异步动作执行 +- 更多建造模板和程序化生成 +- 复杂地形的增强寻路 + +目标是让这个在生存游戏中真正有用,而不仅仅是一个技术演示。 + +## 为什么做这个 + +我们想看看Cursor模型是否能在编码之外工作。事实证明它相当适用。同样的原则:深度环境集成、清晰的动作原语、持久上下文。 + +我的世界实际上是智能体研究的好测试场。足够复杂以至于有趣,足够受限以至于智能体能够真正成功。 + +而且看着AI建造城堡而你去探索真的很有趣。 + +## 致谢 + +- OpenAI/Groq/Google提供LLM API +- Minecraft Forge提供模组框架 +- LangChain/AutoGPT提供智能体架构灵感 + +## 许可证 + +MIT + +## 问题反馈 + +发现Bug?开一个issue:https://github.com/YuvDwi/Steve/issues diff --git a/TECHNICAL_DEEP_DIVE_CN.md b/TECHNICAL_DEEP_DIVE_CN.md new file mode 100644 index 00000000..3d4ccca2 --- /dev/null +++ b/TECHNICAL_DEEP_DIVE_CN.md @@ -0,0 +1,2198 @@ +# Steve AI - 完整技术深度解析 + +**日期**: 2025年11月 +**项目**: Steve AI - LLM驱动的Minecraft自主代理 +**仓库**: https://github.com/YuvDwi/Steve + +--- + +## 目录 + +1. [摘要 - 通俗解释](#摘要---通俗解释) +2. [高层概览](#高层概览) +3. [详细底层技术解析](#详细底层技术解析) +4. [复杂实现亮点](#复杂实现亮点) +5. [简历影响力陈述](#简历影响力陈述) + +--- + +## 摘要 - 通俗解释 + +### 这是什么? + +Steve 是 **"Minecraft版的Cursor"** - 一个与你一起玩游戏的AI伙伴。你不需要输入Minecraft命令,只需按下 `K` 键,输入自然语言如"给我建一座城堡"或"挖20个钻石",AI代理就会自主执行这些任务。 + +### 魔法之处 + +- **自然语言 → 动作**: 输入"给我弄点铁" → 代理导航到地下,找到铁矿,挖掘它,返回 +- **多代理协调**: 告诉3个代理"建一座房子" → 它们自动分配工作,不会冲突,并行建造 +- **实时学习**: 代理观察周围世界(方块、实体、生物群系)并做出上下文感知的决策 + +### 为什么很酷 + +1. **首个具身AI游戏助手** - 不只是聊天机器人,而是一个能导航、建造、战斗和探索的物理实体 +2. **真正的多代理协作** - 多个代理在同一结构上工作,使用空间分区避免冲突 +3. **零脚本要求** - 不需要命令方块,不需要mod配置,只需plain English +4. **下一代游戏AI的概念验证** - 展示AI可以是你的队友,而不仅仅是NPC + +### 一句话技术成就 + +构建了一个生产级的代理AI系统,具有LLM驱动的自然语言理解、实时世界感知、程序化结构生成和无锁多代理协调——全部集成到Minecraft的游戏循环中,零外部依赖。 + +--- + +## 高层概览 + +### 系统架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 用户界面 │ +│ • Cursor风格的滑动面板GUI (按K键) │ +│ • Minecraft聊天命令 (/steve spawn, /steve tell) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 自然语言输入 │ +│ "在我附近建一座城堡" │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 任务规划器 │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ 1. WorldKnowledge扫描环境(16格半径) │ │ +│ │ 2. PromptBuilder创建上下文丰富的提示词 │ │ +│ │ 3. LLM客户端(Groq/OpenAI/Gemini)返回JSON │ │ +│ │ 4. ResponseParser提取结构化任务 │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 结构化任务队列 │ +│ [ │ +│ {action: "build", params: {structure: "castle", ...}}, │ +│ {action: "mine", params: {block: "iron", quantity: 20}} │ +│ ] │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 动作执行器 │ +│ • 管理任务队列 │ +│ • 创建动作实例(BaseAction子类) │ +│ • 每个游戏tick执行动作(50ms) │ +│ • 处理失败和重新规划 │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 动作层 │ +│ ┌────────────┬────────────┬────────────┬────────────┐ │ +│ │BuildAction │MineAction │CombatAction│PathfindAct │ │ +│ └────────────┴────────────┴────────────┴────────────┘ │ +│ 每个动作: │ +│ • onStart(): 初始化(查找位置,设置状态) │ +│ • onTick(): 增量执行(每tick放置1个方块) │ +│ • onCancel(): 清理(禁用飞行,清除导航) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MINECRAFT游戏引擎集成 │ +│ • 通过level.setBlock()放置方块 │ +│ • 通过PathfinderMob实现实体导航 │ +│ • 通过doHurtTarget()进行战斗 │ +│ • 通过level.getBlockState()查询世界 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 核心技术 + +| 组件 | 技术 | 用途 | +|------|------|------| +| **平台** | Minecraft Forge 1.20.1 | Minecraft Java的mod框架 | +| **语言** | Java 17 | 主要实现语言 | +| **AI提供商** | Groq, OpenAI, Gemini | 自然语言的LLM推理 | +| **架构** | 自定义代理循环 | 受ReAct启发(推理 → 行动 → 观察) | +| **并发** | ConcurrentHashMap, AtomicInteger | 无锁多代理协调 | +| **序列化** | Minecraft NBT | 内存持久化,结构模板 | +| **网络** | Java 11+ HttpClient | 与LLM提供商的API通信 | +| **JSON解析** | Gson(Minecraft内置) | LLM响应解析 | + +### 数据流示例:"建一座房子" + +1. **用户输入**: 玩家按下 `K`,输入"建一座房子",按回车 +2. **GUI处理器** (`SteveGUI.java`): 发送命令到ActionExecutor +3. **世界扫描** (`WorldKnowledge.java`): 扫描16格半径 + - 附近方块: grass, dirt, oak_log, stone + - 附近实体: 1个玩家(Steve), 2只羊 + - 生物群系: plains + - Steve位置: [100, 64, 200] +4. **提示词构建** (`PromptBuilder.java`): + ``` + === 你的情况 === + 位置: [100, 64, 200] + 附近玩家: Steve + 附近实体: 2只羊 + 附近方块: grass, dirt, oak_log, stone + 生物群系: plains + + === 玩家命令 === + "建一座房子" + ``` +5. **LLM推理** (`GroqClient.java`): 发送到Groq API + - 模型: llama-3.1-8b-instant + - 响应时间: ~500ms +6. **LLM响应**: + ```json + { + "reasoning": "在附近建造标准房子", + "plan": "建造房子", + "tasks": [{ + "action": "build", + "parameters": { + "structure": "house", + "blocks": ["oak_planks", "cobblestone", "glass_pane"], + "dimensions": [9, 6, 9] + } + }] + } + ``` +7. **响应解析** (`ResponseParser.java`): 提取任务对象 +8. **任务执行** (`BuildStructureAction.java`): + - 找到玩家朝向 + - 计算建造位置(玩家前方12格) + - 找到地面高度(上下扫描固体表面) + - 生成9x6x9的房子,使用程序化算法 + - 注册协作建造(允许其他代理加入) + - 启用飞行模式(setFlying(true)) + - 增量放置方块(每tick 1个方块) + - 渲染粒子效果和播放音效 + - 距离>5格时传送到下一个方块位置 +9. **多代理协调** (`CollaborativeBuildManager.java`): + - 如果另一个Steve在建造过程中启动"建一座房子": + - 加入现有建造而不是创建新的 + - 被分配到一个象限(NW, NE, SW, SE) + - 在其象限内从下到上建造 + - 原子性方块声明防止冲突 +10. **完成**: 结构建造完成,代理报告"协作建造完成!" + +--- + +## 详细底层技术解析 + +### 1. 实体系统 (`SteveEntity.java`) + +Steve代理是扩展自 `PathfinderMob` 的自定义Minecraft实体。 + +**核心特性**: +- **无敌性**: 代理对所有伤害源永久无敌 + ```java + @Override + public boolean isInvulnerableTo(DamageSource source) { + return true; // 免疫所有伤害 + } + ``` +- **飞行机制**: 建造时的动态重力控制 + ```java + public void setFlying(boolean flying) { + this.isFlying = flying; + this.setNoGravity(flying); // 飞行时禁用重力 + this.setInvulnerable(flying); + } + + @Override + public void travel(Vec3 travelVector) { + if (this.isFlying && this.getNavigation().isInProgress()) { + super.travel(travelVector); + // 添加微小上升力防止下落 + this.setDeltaMovement(this.getDeltaMovement().add(0, 0.05, 0)); + } else { + super.travel(travelVector); + } + } + ``` +- **持久化内存**: 世界保存时保存到NBT标签 + ```java + @Override + public void addAdditionalSaveData(CompoundTag tag) { + super.addAdditionalSaveData(tag); + tag.putString("SteveName", this.steveName); + + CompoundTag memoryTag = new CompoundTag(); + this.memory.saveToNBT(memoryTag); + tag.put("Memory", memoryTag); + } + ``` +- **基于Tick的执行**: 每个服务器tick运行动作执行器(50ms) + ```java + @Override + public void tick() { + super.tick(); + if (!this.level().isClientSide) { // 仅服务端 + actionExecutor.tick(); + } + } + ``` + +**属性**: +- 生命值: 20 HP(永不减少) +- 移动速度: 0.25(与玩家步行相同) +- 攻击伤害: 8 HP(高伤害战斗) +- 跟随范围: 48格(可从远处检测目标) + +### 2. AI集成层 + +#### 2.1 LLM客户端架构 + +三个具有相同接口的客户端实现: + +**OpenAIClient.java** - 带重试逻辑的全功能客户端: +```java +public String sendRequest(String systemPrompt, String userPrompt) { + // 指数退避重试逻辑 + for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + HttpResponse response = client.send(request, ...); + + if (response.statusCode() == 200) { + return parseResponse(response.body()); + } + + // 在速率限制(429)或服务器错误(5xx)时重试 + if (response.statusCode() == 429 || response.statusCode() >= 500) { + if (attempt < MAX_RETRIES - 1) { + int delayMs = INITIAL_RETRY_DELAY_MS * (int) Math.pow(2, attempt); + Thread.sleep(delayMs); // 1s, 2s, 4s + continue; + } + } + + return null; // 不可重试的错误 + } catch (Exception e) { + // 网络错误也重试 + } + } +} +``` + +**GroqClient.java** - 为速度优化: +```java +// 使用llama-3.1-8b-instant模型 +requestBody.addProperty("model", "llama-3.1-8b-instant"); +requestBody.addProperty("max_tokens", 500); // 保持较短以获得0.5-2s响应 +requestBody.addProperty("temperature", 0.7); + +// 无重试逻辑 - 快速失败(Groq比Gemini快20-50倍) +// 响应时间: ~500ms vs Gemini的10-30s +``` + +**提供商回退**: +```java +private String getAIResponse(String provider, String systemPrompt, String userPrompt) { + String response = switch (provider) { + case "groq" -> groqClient.sendRequest(systemPrompt, userPrompt); + case "gemini" -> geminiClient.sendRequest(systemPrompt, userPrompt); + case "openai" -> openAIClient.sendRequest(systemPrompt, userPrompt); + default -> groqClient.sendRequest(systemPrompt, userPrompt); + }; + + // 主提供商失败时回退到Groq + if (response == null && !provider.equals("groq")) { + response = groqClient.sendRequest(systemPrompt, userPrompt); + } + + return response; +} +``` + +#### 2.2 提示词工程 (`PromptBuilder.java`) + +**系统提示词** - 严格的JSON输出格式: +``` +你是一个Minecraft AI代理。只用有效的JSON响应,不要额外文本。 + +格式(严格JSON): +{"reasoning": "简短想法", "plan": "动作描述", "tasks": [...]} + +动作: +- attack: {"target": "hostile"} +- build: {"structure": "house", "blocks": ["oak_planks"], "dimensions": [9, 6, 9]} +- mine: {"block": "iron", "quantity": 8} +- follow: {"player": "NAME"} +- pathfind: {"x": 0, "y": 0, "z": 0} + +规则: +1. 攻击目标始终使用"hostile" +2. 结构选项: house, oldhouse, powerplant (NBT), castle, tower, barn (程序化) +3. 使用2-3种方块类型 +4. 不要额外的pathfind任务 +5. 推理保持在15个词以内 +6. 协作建造: 多个Steve可以同时工作 + +关键: 只输出有效的JSON。不要markdown,不要解释。 +``` + +**用户提示词** - 丰富的上下文感知: +```java +public static String buildUserPrompt(SteveEntity steve, String command, WorldKnowledge worldKnowledge) { + StringBuilder prompt = new StringBuilder(); + + // 完整的情境感知 + prompt.append("=== 你的情况 ===\n"); + prompt.append("位置: ").append(formatPosition(steve.blockPosition())).append("\n"); + prompt.append("附近玩家: ").append(worldKnowledge.getNearbyPlayerNames()).append("\n"); + prompt.append("附近实体: ").append(worldKnowledge.getNearbyEntitiesSummary()).append("\n"); + prompt.append("附近方块: ").append(worldKnowledge.getNearbyBlocksSummary()).append("\n"); + prompt.append("生物群系: ").append(worldKnowledge.getBiomeName()).append("\n"); + + prompt.append("\n=== 玩家命令 ===\n"); + prompt.append("\"").append(command).append("\"\n"); + + return prompt.toString(); +} +``` + +**提示词示例**: +``` +=== 你的情况 === +位置: [128, 64, -45] +附近玩家: Alice, Bob +附近实体: 3只羊, 1头牛, 2只鸡 +附近方块: grass_block, dirt, oak_log, stone, iron_ore +生物群系: forest + +=== 玩家命令 === +"挖20个铁矿" + +=== 你的响应(带推理)=== +``` + +**LLM响应**: +```json +{ + "reasoning": "从附近矿石挖铁", + "plan": "挖铁矿", + "tasks": [ + { + "action": "mine", + "parameters": { + "block": "iron", + "quantity": 20 + } + } + ] +} +``` + +#### 2.3 响应解析 (`ResponseParser.java`) + +**健壮的JSON提取**: +```java +private static String extractJSON(String response) { + String cleaned = response.trim(); + + // 移除markdown代码块 + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + + // 规范化空白字符 + cleaned = cleaned.replaceAll("\\n\\s*", " "); + + // 修复常见的AI错误:对象/数组之间缺少逗号 + cleaned = cleaned.replaceAll("}\\s+\\{", "},{"); + cleaned = cleaned.replaceAll("}\\s+\\[", "},["); + cleaned = cleaned.replaceAll("]\\s+\\{", "],{"); + cleaned = cleaned.replaceAll("]\\s+\\[", "],["); + + return cleaned; +} +``` + +**多态参数解析**: +```java +private static Task parseTask(JsonObject taskObj) { + String action = taskObj.get("action").getAsString(); + Map parameters = new HashMap<>(); + + JsonObject paramsObj = taskObj.getAsJsonObject("parameters"); + + for (String key : paramsObj.keySet()) { + JsonElement value = paramsObj.get(key); + + if (value.isJsonPrimitive()) { + if (value.getAsJsonPrimitive().isNumber()) { + parameters.put(key, value.getAsNumber()); + } else if (value.getAsJsonPrimitive().isBoolean()) { + parameters.put(key, value.getAsBoolean()); + } else { + parameters.put(key, value.getAsString()); + } + } else if (value.isJsonArray()) { + List list = new ArrayList<>(); + for (JsonElement element : value.getAsJsonArray()) { + if (element.isJsonPrimitive()) { + if (element.getAsJsonPrimitive().isNumber()) { + list.add(element.getAsNumber()); + } else { + list.add(element.getAsString()); + } + } + } + parameters.put(key, list); + } + } + + return new Task(action, parameters); +} +``` + +### 3. 世界感知 (`WorldKnowledge.java`) + +**环境扫描**(16格半径): +```java +private void scanBlocks() { + nearbyBlocks = new HashMap<>(); + Level level = steve.level(); + BlockPos stevePos = steve.blockPosition(); + + // 每2格采样一次以提高性能(8x8x8 = 512次采样 vs 32^3 = 32768次) + for (int x = -scanRadius; x <= scanRadius; x += 2) { + for (int y = -scanRadius; y <= scanRadius; y += 2) { + for (int z = -scanRadius; z <= scanRadius; z += 2) { + BlockPos checkPos = stevePos.offset(x, y, z); + BlockState state = level.getBlockState(checkPos); + Block block = state.getBlock(); + + if (block != Blocks.AIR && block != Blocks.CAVE_AIR && block != Blocks.VOID_AIR) { + nearbyBlocks.put(block, nearbyBlocks.getOrDefault(block, 0) + 1); + } + } + } + } +} +``` + +**实体检测**(基于AABB): +```java +private void scanEntities() { + Level level = steve.level(); + AABB searchBox = steve.getBoundingBox().inflate(scanRadius); + nearbyEntities = level.getEntities(steve, searchBox); +} +``` + +**上下文摘要**: +```java +public String getNearbyBlocksSummary() { + // 按频率排序,取前5个 + List> sorted = nearbyBlocks.entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .limit(5) + .toList(); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < sorted.size(); i++) { + if (i > 0) sb.append(", "); + sb.append(sorted.get(i).getKey().getName().getString()); + } + return sb.toString(); +} + +public String getNearbyPlayerNames() { + List playerNames = new ArrayList<>(); + for (Entity entity : nearbyEntities) { + if (entity instanceof Player player) { + playerNames.add(player.getName().getString()); + } + } + return playerNames.isEmpty() ? "无" : String.join(", ", playerNames); +} +``` + +### 4. 动作执行系统 + +#### 4.1 动作执行器 (`ActionExecutor.java`) + +**任务队列管理**: +```java +public class ActionExecutor { + private final Queue taskQueue; + private BaseAction currentAction; + private String currentGoal; + private int ticksSinceLastAction; + private BaseAction idleFollowAction; // 空闲时跟随玩家 + + public void tick() { + ticksSinceLastAction++; + + // 执行当前动作直到完成 + if (currentAction != null) { + if (currentAction.isComplete()) { + ActionResult result = currentAction.getResult(); + + steve.getMemory().addAction(currentAction.getDescription()); + + if (!result.isSuccess() && result.requiresReplanning()) { + // 可以在这里用LLM重新规划 + } + + currentAction = null; + } else { + currentAction.tick(); + return; + } + } + + // 延迟后开始下一个任务 + if (ticksSinceLastAction >= ACTION_TICK_DELAY) { + if (!taskQueue.isEmpty()) { + Task nextTask = taskQueue.poll(); + executeTask(nextTask); + ticksSinceLastAction = 0; + return; + } + } + + // 空闲行为:跟随最近的玩家 + if (taskQueue.isEmpty() && currentAction == null && currentGoal == null) { + if (idleFollowAction == null) { + idleFollowAction = new IdleFollowAction(steve); + idleFollowAction.start(); + } else if (idleFollowAction.isComplete()) { + idleFollowAction = new IdleFollowAction(steve); + idleFollowAction.start(); + } else { + idleFollowAction.tick(); + } + } + } +} +``` + +**动作工厂模式**: +```java +private BaseAction createAction(Task task) { + return switch (task.getAction()) { + case "pathfind" -> new PathfindAction(steve, task); + case "mine" -> new MineBlockAction(steve, task); + case "place" -> new PlaceBlockAction(steve, task); + case "craft" -> new CraftItemAction(steve, task); + case "attack" -> new CombatAction(steve, task); + case "follow" -> new FollowPlayerAction(steve, task); + case "gather" -> new GatherResourceAction(steve, task); + case "build" -> new BuildStructureAction(steve, task); + default -> null; + }; +} +``` + +#### 4.2 基础动作模板 (`BaseAction.java`) + +**生命周期钩子**: +```java +public abstract class BaseAction { + protected final SteveEntity steve; + protected final Task task; + protected ActionResult result; + protected boolean started = false; + protected boolean cancelled = false; + + public void start() { + if (started) return; + started = true; + onStart(); + } + + public void tick() { + if (!started || isComplete()) return; + onTick(); + } + + public void cancel() { + cancelled = true; + result = ActionResult.failure("动作取消"); + onCancel(); + } + + public boolean isComplete() { + return result != null || cancelled; + } + + // 子类实现这些方法 + protected abstract void onStart(); + protected abstract void onTick(); + protected abstract void onCancel(); + public abstract String getDescription(); +} +``` + +### 5. 复杂动作实现 + +#### 5.1 建造动作 (`BuildStructureAction.java`) - 900+ 行 + +**阶段1: 初始化** +```java +@Override +protected void onStart() { + structureType = task.getStringParameter("structure").toLowerCase(); + + // 检查是否有现有的协作建造可以加入 + collaborativeBuild = CollaborativeBuildManager.findActiveBuild(structureType); + + if (collaborativeBuild != null) { + // 加入现有建造 + isCollaborative = true; + steve.setFlying(true); + return; + } + + // 创建新建造 + buildMaterials = extractMaterialsFromTask(); + + // 查找建造位置:玩家朝向前方12格 + Player nearestPlayer = findNearestPlayer(); + BlockPos groundPos; + + if (nearestPlayer != null) { + Vec3 eyePos = nearestPlayer.getEyePosition(1.0F); + Vec3 lookVec = nearestPlayer.getLookAngle(); + Vec3 targetPos = eyePos.add(lookVec.scale(12)); + + groundPos = findGroundLevel(new BlockPos(targetPos)); + } else { + groundPos = findGroundLevel(steve.blockPosition().offset(2, 0, 2)); + } + + // 尝试从NBT模板加载 + buildPlan = tryLoadFromTemplate(structureType, groundPos); + + if (buildPlan == null) { + // 回退到程序化生成 + buildPlan = generateBuildPlan(structureType, groundPos, width, height, depth); + } + + // 在注册表中注册结构 + StructureRegistry.register(groundPos, width, height, depth, structureType); + + // 创建协作建造 + collaborativeBuild = CollaborativeBuildManager.registerBuild( + structureType, + convertToCollaborativeBlocks(buildPlan), + groundPos + ); + + steve.setFlying(true); // 启用飞行用于建造 +} +``` + +**阶段2: 程序化结构生成** + +示例:带角楼和城垛的城堡 +```java +private List buildCastle(BlockPos start, int width, int height, int depth) { + List blocks = new ArrayList<>(); + Block stoneMaterial = Blocks.STONE_BRICKS; + Block wallMaterial = Blocks.COBBLESTONE; + Block windowMaterial = Blocks.GLASS_PANE; + + // 主结构墙壁 + for (int y = 0; y <= height; y++) { + for (int x = 0; x < width; x++) { + for (int z = 0; z < depth; z++) { + boolean isEdge = (x == 0 || x == width - 1 || z == 0 || z == depth - 1); + boolean isCorner = (x <= 2 || x >= width - 3) && (z <= 2 || z >= depth - 3); + + if (y == 0) { + // 实心石头地基 + blocks.add(new BlockPlacement(start.offset(x, y, z), stoneMaterial)); + } else if (isEdge && !isCorner) { + if (x == width / 2 && z == 0 && y <= 3) { + // 大门入口 + blocks.add(new BlockPlacement(start.offset(x, y, 0), Blocks.AIR)); + } else if (y % 4 == 2 && !isCorner) { + // 每4格垂直方向的箭缝窗户 + blocks.add(new BlockPlacement(start.offset(x, y, z), windowMaterial)); + } else { + blocks.add(new BlockPlacement(start.offset(x, y, z), wallMaterial)); + } + } + } + } + } + + // 角楼(3x3,比主高度高6格) + int towerHeight = height + 6; + int towerSize = 3; + int[][] corners = {{0, 0}, {width - towerSize, 0}, {0, depth - towerSize}, {width - towerSize, depth - towerSize}}; + + for (int[] corner : corners) { + for (int y = 0; y <= towerHeight; y++) { + for (int dx = 0; dx < towerSize; dx++) { + for (int dz = 0; dz < towerSize; dz++) { + boolean isTowerEdge = (dx == 0 || dx == towerSize - 1 || dz == 0 || dz == towerSize - 1); + + if (y == 0 || isTowerEdge) { + // 实心底座,空心中心 + blocks.add(new BlockPlacement(start.offset(corner[0] + dx, y, corner[1] + dz), stoneMaterial)); + } + + // 每5格的塔楼窗户 + if (y % 5 == 3 && isTowerEdge && (dx == towerSize / 2 || dz == towerSize / 2)) { + blocks.add(new BlockPlacement(start.offset(corner[0] + dx, y, corner[1] + dz), windowMaterial)); + } + } + } + } + + // 塔楼顶部城垛 + for (int dx = 0; dx < towerSize; dx++) { + for (int dz = 0; dz < towerSize; dz++) { + if (dx % 2 == 0 || dz % 2 == 0) { + blocks.add(new BlockPlacement(start.offset(corner[0] + dx, towerHeight + 1, corner[1] + dz), stoneMaterial)); + } + } + } + } + + // 墙壁城垛(城堡雉堞) + for (int x = 0; x < width; x += 2) { + blocks.add(new BlockPlacement(start.offset(x, height + 1, 0), stoneMaterial)); + blocks.add(new BlockPlacement(start.offset(x, height + 2, 0), stoneMaterial)); + blocks.add(new BlockPlacement(start.offset(x, height + 1, depth - 1), stoneMaterial)); + blocks.add(new BlockPlacement(start.offset(x, height + 2, depth - 1), stoneMaterial)); + } + + return blocks; // 14x10x14城堡通常800-1200个方块 +} +``` + +**阶段3: 地面查找算法** +```java +private BlockPos findGroundLevel(BlockPos startPos) { + int maxScanDown = 20; + int maxScanUp = 10; + + // 向下扫描寻找实心地面 + for (int i = 0; i < maxScanDown; i++) { + BlockPos checkPos = startPos.below(i); + BlockPos belowPos = checkPos.below(); + + if (steve.level().getBlockState(checkPos).isAir() && + isSolidGround(belowPos)) { + return checkPos; // 找到地面高度(实心方块上方的空气) + } + } + + // 如果在地下,向上扫描到地表 + for (int i = 1; i < maxScanUp; i++) { + BlockPos checkPos = startPos.above(i); + BlockPos belowPos = checkPos.below(); + + if (steve.level().getBlockState(checkPos).isAir() && + isSolidGround(belowPos)) { + return checkPos; + } + } + + // 回退:向下扫描直到碰到实心方块 + BlockPos fallbackPos = startPos; + while (!isSolidGround(fallbackPos.below()) && fallbackPos.getY() > -64) { + fallbackPos = fallbackPos.below(); + } + + return fallbackPos; +} + +private boolean isSolidGround(BlockPos pos) { + var blockState = steve.level().getBlockState(pos); + var block = blockState.getBlock(); + + // 空气或液体不算实心 + if (blockState.isAir() || block == Blocks.WATER || block == Blocks.LAVA) { + return false; + } + + return blockState.isSolid(); +} +``` + +**阶段4: 增量方块放置**(协作模式) +```java +@Override +protected void onTick() { + ticksRunning++; + + if (ticksRunning > MAX_TICKS) { + steve.setFlying(false); + result = ActionResult.failure("建造超时"); + return; + } + + if (collaborativeBuild.isComplete()) { + CollaborativeBuildManager.completeBuild(collaborativeBuild.structureId); + steve.setFlying(false); + result = ActionResult.success("协作建造完成 " + structureType + "!"); + return; + } + + // 每tick放置BLOCKS_PER_TICK个方块 + for (int i = 0; i < BLOCKS_PER_TICK; i++) { + BlockPlacement placement = + CollaborativeBuildManager.getNextBlock(collaborativeBuild, steve.getSteveName()); + + if (placement == null) { + break; // 该代理区域没有更多方块 + } + + BlockPos pos = placement.pos; + double distance = Math.sqrt(steve.blockPosition().distSqr(pos)); + + // 距离太远时传送(>5格) + if (distance > 5) { + steve.teleportTo(pos.getX() + 2, pos.getY(), pos.getZ() + 2); + } + + // 看向方块位置 + steve.getLookControl().setLookAt(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); + + // 挥手动画 + steve.swing(InteractionHand.MAIN_HAND, true); + + // 放置方块 + BlockState blockState = placement.block.defaultBlockState(); + steve.level().setBlock(pos, blockState, 3); + + // 粒子效果和音效 + if (steve.level() instanceof ServerLevel serverLevel) { + serverLevel.sendParticles( + new BlockParticleOption(ParticleTypes.BLOCK, blockState), + pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, + 15, 0.4, 0.4, 0.4, 0.15 + ); + + var soundType = blockState.getSoundType(steve.level(), pos, steve); + steve.level().playSound(null, pos, soundType.getPlaceSound(), + SoundSource.BLOCKS, 1.0f, soundType.getPitch()); + } + } + + // 每5秒记录进度 + if (ticksRunning % 100 == 0) { + int percentComplete = collaborativeBuild.getProgressPercentage(); + SteveMod.LOGGER.info("{} 建造进度: {}/{} ({}%) - {} 个Steve在工作", + structureType, + collaborativeBuild.getBlocksPlaced(), + collaborativeBuild.getTotalBlocks(), + percentComplete, + collaborativeBuild.participatingSteves.size()); + } +} +``` + +#### 5.2 协作建造管理器 (`CollaborativeBuildManager.java`) + +**无锁空间分区**: +```java +public class CollaborativeBuild { + public final String structureId; + public final List buildPlan; + private final List sections; // 4个象限(NW, NE, SW, SE) + private final Map steveToSectionMap; + private final AtomicInteger nextSectionIndex; + public final Set participatingSteves; + + /** + * 将建造分为4个象限,从下到上排序 + */ + private List divideBuildIntoSections(List plan) { + // 找到边界框 + int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE; + int minZ = Integer.MAX_VALUE, maxZ = Integer.MIN_VALUE; + + for (BlockPlacement placement : plan) { + minX = Math.min(minX, placement.pos.getX()); + maxX = Math.max(maxX, placement.pos.getX()); + minZ = Math.min(minZ, placement.pos.getZ()); + maxZ = Math.max(maxZ, placement.pos.getZ()); + } + + int centerX = (minX + maxX) / 2; + int centerZ = (minZ + maxZ) / 2; + + // 分区到象限 + List northWest = new ArrayList<>(); + List northEast = new ArrayList<>(); + List southWest = new ArrayList<>(); + List southEast = new ArrayList<>(); + + for (BlockPlacement placement : plan) { + int x = placement.pos.getX(); + int z = placement.pos.getZ(); + + if (x <= centerX && z <= centerZ) { + northWest.add(placement); + } else if (x > centerX && z <= centerZ) { + northEast.add(placement); + } else if (x <= centerX && z > centerZ) { + southWest.add(placement); + } else { + southEast.add(placement); + } + } + + // 每个象限从下到上排序(Y轴) + Comparator bottomToTop = Comparator.comparingInt(p -> p.pos.getY()); + northWest.sort(bottomToTop); + northEast.sort(bottomToTop); + southWest.sort(bottomToTop); + southEast.sort(bottomToTop); + + List sectionList = new ArrayList<>(); + if (!northWest.isEmpty()) sectionList.add(new BuildSection(0, northWest, "西北")); + if (!northEast.isEmpty()) sectionList.add(new BuildSection(1, northEast, "东北")); + if (!southWest.isEmpty()) sectionList.add(new BuildSection(2, southWest, "西南")); + if (!southEast.isEmpty()) sectionList.add(new BuildSection(3, southEast, "东南")); + + return sectionList; + } +} +``` + +**原子性方块声明**: +```java +public static class BuildSection { + public final int yLevel; // 区域ID + public final String sectionName; + private final List blocks; + private final AtomicInteger nextBlockIndex; // 线程安全计数器 + + public BlockPlacement getNextBlock() { + int index = nextBlockIndex.getAndIncrement(); // 原子递增 + if (index < blocks.size()) { + return blocks.get(index); + } + return null; // 区域完成 + } + + public int getBlocksPlaced() { + return Math.min(nextBlockIndex.get(), blocks.size()); + } + + public boolean isComplete() { + return nextBlockIndex.get() >= blocks.size(); + } +} +``` + +**Steve到区域的分配**: +```java +private static Integer assignSteveToSection(CollaborativeBuild build, String steveName) { + // 第一轮:查找未分配的区域 + for (int i = 0; i < build.sections.size(); i++) { + BuildSection section = build.sections.get(i); + if (!section.isComplete()) { + boolean alreadyAssigned = build.steveToSectionMap.containsValue(i); + + if (!alreadyAssigned) { + build.steveToSectionMap.put(steveName, i); + SteveMod.LOGGER.info("将Steve '{}' 分配到 {} 象限 - 将从下到上建造 {} 个方块", + steveName, section.sectionName, section.getTotalBlocks()); + return i; + } + } + } + + // 第二轮:帮助任何未完成的区域(负载均衡) + for (int i = 0; i < build.sections.size(); i++) { + BuildSection section = build.sections.get(i); + if (!section.isComplete()) { + build.steveToSectionMap.put(steveName, i); + SteveMod.LOGGER.info("Steve '{}' 帮助 {} 象限(剩余 {} 个方块)", + steveName, section.sectionName, section.getTotalBlocks() - section.getBlocksPlaced()); + return i; + } + } + + return null; // 所有区域完成 +} +``` + +**并发安全**: +- `ConcurrentHashMap` 用于活跃建造映射 +- `AtomicInteger` 用于方块索引(无锁比较并交换) +- `ConcurrentHashMap.newKeySet()` 用于参与Steve集合 +- 所有访问都发生在服务器线程(单线程),但对未来并行化是安全的 + +#### 5.3 挖掘动作 (`MineBlockAction.java`) + +**智能深度导航**: +```java +// 矿石深度映射用于智能挖掘 +private static final Map ORE_DEPTHS = new HashMap<>() {{ + put("iron_ore", 64); // 铁在Y=64及以下生成良好 + put("deepslate_iron_ore", -16); // 深层铁 + put("coal_ore", 96); + put("copper_ore", 48); + put("gold_ore", 32); + put("diamond_ore", -59); // 最佳钻石深度 + put("deepslate_diamond_ore", -59); + put("redstone_ore", 16); + put("emerald_ore", 256); // 山地生物群系 +}}; +``` + +**方向性隧道挖掘**: +```java +@Override +protected void onStart() { + // 从玩家朝向确定挖掘方向 + Player nearestPlayer = findNearestPlayer(); + if (nearestPlayer != null) { + Vec3 lookVec = nearestPlayer.getLookAngle(); + + double angle = Math.atan2(lookVec.z, lookVec.x) * 180.0 / Math.PI; + angle = (angle + 360) % 360; + + // 转换为基本方向 + if (angle >= 315 || angle < 45) { + miningDirectionX = 1; miningDirectionZ = 0; // 东 + } else if (angle >= 45 && angle < 135) { + miningDirectionX = 0; miningDirectionZ = 1; // 南 + } else if (angle >= 135 && angle < 225) { + miningDirectionX = -1; miningDirectionZ = 0; // 西 + } else { + miningDirectionX = 0; miningDirectionZ = -1; // 北 + } + + // 起始位置:玩家前方3格 + Vec3 targetPos = eyePos.add(lookVec.scale(3)); + miningStartPos = new BlockPos(targetPos); + + // 找到实心地面 + for (int y = miningStartPos.getY(); y > -64; y--) { + BlockPos groundCheck = new BlockPos(miningStartPos.getX(), y, miningStartPos.getZ()); + if (steve.level().getBlockState(groundCheck).isSolid()) { + miningStartPos = groundCheck.above(); + break; + } + } + + steve.teleportTo(miningStartPos.getX() + 0.5, miningStartPos.getY(), miningStartPos.getZ() + 0.5); + } + + steve.setFlying(true); + equipIronPickaxe(); // 给代理一把铁镐 +} +``` + +**隧道挖掘**: +```java +private void mineNearbyBlock() { + BlockPos centerPos = currentTunnelPos; + BlockPos abovePos = centerPos.above(); + BlockPos belowPos = centerPos.below(); + + // 挖掘3格高的隧道(中心、上方、下方) + BlockState centerState = steve.level().getBlockState(centerPos); + if (!centerState.isAir() && centerState.getBlock() != Blocks.BEDROCK) { + steve.teleportTo(centerPos.getX() + 0.5, centerPos.getY(), centerPos.getZ() + 0.5); + steve.swing(InteractionHand.MAIN_HAND, true); + steve.level().destroyBlock(centerPos, true); // true = 掉落物品 + } + + BlockState aboveState = steve.level().getBlockState(abovePos); + if (!aboveState.isAir() && aboveState.getBlock() != Blocks.BEDROCK) { + steve.swing(InteractionHand.MAIN_HAND, true); + steve.level().destroyBlock(abovePos, true); + } + + BlockState belowState = steve.level().getBlockState(belowPos); + if (!belowState.isAir() && belowState.getBlock() != Blocks.BEDROCK) { + steve.swing(InteractionHand.MAIN_HAND, true); + steve.level().destroyBlock(belowPos, true); + } + + // 沿挖掘方向推进隧道位置 + currentTunnelPos = currentTunnelPos.offset(miningDirectionX, 0, miningDirectionZ); +} +``` + +**隧道中的矿石检测**: +```java +private void findNextBlock() { + List foundBlocks = new ArrayList<>(); + + // 在隧道方向前方搜索20格 + for (int distance = 0; distance < 20; distance++) { + BlockPos checkPos = currentTunnelPos.offset(miningDirectionX * distance, 0, miningDirectionZ * distance); + + // 检查中心、上方、下方 + for (int y = -1; y <= 1; y++) { + BlockPos orePos = checkPos.offset(0, y, 0); + if (steve.level().getBlockState(orePos).getBlock() == targetBlock) { + foundBlocks.add(orePos); + } + } + } + + if (!foundBlocks.isEmpty()) { + // 获取最近的矿石 + currentTarget = foundBlocks.stream() + .min((a, b) -> Double.compare(a.distSqr(currentTunnelPos), b.distSqr(currentTunnelPos))) + .orElse(null); + } +} +``` + +**自动照明**: +```java +private void placeTorchIfDark() { + BlockPos stevePos = steve.blockPosition(); + int lightLevel = steve.level().getBrightness(LightLayer.BLOCK, stevePos); + + if (lightLevel < MIN_LIGHT_LEVEL) { // MIN_LIGHT_LEVEL = 8 + BlockPos torchPos = findTorchPosition(stevePos); + + if (torchPos != null && steve.level().getBlockState(torchPos).isAir()) { + steve.level().setBlock(torchPos, Blocks.TORCH.defaultBlockState(), 3); + steve.swing(InteractionHand.MAIN_HAND, true); + } + } +} +``` + +#### 5.4 战斗动作 (`CombatAction.java`) + +**目标获取**: +```java +private void findTarget() { + AABB searchBox = steve.getBoundingBox().inflate(32.0); // 32格搜索半径 + List entities = steve.level().getEntities(steve, searchBox); + + LivingEntity nearest = null; + double nearestDistance = Double.MAX_VALUE; + + for (Entity entity : entities) { + if (entity instanceof LivingEntity living && isValidTarget(living)) { + double distance = steve.distanceTo(living); + if (distance < nearestDistance) { + nearest = living; + nearestDistance = distance; + } + } + } + + target = nearest; +} + +private boolean isValidTarget(LivingEntity entity) { + if (!entity.isAlive() || entity.isRemoved()) { + return false; + } + + // 不攻击其他Steve或玩家 + if (entity instanceof SteveEntity || entity instanceof Player) { + return false; + } + + String targetLower = targetType.toLowerCase(); + + // 匹配任何敌对生物 + if (targetLower.contains("mob") || targetLower.contains("hostile") || + targetLower.contains("monster") || targetLower.equals("any")) { + return entity instanceof Monster; + } + + // 匹配特定实体类型 + String entityTypeName = entity.getType().toString().toLowerCase(); + return entityTypeName.contains(targetLower); +} +``` + +**战斗循环**: +```java +@Override +protected void onTick() { + // 周期性重新搜索目标 + if (target == null || !target.isAlive() || target.isRemoved()) { + if (ticksRunning % 20 == 0) { + findTarget(); + } + return; + } + + double distance = steve.distanceTo(target); + + // 冲向目标 + steve.setSprinting(true); + steve.getNavigation().moveTo(target, 2.5); // 高速移动倍数 + + // 卡住检测:卡住2秒后传送 + if (Math.abs(currentX - lastX) < 0.1 && Math.abs(currentZ - lastZ) < 0.1) { + ticksStuck++; + + if (ticksStuck > 40 && distance > ATTACK_RANGE) { + // 向目标传送4格 + double dx = target.getX() - steve.getX(); + double dz = target.getZ() - steve.getZ(); + double dist = Math.sqrt(dx*dx + dz*dz); + double moveAmount = Math.min(4.0, dist - ATTACK_RANGE); + + steve.teleportTo( + steve.getX() + (dx/dist) * moveAmount, + steve.getY(), + steve.getZ() + (dz/dist) * moveAmount + ); + ticksStuck = 0; + } + } + + // 在范围内时攻击 + if (distance <= ATTACK_RANGE) { // ATTACK_RANGE = 3.5格 + steve.doHurtTarget(target); + steve.swing(InteractionHand.MAIN_HAND, true); + + // 每秒攻击3次(每6-7tick) + if (ticksRunning % 7 == 0) { + steve.doHurtTarget(target); + } + } +} +``` + +### 6. 结构模板系统 (`StructureTemplateLoader.java`) + +**NBT模板加载**: +```java +public static LoadedTemplate loadFromNBT(ServerLevel level, String structureName) { + File structuresDir = new File(System.getProperty("user.dir"), "structures"); + + // 精确匹配: "house.nbt" + File exactMatch = new File(structuresDir, structureName + ".nbt"); + if (exactMatch.exists()) { + return loadFromFile(exactMatch, structureName); + } + + // 带空格匹配: "old house.nbt" 对应 "oldhouse" + String withSpaces = structureName.replaceAll("(\\w)(\\p{Upper})", "$1 $2").toLowerCase(); + File spacedMatch = new File(structuresDir, withSpaces + ".nbt"); + if (spacedMatch.exists()) { + return loadFromFile(spacedMatch, structureName); + } + + // 模糊匹配:规范化两个字符串(小写,移除空格/下划线) + File[] files = structuresDir.listFiles((dir, name) -> { + if (!name.endsWith(".nbt")) return false; + + String nameWithoutExt = name.substring(0, name.length() - 4); + String normalizedFile = nameWithoutExt.toLowerCase().replace(" ", "").replace("_", ""); + String normalizedSearch = structureName.toLowerCase().replace(" ", "").replace("_", ""); + + return normalizedFile.equals(normalizedSearch); + }); + + if (files != null && files.length > 0) { + return loadFromFile(files[0], structureName); + } + + return null; // 未找到模板,将回退到程序化生成 +} +``` + +**NBT解析**: +```java +private static LoadedTemplate parseNBTStructure(CompoundTag nbt, String name) { + List blocks = new ArrayList<>(); + + // 读取尺寸 + var sizeList = nbt.getList("size", 3); // TAG_Int + int width = sizeList.getInt(0); + int height = sizeList.getInt(1); + int depth = sizeList.getInt(2); + + // 读取方块调色板 + var paletteList = nbt.getList("palette", 10); // TAG_Compound + List palette = new ArrayList<>(); + + for (int i = 0; i < paletteList.size(); i++) { + CompoundTag blockTag = paletteList.getCompound(i); + String blockName = blockTag.getString("Name"); // 例如 "minecraft:stone_bricks" + + try { + ResourceLocation blockLocation = new ResourceLocation(blockName); + Block block = BuiltInRegistries.BLOCK.get(blockLocation); + palette.add(block.defaultBlockState()); + } catch (Exception e) { + palette.add(Blocks.AIR.defaultBlockState()); + } + } + + // 读取方块放置 + var blocksList = nbt.getList("blocks", 10); + for (int i = 0; i < blocksList.size(); i++) { + CompoundTag blockTag = blocksList.getCompound(i); + + int paletteIndex = blockTag.getInt("state"); + var posList = blockTag.getList("pos", 3); + + BlockPos pos = new BlockPos( + posList.getInt(0), + posList.getInt(1), + posList.getInt(2) + ); + + BlockState state = palette.get(paletteIndex); + if (!state.isAir()) { + blocks.add(new TemplateBlock(pos, state)); + } + } + + return new LoadedTemplate(name, blocks, width, height, depth); +} +``` + +**在BuildStructureAction中的使用**: +```java +private List tryLoadFromTemplate(String structureName, BlockPos startPos) { + if (!(steve.level() instanceof ServerLevel serverLevel)) { + return null; + } + + var template = StructureTemplateLoader.loadFromNBT(serverLevel, structureName); + if (template == null) { + return null; // 回退到程序化生成 + } + + List blocks = new ArrayList<>(); + for (var templateBlock : template.blocks) { + BlockPos worldPos = startPos.offset(templateBlock.relativePos); + Block block = templateBlock.blockState.getBlock(); + blocks.add(new BlockPlacement(worldPos, block)); + } + + return blocks; +} +``` + +### 7. GUI系统 (`SteveGUI.java`) + +**Cursor风格的滑动面板**: +```java +// 面板从右侧滑入 +private static float slideOffset = PANEL_WIDTH; // 初始隐藏 +private static final int ANIMATION_SPEED = 20; + +@SubscribeEvent +public static void onRenderOverlay(RenderGuiOverlayEvent.Post event) { + // 动画滑动 + if (isOpen && slideOffset > 0) { + slideOffset = Math.max(0, slideOffset - ANIMATION_SPEED); + } else if (!isOpen && slideOffset < PANEL_WIDTH) { + slideOffset = Math.min(PANEL_WIDTH, slideOffset + ANIMATION_SPEED); + } + + // 完全隐藏时不渲染 + if (slideOffset >= PANEL_WIDTH) return; + + int panelX = (int) (screenWidth - PANEL_WIDTH + slideOffset); + int panelY = 0; + int panelHeight = screenHeight; + + // 渲染半透明背景 + graphics.fillGradient(panelX, panelY, screenWidth, panelHeight, + BACKGROUND_COLOR, BACKGROUND_COLOR); // 0x15202020 = ~8%不透明度 + + // 渲染边框 + graphics.fillGradient(panelX - 2, panelY, panelX, panelHeight, + BORDER_COLOR, BORDER_COLOR); + + // 渲染标题 + graphics.fillGradient(panelX, panelY, screenWidth, 35, HEADER_COLOR, HEADER_COLOR); + graphics.drawString(mc.font, "§lSteve AI", panelX + 6, panelY + 8, TEXT_COLOR); +} +``` + +**可滚动的消息历史**: +```java +private static class ChatMessage { + String sender; // "你", "Steve", "Alex" + String text; + int bubbleColor; // 颜色编码:绿色(用户),蓝色(Steve),橙色(系统) + boolean isUser; +} + +private static List messages = new ArrayList<>(); +private static int scrollOffset = 0; +private static int maxScroll = 0; + +// 渲染带滚动的消息 +int totalMessageHeight = 0; +for (ChatMessage msg : messages) { + int bubbleHeight = MESSAGE_HEIGHT + 10; + totalMessageHeight += bubbleHeight + 5 + 12; // 消息 + 间距 + 名称 +} +maxScroll = Math.max(0, totalMessageHeight - messageAreaHeight); +scrollOffset = Math.max(0, Math.min(scrollOffset, maxScroll)); + +// 将渲染裁剪到消息区域 +graphics.enableScissor(panelX, messageAreaTop, screenWidth, messageAreaBottom); + +// 渲染每个消息气泡 +for (ChatMessage msg : messages) { + graphics.fill(bubbleX, bubbleY, bubbleX + bubbleWidth, bubbleY + bubbleHeight, + msg.bubbleColor); + graphics.drawString(mc.font, msg.text, textX, textY, TEXT_COLOR); +} +``` + +### 8. 内存系统 + +#### 8.1 SteveMemory (`SteveMemory.java`) + +**短期动作历史**: +```java +public class SteveMemory { + private String currentGoal; + private final LinkedList recentActions; + private static final int MAX_RECENT_ACTIONS = 20; + + public void addAction(String action) { + recentActions.addLast(action); + if (recentActions.size() > MAX_RECENT_ACTIONS) { + recentActions.removeFirst(); // FIFO队列 + } + } + + public List getRecentActions(int count) { + int size = Math.min(count, recentActions.size()); + int startIndex = Math.max(0, recentActions.size() - count); + return new ArrayList<>(recentActions.subList(startIndex, recentActions.size())); + } +} +``` + +**NBT持久化**: +```java +public void saveToNBT(CompoundTag tag) { + tag.putString("CurrentGoal", currentGoal); + + ListTag actionsList = new ListTag(); + for (String action : recentActions) { + actionsList.add(StringTag.valueOf(action)); + } + tag.put("RecentActions", actionsList); +} + +public void loadFromNBT(CompoundTag tag) { + if (tag.contains("CurrentGoal")) { + currentGoal = tag.getString("CurrentGoal"); + } + + if (tag.contains("RecentActions")) { + recentActions.clear(); + ListTag actionsList = tag.getList("RecentActions", 8); // 8 = String + for (int i = 0; i < actionsList.size(); i++) { + recentActions.add(actionsList.getString(i)); + } + } +} +``` + +#### 8.2 结构注册表 (`StructureRegistry.java`) + +跟踪已建造的结构以防止在同一位置重复建造: +```java +private static final Map> structuresByType = new HashMap<>(); + +public static void register(BlockPos pos, int width, int height, int depth, String type) { + StructureRecord record = new StructureRecord(pos, width, height, depth, type); + structuresByType.computeIfAbsent(type, k -> new ArrayList<>()).add(record); +} + +public static List getStructuresOfType(String type) { + return structuresByType.getOrDefault(type, new ArrayList<>()); +} +``` + +### 9. 配置系统 (`SteveConfig.java`) + +**Forge配置规范**: +```java +public static final ForgeConfigSpec.ConfigValue AI_PROVIDER; +public static final ForgeConfigSpec.ConfigValue OPENAI_API_KEY; +public static final ForgeConfigSpec.ConfigValue OPENAI_MODEL; +public static final ForgeConfigSpec.IntValue MAX_TOKENS; +public static final ForgeConfigSpec.DoubleValue TEMPERATURE; +public static final ForgeConfigSpec.IntValue ACTION_TICK_DELAY; +public static final ForgeConfigSpec.BooleanValue ENABLE_CHAT_RESPONSES; +public static final ForgeConfigSpec.IntValue MAX_ACTIVE_STEVES; + +static { + ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); + + AI_PROVIDER = builder + .comment("AI提供商: 'groq'(最快,免费), 'openai', 或 'gemini'") + .define("provider", "groq"); // 默认Groq + + OPENAI_API_KEY = builder + .comment("你的API密钥") + .define("apiKey", ""); + + OPENAI_MODEL = builder + .comment("OpenAI模型 (gpt-4, gpt-4-turbo-preview, gpt-3.5-turbo)") + .define("model", "gpt-4-turbo-preview"); + + MAX_TOKENS = builder + .comment("每次请求的最大token数") + .defineInRange("maxTokens", 8000, 100, 65536); + + TEMPERATURE = builder + .comment("温度 (0.0-2.0, 越低越确定)") + .defineInRange("temperature", 0.7, 0.0, 2.0); + + ACTION_TICK_DELAY = builder + .comment("动作检查之间的tick数 (20 ticks = 1秒)") + .defineInRange("actionTickDelay", 20, 1, 100); + + MAX_ACTIVE_STEVES = builder + .comment("同时活跃的最大Steve数") + .defineInRange("maxActiveSteves", 10, 1, 50); + + SPEC = builder.build(); +} +``` + +**配置文件** (`config/steve-common.toml`): +```toml +[ai] + provider = "groq" + +[openai] + apiKey = "sk-..." + model = "gpt-4-turbo-preview" + maxTokens = 8000 + temperature = 0.7 + +[behavior] + actionTickDelay = 20 + enableChatResponses = true + maxActiveSteves = 10 +``` + +### 10. 命令系统 (`SteveCommands.java`) + +**Brigadier命令注册**: +```java +public static void register(CommandDispatcher dispatcher) { + dispatcher.register(Commands.literal("steve") + .then(Commands.literal("spawn") + .then(Commands.argument("name", StringArgumentType.string()) + .executes(SteveCommands::spawnSteve))) + .then(Commands.literal("remove") + .then(Commands.argument("name", StringArgumentType.string()) + .executes(SteveCommands::removeSteve))) + .then(Commands.literal("list") + .executes(SteveCommands::listSteves)) + .then(Commands.literal("stop") + .then(Commands.argument("name", StringArgumentType.string()) + .executes(SteveCommands::stopSteve))) + .then(Commands.literal("tell") + .then(Commands.argument("name", StringArgumentType.string()) + .then(Commands.argument("command", StringArgumentType.greedyString()) + .executes(SteveCommands::tellSteve)))) + ); +} +``` + +**生成逻辑**: +```java +private static int spawnSteve(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + CommandSourceStack source = context.getSource(); + + ServerLevel serverLevel = source.getLevel(); + SteveManager manager = SteveMod.getSteveManager(); + + // 在玩家朝向前方3格生成 + Vec3 sourcePos = source.getPosition(); + if (source.getEntity() != null) { + Vec3 lookVec = source.getEntity().getLookAngle(); + sourcePos = sourcePos.add(lookVec.x * 3, 0, lookVec.z * 3); + } + + SteveEntity steve = manager.spawnSteve(serverLevel, sourcePos, name); + if (steve != null) { + source.sendSuccess(() -> Component.literal("已生成Steve: " + name), true); + return 1; + } else { + source.sendFailure(Component.literal("生成Steve失败")); + return 0; + } +} +``` + +**异步命令执行**: +```java +private static int tellSteve(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + String command = StringArgumentType.getString(context, "command"); + + SteveManager manager = SteveMod.getSteveManager(); + SteveEntity steve = manager.getSteve(name); + + if (steve != null) { + // 在单独线程中执行以避免阻塞游戏线程 + new Thread(() -> { + steve.getActionExecutor().processNaturalLanguageCommand(command); + }).start(); + + return 1; + } else { + source.sendFailure(Component.literal("未找到Steve: " + name)); + return 0; + } +} +``` + +--- + +## 复杂实现亮点 + +### 1. 无锁多代理协调 + +**挑战**: 多个代理建造同一结构时,不能重复放置同一方块。 + +**传统方案**: 锁、互斥锁、同步块 → 慢,容易死锁 + +**我们的方案**: 无锁原子操作与空间分区 + +**实现**: +```java +// 每个区域有一个原子计数器 +private final AtomicInteger nextBlockIndex; + +public BlockPlacement getNextBlock() { + // 原子比较并交换 - 无需锁 + int index = nextBlockIndex.getAndIncrement(); + if (index < blocks.size()) { + return blocks.get(index); + } + return null; +} +``` + +**为什么有效**: +- `getAndIncrement()` 是硬件级原子操作(CPU指令CMPXCHG) +- 无锁竞争,无等待 +- 每个代理获得唯一的方块索引 +- O(1) 时间复杂度 +- 即使多个代理同时tick也是线程安全的(为未来并行化做好准备) + +**性能影响**: +- 单代理建造零开销 +- 10代理建造亚毫秒开销 +- 随代理数量线性扩展(无二次碰撞检测) + +### 2. 指数退避重试逻辑 + +**挑战**: LLM API不可预测地失败(网络问题、速率限制、服务器错误) + +**实现**: +```java +for (int attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + HttpResponse response = client.send(request, ...); + + if (response.statusCode() == 200) { + return parseResponse(response.body()); + } + + // 在速率限制或服务器错误时重试 + if (response.statusCode() == 429 || response.statusCode() >= 500) { + if (attempt < MAX_RETRIES - 1) { + int delayMs = INITIAL_RETRY_DELAY_MS * (int) Math.pow(2, attempt); + // 重试间隔: 1s, 2s, 4s + Thread.sleep(delayMs); + continue; + } + } + + return null; // 不可重试的错误 + + } catch (Exception e) { + // 网络错误 - 重试 + } +} +``` + +**为什么是指数级**: +- 线性退避(1s, 2s, 3s)在服务器已经过载时还会继续请求 +- 指数级(1s, 2s, 4s)给服务器恢复时间 +- 行业标准(AWS、Google Cloud等都使用指数退避) + +**成功率提升**: +- 之前: 首次尝试约70%成功率 +- 之后: 3次尝试内约95%成功率 + +### 3. 智能地面查找算法 + +**挑战**: 玩家从任意位置(天空、地下、水中)请求"建一座房子" + +**简单方案**: 在当前Y级建造 → 漂浮的房子,地下的房子 + +**我们的方案**: 双向扫描与实心地面验证 + +**算法**: +```java +private BlockPos findGroundLevel(BlockPos startPos) { + // 阶段1: 向下扫描(最常见情况 - 玩家在地面上方) + for (int i = 0; i < 20; i++) { + BlockPos checkPos = startPos.below(i); + BlockPos belowPos = checkPos.below(); + + if (isAir(checkPos) && isSolidGround(belowPos)) { + return checkPos; // 找到地面:实心方块上方的空气 + } + } + + // 阶段2: 向上扫描(玩家在地下) + for (int i = 1; i < 10; i++) { + BlockPos checkPos = startPos.above(i); + BlockPos belowPos = checkPos.below(); + + if (isAir(checkPos) && isSolidGround(belowPos)) { + return checkPos; // 找到地表 + } + } + + // 阶段3: 回退 - 持续下降直到碰到东西 + BlockPos fallbackPos = startPos; + while (!isSolidGround(fallbackPos.below()) && fallbackPos.getY() > -64) { + fallbackPos = fallbackPos.below(); + } + + return fallbackPos; +} + +private boolean isSolidGround(BlockPos pos) { + var blockState = level.getBlockState(pos); + var block = blockState.getBlock(); + + // 空气或液体不算实心 + if (blockState.isAir() || block == Blocks.WATER || block == Blocks.LAVA) { + return false; + } + + return blockState.isSolid(); // Minecraft内置的实心检查 +} +``` + +**处理的边界情况**: +- 悬浮在天空 → 向下扫描,找到地面 +- 地下挖掘 → 向上扫描,找到地表 +- 在水中 → 跳过水,找到下方实心地面 +- 在岩浆中 → 跳过岩浆,找到实心地面 +- 在基岩层 → 停止扫描(Y > -64 检查) + +**性能**: +- 平均情况: 2-5次方块检查(玩家在平坦地面上) +- 最坏情况: 30次方块检查(玩家在地面上方20格) +- 时间复杂度: O(n),其中n = 到地面的垂直距离 + +### 4. 程序化城堡生成 + +**挑战**: 生成具有以下特征的建筑学有趣的结构: +- 空心墙壁 +- 角楼 +- 窗户 +- 城垛(城堡雉堞) +- 入口大门 + +**实现分解**: + +**步骤1: 主墙壁**(空心,带窗户) +```java +for (int y = 0; y <= height; y++) { + for (int x = 0; x < width; x++) { + for (int z = 0; z < depth; z++) { + boolean isEdge = (x == 0 || x == width - 1 || z == 0 || z == depth - 1); + boolean isCorner = (x <= 2 || x >= width - 3) && (z <= 2 || z >= depth - 3); + + if (isEdge && !isCorner) { + if (y % 4 == 2) { + // 每4格垂直方向的箭缝窗户 + blocks.add(new BlockPlacement(pos, GLASS_PANE)); + } else { + blocks.add(new BlockPlacement(pos, COBBLESTONE)); + } + } + } + } +} +``` + +**步骤2: 角楼**(3x3,比主高度高6格) +```java +int towerHeight = height + 6; +int[][] corners = {{0, 0}, {width - towerSize, 0}, {0, depth - towerSize}, {width - towerSize, depth - towerSize}}; + +for (int[] corner : corners) { + for (int y = 0; y <= towerHeight; y++) { + for (int dx = 0; dx < 3; dx++) { + for (int dz = 0; dz < 3; dz++) { + boolean isTowerEdge = (dx == 0 || dx == 2 || dz == 0 || dz == 2); + + if (y == 0 || isTowerEdge) { + // 实心底座,空心中心 + blocks.add(new BlockPlacement(pos, STONE_BRICKS)); + } + } + } + } +} +``` + +**步骤3: 城垛**(城堡雉堞) +```java +// 墙顶城垛 +for (int x = 0; x < width; x += 2) { + blocks.add(new BlockPlacement(start.offset(x, height + 1, 0), STONE_BRICKS)); + blocks.add(new BlockPlacement(start.offset(x, height + 2, 0), STONE_BRICKS)); +} + +// 塔顶城垛 +for (int dx = 0; dx < 3; dx++) { + for (int dz = 0; dz < 3; dz++) { + if (dx % 2 == 0 || dz % 2 == 0) { + blocks.add(new BlockPlacement(pos, STONE_BRICKS)); + } + } +} +``` + +**结果**: 800-1200个方块的结构,包含: +- 4个角楼 +- 箭缝窗户 +- 大门入口 +- 城垛墙壁 +- 全部从3个数字(宽度、高度、深度)程序化生成 + +### 5. 带错误恢复的健壮JSON解析 + +**挑战**: LLM不可靠 - 它们输出: +- 包装在markdown中的JSON(```json ... ```) +- 对象之间缺少逗号 +- JSON前后的额外解释 +- 格式错误的数组 + +**解决方案**: 带自动修复的多阶段解析 + +**阶段1: 从markdown中提取JSON** +```java +String cleaned = response.trim(); + +// 移除markdown代码块 +if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); +} else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); +} +if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); +} +``` + +**阶段2: 规范化空白字符** +```java +cleaned = cleaned.replaceAll("\\n\\s*", " "); +``` + +**阶段3: 修复常见的AI错误** +```java +// 缺少逗号: }{ → },{ +cleaned = cleaned.replaceAll("}\\s+\\{", "},{"); + +// 缺少逗号: }[ → },[ +cleaned = cleaned.replaceAll("}\\s+\\[", "},["); + +// 缺少逗号: ]{ → ],[ +cleaned = cleaned.replaceAll("]\\s+\\{", "],{"); + +// 缺少逗号: ][ → ],[ +cleaned = cleaned.replaceAll("]\\s+\\[", "],["); +``` + +**成功率**: +- 错误恢复前: 约60%成功解析 +- 错误恢复后: 约98%成功解析 + +**示例**: +``` +输入(来自LLM): +```json +{ + "reasoning": "建房子", + "tasks": [ + {"action": "build"} {"action": "mine"} + ] +} +``` + +阶段1后: { "reasoning": "建房子", "tasks": [ {"action": "build"} {"action": "mine"} ]} +阶段2后: { "reasoning": "建房子", "tasks": [ {"action": "build"} {"action": "mine"} ]} +阶段3后: { "reasoning": "建房子", "tasks": [ {"action": "build"},{"action": "mine"} ]} +✅ 有效JSON +``` + +### 6. 空间象限分区 + +**挑战**: 将任意结构分为4个相等的部分,无重叠 + +**简单方案**: 按方块数量划分(每个500个方块)→ 代理在边界碰撞 + +**我们的方案**: 基于边界框的空间分区 + +**算法**: +```java +// 找到边界框 +int minX = Integer.MAX_VALUE, maxX = Integer.MIN_VALUE; +int minZ = Integer.MAX_VALUE, maxZ = Integer.MIN_VALUE; + +for (BlockPlacement placement : buildPlan) { + minX = Math.min(minX, placement.pos.getX()); + maxX = Math.max(maxX, placement.pos.getX()); + minZ = Math.min(minZ, placement.pos.getZ()); + maxZ = Math.max(maxZ, placement.pos.getZ()); +} + +int centerX = (minX + maxX) / 2; +int centerZ = (minZ + maxZ) / 2; + +// 分区到象限 +for (BlockPlacement placement : buildPlan) { + int x = placement.pos.getX(); + int z = placement.pos.getZ(); + + if (x <= centerX && z <= centerZ) { + northWest.add(placement); + } else if (x > centerX && z <= centerZ) { + northEast.add(placement); + } else if (x <= centerX && z > centerZ) { + southWest.add(placement); + } else { + southEast.add(placement); + } +} + +// 每个象限从下到上排序 +Comparator bottomToTop = Comparator.comparingInt(p -> p.pos.getY()); +northWest.sort(bottomToTop); +northEast.sort(bottomToTop); +southWest.sort(bottomToTop); +southEast.sort(bottomToTop); +``` + +**为什么有效**: +- 每个象限在空间上隔离(无X/Z重叠) +- 从下到上建造确保结构完整性(无悬浮方块) +- 即使对于不规则形状,象限也大致相等 +- 不需要复杂的图分区算法 + +**示例**(14x10x14城堡): +- 总方块数: 1200 +- 西北象限: 280个方块 +- 东北象限: 320个方块 +- 西南象限: 290个方块 +- 东南象限: 310个方块 +- 最大不平衡: ~10%(可接受) + +### 7. 方向性隧道挖掘 + +**挑战**: "挖20个钻石" - 在哪里挖? + +**简单方案**: 随机行走、螺旋模式、网格模式 → 低效,看起来不自然 + +**我们的方案**: 沿玩家朝向的直线隧道 + +**实现**: +```java +// 从玩家朝向确定方向 +Vec3 lookVec = nearestPlayer.getLookAngle(); + +double angle = Math.atan2(lookVec.z, lookVec.x) * 180.0 / Math.PI; +angle = (angle + 360) % 360; + +// 转换为基本方向 +if (angle >= 315 || angle < 45) { + miningDirectionX = 1; miningDirectionZ = 0; // 东 +} else if (angle >= 45 && angle < 135) { + miningDirectionX = 0; miningDirectionZ = 1; // 南 +} else if (angle >= 135 && angle < 225) { + miningDirectionX = -1; miningDirectionZ = 0; // 西 +} else { + miningDirectionX = 0; miningDirectionZ = -1; // 北 +} + +// 在玩家前方3格开始 +Vec3 targetPos = eyePos.add(lookVec.scale(3)); +miningStartPos = new BlockPos(targetPos); + +// 挖掘隧道:3格高(中心 + 上方 + 下方) +currentTunnelPos = currentTunnelPos.offset(miningDirectionX, 0, miningDirectionZ); +``` + +**结果**: +- 玩家朝北 → 代理挖掘北向隧道 +- 玩家朝东 → 代理挖掘东向隧道 +- 玩家在地下 → 代理继续该方向 +- 创建逼真的直线隧道(像真正的挖掘) +- 容易导航回来(只需反向行走) + +### 8. 飞行 + 无敌模式用于建造 + +**挑战**: 代理需要在任何高度放置方块,且不能: +- 摔死 +- 在方块中窒息 +- 受到岩浆/火焰伤害 +- 被卡住 + +**解决方案**: 临时的创造模式状态 + +**实现**: +```java +public void setFlying(boolean flying) { + this.isFlying = flying; + this.setNoGravity(flying); // 禁用重力 + this.setInvulnerable(flying); // 免疫伤害 +} + +@Override +public void travel(Vec3 travelVector) { + if (this.isFlying && this.getNavigation().isInProgress()) { + super.travel(travelVector); + + // 添加微小上升力防止下落 + if (Math.abs(motionY) < 0.1) { + this.setDeltaMovement(this.getDeltaMovement().add(0, 0.05, 0)); + } + } else { + super.travel(travelVector); + } +} + +@Override +public boolean isInvulnerableTo(DamageSource source) { + return true; // 免疫所有伤害 +} +``` + +**为什么有效**: +- `setNoGravity(true)` → 代理不会下落 +- 微小上升力(0.05)→ 抵消任何向下速度 +- `isInvulnerableTo()` → 免疫火焰、岩浆、窒息、摔落伤害 +- 建造完成时自动禁用 + +**安全性**: +- 仅在建造/挖掘期间启用 +- 动作取消时禁用 +- 动作完成时禁用 +- 超时时禁用(20分钟) + +--- + +## 简历影响力陈述 + +### 1. 多代理系统工程 +**构建了一个使用原子操作和空间分区的无锁多代理协调系统,使10+个并发代理能够协作完成复杂的3D建造任务,实现零竞争条件和亚毫秒同步开销,工作负载在代理间达到95%平衡。** + +**技术细节**: +- 使用 `AtomicInteger.getAndIncrement()` 实现无锁方块声明 +- 实现基于边界框计算的象限空间分区 +- 实现O(1)时间复杂度的方块分配 +- 在每个象限内按从下到上排序建造计划以确保结构完整性 +- 测量10代理协作建造相比单代理建造的开销<1ms + +### 2. LLM集成与生产可靠性 +**设计了一个生产级的LLM集成管道,具有指数退避重试逻辑、智能JSON错误恢复和提供商故障转移,将API可靠性从70%提升到98%,并通过策略性提供商选择(Groq vs Gemini)将平均响应延迟从10s降低到500ms。** + +**技术细节**: +- 实现指数退避(1s, 2s, 4s)处理速率限制 +- 构建基于正则表达式的JSON提取和自动修复,处理格式错误的LLM输出 +- 创建提供商回退链: 主要 → Groq(最快) +- 将P95延迟从30s(Gemini)降低到2s(Groq),同时保持每100个命令<$0.01的成本 +- 处理429、5xx状态码时重试;4xx(除429外)立即失败 + +### 3. 程序化生成与算法设计 +**设计并实现了8个程序化结构生成算法(城堡、房子、塔楼、谷仓),具有建筑特征包括空心结构、城垛、窗户放置模式和尖顶屋顶,从3参数输入(宽度、高度、深度)生成800-1200个方块的结构。** + +**技术细节**: +- 实现带4个角楼、箭缝窗户和城垛雉堞的城堡算法 +- 创建双向地面查找算法,垂直扫描±20格并进行实心表面验证 +- 构建NBT模板加载系统,具有模糊文件名匹配(规范化小写、移除空格/下划线) +- 优化结构生成到O(w×h×d)时间复杂度,带提前退出优化 +- 集成Minecraft的方块放置API,实现粒子效果和音效同步 + +### 4. 实时游戏引擎集成 +**将自然语言AI代理集成到Minecraft的游戏循环中,使用基于tick的执行(50ms间隔),实现挖掘、建造和战斗动作的状态机,同时保持60 FPS性能并处理卡住检测、寻路失败和环境变化等边界情况。** + +**技术细节**: +- 实现带生命周期钩子(onStart, onTick, onCancel)的BaseAction抽象类 +- 构建带任务验证和失败时重新规划的动作队列系统 +- 使用位置增量跟踪创建卡住检测(40 tick内<0.1格移动后传送) +- 优化世界扫描到512次采样(16格半径,2格步长)vs 32,768次全扫描 +- 实现带重力覆盖和微小上升力(0.05)的飞行机制,用于稳定悬停 + +### 5. 上下文感知AI提示词 +**设计了一个上下文丰富的提示词工程系统,扫描16格环境(方块、实体、生物群系、玩家位置)并生成具有情境感知的结构化提示词,使代理能够做出智能的上下文相关决策,任务完成准确率90%+。** + +**技术细节**: +- 使用AABB(轴对齐边界框)实现实体检测的WorldKnowledge扫描器 +- 构建按出现次数排序的方块频率分析(前5个) +- 通过Minecraft的注册表访问API创建生物群系检测 +- 设计严格的JSON输出格式,带模式验证和推理提取 +- 将平均提示词大小减少到<500 token,同时保持完整的环境上下文 + +--- + +## 附录:关键指标 + +| 指标 | 值 | +|------|-----| +| **总代码行数** | ~3,200行Java | +| **代码文件数** | 47个.java文件 | +| **已实现动作** | 8个(建造、挖掘、攻击、寻路、跟随、收集、放置、合成*) | +| **结构类型** | 8个程序化 + 无限NBT模板 | +| **最大并发代理数** | 10(可配置到50) | +| **平均LLM延迟** | 500ms (Groq), 2s (OpenAI), 10-30s (Gemini) | +| **API可靠性** | 98%成功率(带重试) | +| **内存占用** | 每个代理<50MB | +| **外部依赖** | 0(使用Java 11+ HttpClient,Gson在Minecraft中内置) | +| **支持的Minecraft版本** | 1.20.1 | +| **构建时间** | ~5秒(Gradle) | + +*合成已存根 + +--- + +## 结论 + +Steve AI代表了游戏环境中具身AI的新方法。通过将LLM驱动的自然语言理解与实时游戏引擎集成、多代理协调和程序化生成相结合,该项目证明了AI可以不仅仅是被动助手——它们可以成为复杂动态环境中的主动队友。 + +技术成就包括: +1. 无锁多代理协作 +2. 可靠性98%的生产级LLM集成 +3. 复杂的程序化生成算法 +4. Minecraft引擎内的实时基于tick的执行 +5. 带环境扫描的上下文感知AI提示词 + +该项目作为游戏AI未来的概念验证:智能、协作,真正具身化。 diff --git a/build.gradle b/build.gradle index 3f35f8dc..2d7feee7 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'idea' id 'maven-publish' id 'net.minecraftforge.gradle' version '[6.0,6.2)' + id 'com.github.johnrengelman.shadow' version '8.1.1' } version = '1.0.0' @@ -22,6 +23,8 @@ minecraft { workingDirectory project.file('run') property 'forge.logging.markers', 'REGISTRIES' property 'forge.logging.console.level', 'debug' + property 'file.encoding', 'UTF-8' + property 'log4j2.encoding', 'UTF-8' mods { steve { @@ -34,6 +37,8 @@ minecraft { workingDirectory project.file('run') property 'forge.logging.markers', 'REGISTRIES' property 'forge.logging.console.level', 'debug' + property 'file.encoding', 'UTF-8' + property 'log4j2.encoding', 'UTF-8' mods { steve { @@ -46,7 +51,14 @@ minecraft { sourceSets.main.resources { srcDir 'src/generated/resources' } +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' +} + repositories { + maven { url = 'https://lss233.littleservice.cn/repositories/minecraft/' } + maven { url = 'https://maven.minecraftforge.net/' } + maven { url 'https://maven.aliyun.com/repository/public' } mavenCentral() } @@ -72,6 +84,8 @@ dependencies { // Apache Commons for SHA-256 hashing (cache keys) implementation 'commons-codec:commons-codec:1.16.0' + implementation 'io.modelcontextprotocol.sdk:mcp:2.0.0-M3' + // JUnit 5 for testing testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.3' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.3' @@ -81,6 +95,39 @@ test { useJUnitPlatform() } +configurations { + shadowLibs +} + +// Only include the specific dependencies we need to shade +dependencies { + shadowLibs 'com.github.ben-manes.caffeine:caffeine:3.1.8' + shadowLibs 'io.github.resilience4j:resilience4j-circuitbreaker:2.1.0' + shadowLibs 'io.github.resilience4j:resilience4j-retry:2.1.0' + shadowLibs 'io.github.resilience4j:resilience4j-ratelimiter:2.1.0' + shadowLibs 'io.github.resilience4j:resilience4j-bulkhead:2.1.0' + shadowLibs 'io.modelcontextprotocol.sdk:mcp:2.0.0-M3' + // commons-codec is already in Forge - no need to shade +} + +shadowJar { + archiveClassifier = '' + configurations = [project.configurations.shadowLibs] + // Do NOT relocate - keep original package names + // Exclude transitive dependencies that conflict with Forge modules + dependencies { + exclude(dependency('com.google.errorprone:.*:.*')) + exclude(dependency('com.google.code.findbugs:.*:.*')) + exclude(dependency('org.checkerframework:.*:.*')) + exclude(dependency('com.google.j2objc:.*:.*')) + exclude(dependency('com.google.guava:.*:.*')) + exclude(dependency('org.slf4j:.*:.*')) + exclude(dependency('commons-codec:.*:.*')) + exclude(dependency('org.apache.commons:.*:.*')) + } +} + +// Make jar produce the shadow jar output tasks.named('jar', Jar).configure { manifest { attributes([ @@ -92,14 +139,15 @@ tasks.named('jar', Jar).configure { 'Implementation-Vendor': 'Steve AI' ]) } - - finalizedBy 'reobfJar' + // Use shadowJar as the final output + dependsOn 'shadowJar' + enabled = false } publishing { publications { mavenJava(MavenPublication) { - artifact jar + artifact shadowJar } } repositories { @@ -108,4 +156,3 @@ publishing { } } } - diff --git a/check-demo.py b/check-demo.py new file mode 100644 index 00000000..1f21f7a0 --- /dev/null +++ b/check-demo.py @@ -0,0 +1,43 @@ +"""Quick check: open the dashboard in demo mode and dump the DOM + any errors.""" + +import asyncio +import sys +from playwright.async_api import async_playwright + +URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:5173/?demo=1" + +async def main(): + async with async_playwright() as p: + # Use the full chromium we already have installed, not headless_shell. + browser = await p.chromium.launch( + headless=True, + executable_path=r"C:\Users\LuZhong\AppData\Local\ms-playwright\chromium-1208\chrome-win64\chrome.exe", + ) + context = await browser.new_context(viewport={"width": 1280, "height": 800}) + page = await context.new_page() + errors = [] + page.on("pageerror", lambda exc: errors.append(f"pageerror: {exc}")) + page.on("console", lambda msg: errors.append(f"console.{msg.type}: {msg.text}")) + await page.goto(URL, wait_until="networkidle") + await page.wait_for_timeout(2000) + + info = await page.evaluate("""() => { + const cs = document.querySelectorAll('canvas'); + const demoBanner = !!document.body.innerText.match(/Demo mode/); + const root = document.getElementById('root'); + return { + canvasCount: cs.length, + canvasSizes: Array.from(cs).map(c => ({w: c.width, h: c.height})), + demoBanner, + rootTextLen: root ? root.innerText.length : 0, + rootFirstChars: root ? root.innerText.slice(0, 200) : '', + }; + }""") + print("URL:", URL) + print("Errors:") + for e in errors: + print(" ", e) + print("Info:", info) + await browser.close() + +asyncio.run(main()) diff --git a/config/schematics/duckyausautoroute10091612.schematic b/config/schematics/duckyausautoroute10091612.schematic new file mode 100644 index 00000000..af8380f2 Binary files /dev/null and b/config/schematics/duckyausautoroute10091612.schematic differ diff --git a/config/steve-common.toml.example b/config/steve-common.toml.example index 0246878c..7cf3501f 100644 --- a/config/steve-common.toml.example +++ b/config/steve-common.toml.example @@ -18,7 +18,36 @@ # Allow Steves to respond in chat enableChatResponses = true - + + # Creative mode - Steve has unlimited building materials (no mining needed) + creativeMode = true + # Maximum number of Steves that can be active simultaneously maxActiveSteves = 10 + # Ticks between each block placement during building (20 ticks = 1 second) + # 1 = fastest (20 blocks/sec), 20 = 1 block/sec (default), 100 = 1 block/5sec + buildTickDelay = 20 + +[mcp] + # Enable MCP tool calling + enabled = true + + # JSON array of MCP server configurations + # Example: [{"name":"mempalace","url":"http://localhost:6060"}] + servers = "[{\"name\":\"mempalace\",\"url\":\"http://localhost:6060\"}]" + + # MCP tool call timeout in milliseconds (1000 ~ 120000) + timeoutMs = 30000 + +#ReAct (Reason + Act) Mode Configuration +[react] + #Maximum ReAct steps before force-finishing (1-50) + #Range: 1 ~ 50 + maxSteps = 12 + #Per-observation character truncation (100-4000) + #Range: 100 ~ 4000 + observationTruncateChars = 800 + #Consecutive LLM parse failures before giving up (1-10) + #Range: 1 ~ 10 + maxConsecutiveFailures = 3 \ No newline at end of file diff --git a/config/steve/structures/template_house_1.nbt b/config/steve/structures/template_house_1.nbt new file mode 100644 index 00000000..818ec6d6 Binary files /dev/null and b/config/steve/structures/template_house_1.nbt differ diff --git a/config/steve/structures/template_house_2.nbt b/config/steve/structures/template_house_2.nbt new file mode 100644 index 00000000..5a9747a7 Binary files /dev/null and b/config/steve/structures/template_house_2.nbt differ diff --git "a/config/structures/\346\210\277\345\255\220_1.nbt" "b/config/structures/\346\210\277\345\255\220_1.nbt" new file mode 100644 index 00000000..818ec6d6 Binary files /dev/null and "b/config/structures/\346\210\277\345\255\220_1.nbt" differ diff --git "a/config/structures/\346\210\277\345\255\220_2.nbt" "b/config/structures/\346\210\277\345\255\220_2.nbt" new file mode 100644 index 00000000..5a9747a7 Binary files /dev/null and "b/config/structures/\346\210\277\345\255\220_2.nbt" differ diff --git a/docs/00-overview.md b/docs/00-overview.md new file mode 100644 index 00000000..bb2d6c3c --- /dev/null +++ b/docs/00-overview.md @@ -0,0 +1,67 @@ +# Steve AI - Minecraft AI Agent Mod + +## 项目概述 + +**Steve AI** 是一个 Minecraft Forge 1.20.1 模组,将 AI 驱动的自主 agents(称为 "Steves")引入游戏世界。用户可以通过命令生成 Steves 并给予自然语言指令,如"开采 20 铁矿石"或"在我附近建一座房子"。Steves 使用大语言模型(OpenAI GPT、Groq 或 Google Gemini)来理解指令、规划行动并在 Minecraft 世界中执行。 + +**版本**: 1.0.0 +**Minecraft 版本**: 1.20.1 +**Java 版本**: 17 + +## 快速开始 + +### 命令 + +| 命令 | 功能 | +|------|------| +| `/steve spawn ` | 生成新的 Steve | +| `/steve remove ` | 移除 Steve | +| `/steve list` | 列出所有活跃的 Steves | +| `/steve stop ` | 停止当前动作 | +| `/steve tell ` | 发送自然语言指令 | +| `/steve plan ` | 进入 plan mode (LLM 选 NBT 模板、出设计书、等 approve) | +| `/steve approve` | 批准当前设计,直接进入 CONSTRUCTION 阶段施工 | +| `/steve halt [reason]` | 中止当前 build,已放置方块不撤回 | +| `/steve status` | 输出当前 BuildProject 的所有阶段状态(debug) | +| `/steve dashboard [/stop]` | 启动/停止外部 plan UI HTTP server (默认 127.0.0.1:8765) | + +### 示例 + +``` +/steve spawn miner1 +/steve tell miner1 开采 20 铁矿石 +/steve tell miner1 在我附近建一座房子 +/steve tell miner1 保护我免受僵尸攻击 +``` + +### GUI + +按 **K** 打开右侧滑出面板,可滚动消息历史,支持命令历史(上下箭头)。 + +颜色区分: +- 🟢 绿色: 用户消息 +- 🔵 蓝色: Steve 响应 +- 🟠 橙色: 系统消息 + +## ReAct 模式(Reason + Act) + +Steve 不是"想完再干",而是"想一步干一步": + +1. LLM 看到命令 → 决定一个 Action(带 Thought 说明) +2. Steve 执行 Action → 把结果(`ActionResult`)作为 Observation 反馈给 LLM +3. LLM 根据 Observation 决定下一步 +4. 直到 LLM 输出 `is_final: true` 或达到 `maxSteps` + +这样 LLM 可以先调 MCP 工具查信息(如 `mempalace_list_drawers` 查可用模板),看到结果后再决定下一步该建造什么。命令排队:玩家在 ReAct 进行中发新指令会入队,当前 ReAct 完成后自动处理。 + +详见 [docs/01-architecture.md](01-architecture.md) §5、§6 和 `llm/react/ReActAgent.java`。 + +## Mempalace / MCP 集成 + +- **mempalace**(默认 `http://localhost:6060`)是外部 MCP 服务,存结构模板元信息和已建建筑位置 +- **启动时** `StructureTemplateLoader` 扫描 `config/steve/structures/*.nbt`,注册到 mempalace +- **运行时** LLM 通过 `action="mcp"` 调 mempalace 工具查模板 +- **建造完成** 写位置到 `wing=built_structures` +- **长期记忆** `SteveMemory.queryLongTermMemory()` 也走 mempalace + +详见 `docs/hackathon/03-mempalace-integration.md` 和 [docs/06-llm.md](06-llm.md) §5。 diff --git a/docs/01-architecture.md b/docs/01-architecture.md new file mode 100644 index 00000000..3876e9a8 --- /dev/null +++ b/docs/01-architecture.md @@ -0,0 +1,166 @@ +# 核心架构 + +## 目录结构 + +``` +src/main/java/com/steve/ai/ +├── SteveMod.java # 模组主入口 (Forge mod) +├── action/ # 动作执行系统 +│ ├── ActionExecutor.java # ReAct 调度器(命令排队 + 步骤分发) +│ ├── CollaborativeBuildManager.java # 多 Agent 协调 +│ ├── BuildProject.java # 四阶段 plan-then-build 项目数据模型 +│ ├── Task.java # 动作任务数据模型 +│ ├── ActionResult.java +│ └── actions/ # 独立动作实现 (含 MCPAction) +│ └── PlanBuildAction.java # 四阶段 plan 模式状态机 (FEASIBILITY → DESIGN → ... → COMPLETED) +├── client/ # 客户端 GUI +│ ├── SteveGUI.java # 滑出式面板 GUI (按 K 打开) +│ └── KeyBindings.java +├── command/ # Minecraft 命令 +│ └── SteveCommands.java # /steve spawn/tell/plan/approve/halt/status/dashboard 等 +├── config/ # 配置处理 +│ ├── SteveConfig.java # ForgeConfigSpec, 含 [mcp]/[react]/[dashboard] 段     +├── dashboard/ # 外部 plan UI HTTP server +│ ├── PlanDashboardServer.java # 127.0.0.1:8765, /events + /command + /chat + /plan +│ └── PlanEventJson.java # PlanEvent → JSON 序列化 +├── entity/ # Minecraft 实体类 +│ ├── SteveEntity.java # 自定义实体 (PathfinderMob) +│ └── SteveManager.java # 管理所有活跃的 Steves +├── event/ # 事件总线系统 +│ ├── EventBus.java, SimpleEventBus.java +│ └── plan/ # PlanEvent 标记接口 + 7 个事件 POJO +│ ├── PlanEvent.java +│ ├── PlanCreatedEvent.java +│ ├── PlanDesignReadyEvent.java +│ ├── PlanPhaseChangedEvent.java +│ ├── PlanApprovedEvent.java +│ ├── PlanHaltedEvent.java +│ ├── PlanLogEvent.java +│ └── PlanChatEvent.java +├── execution/ # 状态机、拦截器 +│ ├── AgentStateMachine.java +│ ├── ActionContext.java +│ └── InterceptorChain.java +├── llm/ # LLM 集成 +│ ├── TaskPlanner.java # 编排 LLM 调用 + 暴露异步客户端 +│ ├── PromptBuilder.java # 构建系统/用户/ReAct 提示词 +│ ├── ResponseParser.java # 解析 LLM 响应 (含 parseReActStep) +│ ├── OpenAIClient.java, GroqClient.java, GeminiClient.java +│ ├── async/ # 异步非阻塞客户端 (AsyncOpenAIClient 等) +│ ├── react/ # ReAct 主循环 + plan 模式辅助 +│ │ ├── ReActAgent.java # ReAct (Reason + Act) 主循环 +│ │ ├── BuildPhase.java # FEASIBILITY/DESIGN/.../COMPLETED 枚举 +│ │ └── BuildDesignFormatter.java # BuildProject → 聊天栏设计书文本 +│ └── resilience/ # 熔断器、重试、限流 +├── mcp/ # MCP (Model Context Protocol) 集成 +│ ├── MCPToolRegistry.java # 多 MCP server 单例注册中心 +│ ├── MCPClientWrapper.java # McpSyncClient 包装 +│ └── MCPToolConverter.java # 工具描述 → 提示词段 +├── memory/ # 记忆和知识系统 +│ ├── SteveMemory.java # 短期动作历史 + mempalace 长期记忆查询 +│ └── WorldKnowledge.java # 世界状态追踪 +├── plugin/ # 插件架构 +│ ├── ActionRegistry.java # 动态动作工厂 +│ ├── ActionFactory.java, ActionPlugin.java +│ ├── CoreActionsPlugin.java # 8 个基础动作注册 (pathfind/mine/gather/place/build/craft/attack/follow) +│ └── PluginManager.java +├── structure/ # 建筑生成 + 模板管理 +│ ├── StructureTemplateLoader.java # 扫描 NBT + 注册到 mempalace +│ ├── StructureRegistry.java # 模板索引 +│ └── BlockPlacement.java +└── util/ # 通用工具 +``` + +## 核心组件 + +### 1. 实体系统 (`SteveEntity`) + +自定义实体继承 `PathfinderMob`,支持 Minecraft 原生路径规划。 + +**属性配置**: +- 生命值: 20 +- 移动速度: 0.25 +- 攻击力: 8 +- 跟随距离: 48 + +### 2. LLM 集成 + +支持三个提供商,通过 `TaskPlanner` 统一编排: + +| 提供商 | 模型 | 特点 | +|--------|------|------| +| OpenAI | GPT-3.5-turbo / GPT-4 | 通用能力强 | +| Groq | llama-3.1-8b-instant | 低延迟 | +| Gemini | gemini-1.5-flash | Google 生态 | + +**关键特性**: +- 异步非阻塞调用(`AsyncLLMClient`,游戏永不掉帧) +- 40-60% 缓存命中率(Caffeine + SHA-256 键) +- 熔断器模式(Resilience4j) +- 主提供商失败时自动切换到 Groq +- **ReAct 模式**:LLM 每步决定一个 Action + 参数,根据 Observation 反馈再决定下一步 +- **MCP 工具**:通过 `MCPToolRegistry` 调用外部工具(默认连接 mempalace) + +### 3. 动作系统 + +基于 tick 的增量执行,动作跨多个游戏 tick 完成,防止服务器卡顿。 + +**插件架构**: 动作通过 `ActionRegistry` 动态注册,支持扩展。 + +### 4. 多 Agent 协作 (`CollaborativeBuildManager`) + +当多个 Steves 协同建造时: +- 结构分为 **4 象限**(西北、东北、西南、东南) +- 每个 Steve claim 一个象限,从底部向上建造 +- 使用 `ConcurrentHashMap` 保证线程安全 +- Agent 提前完成时动态重平衡 + +### 5. 代码执行引擎 + +使用 **GraalVM JavaScript** 引擎执行 LLM 生成的脚本代码。 + +## 关键设计决策 + +### 1. Tick-Based Execution +动作在多个游戏 tick 中增量执行,避免阻塞游戏线程。 + +### 2. ReAct(Reason + Act)主循环 +LLM 不再一次性规划全部动作,而是按 Thought → Action → Observation 循环执行:每步决定一个 action,action 完成后把 ActionResult 作为 observation 反馈给 LLM,由 LLM 决定下一步,直到输出 `is_final: true` 或达到 `maxSteps` 终止。命令排队:玩家在 ReAct 进行中发新指令时入队 `pendingCommands`,当前 ReAct 完成后自动处理下一条。 + +### 3. Async Non-Blocking +使用 `CompletableFuture` 确保游戏线程永远不被 LLM 调用阻塞。 + +### 4. Multi-Agent Coordination +使用确定性空间划分(象限),而非动态协商,提高效率。 + +### 5. ReAct Agent (`llm/react/ReActAgent.java`) +ReAct 主循环位于 `com.steve.ai.llm.react.ReActAgent`。状态机: + +``` +[ReAct step N] sendAsync(prompt + scratchpad) + → parseReActStep(LLM response) + ├─ is_final=true → 标记 finished, finalAnswer + ├─ is_final=true and tasks.size()==1 → 先派发 task, 等 observation 落地后再 finish (FINAL-with-task 延迟) + ├─ tasks.size()==1 and !is_final → 设 pendingStep, 等 game thread feedObservation + └─ 解析失败/无 action → 把错误喂回 scratchpad, 继续下一轮 +[game tick] + reactAgent.consumeNextStep() → executeTask(task) → BaseAction + BaseAction.isComplete() → reactAgent.feedObservation(ActionResult) → 触发下一轮 +``` + +终止条件:`is_final` / `stepCount >= maxSteps` / 连续解析失败 ≥ `maxConsecutiveFailures`。Scratchpad 超过 12k 字符时裁掉最早的 step。 + +### 6. MCP 工具桥接 (`mcp/MCPToolRegistry.java`) +- 启动时连接所有配置的 MCP server(默认 mempalace @ `http://localhost:6060`) +- 同步客户端 `McpSyncClient`(`McpClient.sync(transport)`) +- 工具列表注入到 `PromptBuilder` 的 `AVAILABLE MCP TOOLS` 段 +- LLM 输出 `action="mcp"` → `MCPAction` → `MCPToolRegistry.callTool()` + +### 7. Mempalace 知识库 +- 启动时 `StructureTemplateLoader.getAvailableStructures()` 扫描 `config/steve/structures/*.nbt`,按 `{type}_{name}` 命名规则解析后注册到 mempalace(`wing=structure_{type}, room={name}`),用 `mempalace_list_drawers` 验证 +- 运行时 LLM 通过 `mempalace_list_drawers` / `mempalace_get_drawer` 查询模板 +- 建造完成后写回 `wing=built_structures` 记录位置 +- `SteveMemory` 通过 `mempalace:mempalace_list_drawers` 查询长期记忆,不再用 NBT 持久化 + + + diff --git a/docs/02-actions.md b/docs/02-actions.md new file mode 100644 index 00000000..d191900d --- /dev/null +++ b/docs/02-actions.md @@ -0,0 +1,90 @@ +# 动作系统 + +## 概述 + +动作系统是 Steve AI 的核心执行单元,负责在 Minecraft 世界中执行具体任务。 + +## 核心类 + +- `ActionExecutor.java` - 基于 tick 的动作队列处理器 +- `Task.java` - 动作任务数据模型 +- `CollaborativeBuildManager.java` - 多 Agent 协调 + +## 可用动作 + +| 动作 | 功能 | +|------|------| +| `PlanBuildAction` | 四阶段 plan-then-build 状态机 | +| `MineBlockAction` | 智能采矿,带路径规划 | +| `PlaceBlockAction` | 单方块放置(带验证) | +| `PathfindAction` | 导航到坐标 | +| `CombatAction` | 目标战斗 | +| `FollowPlayerAction` | 跟随玩家 | +| `CraftItemAction` | 物品合成 | +| `GatherResourceAction` | 资源采集 | +| `MCPAction` | 调用 MCP 工具(参数 `tool="serverName:toolName"`, `args={...}`) | + +`CoreActionsPlugin` 通过 `ActionRegistry` 注册了 8 个基础动作:`pathfind / mine / gather / place / build / craft / attack / follow`。 +`build` action 在 `ActionExecutor.createActionLegacy` 里被**拦截**到 `PlanBuildAction`(`ActionExecutor.java:334`),所以 ReAct 模式下 `action="build"` 实际走的是 `PlanBuildAction.runDesign` + `runConstruction` 四阶段流程。 + +## 执行流程(ReAct 主循环) + +1. 用户发送自然语言指令(如 `/steve tell miner1 开采 20 铁矿石`) +2. `ActionExecutor.processNaturalLanguageCommand` 把指令加入 `pendingCommands` 队列 +3. 若 `reactAgent == null && currentAction == null`,调 `drainNextCommand()` 取出队首 +4. 构造 `ReActAgent(steve, command, maxSteps, obsTruncate, maxConsecutiveFailures)`,调 `startAsync(client, params)` 发起首次 LLM 调用(非阻塞) +5. 每个游戏 tick: + - 若 `currentAction.isComplete()`,取 `ActionResult` 调 `reactAgent.feedObservation(result, client, params)` —— ReActAgent 自动触发下一轮 LLM 调用 + - 若 `reactAgent.isReadyNextStep()`,调 `consumeNextStep()` 拿到 `Task`,`executeTask(task)` 创建并 `start()` `BaseAction` + - 若 `reactAgent.isFinished()`,把 `finalAnswer` 发到 GUI,自动 `drainNextCommand` 处理下一条 + - 若 `reactAgent.failed()`,硬切(不回退到旧 Plan-and-Execute),发错误到 GUI +6. ReAct 循环直到 LLM 输出 `is_final: true` / `stepCount >= maxSteps` / 连续解析失败 ≥ `maxConsecutiveFailures` + +**重要**:玩家在 ReAct 进行中发新指令时,新指令**入队不打断**,当前 ReAct 完成后顺序处理(`stopCurrentAction` 会清空队列)。 + +## BuildStructureAction 实现 + +> **已弃用**:当前 ReAct/plan 模式全部走 `PlanBuildAction.runDesign` + `runConstruction`。 +> `BuildStructureAction` 仍存在但只被 `CoreActionsPlugin` 之外的老路径调用,保留向后兼容。 +> 下面流程图描述的是该旧实现,新代码请参考 `PlanBuildAction` 源码。 + +### 完整流程 + +``` +用户指令: "建造房子" + ↓ +BuildStructureAction.onStart() + ↓ +1. 解析材料、尺寸、位置(看向玩家的方向 12 格处找地面) +2. tryLoadFromTemplate() → 尝试加载 NBT 模板 + ↓ 失败(目前无 .nbt 文件) +3. generateBuildPlan() → 调用 StructureGenerators 程序化生成 + ↓ +4. CollaborativeBuildManager.registerBuild() → 注册协作建造 + ↓ +BuildStructureAction.onTick() 每 tick: + ↓ +5. getNextBlock() → 从协作管理器获取下一个方块 +7. 放置方块 + 粒子 + 音效 +``` + +### 关键阶段 + +| 阶段 | 说明 | +|------|------| +| **位置确定** | 优先在玩家视线方向 12 格处找地面;无玩家则在 Steve 附近 2 格处 | +| **地形检测** | `findGroundLevel()` 向下/上扫描找实体地面;`isAreaSuitable()` 检查地形平整度(高度差≤2)和上方空间 | +| **模板加载** | `tryLoadFromTemplate()` → `StructureTemplateLoader.loadFromNBT()` — 启动时已扫描 `config/steve/structures/*.nbt` 并注册到 mempalace,LLM 通过 `mempalace_list_drawers` 发现 | +| **程序化生成** | `StructureGenerators.generate()` — 8 种内置建筑类型(无 .nbt 时的回退) | +| **协作建造** | `CollaborativeBuildManager` 分象限分配方块,多 Steve 并行放置 | +| **位置归档** | CONSTRUCTION 完成后 `PlanBuildAction` 在 `CONSTRUCTION → COMPLETED` 转换时自动调 `mempalace_add_drawer(wing=built_structures)` 写入 mempalace(不再由 ReAct agent 触发) | +| **飞行** | 建造时 Steve 启用飞行 (`steve.setFlying(true)`),完成后关闭 | + +## 插件架构 + +动作通过 `ActionRegistry` 动态注册,支持自定义扩展。 + +```java +// 注册新动作 +ActionRegistry.register("custom_action", CustomAction.class); +``` diff --git a/docs/03-config.md b/docs/03-config.md new file mode 100644 index 00000000..b66e4a77 --- /dev/null +++ b/docs/03-config.md @@ -0,0 +1,115 @@ +# 配置参考 + +## 配置文件 + +`config/steve-common.toml` + +## LLM 配置 + +```toml +[llm] +provider = "groq" # 或 "openai", "gemini" +``` + +### OpenAI + +```toml +[openai] +apiKey = "your-api-key" +model = "gpt-3.5-turbo" +maxTokens = 1000 +temperature = 0.7 +``` + +### Groq + +```toml +[groq] +apiKey = "your-api-key" +model = "llama-3.1-70b" +``` + +### Gemini + +```toml +[gemini] +apiKey = "your-api-key" +model = "gemini-pro" +``` + +## 行为配置 + +```toml +[behavior] +actionTickDelay = 20 # 动作检查间隔 (tick) +enableChatResponses = true +maxActiveSteves = 10 # 最大活跃 Steve 数量 +buildTickDelay = 20 # 方块放置间隔 (tick, PlanBuildAction CONSTRUCTION 阶段每方块 tick 数) +creativeMode = true # 创造模式: 材料无限, 跳过采矿 +maxTemplatesPerPlan = 4 # /steve plan 一次最多拼 N 个 NBT 模板 (1-10) +``` + +## MCP / Mempalace 配置 + +```toml +[mcp] +enabled = true +# JSON 数组: [{name, url}] +servers = "[{\"name\":\"mempalace\",\"url\":\"http://localhost:6060\"}]" +timeoutMs = 30000 # MCP 工具调用超时 (ms) +``` + +| 字段 | 说明 | +|------|------| +| `enabled` | 是否启用 MCP 工具调用(ReAct 模式下 LLM 仍可调 `action="mcp"`) | +| `servers` | MCP server 列表,JSON 数组 | +| `timeoutMs` | 单次工具调用超时 | + +默认连 mempalace(结构模板 + 位置归档)。mempalace 不可达时启动不报错,但 `mempalace_list_drawers` 等调用会失败。 + +## ReAct 模式配置 + +ReAct 是唯一模式,无需 `enabled` 开关。 + +```toml +[react] +maxSteps = 12 # 最大步数, 超限强制结束 +observationTruncateChars = 800 # 单条 observation 截断字符 +maxConsecutiveFailures = 3 # 连续解析失败上限, 达上限失败 +``` + +| 字段 | 说明 | +|------|------| +| `maxSteps` | LLM 决策轮上限(1-50),防止死循环 | +| `observationTruncateChars` | ActionResult 截断长度(100-4000),控制 scratchpad 大小 | +| `maxConsecutiveFailures` | 连续 JSON 解析失败上限(1-10),硬切不再回退 | + +## 技术栈 + +- **Minecraft Forge**: 1.20.1-47.2.0 +- **MCP SDK**: `io.modelcontextprotocol.sdk:mcp:2.0.0-M3`(通过 shadowLibs 打包) +- **GraalVM Polyglot**: JavaScript 代码执行 +- **Resilience4j**: 熔断器、重试、限流、隔舱模式 +- **Caffeine**: LLM 响应缓存 +- **Commons Codec**: SHA-256 哈希(缓存键) + + "chiseled_stone_bricks": 128, + "oak_door": 64, + "torch": 256 + } + } + ] +} +``` + +## NBT 建筑模板 + +`config/steve/structures/*.nbt` + +按 `{type}_{name}.nbt` 命名(如 `template_house_1.nbt`、`decoration_tower.nbt`、`castle.nbt`)。启动时 `StructureTemplateLoader.getAvailableStructures()` 扫描并注册到 mempalace(`wing=structure_{type}, room={name}`),LLM 通过 `mempalace_list_drawers` 发现。命名规则: + +| 文件名 | type | name | mempalace wing | +|--------|------|------|----------------| +| `template_house_1.nbt` | template | house_1 | `structure_template` | +| `decoration_tower.nbt` | decoration | tower | `structure_decoration` | +| `castle.nbt`(无下划线)| default | castle | `structure_default` | diff --git a/docs/04-world-knowledge.md b/docs/04-world-knowledge.md new file mode 100644 index 00000000..3d9532a6 --- /dev/null +++ b/docs/04-world-knowledge.md @@ -0,0 +1,209 @@ +# WorldKnowledge - 世界感知系统 + +## 概述 + +WorldKnowledge 是 Steve 智能体的环境感知模块,负责扫描周围世界并提取关键信息,包括生物群系、方块分布和附近实体。这些信息为 LLM 决策提供环境上下文。 + +## 架构设计 + +``` +WorldKnowledge +├── 生物群系扫描 (scanBiome) +│ └── 获取当前位置的生物群系名称 +├── 方块扫描 (scanBlocks) +│ └── 统计扫描半径内的方块类型和数量 +├── 实体扫描 (scanEntities) +│ └── 获取扫描范围内的所有实体 +└── 摘要生成 + ├── getNearbyBlocksSummary() → 前5种方块 + ├── getNearbyEntitiesSummary() → 前5种实体 + └── getNearbyPlayerNames() → 附近玩家列表 +``` + +## 核心设计决策 + +### 1. 构造时扫描 + +在创建时立即执行完整扫描: + +```java +public WorldKnowledge(SteveEntity steve) { + this.steve = steve; + scan(); // 立即扫描 +} +``` + +**原因**:确保获取的是当前时刻的环境快照,避免过时数据。 + +### 2. 采样式扫描 + +使用步长为 2 的采样,而非逐方块扫描: + +```java +for (int x = -scanRadius; x <= scanRadius; x += 2) { + for (int y = -scanRadius; y <= scanRadius; y += 2) { + for (int z = -scanRadius; z <= scanRadius; z += 2) { + // 每隔一个方块采样 + } + } +} +``` + +**原因**: +- 扫描半径 16 格,完整扫描需检查 33³ = 35,937 个方块 +- 采样后仅需检查约 17³ = 4,913 个方块(减少 86%) +- 牺牲少量精度换取大幅性能提升 + +### 3. 过滤空气方块 + +排除三种空气类型: + +```java +if (block != Blocks.AIR && block != Blocks.CAVE_AIR && block != Blocks.VOID_AIR) { + nearbyBlocks.put(block, nearbyBlocks.getOrDefault(block, 0) + 1); +} +``` + +**原因**:空气占绝大多数,过滤后仅保留有意义的方块。 + +### 4. 排序摘要 + +摘要按数量降序排列,限制返回前 5 项: + +```java +List> sorted = nearbyBlocks.entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .limit(5) + .toList(); +``` + +**原因**:LLM 上下文窗口有限,优先展示最重要的环境信息。 + +## 扫描参数 + +| 参数 | 值 | 说明 | +|------|-----|------| +| `scanRadius` | 16 | 扫描半径(格) | +| 采样步长 | 2 | 每隔 2 格采样一次 | +| 方块摘要 | 前 5 种 | 按数量排序 | +| 实体摘要 | 前 5 种 | 按类型分组 | + +## 扫描流程 + +``` +构造 WorldKnowledge + │ + ├─ scanBiome() + │ ├─ 获取当前位置 BlockPos + │ ├─ 从 Level 获取 Biome 对象 + │ ├─ 从注册表获取 BiomeKey + │ └─ 提取路径名称 (如 "plains", "desert") + │ + ├─ scanBlocks() + │ ├─ 创建空 HashMap + │ ├─ 遍历 16x16x16 区域 (步长2) + │ ├─ 过滤空气方块 + │ └─ 统计每种方块数量 + │ + └─ scanEntities() + ├─ 创建膨胀 16 格的 AABB + └─ 获取区域内所有实体 +``` + +## 数据结构 + +### 方块统计 + +```java +Map nearbyBlocks +// 示例: {STONE=120, DIRT=85, GRASS_BLOCK=42, OAK_LOG=15, COAL_ORE=8} +``` + +### 实体列表 + +```java +List nearbyEntities +// 示例: [Player, Zombie, Cow, Sheep, Creeper] +``` + +## 摘要输出格式 + +### 方块摘要 + +``` +"石头, 泥土, 草方块, 橡木原木, 煤矿石" +``` + +- 仅显示方块名称,不含数量 +- 按数量降序排列 +- 最多 5 种 + +### 实体摘要 + +``` +"2 Zombie, 1 Cow, 1 Sheep, 1 Creeper" +``` + +- 格式:`数量 实体类型` +- 按类型分组计数 +- 最多 5 种 + +### 玩家名称 + +``` +"Steve, Alex" +``` + +- 仅包含 Player 类型实体 +- 逗号分隔 +- 无玩家时返回 "none" + +## 性能分析 + +### 时间复杂度 + +| 操作 | 复杂度 | 说明 | +|------|--------|------| +| scanBiome | O(1) | 单次查表 | +| scanBlocks | O(n³) | n = scanRadius/2 = 8,约 512 次迭代 | +| scanEntities | O(m) | m = 区域内实体数 | + +### 空间复杂度 + +| 数据结构 | 大小 | 说明 | +|----------|------|------| +| nearbyBlocks | ≤ 扫描方块种类数 | 通常 < 50 | +| nearbyEntities | 区域内实体数 | 通常 < 100 | + +## 与 LLM 集成 + +WorldKnowledge 的数据通常用于构建 LLM 提示的环境部分: + +```java +String prompt = String.format( + "你在 %s 生物群系。" + + "附近有: %s。" + + "附近实体: %s。" + + "附近玩家: %s。", + worldKnowledge.getBiomeName(), + worldKnowledge.getNearbyBlocksSummary(), + worldKnowledge.getNearbyEntitiesSummary(), + worldKnowledge.getNearbyPlayerNames() +); +``` + +## 已知限制 + +1. **静态快照**:创建后不自动更新,需要重新创建实例 +2. **采样精度**:步长 2 可能遗漏稀有方块 +3. **无高度优先**:垂直方向与水平方向同等采样密度 +4. **实体过滤**:返回所有实体,未区分友好/敌对 +5. **无方块状态**:仅记录方块类型,不包含状态(如红石信号) + +## 扩展建议 + +1. **增量更新**:添加 `refresh()` 方法,仅扫描变化区域 +2. **实体分类**:分离友好实体、敌对实体、中立实体 +3. **危险检测**:标记岩浆、悬崖等危险区域 +4. **资源定位**:记录特定资源的精确位置 +5. **高度图**:生成地形高度概览 diff --git a/docs/05-prompt-builder.md b/docs/05-prompt-builder.md new file mode 100644 index 00000000..3687ae4c --- /dev/null +++ b/docs/05-prompt-builder.md @@ -0,0 +1,352 @@ +# PromptBuilder - 提示词构建系统 + +## 概述 + +PromptBuilder 是 Steve AI 的提示词工程核心,负责构建发送给 LLM 的系统提示词和用户提示词。它将 Steve 的环境状态、玩家命令和游戏模式整合成结构化的提示词,引导 LLM 生成有效的 JSON 动作指令。 + +## 架构设计 + +``` +PromptBuilder +├── buildReActSystemPrompt(maxSteps) # ReAct 系统提示词(输出 Thought/Action/FinalAnswer) +└── buildReActUserPrompt(steve, cmd, scratchpad) # ReAct 用户提示词(带历史) +``` + +**ReAct 模式专用方法**: +- `buildReActSystemPrompt(maxSteps)` — 注入 ReAct 输出格式(单步 action + is_final)、停止条件、可用动作清单(含 mcp)、MCP 工具列表 +- `buildReActUserPrompt(steve, command, scratchpad)` — 包含环境状态、玩家命令、scratchpad(历史 thought/action/observation),每步重建 + +## 核心设计决策 + +### 1. 双层提示词结构 + +系统提示词定义规则,用户提示词提供上下文: + +```java +// 系统提示词:定义AI角色和输出格式 +public static String buildSystemPrompt() { ... } + +// 用户提示词:当前情况和玩家命令 +public static String buildUserPrompt(SteveEntity steve, String command, WorldKnowledge worldKnowledge) { ... } +``` + +**原因**: +- 系统提示词相对稳定,可缓存 +- 用户提示词每次请求都变化 +- 分离关注点,便于维护 + +### 2. 创造/生存模式动态规则 + +根据游戏模式调整材料规则: + +```java +boolean creative = SteveConfig.CREATIVE_MODE.get(); +String materialRule = creative + ? "10. CREATIVE MODE: Unlimited materials. NEVER mine before building. Build directly." + : "10. SURVIVAL MODE: Steve has a 36-slot inventory. Mined blocks go into inventory. Building consumes from inventory. If inventory is empty, mine materials first before building."; +``` + +**创造模式**: +- 材料无限 +- 直接建造,无需采矿 +- 跳过材料检查 + +**生存模式**: +- 36 格背包限制 +- 采矿获得材料 +- 建造消耗背包材料 +- 材料不足时需先采矿 + +### 3. 严格的 JSON 输出格式 + +**Plan-and-Execute(旧)** 一次返回多个任务: +```json +{ + "reasoning": "简短想法", + "plan": "动作描述", + "tasks": [ + {"action": "类型", "parameters": { ... }} + ] +} +``` + +**ReAct(当前)** 每步一个 action 或最终答案: +```json +{ + "thought": "我需要先查询可用模板", + "action": "mcp", + "parameters": {"tool": "mempalace:mempalace_list_drawers", "args": {"wing": "structure_template"}}, + "is_final": false +} +``` + +完成时: +```json +{ + "thought": "城堡建好了", + "is_final": true, + "final_answer": "Built a castle at [100, 64, -200]" +} +``` + +**原因**: +- JSON 易于程序解析 +- 避免自然语言歧义 +- 结构化便于错误处理 +- ReAct 让 LLM 看到 observation 后再决策,支持 MCP 工具的迭代查询 + +### 4. 丰富的环境上下文 + +用户提示词包含完整的情境感知: + +```java +prompt.append("=== YOUR SITUATION ===\n"); +prompt.append("Position: ").append(formatPosition(steve.blockPosition())).append("\n"); +prompt.append("Nearby Players: ").append(worldKnowledge.getNearbyPlayerNames()).append("\n"); +prompt.append("Nearby Entities: ").append(worldKnowledge.getNearbyEntitiesSummary()).append("\n"); +prompt.append("Nearby Blocks: ").append(worldKnowledge.getNearbyBlocksSummary()).append("\n"); +prompt.append("Inventory: ").append(formatInventory(steve)).append("\n"); +prompt.append("Biome: ").append(worldKnowledge.getBiomeName()).append("\n"); +``` + +**包含信息**: +| 信息 | 来源 | 用途 | +|------|------|------| +| 位置 | `steve.blockPosition()` | 定位和导航 | +| 附近玩家 | `WorldKnowledge.getNearbyPlayerNames()` | 跟随、协作目标 | +| 附近实体 | `WorldKnowledge.getNearbyEntitiesSummary()` | 战斗、交互目标 | +| 附近方块 | `WorldKnowledge.getNearbyBlocksSummary()` | 采矿、建造参考 | +| 背包状态 | `steve.getInventory()` | 材料可用性 | +| 生物群系 | `WorldKnowledge.getBiomeName()` | 环境感知 | + +## 系统提示词结构 + +### 动作定义 + +| 动作 | 参数格式 | 说明 | +|------|----------|------| +| attack | `{"target": "hostile"}` | 攻击敌对生物 | +| build | `{"structure": "house", "blocks": [...], "dimensions": [...]}` | 建造结构(ReAct 模式下被拦截到 `PlanBuildAction`) | +| mine | `{"block": "iron", "quantity": 8}` | 采矿 | +| follow | `{"player": "NAME"}` | 跟随玩家 | +| pathfind | `{"x": 0, "y": 0, "z": 0}` | 导航到位置 | +| gather | `{"resource": "iron", "quantity": 8}` | 资源采集 | +| craft | `{"item": "iron_pickaxe", "quantity": 1}` | 物品合成 | +| mcp | `{"tool": "serverName:toolName", "args": {...}}` | 调用 MCP 工具 | + +### 结构类型 + +> **已废弃**:当前 ReAct / `PlanBuildAction` 只走 NBT 模板,`StructureGenerators` 类已删除。 +> 模板列表由 `config/steve/structures/*.nbt` 决定(见 [10-structures.md](10-structures.md))。 +> 无匹配 NBT 时 `PlanBuildAction.runDesign` 会 `ActionResult.failure("None of the requested NBT templates could be loaded")`,不再有兜底生成。 +> +> 下面表格保留作为历史参考。 + +### 示例输入输出 + +**输入**: "build a house" +```json +{ + "reasoning": "Building standard house near player", + "plan": "Construct house", + "tasks": [{ + "action": "build", + "parameters": { + "structure": "house", + "blocks": ["oak_planks", "cobblestone", "glass_pane"], + "dimensions": [9, 6, 9] + } + }] +} +``` + +**输入**: "get me iron" +```json +{ + "reasoning": "Mining iron ore for player", + "plan": "Mine iron", + "tasks": [{ + "action": "mine", + "parameters": { + "block": "iron", + "quantity": 16 + } + }] +} +``` + +**输入**: "kill mobs" +```json +{ + "reasoning": "Hunting hostile creatures", + "plan": "Attack hostiles", + "tasks": [{ + "action": "attack", + "parameters": { + "target": "hostile" + } + }] +} +``` + +## 用户提示词格式 + +### 完整示例 + +``` +=== YOUR SITUATION === +Position: [128, 64, -45] +Nearby Players: Alice, Bob +Nearby Entities: 3 Sheep, 1 Cow, 2 Chicken +Nearby Blocks: grass_block, dirt, oak_log, stone, iron_ore +Inventory: Iron Ingot x16, Oak Planks x32, Cobblestone x64 +Biome: forest + +=== PLAYER COMMAND === +"build a house here" + +=== YOUR RESPONSE (with reasoning) === +``` + +### 背包格式化 + +```java +private static String formatInventory(SteveEntity steve) { + SimpleContainer inventory = steve.getInventory(); + Map itemCounts = new HashMap<>(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + String name = stack.getHoverName().getString(); + itemCounts.merge(name, stack.getCount(), Integer::sum); + } + } + + if (itemCounts.isEmpty()) { + return "[empty]"; + } + + // 输出格式: "ItemName xCount, ItemName xCount" + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : itemCounts.entrySet()) { + if (sb.length() > 0) sb.append(", "); + sb.append(entry.getKey()).append(" x").append(entry.getValue()); + } + return sb.toString(); +} +``` + +**输出格式**: +- 空背包:`[empty]` +- 有物品:`Iron Ingot x16, Oak Planks x32, Cobblestone x64` +- 创造模式:`[unlimited - creative mode]` + +### 位置格式化 + +```java +private static String formatPosition(BlockPos pos) { + return String.format("[%d, %d, %d]", pos.getX(), pos.getY(), pos.getZ()); +} +``` + +**输出格式**:`[128, 64, -45]` + +## 提示词优化策略 + +### 1. Token 效率 + +- 位置使用整数坐标,避免浮点数 +- 实体摘要限制前 5 种 +- 方块摘要限制前 5 种 +- 背包合并相同物品 + +### 2. 上下文相关性 + +- 仅在生存模式显示背包详情 +- 仅显示非空气方块 +- 仅显示玩家类型实体(过滤掉其他 Steve) + +### 3. 指令清晰度 + +- 使用 `===` 分隔符区分不同部分 +- 引号包裹玩家命令 +- 明确要求 JSON 输出 + +## 与 LLM 集成 + +### 调用流程(ReAct 模式) + +```java +// 1. 构造 ReAct 主循环 +ReActAgent agent = new ReActAgent(steve, command, + SteveConfig.REACT_MAX_STEPS.get(), + SteveConfig.REACT_OBS_TRUNCATE.get(), + SteveConfig.REACT_FAIL_TOLERANCE.get()); + +// 2. 系统提示词每步重建(保证模板/MCP 工具列表最新) +String systemPrompt = PromptBuilder.buildReActSystemPrompt(maxSteps); + +// 3. 用户提示词携带 scratchpad(每轮追加 thought/action/observation) +String userPrompt = PromptBuilder.buildReActUserPrompt(steve, command, scratchpad); + +// 4. 异步发送 +AsyncLLMClient client = taskPlanner.getAsyncClient(provider); +client.sendAsync(userPrompt, Map.of("systemPrompt", systemPrompt, "model", ..., "maxTokens", ..., "temperature", ...)) + .thenAccept(response -> { + ParsedResponse step = ResponseParser.parseReActStep(response.getContent()); + // step.isFinal() / step.getTasks().get(0) + }); +``` + +### MCP 工具列表注入 + +`getMcpToolsPrompt()` 在系统提示词末尾追加 `AVAILABLE MCP TOOLS` 段: + +``` +AVAILABLE MCP TOOLS: +- mempalace:mempalace_list_drawers: List drawers with pagination + args: {"wing": "structure_template"} +- mempalace:mempalace_get_drawer: Fetch a single drawer by ID + args: {"wing": "structure_template", "room": "house"} +- mempalace:mempalace_add_drawer: Add a drawer + args: {"wing": "structure_template", "room": "house", "content": "...", "added_by": "steve-ai"} +``` + +LLM 通过 `action="mcp"`, `parameters.tool="mempalace:mempalace_list_drawers"` 调用。`MCPToolConverter.toPromptSection` 把 `MCPToolRegistry.getAllTools()` 转换为这段文本。 + +### 缓存策略 + +系统提示词可缓存,因为: +- 不依赖游戏状态 +- 仅在配置变化时改变 +- 减少重复构建开销 + +用户提示词不可缓存,因为: +- 包含实时位置信息 +- 包含当前背包状态 +- 包含附近实体信息 + +## 已知限制 + +1. **语言限制**:提示词全英文,中文命令可能理解不准确 +2. **上下文长度**:ReAct scratchpad 超过 12k 字符会裁掉最早 step,可能丢失远期上下文 +3. **解析脆弱性**:LLM 输出非 JSON 或 action 不在白名单时会喂回错误 observation 重新提示(最多 `maxConsecutiveFailures` 次) +4. **空间感知**:无法描述复杂的空间关系 + +## 扩展建议 + +1. **多语言支持**:添加中文系统提示词选项 +2. **上下文压缩**:背包物品智能摘要 +3. **MCP 工具发现**:动态从 `MCPToolRegistry` 拉取最新工具列表 +4. **scratchpad 持久化**:跨 ReAct 会话保留观察结果 + +## 配置选项 + +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| `CREATIVE_MODE` | Boolean | false | 创造模式开关 | +| `AI_PROVIDER` | String | "groq" | LLM 提供商 | +| `MAX_TOKENS` | Integer | 8000 | 最大 token 数 | +| `TEMPERATURE` | Double | 0.7 | 生成温度 | +| `MAX_TEMPLATES_PER_PLAN` | Integer | 4 | `/steve plan` 一次最多拼 N 个 NBT 模板 (1-10) | diff --git a/docs/06-llm.md b/docs/06-llm.md new file mode 100644 index 00000000..16e403c7 --- /dev/null +++ b/docs/06-llm.md @@ -0,0 +1,95 @@ +# LLM 集成 + +## 支持的提供商 + +| 提供商 | 模型 | 特点 | +|--------|------|------| +| OpenAI | GPT-3.5-turbo | 通用能力强 | +| Groq | llama-3.1-70b | 低延迟 | +| Gemini | gemini-pro | Google 生态 | + +## 核心组件 + +- `llm/TaskPlanner.java` - LLM 调用编排(暴露 `getAsyncClient()` 和 `buildReActParams()`) +- `llm/PromptBuilder.java` - 构建提示词(`buildReActSystemPrompt` / `buildReActUserPrompt` / `buildPlanPrompt`) +- `llm/ResponseParser.java` - 解析 LLM 响应(`parseAIResponse` 旧 + `parseReActStep` 新) +- `llm/OpenAIClient.java`, `GroqClient.java`, `GeminiClient.java` - 同步客户端 +- `llm/async/AsyncOpenAIClient.java`, `AsyncGroqClient.java`, `AsyncGeminiClient.java` - 异步客户端(Java HttpClient) +- `llm/resilience/ResilientLLMClient.java`, `LLMFallbackHandler.java` - 熔断器 + 降级 +- `llm/react/ReActAgent.java` - **ReAct 主循环**(Thought/Action/Observation) +- `mcp/MCPToolRegistry.java` - 多 MCP server 单例注册中心 +- `mcp/MCPClientWrapper.java` - 同步 MCP 客户端(`McpSyncClient`) +- `mcp/MCPToolConverter.java` - 工具列表 → 提示词段 +- `action/actions/MCPAction.java` - 执行 `action="mcp"` 任务 + +## 关键特性 + +### 1. 异步非阻塞调用 +- `AsyncLLMClient.sendAsync(prompt, params)` 返回 `CompletableFuture` +- `AsyncOpenAIClient` 用 Java `HttpClient.sendAsync`,30 秒超时 +- 读 `params.get("systemPrompt")` 注入 system message + +### 2. 缓存 +- `LLMCache` 用 Caffeine +- 40-60% 缓存命中率 +- SHA-256 哈希作为缓存键 +- 命中后短路 LLM 调用 + +### 3. 熔断器模式 +- `ResilientLLMClient` 包装基础异步客户端 +- 主提供商失败时自动切换到 Groq +- 支持重试、限流、隔舱模式 + +### 4. ReAct Agent +- 路径:`com.steve.ai.llm.react.ReActAgent` +- 状态机:循环 `sendAsync(prompt + scratchpad)` → `parseReActStep` → 等 `feedObservation` → 下一轮 +- 终止:`is_final` / `stepCount >= maxSteps` / 连续解析失败 ≥ `maxConsecutiveFailures` +- Scratchpad 软上限 12k 字符,自动裁剪最早 step +- 线程模型:LLM 调在 client 线程池;`consumeNextStep`/`feedObservation` 在 game thread + +### 5. MCP 工具桥接 +- 启动时 `MCPToolRegistry.init()` 连接所有配置的 MCP server(默认 mempalace @ `http://localhost:6060`) +- `MCPClientWrapper` 用 `McpClient.sync(transport)` 同步客户端 +- 工具列表注入 `PromptBuilder` 的 `AVAILABLE MCP TOOLS` 段 +- LLM 输出 `action="mcp"` → `MCPAction` → `MCPToolRegistry.callTool()` + +## ParsedResponse 字段 + +```java +public static class ParsedResponse { + private final String reasoning; // Plan-and-Execute 模式: 简短想法 + private final String plan; // Plan-and-Execute 模式: 动作描述 + private final List tasks; // 旧模式: 多个任务; ReAct 模式: 单个任务或空 + private final boolean isFinal; // ReAct 模式: 是否完成 + private final String finalAnswer; // ReAct 模式: 给用户的最终回答 +} +``` + +- `parseReActStep(text)` — 解析 ReAct 单步 JSON(`{thought, action, parameters, is_final?}`) +- `parseAIResponse(text)` — 解析旧 Plan-and-Execute JSON(`{reasoning, plan, tasks[]}`),仍保留向后兼容 + +## 配置 + +`config/steve-common.toml`: + +```toml +[ai] +provider = "openai" # 或 "groq", "gemini" + +[openai] +apiKey = "your-key" +model = "gpt-4-turbo-preview" +maxTokens = 8000 +temperature = 0.7 +baseUrl = "" # 自定义 OpenAI 兼容端点 + +[mcp] +enabled = true +servers = "[{\"name\":\"mempalace\",\"url\":\"http://localhost:6060\"}]" +timeoutMs = 30000 + +[react] +maxSteps = 12 +observationTruncateChars = 800 +maxConsecutiveFailures = 3 +``` diff --git a/docs/07-multi-agent.md b/docs/07-multi-agent.md new file mode 100644 index 00000000..10505b97 --- /dev/null +++ b/docs/07-multi-agent.md @@ -0,0 +1,39 @@ +# 多 Agent 协作 + +## CollaborativeBuildManager + +当多个 Steves 协同建造时,`CollaborativeBuildManager` 负责协调。 + +## 工作原理 + +### 1. 结构分块 + +结构被分为 **4 象限**: +- 西北 (NW) +- 东北 (NE) +- 西南 (SW) +- 东南 (SE) + +### 2. 象限分配 + +每个 Steve claim 一个象限: +- 使用 `ConcurrentHashMap` 保证线程安全 +- 原子性 section 分配,防止方块冲突 + +### 3. 建造顺序 + +每个象限从底部向上建造,确保结构完整性。 + +### 4. 动态重平衡 + +当某个 Agent 提前完成时: +- 系统检测空闲 Agent +- 重新分配剩余 section +- 保持负载均衡 + +## 线程安全 + +使用以下机制保证并发安全: +- `ConcurrentHashMap` 存储 agent 状态 +- 原子操作进行 section 分配 +- 无锁设计避免死锁 diff --git a/docs/08-code-execution.md b/docs/08-code-execution.md new file mode 100644 index 00000000..c89e6315 --- /dev/null +++ b/docs/08-code-execution.md @@ -0,0 +1,47 @@ +# 代码执行引擎 + +## 概述 + +Steve AI 使用 **GraalVM JavaScript** 引擎执行 LLM 生成的脚本代码。 + +## 核心组件 + +- `CodeExecutionEngine.java` - GraalVM 引擎封装 +- `SteveAPI.java` - 安全 API 桥接 +- `AgentStateMachine.java` - Agent 状态机 +- `InterceptorChain.java` - 日志、指标、事件拦截器 + +## SteveAPI + +暴露给脚本的安全操作: + +```javascript +// 移动 +steve.moveTo(x, y, z); + +// 建造 +steve.build("cobblestone", count); + +// 采矿 +steve.mine("iron_ore"); + +// 攻击 +steve.attack(entity); + +// 合成 +steve.craft("iron_pickaxe"); + +// 放置 +steve.place("torch", x, y, z); +``` + +## 沙箱环境 + +脚本运行在受限的沙箱环境中,只允许安全的 Minecraft 操作。 + +## 拦截器链 + +`InterceptorChain` 在执行前后插入拦截器: +- 日志记录 +- 指标收集 +- 事件发布 diff --git a/docs/09-memory.md b/docs/09-memory.md new file mode 100644 index 00000000..25cc5053 --- /dev/null +++ b/docs/09-memory.md @@ -0,0 +1,37 @@ +# 记忆系统 + +## 组件 + +### SteveMemory.java + +管理对话与动作历史: +- 用户指令历史 +- Steve 响应历史 +- 最近动作列表(保留最后 20 条) +- 当前目标 (`currentGoal`):ReAct 启动时设,停止时清 + +**长期记忆**:通过 `queryLongTermMemory(query)` 调 `mempalace:mempalace_query(wing=steve_memory, room={steveName}, query={...})` 查询。**不再使用 NBT 持久化**。 + +### WorldKnowledge.java + +追踪世界状态: +- 已发现的资源位置 +- 空间数据 +- 结构信息 + +在 ReAct 模式下,`buildReActUserPrompt` 每步调用 `WorldKnowledge` 重新采样,作为 observation 的一部分反馈给 LLM。 + +## 上下文管理 + +1. **对话历史**: 保留最近的交互记录 +2. **世界状态**: 追踪 Steve 周围的世界变化(每次 ReAct 步重新采样) +3. **动作历史**: 记录最近执行的动作,用于避免重复 + +## 持久化(已迁移到 mempalace) + +**变更前**:记忆数据通过 NBT 持久化到 Minecraft 存档(`saveToNBT` / `loadFromNBT`)。已删除。 + +**变更后**:长期记忆走 mempalace: +- `SteveMemory.queryLongTermMemory(query)` 调 `MCPToolRegistry.callTool("mempalace:mempalace_query", Map.of("wing", "steve_memory", "room", steveName, "query", query))` +- 短期动作历史保留在内存(`addAction`),跨 ReAct 会话不持久化 +- 跨世界数据独立于 Minecraft 存档(存在 mempalace 外部服务) diff --git a/docs/10-structures.md b/docs/10-structures.md new file mode 100644 index 00000000..28b82595 --- /dev/null +++ b/docs/10-structures.md @@ -0,0 +1,79 @@ +# 可建造结构 + +Steve 当前只支持一种生成结构的方式:**NBT 模板**。 + +> **已废弃**:之前文档里提到的"程序化生成(`StructureGenerators`)"已删除。 +> 所有 build 走 NBT 模板,模板列表由 `config/steve/structures/*.nbt` 决定。 +> 无匹配 NBT 时 `PlanBuildAction.runDesign` 会 `ActionResult.failure("None of the requested NBT templates could be loaded")`,**不再有兜底生成**。 +> +> 下面程序化生成结构表保留作为历史参考,**不再生效**。 + +## 生成流程 + +``` +玩家指令 → LLM 解析 → ReAct 输出 action="build" → 拦截到 PlanBuildAction + ├── 1. PlanBuildAction.runDesign + │ └── StructureTemplateLoader.loadFromNBT(name) + │ └── 找到 → 使用模板,按 origin 偏移在世界坐标铺开 + └── 2. 加载失败 + └── ActionResult.failure("None of the requested NBT templates could be loaded") +``` + +## 程序化生成结构列表(已废弃,仅供历史参考) + +| 结构类型 | 别名 | 默认尺寸 | 材料 | 说明 | +|---------|------|---------|------|------| +| `house` | `home` | 9x6x9 | 橡木板、圆石、玻璃板 | 带窗户、门和金字塔屋顶的房屋 | +| `castle` | `catle`, `fort` | 14x10x14 | 石砖、圆石、玻璃板 | 带角楼、城垛和大门的城堡 | +| `tower` | — | 6x6x16 | 石砖、錾制石砖、玻璃板、深色橡木楼梯 | 带窗户和金字塔顶的塔楼 | +| `barn` | `shed` | 12x8x14 | 橡木板、橡木原木、云杉木板 | 带大门和尖顶的谷仓 | +| `modern` | `modern_house` | 9x6x9 | 石英块、平滑石头、玻璃、深色橡木板 | 大量玻璃的现代风格房屋 | +| `wall` | — | 用户指定 | 使用第一个材料 | 单层墙壁 | +| `platform` | — | 用户指定 | 使用第一个材料 | 平台/地板 | +| `box` | `cube` | 用户指定 | 使用第一个材料 | 实心方块 | + +## 使用方式 + +``` +build house +build castle +build tower +build barn +build modern +build wall +build platform +build box +``` + +## 材料说明 + +- `house` — 地板用材料1,墙壁用材料2,屋顶用材料3,窗户固定为玻璃板,门固定为橡木门 +- `castle` — 固定使用石砖(地板)、圆石(墙壁)、玻璃板(窗户) +- `tower` — 固定使用石砖、錾制石砖、玻璃板、深色橡木楼梯 +- `barn` — 固定使用橡木板、橡木原木、云杉木板 +- `modern` — 固定使用石英块、平滑石头、玻璃、深色橡木板 +- `wall`/`platform`/`box` — 使用用户指定的材料 + +## 自定义尺寸 + +``` +build house with dimensions 12x8x12 +build castle with width 20 height 15 depth 20 +``` + +默认尺寸为程序化生成的推荐值。NBT 模板使用自动尺寸(从文件中读取),自定义尺寸参数会被忽略。 + +## NBT 模板 + +`PlanBuildAction` 的唯一结构来源。将 `.nbt` 文件放入运行时配置目录: + +``` +/config/steve/structures/ +``` + +- 文件名即为结构名(如 `house.nbt` → `build house`) +- 支持多种命名格式自动匹配:`name.nbt`、`name_lower.nbt`、`snake_case.nbt` +- LLM prompt 会动态读取目录下的模板名列表(`StructureTemplateLoader.getAvailableStructures()`),供 AI 识别 +- 启动时 `StructureTemplateLoader` 扫描该目录并注册到 mempalace(`wing=structure_{type}, room={name}`),LLM 通过 `mempalace_list_drawers` 发现 + + diff --git a/docs/11-steve-gui.md b/docs/11-steve-gui.md new file mode 100644 index 00000000..199617d6 --- /dev/null +++ b/docs/11-steve-gui.md @@ -0,0 +1,254 @@ +# SteveGUI - 侧边栏聊天界面 + +## 概述 + +SteveGUI 是一个用于与 Steve 智能体交互的侧边栏聊天面板,灵感来自 Cursor 的 composer UI。它从屏幕右侧滑入/滑出,带有平滑动画效果。 + +## 架构设计 + +``` +SteveGUI (静态类) +├── 渲染层 (onRenderOverlay) +│ ├── 面板背景和边框 +│ ├── 头部 ("Steve AI") +│ ├── 消息历史 (可滚动) +│ │ ├── 用户气泡 (绿色,右对齐) +│ │ ├── Steve 气泡 (蓝色,左对齐) +│ │ └── 系统气泡 (橙色,左对齐) +│ └── 输入区域 (EditBox) +├── 输入处理 +│ ├── 按键 (Enter, Escape, 方向键) +│ ├── 字符输入 +│ ├── 鼠标点击 +│ └── 鼠标滚轮 +└── 命令处理 + ├── spawn <名称> → 创建新的 Steve + └── <目标> <命令> → 发送给特定 Steve +``` + +## 核心设计决策 + +### 1. 静态状态管理 + +所有状态都是静态的,无需实例化: + +```java +private static boolean isOpen = false; +private static float slideOffset = PANEL_WIDTH; +private static EditBox inputBox; +private static List messages = new ArrayList<>(); +private static int scrollOffset = 0; +``` + +**原因**:GUI 是单例覆盖层,使用静态变量避免传递实例。 + +### 2. 动画系统 + +使用偏移量插值实现平滑滑动动画: + +```java +if (isOpen && slideOffset > 0) { + slideOffset = Math.max(0, slideOffset - ANIMATION_SPEED); +} else if (!isOpen && slideOffset < PANEL_WIDTH) { + slideOffset = Math.min(PANEL_WIDTH, slideOffset + ANIMATION_SPEED); +} + +int panelX = (int) (screenWidth - PANEL_WIDTH + slideOffset); +``` + +- `ANIMATION_SPEED = 20` 每帧像素数 +- 面板从 `PANEL_WIDTH`(隐藏)滑动到 `0`(显示) +- 当 `slideOffset >= PANEL_WIDTH` 时跳过渲染 + +### 3. 消息气泡系统 + +三种不同样式的气泡: + +| 类型 | 颜色 | 对齐方式 | 用途 | +|------|------|----------|------| +| 用户 | 绿色 (`0xC04CAF50`) | 右对齐 | 玩家命令 | +| Steve | 蓝色 (`0xC02196F3`) | 左对齐 | 智能体回复 | +| 系统 | 橙色 (`0xC0FF9800`) | 左对齐 | 状态消息 | + +每个消息存储: +```java +private static class ChatMessage { + String sender; // 显示名称 + String text; // 消息内容 + int bubbleColor; // ARGB 颜色值 + boolean isUser; // 对齐标志 +} +``` + +### 4. 滚动实现 + +从底部向上渲染,带滚动偏移: + +```java +// 从底部开始 +int currentY = messageAreaBottom - 5; + +for (int i = messages.size() - 1; i >= 0; i--) { + int msgY = currentY - bubbleHeight + scrollOffset; + + // 跳过可见区域外的消息 + if (msgY + bubbleHeight < messageAreaTop - 20 || msgY > messageAreaBottom + 20) { + currentY -= bubbleHeight + 5; + continue; + } + + // 渲染气泡... + currentY -= bubbleHeight + 5 + 12; // 为发送者名称留出空间 +} +``` + +- 消息从下往上渲染(最新消息在底部) +- `scrollOffset` 垂直偏移所有消息 +- 裁剪区域防止溢出 + +### 5. 目标解析 + +智能命令路由到特定 Steve: + +```java +// "all steves build a house" → 发送给所有人 +// "Steve build a house" → 发送给 Steve +// "Steve, Alex build together" → 发送给两者 +``` + +解析逻辑: +1. 检查 "all"、"everyone"、"everybody" 前缀 +2. 按逗号分割实现多目标 +3. 将第一个单词与可用 Steve 名称匹配 +4. 如果没有匹配,默认发送给第一个可用的 Steve + +## 渲染流程 + +``` +RenderGuiOverlayEvent.Post + │ + ├─ 更新动画偏移量 + │ + ├─ 如果隐藏则跳过 (slideOffset >= PANEL_WIDTH) + │ + ├─ 启用混合模式 + │ + ├─ 绘制面板背景 (fillGradient) + │ └─ 超透明: 0x15202020 (~8% 不透明度) + │ + ├─ 绘制左侧边框 + │ + ├─ 绘制头部 + │ └─ "Steve AI" + "按 K 关闭" + │ + ├─ 计算消息区域边界 + │ └─ 顶部: headerHeight + 5 + │ └─ 底部: screenHeight - 80 + │ + ├─ 启用裁剪 (限制在消息区域内) + │ + ├─ 从下往上渲染消息 + │ ├─ 计算气泡尺寸 + │ ├─ 应用滚动偏移 + │ ├─ 跳过可见边界外的消息 + │ └─ 绘制气泡 + 发送者名称 + │ + ├─ 禁用裁剪 + │ + ├─ 绘制滚动条 (如果需要) + │ + ├─ 绘制输入区域背景 + │ + ├─ 渲染 EditBox + │ + └─ 绘制帮助文本 +``` + +## 输入处理 + +### 按键绑定 + +| 按键 | 动作 | +|------|------| +| K | 切换面板 | +| ESC | 关闭面板 | +| Enter | 发送命令 | +| ↑ | 上一条命令 (历史) | +| ↓ | 下一条命令 (历史) | +| Backspace/Delete | 编辑输入 | +| Home/End | 导航输入框 | +| Left/Right | 移动光标 | + +### 事件消费 + +面板打开时消费所有键盘事件: + +```java +return true; // 输入时阻止游戏控制 +``` + +## 集成接口 + +### 与 SteveMod 集成 + +```java +// 获取所有活跃的 Steve +var steves = SteveMod.getSteveManager().getAllSteves(); + +// 通过网络发送命令 +mc.player.connection.sendCommand("steve tell " + steveName + " " + command); +``` + +### 与 SteveOverlayScreen 集成 + +创建屏幕实例用于输入焦点管理: + +```java +if (isOpen) { + mc.setScreen(new SteveOverlayScreen()); + inputBox.setFocused(true); +} +``` + +## 配置常量 + +| 常量 | 值 | 用途 | +|------|-----|------| +| `PANEL_WIDTH` | 200px | 面板宽度 | +| `PANEL_PADDING` | 6px | 内边距 | +| `ANIMATION_SPEED` | 20px/帧 | 滑动速度 | +| `MESSAGE_HEIGHT` | 12px | 文本行高 | +| `MAX_MESSAGES` | 500 | 历史记录限制 | +| `BACKGROUND_COLOR` | `0x15202020` | ~8% 不透明度黑色 | +| `BORDER_COLOR` | `0x40404040` | 25% 不透明度灰色 | +| `HEADER_COLOR` | `0x25252525` | ~15% 不透明度黑色 | + +## 已知限制 + +1. **自动换行**:目前使用 "..." 截断,而非真正的换行 +2. **文本格式**:不支持 markdown 或富文本 +3. **消息持久化**:退出世界时消息丢失 +4. **输入历史**:限制为 50 条命令 +5. **性能**:每帧重新计算完整消息列表 + +## 使用示例 + +``` +# 打开面板 +按 K + +# 生成新的 Steve +spawn Builder + +# 给默认 Steve 发送命令 +build a house here + +# 给特定 Steve 发送命令 +Steve dig a hole + +# 给多个 Steve 发送命令 +Steve, Alex gather wood + +# 给所有 Steve 发送命令 +all steves follow me +``` diff --git a/docs/12-plugin-system.md b/docs/12-plugin-system.md new file mode 100644 index 00000000..195c8992 --- /dev/null +++ b/docs/12-plugin-system.md @@ -0,0 +1,428 @@ +# 插件系统 - 可扩展的动作注册框架 + +## 概述 + +插件系统使用 Java SPI(Service Provider Interface)机制实现动作的动态发现和加载。开发者可以通过实现 `ActionPlugin` 接口来扩展 Steve 的功能,无需修改核心代码。 + +## 架构设计 + +``` +插件系统 +├── ActionPlugin (SPI 接口) +│ ├── getPluginId() → 唯一标识符 +│ ├── onLoad() → 注册动作 +│ ├── onUnload() → 清理资源 +│ ├── getPriority() → 加载优先级 +│ └── getDependencies() → 依赖声明 +├── PluginManager (插件管理器) +│ ├── ServiceLoader 发现 +│ ├── 拓扑排序(依赖解析) +│ └── 生命周期管理 +├── ActionRegistry (动作注册表) +│ ├── register() → 注册动作工厂 +│ └── create() → 创建动作实例 +└── CoreActionsPlugin (核心插件) + └── 注册所有内置动作 +``` + +## 核心设计决策 + +### 1. SPI 服务发现 + +使用 Java ServiceLoader 自动发现插件: + +```java +// 发现所有实现 ActionPlugin 的类 +ServiceLoader loader = ServiceLoader.load(ActionPlugin.class); + +for (ActionPlugin plugin : loader) { + discovered.add(plugin); +} +``` + +**配置文件位置**: +``` +src/main/resources/META-INF/services/com.steve.ai.plugin.ActionPlugin +``` + +**文件内容示例**: +``` +com.steve.ai.plugin.CoreActionsPlugin +com.example.CustomActionsPlugin +``` + +**优势**: +- 零配置发现 +- 编译时确定 +- 无反射开销 + +### 2. 依赖解析(拓扑排序) + +使用拓扑排序确保插件按依赖顺序加载: + +```java +private List sortPlugins(List plugins) { + // 构建依赖图 + Map> dependencies = new HashMap<>(); + Map inDegree = new HashMap<>(); + + // 计算入度 + for (ActionPlugin plugin : plugins) { + for (String dep : plugin.getDependencies()) { + if (pluginMap.containsKey(dep)) { + inDegree.merge(plugin.getPluginId(), 1, Integer::sum); + } + } + } + + // 拓扑排序(使用优先队列处理优先级) + PriorityQueue queue = new PriorityQueue<>( + Comparator.comparingInt(ActionPlugin::getPriority).reversed()); + + // 从无依赖的插件开始 + for (ActionPlugin plugin : plugins) { + if (inDegree.get(plugin.getPluginId()) == 0) { + queue.offer(plugin); + } + } + + // 处理队列 + while (!queue.isEmpty()) { + ActionPlugin plugin = queue.poll(); + sorted.add(plugin); + + // 更新依赖此插件的其他插件 + for (ActionPlugin other : plugins) { + if (dependencies.get(other.getPluginId()).contains(plugin.getPluginId())) { + int newDegree = inDegree.get(other.getPluginId()) - 1; + if (newDegree == 0) { + queue.offer(other); + } + } + } + } + + return sorted; +} +``` + +**依赖示例**: +```java +public class CombatAIPlugin implements ActionPlugin { + @Override + public String[] getDependencies() { + return new String[] { "core-actions" }; // 依赖核心插件 + } +} +``` + +### 3. 优先级系统 + +控制同名动作的覆盖顺序: + +| 优先级范围 | 用途 | 示例 | +|-----------|------|------| +| 1000+ | 核心/必需插件 | CoreActionsPlugin | +| 500-999 | 高优先级插件 | 内置扩展 | +| 0-499 | 普通插件(默认) | 用户插件 | +| 负数 | 低优先级/覆盖插件 | 替换默认行为 | + +```java +public class OverridePlugin implements ActionPlugin { + @Override + public int getPriority() { + return -100; // 低优先级,允许被覆盖 + } +} +``` + +### 4. 工厂模式注册 + +插件注册动作工厂,而非直接实例化: + +```java +@Override +public void onLoad(ActionRegistry registry, ServiceContainer container) { + // 注册工厂 lambda + registry.register("dance", (steve, task, ctx) -> new DanceAction(steve, task)); + + // 带依赖注入的工厂 + registry.register("smart_mine", (steve, task, ctx) -> { + LLMCache cache = container.getService(LLMCache.class); + return new SmartMineAction(steve, task, cache); + }); +} +``` + +**优势**: +- 延迟实例化 +- 支持依赖注入 +- 便于测试 + +## 插件生命周期 + +``` +服务器启动 + │ + ├─ ServiceLoader 发现插件 + │ + ├─ 拓扑排序(考虑依赖和优先级) + │ + ├─ 按顺序加载插件 + │ ├─ 检查依赖是否已加载 + │ ├─ 调用 onLoad(registry, container) + │ └─ 记录到已加载列表 + │ + ├─ 插件运行中 + │ └─ 注册的动作可用 + │ + └─ 服务器关闭 + └─ 按相反顺序调用 onUnload() +``` + +## ActionRegistry 实现 + +### 注册动作工厂 + +```java +public class ActionRegistry { + private final Map factories = new ConcurrentHashMap<>(); + + public void register(String actionName, ActionFactory factory) { + if (factories.containsKey(actionName)) { + LOGGER.warn("Overwriting existing action: {}", actionName); + } + factories.put(actionName.toLowerCase(), factory); + } + + public BaseAction create(SteveEntity steve, Task task, ActionContext ctx) { + String actionName = task.getAction().toLowerCase(); + ActionFactory factory = factories.get(actionName); + + if (factory == null) { + LOGGER.warn("Unknown action: {}", actionName); + return null; + } + + return factory.create(steve, task, ctx); + } +} +``` + +### ActionFactory 接口 + +```java +@FunctionalInterface +public interface ActionFactory { + BaseAction create(SteveEntity steve, Task task, ActionContext ctx); +} +``` + +## CoreActionsPlugin + +核心插件注册所有内置动作: + +```java +public class CoreActionsPlugin implements ActionPlugin { + @Override + public String getPluginId() { + return "core-actions"; + } + + @Override + public int getPriority() { + return 1000; // 最高优先级 + } + + @Override + public void onLoad(ActionRegistry registry, ServiceContainer container) { + registry.register("pathfind", (steve, task, ctx) -> new PathfindAction(steve, task)); + registry.register("mine", (steve, task, ctx) -> new MineBlockAction(steve, task)); + registry.register("place", (steve, task, ctx) -> new PlaceBlockAction(steve, task)); + registry.register("craft", (steve, task, ctx) -> new CraftItemAction(steve, task)); + registry.register("attack", (steve, task, ctx) -> new CombatAction(steve, task)); + registry.register("follow", (steve, task, ctx) -> new FollowPlayerAction(steve, task)); + registry.register("gather", (steve, task, ctx) -> new GatherResourceAction(steve, task)); + registry.register("build", (steve, task, ctx) -> new BuildStructureAction(steve, task)); + } +} +``` + +## 自定义插件示例 + +### 步骤 1:实现 ActionPlugin + +```java +package com.example; + +import com.steve.ai.plugin.ActionPlugin; +import com.steve.ai.plugin.ActionRegistry; +import com.steve.ai.di.ServiceContainer; + +public class DancePlugin implements ActionPlugin { + @Override + public String getPluginId() { + return "dance-plugin"; + } + + @Override + public String getVersion() { + return "1.0.0"; + } + + @Override + public void onLoad(ActionRegistry registry, ServiceContainer container) { + registry.register("dance", (steve, task, ctx) -> new DanceAction(steve, task)); + registry.register("wave", (steve, task, ctx) -> new WaveAction(steve, task)); + } + + @Override + public void onUnload() { + // 清理资源 + } +} +``` + +### 步骤 2:创建 SPI 配置文件 + +**文件**: `src/main/resources/META-INF/services/com.steve.ai.plugin.ActionPlugin` + +``` +com.example.DancePlugin +``` + +### 步骤 3:实现动作类 + +```java +public class DanceAction extends BaseAction { + private int danceTicks = 0; + + public DanceAction(SteveEntity steve, Task task) { + super(steve, task); + } + + @Override + protected void onStart() { + steve.sendActionBarMessage("开始跳舞!"); + } + + @Override + protected void onTick() { + danceTicks++; + + // 每 10 tick 跳一次 + if (danceTicks % 10 == 0) { + steve.swing(InteractionHand.MAIN_HAND, true); + steve.setJumping(true); + } + + // 跳 100 tick 后完成 + if (danceTicks >= 100) { + result = ActionResult.success("跳舞完成!"); + } + } + + @Override + protected void onCancel() { + steve.setJumping(false); + } + + @Override + public String getDescription() { + return "跳舞"; + } +} +``` + +## 依赖注入集成 + +插件可通过 ServiceContainer 获取共享服务: + +```java +@Override +public void onLoad(ActionRegistry registry, ServiceContainer container) { + // 获取已注册的服务 + LLMCache cache = container.getService(LLMCache.class); + EventBus eventBus = container.getService(EventBus.class); + + // 注册带依赖的动作 + registry.register("smart_build", (steve, task, ctx) -> + new SmartBuildAction(steve, task, cache, eventBus)); +} +``` + +## 错误处理 + +### 插件加载失败 + +```java +for (ActionPlugin plugin : sorted) { + try { + loadPlugin(plugin, registry, container); + } catch (Exception e) { + LOGGER.error("Failed to load plugin {}: {}", plugin.getPluginId(), e.getMessage()); + // 继续加载其他插件 + } +} +``` + +### 循环依赖检测 + +```java +if (sorted.size() != plugins.size()) { + LOGGER.error("Circular dependency detected!"); + // 加载剩余插件(忽略依赖) + for (ActionPlugin plugin : plugins) { + if (!processed.contains(plugin.getPluginId())) { + sorted.add(plugin); + } + } +} +``` + +## 线程安全 + +- `ConcurrentHashMap` 存储已加载插件 +- 插件加载在服务器线程执行(非线程安全) +- 动作注册表使用 `ConcurrentHashMap` + +## 已知限制 + +1. **单例插件**:每个插件 ID 只能有一个实例 +2. **无热重载**:插件变更需重启服务器 +3. **依赖解析**:不支持可选依赖 +4. **版本管理**:无版本冲突检测 + +## 扩展建议 + +1. **热重载**:支持运行时重新加载插件 +2. **版本管理**:语义版本冲突检测 +3. **可选依赖**:支持 `optional` 依赖声明 +4. **插件隔离**:类加载器隔离防止冲突 +5. **插件市场**:在线插件仓库 + +## 配置 + +无额外配置。插件通过 SPI 自动发现。 + +## 调试 + +启用插件系统日志: + +```properties +# log4j2.properties +logger.plugin.name = com.steve.ai.plugin +logger.plugin.level = DEBUG +``` + +日志输出示例: +``` +[INFO] Discovering plugins via ServiceLoader... +[INFO] Discovered plugin: core-actions v1.0.0 (priority: 1000) +[INFO] Discovered plugin: dance-plugin v1.0.0 (priority: 0) +[INFO] Loading plugin: core-actions v1.0.0 +[INFO] Plugin core-actions loaded successfully +[INFO] Loading plugin: dance-plugin v1.0.0 +[INFO] Plugin dance-plugin loaded successfully +[INFO] Plugin loading complete: 2 plugins loaded +``` diff --git a/docs/13-event-system.md b/docs/13-event-system.md new file mode 100644 index 00000000..727a9051 --- /dev/null +++ b/docs/13-event-system.md @@ -0,0 +1,428 @@ +# 事件系统 - 解耦的组件通信 + +## 概述 + +事件系统实现了观察者模式(Observer Pattern),用于组件间的解耦通信。发布者不需要知道订阅者的存在,支持同步/异步发布、优先级排序和错误隔离。 + +## 架构设计 + +``` +事件系统 +├── EventBus (接口) +│ ├── subscribe() → 订阅事件 +│ ├── publish() → 同步发布 +│ ├── publishAsync() → 异步发布 +│ └── unsubscribe() → 取消订阅 +├── SimpleEventBus (实现) +│ ├── ConcurrentHashMap 存储订阅者 +│ ├── CopyOnWriteArrayList 线程安全列表 +│ └── ExecutorService 异步执行器 +└── 事件类型 + ├── ActionStartedEvent + ├── ActionCompletedEvent + └── 自定义事件... +``` + +## 核心设计决策 + +### 1. 观察者模式 + +发布者和订阅者完全解耦: + +```java +// 发布者(不知道订阅者的存在) +eventBus.publish(new ActionStartedEvent("mine", "Mining stone")); + +// 订阅者(不知道发布者的存在) +eventBus.subscribe(ActionStartedEvent.class, event -> { + System.out.println("Action started: " + event.getActionName()); +}); +``` + +**优势**: +- 松耦合 +- 易于扩展 +- 单一职责 + +### 2. 优先级订阅 + +高优先级订阅者先执行: + +```java +// 高优先级(先执行) +eventBus.subscribe(ActionStartedEvent.class, event -> { + // 日志记录(优先级 100) +}, 100); + +// 低优先级(后执行) +eventBus.subscribe(ActionStartedEvent.class, event -> { + // 统计收集(优先级 0) +}, 0); +``` + +**优先级排序**: +```java +// 按优先级降序排序(高优先级先执行) +list.sort((a, b) -> Integer.compare(b.priority, a.priority)); +``` + +### 3. 同步/异步发布 + +```java +// 同步发布(在调用线程执行) +eventBus.publish(new ActionStartedEvent("mine", "Mining stone")); + +// 异步发布(在独立线程执行) +eventBus.publishAsync(new ActionCompletedEvent("mine", true)); +``` + +**异步执行器**: +```java +private final ExecutorService asyncExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "event-bus-async"); + t.setDaemon(true); // 守护线程,不阻止 JVM 退出 + return t; +}); +``` + +### 4. 错误隔离 + +单个订阅者的错误不影响其他订阅者: + +```java +for (SubscriberEntry entry : subs) { + try { + ((Consumer) entry.subscriber).accept(event); + } catch (Exception e) { + LOGGER.error("Error in subscriber: {}", e.getMessage()); + // 继续执行其他订阅者 + } +} +``` + +### 5. 线程安全 + +使用 `CopyOnWriteArrayList` 和 `ConcurrentHashMap`: + +```java +// 存储结构 +private final ConcurrentHashMap, CopyOnWriteArrayList>> subscribers; + +// 订阅时 +subscribers.compute(eventType, (key, list) -> { + if (list == null) { + list = new CopyOnWriteArrayList<>(); + } + list.add(entry); + list.sort((a, b) -> Integer.compare(b.priority, a.priority)); + return list; +}); +``` + +**CopyOnWriteArrayList 特点**: +- 写时复制(写操作创建新数组) +- 读操作无锁 +- 适合读多写少场景 + +## 事件类型 + +### 内置事件 + +```java +// 动作开始事件 +public class ActionStartedEvent { + private final String actionName; + private final String description; + private final SteveEntity steve; +} + +// 动作完成事件 +public class ActionCompletedEvent { + private final String actionName; + private final boolean success; + private final String message; + private final SteveEntity steve; +} +``` + +### 自定义事件 + +```java +// 定义事件类 +public class BuildCompletedEvent { + private final String structureType; + private final BlockPos position; + private final int blockCount; + + // 构造函数、getter... +} + +// 发布事件 +eventBus.publish(new BuildCompletedEvent("castle", pos, 1200)); + +// 订阅事件 +eventBus.subscribe(BuildCompletedEvent.class, event -> { + LOGGER.info("Built {} at {} with {} blocks", + event.getStructureType(), + event.getPosition(), + event.getBlockCount()); +}); +``` + +## 订阅管理 + +### 订阅句柄 + +返回 `Subscription` 对象用于取消订阅: + +```java +// 订阅并获取句柄 +EventBus.Subscription subscription = eventBus.subscribe( + ActionStartedEvent.class, + event -> handleAction(event) +); + +// 取消订阅 +subscription.unsubscribe(); + +// 检查是否活跃 +if (subscription.isActive()) { + // 仍然订阅中 +} +``` + +### 批量取消 + +```java +// 取消特定事件类型的所有订阅 +eventBus.unsubscribeAll(ActionStartedEvent.class); + +// 清空所有订阅 +eventBus.clear(); +``` + +## 使用示例 + +### 日志记录 + +```java +public class ActionLogger { + private final EventBus eventBus; + + public ActionLogger(EventBus eventBus) { + this.eventBus = eventBus; + subscribe(); + } + + private void subscribe() { + eventBus.subscribe(ActionStartedEvent.class, event -> { + LOGGER.info("[ACTION] Started: {} - {}", + event.getSteve().getSteveName(), + event.getDescription()); + }, 100); // 高优先级 + + eventBus.subscribe(ActionCompletedEvent.class, event -> { + if (event.isSuccess()) { + LOGGER.info("[ACTION] Completed: {}", event.getActionName()); + } else { + LOGGER.warn("[ACTION] Failed: {} - {}", + event.getActionName(), event.getMessage()); + } + }, 100); + } +} +``` + +### 统计收集 + +```java +public class ActionStatistics { + private final Map actionCounts = new HashMap<>(); + private final Map actionDurations = new HashMap<>(); + + public ActionStatistics(EventBus eventBus) { + eventBus.subscribe(ActionStartedEvent.class, event -> { + actionCounts.merge(event.getActionName(), 1, Integer::sum); + // 记录开始时间 + }, 0); // 低优先级 + + eventBus.subscribe(ActionCompletedEvent.class, event -> { + // 计算持续时间 + }, 0); + } +} +``` + +### GUI 更新 + +```java +public class GUIUpdater { + public GUIUpdater(EventBus eventBus, SteveGUI gui) { + eventBus.subscribe(ActionCompletedEvent.class, event -> { + // 在客户端线程更新 GUI + Minecraft.getInstance().execute(() -> { + if (event.isSuccess()) { + gui.addSteveMessage("完成: " + event.getMessage()); + } else { + gui.addSystemMessage("失败: " + event.getMessage()); + } + }); + }); + } +} +``` + +## 异步发布 + +### 使用场景 + +```java +// 不阻塞游戏线程 +eventBus.publishAsync(new ActionCompletedEvent("mine", true)); + +// 异步处理 +eventBus.subscribe(ActionCompletedEvent.class, event -> { + // 可能耗时的操作 + saveStatistics(event); + updateDatabase(event); +}); +``` + +### 错误处理 + +```java +asyncExecutor.submit(() -> { + try { + publish(event); + } catch (Exception e) { + LOGGER.error("Error in async publishing: {}", e.getMessage()); + } +}); +``` + +## 性能考虑 + +### 写时复制开销 + +`CopyOnWriteArrayList` 的写操作(订阅/取消订阅)有开销: +- 创建新数组 +- 复制所有元素 +- 替换引用 + +**适用场景**:读多写少(订阅后频繁发布) + +**不适用场景**:频繁订阅/取消订阅 + +### 内存占用 + +每个订阅者占用: +- `SubscriberEntry` 对象(~32 字节) +- `Consumer` lambda(~64 字节) +- 数组引用(~8 字节) + +**总计**:~100 字节/订阅者 + +### 发布性能 + +同步发布: +- O(n) 遍历订阅者 +- n = 订阅者数量 +- 通常 < 1μs(10 个订阅者) + +异步发布: +- 提交到线程池 +- 立即返回 +- 异步执行 + +## 线程安全 + +### 保证 + +1. **订阅/取消订阅**:线程安全(ConcurrentHashMap + CopyOnWriteArrayList) +2. **发布**:线程安全(遍历快照) +3. **订阅者执行**:在发布线程执行(同步)或独立线程(异步) + +### 潜在问题 + +```java +// 问题:订阅者在事件处理中取消订阅 +eventBus.subscribe(ActionStartedEvent.class, event -> { + subscription.unsubscribe(); // 可能导致 ConcurrentModificationException +}); +``` + +**解决方案**:CopyOnWriteArrayList 遍历快照,安全 + +## 与其他系统集成 + +### 与 PluginManager 集成 + +```java +public class CoreActionsPlugin implements ActionPlugin { + @Override + public void onLoad(ActionRegistry registry, ServiceContainer container) { + EventBus eventBus = container.getService(EventBus.class); + + // 注册动作并在执行时发布事件 + registry.register("mine", (steve, task, ctx) -> { + MineBlockAction action = new MineBlockAction(steve, task); + return new EventPublishingAction(action, eventBus); + }); + } +} +``` + +### 与 SteveEntity 集成 + +```java +public class SteveEntity extends PathfinderMob { + private final EventBus eventBus; + + public void onActionStarted(BaseAction action) { + eventBus.publish(new ActionStartedEvent( + action.getClass().getSimpleName(), + action.getDescription(), + this + )); + } +} +``` + +## 已知限制 + +1. **无事件过滤**:无法按条件过滤事件 +2. **无事件转换**:无法在传递过程中修改事件 +3. **无死信队列**:异步事件失败后丢失 +4. **无事件重放**:无法重放历史事件 + +## 扩展建议 + +1. **事件过滤**:支持谓词过滤 +2. **事件转换**:支持事件映射和转换 +3. **死信队列**:失败事件的重试机制 +4. **事件持久化**:保存事件历史 +5. **分布式事件**:跨进程事件传递 + +## 配置 + +无额外配置。事件系统自动初始化。 + +## 调试 + +```java +// 获取订阅者数量 +int count = eventBus.getSubscriberCount(ActionStartedEvent.class); +LOGGER.debug("ActionStartedEvent subscribers: {}", count); + +// 列出所有事件类型 +eventBus.subscribers.keySet().forEach(type -> + LOGGER.debug("Event type: {}", type.getSimpleName())); +``` + +## 最佳实践 + +1. **事件不可变**:事件对象应为不可变 +2. **细粒度事件**:每个事件只携带必要信息 +3. **优先级使用**:日志/监控用高优先级,统计用低优先级 +4. **避免阻塞**:同步发布时避免长时间阻塞 +5. **及时取消**:组件销毁时取消订阅 diff --git a/docs/14-resilience.md b/docs/14-resilience.md new file mode 100644 index 00000000..26a8ddd5 --- /dev/null +++ b/docs/14-resilience.md @@ -0,0 +1,471 @@ +# 弹性系统 - LLM 调用容错机制 + +## 概述 + +弹性系统使用 Resilience4j 库为 LLM 调用提供容错保护,包括电路断路器、重试机制、速率限制、隔舱模式和响应缓存。确保在 API 不可用或响应缓慢时系统仍能正常运行。 + +## 架构设计 + +``` +弹性系统 +├── ResilientLLMClient (装饰器) +│ ├── 原始 LLM 客户端(委托对象) +│ ├── LLMCache (响应缓存) +│ ├── LLMFallbackHandler (降级处理) +│ └── Resilience4j 组件 +│ ├── CircuitBreaker (电路断路器) +│ ├── Retry (重试机制) +│ ├── RateLimiter (速率限制) +│ └── Bulkhead (隔舱模式) +├── 请求流程 +│ 1. 检查缓存 → 命中则返回 +│ 2. 检查速率限制 → 超限则等待/拒绝 +│ 3. 检查隔舱 → 满则等待/拒绝 +│ 4. 检查电路断路器 → 开启则降级 +│ 5. 执行请求(带重试) +│ 6. 成功 → 缓存响应,返回 +│ 7. 失败 → 触发降级处理 +└── ResilienceConfig (配置) + ├── 电路断路器参数 + ├── 重试参数 + ├── 速率限制参数 + └── 隔舱参数 +``` + +## 核心设计决策 + +### 1. 装饰器模式 + +在不修改原始客户端的情况下添加弹性功能: + +```java +// 原始客户端 +AsyncLLMClient rawClient = new AsyncOpenAIClient(apiKey, model, maxTokens, temp); + +// 添加弹性保护 +AsyncLLMClient resilientClient = new ResilientLLMClient(rawClient, cache, fallback); + +// 所有调用都受保护 +resilientClient.sendAsync("Build a house", params) + .thenAccept(response -> processResponse(response)); +``` + +**优势**: +- 单一职责(每个组件只负责一个弹性功能) +- 可组合(可以混合搭配不同组件) +- 易于测试(可以 mock 任何组件) + +### 2. 请求流程 + +```java +public CompletableFuture sendAsync(String prompt, Map params) { + return CompletableFuture.supplyAsync(() -> { + // 1. 检查缓存 + String cacheKey = generateCacheKey(prompt, params); + Optional cached = cache.get(cacheKey); + if (cached.isPresent()) { + return cached.get(); + } + + // 2. 检查速率限制 + if (!rateLimiter.acquirePermission()) { + throw new LLMException("Rate limit exceeded"); + } + + // 3. 检查隔舱 + if (!bulkhead.tryAcquirePermission()) { + throw new LLMException("Bulkhead full"); + } + + try { + // 4. 检查电路断路器(由 Resilience4j 自动处理) + // 5. 执行请求(带重试) + LLMResponse response = executeWithRetry(prompt, params); + + // 6. 缓存响应 + cache.put(cacheKey, response); + + return response; + } finally { + bulkhead.releasePermission(); + } + }).exceptionally(throwable -> { + // 7. 降级处理 + return fallbackHandler.handleFallback(prompt, params, throwable); + }); +} +``` + +### 3. 电路断路器(Circuit Breaker) + +防止持续调用失败的服务: + +```java +CircuitBreakerConfig config = CircuitBreakerConfig.custom() + .failureRateThreshold(50) // 失败率阈值 50% + .waitDurationInOpenState(Duration.ofSeconds(30)) // 开启状态等待 30 秒 + .slidingWindowSize(10) // 滑动窗口大小 10 + .minimumNumberOfCalls(5) // 最少调用次数 5 + .build(); + +CircuitBreaker circuitBreaker = CircuitBreaker.of("llm", config); +``` + +**状态转换**: +``` +关闭 (CLOSED) + │ + ├─ 失败率 < 50% → 保持关闭 + │ + └─ 失败率 ≥ 50% → 开启 (OPEN) + │ + ├─ 等待 30 秒 + │ + └─ 半开 (HALF_OPEN) + │ + ├─ 调用成功 → 关闭 + │ + └─ 调用失败 → 开启 +``` + +### 4. 重试机制(Retry) + +自动重试失败的请求: + +```java +RetryConfig config = RetryConfig.custom() + .maxAttempts(3) // 最大重试次数 3 + .waitDuration(Duration.ofSeconds(1)) // 等待时间 1 秒 + .retryExceptions( // 可重试的异常 + IOException.class, + TimeoutException.class, + LLMException.class + ) + .ignoreExceptions( // 忽略的异常 + IllegalArgumentException.class + ) + .build(); + +Retry retry = Retry.of("llm", config); +``` + +**重试策略**: +```java +// 指数退避 +RetryConfig.custom() + .intervalFunction(IntervalFunction.ofExponentialBackoff( + 1000, // 初始间隔 1 秒 + 2.0 // 倍数 + )) + .build(); + +// 结果:1s, 2s, 4s, 8s... +``` + +### 5. 速率限制(Rate Limiter) + +防止 API 配额耗尽: + +```java +RateLimiterConfig config = RateLimiterConfig.custom() + .limitForPeriod(10) // 每个周期 10 次 + .limitRefreshPeriod(Duration.ofSeconds(1)) // 刷新周期 1 秒 + .timeoutDuration(Duration.ofSeconds(5)) // 等待超时 5 秒 + .build(); + +RateLimiter rateLimiter = RateLimiter.of("llm", config); +``` + +**使用场景**: +- OpenAI:3 RPM(免费)、60 RPM(付费) +- Groq:30 RPM +- Gemini:60 RPM + +### 6. 隔舱模式(Bulkhead) + +限制并发请求数量: + +```java +BulkheadConfig config = BulkheadConfig.custom() + .maxConcurrentCalls(5) // 最大并发 5 + .maxWaitDuration(Duration.ofSeconds(10)) // 等待超时 10 秒 + .build(); + +Bulkhead bulkhead = Bulkhead.of("llm", config); +``` + +**作用**: +- 防止线程池耗尽 +- 限制资源使用 +- 快速失败 + +### 7. 响应缓存 + +减少重复请求: + +```java +public class LLMCache { + private final Cache cache; + + public LLMCache() { + this.cache = Caffeine.newBuilder() + .maximumSize(1000) // 最大条目 1000 + .expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期 + .build(); + } + + public Optional get(String key) { + return Optional.ofNullable(cache.getIfPresent(key)); + } + + public void put(String key, LLMResponse response) { + cache.put(key, response); + } +} +``` + +**缓存键生成**: +```java +private String generateCacheKey(String prompt, Map params) { + String input = prompt + "|" + params.toString(); + return DigestUtils.sha256Hex(input); // SHA-256 哈希 +} +``` + +### 8. 降级处理(Fallback) + +所有弹性措施都失败时的兜底方案: + +```java +public class LLMFallbackHandler { + public LLMResponse handleFallback(String prompt, Map params, Throwable throwable) { + LOGGER.warn("LLM call failed, using fallback: {}", throwable.getMessage()); + + // 基于模式的降级响应 + if (prompt.contains("build")) { + return new LLMResponse("{\"action\": \"build\", \"structure\": \"house\"}"); + } else if (prompt.contains("mine")) { + return new LLMResponse("{\"action\": \"mine\", \"block\": \"iron\", \"quantity\": 8}"); + } + + // 默认降级:跟随玩家 + return new LLMResponse("{\"action\": \"follow\", \"player\": \"nearest\"}"); + } +} +``` + +## 配置 + +### ResilienceConfig + +```java +public class ResilienceConfig { + // 电路断路器 + public static final float FAILURE_RATE_THRESHOLD = 50.0f; + public static final int WAIT_DURATION_SECONDS = 30; + public static final int SLIDING_WINDOW_SIZE = 10; + public static final int MIN_CALLS = 5; + + // 重试 + public static final int MAX_RETRIES = 3; + public static final long RETRY_DELAY_MS = 1000; + public static final double RETRY_MULTIPLIER = 2.0; + + // 速率限制 + public static final int RATE_LIMIT_PER_SECOND = 10; + public static final long RATE_LIMIT_TIMEOUT_MS = 5000; + + // 隔舱 + public static final int MAX_CONCURRENT_CALLS = 5; + public static final long BULKHEAD_TIMEOUT_MS = 10000; + + // 缓存 + public static final int CACHE_MAX_SIZE = 1000; + public static final long CACHE_EXPIRE_MINUTES = 5; +} +``` + +### 配置文件 + +```toml +# config/steve-common.toml + +[resilience] + [resilience.circuitBreaker] + failureRateThreshold = 50.0 + waitDurationSeconds = 30 + slidingWindowSize = 10 + minimumNumberOfCalls = 5 + + [resilience.retry] + maxAttempts = 3 + waitDurationMs = 1000 + multiplier = 2.0 + + [resilience.rateLimiter] + limitForPeriod = 10 + limitRefreshPeriodMs = 1000 + timeoutDurationMs = 5000 + + [resilience.bulkhead] + maxConcurrentCalls = 5 + maxWaitDurationMs = 10000 + + [resilience.cache] + maxSize = 1000 + expireAfterMinutes = 5 +``` + +## 使用示例 + +### 基本使用 + +```java +// 创建客户端 +AsyncLLMClient rawClient = new AsyncGroqClient(apiKey, model); +LLMCache cache = new LLMCache(); +LLMFallbackHandler fallback = new LLMFallbackHandler(); + +AsyncLLMClient client = new ResilientLLMClient(rawClient, cache, fallback); + +// 发送请求(自动受保护) +client.sendAsync("Build a castle", params) + .thenAccept(response -> { + if (response.isSuccess()) { + processResponse(response); + } else { + handleFallback(response); + } + }) + .exceptionally(throwable -> { + LOGGER.error("Request failed: {}", throwable.getMessage()); + return null; + }); +``` + +### 监控弹性指标 + +```java +// 电路断路器指标 +CircuitBreaker.Metrics cbMetrics = circuitBreaker.getMetrics(); +int failureRate = cbMetrics.getFailureRate(); +int successfulCalls = cbMetrics.getNumberOfSuccessfulCalls(); +int failedCalls = cbMetrics.getNumberOfFailedCalls(); + +// 速率限制指标 +RateLimiter.Metrics rlMetrics = rateLimiter.getMetrics(); +int availablePermissions = rlMetrics.getAvailablePermissions(); +int waitingThreads = rlMetrics.getNumberOfWaitingThreads(); + +// 隔舱指标 +Bulkhead.Metrics bhMetrics = bulkhead.getMetrics(); +int availableConcurrentCalls = bhMetrics.getAvailableConcurrentCalls(); +int maxAllowedConcurrentCalls = bhMetrics.getMaxAllowedConcurrentCalls(); +``` + +### 动态配置 + +```java +// 运行时修改配置 +circuitBreaker.transitionToOpenState(); +circuitBreaker.transitionToHalfOpenState(); +circuitBreaker.transitionToDisabledState(); + +// 重置指标 +circuitBreaker.reset(); +rateLimiter.resetMetrics(); +``` + +## 性能影响 + +### 缓存命中 + +- 缓存命中:~1μs(内存查找) +- 缓存未命中:~500ms-30s(LLM 调用) +- 命中率:40-60%(典型场景) + +### 弹性开销 + +- 电路断路器:~100ns(状态检查) +- 重试机制:~1μs(检查重试条件) +- 速率限制:~200ns(令牌桶检查) +- 隔舱模式:~300ns(信号量获取) + +**总开销**:< 1μs(可忽略) + +## 错误处理 + +### 可重试错误 + +```java +.retryExceptions( + IOException.class, // 网络错误 + TimeoutException.class, // 超时 + LLMException.class // LLM 特定错误 +) +``` + +### 不可重试错误 + +```java +.ignoreExceptions( + IllegalArgumentException.class, // 参数错误 + AuthenticationException.class // 认证失败 +) +``` + +### 降级触发条件 + +1. 电路断路器开启 +2. 速率限制超限 +3. 隔舱满 +4. 重试次数用尽 +5. 不可重试异常 + +## 监控和日志 + +### 日志配置 + +```properties +# log4j2.properties +logger.resilience.name = com.steve.ai.llm.resilience +logger.resilience.level = DEBUG + +logger.resilience4j.name = io.github.resilience4j +logger.resilience4j.level = INFO +``` + +### 日志输出 + +``` +[DEBUG] Circuit breaker state: CLOSED +[DEBUG] Rate limiter: 8/10 permissions available +[DEBUG] Bulkhead: 3/5 concurrent calls +[INFO] Cache hit: key=abc123 +[WARN] Circuit breaker OPEN: 50% failure rate +[INFO] Retry attempt 2/3 after 1000ms +[ERROR] All retries exhausted, using fallback +``` + +## 最佳实践 + +1. **合理配置**:根据 API 限制配置速率限制 +2. **监控指标**:定期检查电路断路器状态 +3. **降级策略**:设计有意义的降级响应 +4. **缓存策略**:根据数据新鲜度需求设置过期时间 +5. **日志记录**:记录所有弹性事件用于调试 + +## 已知限制 + +1. **单节点**:不支持分布式弹性(如 Redis 缓存) +2. **静态配置**:配置变更需重启 +3. **无优先级队列**:无法优先处理重要请求 +4. **无请求合并**:相同请求不会合并 + +## 扩展建议 + +1. **分布式缓存**:使用 Redis 替代本地缓存 +2. **动态配置**:支持运行时配置更新 +3. **请求优先级**:支持优先级队列 +4. **请求合并**:相同请求自动合并 +5. **指标导出**:导出到 Prometheus/Grafana diff --git a/docs/15-entity-management.md b/docs/15-entity-management.md new file mode 100644 index 00000000..ab6aaafb --- /dev/null +++ b/docs/15-entity-management.md @@ -0,0 +1,475 @@ +# 实体管理 - Steve 生命周期管理 + +## 概述 + +SteveManager 负责管理所有活跃 Steve 实体的生命周期,包括生成、查找、移除和清理。使用 ConcurrentHashMap 保证线程安全,支持按名称和 UUID 双重索引。 + +## 架构设计 + +``` +SteveManager +├── 数据结构 +│ ├── activeSteves: Map (按名称索引) +│ └── stevesByUUID: Map (按 UUID 索引) +├── 生命周期管理 +│ ├── spawnSteve() → 生成新 Steve +│ ├── removeSteve() → 移除 Steve +│ └── clearAllSteves() → 清除所有 Steve +├── 查询方法 +│ ├── getSteve(name) → 按名称查找 +│ ├── getSteve(uuid) → 按 UUID 查找 +│ ├── getAllSteves() → 获取所有 +│ └── getSteveNames() → 获取所有名称 +└── 维护任务 + └── tick() → 清理死亡/移除的 Steve +``` + +## 核心设计决策 + +### 1. 双重索引 + +支持按名称和 UUID 两种方式查找: + +```java +private final Map activeSteves; // 名称 → 实体 +private final Map stevesByUUID; // UUID → 实体 + +// 按名称查找 +public SteveEntity getSteve(String name) { + return activeSteves.get(name); +} + +// 按 UUID 查找 +public SteveEntity getSteve(UUID uuid) { + return stevesByUUID.get(uuid); +} +``` + +**优势**: +- 名称查找:人类友好(用于命令) +- UUID 查找:系统内部使用(用于事件、网络) + +### 2. 线程安全 + +使用 `ConcurrentHashMap` 保证并发安全: + +```java +private final Map activeSteves = new ConcurrentHashMap<>(); +private final Map stevesByUUID = new ConcurrentHashMap<>(); +``` + +**操作安全性**: +- `put()`:原子性 +- `get()`:无锁读取 +- `remove()`:原子性 +- `containsKey()`:无锁检查 + +### 3. 生成流程 + +```java +public SteveEntity spawnSteve(ServerLevel level, Vec3 position, String name) { + // 1. 检查名称是否已存在 + if (activeSteves.containsKey(name)) { + LOGGER.warn("Steve name '{}' already exists", name); + return null; + } + + // 2. 检查是否达到最大数量限制 + int maxSteves = SteveConfig.MAX_ACTIVE_STEVES.get(); + if (activeSteves.size() >= maxSteves) { + LOGGER.warn("Max Steve limit reached: {}", maxSteves); + return null; + } + + // 3. 创建实体 + SteveEntity steve = new SteveEntity(SteveMod.STEVE_ENTITY.get(), level); + + // 4. 设置属性 + steve.setSteveName(name); + steve.setPos(position.x, position.y, position.z); + + // 5. 添加到世界 + boolean added = level.addFreshEntity(steve); + if (added) { + // 6. 注册到索引 + activeSteves.put(name, steve); + stevesByUUID.put(steve.getUUID(), steve); + return steve; + } + + return null; +} +``` + +### 4. 移除流程 + +```java +public boolean removeSteve(String name) { + // 1. 从名称索引移除 + SteveEntity steve = activeSteves.remove(name); + if (steve != null) { + // 2. 从 UUID 索引移除 + stevesByUUID.remove(steve.getUUID()); + + // 3. 从世界中移除实体 + steve.discard(); + + return true; + } + return false; +} +``` + +### 5. 自动清理 + +每 tick 检查并清理死亡或移除的 Steve: + +```java +public void tick(ServerLevel level) { + Iterator> iterator = activeSteves.entrySet().iterator(); + + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + SteveEntity steve = entry.getValue(); + + // 检查 Steve 是否仍然有效 + if (!steve.isAlive() || steve.isRemoved()) { + // 从 UUID 索引移除 + stevesByUUID.remove(steve.getUUID()); + + // 从迭代器中移除(安全方式) + iterator.remove(); + + LOGGER.info("Cleaned up Steve: {}", entry.getKey()); + } + } +} +``` + +**触发清理的情况**: +- Steve 被杀死(isAlive() = false) +- Steve 被命令移除(isRemoved() = true) +- 世界卸载 + +## 查询方法 + +### 获取所有 Steve + +```java +public Collection getAllSteves() { + return Collections.unmodifiableCollection(activeSteves.values()); +} +``` + +**返回不可修改集合**,防止外部修改内部状态。 + +### 获取所有名称 + +```java +public List getSteveNames() { + return new ArrayList<>(activeSteves.keySet()); +} +``` + +**返回新列表**,避免暴露内部键集合。 + +### 获取活跃数量 + +```java +public int getActiveCount() { + return activeSteves.size(); +} +``` + +## 与其他系统集成 + +### 与命令系统集成 + +```java +// /steve spawn +private static int spawnSteve(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + SteveManager manager = SteveMod.getSteveManager(); + + Vec3 spawnPos = calculateSpawnPosition(context); + SteveEntity steve = manager.spawnSteve(serverLevel, spawnPos, name); + + if (steve != null) { + source.sendSuccess(() -> Component.literal("Spawned Steve: " + name), true); + return 1; + } + return 0; +} + +// /steve remove +private static int removeSteve(CommandContext context) { + String name = StringArgumentType.getString(context, "name"); + SteveManager manager = SteveMod.getSteveManager(); + + if (manager.removeSteve(name)) { + source.sendSuccess(() -> Component.literal("Removed Steve: " + name), true); + return 1; + } + return 0; +} + +// /steve list +private static int listSteves(CommandContext context) { + SteveManager manager = SteveMod.getSteveManager(); + List names = manager.getSteveNames(); + + source.sendSuccess(() -> Component.literal("Active Steves: " + String.join(", ", names)), true); + return 1; +} +``` + +### 与 GUI 集成 + +```java +// SteveGUI.java +private static List parseTargetSteves(String command) { + List targets = new ArrayList<>(); + + // "all steves" 命令 + if (command.startsWith("all steves ") || command.startsWith("all ")) { + var allSteves = SteveMod.getSteveManager().getAllSteves(); + for (SteveEntity steve : allSteves) { + targets.add(steve.getSteveName()); + } + return targets; + } + + // 解析特定 Steve 名称 + var allSteves = SteveMod.getSteveManager().getAllSteves(); + List availableNames = new ArrayList<>(); + for (SteveEntity steve : allSteves) { + availableNames.add(steve.getSteveName().toLowerCase()); + } + + // 匹配名称 + String[] parts = command.split(","); + for (String part : parts) { + String firstWord = part.trim().split(" ")[0].toLowerCase(); + if (availableNames.contains(firstWord)) { + targets.add(firstWord); + } + } + + return targets; +} +``` + +### 与任务规划集成 + +```java +// ActionExecutor.java +private void drainNextCommand() { + String next = commandQueue.poll(); + if (next == null) return; + + Map reactBaseParams = getTaskPlanner().buildReActParams(); + reactAgent = new ReActAgent(steve, next, + SteveConfig.REACT_MAX_STEPS.get(), + SteveConfig.REACT_OBS_TRUNCATE.get(), + SteveConfig.REACT_FAIL_TOLERANCE.get()); + reactAgent.startAsync( + getTaskPlanner().getAsyncClient(SteveConfig.AI_PROVIDER.get()), + reactBaseParams); +} +``` + +## 配置 + +### 最大 Steve 数量 + +```java +// SteveConfig.java +public static final ForgeConfigSpec.IntValue MAX_ACTIVE_STEVES; + +static { + MAX_ACTIVE_STEVES = builder + .comment("Maximum number of active Steves simultaneously") + .defineInRange("maxActiveSteves", 10, 1, 50); +} +``` + +**配置文件**: +```toml +# config/steve-common.toml +[behavior] +maxActiveSteves = 10 # 1-50,默认 10 +``` + +### 生成位置 + +```java +// 在玩家朝向前方 3 格生成 +Vec3 sourcePos = source.getPosition(); +if (source.getEntity() != null) { + Vec3 lookVec = source.getEntity().getLookAngle(); + sourcePos = sourcePos.add(lookVec.x * 3, 0, lookVec.z * 3); +} +``` + +## 错误处理 + +### 名称冲突 + +```java +if (activeSteves.containsKey(name)) { + LOGGER.warn("Steve name '{}' already exists", name); + return null; +} +``` + +**解决方案**:使用唯一名称或自动编号。 + +### 达到数量限制 + +```java +if (activeSteves.size() >= maxSteves) { + LOGGER.warn("Max Steve limit reached: {}", maxSteves); + return null; +} +``` + +**解决方案**:移除现有 Steve 或增加限制。 + +### 实体添加失败 + +```java +boolean added = level.addFreshEntity(steve); +if (!added) { + LOGGER.error("Failed to add Steve entity to world"); + return null; +} +``` + +**可能原因**: +- 世界已卸载 +- 位置无效 +- 实体 ID 冲突 + +## 性能考虑 + +### 内存占用 + +每个 Steve 实体占用: +- SteveEntity 对象:~2KB +- ActionExecutor:~1KB +- SteveMemory:~500B +- WorldKnowledge:~1KB +- 其他组件:~500B + +**总计**:~5KB/Steve + +**10 个 Steve**:~50KB(可忽略) + +### 查找性能 + +- 按名称查找:O(1)(HashMap) +- 按 UUID 查找:O(1)(HashMap) +- 获取所有:O(n)(遍历 values) + +### 清理开销 + +每 tick 清理检查: +- 遍历所有 Steve:O(n) +- 检查 isAlive/isRemoved:O(1) +- 移除无效 Steve:O(1) + +**典型场景**:< 1μs(10 个 Steve) + +## 线程安全 + +### 保证 + +1. **读操作**:无锁(ConcurrentHashMap) +2. **写操作**:原子性(put/remove) +3. **迭代操作**:安全(使用 Iterator.remove()) + +### 潜在问题 + +```java +// 问题:在迭代中直接删除 +for (SteveEntity steve : activeSteves.values()) { + if (!steve.isAlive()) { + activeSteves.remove(steve.getSteveName()); // ConcurrentModificationException + } +} +``` + +**解决方案**:使用 Iterator.remove() 或 ConcurrentHashMap.entrySet().removeIf() + +## 已知限制 + +1. **无持久化**:Steve 管理器状态不保存到磁盘 +2. **无分组**:无法将 Steve 分组管理 +3. **无优先级**:所有 Steve 优先级相同 +4. **无限制检查**:无法限制特定类型的 Steve 数量 + +## 扩展建议 + +1. **持久化**:保存 Steve 管理器状态到 NBT +2. **分组管理**:支持 Steve 分组(如 "builders"、"miners") +3. **优先级队列**:支持 Steve 优先级 +4. **类型限制**:限制特定类型 Steve 的数量 +5. **远程管理**:支持远程 Steve 管理 + +## 使用示例 + +### 基本使用 + +```java +// 获取管理器 +SteveManager manager = SteveMod.getSteveManager(); + +// 生成 Steve +SteveEntity steve = manager.spawnSteve(level, position, "builder1"); + +// 查找 Steve +SteveEntity found = manager.getSteve("builder1"); +SteveEntity byUUID = manager.getSteve(steve.getUUID()); + +// 列出所有 Steve +Collection allSteves = manager.getAllSteves(); +List names = manager.getSteveNames(); + +// 移除 Steve +boolean removed = manager.removeSteve("builder1"); + +// 清除所有 +manager.clearAllSteves(); +``` + +### 批量操作 + +```java +// 给所有 Steve 发送命令 +for (SteveEntity steve : manager.getAllSteves()) { + steve.getActionExecutor().processNaturalLanguageCommand("follow me"); +} + +// 统计活跃 Steve +int count = manager.getActiveCount(); +LOGGER.info("Active Steves: {}", count); +``` + +### 条件移除 + +```java +// 移除空闲的 Steve +for (SteveEntity steve : manager.getAllSteves()) { + if (steve.getActionExecutor().isIdle()) { + manager.removeSteve(steve.getSteveName()); + } +} +``` + +## 最佳实践 + +1. **唯一名称**:使用描述性且唯一的名称 +2. **及时清理**:不再需要时及时移除 Steve +3. **错误处理**:检查 spawnSteve/removeSteve 返回值 +4. **避免并发**:在服务器线程操作 Steve 管理器 +5. **监控数量**:定期检查活跃 Steve 数量 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..fa4f631e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,40 @@ +# Steve AI 文档 + +## 核心文档 + +### 基础层 +1. [概述](00-overview.md) - 项目介绍、快速开始 +2. [核心架构](01-architecture.md) - 整体结构、核心组件 +3. [配置参考](03-config.md) - 配置文件、技术栈 + +### AI 核心 +4. [世界感知](04-world-knowledge.md) - 环境扫描与感知系统 +5. [提示词构建](05-prompt-builder.md) - LLM 提示词工程系统 +6. [LLM 集成](06-llm.md) - 提供商、缓存、熔断器 +7. [弹性系统](14-resilience.md) - LLM 调用容错机制 + +### 执行层 +8. [动作系统](02-actions.md) - 动作执行、可用动作列表 +9. [多 Agent 协作](07-multi-agent.md) - 象限分配、并发控制 +10. [代码执行引擎](08-code-execution.md) - GraalVM、SteveAPI + +### 架构模式 +11. [插件系统](12-plugin-system.md) - 可扩展的动作注册框架 +12. [事件系统](13-event-system.md) - 解耦的组件通信 +13. [实体管理](15-entity-management.md) - Steve 生命周期管理 + +### 数据层 +14. [记忆系统](09-memory.md) - 对话历史、世界状态 +15. [可用结构](10-structures.md) - Minecraft 结构参考 +16. [NBT 建筑模板](03-config.md#nbt-建筑模板) - 模板配置 + +### 界面层 +16. [Steve GUI](11-steve-gui.md) - 侧边栏聊天界面实现 + +## 黑客马拉松项目 + +黑客马拉松期间开发的施工系统相关文档: + +1. [mempalace 集成](hackathon/01-mempalace-integration.md) - mempalace × ReAct 模板调度 +2. [四阶段 plan 模式](hackathon/02-plan-mode.md) - plan-then-build 状态机 +3. [外部 HTML Plan Dashboard](hackathon/施工流程.md) - HTTP server + React + Three.js UI diff --git a/docs/hackathon/01-mempalace-integration.md b/docs/hackathon/01-mempalace-integration.md new file mode 100644 index 00000000..7e96f31e --- /dev/null +++ b/docs/hackathon/01-mempalace-integration.md @@ -0,0 +1,454 @@ +# Mempalace × ReAct:建筑模板调度与施工归档 + +## 1. 项目概述 + +### 1.1 背景 + +将 **mempalace**(外部 MCP 服务,作为建筑模板知识库 + 施工记录中枢)与 **ReAct 模式**(Reason + Act 思考-行动循环)结合,实现: + +1. **模板注册** — 启动时把 `config/steve/structures/*.nbt` 模板元信息同步到 mempalace +2. **ReAct 调度** — LLM 通过 `mcp` action 查询可用模板,按用户需求选取 +3. **协同建造** — Steve AI 根据选定模板执行建造(多 Steve 协作分象限) +4. **位置归档** — 建造完成后写回 mempalace,便于后续查询与避让 +5. **长期记忆** — `SteveMemory.queryLongTermMemory()` 也走 mempalace,取代 NBT 持久化 + +### 1.2 核心技术 + +| 组件 | 路径 | 用途 | +|------|------|------| +| 模板加载 | `structure/StructureTemplateLoader` | 从 `config/steve/structures/*.nbt` 加载,命名规则 `{type}_{name}.nbt` | +| 模板注册 | `mcp/MCPClientWrapper` + `MCPToolRegistry` | `mempalace_add_drawer` 写入模板元信息 | +| 模板验证 | `mempalace_list_drawers` | 启动时验证注册成功 | +| ReAct 主循环 | `llm/react/ReActAgent` | Thought → Action → Observation 迭代 | +| LLM 调度 | `llm/PromptBuilder.buildReActSystemPrompt` | 把 MCP 工具列表注入到系统提示词 | +| 工具执行 | `action/actions/MCPAction` | LLM 输出 `action="mcp"` 时调用 | +| 位置归档 | `action/actions/PlanBuildAction.archiveToMempalace` | CONSTRUCTION → COMPLETED 时自动写 `wing=built_structures` | +| 长期记忆 | `memory/SteveMemory.queryLongTermMemory` | 调 `mempalace:mempalace_query` 检索历史对话 | + +### 1.3 NBT 命名规范 + +`config/steve/structures/` 下的文件名采用 `{type}_{name}.nbt` 格式,启动时 `StructureTemplateLoader.getAvailableStructures()` 按下划线 split 两次: + +| 文件名 | type | name | mempalace wing | +|--------|------|------|----------------| +| `template_house_1.nbt` | template | house_1 | `structure_template` | +| `template_house_2.nbt` | template | house_2 | `structure_template` | +| `decoration_tower.nbt` | decoration | tower | `structure_decoration` | +| `castle.nbt`(无下划线) | default | castle | `structure_default` | + +仓库内已自带两个示例:`template_house_1.nbt`、`template_house_2.nbt`。 + +## 2. 整体架构 + +```mermaid +flowchart TB + subgraph Startup["启动阶段"] + S1[mod 启动] --> S2[SteveMod.onServerStarting] + S2 --> S3[MCPToolRegistry.init] + S3 --> S4[连接 mempalace @ localhost:6060] + S2 --> S5[StructureTemplateLoader.getAvailableStructures] + S5 --> S6[扫描 config/steve/structures/*.nbt] + S6 --> S7[解析尺寸 + 方块数] + S7 --> S8[MCPClientWrapper.callTool mempalace_add_drawer] + S8 --> S9[注册到 wing=structure_{type}, room={name}] + S9 --> S10[mempalace_list_drawers 验证] + end + + subgraph Runtime["运行时 ReAct 循环"] + R1[玩家 /steve tell Steve 在这建个城堡] --> R2[ActionExecutor.processNaturalLanguageCommand] + R2 --> R3[pendingCommands.add] + R3 --> R4{reactAgent 空闲?} + R4 -->|是| R5[drainNextCommand 启动 ReActAgent] + R5 --> R6[ReActAgent.startAsync: sendAsync 系统+用户提示词] + R6 --> R7[LLM 输出 step 1: thought + mcp action] + R7 --> R8[ActionExecutor.consumeNextStep → executeTask MCPAction] + R8 --> R9[mempalace_list_drawers wing=structure_template] + R9 --> R10[Observation: [house, castle, tower, ...]] + R10 --> R11[ReActAgent.feedObservation 自动触发下一轮] + R11 --> R12[LLM 输出 step 2: mcp get_drawer room=castle] + R12 --> R13[Observation: castle 30x20x30] + R13 --> R14[ReActAgent.feedObservation] + R14 --> R15[LLM 输出 step 3: action=build structure=castle] + R15 --> R16[executeTask → 拦截到 PlanBuildAction] + R16 --> R17[runDesign: 加载 NBT 模板 + 推 PlanDesignReadyEvent] + R17 --> R18[玩家 /steve approve(或 dashboard Approve)] + R18 --> R19[runConstruction: placeNextBlock 每 BUILD_TICK_DELAY tick 放一块] + R19 --> R20[CONSTRUCTION → COMPLETED 时 archiveToMempalace wing=built_structures] + R20 --> R21[ActionResult 喂回 ReActAgent] + R21 --> R22[LLM 输出 step N: is_final=true, final_answer] + R22 --> R23[GUI 显示: 城堡建好了] + R23 --> R24{队列空?} + R24 -->|否| R5 + R24 -->|是| R25[转 IdleFollowAction] + end +``` + +## 3. 数据流 + +### 3.1 启动时 — 模板注册 + +```mermaid +sequenceDiagram + participant Mod as SteveMod + participant Loader as StructureTemplateLoader + participant Registry as MCPToolRegistry + participant Wrapper as MCPClientWrapper + participant Palace as mempalace + + Mod->>Registry: init() + Registry->>Wrapper: new MCPClientWrapper("mempalace", "http://localhost:6060") + Wrapper->>Palace: initialize (McpSyncClient) + Palace-->>Wrapper: 5 tools registered + Registry-->>Mod: log "MCP server 'mempalace' has 5 tools" + + Mod->>Loader: getAvailableStructures() + loop 每个 *.nbt + Loader->>Loader: parseNBTStructure (NbtIo.readCompressed) + Loader->>Wrapper: callTool("mempalace_add_drawer", {
wing: "structure_template",
room: "house_1",
content: "Type: template | Structure 'house_1' 9x6x9 with 243 blocks",
added_by: "steve-ai"
}) + Wrapper->>Palace: tools/call + Palace-->>Wrapper: OK + Loader->>Wrapper: callTool("mempalace_list_drawers", {wing: "structure_template"}) + Wrapper->>Palace: tools/call + Palace-->>Wrapper: [house_1, house_2, ...] + Loader-->>Mod: log "Registered structure template 'house_1' to mempalace" + end + Loader-->>Mod: ["house_1", "house_2", ...] +``` + +**失败容忍**:`MCPClientWrapper` 抛异常时 `StructureTemplateLoader` 只记录 warn 日志,不中断游戏。 + +### 3.2 运行时 — ReAct 调度 + +```mermaid +sequenceDiagram + participant User as 玩家 + participant Executor as ActionExecutor + participant Agent as ReActAgent + participant LLM as 大模型 + participant MCPAction as MCPAction + participant Plan as PlanBuildAction + participant Palace as mempalace + + User->>Executor: /steve plan 在这建个城堡 + Executor->>Executor: pendingCommands.add("[PLAN MODE] ... 在这建个城堡") + Executor->>Agent: new ReActAgent(steve, cmd, maxSteps=12, obsTruncate=800, maxFail=3) + Executor->>Agent: startAsync(asyncClient, params) + Agent->>LLM: sendAsync(buildReActUserPrompt + systemPrompt) + LLM-->>Agent: {thought, action: "mcp", tool: "mempalace_list_drawers", is_final: false} + Agent-->>Executor: pendingStep ready + Executor->>MCPAction: new MCPAction(tool, args) + MCPAction->>Palace: list_drawers wing=structure_template + Palace-->>MCPAction: [house_1, house_2, castle, ...] + MCPAction-->>Executor: ActionResult.success("[house_1, house_2, ...]") + Executor->>Agent: feedObservation(result, client, params) + Agent->>LLM: sendAsync(prompt with scratchpad) + LLM-->>Agent: {action: "build", structure: "castle"} + Executor->>Plan: new PlanBuildAction (case "build" 在 createActionLegacy 拦截) + Plan->>Plan: runDesign 加载 castle.nbt + 推 PlanDesignReadyEvent + 归档 mempalace build_designs + Plan-->>Executor: 进入 AWAITING_DESIGN_APPROVAL + User->>Executor: /steve approve + Executor->>Plan: approve() → transitionTo(CONSTRUCTION) + Plan->>Plan: runConstruction placeNextBlock 每 BUILD_TICK_DELAY tick 放一块 + Plan->>Palace: archiveToMempalace wing=built_structures room=castle_ + Plan-->>Executor: ActionResult.success("Built 8432 blocks for project #") + Executor->>Agent: feedObservation + Agent->>LLM: sendAsync + LLM-->>Agent: {is_final: true, final_answer: "城堡建好了 at [100,64,-200]"} + Agent-->>Executor: finished, finalAnswer + Executor->>User: GUI: 城堡建好了 at [100,64,-200] + Executor->>Executor: drainNextCommand (若队列非空) +``` + +### 3.3 命令排队 + +```mermaid +sequenceDiagram + participant U1 as 玩家 1 + participant U2 as 玩家 2 + participant Exec as ActionExecutor + participant A1 as ReActAgent (任务1) + participant A2 as ReActAgent (任务2) + + U1->>Exec: tell Steve 建城堡 + Exec->>A1: start + Note over Exec: pendingCommands=[] + U2->>Exec: tell Steve 杀僵尸 + Note over Exec: reactAgent 不为空, 仅入队 + Exec->>Exec: pendingCommands=["杀僵尸"] + A1->>A1: 完成 / failed + A1-->>Exec: isFinished + Exec->>Exec: pendingCommands.poll → 启动 A2 + A2->>A2: 执行杀僵尸 + A2-->>Exec: finished +``` + +## 4. mempalace 数据模型 + +### 4.1 Wing 分类 + +| Wing | 用途 | 写入时机 | 读取时机 | +|------|------|---------|---------| +| `structure_template` | 建筑模板元信息 | 启动时 `StructureTemplateLoader` | LLM 通过 `mempalace_list_drawers` 查询 | +| `structure_decoration` | 装饰类模板 | 启动时 | LLM 查询 | +| `structure_default` | 无下划线文件名(默认 type) | 启动时 | LLM 查询 | +| `built_structures` | 已建造建筑位置 | CONSTRUCTION → COMPLETED 时 `PlanBuildAction.archiveToMempalace` 自动写 | 后续查询 / 避免重复建造 | +| `steve_memory` | 长期记忆(按 steve 名称 room) | 玩家交互时 `SteveMemory.addAction` | `SteveMemory.queryLongTermMemory` | + +### 4.2 Drawer 格式示例 + +模板元信息: +```json +{ + "wing": "structure_template", + "room": "house_1", + "content": "Type: template | Structure 'house_1' 9x6x9 with 243 blocks", + "added_by": "steve-ai", + "metadata": { + "type": "template", + "name": "house_1", + "width": 9, + "height": 6, + "depth": 9, + "block_count": 243 + } +} +``` + +已建建筑归档: +```json +{ + "wing": "built_structures", + "room": "castle", + "content": "Built castle at [100, 64, -200] by Steve-1", + "added_by": "steve-ai" +} +``` + +## 5. 关键代码改动 + +### 5.1 `StructureTemplateLoader.java` + +```java +public static List getAvailableStructures() { + List structures = new ArrayList<>(); + File structuresDir = FMLPaths.CONFIGDIR.get().resolve("steve/structures").toFile(); + + if (structuresDir.exists() && structuresDir.isDirectory()) { + File[] files = structuresDir.listFiles((dir, name) -> name.endsWith(".nbt")); + if (files != null) { + for (File file : files) { + String name = file.getName().replace(".nbt", ""); + structures.add(name); + String[] parts = name.split("_", 2); + String type = parts.length > 1 ? parts[0] : "default"; + registerStructureToMempalace(file, name, type); + } + } + } + return structures; +} + +private static void registerStructureToMempalace(File file, String name, String type) { + LoadedTemplate template = loadFromFile(file, name); + if (template == null) return; + + MCPClientWrapper client = new MCPClientWrapper("mempalace", "http://localhost:6060"); + client.initialize(); + + String content = String.format("Type: %s | Structure '%s' %dx%dx%d with %d blocks", + type, template.name, template.width, template.height, template.depth, template.blocks.size()); + + client.callTool("mempalace_add_drawer", Map.of( + "wing", "structure_" + type, + "room", template.name, + "content", content, + "added_by", "steve-ai" + )); + + // 启动验证 + String queryResult = client.callTool("mempalace_list_drawers", Map.of("wing", "structure_" + type)); + SteveMod.LOGGER.info("Verified mempalace registration: {}", queryResult); + + client.close(); +} +``` + +### 5.2 `ReActAgent.java`(新增) + +```java +public class ReActAgent { + private final StringBuilder scratchpad = new StringBuilder(); + private volatile boolean finished = false; + private volatile ParsedResponse pendingStep = null; + + public void startAsync(AsyncLLMClient client, Map params) { + runStep(client, params); // 首次 LLM 调用 + } + + private void runStep(AsyncLLMClient client, Map baseParams) { + Map params = new HashMap<>(baseParams); + params.put("systemPrompt", PromptBuilder.buildReActSystemPrompt(maxSteps)); + String prompt = PromptBuilder.buildReActUserPrompt(steve, originalCommand, scratchpad); + + client.sendAsync(prompt, params).thenAccept(response -> { + ResponseParser.ParsedResponse step = ResponseParser.parseReActStep(response.getContent()); + if (step.isFinal()) markFinished(step.getFinalAnswer()); + else pendingStep = step; + }); + } + + public ParsedResponse consumeNextStep() { /* game thread 取出 */ } + public void feedObservation(ActionResult result, AsyncLLMClient client, Map params) { + appendScratchpad("[OK/FAIL] " + result.getMessage()); + runStep(client, params); // 触发下一轮 + } +} +``` + +### 5.3 `PromptBuilder.buildReActSystemPrompt()` + +``` +ACTIONS (use these exact names): +- attack: {"target": "hostile|mob_name"} +- build: {"structure": ""} +- mine: {"block": "", "quantity": } +- follow: {"player": ""} +- pathfind: {"x": , "y": , "z": } +- mcp: {"tool": "", "args": {}} + +OUTPUT FORMAT (one JSON object): +{"thought": "...", "action": "", "parameters": {...}, "is_final": false} + +When done: +{"thought": "...", "is_final": true, "final_answer": "..."} + +AVAILABLE MCP TOOLS: +- mempalace:mempalace_list_drawers: List drawers with pagination + args: {"wing": "structure_template"} +- mempalace:mempalace_get_drawer: Fetch a single drawer by ID + args: {"wing": "structure_template", "room": "house_1"} +- mempalace:mempalace_add_drawer: Add a drawer + args: {"wing": "structure_template", "room": "house_1", "content": "...", "added_by": "steve-ai"} +``` + +### 5.4 `ActionExecutor.drainNextCommand()` + +```java +private void drainNextCommand() { + String next = pendingCommands.poll(); + if (next == null) return; + currentGoal = next; + reactBaseParams = getTaskPlanner().buildReActParams(); + reactAgent = new ReActAgent(steve, next, + SteveConfig.REACT_MAX_STEPS.get(), + SteveConfig.REACT_OBS_TRUNCATE.get(), + SteveConfig.REACT_FAIL_TOLERANCE.get()); + reactAgent.startAsync(getTaskPlanner().getAsyncClient(AI_PROVIDER), reactBaseParams); +} +``` + +## 6. 完整工作流 + +### 6.1 命令:`在这建个城堡` + +```mermaid +sequenceDiagram + autonumber + participant H as 玩家 + participant S as Steve + participant A as ReActAgent + participant L as LLM + participant M as mempalace + participant P as PlanBuildAction + + H->>S: /steve plan 在这建个城堡 + S->>A: start (augmented: [PLAN MODE] ...) + A->>L: step 1 + L-->>A: thought: 需要查模板
action: mcp tool=mempalace:list_drawers + A->>M: list_drawers wing=structure_template + M-->>A: [house_1, house_2, castle, ...] + A->>L: step 2 (含 obs) + L-->>A: thought: castle 尺寸合适
action: mcp get_drawer + A->>M: get_drawer room=castle + M-->>A: 30x20x30 with 8432 blocks + A->>L: step 3 + L-->>A: thought: 开始建
action: build structures=[castle] + A->>P: new PlanBuildAction (拦截 build) + P->>P: runDesign 加载 castle.nbt + 推 PlanDesignReadyEvent + 归档 build_designs + P-->>A: ActionResult 进入 AWAITING_DESIGN_APPROVAL + H->>S: /steve approve (或 dashboard Approve) + S->>P: approve() → transitionTo(CONSTRUCTION) + P->>P: runConstruction placeNextBlock 每 BUILD_TICK_DELAY tick 放一块 + P->>M: archiveToMempalace wing=built_structures room=castle_ + P-->>A: ActionResult "Built 8432 blocks for project #" + A->>L: step 4 + L-->>A: is_final=true, final_answer="城堡建好了 at [100,64,-200]" + A-->>S: finished + S->>H: GUI: 城堡建好了 at [100,64,-200] +``` + +### 6.2 错误处理 + +| 错误场景 | 处理 | +|---------|------| +| mempalace 未启动 | 启动时 `MCPClientWrapper.initialize` 抛异常,模板注册跳过但游戏继续 | +| 模板文件损坏 | `parseNBTStructure` 返回 null,记录 warn | +| 重复注册 | `mempalace_add_drawer` 幂等覆盖,每次启动刷新元信息 | +| 建造失败 | ReAct 喂回失败 observation,LLM 决定重试 / 换工具 / 标 is_final | +| LLM 解析失败 | 喂回 `[ERROR] Response not valid JSON`,递增 consecutiveFailures,达上限 failed | +| 连续命令 | `pendingCommands` 队列,当前 ReAct 完成后自动取下一条 | + +## 7. 验证计划 + +### 7.1 启动验证 + +1. 启动 Minecraft(mempalace 服务运行在 `localhost:6060`) +2. 检查日志: + ``` + [MCP] MCP server 'mempalace' has 5 tools + [StructureTemplateLoader] Registered structure template 'house_1' (type: template) to mempalace + [StructureTemplateLoader] Query mempalace after register: [house_1, house_2, ...] + ``` +3. 调 `mempalace_list_drawers wing=structure_template` 验证返回 `["house_1", "house_2"]` + +### 7.2 ReAct 运行时验证 + +4. `/steve tell Steve build a house` +5. 日志确认多步: + ``` + [ReAct step 1/12] Steve 'Steve' thinking for command: build a house + [ReAct step 1/12] thought='...templates available' action=mcp params=... + [MCPAction] Executing MCP tool: mempalace:mempalace_list_drawers + [MCPAction] MCP tool '...' result: [house_1, house_2, ...] + [ReAct step 2/12] thought='...' action=build params={structure: house_1} + [ReAct step 3/12] thought='...' action=mcp add_drawer + [ReAct step 4/12] FINAL: Built a house at [100, 64, -200] + ``` +6. GUI 弹 `Built a house at [100, 64, -200]` +7. `mempalace_list_drawers wing=built_structures` 返回 `["house_1"]` + +### 7.3 命令排队验证 + +8. ReAct 进行中再发 `/steve tell Steve attack zombies` +9. 第一个完成前 GUI 提示 `Got it, will do after current task (queue: 1)` +10. 第一个完成后日志 `Drained command: attack zombies`,自动开始第二个 + +### 7.4 失败熔断验证 + +11. 把 LLM API key 改错 +12. 启动时 `/steve tell Steve build a house` +13. 第一次 LLM 调用 `exceptionally` 触发 → `markFailed` +14. GUI 显示 `AI error: LLM call failed: ...`,队列保留不重试 +15. `mempalace` 仍可独立调(不依赖 LLM 路径) + +## 8. 优势 + +| 优势 | 说明 | +|------|------| +| 模板可发现 | LLM 通过 MCP 工具主动查询,无需硬编码模板列表 | +| 位置可追溯 | 所有建造记录保存在 mempalace,跨世界跨存档可查 | +| 思考可见 | ReAct 模式每步 LLM 思考 + 行动都进 scratchpad,可调试 | +| 失败可恢复 | observation 反馈让 LLM 自主调整重试 / 换工具 | +| 协同工作 | 多个 Steve 共享同一份模板库和建造记录 | +| 解耦 LLM 与 MCP | `MCPToolRegistry` 单例,mempalace 挂了不影响核心游戏逻辑 | +| 队列不丢 | 玩家连续发命令不丢失,按序处理 | diff --git a/docs/hackathon/02-plan-mode.md b/docs/hackathon/02-plan-mode.md new file mode 100644 index 00000000..27d08e7c --- /dev/null +++ b/docs/hackathon/02-plan-mode.md @@ -0,0 +1,281 @@ +# Plan 模式:四阶段施工流程映射 + +## Context + +参考真实高速公路建设的四阶段流程(可研→勘察设计→施工→验收),把当前 Steve AI 的"LLM 一句话 → 直接施工"流程升级为**显式四阶段**,每个阶段都向玩家/系统暴露可检查的中间产物。 + +**目标**: +- LLM 不再"黑盒"决定建造,每次施工**前都有可审阅的计划** +- 玩家在关键节点(设计完成、施工开始)有检查/取消权 +- 失败 / 取消可以回退到上一阶段,而不是从头开始 +- 阶段产物(计划书、施工日志、验收报告)全部沉淀到 mempalace,可追溯 + +**不做** GUI(用户明确要求)——所有交互走聊天栏 + `/steve` 子命令。 + +## 真实工程 → Steve AI 映射 + +| 阶段 | 真实工程 | Steve AI 落地 | 玩家可见产物 | +|------|---------|--------------|------------| +| **一·可研** | 必要性 / 技术 / 经济论证 | LLM 通过 `mcp mempalace_list_drawers` 查模板 → 选最匹配 → 计算材料成本 | 聊天栏:"候选模板:house_1, house_2, castle,已选 house_1(理由:与'建个小屋'最匹配)" | +| **二·设计** | 初设→施工图 | 加载 NBT → 计算 footprint、占地区域、所需材料、协同分区 | 聊天栏:完整**设计图纸**(见下文 4.1),等待 `/steve approve` | +| **三·施工** | 清表→骨架→血肉 | `PlanBuildAction.runConstruction` 走 `placeNextBlock`:每 `BUILD_TICK_DELAY` tick 放一块,Steve 走不到就 `getNavigation().moveTo`,被占用就跳过 | 进度行 `plan.log` 事件 "Construction progress: N/total",可 `/steve halt` 暂停(已放置方块不撤回) | + +## 架构 + +### 1. 新数据模型 + +**`BuildPhase` 枚举**(`llm/react/BuildPhase.java` 新增): + +```java +public enum BuildPhase { + FEASIBILITY, // 阶段一:选模板 + DESIGN, // 阶段二:出图纸 + AWAITING_DESIGN_APPROVAL,// 阶段二末尾:等玩家 /steve approve + CONSTRUCTION, // 阶段三:施工(前端 approve 后直接进入,无二次确认) + AWAITING_ACCEPTANCE, // 保留枚举值以保持源码兼容;当前流程不再进入 + COMPLETED, // 全部完成 + FAILED // 任意阶段失败 +} +``` + +**`BuildProject` 数据类**(`action/BuildProject.java` 新增)——一个建造项目的全部上下文: + +```java +public class BuildProject { + String id; // UUID, 用于多 Steve 隔离 + SteveEntity steve; + String command; // 玩家原始指令 + String selectedTemplate; // 阶段一选定 + List templates; // 阶段二加载(多模板拼接) + BlockPos originPos; // 施工原点 + Map materials; // 阶段二计算 + BuildPhase phase; // 当前阶段 + BuildPhase lastApproved; // 玩家最后 approve 的阶段 + int blocksPlaced; // 阶段三进度 + int totalBlocks; // 阶段二累加 + int nextBlockIndex; // 阶段三施工游标:扁平化后下一个方块的索引 + long phaseDeadlineMs; // 当前阶段超时时间 +} +``` + +**`ActionResult` 扩展**(`action/ActionResult.java` 改一处)——加一个 status 字段,**不破坏**现有 `isSuccess()` 调用方: + +```java +public enum Status { SUCCESS, FAILURE, PHASE_TRANSITION, AWAITING_APPROVAL } +private final Status status; // 新增 +public boolean isAwaitingApproval() { return status == Status.AWAITING_APPROVAL; } +public Status getStatus() { return status; } +// 旧工厂方法保持不变 +public static ActionResult awaitingApproval(String msg) { + return new ActionResult(false, msg, false, Status.AWAITING_APPROVAL); +} +``` + +### 2. 新 Action 类 + +**`PlanBuildAction.java`**(`action/actions/PlanBuildAction.java` 新增)——核心驱动 Action,**取代** LLM 直接发 `build` 时的拦截器。`extends BaseAction`。 + +`PlanBuildAction` 内部状态机: + +``` +FEASIBILITY (选模板) → DESIGN (出图纸) → AWAITING_DESIGN_APPROVAL → CONSTRUCTION → COMPLETED + ↑ halt ↑ halt + | | + └────────── FAILED ←──────────────┘ +``` + +每个阶段都是 `onTick` 里的一个 `switch (project.phase)`,推进条件: +- FEASIBILITY → LLM 通过 ReAct 已给定的 `template` / `structures` 参数驱动选定(不再走 `MCPAction` 查 mempalace,模板列表由 `StructureTemplateLoader.getAvailableStructures()` 提供) +- DESIGN → 加载 NBT + 计算 footprint / 占地区域 / 材料 / 协同分区 + 输出设计书,**写入 mempalace** `wing=build_designs, room=`,转 AWAITING_DESIGN_APPROVAL +- AWAITING_DESIGN_APPROVAL → 等玩家 `/steve approve`(**无超时**,玩家需手动 `/steve approve` 或 `/steve halt`;设计书保留在 mempalace) +- CONSTRUCTION → `PlanBuildAction.runConstruction` 每 `BUILD_TICK_DELAY` tick 调一次 `placeNextBlock`:Steve 离目标 > 6 格时 `getNavigation().moveTo`,到了就 `level.setBlock(pos, state, 3)`,游标 +1。`nextBlockIndex >= totalBlocks` 时 transition 到 COMPLETED。 +- COMPLETED → `result = ActionResult.success("Built N/total blocks for project #")`,写 `built_structures` drawer + +**`halt(reason)`**:玩家 `/steve halt` → 当前 stage 写到 mempalace,phase 转 FAILED,让 ReAct 决定下一步。 + +### 3. 拦截点 + +`ActionExecutor.executeTask`(line 258): + +```java +private void executeTask(Task task) { + if ("build".equals(task.getAction())) { + // 把"build"升级为"plan_build",所有元信息塞进 Task.parameters + Task planTask = new Task("plan_build", task.getParameters()); + currentAction = createAction(planTask); + } else { + currentAction = createAction(task); + } + // ...原 start() 逻辑... +} +``` + +LLM 无感知——它继续输出 `{"action": "build", "structure": "house_1"}`。 + +### 4. 聊天栏产物 + +#### 4.1 设计书(阶段二完成时) + +发给最近玩家(`player.sendSystemMessage`): + +``` +========== Steve-1 设计图 #abc123 ========== +项目: 玩家指令"建个小屋" +模板: house_1 +尺寸: 9 × 6 × 9 (长 × 高 × 深) +占地: 81 平方米 +方块总数: 243 +材料清单: + oak_planks × 180 (74%) + glass × 24 (10%) + cobblestone × 39 (16%) +原点坐标: (123, 64, -456) +协同分区: 4 个象限, 单 Steve 承担全部 +预计耗时: 约 1215 tick (≈ 60 秒) +-------------------------------------------- +输入 /steve approve 开始施工, /steve halt 放弃 +已归档到 mempalace: wing=build_designs/room=abc123 +============================================ +``` + +#### 4.2 施工进度(阶段三,每 50 块一次) + +`plan.log` 事件,dashboard 镜像到历史面板;聊天栏不刷屏。 + +``` +[INFO] Construction progress: 50/243 +[INFO] Construction progress: 100/243 +... +[INFO] Construction complete: 243/243 +``` + +### 5. `/steve` 子命令扩展 + +挂到 `SteveCommands.java` 现有 `steve` 根命令下: + +| 命令 | 作用 | 触发阶段 | +|------|------|---------| +| `/steve approve` | 批准当前阶段,进入下一阶段(当前是 DESIGN→CONSTRUCTION) | AWAITING_DESIGN_APPROVAL | +| `/steve halt [reason]` | 立即停止,已放置方块不撤回 | 任意 | +| `/steve status` | 输出当前 BuildProject 的所有阶段状态 | 任意(debug) | + +`findTargetSteve(player)` 抽成静态复用(line 113 抽方法),优先级:先找有活跃 BuildProject 的最近 Steve,否则原 tellSteve 行为。 + +### 6. ReAct 反馈 + +| 阶段结果 | ActionResult | ReAct scratchpad | +|---------|-------------|------------------| +| 玩家 `/steve approve` 后施工完成 | `success("Built 243/243 blocks for project #abc123")` | `[OK] Build completed, house_1 at [123,64,-456]` | +| 玩家 `/steve halt` | `failure("Build halted at phase 3, 156/243 placed")` | `[FAIL] Halted at construction, 156/243 blocks. Design archived. Re-plan?` | +| 阶段一未选定可用模板 | `failure("None of the requested NBT templates could be loaded")` | `[FAIL] No usable template, try a different request?` | + +**关键设计**:halt / timeout 后,**设计书留在 mempalace**(不删),LLM 下次能 query 到,玩家 `/steve status` 也能看到。这意味着 LLM 可以自主说"那个有悬空问题的小屋"——**记忆连续**。 + +## 关键文件改动 + +### 新增(4 个) + +- `src/main/java/com/steve/ai/llm/react/BuildPhase.java` — 枚举 +- `src/main/java/com/steve/ai/action/BuildProject.java` — 数据模型 +- `src/main/java/com/steve/ai/action/actions/PlanBuildAction.java` — 核心状态机 +- `src/main/java/com/steve/ai/llm/react/BuildDesignFormatter.java` — 纯静态,把 `BuildProject` 格式化成聊天栏文本(方便单测) + +### 修改(3 个) + +- `src/main/java/com/steve/ai/action/ActionResult.java` — 加 `Status` 枚举 + `awaitingApproval` 工厂方法(向后兼容) +- `src/main/java/com/steve/ai/action/ActionExecutor.java` — `executeTask` 拦截 build 升级为 plan_build,加 `approveCurrentBuild()` / `haltCurrentBuild()` 两个回调 +- `src/main/java/com/steve/ai/command/SteveCommands.java` — 加 approve / halt / status 子命令,`findTargetSteve` 抽静态 + +### 不修改 + +- `BuildStructureAction` —— `PlanBuildAction` 内部构造并驱动它 +- `ReActAgent` / `PromptBuilder` —— LLM 继续输出 `build`,拦截器翻译 +- `MCPClientWrapper` / `MCPToolRegistry` —— 阶段二/四的 mempalace 写入用现有 `mempalace_add_drawer` 工具 +- `SteveMemory` —— 长期记忆自动覆盖 + +## 复用清单(不要重新实现) + +| 已有代码 | 路径 | 怎么用 | +|---------|------|-------| +| `StructureTemplateLoader.loadFromNBT(name)` | `structure/StructureTemplateLoader.java` | 阶段二加载 | +| `StructureTemplateLoader.getAvailableStructures()` | 同上 | 阶段一日志里打可用模板列表 | +| `LoadedTemplate.blocks` / `width/height/depth` / `origin` | 同上 | 阶段二数据源 + 阶段三 `placeNextBlock` 计算世界坐标 | +| `LoadedTemplate.BlockPlacement.relativePos` / `blockState` | 同上 | 阶段三 `placeNextBlock` 读取下一方块坐标和状态 | +| `SteveEntity.getNavigation().moveTo` | `entity/SteveEntity.java` | 阶段三 Steve 距离 > 6 格时走过去 | +| `ServerLevel.setBlock(pos, state, flags)` | vanilla | 阶段三 `placeNextBlock` 实际放方块 | +| `MCPToolRegistry.callTool("mempalace_add_drawer", ...)` | `mcp/MCPToolRegistry.java` | 阶段二归档设计书 + halt 时归档 `build_halted` | +| `SteveMod.getPlanEventBus().publish` | `SteveMod.java` | 阶段转换时发 `PlanPhaseChangedEvent` / `PlanLogEvent` | +| `player.sendSystemMessage(Component)` | vanilla | 阶段二设计书输出到聊天栏 | +| `/steve approve` (聊天) / dashboard Approve 按钮 | `SteveCommands` / `PlanDashboardServer` | 玩家在 AWAITING_DESIGN_APPROVAL 推进到 CONSTRUCTION | + +## MemPalace 数据扩展 + +| Wing | Room | 写入时机 | 内容 | +|------|------|---------|------| +| `build_designs` | `` | 阶段二完成 | 完整设计书 JSON(尺寸、材料、原点、分区、玩家指令) | +| `build_halted` | `` | `/steve halt` 时 | halt 原因 + 阶段二设计书保留 | +| `built_structures` | `_` | CONSTRUCTION 完成(自动) | 原 `BuildStructureAction` 已有的归档,复用 | + +**回溯查询**:`/steve status` 查 mempalace `wing=build_halted, room=*` 列出最近 5 个被中止的项目;`wing=built_structures, room=*` 列出已建成的项目。 + +## 验证 + +### 单元层面 + +1. **阶段一选模板**: + - `/steve tell Steve build a house` + - 期望日志:ReAct step 1 `action=mcp mempalace_list_drawers`,step 2 `action=build structure=house_1` + - 期望 PlanBuildAction 收到 `template=house_1` + +2. **阶段二设计书**: + - 阶段一选完后自动进入阶段二 + - 期望聊天栏收到完整设计书(含 243 blocks、9×6×9、材料表 3 行) + - 期望 `mempalace_query wing=build_designs` 能查到 `room=abc123` + +3. **阶段二 approve**: + - 玩家在聊天栏输入 `/steve approve`(或在 dashboard 点 Approve 按钮) + - 期望日志 `phase: AWAITING_DESIGN_APPROVAL -> CONSTRUCTION` + - 期望方块开始放置(`plan.log` "Construction progress: 50/243") + +4. **阶段二 halt**: + - 阶段二等待 approve 时 `/steve halt` + - 期望日志 `BuildProject FAILED at phase AWAITING_DESIGN_APPROVAL` + - 期望 ReAct 收到 `[FAIL] Halted during design approval, design archived` + - 期望 `mempalace` 里设计书**还在**(不删) + +5. **阶段三施工**: + - approve 后日志出现 `plan.log` "Construction progress: N/total"(每 50 块一次) + - 期望最终所有方块放置完成,转 COMPLETED,发 `[OK] Build completed` + +6. **halt at any time**: + - 在阶段三施工中 `/steve halt` + - 期望立刻停止放置,`BuildProject` 转 FAILED + - 期望已放置方块**不撤回**(玩家想撤回用单独命令,本次不做) + +7. **多 Steve 隔离**: + - spawn 两个 Steve,同时下达 build + - 期望每个有独立 `BuildProject.id` + - 期望 `/steve approve` 只作用于"最近且有活跃 BuildProject 的 Steve" + +### 端到端 demo(hackathon 演讲用) + +1. 启动游戏,spawn `Steve-1` +2. `/steve tell Steve 在这建个房子` +3. **截图 1**:聊天栏出现设计书(评审立刻看到"哦它会先告诉我计划") +4. dashboard 点 Approve(或聊天栏 `/steve approve`) +5. **截图 2**:施工进度行实时更新(`plan.log` "Construction progress: N/total") +6. **截图 3**:施工完成时 CONSTRUCTION → COMPLETED 阶段切换事件,方块全部到位 +7. **截图 4**:`mempalace_query wing=built_structures` 列出 `house_1_abc123` +8. 重复 1–7,但这次**不 approve**,改用 `/steve halt` 中止 +9. **截图 5**:halt 后 `BuildProject` 转 FAILED,聊天栏 `Build halted at phase AWAITING_DESIGN_APPROVAL`,`mempalace_query wing=build_designs` 仍能查到设计书 + +## 落地顺序 + +1. 加 `BuildPhase` 枚举 + `BuildProject` 数据类 +2. 扩展 `ActionResult` 加 `Status` 枚举(向后兼容) +3. 写 `BuildDesignFormatter`(纯函数,最容易单测) +4. 写 `PlanBuildAction` 状态机:FEASIBILITY + DESIGN + AWAITING_DESIGN_APPROVAL + CONSTRUCTION + COMPLETED/FAILED,approve 后**直接**进 CONSTRUCTION 自治放方块,无二次确认 +5. `ActionExecutor` 拦截 build + 加回调(`approveCurrentBuild` / `haltCurrentBuild`) +6. `SteveCommands` 加 approve / halt / status 子命令 +7. `PlanDashboardServer` 把状态镜像到 127.0.0.1:8765 的 `/events` / `/command` / `/plan` / `/chat` 端点(React + Three.js 前端) +8. 单测 `BuildDesignFormatter` + `PlanEventJson` + 手工跑 7 个验证(见上文) diff --git a/docs/hackathon/03-module-composition.md b/docs/hackathon/03-module-composition.md new file mode 100644 index 00000000..7a6b6531 --- /dev/null +++ b/docs/hackathon/03-module-composition.md @@ -0,0 +1,308 @@ +# 模块拼装协议 (Lego-style Module Composition) + +> Status: 设计中 · 与 `02-plan-mode.md` (四阶段施工) + `01-mempalace-integration.md` (mempalace 集成) 并列。 + +## Context + +当前 Steve 只能把 NBT 模板沿 +X 串行拼接 —— `PlanBuildAction.runDesign` L168 的 `originX += tpl.width + 1` 是它的全部铺装逻辑。要做高铁、高速公路、长城、运河这类**线状或网状**结构远远不够: + +| 维度 | 现状 | 真正的乐高拼接 | 高铁/高速需求 | +|---|---|---|---| +| 朝向 | 锁死,无 rotation | 任意角度 | 至少 4 个 cardinal | +| 锚点 | 无,所有模块从同 Y 起 | 每个模块自带进/出口 | 必须有"上一块末端"概念 | +| 垂直对齐 | 全局 originY | per-module anchor | 坡道/桥/隧道 | +| 拓扑 | 单链 | 任意 | 直 + 弯 + 分叉 | + +目标:加一个**通用**的模块拼装系统,LLM 用 `{name, dx, dy, dz, facing}` 描述一个模块链,系统自动: + +- 从 bbox 推导每个模块的进/出口 +- 沿上一模块的出口按 `{dx,dz}` 拼装(在上一模块的局部坐标系里) +- 绕 Y 轴 90° 旋转当前模块的局部坐标 +- 用**同一个**坐标变换函数同时算世界方块和 dashboard 预览(零失真) + +向后兼容:旧 `["house_1", "fence"]` 协议继续工作,行为零变化。NBT 文件**完全不用改**(锚点从 bbox 推导)。 + +--- + +## 1. 协议 (LLM → PlanBuildAction) + +```json +{"structures": [ + {"name": "rail_straight_8", "dx": 0, "dy": 0, "dz": 0, "facing": "S"}, + {"name": "rail_curve_90", "dx": 8, "dy": 0, "dz": 0, "facing": "E"} +]} +``` + +字段: + +- `name` (string, required) — NBT 文件 stem,放在 `config/steve/structures/.nbt`。 +- `dx, dy, dz` (int, default 0) — **相对上一模块出口**的偏移,表达在**上一模块的局部坐标系**里(局部坐标系本身已按上一模块的 `facing` 转过)。 +- `facing` (one of `N|E|S|W`, default `S`) — 绕 Y 轴 90° 旋转,作用于本模块的局部坐标。**S = +Z** 对齐 vanilla `StructureTemplate` 默认朝向。 +- `anchor` (optional, **reserved**) — 协议预留,本版本忽略。 + +### LLM 拼装规则 (强制) + +- 任何**非平凡**结构(房子、车站、墙、花园),**必须**用 `structures` 数组形式,通常 ≥3 个 entry —— 一个房子大概需要"主体 + 屋顶 + 门(窗/围栏)"三段以上才算完整。 +- **不要**在 `structures` 数组里只发单个 entry,除非玩家明确指定"放一个 ``"(如 `放 房子_1`)。 +- 长线状结构(铁轨、高速公路、长城、运河)**必须**用 `structures` 数组,每段一个 piece,允许在弯头处用不同 `facing` 拼出折线。 +- 不知道怎么分时,按"主体 → 顶/盖 → 入口/装饰"三段写。 + +### 历史备注 + +早期 `["house_1", "fence"]` 旧协议在新版本中已废弃(`PlanBuildAction` 不再接受),所有 LLM 输出和 mempalace 存档一律用新协议。 + +--- + +## 2. 锚点约定 (NBT 创作) + +**关键:NBT 文件里不存锚点信息,锚点从 bbox 几何推导。** 作者只需要按下列规则摆结构块。 + +### 局部原点 + +NBT bbox 的 min corner 是局部 `(0, 0, 0)`。**入口**默认在该 corner 处,**出口**在 bbox 的某个面中心。 + +### 出口位置(per facing) + +| facing | 出口位置 (local) | 含义 | +|--------|------------------|------| +| `S` (+Z) | `(width/2, 0, depth)` | +Z 面底边中点 | +| `N` (−Z) | `(width/2, 0, 0)` | −Z 面底边中点 | +| `E` (+X) | `(width, 0, depth/2)` | +X 面底边中点 | +| `W` (−X) | `(0, 0, depth/2)` | −X 面底边中点 | + +**直道**:8 格宽 × 1 格高 × 8 格深的轨道,入口在 `(0,0,0)`,出口在 `(4, 0, 8)`(S facing)。 + +**90° 转弯**:进/出口在两个相邻面。比如"从南进、从东出",bbox 8×8(正方形),入口 `(4, 0, 0)`(S face),出口 `(8, 0, 4)`(E face)。 + +### NBT 创作 5 步走 + +1. 在 superflat 创意世界里,放一个 structure block,准备构建 piece。 +2. 摆方块,使 **入口**在 bbox min corner 的某个面上,**出口**在另一个面(可以相同面 —— 直道)。 +3. 命名 + 保存:structure block "Save" → 得到 `.nbt`。 +4. 放进 `config/steve/structures/_.nbt`。`_*` 前缀自动让 mempalace 把模板注册到 `wing=structure_`(沿用 `01-mempalace-integration.md` 1.3 节的约定),比如 `rail_curve_90.nbt` → `structure_rail` wing。 +5. LLM 现在可以这样请求: + ```json + {"structures":[ + {"name":"rail_curve_90","dx":8,"dy":0,"dz":0,"facing":"E"} + ]} + ``` + 表示"从上一块出口向东 8 格放一个 90° 转弯"。 + +### 命名建议 + +`` 用单数名词,描述**结构族**而非**单个 piece**: + +- `rail_straight_8`, `rail_straight_16`, `rail_curve_90`, `rail_switch`, `rail_station_2car` +- `highway_lane_straight`, `highway_onramp`, `highway_offramp`, `highway_interchange_4way` +- `wall_corner`, `wall_gate`, `wall_battlement` +- `canal_lock`, `canal_bend` + +--- + +## 3. 旋转与坐标变换 + +整个系统**唯一**的旋转/偏移源是 `ModuleTransform.apply(BlockPos rel, BlockPos origin, Facing f)`: + +```java +public static BlockPos apply(BlockPos rel, BlockPos origin, Facing f) { + int x = rel.getX(), y = rel.getY(), z = rel.getZ(), rx, rz; + switch (f) { + case S: rx = x; rz = z; break; // identity + case W: rx = z; rz = -x; break; // -90° + case N: rx = -x; rz = -z; break; // 180° + case E: rx = -z; rz = x; break; // +90° + } + return origin.add(rx, y, rz); +} +``` + +旋转规则(Y 轴向上, N=−Z, E=+X, S=+Z, W=−X): + +| facing | (x, y, z) → (x', y', z') | 说明 | +|--------|--------------------------|------| +| S | ( x, y, z) | identity | +| W | ( z, y, -x) | -90° | +| N | (-x, y, -z) | 180° | +| E | (-z, y, x) | +90° | + +**为什么这是关键设计**:把"怎么旋转"集中到一个文件,所有消费方都通过这里计算世界坐标。这意味着 3D dashboard 预览和实际放置**永远不可能错位** —— 任何 bug 都只能在这一个地方出现一次。 + +--- + +## 4. 数据模型 + +### 新增 `PlacedModule` (`com.steve.ai.structure`) + +```java +public final class PlacedModule { + public final LoadedTemplate template; + public final BlockPos worldOrigin; // 模块入口在世界里的坐标(已应用 facing) + public final PlacedModule.Facing facing; + + public enum Facing { N, E, S, W } +} +``` + +### `BuildProject` 字段替换 + +| 旧 | 新 | +|----|----| +| `List templates` | `List placedModules` | +| `int currentTemplateIndex` | `int currentModuleIndex` | + +其它字段(`originPos`, `materials`, `nextBlockIndex`, `blocksPlaced`, `totalBlocks`)不动。 + +### `Task.java` — 新 accessor + +```java +public List> getModuleListParameter(String key) +``` + +返回 `List>`,跟 `getStringListParameter` 并存。`PlanBuildAction` 优先用新接口;若返回空再 fallback 到旧接口并自动转新协议。 + +--- + +## 5. 拼装算法 (`PlanBuildAction.runDesign` 替换 L154-169) + +``` +cursor = project.getOriginPos() // 从玩家 look-target 算 (见 L142) +prevExit = cursor // "上一块末端"初始化为项目原点 +prevFacing = PlacedModule.Facing.S + +for spec in moduleSpecs: // 旧协议已自动转新协议 + tpl = StructureTemplateLoader.loadFromNBT(level, spec.name) + facing = spec.facing ?: S + localIn = BlockPos(spec.dx, spec.dy, spec.dz) + + worldIn = ModuleTransform.apply(localIn, prevExit, prevFacing) + project.placedModules.add(new PlacedModule(tpl, worldIn, facing)) + + prevExit = ModuleTransform.apply( + ModuleTransform.exitAnchor(tpl, facing), worldIn, facing) + prevFacing = facing + +project.totalBlocks = sum(m.template.blocks.size() for m in placedModules) +emit PlanDesignReadyEvent(...) // 这里也走 ModuleTransform.apply +``` + +`placeNextBlock()` (L258-274) 和 `buildSnapshot()` (L470-510) 改遍历 `project.getPlacedModules()`,每个 block 用 `ModuleTransform.apply(tb.relativePos, m.worldOrigin, m.facing)` 算世界坐标。**两处代码完全镜像**。 + +--- + +## 6. 改动文件清单 + +| 路径 | 改动 | +|------|------| +| `src/main/java/com/steve/ai/structure/PlacedModule.java` | **新增** — `PlacedModule` + `Facing` | +| `src/main/java/com/steve/ai/structure/ModuleTransform.java` | **新增** — 唯一旋转源 | +| `src/main/java/com/steve/ai/action/Task.java` | 加 `getModuleListParameter(String)` | +| `src/main/java/com/steve/ai/action/BuildProject.java` | `templates` / `currentTemplateIndex` → `placedModules` / `currentModuleIndex` | +| `src/main/java/com/steve/ai/llm/ResponseParser.java` | `extractValue` 加 JsonArray-of-JsonObject 分支 | +| `src/main/java/com/steve/ai/llm/PromptBuilder.java` | 系统 + ReAct prompt 改 doc,加新示例 | +| `src/main/java/com/steve/ai/action/actions/PlanBuildAction.java` | `runDesign` 改 L154-202;`placeNextBlock` 改 L258-274;构造函数加旧协议转新 | +| `src/main/java/com/steve/ai/dashboard/PlanDashboardServer.java` | `buildSnapshot` L470-510 改遍历 `placedModules` + 走 helper | +| `src/test/java/com/steve/ai/structure/ModuleTransformTest.java` | **新增** — 32 个 rotation 用例 + 4 个 exitAnchor 用例 | + +--- + +## 7. Verification + +### 单元 + +`ModuleTransformTest`: +- 4 facings × 8 unit vectors = **32 个** rotation 用例 +- 4 个 exitAnchor 用例(对照 §2 表格) +- 2 个兼容用例(旧 `["a","b"]` 字符串列表自动转新协议,跟手写新协议**字节级一致**) + +### 端到端 Minecraft + +**准备**(临时手作): +- `config/steve/structures/rail_straight_8.nbt` — 8 格直道,8×1×8 bbox +- `config/steve/structures/rail_curve_90.nbt` — 8×8 转弯,进 S 出 E + +**跑**: +``` +/steve dashboard +# 浏览器开 http://localhost:5173 +/steve plan "build a high speed rail demo" +``` + +**期望 LLM 输出**(mock 或真实): +```json +{"action":"build","structures":[ + {"name":"rail_straight_8","facing":"S"}, + {"name":"rail_straight_8","dx":8,"facing":"S"}, + {"name":"rail_curve_90", "dx":8,"facing":"E"}, + {"name":"rail_straight_8","dx":8,"facing":"E"} +]} +``` + +**期望**: +1. dashboard 3D 预览: 4 段轨道,先 +Z 0–16,转弯,+X 16–24 +2. `/steve approve` 后世界里出现完全相同的 4 段 +3. 走到每个弯点目视验证方向正确(无镜像错位) +4. dashboard 像素级 ≈ 世界位置 + +### 端到端 plan-mode 验证(plan-mode augmentation 是 ≥2 规则与新协议的权威来源) + +``` +/steve plan "建个小屋" +# 期望 LLM 输出 (新协议 + ≥2 entries): +# {"structures": [ +# {"name": "房子_主体", "facing": "S"}, +# {"name": "房子_屋顶", "dx": 0, "dy": 6, "facing": "S"} +# ]} +/steve approve +# 期望: 两段 piece 依次放置,完整房子 + +# 玩家明确单 piece 请求(合法路径未破坏): +/steve plan "放 房子_1" +# LLM 发单 entry: {"structures": [{"name": "房子_1"}]} -> 系统接受 -> 1 piece 设计书 +``` + +> Plan-mode augmentation is the canonical source for the ≥2-entry rule and the new map-array protocol; see `ActionExecutor.startPlannedBuild` (`src/main/java/com/steve/ai/action/ActionExecutor.java:408-417`). +> +> 旧 `["house_1"]` 字符串协议已废弃 — `Task.getModuleListParameter` 看到 `List` 返回 `null`,LLM 发老协议会得到 "None of the requested NBT templates could be loaded" 失败。不再兼容。 + +--- + +## 8. 不做什么 + +- 中段插入/extend API(LLM 想加模块,撤回重建) +- 任意角度(只 4 个 cardinal) +- X/Z 轴旋转(只 Y) +- B-spline / 曲线拟合 +- NBT 创作工具(继续用 vanilla structure block + MCEdit) +- per-module 动态缩放、自动地形适配 +- `anchor` 字段 override(协议预留,本版本忽略) +- per-module metadata(战利品/红石) + +--- + +## 9. 复用清单(不要重新实现) + +| 已有 | 路径 | 怎么用 | +|------|------|--------| +| `StructureTemplateLoader.loadFromNBT(name)` | `structure/StructureTemplateLoader.java` | runDesign 加载每个 NBT | +| `LoadedTemplate.blocks` / `width/height/depth` | 同上 | exitAnchor + blocks 遍历 | +| `BlockPos` 不可变 3-int | vanilla | 整条协议用 | +| `PlanDashboardServer.buildSnapshot()` | `dashboard/PlanDashboardServer.java` | 改遍历 `placedModules` 即可,无需新接口 | +| `mempalace_add_drawer` wing `structure_` | `mcp/MCPToolRegistry.java` | 模板注册零改动,`rail_*` 仍进 `structure_rail` | +| `PlanBuildAction.runDesign` L129-214 整体架构 | 同上 | 只换 L154-169 那个循环,其它(design doc 输出、approve 流、archive)不动 | +| `ResponseParser.extractValue` JsonArray 分支 | `llm/ResponseParser.java` | 加一个 "第一个元素是 JsonObject 且有 name" 的判别子分支 | +| `PromptBuilder` 的 "build action" doc 段 | `llm/PromptBuilder.java` | 改两段,加新示例,旧示例保留 | + +--- + +## 10. 落地顺序 + +1. `PlacedModule` + `Facing` enum(纯数据,最简单) +2. `ModuleTransform.apply` + `exitAnchor` + 单元测试(旋转数学独立可测) +3. `Task.getModuleListParameter` + `ResponseParser` 加分支 +4. `BuildProject` 字段替换 + 所有调用点跟改 +5. `PlanBuildAction.runDesign` 改写 + 构造函数加旧协议转新 +6. `placeNextBlock` + `buildSnapshot` 改走 `ModuleTransform.apply` +7. `PromptBuilder` 改两段 prompt doc +8. 端到端 Minecraft 测试(高铁 demo) +9. 端到端 plan-mode 验证(新协议 + ≥2 entries, 见 §7 端到端块) +10. 把本文件挪到 `docs/hackathon/03-module-composition.md`,更新 `施工流程.md` 末尾的链接表 diff --git "a/docs/hackathon/\346\226\275\345\267\245\346\265\201\347\250\213.md" "b/docs/hackathon/\346\226\275\345\267\245\346\265\201\347\250\213.md" new file mode 100644 index 00000000..7f0652a8 --- /dev/null +++ "b/docs/hackathon/\346\226\275\345\267\245\346\265\201\347\250\213.md" @@ -0,0 +1,196 @@ +📝 阶段一:项目前期 +这个阶段的目标是将建设需求从概念变为一份严谨的可行性文件。 + +开展规划研究:根据国家及省级的《公路网规划》确定项目,编制《工程可行性研究报告》。这份报告是项目立项的核心依据,需要全面论证项目的必要性、技术可行性和经济合理性,通常分为预可研和工可研两步。 + +获取"准生证"(立项批复):政府投资类项目向发改部门报批项目建议书和可研报告;企业投资类项目则办理项目申请报告的核准。要拿到这个"准生证",通常需要前置的专项评估批复作为支撑。 + +同步推进"三大项"前置审批:在等待可研批复的同时,必须交叉推进一系列至关重要的专项评估和审批: + +用地预审与规划选址:向自然资源部门申请,初步确定项目是否符合土地和城乡规划,会占用多少土地。 + +环境影响评价(环评):向生态环境部门提交报告,评估项目对自然生态、水、气、声等环境的影响,并制定防治措施。 + +其他专项评估:包括水土保持、压覆矿产、地质灾害、防洪评价、文物调查等,这些专业报告也需获得主管部门批复。 + +🚧 阶段二:勘察设计与施工准备 +拿到"准生证"后,工程便进入精确设计和进场施工的准备环节。 + +两阶段精确设计:这个阶段将路线方案精确到可以施工的程度。首先是初步设计,确定技术方案和概算,明确桥梁、隧道等关键节点;接着是施工图设计,精确到每个桩位和钢筋用量,并据此编制预算。 + +办理建设用地(征地拆迁):这是整个过程中最复杂和耗时的环节之一,涉及大量群众工作,通常需要: + +土地报批:完成详细的土地勘测定界,并将所有材料组卷上报至自然资源部门乃至国务院审批。 +征地拆迁:获批后,发布征地公告,与被征地方签订补偿协议,支付补偿款,并组织实施红线内的房屋拆除和管线迁改工作。 +招标与施工许可:采用公开招标等方式,选择有实力的施工单位、监理单位和材料供应商。在完成征地拆迁和施工图审批后,向交通主管部门申请,正式获得施工许可。 + +🏗️ 阶段三:建设实施 +得到施工许可后,高速公路便进入热火朝天的实体建造阶段。 + +先行施工准备:在正式开工前,首先进行清表、修建施工便道、搭建项目驻地,并建设混凝土搅拌站、钢筋加工场等临时设施,为大规模施工创造条件。 + +建设"骨架":路基与桥梁隧道 + +路基工程:高速公路的"地基和身体",主要进行挖方(移除多余土石方)和填方(构筑路堤),常采用"三阶段、四区段、八流程"的标准化工艺进行填筑,确保路基的稳固。 + +桥涵工程:属于技术难度最高的环节。会根据地质情况采用旋挖钻或冲击钻进行桩基施工;针对跨河或跨山谷的特大桥,常会采用挂篮悬浇工艺来一段段"吐出"桥梁节段。 + +隧道工程:在一些山岭重丘区,需要开凿隧道。施工时会综合运用光面爆破、管棚支护、湿喷混凝土等多种工艺来安全成洞。 + +铺设"血肉":路面与附属设施 + +路面铺筑:在成型路基上,由下至上分层铺设垫层、底基层、基层和面层(即我们开车接触的沥青或水泥路面),各层对材料和工艺均有严格标准。 + +交通安全设施:在路面工程后半段,同步安装波形护栏、交通标志、标线、隔离栅和防眩板等设施,为未来的行车安全提供保障。 + +打造"大脑":机电与房建工程 + +机电工程:包括收费系统、通信系统、监控系统和供配电照明系统。这些是保障高速公路安全、高效运营的"神经系统和大脑"。 + +房建工程:建设沿线的服务区、收费站、管理中心等建筑设施,为司乘人员和管理人员提供必要的工作与休息场所。 + +✅ 阶段四:验收与运营 +工程结束后,需要经过严格的检验,才能正式向社会开放。 + +专业检测(交工验收):由项目法人组织,对各合同段的工程质量进行全面检查和评定。通过后,标志着工程具备了试运营条件。 + +试运营与审计:交工验收合格后,项目进入试运营阶段。同时,启动工程决算和财务决算审计,进行项目后评价,评估其实际效益。 + +最终验收(竣工验收):在试运营期(通常为2年)结束后,由国家或省级主管部门组织最终的竣工验收,是对整个项目的全面考核。 + +正式通车运营:通过竣工验收后,项目完成全部建设程序,正式移交给运营管理单位,进入长期的收费、养护和服务阶段。 + +📌 关键审批流程协同与提速方式 +高速公路建设的审批环节非常多,且环环相扣。为了提高效率,实践中常采用“并联报批”和“交叉跟进”等模式。此外,现代化的管理手段也应用在建设中,例如通过BIM建筑信息模型提前进行三维模拟施工,精确定位问题,减少返工;以及采用无人机航拍、传感器等物联网技术实时监控工程进度和质量。 + +高速公路的建设是一个多阶段、多专业协同的系统工程,它不仅是土木工程的奇迹,更是国家科学决策、严格监管和无数建设者辛勤付出的体现。 + +--- + +# 外部 HTML Plan Dashboard + +Steve 的 plan-then-build 流程(`/steve plan` → `AWAITING_DESIGN_APPROVAL` → +`/steve approve` / `/steve halt`)除了在游戏内聊天里显示设计书外,还可以通过 +mod 内嵌的 HTTP server 把流程镜像到一个独立的 React + Three.js 页面。 + +> 注:上面 1-66 行是早期写的高速公路施工流程示例,与本节无关;本节才是当前 +> Steve 实现的真实 plan 流程说明。 + +## 架构 + +``` +PlanBuildAction + │ publish(PlanCreatedEvent / PlanDesignReadyEvent / PlanPhaseChangedEvent + │ / PlanApprovedEvent / PlanHaltedEvent / PlanLogEvent) + ▼ +SteveMod.getPlanEventBus() (com.steve.ai.event.EventBus) + │ subscribe + forward + ▼ +PlanDashboardServer (HTTP, 127.0.0.1:8765) + │ GET /events (text/event-stream) + │ POST /command {action, projectId} CORS: http://localhost:5173 + ▼ +Vite dev server (localhost:5173) /events, /command → proxy → 8765 + │ + ▼ +React + Three.js (web/) + ├── Phase badge / materials / timeline / Approve / Halt + └── Three.js InstancedMesh (per blockId) + orbit controls + grid +``` + +## 启动 + +1. **mod 端**:在 Minecraft 中执行 `/steve dashboard` — 启动嵌入式 HTTP server, + 聊天打印 `Plan dashboard: http://127.0.0.1:8765/`。关闭:`/steve dashboard stop`。 +2. **前端**:在 `web/` 下跑 + ```bash + cd web + npm install + npm run dev + # → http://localhost:5173 + ``` + Vite proxy 把 `/events` 和 `/command` 转发到 127.0.0.1:8765。Mod 也加了 + CORS header,**直接**从 5173 调 mod 也行(即使没走 Vite proxy)。 +3. mod **不会**自动开浏览器;端口在配置文件的 `dashboard.port`(默认 8765)。 + +## 事件类型 + +| 事件 | 触发点 | 关键字段 | +| --- | --- | --- | +| `PlanCreatedEvent` | `PlanBuildAction` 构造完成 | projectId, steveName, command, templates, phase | +| `PlanDesignReadyEvent` | `runDesign()` 生成设计书后 | design (fullDesign 文本), materials, totalBlocks, **blocks** (x,y,z,blockId) | +| `PlanPhaseChangedEvent` | `transitionTo(BuildPhase)` | prev, next, deadlineMs? | +| `PlanApprovedEvent` | `approve()` | phase, approvedBy | +| `PlanHaltedEvent` | `halt(reason)` | reason, mempalaceRef, blocksPlaced, totalBlocks | +| `PlanLogEvent` | 关键日志镜像 | severity, message | + +新连接打开时会先收到一个 `snapshot` 事件,包含当前活跃 BuildProject 的所有字段 +**和 blocks 列表**(这样 Three.js 可以立刻开始渲染,不用等下一个 design_ready)。 + +`blocks` 字段是 `(x, y, z, blockId)` 四元组的列表。`x/y/z` 是世界坐标 +(按 origin 偏移过),`blockId` 是 `minecraft:oak_planks` 这种 namespace:path。 + +## 文件位置 + +- Java(事件 + bus + dashboard server): + - `src/main/java/com/steve/ai/event/plan/` — 6 个事件 POJO + `PlanEvent` 标记接口 + - `src/main/java/com/steve/ai/dashboard/` — `PlanDashboardServer` + `PlanEventJson` + - `src/main/java/com/steve/ai/SteveMod.java` — `getPlanEventBus()` / `subscribeToAllPlanEvents` / dashboardServer 字段 + - `src/main/java/com/steve/ai/command/SteveCommands.java` — `/steve dashboard` / `/steve dashboard stop` + - `src/main/java/com/steve/ai/config/SteveConfig.java` — `dashboard.port` (默认 8765) +- 前端(独立 Vite 项目): + - `web/package.json` / `vite.config.ts` / `tsconfig.json` / `index.html` + - `web/src/main.tsx` — React 入口 + - `web/src/App.tsx` — 布局:3D canvas + 侧栏 + - `web/src/hooks/usePlanStore.ts` — SSE 订阅 + reducer + - `web/src/components/Structure3D.tsx` — Three.js InstancedMesh 渲染器 + 自带 orbit 控件 + - `web/src/lib/types.ts` — 跟 `PlanEventJson` 输出对齐的 TypeScript 类型 + - `web/src/styles.css` — 暗玻璃主题 + - `web/README.md` +- 测试:`src/test/java/com/steve/ai/dashboard/PlanEventJsonTest.java` + +## 已知限制 + +- `CONSTRUCTION` 阶段已实现:approve 后 `PlanBuildAction.runConstruction` 直接接管 + 方块放置,每 `BUILD_TICK_DELAY` tick 放一块(默认 20 tick = 1 秒一块)。被占用 / 超出 + 6 格距离的方块会被跳过;完成时 transition 到 COMPLETED。 +- `AWAITING_ACCEPTANCE` 枚举值保留以保持源码兼容,但当前 dashboard-approve 流程 + 不再进入——前端点击 approve 后**直接**施工,无二次确认。 +- 一次只看一个 project:UI 无项目切换器;同时只可能有一个活跃 BuildProject + (`ActionExecutor` 持有),切换 Steve 也只是换一个 project id。 +- 3D 预览是占位配色:方块颜色由 `Structure3D.colorForBlockId` 用 blockId 的 + 哈希转 HSL 生成,**不**对应真实 Minecraft 材质;后续可加 block→color 表或 + 贴 atlas。 +- Vite proxy 默认指向 `127.0.0.1:8765`;如果改了 `dashboard.port` 要同步 + `web/vite.config.ts`。 +- 不自动开浏览器:避免 headless 多人服场景下弹出无用窗口。 + +## Verification + +```bash +# 1. 编译 mod +./gradlew build + +# 2. 跑 plan dashboard 单元测试 +./gradlew test --tests "com.steve.ai.dashboard.*" + +# 3. 启动前端 +cd web && npm install && npm run dev +# → http://localhost:5173 + +# 4. 端到端手测 +./gradlew runClient +# 进游戏后: +/steve spawn Steve1 +/steve dashboard +# 浏览器开 http://localhost:5173 +/steve plan "建一个小屋" +# → 浏览器应依次看到: +# snapshot (含 blocks) → plan.created → plan.phase_changed (DESIGN) +# → plan.design_ready (含 blocks + materials) → plan.phase_changed +# (AWAITING_DESIGN_APPROVAL) +# → 3D 画布按 blockId 渲染出 InstancedMesh 建筑,左键拖动旋转,滚轮缩放 +# 点 Approve → 聊天 "Approved build for Steve1" +# 点 Halt → 聊天 "Halted build for Steve1..." +``` + diff --git a/gradle.properties b/gradle.properties index 4871ce0f..b3eb5a90 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,6 @@ org.gradle.jvmargs=-Xmx3G org.gradle.daemon=false - +systemProp.http.proxyHost=127.0.0.1 +systemProp.http.proxyPort=7897 +systemProp.https.proxyHost=127.0.0.1 +systemProp.https.proxyPort=7897 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3782ddb8..17c9f7a5 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.4-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle b/settings.gradle index 5ce3fe0b..34fa2a00 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,13 +1,10 @@ pluginManagement { repositories { gradlePluginPortal() + maven { url = 'https://lss233.littleservice.cn/repositories/minecraft/' } maven { url = 'https://maven.minecraftforge.net/' } } } -plugins { - id 'org.gradle.toolchains.foojay-resolver-convention' version '0.5.0' -} - rootProject.name = 'steve' diff --git a/shoot-demo.py b/shoot-demo.py new file mode 100644 index 00000000..0877a520 --- /dev/null +++ b/shoot-demo.py @@ -0,0 +1,24 @@ +"""Capture a screenshot of the dashboard in demo mode for visual inspection.""" + +import asyncio +import sys +from playwright.async_api import async_playwright + +URL = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:5173/?demo=1" +OUT = sys.argv[2] if len(sys.argv) > 2 else r"C:\Users\LuZhong\Documents\Github\Steve\demo-shot.png" + +async def main(): + async with async_playwright() as p: + browser = await p.chromium.launch( + headless=True, + executable_path=r"C:\Users\LuZhong\AppData\Local\ms-playwright\chromium-1208\chrome-win64\chrome.exe", + ) + context = await browser.new_context(viewport={"width": 1280, "height": 800}) + page = await context.new_page() + await page.goto(URL, wait_until="networkidle") + await page.wait_for_timeout(2500) + await page.screenshot(path=OUT, full_page=False) + print("Saved", OUT) + await browser.close() + +asyncio.run(main()) diff --git a/src/main/java/com/steve/ai/SteveMod.java b/src/main/java/com/steve/ai/SteveMod.java index 2c139ea0..06e63292 100644 --- a/src/main/java/com/steve/ai/SteveMod.java +++ b/src/main/java/com/steve/ai/SteveMod.java @@ -3,12 +3,20 @@ import com.mojang.logging.LogUtils; import com.steve.ai.command.SteveCommands; import com.steve.ai.config.SteveConfig; +import com.steve.ai.dashboard.PlanDashboardServer; import com.steve.ai.entity.SteveEntity; import com.steve.ai.entity.SteveManager; +import com.steve.ai.event.EventBus; +import com.steve.ai.event.SimpleEventBus; +import com.steve.ai.event.plan.PlanEvent; +import com.steve.ai.mcp.MCPToolRegistry; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MobCategory; import net.minecraftforge.common.MinecraftForge; import net.minecraftforge.event.RegisterCommandsEvent; +import net.minecraftforge.event.server.ServerStartingEvent; +import net.minecraftforge.event.server.ServerStoppingEvent; +import net.minecraftforge.event.entity.player.PlayerEvent; import net.minecraftforge.event.entity.EntityAttributeCreationEvent; import net.minecraftforge.eventbus.api.IEventBus; import net.minecraftforge.eventbus.api.SubscribeEvent; @@ -22,6 +30,9 @@ import net.minecraftforge.registries.RegistryObject; import org.slf4j.Logger; +import java.util.List; +import java.util.function.Consumer; + @Mod(SteveMod.MODID) public class SteveMod { public static final String MODID = "steve"; @@ -38,6 +49,10 @@ public class SteveMod { private static SteveManager steveManager; + private static final EventBus PLAN_BUS = new SimpleEventBus(); + private static PlanDashboardServer dashboardServer; + private static net.minecraft.server.MinecraftServer server; + public SteveMod() { IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus(); @@ -65,8 +80,68 @@ private void entityAttributes(EntityAttributeCreationEvent event) { @SubscribeEvent public void onCommandRegister(RegisterCommandsEvent event) { SteveCommands.register(event.getDispatcher()); } + @SubscribeEvent + public void onServerStarting(ServerStartingEvent event) { + MCPToolRegistry.init(); + server = event.getServer(); + } + + @SubscribeEvent + public void onServerStopping(ServerStoppingEvent event) { + if (dashboardServer != null) { + try { + dashboardServer.stop(); + } catch (Exception e) { + LOGGER.warn("Failed to stop plan dashboard server: {}", e.getMessage()); + } + dashboardServer = null; + } + server = null; + } + + /** Active {@link net.minecraft.server.MinecraftServer}, or null if no + * server is running. Held so the dashboard HTTP handler (which runs on + * the HttpServer's executor threads) can hop back to the main server + * thread before touching {@link SteveEntity} state. */ + public static net.minecraft.server.MinecraftServer getServer() { + return server; + } + public static SteveManager getSteveManager() { return steveManager; } + + /** Global event bus for {@link PlanEvent}s. Independent of the per-entity + * bus in {@code ActionExecutor} because a plan is global across all Steves. */ + public static EventBus getPlanEventBus() { + return PLAN_BUS; + } + + /** Subscribe a single consumer to every concrete {@link PlanEvent} subtype. + * {@code SimpleEventBus} dispatches by exact runtime class, so we register + * one subscription per concrete class. Returns a list of {@link com.steve.ai.event.EventBus.Subscription}s + * for unsubscribing. */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static List subscribeToAllPlanEvents(Consumer consumer) { + return List.of( + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanCreatedEvent.class, (com.steve.ai.event.plan.PlanCreatedEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanDesignReadyEvent.class, (com.steve.ai.event.plan.PlanDesignReadyEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanPhaseChangedEvent.class, (com.steve.ai.event.plan.PlanPhaseChangedEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanApprovedEvent.class, (com.steve.ai.event.plan.PlanApprovedEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanHaltedEvent.class, (com.steve.ai.event.plan.PlanHaltedEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanLogEvent.class, (com.steve.ai.event.plan.PlanLogEvent e) -> consumer.accept(e)), + PLAN_BUS.subscribe(com.steve.ai.event.plan.PlanChatEvent.class, (com.steve.ai.event.plan.PlanChatEvent e) -> consumer.accept(e)) + ); + } + + /** Returns the active {@link PlanDashboardServer}, or null if not started. */ + public static PlanDashboardServer getDashboardServer() { + return dashboardServer; + } + + /** Called by {@code /steve dashboard} to set/clear the active server. */ + public static void setDashboardServer(PlanDashboardServer server) { + dashboardServer = server; + } } diff --git a/src/main/java/com/steve/ai/action/ActionExecutor.java b/src/main/java/com/steve/ai/action/ActionExecutor.java index 830081fb..0ad37c1c 100644 --- a/src/main/java/com/steve/ai/action/ActionExecutor.java +++ b/src/main/java/com/steve/ai/action/ActionExecutor.java @@ -6,17 +6,20 @@ import com.steve.ai.di.SimpleServiceContainer; import com.steve.ai.event.EventBus; import com.steve.ai.event.SimpleEventBus; +import com.steve.ai.event.plan.PlanChatEvent; import com.steve.ai.execution.*; +import com.steve.ai.llm.PromptBuilder; import com.steve.ai.llm.ResponseParser; import com.steve.ai.llm.TaskPlanner; import com.steve.ai.config.SteveConfig; import com.steve.ai.entity.SteveEntity; +import com.steve.ai.llm.react.ReActAgent; import com.steve.ai.plugin.ActionRegistry; import com.steve.ai.plugin.PluginManager; import java.util.LinkedList; +import java.util.Map; import java.util.Queue; -import java.util.concurrent.CompletableFuture; /** * Executes actions for a Steve entity using the plugin-based action system. @@ -34,32 +37,28 @@ public class ActionExecutor { private final SteveEntity steve; private TaskPlanner taskPlanner; // Lazy-initialized to avoid loading dependencies on entity creation - private final Queue taskQueue; private BaseAction currentAction; private String currentGoal; private int ticksSinceLastAction; private BaseAction idleFollowAction; // Follow player when idle - // NEW: Async planning support (non-blocking LLM calls) - private CompletableFuture planningFuture; - private boolean isPlanning = false; - private String pendingCommand; // Store command while planning - // NEW: Plugin architecture components private final ActionContext actionContext; private final InterceptorChain interceptorChain; private final AgentStateMachine stateMachine; private final EventBus eventBus; + // ReAct mode state + private ReActAgent reactAgent; + private final Queue pendingCommands = new LinkedList<>(); + private Map reactBaseParams; + public ActionExecutor(SteveEntity steve) { this.steve = steve; this.taskPlanner = null; // Will be initialized when first needed - this.taskQueue = new LinkedList<>(); this.ticksSinceLastAction = 0; this.idleFollowAction = null; - this.planningFuture = null; - this.pendingCommand = null; // Initialize plugin architecture components this.eventBus = new SimpleEventBus(); @@ -110,168 +109,85 @@ private TaskPlanner getTaskPlanner() { * @param command The natural language command from the user */ public void processNaturalLanguageCommand(String command) { - SteveMod.LOGGER.info("Steve '{}' processing command (async): {}", steve.getSteveName(), command); - - // If already planning, ignore new commands - if (isPlanning) { - SteveMod.LOGGER.warn("Steve '{}' is already planning, ignoring command: {}", steve.getSteveName(), command); - sendToGUI(steve.getSteveName(), "Hold on, I'm still thinking about the previous command..."); - return; - } - - // Cancel any current actions - if (currentAction != null) { - currentAction.cancel(); - currentAction = null; - } - - if (idleFollowAction != null) { - idleFollowAction.cancel(); - idleFollowAction = null; - } + SteveMod.LOGGER.info("Steve '{}' received command: {}", steve.getSteveName(), command); - try { - // Store command and start async planning - this.pendingCommand = command; - this.isPlanning = true; + pendingCommands.add(command); + SteveMod.LOGGER.info("Steve '{}' queued command (queue size: {}): {}", + steve.getSteveName(), pendingCommands.size(), command); - // Send immediate feedback to user + if (reactAgent == null && currentAction == null) { sendToGUI(steve.getSteveName(), "Thinking..."); - - // Start async LLM call - returns immediately! - planningFuture = getTaskPlanner().planTasksAsync(steve, command); - - SteveMod.LOGGER.info("Steve '{}' started async planning for: {}", steve.getSteveName(), command); - - } catch (NoClassDefFoundError e) { - SteveMod.LOGGER.error("Failed to initialize AI components", e); - sendToGUI(steve.getSteveName(), "Sorry, I'm having trouble with my AI systems!"); - isPlanning = false; - planningFuture = null; - } catch (Exception e) { - SteveMod.LOGGER.error("Error starting async planning", e); - sendToGUI(steve.getSteveName(), "Oops, something went wrong!"); - isPlanning = false; - planningFuture = null; + drainNextCommand(); + } else { + sendToGUI(steve.getSteveName(), + "Got it, will do after current task (queue: " + pendingCommands.size() + ")"); } } - /** - * Legacy synchronous command processing (blocking). - * - *

Warning: This method blocks the game thread for 30-60 seconds during LLM calls. - * Use {@link #processNaturalLanguageCommand(String)} instead for non-blocking execution.

- * - * @param command The natural language command - * @deprecated Use {@link #processNaturalLanguageCommand(String)} instead - */ - @Deprecated - public void processNaturalLanguageCommandSync(String command) { - SteveMod.LOGGER.info("Steve '{}' processing command (SYNC - blocking!): {}", steve.getSteveName(), command); - - if (currentAction != null) { - currentAction.cancel(); - currentAction = null; - } - - if (idleFollowAction != null) { - idleFollowAction.cancel(); - idleFollowAction = null; - } - - try { - // BLOCKING CALL - freezes game for 30-60 seconds! - ResponseParser.ParsedResponse response = getTaskPlanner().planTasks(steve, command); - - if (response == null) { - sendToGUI(steve.getSteveName(), "I couldn't understand that command."); - return; - } - - currentGoal = response.getPlan(); - steve.getMemory().setCurrentGoal(currentGoal); - - taskQueue.clear(); - taskQueue.addAll(response.getTasks()); - - if (SteveConfig.ENABLE_CHAT_RESPONSES.get()) { - sendToGUI(steve.getSteveName(), "Okay! " + currentGoal); - } - } catch (NoClassDefFoundError e) { - SteveMod.LOGGER.error("Failed to initialize AI components", e); - sendToGUI(steve.getSteveName(), "Sorry, I'm having trouble with my AI systems!"); + private void drainNextCommand() { + String next = pendingCommands.poll(); + if (next == null) { + return; } - - SteveMod.LOGGER.info("Steve '{}' queued {} tasks", steve.getSteveName(), taskQueue.size()); + currentGoal = next; + steve.getMemory().setCurrentGoal(currentGoal); + + reactBaseParams = getTaskPlanner().buildReActParams(); + String provider = SteveConfig.AI_PROVIDER.get().toLowerCase(); + reactAgent = new ReActAgent(steve, next, + SteveConfig.REACT_MAX_STEPS.get(), + SteveConfig.REACT_OBS_TRUNCATE.get(), + SteveConfig.REACT_FAIL_TOLERANCE.get()); + + SteveMod.LOGGER.info("Steve '{}' starting ReAct agent for: {}", steve.getSteveName(), next); + reactAgent.startAsync(getTaskPlanner().getAsyncClient(provider), reactBaseParams); } - + /** * Send a message to the GUI pane (client-side only, no chat spam) */ private void sendToGUI(String steveName, String message) { - if (steve.level().isClientSide) { - com.steve.ai.client.SteveGUI.addSteveMessage(steveName, message); - } + // The chat surface lives in the browser dashboard now. Forward the line + // through the plan event bus so the SSE channel picks it up. + String projectId = ""; + try { + BuildProject p = getActiveBuildProject(); + if (p != null) projectId = p.id; + } catch (Exception ignored) {} + SteveMod.getPlanEventBus().publish(new PlanChatEvent(projectId, steveName, + PlanChatEvent.Sender.STEVE, message)); } public void tick() { ticksSinceLastAction++; - // Check if async planning is complete (non-blocking check!) - if (isPlanning && planningFuture != null && planningFuture.isDone()) { - try { - ResponseParser.ParsedResponse response = planningFuture.get(); - - if (response != null) { - currentGoal = response.getPlan(); - steve.getMemory().setCurrentGoal(currentGoal); - - taskQueue.clear(); - taskQueue.addAll(response.getTasks()); - - if (SteveConfig.ENABLE_CHAT_RESPONSES.get()) { - sendToGUI(steve.getSteveName(), "Okay! " + currentGoal); - } - - SteveMod.LOGGER.info("Steve '{}' async planning complete: {} tasks queued", - steve.getSteveName(), taskQueue.size()); - } else { - sendToGUI(steve.getSteveName(), "I couldn't understand that command."); - SteveMod.LOGGER.warn("Steve '{}' async planning returned null response", steve.getSteveName()); - } - - } catch (java.util.concurrent.CancellationException e) { - SteveMod.LOGGER.info("Steve '{}' planning was cancelled", steve.getSteveName()); - sendToGUI(steve.getSteveName(), "Planning cancelled."); - } catch (Exception e) { - SteveMod.LOGGER.error("Steve '{}' failed to get planning result", steve.getSteveName(), e); - sendToGUI(steve.getSteveName(), "Oops, something went wrong while planning!"); - } finally { - isPlanning = false; - planningFuture = null; - pendingCommand = null; - } - } + // (Legacy Plan-and-Execute removed; ReAct handles LLM-driven step dispatch below.) if (currentAction != null) { if (currentAction.isComplete()) { ActionResult result = currentAction.getResult(); - SteveMod.LOGGER.info("Steve '{}' - Action completed: {} (Success: {})", + SteveMod.LOGGER.info("Steve '{}' - Action completed: {} (Success: {})", steve.getSteveName(), result.getMessage(), result.isSuccess()); - + steve.getMemory().addAction(currentAction.getDescription()); - + if (!result.isSuccess() && result.requiresReplanning()) { - // Action failed, need to replan if (SteveConfig.ENABLE_CHAT_RESPONSES.get()) { sendToGUI(steve.getSteveName(), "Problem: " + result.getMessage()); } } - + currentAction = null; + + // Feed the observation back to the ReAct agent (if any) + if (reactAgent != null) { + String provider = SteveConfig.AI_PROVIDER.get().toLowerCase(); + reactAgent.feedObservation(result, + getTaskPlanner().getAsyncClient(provider), reactBaseParams); + } } else { if (ticksSinceLastAction % 100 == 0) { - SteveMod.LOGGER.info("Steve '{}' - Ticking action: {}", + SteveMod.LOGGER.info("Steve '{}' - Ticking action: {}", steve.getSteveName(), currentAction.getDescription()); } currentAction.tick(); @@ -279,26 +195,66 @@ public void tick() { } } - if (ticksSinceLastAction >= SteveConfig.ACTION_TICK_DELAY.get()) { - if (!taskQueue.isEmpty()) { - Task nextTask = taskQueue.poll(); - executeTask(nextTask); - ticksSinceLastAction = 0; + // ReAct mode state machine + if (reactAgent != null) { + if (reactAgent.failed()) { + String msg = reactAgent.getFailureMessage(); + SteveMod.LOGGER.error("Steve '{}' ReAct agent failed: {}", + steve.getSteveName(), msg); + sendToGUI(steve.getSteveName(), "AI error: " + msg); + reactAgent = null; + if (!pendingCommands.isEmpty()) { + drainNextCommand(); + return; + } + currentGoal = null; return; } + + if (reactAgent.isFinished()) { + String answer = reactAgent.getFinalAnswer(); + if (answer != null && !answer.isEmpty()) { + SteveMod.LOGGER.info("Steve '{}' ReAct finished: {}", steve.getSteveName(), answer); + sendToGUI(steve.getSteveName(), answer); + } + reactAgent = null; + if (!pendingCommands.isEmpty()) { + drainNextCommand(); + return; + } + currentGoal = null; // allow idle follow when queue is empty + } else if (reactAgent.isReadyNextStep()) { + ResponseParser.ParsedResponse step = reactAgent.consumeNextStep(); + if (step != null && !step.getTasks().isEmpty()) { + Task task = step.getTasks().get(0); + if (!getTaskPlanner().validateTask(task)) { + SteveMod.LOGGER.warn("Steve '{}' invalid action from ReAct: {}", + steve.getSteveName(), task.getAction()); + String provider = SteveConfig.AI_PROVIDER.get().toLowerCase(); + reactAgent.feedObservation( + "Invalid action: '" + task.getAction() + "'. Allowed: pathfind, mine, place, craft, attack, follow, gather, build, mcp", + getTaskPlanner().getAsyncClient(provider), reactBaseParams); + } else { + executeTask(task); + ticksSinceLastAction = 0; + return; + } + } + } + return; // ReAct is in control } - - // When completely idle (no tasks, no goal), follow nearest player - if (taskQueue.isEmpty() && currentAction == null && currentGoal == null) { + + // ReAct path returns above; below runs only when no ReAct is active. + // (No legacy Plan-and-Execute task queue — ReAct drives every step.) + if (currentGoal == null && currentAction == null) { + // When completely idle (no ReAct, no goal), follow nearest player if (idleFollowAction == null) { idleFollowAction = new IdleFollowAction(steve); idleFollowAction.start(); } else if (idleFollowAction.isComplete()) { - // Restart idle following if it stopped idleFollowAction = new IdleFollowAction(steve); idleFollowAction.start(); } else { - // Continue idle following idleFollowAction.tick(); } } else if (idleFollowAction != null) { @@ -373,7 +329,11 @@ private BaseAction createActionLegacy(Task task) { case "attack" -> new CombatAction(steve, task); case "follow" -> new FollowPlayerAction(steve, task); case "gather" -> new GatherResourceAction(steve, task); - case "build" -> new BuildStructureAction(steve, task); + // Intercept "build" -> PlanBuildAction (four-phase plan-then-build workflow). + // The plan action loads NBT, archives design to mempalace, and waits for + // player /steve approve before any blocks are placed. + case "build" -> new PlanBuildAction(steve, task, this); + case "mcp" -> new MCPAction(steve, task); default -> { SteveMod.LOGGER.warn("Unknown action type: {}", task.getAction()); yield null; @@ -390,15 +350,78 @@ public void stopCurrentAction() { idleFollowAction.cancel(); idleFollowAction = null; } - taskQueue.clear(); currentGoal = null; + reactAgent = null; + reactBaseParams = null; + pendingCommands.clear(); // Reset state machine stateMachine.reset(); } + /** + * Approve the current build's pending phase. No-op if not in a PlanBuildAction + * awaiting approval. + */ + public void approveCurrentBuild() { + if (currentAction instanceof PlanBuildAction plan) { + plan.approve(); + } else { + SteveMod.LOGGER.warn("approveCurrentBuild: no PlanBuildAction in progress for Steve '{}'", + steve.getSteveName()); + } + } + + /** + * Halt the current build (if any). No-op if not a PlanBuildAction. + */ + public void haltCurrentBuild(String reason) { + if (currentAction instanceof PlanBuildAction plan) { + plan.halt(reason); + } else { + SteveMod.LOGGER.warn("haltCurrentBuild: no PlanBuildAction in progress for Steve '{}'", + steve.getSteveName()); + } + } + + /** + * Get the active build project, or null if no PlanBuildAction is in flight. + */ + public com.steve.ai.action.BuildProject getActiveBuildProject() { + if (currentAction instanceof PlanBuildAction plan) { + return plan.getProject(); + } + return null; + } + + /** + * Plan a build via LLM. The LLM picks the template and the design phase + * produces a doc the player must /steve approve before any blocks are + * placed. Mirrors Claude Code's plan mode semantics. + * + *

The plan-mode constraint is embedded into the user-facing command + * string itself — ReActAgent.runStep embeds originalCommand raw into the + * === USER COMMAND === block of every step, so the LLM sees the rule on + * every turn without any system-prompt changes.

+ * + *

Used by the /steve plan subcommand.

+ */ + public void startPlannedBuild(String description) { + SteveMod.LOGGER.info("Steve '{}' planning: {}", steve.getSteveName(), description); + int cap = SteveConfig.MAX_TEMPLATES_PER_PLAN.get(); + String augmented = PromptBuilder.buildPlanPrompt(description, cap); + pendingCommands.add(augmented); + if (reactAgent == null && currentAction == null) { + sendToGUI(steve.getSteveName(), "Planning: " + description); + drainNextCommand(); + } else { + sendToGUI(steve.getSteveName(), + "Will plan after current task (queue: " + pendingCommands.size() + ")"); + } + } + public boolean isExecuting() { - return currentAction != null || !taskQueue.isEmpty(); + return currentAction != null || reactAgent != null; } public String getCurrentGoal() { @@ -442,12 +465,10 @@ public ActionContext getActionContext() { } /** - * Checks if the agent is currently planning (async LLM call in progress). - * - * @return true if planning + * Checks if the agent is currently busy with a ReAct agent or an action. */ - public boolean isPlanning() { - return isPlanning; + public boolean isBusy() { + return reactAgent != null || currentAction != null; } } diff --git a/src/main/java/com/steve/ai/action/ActionResult.java b/src/main/java/com/steve/ai/action/ActionResult.java index 7b299da1..293fd0cf 100644 --- a/src/main/java/com/steve/ai/action/ActionResult.java +++ b/src/main/java/com/steve/ai/action/ActionResult.java @@ -1,18 +1,26 @@ package com.steve.ai.action; public class ActionResult { + public enum Status { SUCCESS, FAILURE, PHASE_TRANSITION, AWAITING_APPROVAL } + private final boolean success; private final String message; private final boolean requiresReplanning; + private final Status status; public ActionResult(boolean success, String message) { - this(success, message, !success); + this(success, message, !success, success ? Status.SUCCESS : Status.FAILURE); } public ActionResult(boolean success, String message, boolean requiresReplanning) { + this(success, message, requiresReplanning, success ? Status.SUCCESS : Status.FAILURE); + } + + public ActionResult(boolean success, String message, boolean requiresReplanning, Status status) { this.success = success; this.message = message; this.requiresReplanning = requiresReplanning; + this.status = status; } public boolean isSuccess() { @@ -27,21 +35,37 @@ public boolean requiresReplanning() { return requiresReplanning; } + public Status getStatus() { + return status; + } + + public boolean isAwaitingApproval() { + return status == Status.AWAITING_APPROVAL; + } + public static ActionResult success(String message) { - return new ActionResult(true, message, false); + return new ActionResult(true, message, false, Status.SUCCESS); } public static ActionResult failure(String message) { - return new ActionResult(false, message, true); + return new ActionResult(false, message, true, Status.FAILURE); } public static ActionResult failure(String message, boolean requiresReplanning) { - return new ActionResult(false, message, requiresReplanning); + return new ActionResult(false, message, requiresReplanning, Status.FAILURE); + } + + public static ActionResult phaseTransition(String message) { + return new ActionResult(false, message, false, Status.PHASE_TRANSITION); + } + + public static ActionResult awaitingApproval(String message) { + return new ActionResult(false, message, false, Status.AWAITING_APPROVAL); } @Override public String toString() { - return "ActionResult{success=" + success + ", message='" + message + "', requiresReplanning=" + requiresReplanning + "}"; + return "ActionResult{status=" + status + ", success=" + success + ", message='" + message + "', requiresReplanning=" + requiresReplanning + "}"; } } diff --git a/src/main/java/com/steve/ai/action/BuildProject.java b/src/main/java/com/steve/ai/action/BuildProject.java new file mode 100644 index 00000000..beaf2f27 --- /dev/null +++ b/src/main/java/com/steve/ai/action/BuildProject.java @@ -0,0 +1,110 @@ +package com.steve.ai.action; + +import com.steve.ai.entity.SteveEntity; +import com.steve.ai.llm.react.BuildPhase; +import com.steve.ai.structure.PlacedModule; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.level.block.Block; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Tracks the full state of a single build project driven by PlanBuildAction. + * + *

One project is created per intercepted "build" task. It moves through + * {@link BuildPhase} transitions, with each phase's outputs persisted to mempalace.

+ * + *

The module-composition refactor replaced the legacy + * {@code List templates} with + * {@link #placedModules}: each entry is a {@link PlacedModule} that + * carries the loaded template and its world origin and facing. + * Three downstream consumers (CONSTRUCTION block placement, dashboard + * 3D snapshot, design-ready event payload) iterate this list and + * compute world coordinates through + * {@code ModuleTransform.apply(...)} — so the 3D preview and the placed + * world cannot diverge.

+ */ +public class BuildProject { + + public final String id; + public final SteveEntity steve; + public final String command; + public final long createdAtMs; + + /** Names of the modules the LLM selected, in chain order. Survives even + * if a template fails to load — used in mempalace archives. */ + public final List selectedTemplates = new ArrayList<>(); + + /** Resolved module placements: each entry pairs a loaded NBT template + * with its world origin and Y-rotation. The CONSTRUCTION phase and the + * dashboard snapshot both iterate this list. */ + public final List placedModules = new ArrayList<>(); + + public int currentModuleIndex = 0; + public BlockPos originPos; + public final Map materials = new LinkedHashMap<>(); + + public BuildPhase phase = BuildPhase.FEASIBILITY; + public BuildPhase lastApproved; + + public int blocksPlaced; + public int totalBlocks; + + /** Next world-space block index to place during CONSTRUCTION. Walks the + * same flattened order used by the dashboard snapshot (placedModules + * in project.placedModules order, blocks in PlacedModule.template.blocks + * order). */ + public int nextBlockIndex; + + public long phaseDeadlineMs; + + public final Map mempalaceRefs = new HashMap<>(); + + public BuildProject(SteveEntity steve, String command) { + this(steve, command, List.of(command)); + } + + public BuildProject(SteveEntity steve, String command, List requestedTemplates) { + this.id = UUID.randomUUID().toString().substring(0, 8); + this.steve = steve; + this.command = command; + this.createdAtMs = System.currentTimeMillis(); + this.selectedTemplates.addAll(requestedTemplates); + } + + public Player findNearestPlayer() { + List players = steve.level().players(); + Player nearest = null; + double nearestDistance = Double.MAX_VALUE; + for (Player player : players) { + if (!player.isAlive() || player.isRemoved() || player.isSpectator()) { + continue; + } + double distance = steve.distanceTo(player); + if (distance < nearestDistance) { + nearest = player; + nearestDistance = distance; + } + } + return nearest; + } + + public Map countMaterials(List plan) { + Map counts = new LinkedHashMap<>(); + for (var bp : plan) { + counts.merge(bp.block, 1, Integer::sum); + } + return counts; + } + + public int getIdHash() { + // short hash for log readability + return Math.abs(id.hashCode() % 100000); + } +} diff --git a/src/main/java/com/steve/ai/action/CollaborativeBuildManager.java b/src/main/java/com/steve/ai/action/CollaborativeBuildManager.java index a19ff809..04b34fe2 100644 --- a/src/main/java/com/steve/ai/action/CollaborativeBuildManager.java +++ b/src/main/java/com/steve/ai/action/CollaborativeBuildManager.java @@ -197,15 +197,23 @@ public static BlockPlacement getNextBlock(CollaborativeBuild build, String steve BuildSection section = build.sections.get(sectionIndex); BlockPlacement block = section.getNextBlock(); - + if (block == null) { - if (sectionIndex != null) { - section = build.sections.get(sectionIndex); - block = section.getNextBlock(); - if (block != null) { } + // Section complete - try to find another section that needs help + for (int i = 0; i < build.sections.size(); i++) { + BuildSection otherSection = build.sections.get(i); + if (!otherSection.isComplete()) { + build.steveToSectionMap.put(steveName, i); + block = otherSection.getNextBlock(); + if (block != null) { + SteveMod.LOGGER.info("Steve '{}' finished section, now helping with {} ({} remaining)", + steveName, otherSection.sectionName, otherSection.getTotalBlocks() - otherSection.getBlocksPlaced()); + return block; + } + } } } - + return block; } diff --git a/src/main/java/com/steve/ai/action/Task.java b/src/main/java/com/steve/ai/action/Task.java index 8d87146d..87045b04 100644 --- a/src/main/java/com/steve/ai/action/Task.java +++ b/src/main/java/com/steve/ai/action/Task.java @@ -1,5 +1,9 @@ package com.steve.ai.action; +import com.google.gson.JsonArray; + +import java.util.ArrayList; +import java.util.List; import java.util.Map; public class Task { @@ -33,6 +37,68 @@ public String getStringParameter(String key, String defaultValue) { return value != null ? value.toString() : defaultValue; } + public List getStringListParameter(String key) { + Object value = parameters.get(key); + if (value == null) return null; + if (value instanceof JsonArray arr) { + List out = new ArrayList<>(arr.size()); + arr.forEach(e -> out.add(e.getAsString())); + return out; + } + if (value instanceof List list) { + List out = new ArrayList<>(list.size()); + for (Object o : list) { + if (o != null) out.add(o.toString()); + } + return out; + } + return null; + } + + public List getStringListParameter(String key, List defaultValue) { + List v = getStringListParameter(key); + return v != null ? v : defaultValue; + } + + /** + * Read a parameter as a list of object maps — the shape used by the + * module-composition protocol. Returns {@code null} when the key is + * missing; returns an empty list when the key is present but the value + * is not a list of maps. Elements that are not {@code Map}s are dropped + * silently (the caller decides what to do with a malformed payload). + */ + @SuppressWarnings("unchecked") + public List> getModuleListParameter(String key) { + Object value = parameters.get(key); + if (value == null) return null; + List> out = new ArrayList<>(); + if (value instanceof JsonArray arr) { + arr.forEach(e -> { + if (e != null && e.isJsonObject()) { + // Gson JsonObject implements Map; the + // canonical shape we want is Map. Round-trip + // through a plain HashMap to keep callers insulated from Gson. + out.add(new java.util.HashMap<>(e.getAsJsonObject().asMap())); + } + }); + return out; + } + if (value instanceof List list) { + for (Object o : list) { + if (o instanceof Map m) { + out.add((Map) m); + } + } + return out; + } + return null; + } + + public List> getModuleListParameter(String key, List> defaultValue) { + List> v = getModuleListParameter(key); + return v != null ? v : defaultValue; + } + public int getIntParameter(String key, int defaultValue) { Object value = parameters.get(key); if (value instanceof Number) { diff --git a/src/main/java/com/steve/ai/action/actions/BuildStructureAction.java b/src/main/java/com/steve/ai/action/actions/BuildStructureAction.java deleted file mode 100644 index 272a8d55..00000000 --- a/src/main/java/com/steve/ai/action/actions/BuildStructureAction.java +++ /dev/null @@ -1,511 +0,0 @@ -package com.steve.ai.action.actions; - -import com.steve.ai.SteveMod; -import com.steve.ai.action.ActionResult; -import com.steve.ai.action.CollaborativeBuildManager; -import com.steve.ai.action.Task; -import com.steve.ai.entity.SteveEntity; -import com.steve.ai.memory.StructureRegistry; -import com.steve.ai.structure.BlockPlacement; -import com.steve.ai.structure.StructureGenerators; -import com.steve.ai.structure.StructureTemplateLoader; -import net.minecraft.core.BlockPos; -import net.minecraft.core.particles.BlockParticleOption; -import net.minecraft.core.particles.ParticleTypes; -import net.minecraft.core.registries.BuiltInRegistries; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.sounds.SoundSource; -import net.minecraft.world.InteractionHand; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.state.BlockState; - -import java.util.ArrayList; -import java.util.List; - -public class BuildStructureAction extends BaseAction { - - private String structureType; - private List buildPlan; - private int currentBlockIndex; - private List buildMaterials; - private int ticksRunning; - private CollaborativeBuildManager.CollaborativeBuild collaborativeBuild; // For multi-Steve collaboration - private boolean isCollaborative; - private static final int MAX_TICKS = 120000; - private static final int BLOCKS_PER_TICK = 1; - private static final double BUILD_SPEED_MULTIPLIER = 1.5; - - public BuildStructureAction(SteveEntity steve, Task task) { - super(steve, task); - } - - @Override - protected void onStart() { - structureType = task.getStringParameter("structure").toLowerCase(); - currentBlockIndex = 0; - ticksRunning = 0; - collaborativeBuild = CollaborativeBuildManager.findActiveBuild(structureType); - if (collaborativeBuild != null) { - isCollaborative = true; - - steve.setFlying(true); - - SteveMod.LOGGER.info("Steve '{}' JOINING collaborative build of '{}' ({}% complete) - FLYING & INVULNERABLE ENABLED", - steve.getSteveName(), structureType, collaborativeBuild.getProgressPercentage()); - - buildMaterials = new ArrayList<>(); - buildMaterials.add(Blocks.OAK_PLANKS); // Default material - buildMaterials.add(Blocks.COBBLESTONE); - buildMaterials.add(Blocks.GLASS_PANE); - - return; // Skip structure generation, just join the existing build - } - - isCollaborative = false; - - buildMaterials = new ArrayList<>(); - Object blocksParam = task.getParameter("blocks"); - if (blocksParam instanceof List) { - List blocksList = (List) blocksParam; - for (Object blockObj : blocksList) { - Block block = parseBlock(blockObj.toString()); - if (block != Blocks.AIR) { - buildMaterials.add(block); - } - } - } - - if (buildMaterials.isEmpty()) { - String materialName = task.getStringParameter("material", "oak_planks"); - Block block = parseBlock(materialName); - buildMaterials.add(block != Blocks.AIR ? block : Blocks.OAK_PLANKS); - } - - Object dimensionsParam = task.getParameter("dimensions"); - int width = 9; // Increased from 5 - int height = 6; // Increased from 4 - int depth = 9; // Increased from 5 - - if (dimensionsParam instanceof List) { - List dims = (List) dimensionsParam; - if (dims.size() >= 3) { - width = ((Number) dims.get(0)).intValue(); - height = ((Number) dims.get(1)).intValue(); - depth = ((Number) dims.get(2)).intValue(); - } - } else { - width = task.getIntParameter("width", 5); - height = task.getIntParameter("height", 4); - depth = task.getIntParameter("depth", 5); - } - - net.minecraft.world.entity.player.Player nearestPlayer = findNearestPlayer(); - BlockPos groundPos; - - if (nearestPlayer != null) { - net.minecraft.world.phys.Vec3 eyePos = nearestPlayer.getEyePosition(1.0F); - net.minecraft.world.phys.Vec3 lookVec = nearestPlayer.getLookAngle(); - - net.minecraft.world.phys.Vec3 targetPos = eyePos.add(lookVec.scale(12)); - - BlockPos lookTarget = new BlockPos( - (int)Math.floor(targetPos.x), - (int)Math.floor(targetPos.y), - (int)Math.floor(targetPos.z) - ); - - groundPos = findGroundLevel(lookTarget); - - if (groundPos == null) { - groundPos = findGroundLevel(nearestPlayer.blockPosition().offset( - (int)Math.round(lookVec.x * 10), - 0, - (int)Math.round(lookVec.z * 10) - )); - } - - SteveMod.LOGGER.info("Building in player's field of view at {} (looking from {} towards {})", - groundPos, eyePos, targetPos); - } else { - BlockPos buildPos = steve.blockPosition().offset(2, 0, 2); - groundPos = findGroundLevel(buildPos); - } - - if (groundPos == null) { - result = ActionResult.failure("Cannot find suitable ground for building in your field of view"); - return; - } - - SteveMod.LOGGER.info("Found ground at Y={} (Build starting at {})", groundPos.getY(), groundPos); - - BlockPos clearPos = groundPos; - - buildPlan = tryLoadFromTemplate(structureType, clearPos); - - if (buildPlan == null) { - // Fall back to procedural generation buildPlan = generateBuildPlan(structureType, clearPos, width, height, depth); - } else { - SteveMod.LOGGER.info("Loaded '{}' from NBT template with {} blocks", structureType, buildPlan.size()); - } - - if (buildPlan == null || buildPlan.isEmpty()) { - result = ActionResult.failure("Cannot generate build plan for: " + structureType); - return; - } - - StructureRegistry.register(clearPos, width, height, depth, structureType); - - collaborativeBuild = CollaborativeBuildManager.findActiveBuild(structureType); - - if (collaborativeBuild != null) { - isCollaborative = true; - SteveMod.LOGGER.info("Steve '{}' JOINING existing {} collaborative build at {}", - steve.getSteveName(), structureType, collaborativeBuild.startPos); - } else { - List collaborativeBlocks = new ArrayList<>(); - for (BlockPlacement bp : buildPlan) { - collaborativeBlocks.add(new BlockPlacement(bp.pos, bp.block)); - } - - collaborativeBuild = CollaborativeBuildManager.registerBuild(structureType, collaborativeBlocks, clearPos); - isCollaborative = true; - SteveMod.LOGGER.info("Steve '{}' CREATED new {} collaborative build at {}", - steve.getSteveName(), structureType, clearPos); - } - - steve.setFlying(true); - - SteveMod.LOGGER.info("Steve '{}' starting COLLABORATIVE build of {} at {} with {} blocks using materials: {} [FLYING ENABLED]", - steve.getSteveName(), structureType, clearPos, buildPlan.size(), buildMaterials); - } - - @Override - protected void onTick() { - ticksRunning++; - - if (ticksRunning > MAX_TICKS) { - steve.setFlying(false); // Disable flying on timeout - result = ActionResult.failure("Building timeout"); - return; - } - - if (isCollaborative && collaborativeBuild != null) { - if (collaborativeBuild.isComplete()) { - CollaborativeBuildManager.completeBuild(collaborativeBuild.structureId); - steve.setFlying(false); - result = ActionResult.success("Built " + structureType + " collaboratively!"); - return; - } - - for (int i = 0; i < BLOCKS_PER_TICK; i++) { - BlockPlacement placement = - CollaborativeBuildManager.getNextBlock(collaborativeBuild, steve.getSteveName()); - - if (placement == null) { - if (ticksRunning % 20 == 0) { - SteveMod.LOGGER.info("Steve '{}' has no more blocks! Build {}% complete", - steve.getSteveName(), collaborativeBuild.getProgressPercentage()); - } - break; - } - - BlockPos pos = placement.pos; - double distance = Math.sqrt(steve.blockPosition().distSqr(pos)); - if (distance > 5) { - steve.teleportTo(pos.getX() + 2, pos.getY(), pos.getZ() + 2); - SteveMod.LOGGER.info("Steve '{}' teleported to block at {}", steve.getSteveName(), pos); - } - - steve.getLookControl().setLookAt(pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5); - - steve.swing(InteractionHand.MAIN_HAND, true); - - BlockState existingState = steve.level().getBlockState(pos); - - BlockState blockState = placement.block.defaultBlockState(); - steve.level().setBlock(pos, blockState, 3); - - SteveMod.LOGGER.info("Steve '{}' PLACED BLOCK at {} - Total: {}/{}", - steve.getSteveName(), pos, collaborativeBuild.getBlocksPlaced(), - collaborativeBuild.getTotalBlocks()); - - // Particles and sound - if (steve.level() instanceof ServerLevel serverLevel) { - serverLevel.sendParticles( - new BlockParticleOption(ParticleTypes.BLOCK, blockState), - pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, - 15, 0.4, 0.4, 0.4, 0.15 - ); - - var soundType = blockState.getSoundType(steve.level(), pos, steve); - steve.level().playSound(null, pos, soundType.getPlaceSound(), - SoundSource.BLOCKS, 1.0f, soundType.getPitch()); - } - } - - if (ticksRunning % 100 == 0 && collaborativeBuild.getBlocksPlaced() > 0) { - int percentComplete = collaborativeBuild.getProgressPercentage(); - SteveMod.LOGGER.info("{} build progress: {}/{} ({}%) - {} Steves working", - structureType, - collaborativeBuild.getBlocksPlaced(), - collaborativeBuild.getTotalBlocks(), - percentComplete, - collaborativeBuild.participatingSteves.size()); - } - } else { - steve.setFlying(false); // Disable flying on error - result = ActionResult.failure("Build system error: not in collaborative mode"); - } - } - - @Override - protected void onCancel() { - steve.setFlying(false); // Disable flying when cancelled - steve.getNavigation().stop(); - } - - @Override - public String getDescription() { - return "Build " + structureType + " (" + currentBlockIndex + "/" + (buildPlan != null ? buildPlan.size() : 0) + ")"; - } - - private List generateBuildPlan(String type, BlockPos start, int width, int height, int depth) { - // Delegate to centralized StructureGenerators utility - return StructureGenerators.generate(type, start, width, height, depth, buildMaterials); - } - - private Block getMaterial(int index) { - return buildMaterials.get(index % buildMaterials.size()); - } - - private Block parseBlock(String blockName) { - blockName = blockName.toLowerCase().replace(" ", "_"); - if (!blockName.contains(":")) { - blockName = "minecraft:" + blockName; - } - ResourceLocation resourceLocation = new ResourceLocation(blockName); - Block block = BuiltInRegistries.BLOCK.get(resourceLocation); - return block != null ? block : Blocks.AIR; - } - - /** - * Find the actual ground level from a starting position - * Scans downward to find solid ground, or upward if underground - */ - private BlockPos findGroundLevel(BlockPos startPos) { - int maxScanDown = 20; // Scan up to 20 blocks down - int maxScanUp = 10; // Scan up to 10 blocks up if we're underground - - // First, try scanning downward to find ground - for (int i = 0; i < maxScanDown; i++) { - BlockPos checkPos = startPos.below(i); - BlockPos belowPos = checkPos.below(); - - if (steve.level().getBlockState(checkPos).isAir() && - isSolidGround(belowPos)) { - return checkPos; // This is ground level - } - } - - // Scan upward to find the surface - for (int i = 1; i < maxScanUp; i++) { - BlockPos checkPos = startPos.above(i); - BlockPos belowPos = checkPos.below(); - - if (steve.level().getBlockState(checkPos).isAir() && - isSolidGround(belowPos)) { - return checkPos; - } - } - - // but make sure there's something solid below - BlockPos fallbackPos = startPos; - while (!isSolidGround(fallbackPos.below()) && fallbackPos.getY() > -64) { - fallbackPos = fallbackPos.below(); - } - - return fallbackPos; - } - - /** - * Check if a position has solid ground suitable for building - */ - private boolean isSolidGround(BlockPos pos) { - var blockState = steve.level().getBlockState(pos); - var block = blockState.getBlock(); - - // Not solid if it's air or liquid - if (blockState.isAir() || block == Blocks.WATER || block == Blocks.LAVA) { - return false; - } - - return blockState.isSolid(); - } - - /** - * Find a suitable building site with flat, clear ground - * Searches for an area that is: - * - Relatively flat (max 2 block height difference) - * - Clear of obstructions (trees, rocks, etc.) - * - Has enough vertical space for the structure - */ - private BlockPos findSuitableBuildingSite(BlockPos startPos, int width, int height, int depth) { - int maxSearchRadius = 10; - int searchStep = 3; // Small steps to stay nearby - - if (isAreaSuitable(startPos, width, height, depth)) { - return startPos; - } // Search in expanding circles - for (int radius = searchStep; radius < maxSearchRadius; radius += searchStep) { - for (int angle = 0; angle < 360; angle += 45) { // Check every 45 degrees - double radians = Math.toRadians(angle); - int offsetX = (int) (Math.cos(radians) * radius); - int offsetZ = (int) (Math.sin(radians) * radius); - - BlockPos testPos = new BlockPos( - startPos.getX() + offsetX, - startPos.getY(), - startPos.getZ() + offsetZ - ); - - BlockPos groundPos = findGroundLevel(testPos); - if (groundPos != null && isAreaSuitable(groundPos, width, height, depth)) { - SteveMod.LOGGER.info("Found suitable flat ground at {} ({}m away)", groundPos, radius); - return groundPos; - } - } - } - - SteveMod.LOGGER.warn("Could not find suitable flat ground within {}m", maxSearchRadius); - return null; - } - - /** - * Check if an area is suitable for building - * - Must be relatively flat (max 2 block height variation) - * - Must be clear of obstructions above ground - * - Must have solid ground below - */ - private boolean isAreaSuitable(BlockPos startPos, int width, int height, int depth) { - // Sample key points in the build area to check terrain - int samples = 0; - int maxSamples = 9; // Check 9 points (corners + center + midpoints) - int unsuitable = 0; - - BlockPos[] checkPoints = { - startPos, // Front-left corner - startPos.offset(width - 1, 0, 0), // Front-right corner - startPos.offset(0, 0, depth - 1), // Back-left corner - startPos.offset(width - 1, 0, depth - 1), // Back-right corner - startPos.offset(width / 2, 0, depth / 2), // Center - startPos.offset(width / 2, 0, 0), // Front-center - startPos.offset(width / 2, 0, depth - 1), // Back-center - startPos.offset(0, 0, depth / 2), // Left-center - startPos.offset(width - 1, 0, depth / 2) // Right-center - }; - - int minY = startPos.getY(); - int maxY = startPos.getY(); - - for (BlockPos checkPos : checkPoints) { - samples++; - - if (!isSolidGround(checkPos.below())) { - unsuitable++; - continue; - } - - BlockPos actualGround = findGroundLevel(checkPos); - if (actualGround != null) { - minY = Math.min(minY, actualGround.getY()); - maxY = Math.max(maxY, actualGround.getY()); - } - - for (int y = 1; y <= Math.min(height, 3); y++) { - BlockPos abovePos = checkPos.above(y); - var blockState = steve.level().getBlockState(abovePos); - - if (!blockState.isAir()) { - Block block = blockState.getBlock(); - if (block != Blocks.GRASS && block != Blocks.TALL_GRASS && - block != Blocks.FERN && block != Blocks.DEAD_BUSH && - block != Blocks.DANDELION && block != Blocks.POPPY) { - unsuitable++; - break; - } - } - } - } - - int heightVariation = maxY - minY; - if (heightVariation > 2) { - SteveMod.LOGGER.debug("Area at {} too uneven ({}m height difference)", startPos, heightVariation); - return false; - } - - // Area is suitable if less than 30% of samples are problematic - boolean suitable = unsuitable < (maxSamples * 0.3); - - if (!suitable) { - SteveMod.LOGGER.debug("Area at {} has too many obstructions ({}/{})", startPos, unsuitable, samples); - } - - return suitable; - } - - /** - * Try to load structure from NBT template file - * Returns null if no template found (falls back to procedural generation) - */ - private List tryLoadFromTemplate(String structureName, BlockPos startPos) { - if (!(steve.level() instanceof ServerLevel serverLevel)) { - return null; - } - - var template = StructureTemplateLoader.loadFromNBT(serverLevel, structureName); - if (template == null) { - return null; - } - - List blocks = new ArrayList<>(); - for (var templateBlock : template.blocks) { - BlockPos worldPos = startPos.offset(templateBlock.relativePos); - Block block = templateBlock.blockState.getBlock(); - blocks.add(new BlockPlacement(worldPos, block)); - } - - return blocks; - } - - /** - * Find the nearest player to build in front of - */ - private net.minecraft.world.entity.player.Player findNearestPlayer() { - java.util.List players = steve.level().players(); - - if (players.isEmpty()) { - return null; - } - - net.minecraft.world.entity.player.Player nearest = null; - double nearestDistance = Double.MAX_VALUE; - - for (net.minecraft.world.entity.player.Player player : players) { - if (!player.isAlive() || player.isRemoved() || player.isSpectator()) { - continue; - } - - double distance = steve.distanceTo(player); - if (distance < nearestDistance) { - nearest = player; - nearestDistance = distance; - } - } - - return nearest; - } - -} - diff --git a/src/main/java/com/steve/ai/action/actions/MCPAction.java b/src/main/java/com/steve/ai/action/actions/MCPAction.java new file mode 100644 index 00000000..06353ead --- /dev/null +++ b/src/main/java/com/steve/ai/action/actions/MCPAction.java @@ -0,0 +1,65 @@ +package com.steve.ai.action.actions; + +import com.steve.ai.SteveMod; +import com.steve.ai.action.ActionResult; +import com.steve.ai.action.Task; +import com.steve.ai.entity.SteveEntity; +import com.steve.ai.mcp.MCPToolRegistry; + +import java.util.Map; + +/** + * Action that executes an MCP tool call. + * The task parameters should contain: + * - tool: full tool name in format "serverName:toolName" + * - args: map of arguments to pass to the tool (optional) + */ +public class MCPAction extends BaseAction { + + public MCPAction(SteveEntity steve, Task task) { + super(steve, task); + } + + @Override + protected void onStart() { + Map params = task.getParameters(); + Object toolObj = params.get("tool"); + + if (toolObj == null) { + result = ActionResult.failure("MCP tool name not specified"); + return; + } + + String toolName = toolObj.toString(); + @SuppressWarnings("unchecked") + Map args = (Map) params.getOrDefault("args", Map.of()); + + SteveMod.LOGGER.info("Executing MCP tool: {} with args: {}", toolName, args); + + try { + SteveMod mcp = null; + String response = MCPToolRegistry.getInstance().callTool(toolName, args); + SteveMod.LOGGER.info("MCP tool '{}' result: {}", toolName, response); + result = ActionResult.success("MCP tool executed: " + response); + } catch (Exception e) { + SteveMod.LOGGER.error("Failed to execute MCP tool '{}'", toolName, e); + result = ActionResult.failure("MCP tool failed: " + e.getMessage()); + } + } + + @Override + protected void onTick() { + // MCP calls are synchronous, should be complete after onStart + } + + @Override + protected void onCancel() { + // Cannot cancel a completed MCP call + } + + @Override + public String getDescription() { + Object toolObj = task.getParameters().get("tool"); + return "MCP tool: " + (toolObj != null ? toolObj.toString() : "unknown"); + } +} diff --git a/src/main/java/com/steve/ai/action/actions/MineBlockAction.java b/src/main/java/com/steve/ai/action/actions/MineBlockAction.java index a1b7fee9..bf79b807 100644 --- a/src/main/java/com/steve/ai/action/actions/MineBlockAction.java +++ b/src/main/java/com/steve/ai/action/actions/MineBlockAction.java @@ -6,6 +6,8 @@ import com.steve.ai.entity.SteveEntity; import net.minecraft.core.BlockPos; import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.item.ItemEntity; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.core.registries.BuiltInRegistries; @@ -174,10 +176,10 @@ protected void onTick() { if (steve.level().getBlockState(currentTarget).getBlock() == targetBlock) { steve.teleportTo(currentTarget.getX() + 0.5, currentTarget.getY(), currentTarget.getZ() + 0.5); - + steve.swing(InteractionHand.MAIN_HAND, true); - - steve.level().destroyBlock(currentTarget, true); + + mineBlockToInventory(currentTarget); minedCount++; ticksSinceLastMine = 0; // Reset delay timer @@ -267,20 +269,20 @@ private void mineNearbyBlock() { if (!centerState.isAir() && centerState.getBlock() != Blocks.BEDROCK) { steve.teleportTo(centerPos.getX() + 0.5, centerPos.getY(), centerPos.getZ() + 0.5); steve.swing(InteractionHand.MAIN_HAND, true); - steve.level().destroyBlock(centerPos, true); + mineBlockToInventory(centerPos); SteveMod.LOGGER.info("Steve '{}' mining tunnel at {}", steve.getSteveName(), centerPos); } - + BlockState aboveState = steve.level().getBlockState(abovePos); if (!aboveState.isAir() && aboveState.getBlock() != Blocks.BEDROCK) { steve.swing(InteractionHand.MAIN_HAND, true); - steve.level().destroyBlock(abovePos, true); + mineBlockToInventory(abovePos); } - + BlockState belowState = steve.level().getBlockState(belowPos); if (!belowState.isAir() && belowState.getBlock() != Blocks.BEDROCK) { steve.swing(InteractionHand.MAIN_HAND, true); - steve.level().destroyBlock(belowPos, true); + mineBlockToInventory(belowPos); } currentTunnelPos = currentTunnelPos.offset(miningDirectionX, 0, miningDirectionZ); @@ -330,6 +332,47 @@ private void equipIronPickaxe() { SteveMod.LOGGER.info("Steve '{}' equipped iron pickaxe for mining", steve.getSteveName()); } + // Block-to-item drop mapping (blocks that drop different items when mined) + private static final Map BLOCK_DROPS = new HashMap<>() {{ + put(Blocks.STONE, net.minecraft.world.item.Items.COBBLESTONE); + put(Blocks.DEEPSLATE, net.minecraft.world.item.Items.COBBLESTONE); + put(Blocks.GRASS_BLOCK, net.minecraft.world.item.Items.DIRT); + put(Blocks.MYCELIUM, net.minecraft.world.item.Items.DIRT); + put(Blocks.PODZOL, net.minecraft.world.item.Items.DIRT); + put(Blocks.SNOW_BLOCK, net.minecraft.world.item.Items.SNOWBALL); + }}; + + /** + * Get the item dropped when a block is mined + */ + private ItemStack getBlockDrop(Block block) { + net.minecraft.world.item.Item dropItem = BLOCK_DROPS.getOrDefault(block, block.asItem()); + return new ItemStack(dropItem); + } + + /** + * Mine a block and add it to Steve's inventory instead of dropping it + */ + private void mineBlockToInventory(BlockPos pos) { + BlockState state = steve.level().getBlockState(pos); + Block block = state.getBlock(); + ItemStack itemStack = getBlockDrop(block); + ItemStack remainder = steve.addItemToInventory(itemStack); + steve.level().destroyBlock(pos, false); // false = don't drop items + + if (!remainder.isEmpty()) { + // Inventory full, drop the rest + net.minecraft.world.entity.item.ItemEntity itemEntity = new net.minecraft.world.entity.item.ItemEntity( + steve.level(), pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, remainder + ); + steve.level().addFreshEntity(itemEntity); + SteveMod.LOGGER.warn("Steve '{}' inventory full, dropped {} at {}", steve.getSteveName(), remainder.getCount(), pos); + } + + SteveMod.LOGGER.info("Steve '{}' mined {} -> inventory ({} total)", steve.getSteveName(), + block.getName().getString(), steve.getBlockCount(block)); + } + /** * Find the nearest player to determine mining direction */ @@ -370,6 +413,13 @@ private Block parseBlock(String blockName) { put("redstone", "redstone_ore"); put("lapis", "lapis_ore"); put("emerald", "emerald_ore"); + // Material aliases for building + put("cobblestone", "stone"); + put("oak_planks", "oak_log"); + put("planks", "oak_log"); + put("wood", "oak_log"); + put("glass", "sand"); + put("stone_bricks", "stone"); }}; if (resourceToOre.containsKey(blockName)) { diff --git a/src/main/java/com/steve/ai/action/actions/PlaceBlockAction.java b/src/main/java/com/steve/ai/action/actions/PlaceBlockAction.java index 8dde4979..975e7f84 100644 --- a/src/main/java/com/steve/ai/action/actions/PlaceBlockAction.java +++ b/src/main/java/com/steve/ai/action/actions/PlaceBlockAction.java @@ -2,10 +2,13 @@ import com.steve.ai.action.ActionResult; import com.steve.ai.action.Task; +import com.steve.ai.config.SteveConfig; import com.steve.ai.entity.SteveEntity; import net.minecraft.core.BlockPos; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; @@ -42,29 +45,44 @@ protected void onStart() { @Override protected void onTick() { ticksRunning++; - + if (ticksRunning > MAX_TICKS) { result = ActionResult.failure("Place block timeout"); return; } - + if (!steve.blockPosition().closerThan(targetPos, 5.0)) { steve.getNavigation().moveTo(targetPos.getX(), targetPos.getY(), targetPos.getZ(), 1.0); return; } - + + // Check material (skip in creative mode) + boolean creative = SteveConfig.CREATIVE_MODE.get(); + if (!creative) { + if (!steve.hasBlock(blockToPlace, 1)) { + result = ActionResult.failure("No " + blockToPlace.getName().getString() + " in inventory"); + return; + } + // Consume material from inventory + steve.removeBlockFromInventory(blockToPlace, 1); + } + BlockState currentState = steve.level().getBlockState(targetPos); if (!currentState.isAir() && !currentState.liquid()) { result = ActionResult.failure("Position is not empty"); return; } - + + steve.setItemInHand(InteractionHand.MAIN_HAND, new ItemStack(blockToPlace.asItem())); + steve.swing(InteractionHand.MAIN_HAND, true); + steve.level().setBlock(targetPos, blockToPlace.defaultBlockState(), 3); result = ActionResult.success("Placed " + blockToPlace.getName().getString()); } @Override protected void onCancel() { + steve.setItemInHand(InteractionHand.MAIN_HAND, ItemStack.EMPTY); steve.getNavigation().stop(); } diff --git a/src/main/java/com/steve/ai/action/actions/PlanBuildAction.java b/src/main/java/com/steve/ai/action/actions/PlanBuildAction.java new file mode 100644 index 00000000..4bb75f85 --- /dev/null +++ b/src/main/java/com/steve/ai/action/actions/PlanBuildAction.java @@ -0,0 +1,655 @@ +package com.steve.ai.action.actions; + +import com.steve.ai.SteveMod; +import com.steve.ai.action.ActionExecutor; +import com.steve.ai.action.ActionResult; +import com.steve.ai.action.BuildProject; +import com.steve.ai.action.Task; +import com.steve.ai.config.SteveConfig; +import com.steve.ai.entity.SteveEntity; +import com.steve.ai.event.plan.PlanApprovedEvent; +import com.steve.ai.event.plan.PlanCreatedEvent; +import com.steve.ai.event.plan.PlanDesignReadyEvent; +import com.steve.ai.event.plan.PlanHaltedEvent; +import com.steve.ai.event.plan.PlanLogEvent; +import com.steve.ai.event.plan.PlanPhaseChangedEvent; +import com.steve.ai.llm.react.BuildDesignFormatter; +import com.steve.ai.llm.react.BuildPhase; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.steve.ai.mcp.MCPToolRegistry; +import com.steve.ai.structure.BlockPlacement; +import com.steve.ai.structure.ModuleTransform; +import com.steve.ai.structure.PlacedModule; +import com.steve.ai.structure.StructureTemplateLoader; +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Four-phase build orchestrator. + * + *

Phases: + *

    + *
  • FEASIBILITY — resolve NBT template list (driven by LLM via task params)
  • + *
  • DESIGN — load NBT, emit design doc, archive to mempalace, push dashboard event
  • + *
  • AWAITING_DESIGN_APPROVAL — idle, waits for {@code /steve approve} or {@code /steve halt}
  • + *
  • CONSTRUCTION — place every block from the loaded templates at {@code BUILD_TICK_DELAY} + * cadence. No second confirmation: dashboard approve kicks construction off directly.
  • + *
  • COMPLETED / FAILED — terminal
  • + *
+ * + *

{@link BuildPhase#AWAITING_ACCEPTANCE} is kept as an enum value for source compatibility + * but is no longer entered by the dashboard-approve flow.

+ */ +public class PlanBuildAction extends BaseAction { + + private final BuildProject project; + private final ActionExecutor executor; + + /** Ticks remaining before the next CONSTRUCTION block placement attempt. */ + private int constructionCooldown; + + /** JSON serializer for mempalace archive payloads. {@code disableHtmlEscaping} + * keeps block IDs like {@code minecraft:stone} readable (no {@code :} for + * the colon) — mirrors the pattern in {@code PlanEventJson.GSON}. */ + static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + public PlanBuildAction(SteveEntity steve, Task task, ActionExecutor executor) { + super(steve, task); + this.executor = executor; + + // Module-composition protocol is the only accepted shape. The + // LLM emits a list of {name, dx, dy, dz, facing} objects under + // parameters.structures. For the single-template convenience form + // (parameters.structure), we wrap it into a one-element module + // spec with default offsets and facing S. + List> moduleList = task.getModuleListParameter("structures"); + if (moduleList == null || moduleList.isEmpty()) { + String single = task.getStringParameter("structure"); + if (single == null || single.isEmpty()) { + single = task.getStringParameter("structure", "unknown"); + } + moduleList = new ArrayList<>(1); + Map spec = new java.util.HashMap<>(); + spec.put("name", single); + // dx/dy/dz/facing absent -> defaults applied in runDesign. + moduleList.add(spec); + } + + // Plan-mode fallback: if the LLM returned exactly one module (legacy + // `structure: "X"` wrap or a one-entry structures array), try to + // compose same-type siblings from StructureTemplateLoader. LLM-emitted + // multi-entry lists skip this block entirely — we only rescue the + // "LLM ignored the ≥2 rule" case. The single-piece convenience path + // ("放 房子_1") is preserved when no siblings exist. Logic is in a + // static helper so tests can exercise it without instantiating the + // full action (which needs SteveEntity and a real event bus). + moduleList = expandSingleStructureFallback(moduleList, + () -> SteveConfig.MAX_TEMPLATES_PER_PLAN.get()); + + int cap = SteveConfig.MAX_TEMPLATES_PER_PLAN.get(); + if (moduleList.size() > cap) { + SteveMod.LOGGER.warn("PlanBuildAction: LLM requested {} modules, capping to {}", + moduleList.size(), cap); + moduleList = new ArrayList<>(moduleList.subList(0, cap)); + } + + // Extract a name list for the project record (mempalace archive, + // dashboard display). The full module specs are stashed on the + // instance for runDesign to walk. + List names = new ArrayList<>(moduleList.size()); + for (Map m : moduleList) { + Object n = m.get("name"); + if (n != null) names.add(n.toString()); + } + String label = names.isEmpty() ? "unknown" : names.get(0); + this.project = new BuildProject(steve, label, names); + this.pendingModuleSpecs = moduleList; + + // Fire PlanCreatedEvent so the external HTML dashboard can pick up the + // project as soon as the action is constructed. + publishEvent(new PlanCreatedEvent( + project.id, steve.getSteveName(), project.command, + project.selectedTemplates, project.phase)); + } + + /** Module specs from the {name, dx, dy, dz, facing} protocol, set in + * the constructor and walked by {@link #runDesign()}. Never null: + * even a single-structure request is wrapped into a one-element + * module spec at construction time. */ + private List> pendingModuleSpecs; + + public BuildProject getProject() { + return project; + } + + @Override + protected void onStart() { + SteveMod.LOGGER.info("PlanBuildAction started for Steve '{}', command='{}'", + steve.getSteveName(), project.command); + + // Phase 1: pick templates. LLM already provided them via task.parameters.structures + // (or legacy task.parameters.structure / fallback to player command). + String available = String.join(",", StructureTemplateLoader.getAvailableStructures()); + SteveMod.LOGGER.info("Phase FEASIBILITY: selected templates={} (available: {})", + project.selectedTemplates, available); + publishLog(PlanLogEvent.Severity.INFO, "FEASIBILITY: templates=" + project.selectedTemplates); + transitionTo(BuildPhase.DESIGN); + } + + @Override + protected void onTick() { + switch (project.phase) { + case DESIGN -> runDesign(); + case AWAITING_DESIGN_APPROVAL -> runAwaitingApproval(); + case CONSTRUCTION -> runConstruction(); + case AWAITING_ACCEPTANCE, COMPLETED, FAILED, FEASIBILITY -> { /* terminal / unused */ } + } + } + + @Override + protected void onCancel() { + if (project.phase != BuildPhase.FAILED && project.phase != BuildPhase.COMPLETED) { + transitionTo(BuildPhase.FAILED); + } + } + + @Override + public String getDescription() { + return String.format(Locale.ROOT, "Plan build %s (#%s, %s)", + String.join("+", project.selectedTemplates), project.id, project.phase); + } + + // ===== Phase 2: DESIGN ===== + + private void runDesign() { + if (!(steve.level() instanceof ServerLevel serverLevel)) { + result = ActionResult.failure("PlanBuildAction must run on server level"); + return; + } + + // 1. Resolve origin: nearest player's look-target, fallback to Steve +2 + Player nearest = project.findNearestPlayer(); + BlockPos groundPos; + if (nearest != null) { + var eye = nearest.getEyePosition(1.0F); + var look = nearest.getLookAngle(); + var target = eye.add(look.scale(12)); + BlockPos lookTarget = new BlockPos((int) Math.floor(target.x), (int) Math.floor(target.y), (int) Math.floor(target.z)); + groundPos = lookTarget; + } else { + groundPos = steve.blockPosition().offset(2, 0, 2); + } + project.originPos = groundPos; + + // 2. Walk the spec list. For each entry, load the NBT, resolve + // its world origin via ModuleTransform.apply(localIn, + // prevExit, prevFacing), and append a PlacedModule to the + // project. Skip-on-miss: a single failed NBT drops the + // entry from selectedTemplates and the world keeps building. + List> specs = pendingModuleSpecs; + BlockPos prevExit = groundPos; + PlacedModule.Facing prevFacing = PlacedModule.Facing.S; + List survivors = new ArrayList<>(); + + for (Map spec : specs) { + Object n = spec.get("name"); + String name = n == null ? null : n.toString(); + if (name == null) continue; + int dx = readInt(spec, "dx", 0); + int dy = readInt(spec, "dy", 0); + int dz = readInt(spec, "dz", 0); + PlacedModule.Facing facing = readFacing(spec, "facing", PlacedModule.Facing.S); + + StructureTemplateLoader.LoadedTemplate tpl = StructureTemplateLoader.loadFromNBT(serverLevel, name); + if (tpl == null) { + SteveMod.LOGGER.warn("PlanBuildAction: template '{}' not found, skipping", name); + continue; + } + + BlockPos localIn = new BlockPos(dx, dy, dz); + BlockPos worldIn = ModuleTransform.apply(localIn, prevExit, prevFacing); + + project.placedModules.add(new PlacedModule(tpl, worldIn, facing)); + survivors.add(name); + for (var tb : tpl.blocks) { + project.materials.merge(tb.blockState.getBlock(), 1, Integer::sum); + } + project.totalBlocks += tpl.blocks.size(); + + // Advance the cursor to this module's world exit. + prevExit = ModuleTransform.apply( + ModuleTransform.exitAnchor(tpl, facing), worldIn, facing); + prevFacing = facing; + } + project.selectedTemplates.clear(); + project.selectedTemplates.addAll(survivors); + + if (project.placedModules.isEmpty()) { + result = ActionResult.failure( + "None of the requested NBT templates could be loaded: " + specs); + return; + } + + // 4. Push design doc to nearest player + String design = BuildDesignFormatter.fullDesign(project); + if (nearest != null) { + for (String line : design.replace("\r\n", "\n").split("\n")) { + nearest.sendSystemMessage(Component.literal(line)); + } + } else { + SteveMod.LOGGER.info("Design doc (no player to message):\n{}", design); + } + + // 4b. Mirror design to the external dashboard. We flatten every + // loaded module's blocks into a single world-space list so the + // front-end can render the whole structure in Three.js without + // knowing about per-module origins or facings — every block's + // world position goes through ModuleTransform.apply. + List blocks = new ArrayList<>(project.totalBlocks); + for (var pm : project.placedModules) { + for (var tb : pm.template.blocks) { + BlockPos worldPos = ModuleTransform.apply( + tb.relativePos, pm.worldOrigin, pm.facing); + String id = tb.blockState.getBlock().builtInRegistryHolder() + .key().location().toString(); + blocks.add(new PlanDesignReadyEvent.BlockEntry( + worldPos.getX(), worldPos.getY(), worldPos.getZ(), id)); + } + } + publishEvent(new PlanDesignReadyEvent( + project.id, design, + PlanDesignReadyEvent.MaterialEntry.fromBlockMap(project.materials, project.totalBlocks), + project.totalBlocks, + blocks)); + + // 5. Archive to mempalace (structured JSON: {modules[], materials, totalBlocks, ...}) + archiveToMempalace(BuildPhase.DESIGN, "design", serializeProject(project, steve.getSteveName(), BuildPhase.DESIGN, null)); + + // 6. Wait for approval (no auto-timeout — player must /steve approve or /steve halt) + transitionTo(BuildPhase.AWAITING_DESIGN_APPROVAL); + } + + /** Coerce a JSON / map value to an int, with a default. Handles both + * {@code Number} (Gson emits {@code Double} for integer numerics in + * some paths) and {@code String} inputs — the latter for the rare + * case where the LLM serialises an int as a quoted string. */ + static int readInt(Map m, String key, int def) { + Object v = m.get(key); + if (v instanceof Number n) return n.intValue(); + if (v instanceof String s) { + try { return Integer.parseInt(s.trim()); } catch (NumberFormatException ignored) { return def; } + } + return def; + } + + static PlacedModule.Facing readFacing(Map m, String key, PlacedModule.Facing def) { + Object v = m.get(key); + if (v == null) return def; + String s = v.toString().trim().toUpperCase(Locale.ROOT); + return switch (s) { + case "N", "NORTH" -> PlacedModule.Facing.N; + case "E", "EAST" -> PlacedModule.Facing.E; + case "S", "SOUTH" -> PlacedModule.Facing.S; + case "W", "WEST" -> PlacedModule.Facing.W; + default -> def; + }; + } + + /** Serialize a {@link BuildProject} as a structured JSON object for mempalace. + * Shape: {schemaVersion, projectId, command, phase, steveName, createdAtMs, + * origin, modules[], materials, totalBlocks, [halted{...}]}. + * The {@code modules} array contains one entry per {@link PlacedModule} with + * the full NBT blocks already resolved to world coordinates through + * {@link ModuleTransform#apply}. {@code haltReasonOrNull} non-null adds a + * {@code halted} object with the halt metadata and omits the (large) per-block + * list — the HALT drawer is metadata-only because the full block layout is + * already archived in the DESIGN drawer for the same project. + * + *

{@code steveName} is taken as a parameter (rather than read from + * {@code p.steve.getSteveName()}) so this method is testable without a live + * Minecraft server: callers pass {@code project.steve.getSteveName()}.

*/ + static JsonObject serializeProject(BuildProject p, String steveName, BuildPhase phase, String haltReasonOrNull) { + JsonObject root = new JsonObject(); + root.addProperty("schemaVersion", 1); + root.addProperty("projectId", p.id); + root.addProperty("command", p.command); + root.addProperty("phase", phase.name()); + root.addProperty("steveName", steveName); + root.addProperty("createdAtMs", p.createdAtMs); + if (p.originPos != null) { + root.add("origin", blockPosToJson(p.originPos)); + } + + JsonArray modules = new JsonArray(); + boolean includeBlocks = haltReasonOrNull == null; + for (PlacedModule pm : p.placedModules) { + JsonObject m = new JsonObject(); + m.addProperty("name", pm.template.name); + m.addProperty("facing", pm.facing.name()); + m.add("worldOrigin", blockPosToJson(pm.worldOrigin)); + m.add("worldExit", blockPosToJson(pm.worldExit())); + m.addProperty("width", pm.template.width); + m.addProperty("height", pm.template.height); + m.addProperty("depth", pm.template.depth); + if (includeBlocks) { + JsonArray blocks = new JsonArray(); + for (var tb : pm.template.blocks) { + BlockPos worldPos = ModuleTransform.apply(tb.relativePos, pm.worldOrigin, pm.facing); + JsonObject b = new JsonObject(); + b.addProperty("x", worldPos.getX()); + b.addProperty("y", worldPos.getY()); + b.addProperty("z", worldPos.getZ()); + b.addProperty("blockId", + tb.blockState.getBlock().builtInRegistryHolder().key().location().toString()); + blocks.add(b); + } + m.add("blocks", blocks); + } + modules.add(m); + } + root.add("modules", modules); + + JsonObject materials = new JsonObject(); + for (var e : p.materials.entrySet()) { + String id = BuiltInRegistries.BLOCK.getKey(e.getKey()).toString(); + materials.addProperty(id, e.getValue()); + } + root.add("materials", materials); + + root.addProperty("totalBlocks", p.totalBlocks); + + if (haltReasonOrNull != null) { + JsonObject halted = new JsonObject(); + halted.addProperty("reason", haltReasonOrNull); + halted.addProperty("blocksPlaced", p.blocksPlaced); + halted.addProperty("totalBlocks", p.totalBlocks); + halted.addProperty("fromPhase", p.phase.name()); + root.add("halted", halted); + } + + return root; + } + + private static JsonObject blockPosToJson(BlockPos pos) { + JsonObject o = new JsonObject(); + o.addProperty("x", pos.getX()); + o.addProperty("y", pos.getY()); + o.addProperty("z", pos.getZ()); + return o; + } + + private void runAwaitingApproval() { + // Idle — waits indefinitely for player /steve approve or /steve halt. + } + + private void runConstruction() { + if (!(steve.level() instanceof ServerLevel serverLevel)) { + result = ActionResult.failure("PlanBuildAction.runConstruction must run on server level"); + return; + } + + // Walk the same flattened order used by the dashboard snapshot, so the + // 3D preview and the placed world are guaranteed to line up. + int total = project.totalBlocks; + if (project.nextBlockIndex >= total) { + transitionTo(BuildPhase.COMPLETED); + result = ActionResult.success( + "Built " + project.blocksPlaced + "/" + total + " blocks for project #" + project.id); + publishEvent(new PlanLogEvent( + project.id, + PlanLogEvent.Severity.INFO, + "Construction complete: " + project.blocksPlaced + "/" + total)); + return; + } + + int delay = Math.max(1, SteveConfig.BUILD_TICK_DELAY.get()); + if (constructionCooldown > 0) { + constructionCooldown--; + return; + } + + if (placeNextBlock(serverLevel)) { + constructionCooldown = delay; + } else { + // Try the same index again next tick (Steve still pathing, position + // blocked, etc.). Don't burn the cooldown. + } + } + + /** Attempt to place the block at {@code project.nextBlockIndex}. + * @return true if a block was actually placed (resets the per-block cooldown); + * false if Steve is out of range, the target cell is occupied by a + * non-air non-liquid block, or the project has no block to place. */ + private boolean placeNextBlock(ServerLevel level) { + int idx = project.nextBlockIndex; + // Flatten placedModules × blocks into the same (moduleIndex, blockIndex) + // order that runDesign / PlanDashboardServer.buildSnapshot emit, so the + // 3D preview and the placed world are guaranteed to line up. World + // coordinates go through ModuleTransform.apply — the single source of + // truth for rotation. + int remaining = idx; + BlockPos worldPos = null; + net.minecraft.world.level.block.state.BlockState state = null; + for (var pm : project.placedModules) { + if (remaining < pm.template.blocks.size()) { + var tb = pm.template.blocks.get(remaining); + worldPos = ModuleTransform.apply(tb.relativePos, pm.worldOrigin, pm.facing); + state = tb.blockState; + break; + } + remaining -= pm.template.blocks.size(); + } + if (worldPos == null || state == null) { + // Index out of range (project.totalBlocks shrunk since last tick). + // Skip to end. + project.nextBlockIndex = project.totalBlocks; + return false; + } + + // Move Steve into range first. Don't burn the index until he's there. + if (!steve.blockPosition().closerThan(worldPos, 6.0)) { + steve.getNavigation().moveTo(worldPos.getX() + 0.5, worldPos.getY(), worldPos.getZ() + 0.5, 1.0); + return false; + } + + net.minecraft.world.level.block.state.BlockState current = level.getBlockState(worldPos); + if (!current.isAir() && !current.liquid()) { + // Cell already occupied (e.g. worldgen placed something here) — skip. + publishLog(PlanLogEvent.Severity.WARN, + "Skipping " + worldPos + ": already " + current.getBlock().getName().getString()); + project.nextBlockIndex++; + project.blocksPlaced++; + return true; + } + + level.setBlock(worldPos, state, 3); + project.nextBlockIndex++; + project.blocksPlaced++; + if (project.blocksPlaced % 50 == 0 || project.blocksPlaced == project.totalBlocks) { + publishLog(PlanLogEvent.Severity.INFO, + "Construction progress: " + project.blocksPlaced + "/" + project.totalBlocks); + } + return true; + } + + // ===== Player commands ===== + + /** Called by ActionExecutor when player issues /steve approve. */ + public void approve() { + if (project.phase != BuildPhase.AWAITING_DESIGN_APPROVAL) { + SteveMod.LOGGER.warn("PlanBuildAction.approve() called in phase {} — ignoring", project.phase); + return; + } + project.lastApproved = project.phase; + SteveMod.LOGGER.info("BuildProject #{} approved at phase {}", project.id, project.phase); + publishEvent(new PlanApprovedEvent(project.id, project.phase, "player")); + publishLog(PlanLogEvent.Severity.INFO, "Approved by player at phase " + project.phase); + // Drive construction directly — no second confirmation. result stays null + // so BaseAction.isComplete() returns false and onTick keeps being invoked. + transitionTo(BuildPhase.CONSTRUCTION); + } + + /** Called by ActionExecutor when player issues /steve halt or timeout fires. */ + public void halt(String reason) { + if (project.phase == BuildPhase.FAILED || project.phase == BuildPhase.COMPLETED) { + return; + } + SteveMod.LOGGER.info("BuildProject #{} halted at phase {}: {}", project.id, project.phase, reason); + + // Archive halt record (design doc stays in mempalace — memory continuity). + // Same JSON schema as DESIGN, plus a `halted` object; per-block list is + // omitted because the DESIGN drawer already carries the full layout. + archiveToMempalace(BuildPhase.FAILED, "halted", serializeProject(project, steve.getSteveName(), project.phase, reason)); + + publishEvent(new PlanHaltedEvent( + project.id, project.phase, reason, + project.mempalaceRefs.get(BuildPhase.DESIGN), + project.blocksPlaced, project.totalBlocks)); + publishLog(PlanLogEvent.Severity.WARN, "Halted: " + reason); + + result = ActionResult.failure( + "Build halted at phase " + project.phase + ": " + reason + + ". Design archived: " + project.mempalaceRefs.getOrDefault(BuildPhase.DESIGN, "(none)"), + true); + project.phase = BuildPhase.FAILED; + } + + // ===== Helpers ===== + + private void transitionTo(BuildPhase next) { + BuildPhase prev = project.phase; + project.phase = next; + SteveMod.LOGGER.info("BuildProject #{} phase: {} -> {}", project.id, prev, next); + publishEvent(new PlanPhaseChangedEvent(project.id, prev, next, null)); + } + + private void publishEvent(com.steve.ai.event.plan.PlanEvent event) { + try { + SteveMod.getPlanEventBus().publish(event); + } catch (Exception e) { + SteveMod.LOGGER.warn("Failed to publish plan event: {}", e.getMessage()); + } + } + + private void publishLog(PlanLogEvent.Severity severity, String message) { + publishEvent(new PlanLogEvent(project.id, severity, message)); + } + + private void archiveToMempalace(BuildPhase phase, String roomSuffix, com.google.gson.JsonObject payload) { + try { + String room = project.id + "_" + roomSuffix; + String wing = switch (phase) { + case DESIGN -> "build_designs"; + case FAILED -> "build_halted"; + case AWAITING_ACCEPTANCE -> "build_acceptance"; + case COMPLETED -> "built_structures"; + default -> "build_misc"; + }; + Map args = Map.of( + "wing", wing, + "room", room, + "content", GSON.toJson(payload), + "added_by", "steve-ai" + ); + String res = MCPToolRegistry.getInstance().callTool("mempalace:mempalace_add_drawer", args); + String ref = "wing=" + wing + "/room=" + room; + project.mempalaceRefs.put(phase, ref); + SteveMod.LOGGER.info("Archived {} to mempalace {} (response: {})", phase, ref, truncate(res, 200)); + } catch (Exception e) { + SteveMod.LOGGER.warn("Failed to archive {} to mempalace: {}", phase, e.getMessage()); + } + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max) + "..."; + } + + /** + * Plan-mode fallback: if {@code moduleList} has exactly one entry, try to + * compose same-type siblings from {@link StructureTemplateLoader}. Returns + * a new list when expansion fires; returns the input list unchanged + * otherwise. Package-private + static so the rule can be tested without a + * live {@code SteveEntity} or event bus. + * + *

{@code capSupplier} reads {@code MAX_TEMPLATES_PER_PLAN} at call + * time so test code can inject a different cap without poking the static + * config value.

+ */ + static List> expandSingleStructureFallback( + List> moduleList, + java.util.function.IntSupplier capSupplier) { + if (moduleList == null || moduleList.size() != 1) { + return moduleList; + } + Object nameObj = moduleList.get(0).get("name"); + if (nameObj == null) { + return moduleList; + } + String headName = nameObj.toString(); + List siblings = + StructureTemplateLoader.getSiblingStructuresOfSameType(headName); + if (siblings == null) { + SteveMod.LOGGER.warn( + "PlanBuildAction: single structure '{}' not found in StructureTemplateLoader, keeping 1-element list.", + headName); + return moduleList; + } + if (siblings.size() <= 1) { + return moduleList; + } + return composeFromSiblings(headName, siblings, + StructureTemplateLoader.getTypeFor(headName), capSupplier); + } + + /** + * Pure expansion logic: given the head template name, a pre-fetched list + * of same-type siblings (in registration order, including the head), and + * the type label, build a new expanded list with {@code headName} first + * and the rest appended in order, capped by {@code capSupplier}. + * + *

Static + side-effect free (modulo logging) so unit tests can call it + * directly without mocking {@code StructureTemplateLoader}.

+ */ + static List> composeFromSiblings( + String headName, + List siblings, + String typeName, + java.util.function.IntSupplier capSupplier) { + // headName first, then the rest in registration order. + List ordered = new ArrayList<>(siblings.size()); + ordered.add(headName); + for (String s : siblings) { + if (!s.equals(headName)) ordered.add(s); + } + int fallbackCap = capSupplier.getAsInt(); + int keep = Math.min(ordered.size(), fallbackCap); + if (ordered.size() > fallbackCap) { + SteveMod.LOGGER.warn( + "PlanBuildAction: same-type expansion produced {} templates, capping to {}", + ordered.size(), fallbackCap); + } + List> expanded = new ArrayList<>(keep); + for (int i = 0; i < keep; i++) { + Map spec = new java.util.HashMap<>(); + spec.put("name", ordered.get(i)); + expanded.add(spec); + } + SteveMod.LOGGER.warn( + "PlanBuildAction: LLM returned single structure '{}' for plan-mode, " + + "auto-expanding to {} templates of type '{}' (LLM ignored ≥2 rule).", + headName, expanded.size(), typeName); + return expanded; + } +} diff --git a/src/main/java/com/steve/ai/client/SteveGUI.java b/src/main/java/com/steve/ai/client/SteveGUI.java index 45ba4552..add513c5 100644 --- a/src/main/java/com/steve/ai/client/SteveGUI.java +++ b/src/main/java/com/steve/ai/client/SteveGUI.java @@ -397,6 +397,20 @@ private static void sendCommand(String command) { return; } + // Pass-through: any input that targets the /steve root command itself + // (list, status, approve, halt, stop, remove, plan, etc.) must NOT be wrapped + // as "tell " — that would cause the LLM to receive + // the bare subcommand and hallucinate a final answer. + String trimmed = command.stripLeading(); + String lower = trimmed.toLowerCase(); + if (lower.startsWith("/steve") || lower.startsWith("steve ")) { + if (mc.player != null) { + String cmd = trimmed.startsWith("/") ? trimmed.substring(1) : trimmed; + mc.player.connection.sendCommand(cmd); + } + return; + } + List targetSteves = parseTargetSteves(command); if (targetSteves.isEmpty()) { diff --git a/src/main/java/com/steve/ai/command/SteveCommands.java b/src/main/java/com/steve/ai/command/SteveCommands.java index a6e8d4f4..d20f0cd3 100644 --- a/src/main/java/com/steve/ai/command/SteveCommands.java +++ b/src/main/java/com/steve/ai/command/SteveCommands.java @@ -4,18 +4,62 @@ import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.context.CommandContext; import com.steve.ai.SteveMod; +import com.steve.ai.action.BuildProject; +import com.steve.ai.config.SteveConfig; +import com.steve.ai.dashboard.PlanDashboardServer; import com.steve.ai.entity.SteveEntity; import com.steve.ai.entity.SteveManager; +import com.steve.ai.llm.react.BuildDesignFormatter; import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.Commands; import net.minecraft.network.chat.Component; import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; import net.minecraft.world.phys.Vec3; +/** + * {@code /steve} 根命令的 Brigadier 命令树。 + * + *

三个功能分组:

+ *
    + *
  • Steve 生命周期:{@code spawn / remove / list / stop} — 管理 Steve 实体
  • + *
  • LLM 任务派发:{@code tell} — 给指定 Steve 发自然语言指令(启动 ReAct)
  • + *
  • 先规划再施工工作流:{@code plan / approve / halt / status} — 四阶段施工流程 + * (可研 → 设计 → 施工 → 验收)
  • + *
+ */ public class SteveCommands { - + + /** + * 把完整的 {@code /steve} 命令树注册到 dispatcher。 + * + *

命令清单:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
命令功能
{@code /steve spawn }在玩家面前 3 格处生成一个 Steve 实体
{@code /steve remove }移除已存在的 Steve
{@code /steve list}列出所有活跃的 Steve
{@code /steve stop }强制取消 Steve 当前动作并清空任务队列
{@code /steve tell }给指定 Steve 发自然语言指令(LLM ReAct)
{@code /steve plan }对最近的 Steve 走 LLM 规划模式 — 输出设计书,等待 + * {@code /steve approve} 后才开始放方块
{@code /steve approve}批准最近的 Steve 当前 BuildProject 待批准的阶段
{@code /steve halt}中止最近的 Steve 当前 BuildProject,设计书留在 mempalace
{@code /steve status}打印最近的 Steve 当前 BuildProject 的阶段、模板、进度
+ */ public static void register(CommandDispatcher dispatcher) { dispatcher.register(Commands.literal("steve") + // ----- Steve 生命周期 ----- .then(Commands.literal("spawn") .then(Commands.argument("name", StringArgumentType.string()) .executes(SteveCommands::spawnSteve))) @@ -27,13 +71,38 @@ public static void register(CommandDispatcher dispatcher) { .then(Commands.literal("stop") .then(Commands.argument("name", StringArgumentType.string()) .executes(SteveCommands::stopSteve))) + // ----- LLM 任务派发 ----- .then(Commands.literal("tell") .then(Commands.argument("name", StringArgumentType.string()) .then(Commands.argument("command", StringArgumentType.greedyString()) .executes(SteveCommands::tellSteve)))) + // ----- 先规划再施工工作流 ----- + // /steve plan — LLM 选模板、出设计书、等玩家 approve + .then(Commands.literal("plan") + .then(Commands.argument("description", StringArgumentType.greedyString()) + .executes(ctx -> SteveCommands.planBuild( + StringArgumentType.getString(ctx, "description"), ctx)))) + // 这三个不带 name 参数 — 自动作用于玩家附近有活跃 BuildProject 的最近 Steve + .then(Commands.literal("approve") + .executes(SteveCommands::approveBuild)) + .then(Commands.literal("halt") + .executes(SteveCommands::haltBuild)) + .then(Commands.literal("status") + .executes(SteveCommands::buildStatus)) + // ----- External HTML plan dashboard ----- + // /steve dashboard — start the embedded HTTP server and print the URL + // /steve dashboard stop — stop the server + .then(Commands.literal("dashboard") + .executes(SteveCommands::startDashboard) + .then(Commands.literal("stop") + .executes(SteveCommands::stopDashboard))) ); } + /** + * {@code /steve spawn } — 在玩家面前 3 格处生成一个 Steve 实体(控制台执行时 + * 退化为向东 3 格)。如果名字已存在或单玩家 Steve 数量达到上限则失败。 + */ private static int spawnSteve(CommandContext context) { String name = StringArgumentType.getString(context, "name"); CommandSourceStack source = context.getSource(); @@ -65,6 +134,10 @@ private static int spawnSteve(CommandContext context) { } } + /** + * {@code /steve remove } — 按名字移除一个 Steve 实体。如果当前没有该名字的 + * Steve 则返回失败提示。 + */ private static int removeSteve(CommandContext context) { String name = StringArgumentType.getString(context, "name"); CommandSourceStack source = context.getSource(); @@ -79,6 +152,9 @@ private static int removeSteve(CommandContext context) { } } + /** + * {@code /steve list} — 列出所有活跃的 Steve 名字和数量。只读,无副作用,不广播给其他玩家。 + */ private static int listSteves(CommandContext context) { CommandSourceStack source = context.getSource(); SteveManager manager = SteveMod.getSteveManager(); @@ -92,6 +168,11 @@ private static int listSteves(CommandContext context) { return 1; } + /** + * {@code /steve stop } — 硬中断指定 Steve 当前正在做的任何事情:停止当前动作、 + * 清空记忆中的任务队列、广播完成状态。与 {@code /steve halt} 的区别:stop 是通用的硬 + * 中断,halt 是 build-aware 的并会归档到 mempalace。 + */ private static int stopSteve(CommandContext context) { String name = StringArgumentType.getString(context, "name"); CommandSourceStack source = context.getSource(); @@ -110,27 +191,219 @@ private static int stopSteve(CommandContext context) { } } + /** + * {@code /steve tell } — 把自然语言指令派发给指定的 Steve。LLM 调用 + * 在后台线程执行(非阻塞),聊天线程立即返回。Steve 的 ReAct agent 之后会按步驱动响应。 + */ private static int tellSteve(CommandContext context) { String name = StringArgumentType.getString(context, "name"); String command = StringArgumentType.getString(context, "command"); CommandSourceStack source = context.getSource(); - + SteveManager manager = SteveMod.getSteveManager(); SteveEntity steve = manager.getSteve(name); - + if (steve != null) { // Disabled command feedback message // source.sendSuccess(() -> Component.literal("Instructing " + name + ": " + command), true); - + new Thread(() -> { steve.getActionExecutor().processNaturalLanguageCommand(command); }).start(); - + return 1; } else { source.sendFailure(Component.literal("Steve not found: " + name)); return 0; } } + + // ===== Plan-then-build subcommands ===== + + /** + * 找玩家附近、且当前有活跃 {@link BuildProject} 的最近 Steve。{@code /steve approve}、 + * {@code /steve halt}、{@code /steve status} 这三个命令都靠这个 helper 隐式选目标 + * (都不带 name 参数)。 + * + * @return 最近的、有活跃 build 的 Steve;没有则返回 {@code null} + */ + private static SteveEntity findSteveWithActiveBuild(Player player) { + SteveManager manager = SteveMod.getSteveManager(); + if (player == null) return null; + SteveEntity nearest = null; + double nearestDist = Double.MAX_VALUE; + for (String name : manager.getSteveNames()) { + SteveEntity s = manager.getSteve(name); + if (s == null) continue; + if (s.getActionExecutor().getActiveBuildProject() == null) continue; + double d = player.distanceTo(s); + if (d < nearestDist) { + nearestDist = d; + nearest = s; + } + } + return nearest; + } + + /** Locate the nearest Steve to the issuing player, regardless of whether + * that Steve is currently busy. Mirrors {@link #findSteveWithActiveBuild} + * but drops the "has active build" filter — {@code /steve plan} is for + * kicking off a new build, so the Steve may be idle or already mid-task. */ + private static SteveEntity findNearestSteve(Player player) { + SteveManager manager = SteveMod.getSteveManager(); + if (player == null) return null; + SteveEntity nearest = null; + double nearestDist = Double.MAX_VALUE; + for (String name : manager.getSteveNames()) { + SteveEntity s = manager.getSteve(name); + if (s == null) continue; + double d = player.distanceTo(s); + if (d < nearestDist) { + nearestDist = d; + nearest = s; + } + } + return nearest; + } + + /** + * {@code /steve approve} — 批准最近 Steve 当前 BuildProject 待批准的阶段。当前用于 + * {@code AWAITING_DESIGN_APPROVAL}(PR2 落地后还会用于 {@code AWAITING_ACCEPTANCE})。 + * 如果没有 Steve 在等待批准则是 no-op。 + */ + private static int approveBuild(CommandContext context) { + CommandSourceStack source = context.getSource(); + Player player = source.getPlayer(); + SteveEntity steve = findSteveWithActiveBuild(player); + if (steve == null) { + source.sendFailure(Component.literal("No Steve currently awaiting approval")); + return 0; + } + steve.getActionExecutor().approveCurrentBuild(); + source.sendSuccess(() -> Component.literal("Approved build for " + steve.getSteveName()), true); + return 1; + } + + /** + * {@code /steve halt} — 在任意阶段中止最近 Steve 的活跃 BuildProject。BuildProject + * 转为 {@code FAILED},中止原因归档到 mempalace 的 {@code build_halted} wing, + * {@code build_designs} wing 的设计书保留(记忆连续)。已放置的方块**不**回滚 — + * 那是单独的 undo 流程。 + */ + private static int haltBuild(CommandContext context) { + CommandSourceStack source = context.getSource(); + Player player = source.getPlayer(); + SteveEntity steve = findSteveWithActiveBuild(player); + if (steve == null) { + source.sendFailure(Component.literal("No Steve currently in a build to halt")); + return 0; + } + steve.getActionExecutor().haltCurrentBuild("player halted via /steve halt"); + source.sendSuccess(() -> Component.literal("Halted build for " + steve.getSteveName() + " (design archived)"), true); + return 1; + } + + /** + * {@code /steve status} — 打印最近 Steve 当前 BuildProject 的一行状态摘要:阶段、选中模板、 + * 施工进度(已放/总块数)。只读 debug 命令。即使没有活跃 project 也返回成功 (1), + * 并输出 "No active build project." 提示。 + */ + private static int buildStatus(CommandContext context) { + CommandSourceStack source = context.getSource(); + Player player = source.getPlayer(); + SteveEntity steve = findSteveWithActiveBuild(player); + if (steve == null) { + source.sendSuccess(() -> Component.literal("No active build project."), false); + return 1; + } + BuildProject project = steve.getActionExecutor().getActiveBuildProject(); + String text = BuildDesignFormatter.header(project) + System.lineSeparator() + + "状态: " + project.phase + + ", 模板: " + (project.selectedTemplates.isEmpty() + ? "(none)" + : String.join("+", project.selectedTemplates)) + + ", 进度: " + project.blocksPlaced + "/" + project.totalBlocks; + for (String line : text.split("\n")) { + source.sendSuccess(() -> Component.literal(line), false); + } + return 1; + } + + // ===== /steve plan — LLM-driven plan-mode entry ===== + + /** + * Brigadier command body for {@code /steve plan }: locate the + * nearest Steve to the issuing player and hand the description to + * {@link com.steve.ai.action.ActionExecutor#startPlannedBuild}. The actual + * plan-mode prompt template lives in {@code PromptBuilder.buildPlanPrompt} + * — this method is just command-layer plumbing. + */ + private static int planBuild(String description, CommandContext context) { + CommandSourceStack source = context.getSource(); + Player player = source.getPlayer(); + SteveEntity steve = findNearestSteve(player); + if (steve == null) { + source.sendFailure(Component.literal("No Steve found nearby — /steve spawn first")); + return 0; + } + steve.getActionExecutor().startPlannedBuild(description); + source.sendSuccess(() -> Component.literal( + "Planning '" + description + "' for " + steve.getSteveName() + + " — LLM will pick template and emit design doc, then wait for /steve approve"), + true); + return 1; + } + + // ===== /steve dashboard — embedded HTTP server for the external plan UI ===== + + /** + * {@code /steve dashboard} — 启动一个嵌入式 HTTP server,服务 127.0.0.1 上的 plan + * dashboard 页面。幂等:已启动时只打印 URL 提示。**不**自动打开浏览器——按设计决策。 + */ + private static int startDashboard(CommandContext context) { + CommandSourceStack source = context.getSource(); + PlanDashboardServer existing = SteveMod.getDashboardServer(); + String frontendUrl = SteveConfig.DASHBOARD_FRONTEND_URL.get(); + if (existing != null && existing.isRunning()) { + source.sendSuccess(() -> Component.literal( + "Plan dashboard already running. Open " + frontendUrl + " in your browser."), + false); + return 1; + } + int port = SteveConfig.DASHBOARD_PORT.get(); + PlanDashboardServer server = new PlanDashboardServer(port); + try { + server.start(); + SteveMod.setDashboardServer(server); + source.sendSuccess(() -> Component.literal( + "Plan dashboard backend started on 127.0.0.1:" + port + + ". Open " + frontendUrl + " in your browser."), + true); + return 1; + } catch (Exception e) { + SteveMod.LOGGER.error("Failed to start plan dashboard: {}", e.getMessage(), e); + source.sendFailure(Component.literal("Failed to start plan dashboard: " + e.getMessage())); + return 0; + } + } + + /** + * {@code /steve dashboard stop} — 关闭嵌入式 HTTP server。 + */ + private static int stopDashboard(CommandContext context) { + CommandSourceStack source = context.getSource(); + PlanDashboardServer server = SteveMod.getDashboardServer(); + if (server == null) { + source.sendFailure(Component.literal("Plan dashboard is not running")); + return 0; + } + try { + server.stop(); + } finally { + SteveMod.setDashboardServer(null); + } + source.sendSuccess(() -> Component.literal("Plan dashboard stopped"), true); + return 1; + } } diff --git a/src/main/java/com/steve/ai/config/SteveConfig.java b/src/main/java/com/steve/ai/config/SteveConfig.java index cefd50e3..727d476a 100644 --- a/src/main/java/com/steve/ai/config/SteveConfig.java +++ b/src/main/java/com/steve/ai/config/SteveConfig.java @@ -9,9 +9,21 @@ public class SteveConfig { public static final ForgeConfigSpec.ConfigValue OPENAI_MODEL; public static final ForgeConfigSpec.IntValue MAX_TOKENS; public static final ForgeConfigSpec.DoubleValue TEMPERATURE; + public static final ForgeConfigSpec.ConfigValue OPENAI_BASE_URL; public static final ForgeConfigSpec.IntValue ACTION_TICK_DELAY; public static final ForgeConfigSpec.BooleanValue ENABLE_CHAT_RESPONSES; + public static final ForgeConfigSpec.BooleanValue CREATIVE_MODE; public static final ForgeConfigSpec.IntValue MAX_ACTIVE_STEVES; + public static final ForgeConfigSpec.IntValue BUILD_TICK_DELAY; + public static final ForgeConfigSpec.IntValue MAX_TEMPLATES_PER_PLAN; + public static final ForgeConfigSpec.BooleanValue MCP_ENABLED; + public static final ForgeConfigSpec.ConfigValue MCP_SERVERS; + public static final ForgeConfigSpec.IntValue MCP_TIMEOUT_MS; + public static final ForgeConfigSpec.IntValue REACT_MAX_STEPS; + public static final ForgeConfigSpec.IntValue REACT_OBS_TRUNCATE; + public static final ForgeConfigSpec.IntValue REACT_FAIL_TOLERANCE; + public static final ForgeConfigSpec.IntValue DASHBOARD_PORT; + public static final ForgeConfigSpec.ConfigValue DASHBOARD_FRONTEND_URL; static { ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder(); @@ -41,7 +53,11 @@ public class SteveConfig { TEMPERATURE = builder .comment("Temperature for AI responses (0.0-2.0, lower is more deterministic)") .defineInRange("temperature", 0.7, 0.0, 2.0); - + + OPENAI_BASE_URL = builder + .comment("Custom OpenAI API URL (leave empty for default)") + .define("baseUrl", ""); + builder.pop(); builder.comment("Steve Behavior Configuration").push("behavior"); @@ -53,11 +69,70 @@ public class SteveConfig { ENABLE_CHAT_RESPONSES = builder .comment("Allow Steves to respond in chat") .define("enableChatResponses", true); - + + CREATIVE_MODE = builder + .comment("Creative mode - Steve has unlimited building materials (no mining needed)") + .define("creativeMode", true); + MAX_ACTIVE_STEVES = builder .comment("Maximum number of Steves that can be active simultaneously") .defineInRange("maxActiveSteves", 10, 1, 50); - + + BUILD_TICK_DELAY = builder + .comment("Ticks between each block placement during building (20 ticks = 1 second, default 20 = 1 block/sec)") + .defineInRange("buildTickDelay", 20, 1, 200); + + MAX_TEMPLATES_PER_PLAN = builder + .comment("Maximum number of NBT templates the LLM may combine in one /steve plan (1-10)") + .defineInRange("maxTemplatesPerPlan", 4, 1, 10); + + builder.pop(); + + builder.comment("MCP (Model Context Protocol) Configuration").push("mcp"); + + MCP_ENABLED = builder + .comment("Enable MCP tool calling") + .define("enabled", false); + + MCP_SERVERS = builder + .comment("JSON array of MCP server configurations: [{\"name\":\"mempalace\",\"url\":\"http://localhost:6060\"}]") + .define("servers", "[{\"name\":\"mempalace\",\"url\":\"http://localhost:6060\"}]"); + + MCP_TIMEOUT_MS = builder + .comment("MCP tool call timeout in milliseconds") + .defineInRange("timeoutMs", 30000, 1000, 120000); + + builder.pop(); + + builder.comment("ReAct (Reason + Act) Mode Configuration").push("react"); + + REACT_MAX_STEPS = builder + .comment("Maximum ReAct steps before force-finishing (1-50)") + .defineInRange("maxSteps", 12, 1, 50); + + REACT_OBS_TRUNCATE = builder + .comment("Per-observation character truncation (100-4000)") + .defineInRange("observationTruncateChars", 800, 100, 4000); + + REACT_FAIL_TOLERANCE = builder + .comment("Consecutive LLM parse failures before giving up (1-10)") + .defineInRange("maxConsecutiveFailures", 3, 1, 10); + + builder.pop(); + + builder.comment("HTTP Dashboard Configuration (external HTML plan UI)").push("dashboard"); + + DASHBOARD_PORT = builder + .comment("Port the /steve dashboard embedded HTTP server binds to. 127.0.0.1 only.") + .defineInRange("port", 8765, 1024, 65535); + + DASHBOARD_FRONTEND_URL = builder + .comment("URL the /steve dashboard command tells the player to open. " + + "The embedded HTTP server only serves /events and /command; the " + + "real UI lives at this URL (Vite dev server in development, " + + "or a static host in production).") + .define("frontendUrl", "http://localhost:5173"); + builder.pop(); SPEC = builder.build(); diff --git a/src/main/java/com/steve/ai/dashboard/PlanDashboardServer.java b/src/main/java/com/steve/ai/dashboard/PlanDashboardServer.java new file mode 100644 index 00000000..233a2233 --- /dev/null +++ b/src/main/java/com/steve/ai/dashboard/PlanDashboardServer.java @@ -0,0 +1,549 @@ +package com.steve.ai.dashboard; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mojang.logging.LogUtils; +import com.steve.ai.SteveMod; +import com.steve.ai.entity.SteveEntity; +import com.steve.ai.event.EventBus; +import com.steve.ai.event.plan.PlanEvent; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; +import org.slf4j.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Embedded HTTP server that exposes the plan dashboard to a browser. + * + *

Routes:

+ *
    + *
  • {@code GET /} — serves {@code /assets/steve/dashboard/index.html}
  • + *
  • {@code GET /} — serves static files from the dashboard classpath folder
  • + *
  • {@code GET /events} — Server-Sent Events stream of {@link PlanEvent}s
  • + *
  • {@code POST /command} — accepts {@code {action:"approve"|"halt", projectId:"..."}}
  • + *
+ * + *

Bind address: {@code 127.0.0.1:} only. Listens for {@code /steve dashboard} + * to start it; {@code /steve dashboard stop} stops it. The server is fully + * optional — players who never open the dashboard pay no cost beyond + * {@code EventBus.publish} being a no-op when no subscribers exist.

+ */ +public class PlanDashboardServer { + + private static final Logger LOG = LogUtils.getLogger(); + private static final String CORS_ORIGIN = "http://localhost:5173"; + + private final int port; + private HttpServer http; + private final List subscriptions = new CopyOnWriteArrayList<>(); + private final AtomicReference> clients = new AtomicReference<>(new CopyOnWriteArrayList<>()); + + public PlanDashboardServer(int port) { + this.port = port; + } + + public int getPort() { return port; } + + public synchronized void start() throws IOException { + if (http != null) return; + http = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + http.createContext("/events", new SseHandler()); + http.createContext("/command", new CommandHandler()); + http.createContext("/chat", new ChatHandler()); + http.createContext("/plan", new PlanStartHandler()); + http.setExecutor(Executors.newFixedThreadPool(2, r -> { + Thread t = new Thread(r, "plan-dashboard-http"); + t.setDaemon(true); + return t; + })); + http.start(); + LOG.info("Plan dashboard started at http://127.0.0.1:{}/ (CORS: {})", port, CORS_ORIGIN); + + // Subscribe to all plan events; forward each to every connected SSE client. + subscriptions.addAll(SteveMod.subscribeToAllPlanEvents(this::broadcast)); + } + + public synchronized void stop() { + if (http != null) { + // Close all SSE clients so they notice. + for (SseClient c : clients.get()) { + try { c.close(); } catch (Exception ignored) {} + } + clients.get().clear(); + http.stop(0); + http = null; + } + subscriptions.forEach(EventBus.Subscription::unsubscribe); + subscriptions.clear(); + LOG.info("Plan dashboard stopped"); + } + + public boolean isRunning() { return http != null; } + + /** Push one event to every connected SSE client. */ + private void broadcast(PlanEvent event) { + String sse = PlanEventJson.toSseData(event); + for (SseClient c : clients.get()) { + try { + c.write(sse); + } catch (Exception e) { + // Client gone — drop it lazily. + clients.get().remove(c); + } + } + } + + // ===== Handlers ===== + + /** Sets CORS headers on every response and short-circuits preflight OPTIONS. */ + private void applyCors(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", CORS_ORIGIN); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type"); + exchange.getResponseHeaders().set("Vary", "Origin"); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(204, -1); + } + } + + private final class SseHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + applyCors(exchange); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) return; + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + exchange.getResponseHeaders().set("Content-Type", "text/event-stream; charset=utf-8"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.sendResponseHeaders(200, 0); + + SseClient client = new SseClient(exchange); + clients.get().add(client); + + // Send an initial snapshot so the UI doesn't show "empty" for events + // that happened before the browser connected. buildSnapshot() reads + // SteveEntity state, which is main-thread-only, so we hop back to + // the server thread for the read and return here to write the SSE. + final MinecraftServer mc = SteveMod.getServer(); + if (mc == null) { + // No server yet — server thread can't help. Send an idle + // snapshot synchronously and let the client wait for events. + try { + client.write("data: " + new Gson().toJson(PlanEventJson.idleSnapshot()) + "\n\n"); + } catch (Exception e) { + LOG.warn("Failed to write idle snapshot: {}", e.getMessage()); + clients.get().remove(client); + } + return; + } + try { + mc.execute(() -> { + JsonObject snap; + try { + snap = buildSnapshot(); + } catch (Exception e) { + LOG.error("buildSnapshot() failed; sending idle snapshot. Cause:", e); + snap = PlanEventJson.idleSnapshot(); + } + try { + client.write("data: " + new Gson().toJson(snap) + "\n\n"); + } catch (Exception e) { + LOG.warn("Failed to write initial snapshot to SSE client: {}", e.getMessage()); + clients.get().remove(client); + } + }); + } catch (Exception e) { + // mc.execute() should not throw, but be defensive: the + // HttpServer would otherwise turn this into a 500. + LOG.error("Failed to schedule snapshot write on main thread: ", e); + } + } + } + + private final class CommandHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + applyCors(exchange); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) return; + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + String body; + try (InputStream in = exchange.getRequestBody()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + JsonObject req; + try { + req = JsonParser.parseString(body).getAsJsonObject(); + } catch (Exception e) { + respondJson(exchange, 400, error("invalid_json", e.getMessage())); + return; + } + String action = req.has("action") ? req.get("action").getAsString() : ""; + String projectId = req.has("projectId") ? req.get("projectId").getAsString() : ""; + + // Both the project lookup and the mutation need to run on the + // main server thread (SteveEntity / BuildProject are main-thread- + // only). Hop there, then write the HTTP response from the main + // thread too — the HttpServer lets any thread send the response. + MinecraftServer mc = SteveMod.getServer(); + if (mc == null) { + respondJson(exchange, 503, error("server_not_ready", "Minecraft server not running")); + return; + } + try { + mc.execute(() -> { + try { + SteveEntity target = findSteveByProjectId(projectId); + if (target == null) { + respondJson(exchange, 404, error("project_not_found", + "No active BuildProject with id=" + projectId)); + return; + } + try { + switch (action) { + case "approve" -> { + target.getActionExecutor().approveCurrentBuild(); + broadcastLocal(Map.of("type", "plan.command_ack", + "action", "approve", "projectId", projectId, "ok", true)); + } + case "halt" -> { + target.getActionExecutor().haltCurrentBuild("player halted via dashboard"); + broadcastLocal(Map.of("type", "plan.command_ack", + "action", "halt", "projectId", projectId, "ok", true)); + } + default -> { + respondJson(exchange, 400, error("unknown_action", action)); + return; + } + } + respondJson(exchange, 202, Map.of("ok", true, "queued", action, "projectId", projectId)); + } catch (Exception e) { + LOG.warn("Command '{}' for project {} failed: {}", action, projectId, e.getMessage()); + broadcastLocal(Map.of("type", "plan.command_ack", + "action", action, "projectId", projectId, "ok", false, + "error", e.getMessage())); + respondJson(exchange, 500, error("command_failed", e.getMessage())); + } + } catch (Exception e) { + LOG.error("Command handler failed on main thread: ", e); + try { respondJson(exchange, 500, error("server_error", e.getMessage())); } + catch (Exception ignored) {} + } + }); + } catch (Exception e) { + LOG.error("Failed to schedule command on main thread: ", e); + respondJson(exchange, 503, error("server_not_ready", e.getMessage())); + } + } + } + + private final class ChatHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + applyCors(exchange); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) return; + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + String body; + try (InputStream in = exchange.getRequestBody()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + JsonObject req; + try { + req = JsonParser.parseString(body).getAsJsonObject(); + } catch (Exception e) { + respondJson(exchange, 400, error("invalid_json", e.getMessage())); + return; + } + String steveName = req.has("steveName") ? req.get("steveName").getAsString() : ""; + String message = req.has("message") ? req.get("message").getAsString() : ""; + if (message.isEmpty()) { + respondJson(exchange, 400, error("empty_message", "message must be non-empty")); + return; + } + + MinecraftServer mc = SteveMod.getServer(); + if (mc == null) { + respondJson(exchange, 503, error("server_not_ready", "Minecraft server not running")); + return; + } + try { + mc.execute(() -> { + try { + SteveEntity target = findSteveByName(steveName); + if (target == null) { + respondJson(exchange, 404, error("steve_not_found", + "No Steve with name=" + steveName)); + return; + } + try { + // Echo the user's message back as a USER chat bubble so + // every connected dashboard sees the same conversation. + String projectId = target.getActionExecutor().getActiveBuildProject() != null + ? target.getActionExecutor().getActiveBuildProject().id : ""; + broadcastLocal(java.util.Map.of( + "type", "plan.chat", + "projectId", projectId, + "steveName", target.getSteveName(), + "sender", "USER", + "message", message, + "timestamp", java.time.Instant.now().toString())); + + // Kick off the natural-language pipeline on the main thread. + target.getActionExecutor().processNaturalLanguageCommand(message); + respondJson(exchange, 202, java.util.Map.of( + "ok", true, "queued", "chat", "steveName", target.getSteveName())); + } catch (Exception e) { + LOG.warn("Chat dispatch for {} failed: {}", target.getSteveName(), e.getMessage()); + respondJson(exchange, 500, error("chat_failed", e.getMessage())); + } + } catch (Exception e) { + LOG.error("Chat handler failed on main thread: ", e); + try { respondJson(exchange, 500, error("server_error", e.getMessage())); } + catch (Exception ignored) {} + } + }); + } catch (Exception e) { + LOG.error("Failed to schedule chat on main thread: ", e); + respondJson(exchange, 503, error("server_not_ready", e.getMessage())); + } + } + } + + /** Find a Steve by exact name (case-insensitive). Returns null if not found. */ + private SteveEntity findSteveByName(String name) { + if (name == null || name.isEmpty()) return null; + for (SteveEntity s : SteveMod.getSteveManager().getAllSteves()) { + if (s.getSteveName().equalsIgnoreCase(name)) return s; + } + return null; + } + + /** Find the Steve nearest to any online player. Used by /plan to pick a + * target when the browser didn't name one. Returns null if no Steves + * exist. We scan all players, not the "local" one, because in + * multiplayer the dashboard player may not be in any level. */ + private SteveEntity findNearestSteveToLocalPlayer() { + SteveEntity nearest = null; + double nearestDist = Double.MAX_VALUE; + for (SteveEntity s : SteveMod.getSteveManager().getAllSteves()) { + for (var player : s.level().players()) { + if (player.isSpectator()) continue; + double d = player.distanceTo(s); + if (d < nearestDist) { nearestDist = d; nearest = s; } + } + // If a Steve has no nearby player, fall back to "any" ordering + if (nearest == null) nearest = s; + } + return nearest; + } + + private final class PlanStartHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + applyCors(exchange); + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) return; + if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + String body; + try (InputStream in = exchange.getRequestBody()) { + body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + JsonObject req; + try { + req = JsonParser.parseString(body).getAsJsonObject(); + } catch (Exception e) { + respondJson(exchange, 400, error("invalid_json", e.getMessage())); + return; + } + String description = req.has("description") ? req.get("description").getAsString().trim() : ""; + if (description.isEmpty()) { + respondJson(exchange, 400, error("empty_description", "description must be non-empty")); + return; + } + + MinecraftServer mc = SteveMod.getServer(); + if (mc == null) { + respondJson(exchange, 503, error("server_not_ready", "Minecraft server not running")); + return; + } + try { + mc.execute(() -> { + try { + SteveEntity target = findNearestSteveToLocalPlayer(); + if (target == null) { + respondJson(exchange, 404, error("no_steves", + "No Steve available — spawn one in Minecraft first")); + return; + } + // Reject if a plan is already in flight for this Steve + if (target.getActionExecutor().getActiveBuildProject() != null) { + respondJson(exchange, 409, error("plan_in_progress", + target.getSteveName() + " is already building. Halt it first.")); + return; + } + try { + target.getActionExecutor().startPlannedBuild(description); + respondJson(exchange, 202, java.util.Map.of( + "ok", true, "queued", "plan", + "steveName", target.getSteveName(), + "description", description)); + } catch (Exception e) { + LOG.warn("Plan start for {} failed: {}", target.getSteveName(), e.getMessage()); + respondJson(exchange, 500, error("plan_failed", e.getMessage())); + } + } catch (Exception e) { + LOG.error("PlanStartHandler failed on main thread: ", e); + try { respondJson(exchange, 500, error("server_error", e.getMessage())); } + catch (Exception ignored) {} + } + }); + } catch (Exception e) { + LOG.error("Failed to schedule plan start on main thread: ", e); + respondJson(exchange, 503, error("server_not_ready", e.getMessage())); + } + } + } + + // ===== Helpers ===== + + private void respondJson(HttpExchange ex, int code, Object body) throws IOException { + byte[] data = new Gson().toJson(body).getBytes(StandardCharsets.UTF_8); + ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + ex.sendResponseHeaders(code, data.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(data); + } + } + + private static Map error(String code, String detail) { + Map m = new HashMap<>(); + m.put("ok", false); + m.put("error", code); + if (detail != null) m.put("detail", detail); + return m; + } + + /** Send a synthetic event (not from {@code PlanEventBus}) to all clients. */ + private void broadcastLocal(Map payload) { + String sse = "data: " + new Gson().toJson(payload) + "\n\n"; + for (SseClient c : clients.get()) { + try { c.write(sse); } catch (Exception ignored) {} + } + } + + /** Look up the Steve whose active BuildProject matches {@code projectId}. */ + private SteveEntity findSteveByProjectId(String projectId) { + for (SteveEntity s : SteveMod.getSteveManager().getAllSteves()) { + var p = s.getActionExecutor().getActiveBuildProject(); + if (p != null && p.id.equals(projectId)) { + return s; + } + } + return null; + } + + private JsonObject buildSnapshot() { + // Always ship the list of all active Steves so the browser can target + // a chat / plan start even when no plan is currently running. + java.util.List allNames = new java.util.ArrayList<>(); + for (SteveEntity s : SteveMod.getSteveManager().getAllSteves()) { + allNames.add(s.getSteveName()); + } + for (SteveEntity s : SteveMod.getSteveManager().getAllSteves()) { + var p = s.getActionExecutor().getActiveBuildProject(); + if (p == null) continue; + JsonObject o = new JsonObject(); + o.addProperty("type", "snapshot"); + o.addProperty("projectId", p.id); + o.addProperty("steveName", s.getSteveName()); + o.add("steves", new Gson().toJsonTree(allNames)); + o.addProperty("command", p.command); + o.addProperty("phase", p.phase.name()); + o.addProperty("blocksPlaced", p.blocksPlaced); + o.addProperty("totalBlocks", p.totalBlocks); + o.add("materials", new Gson().toJsonTree(p.materials.entrySet().stream() + .map(e -> { + JsonObject m = new JsonObject(); + m.addProperty("name", e.getKey().getName().getString()); + m.addProperty("count", e.getValue()); + return m; + }).toList())); + // Flatten all placed modules into one world-space block list so + // the dashboard can render the structure in 3D immediately on + // connect. World coordinates go through ModuleTransform.apply + // — the same helper PlanBuildAction.placeNextBlock uses — so + // the preview and the placed world cannot diverge. + java.util.List flat = new java.util.ArrayList<>(); + for (var pm : p.placedModules) { + for (var tb : pm.template.blocks) { + net.minecraft.core.BlockPos worldPos = com.steve.ai.structure.ModuleTransform.apply( + tb.relativePos, pm.worldOrigin, pm.facing); + JsonObject b = new JsonObject(); + b.addProperty("x", worldPos.getX()); + b.addProperty("y", worldPos.getY()); + b.addProperty("z", worldPos.getZ()); + b.addProperty("blockId", tb.blockState.getBlock().builtInRegistryHolder() + .key().location().toString()); + flat.add(b); + } + } + o.add("blocks", new Gson().toJsonTree(flat)); + String ref = p.mempalaceRefs.get(com.steve.ai.llm.react.BuildPhase.DESIGN); + if (ref != null) o.addProperty("mempalaceRef", ref); + return o; + } + return PlanEventJson.idleSnapshot(allNames); + } + + // StaticHandler removed: the Vite project (../web) now serves the page itself. + // PlanDashboardServer only handles /events (SSE) and /command (POST). + + /** Wrapper around an open HTTP exchange that lets us write SSE chunks and + * detect client disconnect. */ + private static final class SseClient { + private final HttpExchange exchange; + private final OutputStream out; + private volatile boolean closed = false; + + SseClient(HttpExchange exchange) throws IOException { + this.exchange = exchange; + this.out = exchange.getResponseBody(); + } + + synchronized void write(String chunk) throws IOException { + if (closed) throw new IOException("client closed"); + out.write(chunk.getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + + void close() { + closed = true; + try { exchange.close(); } catch (Exception ignored) {} + } + } +} diff --git a/src/main/java/com/steve/ai/dashboard/PlanEventJson.java b/src/main/java/com/steve/ai/dashboard/PlanEventJson.java new file mode 100644 index 00000000..1c1163db --- /dev/null +++ b/src/main/java/com/steve/ai/dashboard/PlanEventJson.java @@ -0,0 +1,123 @@ +package com.steve.ai.dashboard; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.steve.ai.event.plan.PlanApprovedEvent; +import com.steve.ai.event.plan.PlanChatEvent; +import com.steve.ai.event.plan.PlanCreatedEvent; +import com.steve.ai.event.plan.PlanDesignReadyEvent; +import com.steve.ai.event.plan.PlanEvent; +import com.steve.ai.event.plan.PlanHaltedEvent; +import com.steve.ai.event.plan.PlanLogEvent; +import com.steve.ai.event.plan.PlanPhaseChangedEvent; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Serializes {@link PlanEvent}s to JSON for the SSE channel. + * Hand-rolled to avoid Gson reflection on every event class — keeps the + * wire format explicit and stable across changes. */ +public final class PlanEventJson { + + private static final Gson GSON = new GsonBuilder() + .disableHtmlEscaping() + .create(); + + private PlanEventJson() {} + + /** Render the event to a JSON object with {@code type} field set to a stable + * string like {@code "plan.created"}. Returned as a {@link JsonObject} for + * callers that want to merge with snapshot data. */ + public static JsonObject toJson(PlanEvent event) { + JsonObject o = new JsonObject(); + if (event instanceof PlanCreatedEvent e) { + o.addProperty("type", "plan.created"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("steveName", e.getSteveName()); + o.addProperty("command", e.getCommand()); + o.add("templates", GSON.toJsonTree(e.getTemplates())); + o.addProperty("phase", e.getPhase().name()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanDesignReadyEvent e) { + o.addProperty("type", "plan.design_ready"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("design", e.getDesign()); + o.add("materials", GSON.toJsonTree(e.getMaterials())); + o.addProperty("totalBlocks", e.getTotalBlocks()); + o.add("blocks", GSON.toJsonTree(e.getBlocks())); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanPhaseChangedEvent e) { + o.addProperty("type", "plan.phase_changed"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("prev", e.getPrev().name()); + o.addProperty("next", e.getNext().name()); + if (e.getDeadlineMs() != null) o.addProperty("deadlineMs", e.getDeadlineMs()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanApprovedEvent e) { + o.addProperty("type", "plan.approved"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("phase", e.getPhase().name()); + o.addProperty("approvedBy", e.getApprovedBy()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanHaltedEvent e) { + o.addProperty("type", "plan.halted"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("phase", e.getPhase().name()); + o.addProperty("reason", e.getReason()); + if (e.getMempalaceRef() != null) o.addProperty("mempalaceRef", e.getMempalaceRef()); + o.addProperty("blocksPlaced", e.getBlocksPlaced()); + o.addProperty("totalBlocks", e.getTotalBlocks()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanLogEvent e) { + o.addProperty("type", "plan.log"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("severity", e.getSeverity().name()); + o.addProperty("message", e.getMessage()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else if (event instanceof PlanChatEvent e) { + o.addProperty("type", "plan.chat"); + o.addProperty("projectId", e.getProjectId()); + o.addProperty("steveName", e.getSteveName()); + o.addProperty("sender", e.getSender().name()); + o.addProperty("message", e.getMessage()); + o.addProperty("timestamp", e.getTimestamp().toString()); + } else { + o.addProperty("type", "plan.unknown"); + o.addProperty("timestamp", Instant.now().toString()); + } + return o; + } + + /** Encode as a single SSE data line: {@code data: \n\n}. */ + public static String toSseData(PlanEvent event) { + return "data: " + GSON.toJson(toJson(event)) + "\n\n"; + } + + /** Build a snapshot object representing "no active project". Sent on SSE + * connect when {@code SteveManager} has no Steve with an active build. + * The caller may pass an explicit list of active Steve names so the + * browser can target a chat / plan even when no plan is in flight. */ + public static JsonObject idleSnapshot(java.util.List steves) { + JsonObject o = new JsonObject(); + o.addProperty("type", "snapshot"); + o.addProperty("projectId", ""); + o.addProperty("idle", true); + o.addProperty("timestamp", Instant.now().toString()); + o.add("steves", GSON.toJsonTree(steves == null ? java.util.List.of() : steves)); + return o; + } + + /** Backwards-compatible overload used by tests. */ + public static JsonObject idleSnapshot() { + return idleSnapshot(java.util.List.of()); + } + + /** Helper for test code: pretty-print a JSON object as a Map for assertions. */ + public static Map toMap(JsonObject o) { + Map out = new LinkedHashMap<>(); + o.entrySet().forEach(e -> out.put(e.getKey(), GSON.fromJson(e.getValue(), Object.class))); + return out; + } +} \ No newline at end of file diff --git a/src/main/java/com/steve/ai/entity/SteveEntity.java b/src/main/java/com/steve/ai/entity/SteveEntity.java index c515d2f3..30c064f9 100644 --- a/src/main/java/com/steve/ai/entity/SteveEntity.java +++ b/src/main/java/com/steve/ai/entity/SteveEntity.java @@ -3,11 +3,13 @@ import com.steve.ai.action.ActionExecutor; import com.steve.ai.memory.SteveMemory; import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; import net.minecraft.network.chat.Component; import net.minecraft.network.syncher.EntityDataAccessor; import net.minecraft.network.syncher.EntityDataSerializers; import net.minecraft.network.syncher.SynchedEntityData; import net.minecraft.world.DifficultyInstance; +import net.minecraft.world.SimpleContainer; import net.minecraft.world.entity.*; import net.minecraft.world.entity.ai.attributes.AttributeSupplier; import net.minecraft.world.entity.ai.attributes.Attributes; @@ -15,8 +17,12 @@ import net.minecraft.world.entity.ai.goal.LookAtPlayerGoal; import net.minecraft.world.entity.ai.goal.RandomLookAroundGoal; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import net.minecraft.world.level.ServerLevelAccessor; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; import org.jetbrains.annotations.Nullable; public class SteveEntity extends PathfinderMob { @@ -26,7 +32,7 @@ public class SteveEntity extends PathfinderMob { private String steveName; private SteveMemory memory; private ActionExecutor actionExecutor; - private int tickCounter = 0; + private SimpleContainer inventory; private boolean isFlying = false; private boolean isInvulnerable = false; @@ -35,8 +41,9 @@ public SteveEntity(EntityType entityType, Level level) this.steveName = "Steve"; this.memory = new SteveMemory(this); this.actionExecutor = new ActionExecutor(this); + this.inventory = new SimpleContainer(36); // 36 slots like a player this.setCustomNameVisible(true); - + this.isInvulnerable = true; this.setInvulnerable(true); } @@ -65,7 +72,7 @@ protected void defineSynchedData() { @Override public void tick() { super.tick(); - + if (!this.level().isClientSide) { actionExecutor.tick(); } @@ -89,14 +96,86 @@ public ActionExecutor getActionExecutor() { return this.actionExecutor; } + public SimpleContainer getInventory() { + return this.inventory; + } + + /** + * Add items to inventory. Returns the remainder that didn't fit. + */ + public ItemStack addItemToInventory(ItemStack stack) { + return this.inventory.addItem(stack); + } + + /** + * Check if inventory contains at least 'count' items of the given block type. + */ + public boolean hasBlock(Block block, int count) { + ItemStack target = new ItemStack(block.asItem()); + int total = 0; + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack slot = inventory.getItem(i); + if (ItemStack.isSameItemSameTags(slot, target)) { + total += slot.getCount(); + if (total >= count) return true; + } + } + return false; + } + + /** + * Get total count of a specific block item in inventory. + */ + public int getBlockCount(Block block) { + ItemStack target = new ItemStack(block.asItem()); + int total = 0; + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack slot = inventory.getItem(i); + if (ItemStack.isSameItemSameTags(slot, target)) { + total += slot.getCount(); + } + } + return total; + } + + /** + * Remove 'count' items of the given block from inventory. Returns true if successful. + */ + public boolean removeBlockFromInventory(Block block, int count) { + if (!hasBlock(block, count)) return false; + + ItemStack target = new ItemStack(block.asItem()); + int remaining = count; + for (int i = 0; i < inventory.getContainerSize() && remaining > 0; i++) { + ItemStack slot = inventory.getItem(i); + if (ItemStack.isSameItemSameTags(slot, target)) { + int take = Math.min(remaining, slot.getCount()); + slot.shrink(take); + remaining -= take; + } + } + return true; + } + @Override public void addAdditionalSaveData(CompoundTag tag) { super.addAdditionalSaveData(tag); tag.putString("SteveName", this.steveName); - + CompoundTag memoryTag = new CompoundTag(); - this.memory.saveToNBT(memoryTag); tag.put("Memory", memoryTag); + + ListTag inventoryTag = new ListTag(); + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + CompoundTag slotTag = new CompoundTag(); + slotTag.putByte("Slot", (byte) i); + stack.save(slotTag); + inventoryTag.add(slotTag); + } + } + tag.put("Inventory", inventoryTag); } @Override @@ -105,10 +184,19 @@ public void readAdditionalSaveData(CompoundTag tag) { if (tag.contains("SteveName")) { this.setSteveName(tag.getString("SteveName")); } - - if (tag.contains("Memory")) { - this.memory.loadFromNBT(tag.getCompound("Memory")); + + if (tag.contains("Inventory")) { + ListTag inventoryTag = tag.getList("Inventory", 10); + this.inventory = new SimpleContainer(36); + for (int i = 0; i < inventoryTag.size(); i++) { + CompoundTag slotTag = inventoryTag.getCompound(i); + int slot = slotTag.getByte("Slot") & 255; + if (slot < inventory.getContainerSize()) { + inventory.setItem(slot, ItemStack.of(slotTag)); + } + } } + } @Override @@ -130,6 +218,15 @@ public void sendChatMessage(String message) { @Override protected void dropCustomDeathLoot(net.minecraft.world.damagesource.DamageSource source, int looting, boolean recentlyHit) { super.dropCustomDeathLoot(source, looting, recentlyHit); + if (inventory != null) { + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + this.spawnAtLocation(stack); + } + } + inventory.clearContent(); + } } public void setFlying(boolean flying) { diff --git a/src/main/java/com/steve/ai/entity/SteveManager.java b/src/main/java/com/steve/ai/entity/SteveManager.java index f2efaf59..bec83548 100644 --- a/src/main/java/com/steve/ai/entity/SteveManager.java +++ b/src/main/java/com/steve/ai/entity/SteveManager.java @@ -57,6 +57,20 @@ public SteveEntity getSteve(String name) { return activeSteves.get(name); } + /** + * Register an existing Steve entity (loaded from save) with the manager. + */ + public void registerExistingSteve(SteveEntity steve) { + String name = steve.getSteveName(); + if (activeSteves.containsKey(name)) { + SteveMod.LOGGER.warn("Steve '{}' already registered, skipping", name); + return; + } + activeSteves.put(name, steve); + stevesByUUID.put(steve.getUUID(), steve); + SteveMod.LOGGER.info("Registered existing Steve: {} (UUID: {})", name, steve.getUUID()); + } + public SteveEntity getSteve(UUID uuid) { return stevesByUUID.get(uuid); } @@ -73,10 +87,14 @@ public boolean removeSteve(String name) { public void clearAllSteves() { SteveMod.LOGGER.info("Clearing {} Steve entities", activeSteves.size()); for (SteveEntity steve : activeSteves.values()) { + SteveMod.LOGGER.info("Removing Steve '{}' (UUID: {}, removed: {})", + steve.getSteveName(), steve.getUUID(), steve.isRemoved()); steve.discard(); + SteveMod.LOGGER.info("After discard - removed: {}", steve.isRemoved()); } activeSteves.clear(); - stevesByUUID.clear(); } + stevesByUUID.clear(); + } public Collection getAllSteves() { return Collections.unmodifiableCollection(activeSteves.values()); diff --git a/src/main/java/com/steve/ai/event/ServerEventHandler.java b/src/main/java/com/steve/ai/event/ServerEventHandler.java index 88d984e0..d3b77dfd 100644 --- a/src/main/java/com/steve/ai/event/ServerEventHandler.java +++ b/src/main/java/com/steve/ai/event/ServerEventHandler.java @@ -3,7 +3,7 @@ import com.steve.ai.SteveMod; import com.steve.ai.entity.SteveEntity; import com.steve.ai.entity.SteveManager; -import com.steve.ai.memory.StructureRegistry; +import com.steve.ai.structure.StructureRegistry; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.phys.Vec3; @@ -11,53 +11,67 @@ import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; +import java.util.ArrayList; +import java.util.List; + @Mod.EventBusSubscriber(modid = SteveMod.MODID, bus = Mod.EventBusSubscriber.Bus.FORGE) public class ServerEventHandler { - private static boolean stevesSpawned = false; @SubscribeEvent public static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) { - if (event.getEntity() instanceof ServerPlayer player) { - ServerLevel level = (ServerLevel) player.level(); - SteveManager manager = SteveMod.getSteveManager(); - if (!stevesSpawned) { manager.clearAllSteves(); - - // Clear structure registry for fresh spatial awareness - StructureRegistry.clear(); - - // Then, remove ALL SteveEntity instances from the world (including ones loaded from NBT) - int removedCount = 0; - for (var entity : level.getAllEntities()) { - if (entity instanceof SteveEntity) { - entity.discard(); - removedCount++; - } - } Vec3 playerPos = player.position(); - Vec3 lookVec = player.getLookAngle(); - - String[] names = {"Steve", "Alex", "Bob", "Charlie"}; - - for (int i = 0; i < 4; i++) { - double offsetX = lookVec.x * 5 + (lookVec.z * (i - 1.5) * 2); - double offsetZ = lookVec.z * 5 + (-lookVec.x * (i - 1.5) * 2); - - Vec3 spawnPos = new Vec3( - playerPos.x + offsetX, - playerPos.y, - playerPos.z + offsetZ - ); - - SteveEntity steve = manager.spawnSteve(level, spawnPos, names[i]); - if (steve != null) { } - } - - stevesSpawned = true; } + if (!(event.getEntity() instanceof ServerPlayer player)) return; + + ServerLevel level = (ServerLevel) player.level(); + SteveManager manager = SteveMod.getSteveManager(); + + // 如果 manager 里已有活跃的 Steve,跳过 + if (manager.getActiveCount() > 0) { + SteveMod.LOGGER.info("Manager 已有 {} 个 Steve,跳过生成", manager.getActiveCount()); + return; + } + + // 扫描世界中是否已有 Steve 实体(从存档恢复的) + List existingSteves = new ArrayList<>(); + for (var entity : level.getAllEntities()) { + if (entity instanceof SteveEntity steve) { + existingSteves.add(steve); + } + } + + if (!existingSteves.isEmpty()) { + SteveMod.LOGGER.info("从存档找到 {} 个 Steve 实体,注册到 manager", existingSteves.size()); + for (SteveEntity steve : existingSteves) { + manager.registerExistingSteve(steve); + } + StructureRegistry.clear(); + return; + } + + // 没有已有实体,生成 4 个新的 + SteveMod.LOGGER.info("没有找到 Steve 实体,生成 4 个新的"); + manager.clearAllSteves(); + StructureRegistry.clear(); + + Vec3 playerPos = player.position(); + Vec3 lookVec = player.getLookAngle(); + String[] names = {"Steve", "Alex", "Bob", "Charlie"}; + + for (int i = 0; i < 4; i++) { + double offsetX = lookVec.x * 5 + (lookVec.z * (i - 1.5) * 2); + double offsetZ = lookVec.z * 5 + (-lookVec.x * (i - 1.5) * 2); + + Vec3 spawnPos = new Vec3( + playerPos.x + offsetX, + playerPos.y, + playerPos.z + offsetZ + ); + + manager.spawnSteve(level, spawnPos, names[i]); } } @SubscribeEvent public static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) { - stevesSpawned = false; + // 不重置 stevesSpawned,防止重复生成 } } - diff --git a/src/main/java/com/steve/ai/event/plan/PlanApprovedEvent.java b/src/main/java/com/steve/ai/event/plan/PlanApprovedEvent.java new file mode 100644 index 00000000..af3541f6 --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanApprovedEvent.java @@ -0,0 +1,25 @@ +package com.steve.ai.event.plan; + +import com.steve.ai.llm.react.BuildPhase; + +import java.time.Instant; + +/** Player (or auto-rule) approved a pending phase. */ +public final class PlanApprovedEvent implements PlanEvent { + private final String projectId; + private final BuildPhase phase; + private final String approvedBy; + private final Instant timestamp; + + public PlanApprovedEvent(String projectId, BuildPhase phase, String approvedBy) { + this.projectId = projectId; + this.phase = phase; + this.approvedBy = approvedBy; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public BuildPhase getPhase() { return phase; } + public String getApprovedBy() { return approvedBy; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanChatEvent.java b/src/main/java/com/steve/ai/event/plan/PlanChatEvent.java new file mode 100644 index 00000000..50bc390b --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanChatEvent.java @@ -0,0 +1,35 @@ +package com.steve.ai.event.plan; + +import java.time.Instant; + +/** Free-form chat line from a Steve (or a player/system message) for the + * external dashboard's chat panel. Distinct from {@link PlanLogEvent}: + * PlanLogEvent is a log line with severity; PlanChatEvent is a chat bubble + * with a sender and a target Steve. + * + *

{@code projectId} is empty when no plan is active (general chat). + * {@code sender} names who wrote the line; the browser renders it as a + * bubble on the corresponding side.

*/ +public final class PlanChatEvent implements PlanEvent { + public enum Sender { USER, STEVE, SYSTEM } + + private final String projectId; + private final String steveName; + private final Sender sender; + private final String message; + private final Instant timestamp; + + public PlanChatEvent(String projectId, String steveName, Sender sender, String message) { + this.projectId = projectId == null ? "" : projectId; + this.steveName = steveName == null ? "" : steveName; + this.sender = sender == null ? Sender.SYSTEM : sender; + this.message = message == null ? "" : message; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public String getSteveName() { return steveName; } + public Sender getSender() { return sender; } + public String getMessage() { return message; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanCreatedEvent.java b/src/main/java/com/steve/ai/event/plan/PlanCreatedEvent.java new file mode 100644 index 00000000..106e5229 --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanCreatedEvent.java @@ -0,0 +1,33 @@ +package com.steve.ai.event.plan; + +import com.steve.ai.llm.react.BuildPhase; + +import java.time.Instant; +import java.util.List; + +/** A new BuildProject has been created (the LLM just selected templates). */ +public final class PlanCreatedEvent implements PlanEvent { + private final String projectId; + private final String steveName; + private final String command; + private final List templates; + private final BuildPhase phase; + private final Instant timestamp; + + public PlanCreatedEvent(String projectId, String steveName, String command, + List templates, BuildPhase phase) { + this.projectId = projectId; + this.steveName = steveName; + this.command = command; + this.templates = templates == null ? List.of() : List.copyOf(templates); + this.phase = phase; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public String getSteveName() { return steveName; } + public String getCommand() { return command; } + public List getTemplates() { return templates; } + public BuildPhase getPhase() { return phase; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanDesignReadyEvent.java b/src/main/java/com/steve/ai/event/plan/PlanDesignReadyEvent.java new file mode 100644 index 00000000..77a1f8f3 --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanDesignReadyEvent.java @@ -0,0 +1,87 @@ +package com.steve.ai.event.plan; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** The design doc text + material breakdown + raw block list are now available. */ +public final class PlanDesignReadyEvent implements PlanEvent { + private final String projectId; + private final String design; + private final List materials; + private final int totalBlocks; + private final List blocks; + private final Instant timestamp; + + public PlanDesignReadyEvent(String projectId, String design, + List materials, int totalBlocks, + List blocks) { + this.projectId = projectId; + this.design = design; + this.materials = materials == null ? List.of() : List.copyOf(materials); + this.totalBlocks = totalBlocks; + this.blocks = blocks == null ? List.of() : List.copyOf(blocks); + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public String getDesign() { return design; } + public List getMaterials() { return materials; } + public int getTotalBlocks() { return totalBlocks; } + public List getBlocks() { return blocks; } + public Instant getTimestamp() { return timestamp; } + + /** Single block in the design, in world (relative-to-origin) coordinates. + * {@code blockId} is the namespace:path registry key of the block (e.g. + * {@code minecraft:oak_planks}) — kept as a string so the dashboard can + * deserialize without depending on Minecraft classpath. */ + public static final class BlockEntry { + private final int x; + private final int y; + private final int z; + private final String blockId; + + public BlockEntry(int x, int y, int z, String blockId) { + this.x = x; + this.y = y; + this.z = z; + this.blockId = blockId; + } + + public int getX() { return x; } + public int getY() { return y; } + public int getZ() { return z; } + public String getBlockId() { return blockId; } + } + + /** Material row for the dashboard UI. Keys from {@code BuildProject.materials} + * are flattened to a human-readable name + count + percent. */ + public static final class MaterialEntry { + private final String name; + private final int count; + private final int percent; + + public MaterialEntry(String name, int count, int percent) { + this.name = name; + this.count = count; + this.percent = percent; + } + + public String getName() { return name; } + public int getCount() { return count; } + public int getPercent() { return percent; } + + public static List fromBlockMap(Map mat, + int total) { + return mat.entrySet().stream() + .sorted((a, b) -> Integer.compare(b.getValue(), a.getValue())) + .map(e -> { + String name = e.getKey().getName().getString(); + int n = e.getValue(); + int pct = total > 0 ? (n * 100 / total) : 0; + return new MaterialEntry(name, n, pct); + }) + .toList(); + } + } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanEvent.java b/src/main/java/com/steve/ai/event/plan/PlanEvent.java new file mode 100644 index 00000000..6cda57d5 --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanEvent.java @@ -0,0 +1,14 @@ +package com.steve.ai.event.plan; + +/** + * Marker interface for events that flow from {@code PlanBuildAction} to the + * external HTML dashboard. + * + *

Implementations are immutable POJOs published on + * {@code SteveMod.getPlanEventBus()}. {@code SimpleEventBus} dispatches by + * exact runtime class, so {@code PlanDashboardServer} subscribes to each + * concrete subtype individually — see + * {@code SteveMod.subscribeToAllPlanEvents}.

+ */ +public interface PlanEvent { +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanHaltedEvent.java b/src/main/java/com/steve/ai/event/plan/PlanHaltedEvent.java new file mode 100644 index 00000000..e27c1b4c --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanHaltedEvent.java @@ -0,0 +1,35 @@ +package com.steve.ai.event.plan; + +import com.steve.ai.llm.react.BuildPhase; + +import java.time.Instant; + +/** Player (or timeout) halted the build. Design stays archived in mempalace. */ +public final class PlanHaltedEvent implements PlanEvent { + private final String projectId; + private final BuildPhase phase; + private final String reason; + private final String mempalaceRef; + private final int blocksPlaced; + private final int totalBlocks; + private final Instant timestamp; + + public PlanHaltedEvent(String projectId, BuildPhase phase, String reason, + String mempalaceRef, int blocksPlaced, int totalBlocks) { + this.projectId = projectId; + this.phase = phase; + this.reason = reason; + this.mempalaceRef = mempalaceRef; + this.blocksPlaced = blocksPlaced; + this.totalBlocks = totalBlocks; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public BuildPhase getPhase() { return phase; } + public String getReason() { return reason; } + public String getMempalaceRef() { return mempalaceRef; } + public int getBlocksPlaced() { return blocksPlaced; } + public int getTotalBlocks() { return totalBlocks; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanLogEvent.java b/src/main/java/com/steve/ai/event/plan/PlanLogEvent.java new file mode 100644 index 00000000..552220ff --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanLogEvent.java @@ -0,0 +1,25 @@ +package com.steve.ai.event.plan; + +import java.time.Instant; + +/** Free-form log line mirrored from SteveMod.LOGGER to the dashboard timeline. */ +public final class PlanLogEvent implements PlanEvent { + public enum Severity { INFO, WARN, ERROR } + + private final String projectId; + private final Severity severity; + private final String message; + private final Instant timestamp; + + public PlanLogEvent(String projectId, Severity severity, String message) { + this.projectId = projectId; + this.severity = severity; + this.message = message; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public Severity getSeverity() { return severity; } + public String getMessage() { return message; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/event/plan/PlanPhaseChangedEvent.java b/src/main/java/com/steve/ai/event/plan/PlanPhaseChangedEvent.java new file mode 100644 index 00000000..5910b410 --- /dev/null +++ b/src/main/java/com/steve/ai/event/plan/PlanPhaseChangedEvent.java @@ -0,0 +1,28 @@ +package com.steve.ai.event.plan; + +import com.steve.ai.llm.react.BuildPhase; + +import java.time.Instant; + +/** A BuildProject transitioned from {@code prev} to {@code next}. */ +public final class PlanPhaseChangedEvent implements PlanEvent { + private final String projectId; + private final BuildPhase prev; + private final BuildPhase next; + private final Long deadlineMs; + private final Instant timestamp; + + public PlanPhaseChangedEvent(String projectId, BuildPhase prev, BuildPhase next, Long deadlineMs) { + this.projectId = projectId; + this.prev = prev; + this.next = next; + this.deadlineMs = deadlineMs; + this.timestamp = Instant.now(); + } + + public String getProjectId() { return projectId; } + public BuildPhase getPrev() { return prev; } + public BuildPhase getNext() { return next; } + public Long getDeadlineMs() { return deadlineMs; } + public Instant getTimestamp() { return timestamp; } +} diff --git a/src/main/java/com/steve/ai/llm/PromptBuilder.java b/src/main/java/com/steve/ai/llm/PromptBuilder.java index 7639d899..ecb04d0c 100644 --- a/src/main/java/com/steve/ai/llm/PromptBuilder.java +++ b/src/main/java/com/steve/ai/llm/PromptBuilder.java @@ -1,80 +1,60 @@ package com.steve.ai.llm; +import com.steve.ai.config.SteveConfig; import com.steve.ai.entity.SteveEntity; import com.steve.ai.memory.WorldKnowledge; +import com.steve.ai.mcp.MCPToolConverter; +import com.steve.ai.mcp.MCPToolRegistry; +import com.steve.ai.structure.StructureTemplateLoader; import net.minecraft.core.BlockPos; +import net.minecraft.world.SimpleContainer; import net.minecraft.world.item.ItemStack; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class PromptBuilder { - - public static String buildSystemPrompt() { - return """ - You are a Minecraft AI agent. Respond ONLY with valid JSON, no extra text. - - FORMAT (strict JSON): - {"reasoning": "brief thought", "plan": "action description", "tasks": [{"action": "type", "parameters": {...}}]} - - ACTIONS: - - attack: {"target": "hostile"} (for any mob/monster) - - build: {"structure": "house", "blocks": ["oak_planks", "cobblestone", "glass_pane"], "dimensions": [9, 6, 9]} - - mine: {"block": "iron", "quantity": 8} (resources: iron, diamond, coal, gold, copper, redstone, emerald) - - follow: {"player": "NAME"} - - pathfind: {"x": 0, "y": 0, "z": 0} - - RULES: - 1. ALWAYS use "hostile" for attack target (mobs, monsters, creatures) - 2. STRUCTURE OPTIONS: house, oldhouse, powerplant, castle, tower, barn, modern - 3. house/oldhouse/powerplant = pre-built NBT templates (auto-size) - 4. castle/tower/barn/modern = procedural (castle=14x10x14, tower=6x6x16, barn=12x8x14) - 5. Use 2-3 block types: oak_planks, cobblestone, glass_pane, stone_bricks - 6. NO extra pathfind tasks unless explicitly requested - 7. Keep reasoning under 15 words - 8. COLLABORATIVE BUILDING: Multiple Steves can work on same structure simultaneously - 9. MINING: Can mine any ore (iron, diamond, coal, etc) - - EXAMPLES (copy these formats exactly): - - Input: "build a house" - {"reasoning": "Building standard house near player", "plan": "Construct house", "tasks": [{"action": "build", "parameters": {"structure": "house", "blocks": ["oak_planks", "cobblestone", "glass_pane"], "dimensions": [9, 6, 9]}}]} - - Input: "get me iron" - {"reasoning": "Mining iron ore for player", "plan": "Mine iron", "tasks": [{"action": "mine", "parameters": {"block": "iron", "quantity": 16}}]} - - Input: "find diamonds" - {"reasoning": "Searching for diamond ore", "plan": "Mine diamonds", "tasks": [{"action": "mine", "parameters": {"block": "diamond", "quantity": 8}}]} - - Input: "kill mobs" - {"reasoning": "Hunting hostile creatures", "plan": "Attack hostiles", "tasks": [{"action": "attack", "parameters": {"target": "hostile"}}]} - - Input: "murder creeper" - {"reasoning": "Targeting creeper", "plan": "Attack creeper", "tasks": [{"action": "attack", "parameters": {"target": "creeper"}}]} - - Input: "follow me" - {"reasoning": "Player needs me", "plan": "Follow player", "tasks": [{"action": "follow", "parameters": {"player": "USE_NEARBY_PLAYER_NAME"}}]} - - CRITICAL: Output ONLY valid JSON. No markdown, no explanations, no line breaks in JSON. - """; + + private static String getAvailableTemplates() { + List templates = StructureTemplateLoader.getAvailableStructures(); + if (templates.isEmpty()) { + return "(none)"; + } + return String.join(", ", templates); + } + + private static String getMaterialRule() { + if (SteveConfig.CREATIVE_MODE.get()) { + return "10. CREATIVE MODE: Unlimited materials. NEVER mine before building. Build directly."; + } + return "10. SURVIVAL MODE: Steve has a 36-slot inventory. Mined blocks go into inventory. Building consumes from inventory. If inventory is empty, mine materials first before building."; } - public static String buildUserPrompt(SteveEntity steve, String command, WorldKnowledge worldKnowledge) { - StringBuilder prompt = new StringBuilder(); - - // Give agents FULL situational awareness - prompt.append("=== YOUR SITUATION ===\n"); - prompt.append("Position: ").append(formatPosition(steve.blockPosition())).append("\n"); - prompt.append("Nearby Players: ").append(worldKnowledge.getNearbyPlayerNames()).append("\n"); - prompt.append("Nearby Entities: ").append(worldKnowledge.getNearbyEntitiesSummary()).append("\n"); - prompt.append("Nearby Blocks: ").append(worldKnowledge.getNearbyBlocksSummary()).append("\n"); - prompt.append("Biome: ").append(worldKnowledge.getBiomeName()).append("\n"); - - prompt.append("\n=== PLAYER COMMAND ===\n"); - prompt.append("\"").append(command).append("\"\n"); - - prompt.append("\n=== YOUR RESPONSE (with reasoning) ===\n"); - - return prompt.toString(); + private static String getMcpToolsPrompt() { + if (!SteveConfig.MCP_ENABLED.get()) { + return "(none - MCP is disabled in config)"; + } + try { + MCPToolRegistry registry = MCPToolRegistry.getInstance(); + if (registry == null) { + return "(none - MCP registry not initialized)"; + } + List tools = registry.getAllTools(); + if (tools.isEmpty()) { + return "(none - no MCP servers connected)"; + } + return MCPToolConverter.toPromptSection(tools); + } catch (Exception e) { + return "(none - error loading MCP tools)"; + } + } + + private static String getInventoryStatus(SteveEntity steve) { + if (SteveConfig.CREATIVE_MODE.get()) { + return "[unlimited - creative mode]"; + } + return formatInventory(steve); } private static String formatPosition(BlockPos pos) { @@ -82,7 +62,163 @@ private static String formatPosition(BlockPos pos) { } private static String formatInventory(SteveEntity steve) { - return "[empty]"; + SimpleContainer inventory = steve.getInventory(); + Map itemCounts = new HashMap<>(); + + for (int i = 0; i < inventory.getContainerSize(); i++) { + ItemStack stack = inventory.getItem(i); + if (!stack.isEmpty()) { + String name = stack.getHoverName().getString(); + itemCounts.merge(name, stack.getCount(), Integer::sum); + } + } + + if (itemCounts.isEmpty()) { + return "[empty]"; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : itemCounts.entrySet()) { + if (sb.length() > 0) sb.append(", "); + sb.append(entry.getKey()).append(" x").append(entry.getValue()); + } + return sb.toString(); } -} + public static String buildReActSystemPrompt(int maxSteps) { + return """ + 你是 Minecraft AI 智能体,正在以 ReAct (推理 + 行动) 模式工作。 + 你每回合只决定一个 action。执行后你会收到一段 Observation 描述结果,请根据 Observation 决定下一步。 + 你最多可用 %d 步完成玩家指令。 + + 输出格式(严格 JSON,只输出一个对象): + {"thought": "你在想什么、为什么选这个 action", + "action": "", + "parameters": {}, + "is_final": false} + + 当任务完全完成(或你确定无法完成)时,输出: + {"thought": "总结已完成的成果", + "is_final": true, + "final_answer": "一句简短友好的中文回复给玩家"} + + 动作列表(用以下英文 key,大小写敏感): + - attack: {"target": "hostile|生物名"} (攻击任何敌对生物/怪物) + - build: {"structure": "<模板名>"} (单个 NBT,自动算尺寸) —— 或 {"structures": [{"name":"","dx":,"dy":,"dz":,"facing":"N|E|S|W"}]} 模块拼装协议 (dx/dy/dz 是相对上一块出口的偏移,在上一块的局部坐标系下表达;facing 默认 S)。长线状结构(铁轨、高速、长城、运河)优先用模块拼装形式,这样每段能旋转拼出折线。 + 组合规则:非平凡结构 ≥3 entry,简单结构 ≥2 entry;玩家明确要单个 piece 时 (如 "放 房子_1") 才允许单 entry。 + - mine: {"block": "<资源名>", "quantity": } (资源: iron, diamond, coal, gold, copper, redstone, emerald 等) + - follow: {"player": "<玩家名>"} + - pathfind: {"x": , "y": , "z": } + - gather: {"resource": "<资源名>", "quantity": } + - craft: {"item": "<物品>", "quantity": } + - mcp: {"tool": "", "args": {}} (调用 MCP 工具) + + 规则: + 1. 攻击目标统一用 "hostile",除非玩家明确指定了具体生物名 + 2. 可用 NBT 模板: %s + 3. 除非明确需要否则不要发 pathfind 任务(build/mine 会自动寻路) + 4. "thought" 字段保持在 30 字以内 + 5. 协同建造:多个 Steve 可以同时做同一个结构 + 6. %s + 7. MCP 工具调用:用 action="mcp",parameters.tool = "serverName:toolName" + 8. 计划模式 (action=build 时): 任何非平凡结构都必须用模块拼装形式,parameters.structures 数组至少 2 个 entry (典型 3+);玩家明确说 "放 X" 的单 piece 例外 —— 此规则与 plan-mode 用户消息中的规则是同一约束。 + 9. 工具调用失败或 action 选错时,Observation 会告诉你 —— 调整重试,或者用 is_final:true 给出说明 + 10. 终止时设 is_final:true,同一个失败的 action 不要重复发两次 + 11. 只输出合法 JSON,不要 markdown、不要散文、JSON 里不要换行 + + 示例: + + 步骤 1 (需要先查信息): + {"thought": "我先查一下有哪些 build 模板可用", + "action": "mcp", + "parameters": {"tool": "mempalace:mempalace_list_drawers", "args": {"wing": "structure_template"}}, + "is_final": false} + + 步骤 2 (拿到模板后建): + {"thought": "house 可用,直接建", + "action": "build", + "parameters": {"structure": "house"}, + "is_final": false} + + 步骤 2b (组合建造 —— 村庄 3 个 module): + {"thought": "村庄需要 房子_1 + 井 + 围栏,都有,直接拼", + "action": "build", + "parameters": {"structures": [ + {"name": "房子_1"}, + {"name": "井", "dx": 0, "dy": 0, "dz": 0, "facing": "S"}, + {"name": "围栏", "dx": 0, "dy": 0, "dz": 0, "facing": "S"} + ]}, + "is_final": false} + + 收尾步骤: + {"thought": "房子在目标位置建好了", + "is_final": true, + "final_answer": "已在 [100, 64, -200] 位置建好房子"} + + 可用 MCP 工具: + %s + """.formatted(maxSteps, getAvailableTemplates(), getMaterialRule(), getMcpToolsPrompt()); + } + + public static String buildReActUserPrompt(SteveEntity steve, String command, String scratchpad) { + return """ + === YOUR SITUATION === + Position: %s + Nearby Players: %s + Nearby Entities: %s + Nearby Blocks: %s + Inventory: %s + Biome: %s + + === USER COMMAND === + "%s" + + === SCRATCHPAD (your previous thoughts, actions, and observations) === + %s + + === YOUR NEXT STEP (JSON only) === + """.formatted( + formatPosition(steve.blockPosition()), + new WorldKnowledge(steve).getNearbyPlayerNames(), + new WorldKnowledge(steve).getNearbyEntitiesSummary(), + new WorldKnowledge(steve).getNearbyBlocksSummary(), + getInventoryStatus(steve), + new WorldKnowledge(steve).getBiomeName(), + command, + scratchpad.isEmpty() ? "(no steps taken yet)" : scratchpad + ); + } + + /** + * Build the plan-mode user-prompt prefix that the LLM sees in + * {@code === USER COMMAND ===}. The constraint text must travel with the + * command (ReActAgent.runStep embeds the original command raw on every + * turn), so this string is prepended to the player's free-form description. + * + *

Used by {@code ActionExecutor.startPlannedBuild} and surfaced via + * {@code /steve plan <description>}.

+ * + * @param description player's free-form request, e.g. {@code "build a castle"} + * @param maxEntries cap on the number of {@code structures[]} entries + * (from {@code SteveConfig.MAX_TEMPLATES_PER_PLAN}) + * @return the full prompt string ready to be queued for the ReAct agent + */ + public static String buildPlanPrompt(String description, int maxEntries) { + return "[PLAN MODE] Player wants a plan, NOT immediate execution. " + + "Do NOT gather/mine/craft/pathfind first — the player will /steve approve " + + "before any blocks are placed.\n\n" + + "可用 NBT 模板 (直接复用,不要重新查询): " + String.join(", ", StructureTemplateLoader.getAvailableStructures()) + "\n\n" + + "You MUST respond by emitting action=build with the module-composition form:\n" + + " parameters: {\"structures\": [{\"name\": \"