Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1311cf3c1 | ||
|
|
20c6682202 | ||
|
|
cc8b6ba729 |
@@ -204,7 +204,12 @@ GODOT_BIN=... bash build/accept-ws.sh <zip> --skip-slow
|
|||||||
* `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧
|
* `build/accept-ws.sh`:编排两者,并核对**客户端看不到的那一半**(服务端侧
|
||||||
`frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。
|
`frames_dropped` / `malformed_frames` / 控制帧计数 / 去重事件 / 关闭原因)。
|
||||||
|
|
||||||
实测(已发布的 v0.2.3 资产):客户端 **30 通过 / 0 失败** + 服务端侧 16 项事实全过,
|
**契约边界场景**(配合 Web v0.7.5):① **不可用模型**(真实 PMX 经 Assimp 转 GLB 后入库)
|
||||||
|
必须 `usable=false`、**不进入** `catalog/export`、不影响 `defaultModelId`,且包内客户端
|
||||||
|
`bundle.models` 里没有任何非 VRM 模型;② 场景 `config` **只给最少字段**时,服务端补齐的默认值
|
||||||
|
与客户端解析出的默认值**逐项一致**(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`…)。
|
||||||
|
|
||||||
|
实测(已发布的 v0.2.5 资产):客户端 **40 通过 / 0 失败** + 服务端侧 16 项事实全过,
|
||||||
其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接
|
其中最关键的是**保活对照**——只发应用层 `ping` 持续 19 秒(> 静默超时 15 秒)连接
|
||||||
**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。
|
**仍保持**,而完全不发数据的连接在 **15011 ms**(阈值 15000 ms)被 `close(1001, "timeout")`。
|
||||||
反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明
|
反向验证用一个**故意不合规的服务端**(`build/integration/probe-ws-bad.gd`:welcome 不声明
|
||||||
|
|||||||
@@ -56,6 +56,22 @@ log "期望版本:$EXPECTED"
|
|||||||
log "工作目录:$WORK_DIR"
|
log "工作目录:$WORK_DIR"
|
||||||
mkdir -p "$WORK_DIR"
|
mkdir -p "$WORK_DIR"
|
||||||
|
|
||||||
|
# ---- 0. 逐文件核对:发布包 == 打标签的树(T-WIN-025)----
|
||||||
|
# 「发布包里的东西必须等于打标签的那棵树」此前只靠抽查几个文件,必然会漏。
|
||||||
|
# 本步骤对包内**每一个**文件核对:源码/文档比 tag,构建产物(exe/pck)比工作树导出结果。
|
||||||
|
CHECKER="$PROJECT_DIR/build/check-tracked-release.py"
|
||||||
|
if [ -f "$CHECKER" ] && command -v git >/dev/null 2>&1 && [ -f "$TARGET" ]; then
|
||||||
|
TAG="v$(basename "$TARGET" .zip | sed 's/^opendance-windows-v//')"
|
||||||
|
if git -C "$PROJECT_DIR" rev-parse --verify "${TAG}^{commit}" >/dev/null 2>&1; then
|
||||||
|
log "逐文件核对:发布包 == tag $TAG"
|
||||||
|
python3 "$CHECKER" "$TARGET" --tag "$TAG" || fail "发布包与 tag $TAG 不一致"
|
||||||
|
else
|
||||||
|
log "跳过逐文件核对(仓库里没有 tag $TAG;打标签后可手工跑 $CHECKER)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "跳过逐文件核对(缺 checker / git / 目标是已解压目录)"
|
||||||
|
fi
|
||||||
|
|
||||||
# ---- 1. sha256 + 解压 ----
|
# ---- 1. sha256 + 解压 ----
|
||||||
if [ -f "$TARGET" ]; then
|
if [ -f "$TARGET" ]; then
|
||||||
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
|
# SHA256SUMS 里是**裸文件名**,所以校验时要把 cwd 切到包所在目录;
|
||||||
|
|||||||
Executable
+182
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""发布产物 × 打标签的树:**逐文件**核对(T-WEB-018)
|
||||||
|
|
||||||
|
## 为什么需要
|
||||||
|
|
||||||
|
「发布包里的东西必须等于打标签的那棵树」这条规则此前是靠人手工抽查几个文件
|
||||||
|
(README、contracts/VERSION…)来确认的 —— 而抽查必然会漏。本轮实测就发现
|
||||||
|
两个认知错误:
|
||||||
|
|
||||||
|
* Web 的发布包**包含** `web-server/test/**`(33 个文件)而我此前以为不含,
|
||||||
|
于是 `test/**` 的改动是否影响产物从来没被检查过;
|
||||||
|
* 发布包里另有构建产物(`OpenDance.exe` / `OpenDance.pck`)与 git 无关,
|
||||||
|
必须与工作树的导出结果比 —— 只比源码文件是不够的。
|
||||||
|
|
||||||
|
本脚本把这条规则变成**可重复的全量核对**:
|
||||||
|
|
||||||
|
对发布包里的**每一个**文件,按路径决定比较基准:
|
||||||
|
|
||||||
|
| 路径 | 比较基准 |
|
||||||
|
|--|--|
|
||||||
|
| 构建产物(见 `BUILD_OUTPUTS`) | 工作树里对应的构建输出 |
|
||||||
|
| 其余文件 | `git show <tag>:<路径>`(打标签的树) |
|
||||||
|
|
||||||
|
任何一处不一致 / 包内多余(既不在 git 也不在构建输出)/ 缺失都会列出并以非 0 退出。
|
||||||
|
|
||||||
|
## 用法(在仓库根目录执行)
|
||||||
|
|
||||||
|
python3 <script> <发布包> --tag v0.7.5 [--json]
|
||||||
|
|
||||||
|
Windows 的包是 `.zip`(LZMA 压缩,Python 的 zipfile 能直接读)。
|
||||||
|
包内通常有**一层顶层目录**(`opendance-windows-v0.2.5/`),脚本会自动剥掉。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 仓库相关配置
|
||||||
|
# 各仓库按自己的产物结构改这一段即可;脚本其余部分与仓库无关。
|
||||||
|
|
||||||
|
# 包内路径前缀 → 工作树路径(构建产物:不在 git 里,与工作树的构建结果比)
|
||||||
|
BUILD_OUTPUTS: dict[str, str] = {
|
||||||
|
'OpenDance.exe': 'build/windows/OpenDance.exe', # Godot 导出产物
|
||||||
|
'OpenDance.pck': 'build/windows/OpenDance.pck',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_archive(path: str) -> dict[str, bytes]:
|
||||||
|
"""读出包内全部文件(剥掉顶层目录)。"""
|
||||||
|
files: dict[str, bytes] = {}
|
||||||
|
if path.endswith('.tar.gz') or path.endswith('.tgz'):
|
||||||
|
with tarfile.open(path, 'r:gz') as tar:
|
||||||
|
for member in tar.getmembers():
|
||||||
|
if not member.isfile():
|
||||||
|
continue
|
||||||
|
handle = tar.extractfile(member)
|
||||||
|
if handle is not None:
|
||||||
|
files[_strip_top(member.name)] = handle.read()
|
||||||
|
elif path.endswith('.zip'):
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
files[_strip_top(info.filename)] = zf.read(info)
|
||||||
|
else:
|
||||||
|
raise SystemExit(f'✗ 不认识的包格式:{path}(支持 .tar.gz / .zip)')
|
||||||
|
return {k: v for k, v in files.items() if not k.startswith('/')}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_top(name: str) -> str:
|
||||||
|
parts = name.split('/', 1)
|
||||||
|
return parts[1] if len(parts) == 2 else name
|
||||||
|
|
||||||
|
|
||||||
|
def md5(data: bytes) -> str:
|
||||||
|
return hashlib.md5(data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def git_blob(rel_path: str, tag: str) -> bytes | None:
|
||||||
|
result = subprocess.run(
|
||||||
|
['git', 'show', f'{tag}:{rel_path}'],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
return result.stdout if result.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('archive')
|
||||||
|
parser.add_argument('--tag', required=True, help='要对比的 Git tag,例如 v0.7.5')
|
||||||
|
parser.add_argument('--json', action='store_true', help='输出机器可读结果')
|
||||||
|
args = parser.parse_args(argv[1:])
|
||||||
|
|
||||||
|
if not os.path.exists(args.archive):
|
||||||
|
print(f'✗ 找不到发布包:{args.archive}', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if subprocess.run(['git', 'rev-parse', '--verify', f'{args.tag}^{{commit}}'],
|
||||||
|
capture_output=True).returncode != 0:
|
||||||
|
print(f'✗ 仓库里没有 tag {args.tag}', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
files = read_archive(args.archive)
|
||||||
|
mismatch: list[str] = []
|
||||||
|
extra: list[str] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
tracked = built = 0
|
||||||
|
|
||||||
|
for rel, data in sorted(files.items()):
|
||||||
|
local_base: str | None = None
|
||||||
|
for prefix, target in BUILD_OUTPUTS.items():
|
||||||
|
# 目录前缀(以 / 结尾):把剩余路径拼到目标目录下;
|
||||||
|
# 精确文件名:目标就是该文件的路径(Windows/Android 的产物是单文件)
|
||||||
|
if prefix.endswith('/'):
|
||||||
|
if rel.startswith(prefix):
|
||||||
|
local_base = os.path.join(target, rel[len(prefix):])
|
||||||
|
break
|
||||||
|
elif rel == prefix:
|
||||||
|
local_base = target
|
||||||
|
break
|
||||||
|
if local_base is not None:
|
||||||
|
built += 1
|
||||||
|
if not os.path.exists(local_base):
|
||||||
|
missing.append(f'{rel}(工作树缺 {local_base})')
|
||||||
|
elif md5(data) != md5(open(local_base, 'rb').read()):
|
||||||
|
mismatch.append(f'{rel}(与工作树构建 {local_base} 不一致)')
|
||||||
|
continue
|
||||||
|
|
||||||
|
tracked += 1
|
||||||
|
blob = git_blob(rel, args.tag)
|
||||||
|
if blob is None:
|
||||||
|
extra.append(rel)
|
||||||
|
elif md5(data) != md5(blob):
|
||||||
|
mismatch.append(f'{rel}(与 {args.tag} 的内容不一致)')
|
||||||
|
|
||||||
|
report = {
|
||||||
|
'archive': args.archive,
|
||||||
|
'tag': args.tag,
|
||||||
|
'files': len(files),
|
||||||
|
'trackedFiles': tracked,
|
||||||
|
'buildOutputs': built,
|
||||||
|
'mismatch': mismatch,
|
||||||
|
'extra': extra,
|
||||||
|
'missing': missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
else:
|
||||||
|
print(f' 发布包 {args.archive}')
|
||||||
|
print(f' 对比 tag:{args.tag}')
|
||||||
|
print(f' 包内 {len(files)} 个文件:源码/文档 {tracked}(与 tag 比)、构建产物 {built}(与工作树构建比)')
|
||||||
|
if mismatch:
|
||||||
|
print(f' ✗ 内容不一致 {len(mismatch)} 处:')
|
||||||
|
for item in mismatch[:20]:
|
||||||
|
print(f' · {item}')
|
||||||
|
if extra:
|
||||||
|
print(f' ✗ 包内有、{args.tag} 里没有 {len(extra)} 处(既不是声明过的构建产物):')
|
||||||
|
for item in extra[:20]:
|
||||||
|
print(f' · {item}')
|
||||||
|
if missing:
|
||||||
|
print(f' ✗ 构建产物在工作树里找不到 {len(missing)} 处:')
|
||||||
|
for item in missing[:20]:
|
||||||
|
print(f' · {item}')
|
||||||
|
|
||||||
|
if mismatch or extra or missing:
|
||||||
|
if not args.json:
|
||||||
|
print(' ✗ 发布包与打标签的树不一致')
|
||||||
|
return 1
|
||||||
|
if not args.json:
|
||||||
|
print(' ✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@@ -245,6 +245,45 @@ func _run() -> void:
|
|||||||
else:
|
else:
|
||||||
_fail("动作缩略图下载失败:%s" % thumb["error"])
|
_fail("动作缩略图下载失败:%s" % thumb["error"])
|
||||||
|
|
||||||
|
# ---------- 5b. ② 目录边界:不可用模型不得出现在客户端目录里(①)----------
|
||||||
|
var nonVrm: Array = []
|
||||||
|
for m in models:
|
||||||
|
if str(m.format) != "vrm":
|
||||||
|
nonVrm.append("%s(%s)" % [m.name, m.format])
|
||||||
|
_check(nonVrm.is_empty(),
|
||||||
|
"目录里的模型全是 VRM(不可用模型不进目录):%s" % ("OK" if nonVrm.is_empty() else str(nonVrm)))
|
||||||
|
if _expect.has("unusableModelId") and _expect["unusableModelId"] != null:
|
||||||
|
var unusable_id := int(_expect["unusableModelId"])
|
||||||
|
_check(bundle.find_model(unusable_id) == null,
|
||||||
|
"不可用模型 #%d 不在客户端目录里" % unusable_id)
|
||||||
|
_check(bundle.default_model_id == _expect["modelId"],
|
||||||
|
"defaultModelId 仍指向可用的 VRM 模型(实际 %d)" % bundle.default_model_id)
|
||||||
|
|
||||||
|
# ---------- 5c. ④ 场景 config 缺字段时的默认值(契约 0.5.0 边界)----------
|
||||||
|
if _expect.has("minimalSceneId") and _expect.has("minimalSceneExpect"):
|
||||||
|
var minimal: CatalogTypes.Scene = bundle.find_scene(int(_expect["minimalSceneId"]))
|
||||||
|
if minimal == null:
|
||||||
|
_fail("目录里找不到最简配置场景 #%d" % int(_expect["minimalSceneId"]))
|
||||||
|
else:
|
||||||
|
var want_min: Dictionary = _expect["minimalSceneExpect"]
|
||||||
|
var cfg2: CatalogTypes.SceneConfig = minimal.config
|
||||||
|
_check(_near(cfg2.physics.gravity.y, float(want_min["gravityY"])),
|
||||||
|
"最简配置:重力保留 %.1f" % cfg2.physics.gravity.y)
|
||||||
|
_check(cfg2.physics.colliders == str(want_min["colliders"]),
|
||||||
|
"最简配置:碰撞体补默认 %s" % cfg2.physics.colliders)
|
||||||
|
_check(_near(cfg2.physics.ground.friction, float(want_min["groundFriction"]))
|
||||||
|
and _near(cfg2.physics.ground.slope_limit, float(want_min["groundSlopeLimit"])),
|
||||||
|
"最简配置:地面默认 摩擦 %.2f / 坡度 %.0f°" % [
|
||||||
|
cfg2.physics.ground.friction, cfg2.physics.ground.slope_limit])
|
||||||
|
_check(cfg2.physics.wind.is_calm() == bool(want_min["windCalm"]),
|
||||||
|
"最简配置:风默认静风(is_calm=%s)" % str(cfg2.physics.wind.is_calm()))
|
||||||
|
_check(cfg2.camera.mode == str(want_min["cameraMode"]),
|
||||||
|
"最简配置:相机默认 %s" % cfg2.camera.mode)
|
||||||
|
_check(_near(cfg2.environment.ambient_intensity, float(want_min["ambientIntensity"])),
|
||||||
|
"最简配置:环境光默认 %.1f" % cfg2.environment.ambient_intensity)
|
||||||
|
_check(cfg2.spawn_points.size() <= 1,
|
||||||
|
"最简配置:出生点默认最多一条(实际 %d)" % cfg2.spawn_points.size())
|
||||||
|
|
||||||
# ---------- 6. ① 模型资源可下载 ----------
|
# ---------- 6. ① 模型资源可下载 ----------
|
||||||
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
|
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
|
||||||
if model == null:
|
if model == null:
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
|
* 真实 VMD 动作 → 预处理成内部标准 33 点;真实 PMX 模型 → 转 VRM;
|
||||||
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
|
* 场景 GLB(复用真实转换产物的几何)+ 契约 0.5.0 的物理参数。
|
||||||
*/
|
*/
|
||||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
|
const API = process.env.API_BASE ?? 'http://127.0.0.1:3100';
|
||||||
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
|
// 样例目录与期望值输出路径都可通过环境变量覆盖(默认取仓库根的 示例文件/)
|
||||||
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
|
const SAMPLES = process.env.OPENDANCE_SAMPLES_DIR
|
||||||
?? new URL('../../../示例文件', import.meta.url).pathname;
|
?? new URL('../../../示例文件', import.meta.url).pathname;
|
||||||
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
|
const EXPECT_PATH = process.env.EXPECT_PATH ?? '/tmp/opendance-catalog-expect.json';
|
||||||
|
/** 用于把真实 PMX 转成 GLB(模拟"转换只产出 GLB"的入库形态) */
|
||||||
|
const ASSIMP_BIN = process.env.ASSIMP_BIN ?? '/usr/bin/assimp';
|
||||||
const SOURCE = {
|
const SOURCE = {
|
||||||
sourceType: 'original',
|
sourceType: 'original',
|
||||||
sourceName: '跨端集成验收素材',
|
sourceName: '跨端集成验收素材',
|
||||||
@@ -102,8 +105,67 @@ const run = async () => {
|
|||||||
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
|
must(scene.status === 201, '上传场景 GLB', `${scene.status} ${JSON.stringify(scene.body)}`);
|
||||||
const sceneId = scene.body.id;
|
const sceneId = scene.body.id;
|
||||||
|
|
||||||
|
// ---------- 边界 A:不可用模型(只有 GLB、没有 VRM 容器)----------
|
||||||
|
// 形态等价于"管线只产出了 GLB":契约 model-formats §1.1 要求这类模型
|
||||||
|
// **可入库、可审核,但 usable=false,且不进入 catalog/export**(① 的硬性要求)。
|
||||||
|
let unusable = null;
|
||||||
|
try {
|
||||||
|
const pmxZip = `${SAMPLES}/模型_初音未来Hatsune Miku_by_Boku_Wa_Luka.zip`;
|
||||||
|
const entries = execFileSync('unzip', ['-Z1', pmxZip], { encoding: 'utf8' })
|
||||||
|
.split('\n').map((line) => line.trim())
|
||||||
|
.filter((line) => line.toLowerCase().endsWith('.pmx'));
|
||||||
|
if (entries.length === 0) throw new Error('压缩包里没有 .pmx');
|
||||||
|
const pmxPath = join('/tmp', 'opendance-seed-model.pmx');
|
||||||
|
const glbPath = join('/tmp', 'opendance-seed-model.glb');
|
||||||
|
execFileSync('unzip', ['-p', pmxZip, entries[0]], { stdio: ['ignore', openSync(pmxPath, 'w'), 'ignore'] });
|
||||||
|
execFileSync(ASSIMP_BIN, ['export', pmxPath, glbPath], { stdio: 'ignore' });
|
||||||
|
const glb = readFileSync(glbPath);
|
||||||
|
must(glb.subarray(0, 4).toString('ascii') === 'glTF', `Assimp 转出 GLB(${glb.length} 字节)`);
|
||||||
|
const created = await request('POST', '/api/models', {
|
||||||
|
form: fileForm({ name: '不可用模型(仅 GLB,未产出 VRM)', ...SOURCE }, 'unusable.glb', glb, 'model/gltf-binary'),
|
||||||
|
});
|
||||||
|
must(created.status === 201, '上传仅 GLB 的模型', `${created.status} ${JSON.stringify(created.body)}`);
|
||||||
|
const id = created.body.id;
|
||||||
|
must(created.body.format === 'glb', `入库格式为 glb(实际 ${created.body.format})`);
|
||||||
|
must(created.body.usable === false, '该模型标记为不可用(usable=false)',
|
||||||
|
JSON.stringify(created.body.unusableReason));
|
||||||
|
const approve = await request('POST', `/api/reviews/model/${id}/approve`, {
|
||||||
|
json: { reviewer: 'qa', comment: '通过但不设为默认' },
|
||||||
|
});
|
||||||
|
must(approve.status === 200 || approve.status === 201, '审核通过(不设为默认模型)', String(approve.status));
|
||||||
|
const detail = await request('GET', `/api/models/${id}`);
|
||||||
|
const reason = String(detail.body?.unusableReason ?? '');
|
||||||
|
const drivable = detail.body?.stats?.drivable === true;
|
||||||
|
must(reason.includes('未产出 VRM'), `不可用原因说明"未产出 VRM":${reason}`);
|
||||||
|
// 归因准确性(T-WEB-017):骨架已达标时**不得**说"humanoid 不达标"
|
||||||
|
if (drivable) {
|
||||||
|
must(!reason.includes('humanoid 不达标'),
|
||||||
|
`骨架达标(drivable=true,humanoid ${detail.body?.stats?.humanoidMapped}/${detail.body?.stats?.humanoidTotal})`
|
||||||
|
+ `时不得归因于 humanoid:${reason}`);
|
||||||
|
} else {
|
||||||
|
console.log(` · 该 GLB 的 drivable=false(humanoid ${detail.body?.stats?.humanoidMapped}/`
|
||||||
|
+ `${detail.body?.stats?.humanoidTotal}),原因归因于 humanoid 属正确`);
|
||||||
|
}
|
||||||
|
unusable = { id, reason, drivable };
|
||||||
|
rmSync(pmxPath, { force: true });
|
||||||
|
rmSync(glbPath, { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` · 跳过不可用模型场景(${error.message})—— 需要 unzip 与 ASSIMP_BIN`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 边界 B:场景 config 只给最少字段,服务端必须补齐默认 ----------
|
||||||
|
const minimalConfig = { physics: { gravity: { y: -9.8 } } };
|
||||||
|
const minimalScene = await request('POST', '/api/scenes', {
|
||||||
|
form: fileForm(
|
||||||
|
{ name: '最简配置场景(只有 gravity)', config: JSON.stringify(minimalConfig), ...SOURCE },
|
||||||
|
'minimal.glb', glbBytes, 'model/gltf-binary',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
must(minimalScene.status === 201, '上传最简配置场景', `${minimalScene.status} ${JSON.stringify(minimalScene.body)}`);
|
||||||
|
const minimalSceneId = minimalScene.body.id;
|
||||||
|
|
||||||
// ---------- 审核发布 ----------
|
// ---------- 审核发布 ----------
|
||||||
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId]]) {
|
for (const [kind, id] of [['song', songId], ['motion', motionId], ['model', modelId], ['scene', sceneId], ['scene', minimalSceneId]]) {
|
||||||
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
|
// 契约:**上传即 pending**(draft 才需要显式 submit),所以 409「已处于 pending」是正常的
|
||||||
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } });
|
const submit = await request('POST', `/api/reviews/${kind}/${id}/submit`, { json: { reviewer: 'qa' } });
|
||||||
must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
|
must([200, 201, 409].includes(submit.status), `提交审核 ${kind}#${id}`, String(submit.status));
|
||||||
@@ -122,6 +184,31 @@ const run = async () => {
|
|||||||
const data = catalog.body;
|
const data = catalog.body;
|
||||||
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
|
console.log(` 目录:songs=${data.songs?.length} motions=${data.motions?.length} models=${data.models?.length} scenes=${data.scenes?.length}`);
|
||||||
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
|
must(data.scenes?.length >= 1, '目录含场景(④ 的 scenes[])');
|
||||||
|
// 不可用模型不得进入客户端目录(①),且默认模型不受影响
|
||||||
|
const modelsInCatalog = (catalog.body?.models ?? []);
|
||||||
|
const nonVrm = modelsInCatalog.filter((m) => m.format !== 'vrm');
|
||||||
|
must(nonVrm.length === 0, '目录里的模型全是 VRM(不可用模型不进目录)', JSON.stringify(nonVrm));
|
||||||
|
if (unusable) {
|
||||||
|
must(!modelsInCatalog.some((m) => m.id === unusable.id), `不可用模型 #${unusable.id} 不在目录里`);
|
||||||
|
must(catalog.body?.defaultModelId === modelId,
|
||||||
|
`defaultModelId 仍是可用的 VRM 模型(实际 ${catalog.body?.defaultModelId},期望 ${modelId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最简配置场景:服务端必须补齐默认值(契约 0.5.0 的字段边界)
|
||||||
|
const minimalEntry = (catalog.body?.scenes ?? []).find((s) => s.id === minimalSceneId);
|
||||||
|
must(minimalEntry !== undefined, `最简配置场景 #${minimalSceneId} 在目录里`);
|
||||||
|
const mc = minimalEntry?.config ?? {};
|
||||||
|
must(mc.physics?.gravity?.y === -9.8, `重力保留(${JSON.stringify(mc.physics?.gravity)})`);
|
||||||
|
must(mc.physics?.colliders === 'auto', `碰撞体补默认 auto(实际 ${mc.physics?.colliders})`);
|
||||||
|
must(mc.physics?.ground?.friction === 0.6 && mc.physics?.ground?.slopeLimit === 45,
|
||||||
|
`地面补默认 0.6/45°(实际 ${JSON.stringify(mc.physics?.ground)})`);
|
||||||
|
must(mc.physics?.wind?.gustAmplitude === 0 && mc.physics?.wind?.base?.x === 0,
|
||||||
|
`风补默认(静风):${JSON.stringify(mc.physics?.wind)}`);
|
||||||
|
must(mc.camera?.mode === 'follow', `相机补默认 follow(实际 ${mc.camera?.mode})`);
|
||||||
|
must(mc.environment?.ambientIntensity === 1, `环境光补默认 1(实际 ${mc.environment?.ambientIntensity})`);
|
||||||
|
must(mc.spawnPoints === undefined || mc.spawnPoints.length <= 1,
|
||||||
|
`出生点补默认(最多一条):${JSON.stringify(mc.spawnPoints)}`);
|
||||||
|
|
||||||
const version = await request('GET', '/api/catalog/version');
|
const version = await request('GET', '/api/catalog/version');
|
||||||
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
|
console.log(` 目录指纹:${JSON.stringify(version.body)}`);
|
||||||
|
|
||||||
@@ -135,6 +222,18 @@ const run = async () => {
|
|||||||
keypointFormat: pre.body?.keypointFormat ?? null,
|
keypointFormat: pre.body?.keypointFormat ?? null,
|
||||||
catalogRevision: version.body?.revision ?? null,
|
catalogRevision: version.body?.revision ?? null,
|
||||||
contractVersions: health.body?.contractVersion ?? {},
|
contractVersions: health.body?.contractVersion ?? {},
|
||||||
|
unusableModelId: unusable?.id ?? null,
|
||||||
|
unusableDrivable: unusable?.drivable ?? null,
|
||||||
|
minimalSceneId,
|
||||||
|
minimalSceneExpect: {
|
||||||
|
gravityY: -9.8,
|
||||||
|
colliders: 'auto',
|
||||||
|
groundFriction: 0.6,
|
||||||
|
groundSlopeLimit: 45,
|
||||||
|
windCalm: true,
|
||||||
|
cameraMode: 'follow',
|
||||||
|
ambientIntensity: 1,
|
||||||
|
},
|
||||||
}, null, 2));
|
}, null, 2));
|
||||||
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
|
console.log(`\n期望值已写入 ${EXPECT_PATH}`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ echo ""
|
|||||||
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
echo "分发:上传 $NAME.zip 与 SHA256SUMS 两个资产;用户下载后"
|
||||||
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
echo " sha256sum -c SHA256SUMS && unzip $NAME.zip 即可直接运行 OpenDance.exe。"
|
||||||
echo ""
|
echo ""
|
||||||
echo "下一步(文档 §7.5 / §7.6:发布前必须做的三条产物级验收):"
|
echo "下一步(文档 §7.5 / §7.6 / §7.7:发布前必须做的验收,含逐文件核对):"
|
||||||
|
echo " python3 build/check-tracked-release.py $ZIP --tag v$VERSION # 包内每个文件 vs 打标签的树"
|
||||||
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
|
echo " GODOT_BIN=/path/to/godot bash build/accept-release.sh $ZIP # 产物自证"
|
||||||
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
|
echo " GODOT_BIN=/path/to/godot bash build/accept-ws.sh $ZIP # WebSocket 契约一致性"
|
||||||
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
|
echo " GODOT_BIN=/path/to/godot bash build/accept-frame-vectors.sh $ZIP # 关键点帧跨实现一致性"
|
||||||
|
|||||||
@@ -122,6 +122,10 @@ tasks:
|
|||||||
file: tasks/024-config-push-and-legacy-peer.md
|
file: tasks/024-config-push-and-legacy-peer.md
|
||||||
scope: config 改为变更驱动(原每 2 秒无条件广播)+ v0.1.0 风格对端/缺 deviceId/无 hello 的契约语义验收
|
scope: config 改为变更驱动(原每 2 秒无条件广播)+ v0.1.0 风格对端/缺 deviceId/无 hello 的契约语义验收
|
||||||
status: done
|
status: done
|
||||||
|
- id: T-WIN-025
|
||||||
|
file: tasks/025-tracked-release-check.md
|
||||||
|
scope: 逐文件核对「发布包 == 打标签的树」(源码比 tag、构建产物比工作树导出)
|
||||||
|
status: done
|
||||||
|
|
||||||
deliverable:
|
deliverable:
|
||||||
artifact: opendance-windows-v0.2.5.zip
|
artifact: opendance-windows-v0.2.5.zip
|
||||||
|
|||||||
@@ -52,6 +52,32 @@ Web 端的测试只验自己那一侧。**两端各自绿灯 ≠ 契约字段真
|
|||||||
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
|
| ③ 动作预览(资产链路) | 动作缩略图 **可下载**且为 PNG |
|
||||||
| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 |
|
| ① 模型交付 | 模型格式 `vrm`、`isPrimary`、模型文件可下载且为 `glTF` 容器、大小与 `fileSize` 一致 |
|
||||||
|
|
||||||
|
## 契约边界场景(续,配合 Web v0.7.5)
|
||||||
|
|
||||||
|
正常路径之外的**字段边界**同样只在跨端跑起来才会暴露。`seed-catalog.mjs` 与
|
||||||
|
`probe-integration.gd` 因此增加两类场景,**两端都断言**:
|
||||||
|
|
||||||
|
| 场景 | Web 侧(播种脚本断言) | Windows 侧(包内探针断言) |
|
||||||
|
|--|--|--|
|
||||||
|
| **不可用模型**:真实 PMX 用 Assimp 转 GLB 后入库(等价"管线只产出 GLB") | `usable=false`;原因准确(骨架达标时**不得**说"humanoid 不达标");**不在** `catalog/export`;`defaultModelId` 不变 | `bundle.models` 里没有任何非 VRM 模型;按 id 找不到该模型;`defaultModelId` 仍指向可用的 VRM |
|
||||||
|
| **场景 config 只给最少字段**(`{physics:{gravity:{y:-9.8}}}`) | 服务端补齐默认(碰撞体 `auto`、地面 `0.6/45°`、静风、相机 `follow`、环境光 `1`、出生点 1 条) | 客户端解析出的**同一组默认值**逐项一致 |
|
||||||
|
|
||||||
|
实测(Web v0.7.5 × Windows v0.2.5 包内 pck):
|
||||||
|
|
||||||
|
```
|
||||||
|
✓ 该模型标记为不可用(usable=false)
|
||||||
|
✓ 不可用原因说明"未产出 VRM":未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标),需重新以 VRM 交付才能进入客户端目录
|
||||||
|
✓ 骨架达标(drivable=true,humanoid 19/22)时不得归因于 humanoid
|
||||||
|
✓ 目录里的模型全是 VRM(不可用模型不进目录)/✓ 不可用模型 #2 不在客户端目录里
|
||||||
|
✓ defaultModelId 仍是可用的 VRM 模型(实际 1,期望 1)
|
||||||
|
✓ 最简配置:重力保留 -9.8 / 碰撞体补默认 auto / 地面默认 0.60 / 45°
|
||||||
|
✓ 最简配置:风默认静风(is_calm=true)/ 相机默认 follow / 环境光默认 1.0 / 出生点 1 条
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这两类场景的价值在**同一份契约的两侧解释**:Web 认为"不可用"的模型长什么样、
|
||||||
|
> 客户端是否真的看不到它;以及"缺字段"时两端的默认值是否逐项一致
|
||||||
|
> (服务端补齐 vs 客户端收敛,任一侧不一致都会表现为"配置看起来生效了但行为不对")。
|
||||||
|
|
||||||
## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL)
|
## 实测(v0.2.3 包内 pck × 真实 Web v0.7.4 + 真实 MySQL)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# T-WIN-025:逐文件核对「发布包 == 打标签的树」
|
||||||
|
|
||||||
|
- **状态**: done
|
||||||
|
- **依赖**: T-WIN-018(发布包级验收)
|
||||||
|
- **文档**: v1.6 §5.3 / §7.7
|
||||||
|
- **契约**: 不涉及
|
||||||
|
|
||||||
|
## 为什么需要
|
||||||
|
|
||||||
|
「发布包里的东西必须等于打标签的那棵树」此前只靠**抽查**几个文件
|
||||||
|
(README、`contracts/VERSION`…),而抽查必然会漏。上一轮实测暴露两个认知错误:
|
||||||
|
|
||||||
|
* Web 的发布包**包含** `web-server/test/**`(33 个文件),而我此前以为不含;
|
||||||
|
* 发布包里另有**构建产物**(本仓是 `OpenDance.exe` / `OpenDance.pck`)与 git 无关,
|
||||||
|
必须与工作树的导出结果比。
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
|
||||||
|
`build/check-tracked-release.py`(纯标准库,`.zip` 直接读,LZMA 也支持):
|
||||||
|
对包内**每一个**文件按路径决定比较基准
|
||||||
|
|
||||||
|
| 路径 | 比较基准 |
|
||||||
|
|--|--|
|
||||||
|
| `OpenDance.exe` / `OpenDance.pck`(`BUILD_OUTPUTS`) | 工作树 `build/windows/` 下的导出产物 |
|
||||||
|
| 其余全部 | `git show <tag>:<路径>` |
|
||||||
|
|
||||||
|
任一**内容不一致** / **包内多余**(既不在 git 也不是声明的构建产物)/
|
||||||
|
**构建产物在工作树缺失** 都会列出并以非 0 退出。已接入 `build/accept-release.sh`
|
||||||
|
(第 0 步;目标须是 `.zip` 且仓库里有对应 tag)与 `build/package-release.sh` 的提示。
|
||||||
|
|
||||||
|
## 实测(**已发布**的 v0.2.5 资产重新下载后跑)
|
||||||
|
|
||||||
|
```
|
||||||
|
[accept] 逐文件核对:发布包 == tag v0.2.5
|
||||||
|
包内 13 个文件:源码/文档 11(与 tag 比)、构建产物 2(与工作树构建比)
|
||||||
|
✓ 逐文件一致:0 处不一致 / 0 处多余 / 0 处缺失
|
||||||
|
```
|
||||||
|
|
||||||
|
**反向验证**:把包内 `README.txt` 追加一行后用 7z 重新打成 LZMA zip →
|
||||||
|
|
||||||
|
```
|
||||||
|
✗ 内容不一致 1 处:· README.txt(与 v0.2.5 的内容不一致)
|
||||||
|
✗ 发布包与打标签的树不一致 退出码=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 踩坑
|
||||||
|
|
||||||
|
脚本第一版把 `BUILD_OUTPUTS` 的键一律当**目录前缀**处理(`rel[len(prefix):]`),
|
||||||
|
而本仓与 Android 的产物是**单文件**(键不带 `/`),于是拼出
|
||||||
|
`build/windows/OpenDance.exe/` 这种路径,误报「构建产物在工作树缺失」。
|
||||||
|
现在区分两种键:以 `/` 结尾的是目录前缀,否则是精确文件名。
|
||||||
|
|
||||||
|
## 与「产物内开发残留检查」的区别
|
||||||
|
|
||||||
|
| 检查 | 回答的问题 |
|
||||||
|
|--|--|
|
||||||
|
| `list-pck.gd`(T-WIN-023) | 产物**内部**(pck 里)有没有混进开发/验收工具 |
|
||||||
|
| 本脚本(T-WIN-025) | 产物**对着仓库**看有没有多、少、被改过的文件 |
|
||||||
Reference in New Issue
Block a user