Files
youle_cocos/docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md
T
joywayerandClaude Opus 5 02dc5d51ca chore(spec): 综合清理 + legacy-layer 迁移 spec/plan/data
主要改动:
- 切到 funplay-cocos-mcp v0.5.1 (用户级配置, 项目级 .mcp.json 删除)
- 仓库文档/CLAUDE.md/.gitignore 等清理过时 cocos-mcp-server 引用
- memory 文件同步: cocos-mcp-setup/path/blocker/spriteframe-uuid/prefab-persist 等加 funplay 实测警告
- memory 新建 funplay-cocos-mcp-pending-verification.md (后已被实测覆盖)
- spec/plan/data:
  - docs/superpowers/specs/2026-09-02-legacy-layer-migration-design.md
  - docs/superpowers/plans/2026-09-02-legacy-layer-migration.md
  - docs/superpowers/data/layer-spirit-summary.json
- YouleNexus: profiles.ts / defaults.ts / PlayerInfoView.prefab / scene 改动

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 07:36:54 +08:00

366 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# legacy-layer → Cocos prefab 转换脚本设计
> **Status**: Draft v1 (brainstorming approved, awaiting user spec review)
> **Date**: 2026-09-02
> **Spec for**: `scripts/legacy-layer-to-cocos-prefab.mjs` + 测试套件
## Goal
把 `projects/Game_Surface_3/save/Layer*.xml`(GB18030 编码 XML)+ `output/gameabc_*.json` 的结构化 UI 数据,**程序化转换为 Cocos prefab JSON**,让 YouleNexus 已迁移的 ~17 个组件 prefab(widgets/ + views/ + Login_Layer)能 1:1 对齐原项目 Game_Surface_3 的布局+精灵+Widget 锚定。
**成功标准**:脚本生成的 prefab 加载到 Cocos 编辑器 0 报错,节点树/SpriteFrame/Widget 锚定与原 Layer XML 字段语义一致。
## 范围(本设计覆盖)
### 已核实映射表(2026-09-02 全量 XML 解析 + 用户拍板)
| 目标 prefab(YouleNexus) | 对应 Layer XML | Layer 名称 | Group | Spirit 数 | 备注 |
|---|---|---|---|---|---|
| `prefabs/Login_Layer.prefab` | Layer00002.xml | Login_Layer | 2 | 18 | 已有完整复刻,端到端 PoC 验证 |
| `prefabs/views/PlayerInfoView.prefab` | **Layer00017.xml** | **MenuPlayerInfo_Layer** | **18** | 15 | **用户拍板 A** |
| `prefabs/widgets/Group106_PhoneVerify.prefab` | Layer00029.xml | phoneInfo | 106, 108 | 38 | |
| `prefabs/widgets/Layer10_Protocol.prefab` | Layer00010.xml | Protol_Layer | 37 | 4 | **PoC 阶段 2**(含 Widget 锚定) |
| `prefabs/widgets/Layer609_Setting.prefab` | Layer00609.xml | Setting_Layer | 6 | 15 | |
| `prefabs/widgets/Layer612_Tips.prefab` | Layer00612.xml | Tips_Layer | 32, 89 | 5 | |
| `prefabs/widgets/Layer613_RoomCardUpdate.prefab` | Layer00613.xml | updateRoomCard_Layer | 34 | 3 | |
| `prefabs/widgets/Layer614_Loading.prefab` | Layer00614.xml | Loading_Layer | 40 | 2 | |
| `prefabs/widgets/Layer615_Reconnect.prefab` | Layer00615.xml | Reconnect_Layer | 35 | 4 | |
| `prefabs/widgets/Layer616_Kick.prefab` | Layer00616.xml | Kick_Layer | 33 | 3 | |
| `prefabs/widgets/Layer617_Notice.prefab` | **Layer00008.xml** | **Notice_Layer** | **3, 95** | **9** | **用户拍板 B** |
| `prefabs/widgets/Layer619_BackOtherGame.prefab` | Layer00619.xml | BackHall_Layer | 55 | 1 | Layer 名 BackHall ≠ BackOtherGame,但 group 唯一匹配 |
| `prefabs/widgets/Layer620_Record.prefab` | Layer00620.xml | recordLayer | 101 | 3 | |
**总计:11 个 prefab 全部映射到明确 Layer XML**(不含 6 个 NO_MATCH widget)
### 范围外(**用户拍板 C:先不做**)
| 目标 prefab | 原因 |
|---|---|
| `prefabs/templates/ListItem_Room.prefab` | 跨 Layer 复用的 widget 子模板 / YouleNexus 新加,未在 save/Layer*.xml 中找到 |
| `prefabs/widgets/Btn_Primary.prefab` | 通用按钮 widget,YouleNexus 新加 |
| `prefabs/widgets/IconButton.prefab` | 通用图标按钮 widget,YouleNexus 新加 |
| `prefabs/widgets/Modal.prefab` | 通用弹窗 widget,YouleNexus 新加 |
| `prefabs/widgets/NumericLabel_Score.prefab` | 数字滚动 Label widget,YouleNexus 新加 |
| `prefabs/widgets/ProgressBar_Standard.prefab` | 通用进度条 widget,YouleNexus 新加 |
### 事实源
完整 Layer 解析数据见 `docs/superpowers/data/layer-spirit-summary.json`(68 个 Layer 全量、81 个 group、5 种 SpiritType 分布)。
### 未来扩展(如需)
如果要补做 6 个 NO_MATCH widget,需先在 `scripts/legacy-layer-to-cocos-prefab.mjs` 加"跨 Layer Spirit 提取"能力(按 Sprite name / Layer 内 widget 模式匹配),不在本设计范围。
**不在范围**:
- 未迁移的 68 个 Layer 中的其余 ~50 个
- 动画(Spirit 的 `ani_*` 属性 / Spine / DragonBones)
- 多语言文本
- 协议层字段
## Architecture
### 数据流
```
projects/Game_Surface_3/save/Layer*.xml ─┐
projects/Game_Surface_3/output/ ─┼→ [legacy-layer-to-cocos-prefab.mjs] → out/legacy-migration/
gameabc_Image.json (sprite 映射) │ ├─ <Name>.prefab (UTF-8 JSON 数组)
gameabc_Project.json (适配元数据) │ └─ <Name>.diff (vs YouleNexus 现有)
projects/Game_Surface_3/js/ ─┘
gameabc.min.js (语义来源,需逆向)
```
### 模块划分(单文件脚本 + 7 个测试)
| 模块 | 职责 |
|---|---|
| `readLayerXml(path)` | GB18030 编码读 XML → JS 对象 |
| `resolveSpriteFrameUuid(imgResId)` | 查 gameabc_Image.json → atlas SpriteFrame 真 UUID(含 @f9941) |
| `buildGroupParents(spirits)` | 按 BelongGroupID 分组 → group-X 父节点骨架 |
| `mapSpiritTypeToComponent(type)` | SpiritType → 组件类型枚举(逆向 gameabc.min.js) |
| `computeLpos(spirit, parentGroup)` | CoordSelfPos + CoordRelaX/Y → _lpos {x,y,z} |
| `computeContentSize(spirit)` | SizeCalcMode + SizeRelaWidth/Height + Width/HeightCalcMode → _contentSize {width,height} |
| `computeWidgetAlignment(spirit)` | SizeCalcMode + 父 group 关系 → cc.Widget._alignFlags + _horizontalCenter 等 |
| `emitPrefabJson(name, group, root)` | 按 Login_Layer.prefab schema 输出 JSON 数组(含 CompPrefabInfo / PrefabInfo) |
| `diffAgainstExisting(generated, existing)` | 输出 .diff 文件 |
## 输入契约
### `save/Layer<id>.xml`
**GB18030 编码**(不是 UTF-8 也不是 GBK!实测 Python xml.etree 用 GBK 解码会乱码,GB18030 才能完整解析中文 Spirit 名)。结构:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<Layer Version="2.6" SaveTime="...">
<PropertyList>
<Property Name="ID" Value="2"/>
<Property Name="Name" Value="Login_Layer"/>
<Property Name="Description" Value=""/>
</PropertyList>
<OptionList/> <EventList/>
<SpiritList>
<Spirit>
<PropertyList>
<Property Name="SpiritType" Value="0"/>
<Property Name="ID" Value="<spirit_id>"/>
<Property Name="Name" Value="<spirit_name_zh>"/> <!-- GB18030 -->
<Property Name="ImgResID" Value="<sprite_id>"/>
<Property Name="X" Value="0"/>
<Property Name="Y" Value="0"/>
<Property Name="Width" Value="1280"/>
<Property Name="Height" Value="720"/>
<Property Name="BelongLayerID" Value="2"/>
<Property Name="IndexOfLayer" Value="1"/>
<Property Name="BelongGroupID" Value="2"/> <!-- 0=根, >0=group-N -->
<Property Name="CoordParentID" Value="0"/>
<Property Name="CoordParentPos" Value="0"/>
<Property Name="CoordSelfPos" Value="0"/>
<Property Name="CoordRelaX" Value="0"/>
<Property Name="CoordRelaY" Value="0"/>
<Property Name="SizeCalcMode" Value="2"/>
<Property Name="SizeParentID" Value="21"/>
<Property Name="WidthCalcMode" Value="1"/>
<Property Name="SizeRelaWidth" Value="10000"/>
<Property Name="HeightCalcMode" Value="1"/>
<Property Name="SizeRelaHeight" Value="10000"/>
</PropertyList>
<OptionList/> <EventList/>
</Spirit>
...
</SpiritList>
</Layer>
```
> **GB18030 编码陷阱**:脚本必须用 GB18030 读(GBK 是 GB18030 子集,但部分生僻字 / 私有区字符需要 GB18030 才能解码)。UTF-8 会乱码。Spirit 名是中文,输出 .prefab JSON 时按 UTF-8 写。Node.js 用 `iconv-lite` 包支持 GB18030 解码。
### `output/gameabc_Image.json`
`ImgResID → atlas spriteFrame path` 映射(具体 schema 在 PoC 阶段 1 解析)。
### `output/gameabc_Project.json`
```json
{
"Property": {
"ProjectName": "Game_Surface_3",
"ScreenWidth": 1280,
"ScreenHeight": 720,
"GameSceneWidth": 1699,
"GameSceneHeight": 1800,
"ScreenFitMode": 1
}
}
```
> **适配策略**:原项目 ScreenWidth=1280, ScreenHeight=720,但 YouleNexus 当前 Login_Layer.prefab 根 _contentSize = 1600×720。脚本默认生成 1600×720 的根 UITransform(与 Login_Layer.prefab 一致),但 Spirit 内部坐标按原 XML 不变——**Widget 锚定负责多分辨率适配**。
### `js/gameabc.min.js`
逆向目标:
- `SpiritType` 枚举(0=Sprite?1=Text?2=Button?3=ProgressBar?...)
- `CoordSelfPos` 计算公式(X/Y 相对父节点的偏移量)
- `SizeCalcMode` 公式(SizeParentID / SizeRelaWidth / SizeRelaHeight 怎么合成 _contentSize)
- `WidthCalcMode` / `HeightCalcMode` 公式
- BelongGroupID 的语义
> **逆向策略**:在 PoC 阶段 1 之前完成。逆向结果固化到 `framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json` 作为已知事实源。
## 输出契约
### `out/legacy-migration/<Name>.prefab`
UTF-8 JSON 数组(**严格按 Login_Layer.prefab schema**),字段含义:
```typescript
type PrefabJson = Array<PrefabObject>
type PrefabObject =
| { __type__: "cc.Prefab", _name: string, ... } // 0
| { __type__: "cc.Node", _name: string, _children: [{__id__: number}], _components: [{__id__: number}], _lpos: Vec3, _lrot: Quat, _lscale: Vec3, ... }
| { __type__: "cc.UITransform", _contentSize: Size, _anchorPoint: Vec2, ... }
| { __type__: "cc.Sprite", _spriteFrame: { __uuid__: string, __expectedType__: "cc.SpriteFrame" }, _type: 0|1, _sizeMode: 0|1|2, ... }
| { __type__: "cc.Label", _string: string, _fontSize: number, _horizontalAlign: 0|1|2, _verticalAlign: 0|1|2, _color: Color, _font: null, _isSystemFontUsed: true, ... }
| { __type__: "cc.Widget", _alignFlags: number, _left?: number, _right?: number, _top?: number, _bottom?: number, _horizontalCenter?: number, _verticalCenter?: number, _isAbs*: true, _alignMode: 2, ... }
| { __type__: "cc.CompPrefabInfo", fileId: string }
| { __type__: "cc.PrefabInfo", root: {__id__: number}, asset: {__id__: number}, fileId: string, ... }
| { __type__: string /* custom class UUID */, _name: "", _enabled: true, ... }
```
### `out/legacy-migration/<Name>.diff`
`diff -u` 格式,对比脚本输出 vs YouleNexus `prefabs/<path>/<Name>.prefab` 现有内容。**仅供人审**,不自动覆盖。
### 输出根节点 `_contentSize`
默认 `1600×720`(与 Login_Layer.prefab 一致),**不按原 Project.GameSceneWidth/Height**——理由:YouleNexus Canvas 设计尺寸固定 1600×720(CLAUDE.md 第一准则精神:UI 层自己处理适配,原项目 ScreenWidth 是另一回事)。
## 关键映射规则
### Spirit → cc.Node
| 原字段 | Cocos 字段 |
|---|---|
| `Spirit.Name` | `cc.Node._name` |
| `Spirit.ID` | 用于生成 `_id`(cocos 内部 ID,与 fileId 不同) |
| `Spirit.BelongGroupID = "0"` | 节点挂在 Layer 根节点下 |
| `Spirit.BelongGroupID = "N"` (N>0) | 节点挂在 `group-N` 父节点下 |
| `Spirit.IndexOfLayer` | 同 group 内的兄弟顺序(生成 `_children` 数组顺序) |
| `Spirit.CoordParentID` | 父节点引用(=同 group 内其他 Spirit 的 ID) |
### Group 父节点自动生成
- 自动创建 `cc.Node _name = "group-<N>"`,N = 原 BelongGroupID 值
- **保留全局编号**(不重新编号为 group-1/2/3),便于 cross-layer 调试(与 Login_Layer 复刻一致)
- 父节点本身**无 sprite/label**,只挂 UITransform + Widget(与 Login_Layer.prefab 的 group-2 一致)
- 多个 group 时按 group ID 升序排(group-1, group-2, ...)
### SpiritType → 组件
**逆向目标**(在 PoC 阶段 1 之前完成)。当前猜测:
| SpiritType | 组件类型 | 说明 |
|---|---|---|
| 0 | `cc.Sprite` | 普通精灵 |
| 1 | `cc.Label` | 文本 |
| 2 | `cc.Button` | 按钮(待确认) |
| 3 | `cc.ProgressBar` | 进度条(Login_Layer "进度条底" 是 type=3) |
| ... | ... | 全部逆向 gameabc.min.js 后填表 |
**未识别 type → throw**,不静默。
### ImgResID → SpriteFrame 真 UUID
查 `output/gameabc_Image.json` 拿到 atlas 图片路径 → 查 `atlas-hall/atlas-login/atlas-room` atlas 的 SpriteFrame UUID(含 `@f9941` sub-asset 后缀)。
> **找不到 → throw + 列出所有未映射 ID**(CLAUDE.md 第二准则)。
### 坐标/尺寸计算
**锚点转换公式(实测验证 2026-09-02,Login_Layer 渠道logo + 游客登录双节点匹配)**:
原项目精灵锚点 = **左上角**(X/Y 是精灵左上角的绝对屏幕坐标,基于 1280×720)
Cocos 精灵锚点 = **中心**(_lpos 是精灵中心在父节点坐标系的位置,Y-up)
```
# Spirit → cc.Node._lpos(假设父节点 _contentSize 1600×720、anchorPoint (0.5, 0.5))
cocos_lpos.x = original_X + original_W / 2 - 640 # 原坐标系半宽 1280/2
cocos_lpos.y = 360 - (original_Y + original_H / 2) # Y-up 翻转 + 原坐标系半高 720/2
cocos_lpos.z = 0
```
**验证证据**:
- 渠道logo:原 (734, 143, 59, 148) → Cocos (763.5 - 640, 360 - 217) = (123.5, 143) ✓
- 游客登录:原 (540, 388, 320, 95) → Cocos (700 - 640, 360 - 435.5) = (60, -75.5) ✓
**SizeCalcMode 公式(待 PoC 阶段 1 逆向 gameabc.min.js)**:
```
_contentSize.width = Spirit.Width || 0
_contentSize.height = Spirit.Height || 0
# 如果 SizeCalcMode != 0,用 SizeParentID + SizeRelaWidth/Height 计算(公式逆向中)
if Spirit.SizeCalcMode != 0:
parent_size = lookup(Spirit.SizeParentID)
_contentSize.width = parent_size.width * Spirit.SizeRelaWidth / 10000
_contentSize.height = parent_size.height * Spirit.SizeRelaHeight / 10000
```
**Widget 锚定**:
| 情况 | `_alignFlags` | 其他字段 |
|---|---|---|
| 节点相对 Canvas 全屏铺满 | 45 (= 左+右+上+下) | `_left/_right/_top/_bottom = 0` |
| 节点水平居中 | 16 (= 水平居中) | `_horizontalCenter = Spirit.X` |
| 节点垂直居中 | 8 | `_verticalCenter = Spirit.Y` |
| 节点左上对齐 | 9 (= 左+上) | `_left = Spirit.X, _top = Spirit.Y` |
| ... | ... | 全部逆向后填表 |
**逆向不出公式 → throw**,不兜底默认值。
## 错误处理(CLAUDE.md 第二准则:不静默兜底)
| 场景 | 处理 |
|---|---|
| `gameabc_Image.json` 找不到 ImgResID | `throw new Error(\`ImgResID ${id} (Spirit ${sid}) not in gameabc_Image.json\`)` |
| SpiritType 不在已知枚举 | `throw new Error(\`Unknown SpiritType ${type} (Spirit ${sid}) — reverse gameabc.min.js first\`)` |
| CoordSelfPos/SizeCalcMode 公式逆向不出 | `throw new Error(\`Cannot compute coord/size for Spirit ${sid}: formula unknown\`)` |
| XML 字段缺失或编码错 | `throw new Error(\`Spirit ${sid} missing field ${fieldName}\`)` |
| 输出目录有同名 .prefab | **覆盖**(隔离目录是 transient)+ stdout 列 diff 路径 |
| `gameabc_Image.json` schema 变更 | 启动时 schema 校验,失败直接 throw |
| Layer 在 PoC 阶段 4 找不到目标 YouleNexus prefab | throw + 列出所有"无目标"Layer,让用户补映射 |
## 测试策略(TDD)
按 ui-asset-and-skin plan 风格:`node:test` + `node:assert` + `tsx`,零第三方依赖。
### 测试套件:`framework-tests/legacy-layer-migration/`
| 文件 | 覆盖 |
|---|---|
| `xml-parser.test.mjs` | GB18030 编码读、字段提取、BelongGroupID 分组 |
| `spirit-to-node.test.mjs` | SpiritType=0/1/3 → Sprite/Text/ProgressBar 组件映射 |
| `group-builder.test.mjs` | BelongGroupID → group-X 父节点合并 + 同 group 内 IndexOfLayer 排序 |
| `coord-calculator.test.mjs` | CoordSelfPos/SizeCalcMode/WidthCalcMode/HeightCalcMode 公式正确性 |
| `sprite-uuid-resolver.test.mjs` | ImgResID → 现有 atlas SpriteFrame UUID(基于真实 gameabc_Image.json fixture) |
| `prefab-emitter.test.mjs` | 输出 JSON 格式严格按 Login_Layer.prefab schema(用 Login_Layer.prefab 实际内容作 fixture) |
| `poc-iconbutton.test.mjs` | 端到端:输入 save/Layer*.xml → 输出 .prefab → 与 YouleNexus 原 prefab 做 schema 对比(**不对比内容,只对比结构**) |
### TDD 红绿循环(每个 PoC 阶段)
1. **红**:写测试(输入已知 Layer XML,期望已知 prefab JSON 结构)
2. **绿**:写脚本直到测试通过
3. **重构**:优化代码
4. **人工 diff**:`diff -u out/legacy-migration/<Name>.prefab prefabs/<path>/<Name>.prefab`,看 diff
5. **修订**:根据 diff 调整映射规则,回到 1
### 测试 fixtures
- `framework-tests/legacy-layer-migration/fixtures/spirit-semantics.json` — gameabc.min.js 逆向结果固化
- `framework-tests/legacy-layer-migration/fixtures/gameabc_Image.json` — 真实 gameabc_Image.json 副本
- `framework-tests/legacy-layer-migration/fixtures/Layer00002.xml` — Login_Layer XML 副本(GB18030)
- `framework-tests/legacy-layer-migration/fixtures/Login_Layer.prefab` — 已复刻的 Login_Layer prefab 副本(用于端到端对比)
## PoC 路径(4 阶段)
| 阶段 | 目标 | 验证点 |
|---|---|---|
| **阶段 1** | IconButton(单 Sprite、无 Widget、无 group) | Spirit→Node + SpriteFrame 解析 + JSON 输出 + schema 对比 |
| **阶段 2** | Layer10_Protol(Layer00010,group 37,含 Widget 锚定) | group-X 父节点合并 + Widget 锚定逻辑 |
| **阶段 3** | Login_Layer(已有完整 1:1 复刻可对比) | 端到端验证 + 人工 diff 接近零差异 |
| **阶段 4** | 批量跑剩 14 个 | 每个输出 .diff 供人审 |
阶段 1 必须先逆向 gameabc.min.js 中 IconButton 用到的 SpiritType/Coord 公式。
## 不做的事(明确边界)
- ❌ 不覆盖 YouleNexus 原 prefab(**只生成候选 + diff**)
- ❌ 不处理动画(Spirit 的 `ani_*` 属性 → throw + 标出该 Spirit)
- ❌ 不处理 Spine / DragonBones(unknown SpiritType → throw)
- ❌ 不处理多语言(只读 `_string` 字面值,不查国际化表)
- ❌ 不迁移协议字段(CLAUDE.md 第一准则:协议不动)
- ❌ 不重写为 click handler / 业务逻辑(只做布局+精灵迁移,**业务挂脚本留 follow-up**)
- ❌ 不处理 OptionList.tag/tag1-3 / EventList(除非 layer-to-prefab 有明确映射,待逆向)
## Risks & Open Questions
1. **gameabc.min.js 逆向深度**:SpiritType 枚举只有 5 种(0/1/3/4/5,全量解析已确认),但 SpiritType 4 和 5 的语义仍需逆向(猜测 4=Particle / 5=Button,但需 PoC 阶段 1 验证);CoordSelfPos / SizeCalcMode 公式逆向中。
2. **SpriteFrame UUID 跨 atlas 选择规则**:atlas 选哪个取决于原 Layer 来源场景 —— Login_Layer 用 `atlas-login`,MainScene 用 `atlas-hall`,Room 用 `atlas-room`。具体规则按 Layer 编号或 Spirit 名称模式决定,**PoC 阶段 1 实测后固化到 fixtures/spirit-semantics.json**。
3. **Layer00007 ewm_Layer 含 group=0**:表示有节点不在 group-X 父节点下(直接挂在 Layer 根)。脚本需支持 BelongGroupID=0 节点(按现有 group-X 父节点方案应直接挂 Layer 根)—— **已在范围表规则中说明**,待 PoC 验证。
4. **YouleNexus 原 prefab 的偏差**:某些 prefab 可能已经人工调整过布局,与原 Layer 不一致 —— **diff 工具帮人审,但最终覆盖决策在你**
## Out of Scope(follow-up)
- 把所有 68 个 Layer 全迁移
- 自动生成 sprite atlas(走 ui-asset-and-skin plan Task 3 独立推进)
- 迁移 OptionList.tag/tag1-3 业务字段
- 迁移 EventList 事件处理
## Success Metrics
- 17 个目标 prefab 全部生成;与 YouleNexus 现有版本 diff **仅显示手工已修字段的差异**(坐标/尺寸/spriteFrame UUID/Widget 锚定全部精确匹配)
- 锚点转换公式精度:**至少 3 个已知 Spirit 的 _lpos 与人工复刻版 prefab 误差 < 0.001**
- 7 个测试文件全部通过(node:test)
- `framework-tests/legacy-layer-migration/` 覆盖率 ≥ 80%
- 脚本可在 CI 跑(无 GUI 依赖)
- gameabc.min.js 逆向结果固化到 fixtures/spirit-semantics.json,作为后续 PoC 阶段的事实源