Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f456d974d | |||
| 01e97d3a02 | |||
| 9a9d1d6b72 | |||
| ccc703a72e | |||
| 3b20d3f8d0 | |||
| e470d23576 | |||
| 4f6f7a76bb | |||
| 75fda11b99 | |||
| 62d413c792 | |||
| 63d16ae8c7 | |||
| 6d351c2251 | |||
| c4c1428126 | |||
| 4f5e409115 | |||
| 7083ee9ffd | |||
| 69a28847dc | |||
| 48fa394bfc | |||
| feb20ee659 | |||
| 2bcf210552 | |||
| 9cd43b2ce8 | |||
| bd0c499773 | |||
| a18aecd0e0 | |||
| f138cd7d55 | |||
| 147976218b | |||
| d5a023c87c | |||
| c1b5f17c36 | |||
| 62466de01a | |||
| 6f23293ca5 | |||
| 051c2ec898 | |||
| 5576acdc11 | |||
| 4ed342d882 | |||
| 6664b77142 | |||
| 152041cd19 | |||
| 6d190e2f8c | |||
| d48d09905d | |||
| 5bc7340304 | |||
| 76bbeb6848 | |||
| 24bbdfda7d | |||
| 34503990f2 | |||
| 34db1b6ad1 | |||
| 6d7b3e74a6 | |||
| 390bfafed8 | |||
| 2ef9238aa9 | |||
| 7015f41a8e | |||
| c7ba007463 |
@@ -164,6 +164,7 @@ module.exports = {
|
||||
'server-rs/target-*',
|
||||
'apps/desktop-shell/src-tauri/target',
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types/**',
|
||||
'apps/ai-game-creator-shell/src/features/project-workspace/generated/**',
|
||||
'target',
|
||||
'src/main.tsx',
|
||||
'src/App.tsx',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
概念阶段定稿时,还必须创建或更新 `project/速览卡.md`。Runtime 只检查该文件是否存在,不检查内容。请使用下面的固定结构,不要加入审批操作说明或独立的决定状态段落:
|
||||
概念阶段定稿时,创建或更新 `project/速览卡.md`。下面是速览卡的参考结构;根据游戏类型、项目规模和用户要求选择字段,同类内容可以合并,项目不需要的字段可以省略,复杂项目可以增加必要字段。表格和列表中的示例行可按实际对象逐行扩展,不代表数量上限:
|
||||
|
||||
# 速览卡:《游戏名》
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
## 6. 核心循环
|
||||
|
||||
## 7. 目标用户
|
||||
## 7. 目标用户与情境
|
||||
- 核心用户:
|
||||
- 游戏偏好:
|
||||
- 单次游玩时长:
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -33,7 +33,7 @@ description: 写"美术圣经"(美术侧)分册时使用。与总纲(技
|
||||
对象(资产总清单的范围)、物品表(item_id 绑定依据,数据侧已定)、
|
||||
画风 skill(全局画风库可引用)。
|
||||
2. 本件在数据侧表结构定稿后开写(素材清单引用 item_id)。
|
||||
3. 读金样 exemplars/stardew-tdd-art-bible.md——契约表与资产状态表的登记密度以它为准(同层只读一次)。
|
||||
3. 读取金样 exemplars/stardew-tdd-art-bible.md 了解契约表与资产状态表包含的信息类型(同层只读一次)。
|
||||
|
||||
## 三、怎么写(模板即流程,按节)
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ description: 写"数据与配表"(数据侧)分册时使用。与总纲(
|
||||
(架构层的定性基准,在本件落成前 N 日验算)。
|
||||
2. 先读两份提取件:字段字典全套规则与验收模板已在那里成文,本件是
|
||||
项目实例化,不是重新发明。
|
||||
3. 读金样 exemplars/stardew-tdd-data.md——总清单规模、验算表与验收结论的写法以它为准(同层只读一次)。
|
||||
3. 读取金样 exemplars/stardew-tdd-data.md 了解数据清单、验算表与验收结论包含的信息类型(同层只读一次)。
|
||||
|
||||
## 三、怎么写(模板即流程,按节)
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ description: 写"技术实现"(程序侧)分册时使用。与总纲(技
|
||||
1. 输入齐了吗:架构层系统范围表+P0 清单(拆模块依据)、数据侧表结构契约
|
||||
(加载与校验要引用)、skill 选型卡(实现类需求先查卡,不自造轮子)。
|
||||
2. 读总纲判断立场;本件在数据侧表结构定稿后开写。
|
||||
3. 读金样 exemplars/stardew-tdd-tech.md——各节的填充密度与"实证参照"写法以它为准(同层只读一次)。
|
||||
3. 读取金样 exemplars/stardew-tdd-tech.md 了解技术实现文档包含的信息类型(同层只读一次)。
|
||||
|
||||
## 三、怎么写(模板即流程,按节)
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计
|
||||
> 本文件是系统架构层唯一承载写作流程的教学件。
|
||||
> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。
|
||||
|
||||
## 〇、结构适配原则
|
||||
|
||||
本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和顶层设计判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用架构。
|
||||
|
||||
## 一、这一层的判断立场
|
||||
你是架构师,切系统的刀在你手里。在这个层里你相信:
|
||||
- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量——每个系统必须能
|
||||
一句话答出"删了它,什么塌"(P0 原因)。
|
||||
- 切分是为了**职责清晰、可独立讨论**,不是为了凑数量。只有确实需要独立职责、状态或数据边界的部分才拆成系统;每个实际拆出的系统应能说明删除后的影响。
|
||||
- **数据所有权唯一**:同一事实只由一个系统维护,其他系统只引用稳定 ID,
|
||||
不复制主数据。两个系统管同一件事 = 架构事故。
|
||||
- **依赖无环**是硬要求;信息呈现层只读状态、只经行动入口写入。
|
||||
@@ -28,11 +31,11 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计
|
||||
## 二、动笔前
|
||||
1. 顶层设计已定稿可用——把它的**系统范围表**(粗清单)和**顶层定稿约束**
|
||||
摊开当输入;切分是对粗清单的正式化(拆、并、裁都在这层做)。
|
||||
2. 读 exemplars/stardew-architecture.md 做质量锚(模仿密度,不抄内容),
|
||||
2. 读取 exemplars/stardew-architecture.md 了解内容组织方式,
|
||||
然后往 templates/architecture.md 里填。
|
||||
3. 记住顶层的核心循环图——切完必须跑覆盖检查。
|
||||
|
||||
## 三、十二节总览:写什么、为什么、怎么咬合
|
||||
## 三、架构设计的组织维度:写什么、为什么、怎么咬合
|
||||
|
||||
架构文档回答四个问题:
|
||||
**这个架构为什么这样切(1~3)→ 系统是什么、怎么连接(4~6)→
|
||||
@@ -88,7 +91,7 @@ description: 写游戏策划案(GDD)系统架构时使用。在顶层设计
|
||||
Sxx 编号清单(核心系统通常 1-5 个,有明确要求可超出 5 个)+ 支撑层(存档/UI,不拥有核心规则)。
|
||||
P0 段五列表:
|
||||
| 系统 | 目的 | 输入 | 输出 | P0 原因 |
|
||||
→ 每行 P0 原因必须答"删了它,__ 塌";答不出的降级或合并。
|
||||
→ 对实际拆出的系统说明删除后的影响;无法形成独立职责的部分合并,不为满足数量新增系统。
|
||||
|
||||
### 3. 系统职责
|
||||
| 系统 | 主要职责 | 不负责 → 移交谁 |
|
||||
|
||||
@@ -13,6 +13,10 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
> 本文件是概念层唯一承载写作流程的教学件。
|
||||
> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。
|
||||
|
||||
## 〇、结构适配原则
|
||||
|
||||
本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和上层已定范围判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。
|
||||
|
||||
## 一、这一层的判断立场
|
||||
你是资深游戏策划,看过上千份概念案,清楚绝大多数死在"什么都说、什么都不尖"。
|
||||
在这个层里你相信:
|
||||
@@ -27,11 +31,11 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
## 二、动笔前
|
||||
1. 拿到用户真实回答过的定调信息(参照对象、题材偏好、压力档位)。
|
||||
没有 → 先问一个定调问题,禁止自问自答充当用户。
|
||||
2. 读 exemplars/stardew-concept.md 做质量锚(模仿密度,不抄内容),
|
||||
2. 读取 exemplars/stardew-concept.md 了解内容组织方式,
|
||||
然后往 templates/concept-design.md 里填。
|
||||
3. 零参照时在文档头注明"零参照"。
|
||||
|
||||
## 三、九节总览:写什么、为什么、怎么咬合
|
||||
## 三、概念设计的组织维度:写什么、为什么、怎么咬合
|
||||
|
||||
概念文档回答四个问题:
|
||||
**这是什么(1~5)→ 它不是什么(6)→ 它靠什么让人一直玩(7)→
|
||||
@@ -50,7 +54,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
| 6 | 不是什么 | 负面定位表:不是 X,因为 Y | 正面定义写多必然发散;负面定位用"误会方向+封死原因"收边界,比光秃的非目标锋利一档 | 2 的非目标与跑偏风险的表化展开;与 5 的防串味声明呼应 |
|
||||
| 7 | 核心张力 | 玩家持续面对的两难,两端各有代价 | 长期游玩的根本动力;没有张力,再丰富的内容玩几次就腻 | **向下接口**:每条张力必须在顶层变成取舍表里的具体决策 |
|
||||
| 8 | 边界与约束 | 本层只定什么、什么留给后面 + 规模回流 | 防止概念层越层写数值和系统(越层是下游返工之源);给写作画线 | 保护 2 的纯度;告诉顶层"你们的地盘从哪开始" |
|
||||
| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 给顶层的硬约束 | 收口重锤:写完九节重述一遍,检验整份文档有没有写散;把承诺变成对下的契约 | 回环呼应 1;把 8 的交接具体化成 2~4 条硬约束 |
|
||||
| 9 | 概念定稿 | "核心不是 __ 而是 __"重述 + 按需记录给顶层的约束 | 收口并检查概念是否写散;把承诺转成对下的契约 | 回环呼应 1;把边界和交接约束传给下一层 |
|
||||
|
||||
咬合一图:
|
||||
|
||||
@@ -80,7 +84,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
**定调记录**(全项目调性真源,此节定死):
|
||||
- 参照选择:以 __ 为主、__ 学 __(参照即定调,选完调性随之而来)。
|
||||
- 调性滑杆:压力感/战斗比重/管理深度/叙事比重/节奏,各一档。
|
||||
- 调性锚 T 原则:3~7 条逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。
|
||||
- 调性锚 T 原则:按项目需要提炼并逐条具名(如"T2 不劝退——凡惩罚类问题默认取最轻档")。
|
||||
检验:每条 T 都能当一句 IF-THEN 用——"凡__类问题默认__";写不出口径的 T 是空话。
|
||||
→ 下游每个开放问题先来这里级联批量起草,级联不了的才升级提问。
|
||||
**设计锚点(六项,争议时的仲裁原则,全部具名)**
|
||||
@@ -112,8 +116,7 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
### 7. 核心张力
|
||||
- __ 有限,但 __。
|
||||
- __ vs __(两端的代价各是什么)。
|
||||
→ 每条两端都必须有代价,只有一端的"假张力"删掉。这些是顶层取舍表的
|
||||
种子,后面要逐条对应。
|
||||
→ 如果项目存在核心张力,保留的每条张力都应说明双方代价;没有形成有效张力时,不为了满足结构新增张力。这些是顶层取舍表的种子,后面按需对应。
|
||||
|
||||
### 8. 边界与约束
|
||||
- 概念边界放首位:本层只定幻想、用户、基调与排除方向;具体数值、
|
||||
@@ -124,9 +127,9 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
### 9. 概念定稿(收口重锤)
|
||||
这个游戏的核心不是 __,而是:
|
||||
> (一句话重述核心承诺)
|
||||
交给下一层的约束:__ 必须 __(2~4 条,顶层必须围绕它们展开)。
|
||||
交给下一层的约束:按项目需要记录,顶层据此展开。
|
||||
|
||||
某节对本项目没意义 → 写一行"略,因为 __",不硬凑。
|
||||
若某节对本项目没意义,直接省略。
|
||||
|
||||
## 五、分析文档(全局一份,按层分节)
|
||||
|
||||
@@ -158,4 +161,4 @@ description: 写游戏策划案(GDD)概念层时使用。把一句话游戏
|
||||
## 七、红线(只有三条)
|
||||
1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。
|
||||
2. 不越层:出现具体数值、按键、界面即删。
|
||||
3. 不凑数:写不满就说明缺什么,禁止万金油句填充。
|
||||
3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。
|
||||
|
||||
@@ -12,6 +12,10 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
> 本文件是系统文档层的总纲;各系统的专属写法在 `modules/system-types/` 下对应目录的 `SKILL.md`,
|
||||
专属模板在 `modules/system-types/` 对应目录的 `模板.md`。通用纪律不在各系统 skill 里重复。
|
||||
|
||||
## 〇、结构适配原则
|
||||
|
||||
本分册的章节、字段和数量是参考结构,不是固定清单。先根据系统类型、实际复杂度、用户要求和架构职责判断适用项:适用项写入,同类项可合并,若某项对本系统没意义则省略;复杂系统可以拆分补充,简单系统可以压缩为最小可执行规格。
|
||||
|
||||
## 一、这一层的判断立场
|
||||
你是写单个系统的策划。在这个层里你相信:
|
||||
- 系统文档是**执行层**:刀已经在架构层切好——服从系统地图编号、职责表
|
||||
@@ -20,7 +24,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
防返工价值最高的几行。
|
||||
- 接口纪律:引用具名系统与具名数据,禁泛称;别家主数据只引 ID 不复制。
|
||||
- 字段定义、数值配置、表结构不归你——写交接声明,交技术文档层(数值策划)。
|
||||
- 所有系统同构:读者读熟一份就能读所有份。
|
||||
- 系统文档保持基本可读的一致性,但不要求所有系统使用相同章节;结构应服从系统类型和实际行为。
|
||||
|
||||
## 二、动笔前
|
||||
1. 架构已定稿:找到本系统的 Sxx 编号、职责表行、依赖方向——这是合同。
|
||||
@@ -43,7 +47,7 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
| 5 | 取舍表 | 玩家在本系统内的决策 | 张力在系统内的落地 | 概念张力→顶层取舍表→本表 |
|
||||
| 6 | 状态与规则 | 对象/状态/转换/异常,枚举表达 | 定性规则真源 | 架构职责表对齐 |
|
||||
| 7 | 数值与数据交接 | 本系统交 TDD 的数据类别+定性约束 | 分层边界 | 技术文档层承接 |
|
||||
| 8 | 反馈 | 何时/何强度/何通道 | 无反馈=没发生 | 顶层反馈四层 |
|
||||
| 8 | 反馈 | 关键结果何时、以何种方式反馈 | 让实际结果可理解 | 与本系统实际结果对应 |
|
||||
| 9 | 内部循环 | 本系统内的小循环 | 系统自己的心跳 | 顶层小循环的组成 |
|
||||
| 10 | 输入、输出与依赖 | 消费/交付/依赖谁 | 接口真源 | 架构依赖图逐边对齐 |
|
||||
| 11 | 边界与非目标 | 不负责什么→移交谁 | **防返工价值最高** | 架构职责表"不负责"列 |
|
||||
@@ -57,12 +61,12 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
|
||||
1 系统目的:若删除它,__ 会塌——一句话说不出 = 该系统不该存在。
|
||||
2 支撑体验:对应顶层目标第__条、调性原则第__条。
|
||||
3 进入与退出:常规进入/读档恢复/特殊事件后返回,三入口必写。
|
||||
4 玩家行动:≥4 个具名动词组;编排类写"安排"动词,活动类写"操作"动词。
|
||||
3 进入与退出:按本系统实际存在的入口、退出和恢复路径记录。
|
||||
4 玩家行动:记录本系统实际存在的具名动词组;编排类写"安排"动词,活动类写"操作"动词。
|
||||
5 取舍表:决策/立即收益/延迟收益/主要代价;挂顶层张力编号。
|
||||
6 状态与规则:对象-状态-转换-异常,全部枚举表达,不许整段散文。
|
||||
7 数值与数据交接:列数据类别名 + 设计侧定性约束;字段定义归 TDD。
|
||||
8 反馈:每种关键结果给独立反馈形态;失败必须说明原因和恢复路径。
|
||||
8 反馈:记录本系统关键结果的可理解反馈;存在失败时说明原因和恢复路径。
|
||||
9 内部循环:动词链;可拆单次/区域/长期三层。
|
||||
10 输入输出与依赖:引用具名系统与具名数据,禁泛称"资源"。
|
||||
11 边界与非目标:参考该类型 skill 的“三不”说明边界;建议说明字段与数值的交接边界。
|
||||
@@ -100,4 +104,4 @@ description: 写单个系统的设计文档(Sxx)时的总纲——通用纪
|
||||
1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。
|
||||
2. 不越层:不翻架构的案(要改走分析文档+登记表),不写字段数值(归 TDD),
|
||||
不替别的系统定规则。
|
||||
3. 不凑数:写不出"删了塌什么"、填不满的节,说明缺料——停笔说明,不硬凑。
|
||||
3. 不凑数:写不出"删了塌什么"的系统直接删除;章节对项目有意义但信息不足时,记录已确定内容与待补问题。
|
||||
|
||||
@@ -12,12 +12,16 @@ description: 写游戏技术文档(TDD)时使用的总纲。GDD 四层定稿
|
||||
> 本文件是 TDD 层唯一承载写作流程的教学件;各分册 SKILL 与模板配套使用。
|
||||
> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。
|
||||
|
||||
## 〇、结构适配原则
|
||||
|
||||
本分册的文档件、章节、字段和数量是参考结构,不是固定清单。先根据当前版本的实现目标、游戏规模、运行时和用户要求判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以合并为最小施工合同。
|
||||
|
||||
## 〇、TDD 的完成判据(总纲)
|
||||
|
||||
**TDD 是自足构建包:一个施工 agent 只看 TDD,就能做完完整游戏。**
|
||||
**TDD 是当前版本的施工合同:施工方只看 TDD,应能完成本项目实际范围内的实现。**
|
||||
GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施工看)。
|
||||
检验方式=自足性检查(见总册):不看 GDD 能否回答——每个系统怎么行为、
|
||||
每张表多少行内容、每个界面怎么走、每份素材什么规格。答不出的项就是缺口,
|
||||
检验方式=按项目范围检查施工所需信息是否齐全:实际存在的系统怎么行为、
|
||||
实际使用的表和配置怎么读取、实际存在的界面怎么走、实际需要的素材什么规格。答不出的项就是缺口,
|
||||
缺口回 GDD 同步后**收编**进 TDD(带版本锁)。收编是构建期快照:GDD 定稿
|
||||
变更 → 触发对应收编节重同步(与 fast_gdd 投影同一机制,方向相反)。
|
||||
|
||||
@@ -36,8 +40,7 @@ GDD 是设计真源(给人看、给迭代看);TDD 是构建真源(给施
|
||||
TDD 不擅自换运行时。
|
||||
- **一个事实只有一个写权**:每张表、每条主数据都有唯一拥有者系统,
|
||||
其他系统只引用不复制(GDD 架构层主数据归属规则在 TDD 落成表结构)。
|
||||
- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容——这条竞品四十轮实测
|
||||
验证过,照抄。
|
||||
- **验收是硬闸不是仪式**:有 blocker 禁止扩充内容。
|
||||
- **先少量验证再量产**(美术)/ **先建索引再转表**(数据)——任何方向都
|
||||
不做"做完一大批才发现不对"的事。
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
> 本文件是顶层设计唯一承载写作流程的教学件。
|
||||
> 模板与例子文件保持纯净:不含任何步骤、检验提示与标记。
|
||||
|
||||
## 〇、结构适配原则
|
||||
|
||||
本分册的章节、字段和数量是参考结构,不是固定清单。先根据游戏类型、项目规模、用户要求和概念层定稿判断适用项:适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。
|
||||
|
||||
## 一、这一层的判断立场
|
||||
你是资深游戏策划,正在写全 GDD 最重要的一份文档——概念说"凭什么成立",
|
||||
顶层说"好玩在哪"。核心循环无趣,后面写再多系统也救不回来。在这个层里你相信:
|
||||
@@ -26,11 +30,11 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
|
||||
## 二、动笔前
|
||||
1. 概念层 design.md 已定稿可用——顶层定位与取舍表直接从它长出来。
|
||||
2. 读 exemplars/stardew-top-design.md 做质量锚(模仿密度,不抄内容),
|
||||
2. 读取 exemplars/stardew-top-design.md 了解内容组织方式,
|
||||
然后往 templates/top-design.md 里填。
|
||||
3. 把概念层的核心张力清单摊开放在手边——取舍表必须逐条挂上编号。
|
||||
3. 把概念层已确认的核心张力作为输入;存在对应取舍时再挂上编号。
|
||||
|
||||
## 三、十六节总览:写什么、为什么、怎么咬合
|
||||
## 三、顶层设计的组织维度:写什么、为什么、怎么咬合
|
||||
|
||||
顶层文档回答四个问题:
|
||||
**玩家在玩什么(1~9)→ 玩家面对什么选择与后果(10~11)→
|
||||
@@ -43,14 +47,14 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
|---|---|---|---|---|
|
||||
| 1 | 顶层定位与规模锚点 | 承概念定稿 + "让玩家每天都在想"念头句 + 不是X不是Y + 规模参数表(循环单位/段落/复杂度/长期主轴) | 循环单位定错全盘错;定位句防止顶层漂离概念 | 承概念层"概念定稿";念头句是概念层玩家念头的时间维度版 |
|
||||
| 2 | 设计目标 | 几种回报、如何互相供给 | 回报并列=小游戏拼盘;互相供给才是循环 | 供给关系落到 4~5 的循环里 |
|
||||
| 3 | 核心推动力 | 动机主次 + 即时/日程/季节/长期四层推动 | 玩家"什么时候被什么推着走"的完整图谱 | 时间四层对应 10 节奏结构的四层 |
|
||||
| 3 | 核心推动力 | 按项目实际存在的即时、阶段或长期推动力组织 | 玩家"什么时候被什么推着走"的推动结构 | 与实际节奏结构对应 |
|
||||
| 4 | 大循环 | 跨较长时间的循环:文字箭头 + 核心循环图 | 长期留存的结构骨架 | 与 5、7 三层互检:大循环的每环应有小循环供血 |
|
||||
| 5 | 小循环 | 几十秒到几分钟的具名动词链 ×3+ | 真正被玩到的那层;动词链可直接复制进实现 | 检验:删掉某条,游戏是否少了一块可命名的乐趣 |
|
||||
| 6 | 资源流与输入输出 | 资源流图(来源→储存→消耗)+ 输入输出清单 + 反馈四层 | 资源是循环的血液;防白给、防废物、防套利 | 供血给 4~5 的每个循环环节 |
|
||||
| 5 | 小循环 | 按项目实际存在的局内或短周期动词链组织 | 记录真正被玩到的循环 | 按实际循环层级互检 |
|
||||
| 6 | 资源流与输入输出 | 按项目实际存在的资源流、输入输出和反馈组织 | 说明循环中的实际供给与结果 | 与实际循环环节对应 |
|
||||
| 7 | 最小体验单位 | 多短一段玩法就能体现独有乐趣 + 反馈铁律 | 原型只做这一个单位——定原型规模 | 是 5 的最小切片;14 验证标准的试验对象 |
|
||||
| 8 | 核心活动流程 | 段落表:阶段/玩家行为/**设计目的** | "玩这个游戏的一天"的可复述剧本 | 设计目的列写不出的段=该删的段 |
|
||||
| 9 | 取舍表 | 决策/立即收益/延迟收益/主要代价 | 张力的具体化——玩家决策的路口 | **逐条对应概念层核心张力**(对上接口) |
|
||||
| 10 | 节奏结构 | 日内/周内/季节/长期四层 + 情绪摆动 | 防止"一直紧张"或"一直平";摆动才有呼吸 | 四层对应 3 的推动力四层 |
|
||||
| 10 | 节奏结构 | 按项目实际存在的时间层级和情绪变化组织 | 说明玩法节奏如何变化 | 与实际推动力层级对应 |
|
||||
| 11 | 失败与回收 | 亏损定性 + 情况/结果表 | 失败的形态决定调性——"少拿"还是"毁掉" | 对齐概念层情绪基调的边界句 |
|
||||
| 12 | 系统范围 | 系统/顶层目的/**边界** 表 | 架构层接口:系统地图的种子 | **对下接口**:架构照此拆系统 |
|
||||
| 13 | 范围与非目标 | 最小完整版本清单 + 不做清单 | 立项交付物的边界 | 承概念层"不是什么";给 14 提供验证范围 |
|
||||
@@ -96,24 +100,24 @@ description: 写游戏策划案(GDD)顶层设计时使用。在概念层定
|
||||
### 3. 核心推动力
|
||||
- 动机主次:__。
|
||||
- 即时推动 __;日程推动 __;季节推动 __;长期推动 __。
|
||||
→ 四层都要有实指;空着的那层就是将来留存崩塌的地方。
|
||||
→ 只展开项目实际存在的时间层级;不存在的层级不设字段。
|
||||
|
||||
### 4. 大循环
|
||||
**__ → __ → __ → __ → 回到 __。**(附核心循环图)
|
||||
→ 检验:断掉任何一环,后面是否塌;每一环应有对应小循环供血。
|
||||
|
||||
### 5. 小循环(具名动词链 ×3+)
|
||||
### 5. 小循环(按项目实际数量)
|
||||
**__循环**:__ → __ → __ → __ → __。
|
||||
→ 必须具名("农务循环"不是"资源循环");动词链完整到可以直接照做。
|
||||
|
||||
### 6. 资源流与输入输出
|
||||
(资源流图:每种核心资源 来源 → 储存 → 消耗 三段全)
|
||||
主要输入 __;主要输出 __;反馈四层:立即 __ / 短期 __ / 中期 __ / 长期 __。
|
||||
主要输入 __;主要输出 __;按项目需要记录反馈层级。
|
||||
→ 三问:这资源哪来的?存在哪?花在哪去?答不出=资源设计未完成。
|
||||
|
||||
### 7. 最小体验单位
|
||||
__(多短一段玩法体现独有乐趣——原型只做这一个单位)。
|
||||
单个行动必须至少提供一种清晰反馈:资源/进度/能力/关系/信息/视觉状态之一。
|
||||
保留的玩家行动应有与玩法相称的可理解反馈;反馈形式和数量按项目决定。
|
||||
|
||||
### 8. 核心活动流程(段落表)
|
||||
| 阶段 | 玩家行为 | 设计目的 |
|
||||
@@ -153,7 +157,7 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。
|
||||
顶层当前定稿为:__(循环单位、核心结构、关键档位一句话说全)。
|
||||
后续架构必须围绕 __ 拆系统;不得 __。
|
||||
|
||||
某节对本项目没意义 → 写一行"略,因为 __",不硬凑。
|
||||
若某节对本项目没意义,直接省略。
|
||||
|
||||
## 五、分析文档(全局一份,按层分节)
|
||||
|
||||
@@ -187,4 +191,4 @@ __(多短一段玩法体现独有乐趣——原型只做这一个单位)。
|
||||
## 七、红线(只有三条)
|
||||
1. 不冒充用户决定:用户没说的方向标"待确认",正文不写死。
|
||||
2. 不越层:向上不翻概念层的案,向下不写系统内部规则与具体数值。
|
||||
3. 不凑数:写不满就说明缺什么,禁止万金油句填充。
|
||||
3. 不凑数:章节对项目有意义但信息不足时,记录已确定内容与待补问题;章节对项目无意义时,直接省略。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
### C1 模板_系统架构.md(→ templates/architecture.md)
|
||||
|
||||
本模板是参考结构,不是固定清单。只有需要独立职责、状态或数据边界的部分才拆成系统;简单项目可以合并系统和章节,复杂项目可以增加必要的系统与校验。表格中的示例行可按实际系统、风险和问题扩展,不代表数量上限。
|
||||
|
||||
# 系统架构:《游戏名》
|
||||
|
||||
## 架构定位与目标
|
||||
@@ -18,6 +20,7 @@
|
||||
|---|---|---|---|
|
||||
| S01 | __ | __ | P0 |
|
||||
| S02 | __ | __ | |
|
||||
(以上为示例,可按实际系统删减或扩充。)
|
||||
|
||||
支撑层(不拥有核心规则):__。
|
||||
|
||||
@@ -105,6 +108,8 @@ flowchart LR
|
||||
| 风险 | 校验方式 |
|
||||
|---|---|
|
||||
| __ | __ |
|
||||
(按实际风险逐行补充。)
|
||||
|
||||
## 开放的结构问题
|
||||
- __
|
||||
(按实际问题逐条补充。)
|
||||
|
||||
+5
-3
@@ -1,5 +1,7 @@
|
||||
### C1 模板_概念设计.md(→ templates/concept-design.md)
|
||||
|
||||
本模板是参考结构,不是固定清单。填写前按项目类型、规模和用户要求筛选章节与字段;同类内容可合并,若某节对项目没有实际意义则删除,复杂项目可增加必要内容。表格和列表中的示例项可按实际内容扩展,不代表数量上限。
|
||||
|
||||
# 概念设计:《游戏名》
|
||||
|
||||
## 一句话概念
|
||||
@@ -10,14 +12,14 @@
|
||||
### 定调记录(全项目调性真源,级联决策的依据库)
|
||||
- 参照选择:以《__》为主(__, 学 __);不参考 __。
|
||||
- 调性滑杆:压力感 __ / 战斗比重 __ / 管理深度 __ / 叙事比重 __ / 节奏 __。
|
||||
- 调性锚(T 原则,逐条具名,下游每个开放问题先来这里级联):
|
||||
T1 __;T2 __;T3 __;T4 __;T5 __。
|
||||
- 调性锚(按项目需要逐条具名,下游开放问题按需从这里级联):
|
||||
T__ __。
|
||||
|
||||
### 设计锚点(六仲裁位)
|
||||
- 核心幻想:__。
|
||||
玩家念头:"__"
|
||||
- 目标体验:__。
|
||||
- 玩家动机:短期 __;长期 __。
|
||||
- 玩家动机(按项目实际存在的时间尺度填写):__。
|
||||
- 核心循环:__ → __ → __ → __ → 回到 __。
|
||||
- 跑偏风险:__。
|
||||
- 非目标:__(详见《不是什么》)。
|
||||
|
||||
+8
-3
@@ -1,5 +1,7 @@
|
||||
### C3 02_美术圣经/模板.md(→ templates/tdd-art-bible.md)
|
||||
|
||||
本模板是美术实施的参考结构。按项目实际需要选择角色、场景、UI、动画和素材契约;没有对应资产类型时删除相应章节,复杂项目可增加必要的视觉规则。表格和资产条目可按实际内容扩展,不代表数量上限。
|
||||
|
||||
# 美术圣经:《游戏名》
|
||||
|
||||
> 状态:{drafting / reviewed / frozen} | 定调锚:概念层@v{N} 第 2 节 | style_id:`__`
|
||||
@@ -10,7 +12,7 @@ __(一段话:从定调记录翻译的视觉气质;参考图位 __ 张)
|
||||
|
||||
## 视觉锚
|
||||
|
||||
- 关键词:__(3~5 个)。
|
||||
- 关键词:__(按项目需要)。
|
||||
- 禁用关键词:__。
|
||||
- 色板:主色 __ / 辅色 __ / 点缀 __(配比 __);昼夜·天气·季节表现 __。
|
||||
- 形状语言:__。
|
||||
@@ -40,6 +42,7 @@ __(承 UI 系统文档的界面清单;视觉语言与信息分层对齐)
|
||||
| 素材 | 规格(尺寸/帧数/方向数) | 命名规则 | atlas 格式 | 验收 | 绑定 |
|
||||
|---|---|---|---|---|---|
|
||||
| __ | __ | __ | __ | __ | `item_ __` / 豁免:__ |
|
||||
(以上为示例,可按实际素材删减或扩充。)
|
||||
|
||||
- 绘制工艺:__(用陶泥儿 MCP 的路径与参数;封装流程)。
|
||||
- 豁免类型仅限:程序化生成 / UI 文本 / 本期不需要。
|
||||
@@ -49,17 +52,19 @@ __(承 UI 系统文档的界面清单;视觉语言与信息分层对齐)
|
||||
| asset_id | 规格 | 绑定 | 状态 | 验收记录 | contract_version |
|
||||
|---|---|---|---|---|---|
|
||||
| __ | __ | `item_ __` / 豁免 | 缺失/草稿/已交付/已验收/已接入 | 技术过/视觉过 @__ | __ |
|
||||
(以上为示例,可按实际资产删减或扩充。)
|
||||
|
||||
- 状态单向流转:缺失 → 草稿 → 已交付 → 已验收 → 已接入;驳回退回草稿并记原因。
|
||||
- 验收两维:技术(尺寸/透明/帧数/命名)+ 视觉(对照视觉锚);两维都过才进"已验收"。
|
||||
- 每个 gameplay 可见对象必有一行,或显式豁免——没有第三种状态。
|
||||
- 需要登记的 gameplay 可见对象有一行;不需要资产登记的对象不建立空记录。
|
||||
- 程序接入后填消费点(哪个模块加载、事件映射),`contract_version` 变更须重验收。
|
||||
|
||||
## 量产流程与验证
|
||||
|
||||
1. 概念候选 __ 张 → 2. 人选方向 → 3. 锚点图 __ 张 → 4. 锁圣经 →
|
||||
5. 写契约 → 6. 小批 __ 张 → 7. 技术检查(__)→ 8. 接入程序 →
|
||||
9. 运行时截图验收(桌面/移动双视口下 __ 可辨)→ 10. 扩产。
|
||||
9. 运行时验收(按项目支持的平台)→ 10. 扩产。
|
||||
(以上为示例,可按实际流程删减或扩充。)
|
||||
|
||||
## 开放问题回执
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
### C3 03_数据与配表/模板.md(→ templates/tdd-data.md)
|
||||
|
||||
本模板是数据与配表的参考结构。只有项目实际存在配置、枚举、关系或条件数据时才建立对应表和校验;简单项目可以直接写配置约定,复杂项目再拆分表结构与验算流程。表格中的示例行可按实际数据、字段和验算项扩展,不代表数量上限。
|
||||
|
||||
# 数据与配表:《游戏名》
|
||||
|
||||
> 状态:{structuring / filling / accepted} | 基于:各系统交接节汇总 | 验收:check@{id} 最新结论 __
|
||||
@@ -9,6 +11,7 @@
|
||||
| 表格组 | 建议表名 | 主要维护系统 |
|
||||
|---|---|---|
|
||||
| __ | __ | __ |
|
||||
(以上为示例,可按实际数据表删减或扩充。)
|
||||
|
||||
(表格拆分是生产组织方式,不改变主数据归属。)
|
||||
|
||||
@@ -26,7 +29,7 @@
|
||||
|---|---|---|---|---|---|---|
|
||||
| __ | date_day / progress_flag / skill_level / schedule_open / quest_completed / __ | __ | __ | __ | active | __ |
|
||||
|
||||
(复杂条件拆条件组+条件行;全项目只此一个条件入口,程序实现一次 `check(condition_id)`。)
|
||||
(存在复杂条件时再拆条件组与条件行;没有条件系统时删除本节。)
|
||||
|
||||
## 工作簿组织与建表顺序
|
||||
|
||||
@@ -36,6 +39,7 @@
|
||||
|
||||
建表顺序:①物品表(公共 item_id)→ ②__ → ③__ → ④__ → ⑤__ → ⑥__ → ⑦__ → ⑧__。
|
||||
每完成一组查三件事:引用 ID 存在 / 条件有负责系统 / 同一数值只有一个系统维护。
|
||||
(以上为示例,可按实际表结构删减或扩充。)
|
||||
|
||||
## 表格-程序契约
|
||||
|
||||
@@ -54,12 +58,14 @@
|
||||
| 表 | 字段 | 默认值 | 依据 | 推翻条件 |
|
||||
|---|---|---|---|---|
|
||||
| __ | __ | __ | T__ / 台账 id | __ |
|
||||
(以上为示例,可按实际验算字段删减或扩充。)
|
||||
|
||||
- 前五日闭环验算:
|
||||
|
||||
| 日期 | 主目标 | 关键行动 | 主要成本 | 主要获得 | 结果 |
|
||||
|---|---|---|---|---|---|
|
||||
| 第 1 日 | __ | __ | __ | __ | __ |
|
||||
(以上为示例,可按实际循环或阶段删减或扩充。)
|
||||
|
||||
- 收益链校验:`__ → __ → __ → __ → __`(逐环引 ID)。
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
### C3 模板_TDD总册.md(→ templates/tdd-master.md)
|
||||
|
||||
本模板是 TDD 总册的参考结构。只建立当前项目实际需要的技术、美术、数据和索引内容;没有对应方向时不创建空分册,复杂项目可以增加施工所需的分册。表格中的示例行可按实际分册、问题和验收项扩展,不代表数量上限。
|
||||
|
||||
# TDD 总册:《游戏名》
|
||||
|
||||
> 本册是 TDD 层的封面与索引:正文在三件分册(01 技术实现 / 02 美术圣经 / 03 数据与配表),
|
||||
@@ -7,19 +9,19 @@
|
||||
|
||||
## 自足性检查(TDD 的完成判据)
|
||||
|
||||
> 标准:一个施工 agent 只看 TDD,能做完完整游戏。逐项模拟它必问的问题,
|
||||
> 标准:施工方只看当前 TDD,能完成项目实际范围内的实现。逐项检查当前项目真正需要的问题,
|
||||
> 答得出=过;答不出=缺口(列 GDD 来源与同步动作)。
|
||||
|
||||
| # | 施工 agent 的问题 | 答案在哪 | 状态 |
|
||||
|---|---|---|---|
|
||||
| 1 | 每个系统怎么行为(规则/行动/反馈)? | 01 收编章(@v{N}) | __ |
|
||||
| 2 | 每张表有多少行内容、文本全填了吗? | 03 全量填充+完成度验收 | __ |
|
||||
| 3 | 每个界面长什么样、怎么走? | 01 UI 交互规格 | __ |
|
||||
| 4 | 每份素材什么规格、谁验收过? | 02 资产状态表(全行非缺失) | __ |
|
||||
| 1 | 实际存在的系统怎么行为? | 01 收编章(@v{N}) | __ |
|
||||
| 2 | 实际使用的表和配置是否可施工? | 03 数据与配表 | __ |
|
||||
| 3 | 实际存在的界面怎么走? | 01 UI 交互规格 | __ |
|
||||
| 4 | 实际需要的素材什么规格? | 02 资产状态表 | __ |
|
||||
| 5 | 代码怎么组织、跑在哪? | 01 代码组织+能力边界 | __ |
|
||||
| 6 | 怎么算做完了(判据)? | 01 里程碑+各件验收 | __ |
|
||||
|
||||
全部为"过"时,TDD 进入 frozen——构建可以完全脱离 GDD 进行。
|
||||
当前项目所需检查全部为"过"时,TDD 进入 frozen——构建可以在本版本范围内脱离 GDD 进行。
|
||||
|
||||
## 三件状态
|
||||
|
||||
@@ -54,7 +56,7 @@
|
||||
|
||||
| 件 | 最近验收 | blocker | 结论 |
|
||||
|---|---|---|---|
|
||||
| 01 | __(构建+双视口验证 @__) | __ | __ |
|
||||
| 01 | __(按项目平台验证 @__) | __ | __ |
|
||||
| 02 | __(技术+视觉两维 @__) | __ | __ |
|
||||
| 03 | __(七查 @check_id) | __ | __ |
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
### C3 01_技术实现/模板.md(→ templates/tdd-tech.md)
|
||||
|
||||
本模板是技术实现的参考结构。按当前运行时、系统复杂度和用户要求选择章节;没有对应系统、界面、输入、音频或存档需求时,删除相应内容,复杂项目可增加施工所需章节。表格和系统条目可按实际实现范围扩展,不代表数量上限。
|
||||
|
||||
# 技术实现:《游戏名》
|
||||
|
||||
> 状态:{drafting / reviewed / frozen} | 基于 GDD:架构层@v{N} | 数据侧契约:data/contracts@v{M}
|
||||
> **目标运行时:{HTML / Unity / Godot / Cocos}(由 GDD 平台事实锁定)** | 预览:{HTML=双视口浏览器 / 引擎=陶泥儿驱动弹窗} | 导出:{HTML=自包含 / 引擎=陶泥儿驱动 CLI}
|
||||
> **目标运行时:{HTML / Unity / Godot / Cocos}(由 GDD 平台事实锁定)** | 预览:{按项目平台验证 / 引擎=陶泥儿驱动弹窗} | 导出:{HTML=自包含 / 引擎=陶泥儿驱动 CLI}
|
||||
|
||||
## 系统行为规格(收编章)
|
||||
|
||||
@@ -28,7 +30,7 @@
|
||||
|
||||
## 技术目标与平台事实
|
||||
|
||||
- 平台事实(注入,禁改):自包含 Web · 双视口(桌面/移动)· 键鼠/触屏双输入 · 本地 HTTP 预览。
|
||||
- 平台事实(由 GDD 平台事实锁定):__。
|
||||
- 技术目标:__(可测量,如"首屏可玩 ≤ __ 秒")。
|
||||
|
||||
## 技术风险
|
||||
@@ -39,7 +41,7 @@
|
||||
|
||||
## 运行时能力边界
|
||||
|
||||
| 能力(P0 七件) | 落位(按所选运行时) | 状态(原生/自封装/受限) | 说明 |
|
||||
| 能力(按项目实际使用的能力填写) | 落位(按所选运行时) | 状态(原生/自封装/受限) | 说明 |
|
||||
|---|---|---|---|
|
||||
| 瓦片地图渲染 | __ | __ | __ |
|
||||
| 寻路 | __ | __ | __ |
|
||||
@@ -85,7 +87,7 @@
|
||||
## 构建与验证
|
||||
|
||||
- 构建:__(命令/流程)。
|
||||
- 验证分级:自动__(跑什么、看什么输出为过);半自动__(双视口浏览器验证步骤);手测__(谁试玩、观察什么)。
|
||||
- 验证分级:自动__(跑什么、看什么输出为过);半自动__(按项目平台验证步骤);手测__(谁试玩、观察什么)。
|
||||
|
||||
## 版本里程碑
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
### C1 模板_顶层设计.md(→ templates/top-design.md)
|
||||
|
||||
本模板是参考结构,不是固定清单。填写前按项目实际存在的循环、资源、时间层级和用户要求筛选章节;同类内容可合并,若某项不存在则删除,复杂项目可增加必要内容。表格、列表和循环示例可按实际内容扩展,不代表数量上限。
|
||||
|
||||
# 顶层设计:《游戏名》
|
||||
|
||||
## 顶层定位与规模锚点
|
||||
@@ -43,6 +45,7 @@ __ → __ → __ → __ → __。
|
||||
|
||||
### __循环
|
||||
__ → __ → __ → __。
|
||||
(以上为示例,可按实际循环删减或扩充。)
|
||||
|
||||
## 资源流与输入输出
|
||||
|
||||
@@ -58,13 +61,14 @@ flowchart LR
|
||||
|
||||
## 最小体验单位
|
||||
__。
|
||||
单个行动必须至少提供一种清晰反馈:__。
|
||||
保留的玩家行动应有与玩法相称的可理解反馈:__。
|
||||
|
||||
## 核心活动流程
|
||||
|
||||
| 阶段 | 玩家行为 | 设计目的 |
|
||||
|---|---|---|
|
||||
| __ | __ | __ |
|
||||
(按实际阶段逐行补充。)
|
||||
|
||||
## 取舍表
|
||||
|
||||
@@ -77,6 +81,7 @@ __。
|
||||
- 周内节奏:__。
|
||||
- 季节/章节节奏:__。
|
||||
- 长期节奏:__。
|
||||
(以上为示例,可按实际节奏层级删减或扩充。)
|
||||
|
||||
整体情绪在"__"与"__"之间摆动(恢复来源:__;变化来源:__)。
|
||||
|
||||
@@ -103,9 +108,11 @@ __。
|
||||
| 验证点 | 成功标准 |
|
||||
|---|---|
|
||||
| __ | __ |
|
||||
(按实际验证点逐行补充。)
|
||||
|
||||
## 开放问题
|
||||
- __
|
||||
(按实际问题逐条补充。)
|
||||
|
||||
## 顶层定稿
|
||||
顶层当前定稿为:__。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": "agc-skill-pack.v1",
|
||||
"version": "2026-08-26.14",
|
||||
"version": "2026-08-26.15",
|
||||
"skills": [
|
||||
{
|
||||
"name": "agc-game-production-workflow",
|
||||
@@ -63,7 +63,7 @@
|
||||
"agents/openai.yaml",
|
||||
"references/platform-art-contract.md"
|
||||
],
|
||||
"sha256": "bd1e415aac0cd0f97090296f34c67898dd731d1e177ec91a56027f9b68a88b37"
|
||||
"sha256": "ff3e1645a35fc9bff1ef255aa7bdc2a9729843d68729589b6f2670c84b8130ec"
|
||||
},
|
||||
{
|
||||
"name": "agc-web-game-development",
|
||||
|
||||
@@ -16,10 +16,11 @@ mod design_runtime;
|
||||
mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_references;
|
||||
mod direct_codex_user_item;
|
||||
mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tools_mcp;
|
||||
mod generation;
|
||||
@@ -35,6 +36,7 @@ mod runtime_tools;
|
||||
mod skill_pack;
|
||||
use codex_app_server::*;
|
||||
pub(crate) use codex_app_server::{
|
||||
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
|
||||
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
|
||||
};
|
||||
use codex_cli::*;
|
||||
@@ -45,10 +47,11 @@ pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_codex_references::*;
|
||||
pub(crate) use direct_codex_user_item::*;
|
||||
pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use generation::*;
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
//! DirectProject 历史注入载荷的单一构造 seam。
|
||||
//!
|
||||
//! 历史读取与大小前置校验集中在这里;调用方只负责线程生命周期与 RPC 传输。
|
||||
|
||||
use super::super::*;
|
||||
use super::direct_project_history_injection_oversize_error;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn build_direct_project_history_injection_params(
|
||||
history_root: &Path,
|
||||
thread_id: &str,
|
||||
) -> Result<Value, platform_llm::LlmError> {
|
||||
let canonical_items = read_direct_project_history_items_at(history_root)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let items = canonical_items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let params = serde_json::json!({"threadId": thread_id, "items": items});
|
||||
let payload_bytes = serde_json::to_vec(¶ms)
|
||||
.map(|bytes| bytes.len().saturating_add(1))
|
||||
.unwrap_or(usize::MAX);
|
||||
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
|
||||
if let Some(error) = direct_project_history_injection_oversize_error(¶ms, payload_bytes) {
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
//! DirectProject 线程池身份的 canonical 解析与摘要。
|
||||
|
||||
use super::super::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn direct_codex_canonical_project_identity(
|
||||
root: &std::path::Path,
|
||||
) -> Result<(std::path::PathBuf, String), String> {
|
||||
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
|
||||
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
|
||||
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
|
||||
let manifest_project_id = manifest.project_id.trim();
|
||||
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
|
||||
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
|
||||
}
|
||||
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
|
||||
Ok((
|
||||
canonical_root,
|
||||
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
return path.as_os_str().as_bytes().to_vec();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut bytes = Vec::new();
|
||||
for unit in path.as_os_str().encode_wide() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
path.as_os_str().to_string_lossy().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub(super) fn direct_codex_project_identity_digest(
|
||||
path_identity: &[u8],
|
||||
project_id: &[u8],
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"genarrative-direct-project-identity.v1\0");
|
||||
digest.update((path_identity.len() as u64).to_le_bytes());
|
||||
digest.update(path_identity);
|
||||
digest.update((project_id.len() as u64).to_le_bytes());
|
||||
digest.update(project_id);
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
+188
-75
@@ -10,6 +10,12 @@ use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufR
|
||||
use tokio::sync::{mpsc, oneshot, Mutex, Notify};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod direct_project_history_wire;
|
||||
use direct_project_history_wire::build_direct_project_history_injection_params;
|
||||
mod direct_project_identity;
|
||||
pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands;
|
||||
use direct_project_identity::*;
|
||||
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY";
|
||||
const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
|
||||
@@ -558,6 +564,10 @@ enum CodexTurnEvent {
|
||||
completed: bool,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
Request {
|
||||
event_type: &'static str,
|
||||
params: serde_json::Value,
|
||||
},
|
||||
RawItem(serde_json::Value),
|
||||
Terminal(serde_json::Value),
|
||||
TransportClosed(String),
|
||||
@@ -712,6 +722,25 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
|
||||
direct_codex_safe_activity_for_item(item_type)
|
||||
}
|
||||
|
||||
/// Project an app-server item into the small public payload carried by the
|
||||
/// DirectProject event queue. Full item contents are persisted in JSONL and
|
||||
/// must not be forwarded through the runtime event stream.
|
||||
fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"itemType": item
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("unknown"),
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
||||
let command = command.to_ascii_lowercase();
|
||||
command.contains("game.static_smoke")
|
||||
@@ -885,7 +914,7 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
| "item/reasoning/summaryTextDelta"
|
||||
| "item/reasoning/summaryPartAdded"
|
||||
| "item/reasoning/textDelta" => Some("preparing"),
|
||||
"item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"),
|
||||
"item/mcpToolCall/progress" => Some("controlled-tool"),
|
||||
"item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-write"),
|
||||
"command/exec/outputDelta"
|
||||
| "process/outputDelta"
|
||||
@@ -895,6 +924,23 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
|
||||
match method {
|
||||
"item/fileChange/requestApproval"
|
||||
| "item/commandExecution/requestApproval"
|
||||
| "item/permissions/requestApproval" => Some("approval.requested"),
|
||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
|
||||
match method {
|
||||
"serverRequest/resolved" => Some("request.resolved"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_codex_intermediate_text_for_notification(
|
||||
method: &str,
|
||||
params: &serde_json::Value,
|
||||
@@ -2665,6 +2711,7 @@ impl CodexAppServerConnection {
|
||||
request,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
on_agent_message_delta,
|
||||
direct_observer,
|
||||
audit,
|
||||
@@ -2679,6 +2726,7 @@ impl CodexAppServerConnection {
|
||||
request: LlmRunRequest,
|
||||
direct_history_root: Option<&std::path::Path>,
|
||||
direct_client_turn_id: Option<&str>,
|
||||
direct_user_item: Option<&serde_json::Value>,
|
||||
mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>,
|
||||
mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
mut audit: Option<&mut DirectCodexTurnAudit>,
|
||||
@@ -2694,12 +2742,19 @@ impl CodexAppServerConnection {
|
||||
));
|
||||
}
|
||||
if let Some(client_turn_id) = direct_client_turn_id {
|
||||
let user_item = direct_project_local_message_item(
|
||||
"user",
|
||||
current_prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
let user_item = match direct_user_item {
|
||||
Some(item) => {
|
||||
direct_codex_user_item_to_response_item(history_root, item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
item.clone()
|
||||
}
|
||||
None => direct_project_local_message_item(
|
||||
"user",
|
||||
current_prompt,
|
||||
Some(&format!("direct-codex:{client_turn_id}:user")),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?,
|
||||
};
|
||||
append_direct_project_user_message_at(history_root, &user_item)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
}
|
||||
@@ -2709,24 +2764,14 @@ impl CodexAppServerConnection {
|
||||
let thread_id = thread_lease.thread_id.clone();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if thread_created {
|
||||
let items = match read_direct_project_history_items_at(history_root) {
|
||||
Ok(items) => items,
|
||||
Err(error) => {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
};
|
||||
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
|
||||
let payload_bytes = serde_json::to_vec(¶ms)
|
||||
.map(|bytes| bytes.len().saturating_add(1))
|
||||
.unwrap_or(usize::MAX);
|
||||
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
|
||||
if let Some(error) =
|
||||
direct_project_history_injection_oversize_error(¶ms, payload_bytes)
|
||||
{
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::InvalidRequest(error));
|
||||
}
|
||||
let params =
|
||||
match build_direct_project_history_injection_params(history_root, &thread_id) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Err(error) = self.request("thread/inject_items", params).await {
|
||||
self.release_thread(snapshot, &thread_id).await;
|
||||
return Err(platform_llm::LlmError::Transport(error));
|
||||
@@ -2812,6 +2857,21 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
};
|
||||
turn_start_guard.armed = false;
|
||||
let direct_thread_id = history_root.to_string_lossy().into_owned();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({
|
||||
"threadId": thread_id,
|
||||
"turnId": turn_id,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
let mut guard = CodexTurnGuard {
|
||||
@@ -2863,6 +2923,15 @@ impl CodexAppServerConnection {
|
||||
Some(CodexTurnEvent::AgentMessageDelta { item_id, delta }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
direct_project_history.observe_delta(&item_id, &delta);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.delta".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: Some(item_id.clone()),
|
||||
payload: serde_json::json!({ "delta": delta.clone() }),
|
||||
},
|
||||
);
|
||||
}
|
||||
streamed_text.push_str(&delta);
|
||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||
@@ -2905,6 +2974,37 @@ impl CodexAppServerConnection {
|
||||
})?
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||
direct_project_history.complete_item(&item);
|
||||
let item_id = direct_thread_item_id(&item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: serde_json::json!({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Request { event_type, params }) => {
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
let request_id = params
|
||||
.get("requestId")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| params.get("id").and_then(serde_json::Value::as_str))
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: event_type.to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: request_id
|
||||
.map(|id| serde_json::json!({ "requestId": id }))
|
||||
.unwrap_or_else(|| serde_json::json!({})),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Activity(activity)) => {
|
||||
@@ -2967,6 +3067,21 @@ impl CodexAppServerConnection {
|
||||
self.inner.workspace_mode.passive_item_boundary_name(),
|
||||
)));
|
||||
}
|
||||
if !completed
|
||||
&& self.inner.workspace_mode
|
||||
== CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
let item_id = direct_thread_item_id(item);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "item.started".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
payload: direct_thread_item_started_payload(item),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(CodexTurnEvent::Terminal(params)) => {
|
||||
@@ -2983,11 +3098,24 @@ impl CodexAppServerConnection {
|
||||
})
|
||||
});
|
||||
}
|
||||
match turn
|
||||
let status = turn
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||
&& matches!(status, "completed" | "interrupted" | "failed")
|
||||
{
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadRawEventDraft {
|
||||
event_type: "turn.completed".to_string(),
|
||||
turn_id: turn_id.clone(),
|
||||
item_id: None,
|
||||
payload: serde_json::json!({ "status": status }),
|
||||
},
|
||||
);
|
||||
}
|
||||
match status {
|
||||
"completed" => {
|
||||
return final_text
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
@@ -3423,7 +3551,9 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
| "item/completed"
|
||||
| "rawResponseItem/completed"
|
||||
| "turn/completed"
|
||||
) && safe_activity.is_none()
|
||||
) && direct_codex_request_event_type(method).is_none()
|
||||
&& direct_codex_resolution_event_type(method).is_none()
|
||||
&& safe_activity.is_none()
|
||||
&& intermediate_text.is_none()
|
||||
{
|
||||
continue;
|
||||
@@ -3460,7 +3590,9 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let event = if let Some(activity) = safe_activity {
|
||||
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
|
||||
CodexTurnEvent::Request { event_type, params }
|
||||
} else if let Some(activity) = safe_activity {
|
||||
// Preparing notifications may carry private plan/reasoning text;
|
||||
// expose only the safe activity category. Other categories may
|
||||
// retain their bounded, redacted intermediate text below.
|
||||
@@ -3509,6 +3641,13 @@ async fn read_game_creator_codex_app_server_stdout(
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
),
|
||||
method if direct_codex_request_event_type(method).is_some() => {
|
||||
CodexTurnEvent::Request {
|
||||
event_type: direct_codex_request_event_type(method)
|
||||
.expect("request event type checked above"),
|
||||
params,
|
||||
}
|
||||
}
|
||||
_ => CodexTurnEvent::Terminal(params),
|
||||
}
|
||||
};
|
||||
@@ -3757,6 +3896,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -3774,56 +3914,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
None,
|
||||
Some(observer),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn direct_codex_canonical_project_identity(
|
||||
root: &std::path::Path,
|
||||
) -> Result<(std::path::PathBuf, String), String> {
|
||||
let (canonical_root, _) = resolve_direct_codex_project_authority(root)?;
|
||||
let manifest = read_manifest(&canonical_root.join(".agent/manifest.json"))
|
||||
.map_err(|error| format!("读取 DirectProject 权威项目身份失败:{error}"))?;
|
||||
let manifest_project_id = manifest.project_id.trim();
|
||||
if manifest_project_id.is_empty() || manifest_project_id.chars().count() > 256 {
|
||||
return Err("DirectProject manifest.projectId 不满足身份边界".to_string());
|
||||
}
|
||||
let path_identity = direct_codex_os_path_identity_bytes(&canonical_root);
|
||||
Ok((
|
||||
canonical_root,
|
||||
direct_codex_project_identity_digest(&path_identity, manifest_project_id.as_bytes()),
|
||||
))
|
||||
}
|
||||
|
||||
fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
return path.as_os_str().as_bytes().to_vec();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let mut bytes = Vec::new();
|
||||
for unit in path.as_os_str().encode_wide() {
|
||||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
path.as_os_str().to_string_lossy().as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn direct_codex_project_identity_digest(path_identity: &[u8], project_id: &[u8]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"genarrative-direct-project-identity.v1\0");
|
||||
digest.update((path_identity.len() as u64).to_le_bytes());
|
||||
digest.update(path_identity);
|
||||
digest.update((project_id.len() as u64).to_le_bytes());
|
||||
digest.update(project_id);
|
||||
format!("{:x}", digest.finalize())
|
||||
}
|
||||
|
||||
pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
@@ -3831,6 +3926,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
client_turn_id: Option<&str>,
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
@@ -3894,6 +3990,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
request,
|
||||
Some(&codex_root),
|
||||
effective_client_turn_id,
|
||||
direct_user_item.as_ref(),
|
||||
None,
|
||||
observer,
|
||||
audit,
|
||||
@@ -4003,6 +4100,22 @@ pub(crate) fn build_direct_codex_history_prompt(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_thread_item_projection_drops_full_app_server_payload() {
|
||||
let item = serde_json::json!({
|
||||
"id": "item-1",
|
||||
"type": "mcpToolCall",
|
||||
"tool": "agc_write_file",
|
||||
"arguments": { "path": "game/index.html", "token": "secret" },
|
||||
"result": { "content": "large output" }
|
||||
});
|
||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
||||
assert_eq!(
|
||||
direct_thread_item_started_payload(&item),
|
||||
serde_json::json!({ "itemType": "mcpToolCall" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_item_activities_are_closed_safe_categories() {
|
||||
let allowed = [
|
||||
@@ -150,14 +150,26 @@ fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReas
|
||||
let mut group_index = 0;
|
||||
let mut assistant_index = 0;
|
||||
let mut sequence = 0_u64;
|
||||
let mut current_reasoning = Vec::new();
|
||||
let mut pending_reasoning = Vec::new();
|
||||
let mut current_reasoning: Vec<DesignReasoningEntry> = Vec::new();
|
||||
let mut pending_reasoning: Vec<DesignReasoningEntry> = Vec::new();
|
||||
let mut saw_response_output = false;
|
||||
|
||||
for item in &session.history {
|
||||
if item.get("role").and_then(Value::as_str) == Some("user") {
|
||||
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
// A user item closes the previous turn. Resolve its reasoning
|
||||
// against that turn's last assistant message before moving to
|
||||
// the next group; otherwise it is incorrectly attached to the
|
||||
// next turn and rendered at the bottom as an orphan.
|
||||
let assistant_id = assistant_groups
|
||||
.get(group_index)
|
||||
.and_then(|ids| ids.last())
|
||||
.cloned();
|
||||
for mut entry in pending_reasoning.drain(..) {
|
||||
entry.message_id = assistant_id.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
group_index += 1;
|
||||
assistant_index = 0;
|
||||
@@ -1638,6 +1650,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_reasoning_stays_with_the_turn_before_an_approval_boundary() {
|
||||
let mut session = new_design_session("project", "quality");
|
||||
session.messages = vec![
|
||||
DesignMessage {
|
||||
id: "turn-1:user".into(),
|
||||
role: "user".into(),
|
||||
text: "第一轮需求".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "turn-1:assistant".into(),
|
||||
role: "assistant".into(),
|
||||
text: "第一轮已提交审批".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "turn-2:user".into(),
|
||||
role: "user".into(),
|
||||
text: "用户已批准,进入下一阶段".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "turn-2:assistant".into(),
|
||||
role: "assistant".into(),
|
||||
text: "查询工作阶段".into(),
|
||||
},
|
||||
];
|
||||
session.history = vec![
|
||||
json!({"role":"user", "content":"第一轮需求"}),
|
||||
json!({"type":"reasoning", "id":"before-approval", "content":[{"type":"reasoning_text", "text":"审批前的思考"}]}),
|
||||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"第一轮已提交审批"}]}),
|
||||
json!({"role":"user", "content":"用户已批准,进入下一阶段"}),
|
||||
json!({"type":"reasoning", "id":"after-approval", "content":[{"type":"reasoning_text", "text":"审批后的思考"}]}),
|
||||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"查询工作阶段"}]}),
|
||||
];
|
||||
|
||||
let entries = persisted_design_reasoning_entries(&session);
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("before-approval", Some("turn-1:assistant")),
|
||||
("after-approval", Some("turn-2:assistant")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
|
||||
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
|
||||
|
||||
const DIRECT_CODEX_REFERENCE_HEADER: &str =
|
||||
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "type")]
|
||||
pub(crate) enum DirectCodexTurnReference {
|
||||
#[serde(rename = "resource")]
|
||||
Resource(DirectCodexResourceReference),
|
||||
#[serde(rename = "runtime-region")]
|
||||
RuntimeRegion(DirectCodexRuntimeRegionReference),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexResourceReference {
|
||||
pub(crate) resource_id: String,
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectCodexRuntimeRegionReference {
|
||||
#[serde(default)]
|
||||
pub(crate) label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) run_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) version_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) element_role: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) width: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub(crate) resource_ids: Vec<String>,
|
||||
}
|
||||
|
||||
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
|
||||
let sanitized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(max_chars)
|
||||
.collect::<String>();
|
||||
if sanitized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sanitized)
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
|
||||
}
|
||||
|
||||
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
|
||||
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
|
||||
if value
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
|
||||
{
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
|
||||
value
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
|
||||
}
|
||||
|
||||
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
|
||||
let basename = asset
|
||||
.local_path
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(&asset.id);
|
||||
let without_extension = basename
|
||||
.rsplit_once('.')
|
||||
.map(|(name, _)| name)
|
||||
.unwrap_or(basename)
|
||||
.trim();
|
||||
if without_extension.is_empty() {
|
||||
asset.id.clone()
|
||||
} else {
|
||||
without_extension
|
||||
.chars()
|
||||
.filter(|character| !character.is_control())
|
||||
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
|
||||
let resource_id = value.trim();
|
||||
if resource_id.is_empty()
|
||||
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|
||||
|| resource_id.chars().any(char::is_control)
|
||||
{
|
||||
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
|
||||
}
|
||||
Ok(resource_id.to_string())
|
||||
}
|
||||
|
||||
fn render_resource_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexResourceReference,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let local_path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| asset_display_label(asset));
|
||||
let source = sanitize_reference_source(reference.source.as_deref())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
Ok(format!(
|
||||
"- 素材 ID:{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
|
||||
asset.kind, asset.media_type
|
||||
))
|
||||
}
|
||||
|
||||
fn render_runtime_region_reference_line(
|
||||
manifest: &GameCreationAppManifest,
|
||||
reference: &DirectCodexRuntimeRegionReference,
|
||||
) -> Result<String, String> {
|
||||
let label = sanitize_reference_label(reference.label.as_deref())
|
||||
.unwrap_or_else(|| "运行画面区域".to_string());
|
||||
let run_id = sanitize_reference_source(reference.run_id.as_deref());
|
||||
let version_id = sanitize_reference_source(reference.version_id.as_deref());
|
||||
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
|
||||
let element_role = sanitize_reference_element(reference.element_role.as_deref());
|
||||
let text = reference
|
||||
.text
|
||||
.as_deref()
|
||||
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
|
||||
let width = sanitize_reference_dimension(reference.width);
|
||||
let height = sanitize_reference_dimension(reference.height);
|
||||
|
||||
// `resourceIds` 是本模块唯一由客户端直接给出、且自身还是一条列表的字段:条数不设界时,
|
||||
// 每个 id 都要扫一遍 manifest(O(assets)),注入提示词的 `关联素材 ID:…` 行也会跟着无界
|
||||
// 变长(最终只被 32 MiB 写入护栏拦下,变成一条和原因无关的连接级错误)。这里按模块的
|
||||
// 失败关闭口径直接拒绝超限,而不是静默丢掉用户选中的关联。
|
||||
if reference.resource_ids.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!(
|
||||
"运行画面区域一次最多关联 {MAX_DIRECT_CODEX_REFERENCES} 个素材,请重新点选"
|
||||
));
|
||||
}
|
||||
let mut related_resource_ids = Vec::new();
|
||||
for resource_id in &reference.resource_ids {
|
||||
let resource_id = validate_resource_reference_id(resource_id)?;
|
||||
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
|
||||
return Err("运行画面引用的素材已变化,请重新点选".to_string());
|
||||
}
|
||||
// 去重:同一个 id 在注入提示词里重复出现没有信息量,只是把行撑长。
|
||||
// 条数已按上限收口,所以这里的逐项比较不会退化成大面积二次扫描。
|
||||
if !related_resource_ids.contains(&resource_id) {
|
||||
related_resource_ids.push(resource_id);
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts = vec![format!("名称:{label}")];
|
||||
if let Some(run_id) = run_id {
|
||||
parts.push(format!("运行标识:{run_id}"));
|
||||
}
|
||||
if let Some(version_id) = version_id {
|
||||
parts.push(format!("版本标识:{version_id}"));
|
||||
}
|
||||
if let Some(element_tag) = element_tag {
|
||||
parts.push(format!("元素:{element_tag}"));
|
||||
}
|
||||
if let Some(element_role) = element_role {
|
||||
parts.push(format!("角色:{element_role}"));
|
||||
}
|
||||
if let Some(text) = text {
|
||||
parts.push(format!("文本摘要:{text}"));
|
||||
}
|
||||
if let (Some(width), Some(height)) = (width, height) {
|
||||
parts.push(format!("尺寸:{width}x{height}"));
|
||||
}
|
||||
if !related_resource_ids.is_empty() {
|
||||
parts.push(format!("关联素材 ID:{}", related_resource_ids.join(",")));
|
||||
}
|
||||
Ok(format!("- 运行画面区域:{}", parts.join(";")))
|
||||
}
|
||||
|
||||
pub(crate) fn render_direct_codex_references_section(
|
||||
root: &Path,
|
||||
references: &[DirectCodexTurnReference],
|
||||
) -> Result<Option<String>, String> {
|
||||
if references.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut lines = Vec::with_capacity(references.len());
|
||||
for reference in references {
|
||||
lines.push(match reference {
|
||||
DirectCodexTurnReference::Resource(reference) => {
|
||||
render_resource_reference_line(&manifest, reference)?
|
||||
}
|
||||
DirectCodexTurnReference::RuntimeRegion(reference) => {
|
||||
render_runtime_region_reference_line(&manifest, reference)?
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(
|
||||
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
|
||||
.chain(lines)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture_project() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().expect("temp project");
|
||||
let root = directory.path();
|
||||
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
|
||||
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
|
||||
manifest.assets.push(GameCreationAppAssetManifestEntry {
|
||||
id: "asset-hero".to_string(),
|
||||
kind: "character".to_string(),
|
||||
media_type: "image/png".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
source: GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Uploaded,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind("character"),
|
||||
tags: Vec::new(),
|
||||
});
|
||||
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
|
||||
directory
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("素材 ID:asset-hero"));
|
||||
assert!(section.contains("名称:主角"));
|
||||
assert!(section.contains("项目路径:assets/hero.png"));
|
||||
assert!(!section.contains("C:\\secret.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_resource_fails_closed() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect_err("missing resource");
|
||||
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
|
||||
let project = fixture_project();
|
||||
let reference: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&reference),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(section.contains("运行画面区域:名称:开始按钮"));
|
||||
assert!(section.contains("文本摘要:开始游戏"));
|
||||
assert!(section.contains("尺寸:120x40"));
|
||||
assert!(section.contains("关联素材 ID:asset-hero"));
|
||||
assert!(!section.contains("onclick"));
|
||||
assert!(!section.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_region_dedupes_and_bounds_related_resource_ids() {
|
||||
let project = fixture_project();
|
||||
// 同一个 id 重复出现只应产生一条关联。
|
||||
let duplicated: DirectCodexTurnReference = serde_json::from_str(
|
||||
r#"{"type":"runtime-region","label":"开始按钮","resourceIds":["asset-hero","asset-hero"," asset-hero "],"text":"开始游戏"}"#,
|
||||
)
|
||||
.expect("reference json");
|
||||
let section = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&duplicated),
|
||||
)
|
||||
.expect("render")
|
||||
.expect("section");
|
||||
assert!(
|
||||
section.contains("关联素材 ID:asset-hero\n")
|
||||
|| section.trim_end().ends_with("关联素材 ID:asset-hero"),
|
||||
"{section}"
|
||||
);
|
||||
assert!(
|
||||
!section.contains("asset-hero,"),
|
||||
"重复 id 不得在注入提示词里重复出现:{section}"
|
||||
);
|
||||
|
||||
// 超出上限直接失败关闭:不能按对方给的长度注入提示词。
|
||||
let oversized_ids = (0..MAX_DIRECT_CODEX_REFERENCES + 1)
|
||||
.map(|_| "\"asset-hero\"".to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let oversized: DirectCodexTurnReference = serde_json::from_str(&format!(
|
||||
r#"{{"type":"runtime-region","label":"开始按钮","resourceIds":[{oversized_ids}]}}"#
|
||||
))
|
||||
.expect("reference json");
|
||||
let error = render_direct_codex_references_section(
|
||||
project.path(),
|
||||
std::slice::from_ref(&oversized),
|
||||
)
|
||||
.expect_err("oversized resource id list must fail closed");
|
||||
assert!(error.contains("最多关联"), "{error}");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user