chore: add agent guidance and ignore local workspaces

Document Unity and Blender MCP workflows and retain workspace initialization tooling and QA evidence. Exclude local resource copies and the credential draft from version control.
This commit is contained in:
2026-11-12 14:31:34 +08:00
parent 92623a1bc8
commit 0857e291d5
8 changed files with 664 additions and 0 deletions
+7
View File
@@ -6,6 +6,12 @@
/[Bb]uilds/
/MemoryCaptures/
/Recordings/
/tree1/
/tree2/
/tree3/
/[Ww]ork[Tt]ree/
/worktrees/
/.worktrees/
.vs/
.idea/
*.csproj
@@ -24,5 +30,6 @@ __pycache__/
/.env
/.env.*
!/.env.example
/agents.md.txt
.DS_Store
Thumbs.db
+44
View File
@@ -0,0 +1,44 @@
# 项目协作约定
本文件适用于仓库及其子目录。默认用中文交流,按现有方案完成常规实现;涉及玩法方向、平台、存档格式等重大决策时向用户确认。用户已经授权的操作持续有效。
## 工程与工作入口
- 项目为 IRONSPORE:Windows PC 键鼠机甲搜打撤原型,含越肩和第一人称、配装、局内改装及中英双语。
- 使用 `ProjectSettings/ProjectVersion.txt` 指定的 Unity 版本,当前为 `6000.6.8f1`。依赖版本以 `Packages/manifest.json` 和锁定文件为准。
- 开始相关工作前按需阅读 [README](README.md)、[玩法设计](Docs/Design/GameDesign.md)、[实施计划](Docs/Production/ImplementationPlan.md) 和 [文件规范](Docs/Production/ProjectConventions.md)。沿用现有结构,避免无关重构。
## Unity 与 Blender
- 引擎操作使用 **Unity MCP**。场景、组件、预制体、导入设置和构建修改前,回读目标工程的完整路径、活动场景与编辑器状态;操作后检查保存结果及 Console。
- 多实例操作明确绑定准确的 `Name@hash`,逐次核对完整工程路径,不以项目名或路径前缀推断目标。不同目录分别维护缓存和 MCP 会话。
- C#、USS、JSON、工具脚本和文档可以通过文件工具修改。运行时代码修改后,通过 Unity MCP 刷新并等到编译与域重载结束,再测试或构建。现有桥接入口为 `Tools/Unity/McpClient.py`。
- 3D 建模、修整、导出使用 **Blender MCP**。先读当前文件、场景和对象;在任务自己的场景或集合中操作,保留已有未参与任务的内容。
- Blender 源文件保存在 `SourceAssets/Blender/`,导出模型进入 `Assets/Game/Art/Models/`。尺寸以米为单位,核对朝向、碰撞和武器/驾驶舱挂点。
- 不通过重建全部原型场景来完成局部修改;编辑器生成工具只在任务明确需要时运行。
## 文件、资源与数据
- 源码按功能放入 `Assets/Game/Scripts/`,配置放入 `Assets/Game/Data/`,场景放入 `Assets/Scenes/`,UI 和本地化沿用各自现有目录。
- Unity 资产与对应 `.meta` 一起维护,保留 GUID。已导入资产的移动或重命名通过 Unity MCP 完成并检查引用。
- 允许从公开网站下载免费的模型、声音、字体或代码。核实具体许可,保留原件、作者、版本/日期及 SHA-256,在 [素材台账](Docs/Production/AssetSources.md) 登记;许可原文放入 `Docs/Production/Licenses/`,发布包附必要署名。
- 下载原件放入 `SourceAssets/ThirdParty/<发行方>/<资源包>/`,Unity 导入物放入 `Assets/ThirdParty/` 对应目录;项目适配代码和派生资源使用项目自身目录。
- 翻译使用稳定文本键和完整句式参数,保留物品 ID、蓝图 ID、存档字段及回收事件编号。已有通知和结算也应随语言切换刷新。
- 存档、回收去重与写入失败处理属于核心行为;修改时保留现有版本兼容,并验证状态没有重复结算。
## 本地资源目录与版本控制
- `workTree/`、`worktrees/`、`.worktrees/` 和 `tree1/`、`tree2/`、`tree3/` 是本地工作区或资源副本目录,排除在主仓库提交及云端推送之外。不要使用强制添加将其纳入 Git。
- 已初始化的 tree 资源副本尚无独立分支或 worktree 注册。运行前核对目录内的 `WorkspaceInfo.json` 和实际路径;移动后的旧清单不能作为正确连接目标的依据。
- 并行运行前分别隔离测试存档与 PlayerPrefs,并为各目录完成 Unity 导入和 MCP 实例绑定。资源复制通过不等于编译、运行或性能验收通过。
- 分支、worktree 的创建和合并按用户明确要求执行。推送前检查暂存区、完整待推送路径及远程状态,使用正常推送,不擅自强制覆盖远程历史。
- 维护忽略规则:不提交 Library、Temp、Logs、Builds、UserSettings、IDE 缓存、生成的工程文件或凭据。已整理的 Docs/QA 验收摘要、测试结果及截图可以提交。
- 仓库地址通过 Git remote 管理。密码、令牌和带凭据的 URL 不写入本文件、源码、提交说明或可上传的日志。
## 验证与交付
- 根据实际修改选择必要检查。纯文档或 Git 配置变更检查链接、暂存范围与忽略规则;玩法、存档和本地化修改运行相关测试,修复失败后再继续。
- 当前自动化测试在 `Assets/Tests/EditMode/`,Unity MCP 测试命令在 `Tools/Unity/Commands/Tests.json`,结果通过对应 job 查询到完成。
- UI 变更核对实际分辨率、文本溢出、中文字符和鼠标/键盘交互。Windows 构建通过 MCP 发起并轮询到结束,启动完整产物目录验证实际行为。
- QA 记录放入 `Docs/QA/<阶段>/`,构建产物放入 `Builds/Windows/x64/`。明确区分静态检查、编辑器测试和独立程序实测,仅记录实际完成的验证。
- 60 FPS 与完整出击节奏为验收目标,不以未测量的推断代替结果。并行开发后的性能采样在单独运行的测试实例中进行。
+2
View File
@@ -4,6 +4,8 @@
## 工程起点
tree1–tree3 的普通目录与资源副本已按 [资源初始化计划](TreeInitializationPlan.md) 完成,结果见 [初始化验收](../QA/Workspaces/Initialization.md)。三个目录暂为普通资源副本,未创建分支或注册 worktree。
2026-11-12 增补的主页语言切换及全局双语工作按 [多语言实施计划](LocalizationPlan.md) 的 L1–L7 执行,完成后继续原有美术、实战与性能工作。
多语言增量已交付简体中文、English、主页右下角即时切换与独立偏好。验证与 Windows 产物见 [多语言验收记录](../QA/Localization/Acceptance.md)。
+95
View File
@@ -0,0 +1,95 @@
# tree1–tree3 资源初始化计划
2026-11-12。资源初始化已按本计划执行完成:建立普通目录及资源快照,暂不创建 Git 分支、不注册 worktree,也不开始玩法优化或同时启动三个编辑器。实际结果见 [初始化验收](../QA/Workspaces/Initialization.md)。
## 位置与基线
三个目标按当前项目根目录下规划:
| 名称 | 目标绝对路径 | 初始用途 |
| --- | --- | --- |
| tree1 | `D:/vibeCoding/unityRouge/tree1` | 完整资源基线,具体任务后续分配。 |
| tree2 | `D:/vibeCoding/unityRouge/tree2` | 完整资源基线,具体任务后续分配。 |
| tree3 | `D:/vibeCoding/unityRouge/tree3` | 完整资源基线,具体任务后续分配。 |
执行前核查确认三个目录均不存在。参考基线为 `main` 的 `92623a1bc8678319819e3300eddf1ccff41a55e9`,Unity 版本为 `6000.6.8f1`。执行开始时冻结并记录一次基线提交,三份快照均从该提交导出,不分别读取可能变化的工作区文件。若执行时 HEAD 已更新,先将实际采用的提交写入初始化记录。
当前基线共 256 个已提交文件,包含源码、资源、制作源文件、工具、文档和验收证据。三份采用独立文件副本,预计合计约 126 MiB,尚不包含以后 Unity 导入生成的缓存。
## 每个目录的内容
```text
tree1/ # tree2、tree3 同样结构
Assets/ # 场景、脚本、配置、模型、字体及全部 .meta
Packages/ # manifest 与锁定文件
ProjectSettings/ # 当前引擎、输入、渲染和构建配置
SourceAssets/ # Blender 源文件、字体原件与许可
Tools/ # Unity、Blender、本地化工具
Docs/ # 设计、规范、许可及已提交验收证据
README.md # 基线的工程说明
.editorconfig
.gitattributes
.gitignore
.vsconfig
WORKSPACE.md # 当前目录用途、阶段及使用边界
WorkspaceInfo.json # tree 名称、来源提交、目标路径及状态
InitializationManifest.json # 文件大小、SHA-256、路径适配记录
```
Unity 资产及 `.meta` 原样成对复制,不重新生成 GUID。原始字体、Blender 源文件与许可完整保留,沿用 [文件规范](ProjectConventions.md) 与 [素材台账](AssetSources.md)。不使用硬链接、目录联接或符号链接共享可修改的 Assets、ProjectSettings 或 SourceAssets。
不复制主仓库的 `.git`、Library、Temp、Obj、Logs、UserSettings、Builds、IDE 缓存和生成的工程文件。已提交到 Docs 的验收摘要与截图属于基线文档,随快照保留。现有 Windows 成品继续位于主工程的 Builds,不为这三个资源目录重新构建。
在主仓库 `.gitignore` 中增加 `/tree1/`、`/tree2/`、`/tree3/`,避免后续普通提交把三份资源重复加入主仓库。三个目录此时没有独立 Git 历史;在其内部运行 Git 仍可能识别到父仓库,不能将其视为已经建立的 worktree。
## 初始化时处理的路径
已发现以下四份工具文件含主工程的绝对路径。每份副本仅将对应根路径替换为本 tree 的完整路径,记录适配前后哈希;主工程工具保持原样。
| 文件 | 适配内容 |
| --- | --- |
| `Tools/Unity/Commands/Build.json` | Windows 原型输出进入本 tree 的 Builds。 |
| `Tools/Unity/Commands/LocalizationBuild.json` | 多语言构建输出进入本 tree 的 Builds。 |
| `Tools/Unity/Commands/Restart.json` | 重开工程的目标指向本 tree。 |
| `Tools/Blender/CreatePrototypeMech.py` | Blender 保存和模型导出根目录指向本 tree。 |
适配采用 JSON 解析或明确的源码常量替换,随后检查解析结果及预期差异。许可、历史验收记录和来源元数据里的原始路径属于记录内容,保持原样。初始化不执行这些工具。
`WorkspaceInfo.json` 记录预期 Unity 工程路径,MCP 实例 ID 保持空值,状态为 `resources-only / not-connected`。Python 服务安装与已有 Unity MCP 包版本可以复用,此阶段不复制全局 Python 环境,不修改现有 MCP 连接。
## 执行顺序
| 编号 | 工作 | 完成条件 |
| --- | --- | --- |
| T0 | 核对主仓库、基线提交、目标路径和剩余空间。 | 三个目标在预定根目录内,没有覆盖风险;记录采用的提交。 |
| T1 | 配置主仓库的三个忽略目录,建立初始化工具与临时区。 | 工具放在 `Tools/Workspaces/`,临时区放在已忽略的 `Logs/WorkspaceInitialization/`。 |
| T2 | 从冻结提交导出资源,在临时区分别生成三份独立副本。 | 已提交内容完整;没有 `.git`、Unity 缓存、构建包或共享链接。 |
| T3 | 适配四份工具路径,生成工作区说明、元数据与 SHA-256 清单。 | 每个副本的输出和重开目标均属于本 tree,原始资源内容保持。 |
| T4 | 校验文件、资产引用基础条件和目录隔离。 | 下方静态验收全部通过。 |
| T5 | 将通过校验的临时目录移到 tree1、tree2、tree3。 | 再次核对源和目标绝对路径;目标不存在才发布,不覆盖已有文件。 |
| T6 | 写入初始化验收与主仓库状态记录。 | 结果进入 `Docs/QA/Workspaces/Initialization.md`;分支列表、worktree 列表与主工程 Assets 不变。 |
若同名目录已经存在,先检查其初始化清单。同一基线且内容完整时可以跳过;存在未知内容或差异时保留现场,不自动覆盖。重新初始化不使用删除整个工作区的方式。
## 静态验收
1. 三份目录的基线提交和复制清单一致;除说明、元数据及四份路径适配外,所有文件与源提交的 SHA-256 一致。
2. Assets 下每个资产与目录均有对应 `.meta`,GUID 与基线一致;场景、预制体、字体配置及本地化表均存在。
3. 包清单、锁定文件、Unity 版本、自制 Blender 源文件、原始字体和许可齐全。
4. 四份工具的写入目标全部在自己的 tree 根目录内,不残留指向主工程的活动输出路径;历史文档中的路径单独列为来源记录。
5. 目录之间没有共享可写链接;没有复制缓存、构建产物或 `.git`。
6. 主仓库的 `git status` 不列出 tree 内资源;分支和 worktree 注册数量保持不变。
7. 记录实际大小、文件数量、适配项与静态检查结果。此阶段不把文件复制成功记录为 Unity 编译、播放或构建通过。
## 启动并行开发前的后续工作
- 各 tree 首次打开 Unity 时独立解析 UPM 包、导入资源和生成缓存,再通过 Unity MCP检查场景、编译与中文字体。资源初始化不预先复制主工程 Library。
- 每个 Unity 编辑器通过准确的 `Name@hash` 与完整工程路径绑定自己的 MCP 会话,实际发现实例后才填入连接信息。
- 当前游戏读取 `Application.persistentDataPath`,语言偏好使用 PlayerPrefs;复制工程会保留相同的 companyName / productName。在并行运行前,通过各自 Unity MCP 配置独立的测试存档及偏好命名空间,避免互相修改主工程测试数据。
- 需要分支时,先明确各目录的任务与修改归属,再在新的空目标目录建立真实 Git worktree。已有资源目录若产生变更,按初始化清单保存和迁移差异,经验证后再切换用途,不直接覆盖现有目录。
## 本次执行结果
T0–T6 已完成,三份均采用上述 `92623a1` 基线。每份共 259 个文件、约 41.96 MiB,其中 252 个基线文件原样保留、4 个工具文件完成路径适配,另附 3 个初始化说明与清单文件。85 个 Unity GUID 及每种语言 134 个文本键检查通过。三份总计约 125.88 MiB。
初始化工具为 `Tools/Workspaces/InitializeTrees.py`,可核验同基线的已有目录并跳过复制,遇到未知内容时拒绝覆盖。逐文件 SHA-256 位于各 tree 的 `InitializationManifest.json`,总结果见 [Initialization.json](../QA/Workspaces/Initialization.json)。本轮未创建分支或 worktree,未运行导入、编译、播放或构建。
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
# tree1–tree3 资源初始化验收
时间:2026-11-12T13:38:14+08:00(Asia/Hong_Kong)。基线:`92623a1bc8678319819e3300eddf1ccff41a55e9`。
| 目录 | 文件数 | 大小 | Unity .meta | 结果 |
| --- | --- | --- | --- | --- |
| tree1 | 259 | 41.96 MiB | 85 | created / passed |
| tree2 | 259 | 41.96 MiB | 85 | created / passed |
| tree3 | 259 | 41.96 MiB | 85 | created / passed |
每份包含 256 个基线文件、4 份工具路径适配及 3 份工作区说明/清单文件。其中 252 个基线文件逐字节保留;适配文件与源、目标路径均按清单验证。
检查通过:逐文件 SHA-256、资产与 .meta 配对、GUID 唯一且与基线一致、中英文表键一致、源模型/字体/许可/包锁完整、无共享可写链接、无缓存或构建包、父仓库忽略规则生效。主工程 Assets、HEAD、分支、worktree 注册及 Git 暂存区均保持原状。
三个目录均为普通资源副本,没有独立 Git 历史。MCP 实例未连接;Unity 导入、编译、播放和构建未执行;并行运行前仍需隔离存档及 PlayerPrefs。
完整记录见 [Initialization.json](Initialization.json),目录内的 InitializationManifest.json 提供每个基线文件与适配项的哈希。执行计划见 [TreeInitializationPlan.md](../../Production/TreeInitializationPlan.md)。
+1
View File
@@ -31,6 +31,7 @@ Windows PC 键鼠原型,工程版本为 Unity 6000.6.8f1。机体支持越肩
## 工程与验证
- [AGENTS.md](AGENTS.md) 保存 Unity MCP、Blender MCP、资产存放、验证和 Git 提交的协作约定。
- [玩法设计](Docs/Design/GameDesign.md) 与 [开发任务](Docs/Production/ImplementationPlan.md) 保存约定和阶段验收。
- [文件规范](Docs/Production/ProjectConventions.md) 与 [素材台账](Docs/Production/AssetSources.md) 记录模型、代码和声音的来源与存放路径。
- Tools/Unity/McpClient.py 使用已安装的 Unity MCP 服务。命令文件位于 Tools/Unity/Commands,Unity 对象与场景由 MCP 操作。
+411
View File
@@ -0,0 +1,411 @@
"""Initialize three ordinary resource copies from a frozen Git commit.
Run from the primary repository. This script creates no branches or worktrees,
does not launch Unity/Blender, and never overwrites an existing resource copy.
"""
import argparse
import ast
from datetime import datetime, timedelta, timezone
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import shutil
import stat
import subprocess
import uuid
import zipfile
ROOT = Path(__file__).resolve().parents[2]
NAMES = ('tree1', 'tree2', 'tree3')
ADAPTED = (
'Tools/Unity/Commands/Build.json',
'Tools/Unity/Commands/LocalizationBuild.json',
'Tools/Unity/Commands/Restart.json',
'Tools/Blender/CreatePrototypeMech.py',
)
GENERATED = ('WORKSPACE.md', 'WorkspaceInfo.json', 'InitializationManifest.json')
FORBIDDEN = {'.git', 'library', 'temp', 'obj', 'logs', 'usersettings', 'builds', '.vs', '.idea', 'memorycaptures', 'recordings'}
HK = timezone(timedelta(hours=8), 'Asia/Hong_Kong')
def git(*args, check=True):
return subprocess.run(['git', '-C', str(ROOT), *args], capture_output=True, check=check)
def git_text(*args):
return git(*args).stdout.decode('utf-8').strip()
def sha(data):
return hashlib.sha256(data).hexdigest()
def file_sha(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def safe_child(base, path):
"""Resolve paths before any directory publication/move or file creation."""
base = base.resolve()
resolved = path.resolve()
if resolved == base or not resolved.is_relative_to(base):
raise RuntimeError(f'Path leaves the allowed directory: {path}')
return resolved
def is_link(path):
attributes = getattr(path.lstat(), 'st_file_attributes', 0)
return path.is_symlink() or bool(attributes & getattr(stat, 'FILE_ATTRIBUTE_REPARSE_POINT', 0x400))
def write_json(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def main_assets_state():
return {p.relative_to(ROOT).as_posix(): file_sha(p) for p in sorted((ROOT / 'Assets').rglob('*')) if p.is_file()}
def git_state():
return {
'head': git_text('rev-parse', 'HEAD'),
'branches': git_text('for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads'),
'worktrees': git_text('worktree', 'list', '--porcelain'),
'index': git_text('ls-files', '--stage'),
}
def baseline_files(commit, run):
modes = {}
for record in git('ls-tree', '-r', '-z', commit).stdout.split(b'\0'):
if not record:
continue
meta, raw_path = record.split(b'\t', 1)
mode, kind, _ = meta.decode('ascii').split()
name = raw_path.decode('utf-8')
path = PurePosixPath(name)
if path.is_absolute() or '..' in path.parts or '\\' in name or ':' in name:
raise RuntimeError(f'Invalid archive path: {name}')
if path.parts[0].lower() in FORBIDDEN or path.parts[0] in NAMES:
raise RuntimeError(f'Generated directory is tracked in baseline: {name}')
if mode not in ('100644', '100755') or kind != 'blob':
raise RuntimeError(f'Only ordinary files are supported: {mode} {name}')
modes[name] = mode
archive_path = run / 'baseline.zip'
with archive_path.open('xb') as stream:
subprocess.run(['git', '-C', str(ROOT), 'archive', '--format=zip', commit], stdout=stream, check=True)
with zipfile.ZipFile(archive_path) as archive:
files = {i.filename: archive.read(i) for i in archive.infolist() if not i.is_dir()}
if set(files) != set(modes):
raise RuntimeError('Archive does not match the frozen Git tree.')
if set(GENERATED) & set(files):
raise RuntimeError('Workspace metadata would overwrite baseline files.')
for name in ADAPTED:
if name not in files:
raise RuntimeError(f'Expected tool is missing: {name}')
return files
def source_root(files):
text = files[ADAPTED[3]].decode('utf-8')
match = re.search(r"^root_path = Path\('([^']+)'\)$", text, re.MULTILINE)
if not match:
raise RuntimeError('The Blender root constant is not recognized.')
return match.group(1)
def adapted_files(files, destination):
before_root = source_root(files)
after_root = destination.as_posix()
output = dict(files)
records = []
for name in ADAPTED:
if name.endswith('.json'):
commands = json.loads(files[name].decode('utf-8'))
if len(commands) != 1:
raise RuntimeError(f'Expected one command in {name}')
arguments = commands[0]['arguments']
if name.endswith('/Restart.json'):
previous = arguments['code']
expected = f'UnityEditor.EditorApplication.OpenProject("{before_root}")'
replacement = f'UnityEditor.EditorApplication.OpenProject("{after_root}")'
if previous.count(expected) != 1:
raise RuntimeError(f'Unexpected restart target in {name}')
arguments['code'] = previous.replace(expected, replacement)
clone = json.loads(json.dumps(commands))
clone[0]['arguments']['code'] = clone[0]['arguments']['code'].replace(replacement, expected)
else:
previous = arguments['output_path']
relative = PurePosixPath(previous).relative_to(PurePosixPath(before_root))
if relative.parts[0] != 'Builds' or '..' in relative.parts:
raise RuntimeError(f'Unexpected build output in {name}')
arguments['output_path'] = destination.joinpath(*relative.parts).as_posix()
safe_child(destination, Path(arguments['output_path']))
clone = json.loads(json.dumps(commands))
clone[0]['arguments']['output_path'] = previous
if clone != json.loads(files[name].decode('utf-8')):
raise RuntimeError(f'Unexpected semantic change to {name}')
output[name] = (json.dumps(commands, ensure_ascii=False, indent=2) + '\n').encode('utf-8')
else:
previous = files[name].decode('utf-8')
expected = f"root_path = Path('{before_root}')"
replacement = f"root_path = Path('{after_root}')"
if previous.count(expected) != 1:
raise RuntimeError('Unexpected Blender root constant.')
result = previous.replace(expected, replacement)
ast.parse(result, filename=name)
if result.replace(replacement, expected) != previous:
raise RuntimeError('Unexpected Blender source edit.')
output[name] = result.encode('utf-8')
if output[name] == files[name]:
raise RuntimeError(f'Expected a path adaptation: {name}')
records.append({'path': name, 'fromRoot': before_root, 'toRoot': after_root,
'sourceSha256': sha(files[name]), 'sha256': sha(output[name])})
return output, records
def workspace_info(name, destination, commit, initialized):
return {
'schemaVersion': 1, 'name': name, 'stage': 'resources-only', 'initializedAt': initialized,
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
'projectRoot': destination.as_posix(), 'unityVersion': '6000.6.8f1',
'git': {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False},
'mcp': {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False},
'runtimeDataIsolation': {'status': 'pending-before-play',
'reason': 'The copied companyName/productName and PlayerPrefs namespace still match the baseline.'},
'unityImport': 'not-run', 'unityCompilation': 'not-run', 'unityBuild': 'not-run',
}
def create_copy(staging, destination, name, files, commit, initialized):
staging.mkdir()
output, adaptations = adapted_files(files, destination)
for relative, data in output.items():
target = safe_child(staging, staging / relative)
target.parent.mkdir(parents=True, exist_ok=True)
with target.open('xb') as stream:
stream.write(data)
# Git cannot store empty folders; Unity folder metadata can describe one.
for relative, data in files.items():
if relative.startswith('Assets/') and relative.endswith('.meta') and re.search(rb'^folderAsset:\s*yes\s*$', data, re.MULTILINE):
safe_child(staging, staging / relative[:-5]).mkdir(parents=True, exist_ok=True)
write_json(staging / 'WorkspaceInfo.json', workspace_info(name, destination, commit, initialized))
(staging / 'WORKSPACE.md').write_text(
f'# {name} 资源目录\n\n'
f'基线:`{commit}`。Unity:`6000.6.8f1`。目标:`{destination.as_posix()}`。\n\n'
'本目录是普通资源副本,未建立独立 Git 仓库、分支或 worktree。'
'目录内部的 Git 命令可能识别到父仓库,不应作为独立版本记录使用。\n\n'
'资源、GUID、制作源文件与许可已复制;四份工具的输出或重开路径已指向本目录。'
'路径适配及逐文件校验见 InitializationManifest.json。历史文档中的路径仍记录原来源。\n\n'
'MCP 尚未连接,Unity 尚未导入、编译或构建。首次使用需要为本目录选择明确的 Unity 实例与完整路径。'
'默认存档和 PlayerPrefs 命名空间尚未隔离,完成隔离前不要并行运行游戏。\n\n'
'后续按主工程 Docs/Production/TreeInitializationPlan.md 迁移至正式开发工作区。\n',
encoding='utf-8')
generated = [{'path': relative, 'bytes': (staging / relative).stat().st_size,
'sha256': file_sha(staging / relative)} for relative in GENERATED[:2]]
manifest = {
'schemaVersion': 1, 'sourceCommit': commit, 'projectRoot': destination.as_posix(),
'sourceFileCount': len(files), 'sourceBytes': sum(map(len, files.values())),
'adaptations': adaptations,
'files': [{'path': path, 'bytes': len(data), 'sourceSha256': sha(files[path]),
'sha256': sha(data), 'adapted': path in ADAPTED} for path, data in sorted(output.items())],
'generatedFiles': generated,
'selfHash': 'Recorded in the primary repository initialization report, not inside this manifest.',
}
write_json(staging / 'InitializationManifest.json', manifest)
def validate_copy(directory, destination, name, files, commit):
output, adaptations = adapted_files(files, destination)
metadata = json.loads((directory / 'WorkspaceInfo.json').read_text(encoding='utf-8'))
if metadata['name'] != name or metadata['sourceCommit'] != commit or metadata['projectRoot'] != destination.as_posix():
raise RuntimeError(f'Existing workspace has a different identity: {directory}')
if metadata['stage'] != 'resources-only' or metadata['git'] != {'kind': 'ordinary-directory', 'branchCreated': False, 'worktreeRegistered': False}:
raise RuntimeError(f'Workspace has advanced beyond resource initialization: {directory}')
if metadata['mcp'] != {'expectedProjectRoot': destination.as_posix(), 'instanceId': None, 'connected': False}:
raise RuntimeError(f'Workspace connection state changed: {directory}')
manifest = json.loads((directory / 'InitializationManifest.json').read_text(encoding='utf-8'))
if manifest['sourceCommit'] != commit or manifest['projectRoot'] != destination.as_posix() or manifest['adaptations'] != adaptations:
raise RuntimeError(f'Manifest baseline or adaptations differ: {directory}')
if manifest['sourceFileCount'] != len(files) or manifest['sourceBytes'] != sum(map(len, files.values())):
raise RuntimeError(f'Manifest baseline count or size differs: {directory}')
recorded = {entry['path']: entry for entry in manifest['files']}
if len(recorded) != len(files) or set(recorded) != set(output):
raise RuntimeError(f'Manifest file set differs: {directory}')
found = []
for path in directory.rglob('*'):
safe_child(directory, path)
if is_link(path):
raise RuntimeError(f'Linked path is not allowed: {path}')
if path.is_file():
if path.stat().st_nlink != 1:
raise RuntimeError(f'Hard-linked file is not allowed: {path}')
found.append(path.relative_to(directory).as_posix())
if set(found) != set(files) | set(GENERATED):
raise RuntimeError(f'Unexpected or missing files in {directory}')
if any((directory / item).exists() for item in ('.git', 'Library', 'Temp', 'Obj', 'Logs', 'UserSettings', 'Builds', '.vs', '.idea')):
raise RuntimeError(f'Generated directory was copied: {directory}')
for relative, expected in output.items():
path = directory / relative
entry = recorded[relative]
if entry != {'path': relative, 'bytes': len(expected), 'sourceSha256': sha(files[relative]),
'sha256': sha(expected), 'adapted': relative in ADAPTED}:
raise RuntimeError(f'Manifest hash is not derived from the baseline: {relative}')
if path.stat().st_size != len(expected) or file_sha(path) != sha(expected):
raise RuntimeError(f'File does not match baseline/adaptation: {path}')
if relative.endswith('.json'):
json.loads(path.read_text(encoding='utf-8-sig'))
if relative.startswith('Tools/') and relative.endswith('.py'):
ast.parse(path.read_text(encoding='utf-8-sig'), filename=relative)
if {entry['path'] for entry in manifest['generatedFiles']} != set(GENERATED[:2]) or len(manifest['generatedFiles']) != 2:
raise RuntimeError('Generated metadata records differ.')
for entry in manifest['generatedFiles']:
path = directory / entry['path']
if path.stat().st_size != entry['bytes'] or file_sha(path) != entry['sha256']:
raise RuntimeError(f'Generated metadata was changed: {path}')
assets = directory / 'Assets'
meta_count = 0
guids = set()
for path in assets.rglob('*'):
if path.name.endswith('.meta'):
meta_count += 1
if not path.with_suffix('').exists():
raise RuntimeError(f'Orphan Unity metadata: {path}')
match = re.search(r'^guid: ([a-f0-9]{32})$', path.read_text(encoding='utf-8'), re.MULTILINE)
if not match or match.group(1) in guids:
raise RuntimeError(f'Missing or duplicate Unity GUID: {path}')
guids.add(match.group(1))
elif not Path(str(path) + '.meta').is_file():
raise RuntimeError(f'Unity asset has no metadata: {path}')
required = (
'Assets/Scenes/Bootstrap.unity', 'Assets/Scenes/MechTestRange.unity', 'Assets/Scenes/WarzonePrototype.unity',
'Assets/Game/UI/Fonts/GameFont.asset', 'Assets/Game/UI/Fonts/GameTextSettings.asset',
'Assets/Game/UI/GamePanelSettings.asset', 'Assets/Game/Localization/LocalizationCatalog.asset',
'Assets/Game/Localization/Tables/en.json', 'Assets/Game/Localization/Tables/zh-Hans.json',
'Assets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf', 'Assets/ThirdParty/Adobe/NotoSansSC/OFL.txt',
'SourceAssets/Blender/Mechs/Mech_Scout_A.blend', 'SourceAssets/ThirdParty/Adobe/NotoSansSC/NotoSansSC-VF.ttf',
'Docs/Production/Licenses/Adobe/NotoSansSC/OFL.txt', 'Packages/manifest.json', 'Packages/packages-lock.json',
)
if any(not (directory / path).is_file() for path in required):
raise RuntimeError('Required engine resource, source or license is missing.')
if 'm_EditorVersion: 6000.6.8f1' not in (directory / 'ProjectSettings/ProjectVersion.txt').read_text():
raise RuntimeError('Unexpected Unity version.')
tables = [json.loads((directory / f'Assets/Game/Localization/Tables/{code}.json').read_text(encoding='utf-8'))['entries'] for code in ('en', 'zh-Hans')]
keys = [{entry['key'] for entry in entries} for entries in tables]
if keys[0] != keys[1] or any(len(entries) != len(key_set) for entries, key_set in zip(tables, keys)):
raise RuntimeError('Localization table keys do not match.')
return {'name': name, 'path': destination.as_posix(), 'status': 'passed', 'sourceCommit': commit,
'sourceFiles': len(files), 'totalFiles': len(found), 'bytes': sum((directory / item).stat().st_size for item in found),
'adaptedFiles': len(ADAPTED), 'unchangedFiles': len(files) - len(ADAPTED),
'unityMetaFiles': meta_count, 'uniqueUnityGuids': len(guids), 'localizationKeysPerLocale': len(keys[0]),
'independentOrdinaryFiles': True, 'cacheAndGitExcluded': True, 'hashValidation': 'passed',
'manifestSha256': file_sha(directory / 'InitializationManifest.json')}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', default='HEAD', help='HEAD or a hexadecimal Git commit ID.')
args = parser.parse_args()
if not re.fullmatch(r'HEAD|[a-fA-F0-9]{7,40}', args.base):
raise RuntimeError('Use HEAD or a hexadecimal commit ID for --base.')
if Path(git_text('rev-parse', '--show-toplevel')).resolve() != ROOT:
raise RuntimeError('Run this tool from the primary repository, not a resource copy.')
commit = git_text('rev-parse', '--verify', args.base + '^{commit}')
state_before = git_state()
assets_before = main_assets_state()
targets = {}
for name in NAMES:
raw = ROOT / name
if raw.exists() or raw.is_symlink():
if is_link(raw) or not raw.is_dir():
raise RuntimeError(f'Target is not an ordinary directory: {raw}')
target = safe_child(ROOT, raw)
if target.parent != ROOT or target.name != name:
raise RuntimeError(f'Target must be a direct child of the primary repository: {target}')
if git('check-ignore', '-q', '--', name + '/Assets').returncode != 0:
raise RuntimeError(f'Add /{name}/ to the primary .gitignore before initialization.')
if git_text('ls-files', '--', name):
raise RuntimeError(f'Target already contains tracked files: {name}')
targets[name] = target
initialized = datetime.now(HK).isoformat(timespec='seconds')
run_id = datetime.now(HK).strftime('%Y%m%dT%H%M%S') + '-' + uuid.uuid4().hex[:8]
logs = safe_child(ROOT, ROOT / 'Logs/WorkspaceInitialization')
run = safe_child(logs, logs / run_id)
run.mkdir(parents=True)
files = baseline_files(commit, run)
required_bytes = sum(map(len, files.values())) * (sum(not p.exists() for p in targets.values()) + 1) + 64 * 1024 * 1024
free_bytes = shutil.disk_usage(ROOT).free
if free_bytes < required_bytes:
raise RuntimeError(f'Not enough free disk space: {free_bytes} < {required_bytes}')
prepared = {}
outcomes = {}
# All three copies are validated before publishing any new directory.
for name, destination in targets.items():
if destination.exists():
outcomes[name] = 'verified-existing'
validate_copy(destination, destination, name, files, commit)
prepared[name] = destination
else:
staging = safe_child(run, run / name)
create_copy(staging, destination, name, files, commit, initialized)
validate_copy(staging, destination, name, files, commit)
prepared[name] = staging
outcomes[name] = 'created'
results = []
for name, destination in targets.items():
source = prepared[name]
if source != destination:
# Recheck resolved absolute source and destination immediately before moving.
if safe_child(run, source) != source or safe_child(ROOT, destination) != destination:
raise RuntimeError('Publication paths changed.')
if destination.exists() or destination.is_symlink():
raise RuntimeError(f'Target appeared during preparation; refusing to overwrite: {destination}')
source.rename(destination)
result = validate_copy(destination, destination, name, files, commit)
result['operation'] = outcomes[name]
if git('check-ignore', '-q', '--', name + '/WorkspaceInfo.json').returncode != 0:
raise RuntimeError('Published copy is not ignored by the primary repository.')
results.append(result)
if git_state() != state_before:
raise RuntimeError('Primary HEAD, branches, worktrees or index changed during initialization.')
if main_assets_state() != assets_before:
raise RuntimeError('Primary Assets changed during initialization.')
report = {
'schemaVersion': 1, 'initializedAt': initialized, 'runId': run_id,
'sourceRepository': ROOT.as_posix(), 'sourceCommit': commit,
'baselineFileCount': len(files), 'baselineBytes': sum(map(len, files.values())),
'freeDiskBytesAtStart': free_bytes, 'trees': results,
'primaryAssetsUnchanged': True, 'gitHeadUnchanged': True, 'gitBranchesUnchanged': True,
'gitWorktreeRegistrationsUnchanged': True, 'gitIndexUnchanged': True,
'primaryGitState': state_before,
'scope': 'Static resource initialization only; Unity/Blender/MCP were not started.',
'pending': ['Unity package resolution/import/compilation', 'MCP explicit instance binding',
'Separate test saves and PlayerPrefs before parallel play', 'Branches/worktrees only when requested'],
}
write_json(run / 'Result.json', report)
qa = safe_child(ROOT, ROOT / 'Docs/QA/Workspaces')
qa.mkdir(parents=True, exist_ok=True)
write_json(qa / 'Initialization.json', report)
rows = '\n'.join(f"| {r['name']} | {r['totalFiles']} | {r['bytes'] / 1048576:.2f} MiB | {r['unityMetaFiles']} | {r['operation']} / passed |" for r in results)
(qa / 'Initialization.md').write_text(
'# tree1–tree3 资源初始化验收\n\n'
f'时间:{initialized}(Asia/Hong_Kong)。基线:`{commit}`。\n\n'
'| 目录 | 文件数 | 大小 | Unity .meta | 结果 |\n| --- | --- | --- | --- | --- |\n' + rows + '\n\n'
f'每份包含 {len(files)} 个基线文件、4 份工具路径适配及 3 份工作区说明/清单文件。'
f'其中 {len(files) - 4} 个基线文件逐字节保留;适配文件与源、目标路径均按清单验证。\n\n'
'检查通过:逐文件 SHA-256、资产与 .meta 配对、GUID 唯一且与基线一致、'
'中英文表键一致、源模型/字体/许可/包锁完整、无共享可写链接、无缓存或构建包、'
'父仓库忽略规则生效。主工程 Assets、HEAD、分支、worktree 注册及 Git 暂存区均保持原状。\n\n'
'三个目录均为普通资源副本,没有独立 Git 历史。MCP 实例未连接;'
'Unity 导入、编译、播放和构建未执行;并行运行前仍需隔离存档及 PlayerPrefs。\n\n'
'完整记录见 [Initialization.json](Initialization.json),目录内的 InitializationManifest.json '
'提供每个基线文件与适配项的哈希。执行计划见 [TreeInitializationPlan.md](../../Production/TreeInitializationPlan.md)。\n',
encoding='utf-8')
print(json.dumps({key: report[key] for key in ('runId', 'sourceCommit', 'baselineFileCount', 'trees', 'primaryAssetsUnchanged', 'gitBranchesUnchanged', 'gitWorktreeRegistrationsUnchanged')}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()