正常路径之外的**字段边界**同样只在跨端跑起来才暴露。配合 Web v0.7.5 增加:
1. 不可用模型:用 Assimp 把真实 PMX 转成 GLB 后入库(等价"管线只产出 GLB"),
Web 侧断言 usable=false、原因准确(骨架达标时不得说"humanoid 不达标")、
不在 catalog/export、defaultModelId 不变;Windows 侧(包内探针)断言
bundle.models 里没有任何非 VRM 模型、按 id 找不到该模型、defaultModelId 仍指向可用 VRM。
2. 场景 config 只给 {physics:{gravity:{y:-9.8}}}:Web 侧断言服务端补齐默认
(碰撞体 auto、地面 0.6/45°、静风、相机 follow、环境光 1、出生点 1 条);
Windows 侧断言客户端解析出的**同一组默认值**逐项一致。
实测(Web v0.7.5 × Windows v0.2.5 包内 pck,真实 MySQL):
✓ usable=false 且原因为「未产出 VRM(格式为 GLB;humanoid 覆盖 19/22 已达标)…」
✓ 目录里的模型全是 VRM/不可用模型 #2 不在目录里/defaultModelId 仍为 1
✓ 最简配置:重力 -9.8 保留,碰撞体 auto、地面 0.6/45°、静风、相机 follow、
环境光 1.0、出生点 1 条,两端逐项一致
跨端集成验收全部通过。
顺带修掉播种脚本自身的顺序错误(把依赖 catalog 的断言插到了 catalog 取回之前),
以及把断言改成「先取目录再断言」。不影响发布产物(build/ 已排除出 pck)。
315 lines
15 KiB
GDScript
315 lines
15 KiB
GDScript
extends SceneTree
|
||
|
||
## 跨端集成探针(T-WIN-019):**发布包里的 Windows 客户端** × **真实 Web 服务端**
|
||
##
|
||
## 用法(由 build/accept-integration.sh 调用,也可手工执行):
|
||
## godot --headless --main-pack <包内 OpenDance.pck> \
|
||
## --script /绝对路径/build/integration/probe-integration.gd \
|
||
## -- --api-base http://127.0.0.1:3100 --expect /绝对路径/catalog-expect.json
|
||
##
|
||
## 为什么需要它:本仓的单测用的是**假 HTTP 夹具**(`tests/test_api_integration.gd`
|
||
## 打的是本地起的小服务),Web 端的测试又只验自己那一侧。两端各自绿灯,
|
||
## 不代表**契约字段真的对得上**:字段名大小写、嵌套层级、null 与缺字段、
|
||
## 单位(米/度/秒)、归一化坐标范围……都可能在一侧对、另一侧错。
|
||
## 契约 0.5.0 新增的 `scenes[]` 与物理参数尤其如此。
|
||
##
|
||
## 本探针只加载**包内**代码(`--main-pack` + `res://`),打真实服务端:
|
||
## 1. `/health`:连通性 + 契约版本;
|
||
## 2. `/api/catalog/version`:目录指纹;
|
||
## 3. `/api/catalog/export` → `CatalogStore` 解析出的**类型化**目录:
|
||
## 曲目 / 动作 / 模型 / 场景 + 默认模型;
|
||
## 4. 场景物理参数逐项比对(重力 / 风 / 地面 / 碰撞体 / 出生点 / 相机 / 环境)
|
||
## —— ⑤「场景选择与物理参数落地」的跨端证据;
|
||
## 5. 动作关键点序列(NDJSON)→ `KeypointSequence.parse_ndjson`:
|
||
## 帧数、33 点、时间轴、服务端关节角 —— ②「动作内部标准」跨端闭环;
|
||
## 6. 模型 / 场景 / 缩略图资源**可下载**且魔数正确(① ③ 的消费侧)。
|
||
|
||
const CatalogTypes = preload("res://src/net/catalog_types.gd")
|
||
const KeypointSequence = preload("res://src/net/keypoint_sequence.gd")
|
||
const KeypointFrame = preload("res://src/net/keypoint_frame.gd")
|
||
|
||
var _api_base := ""
|
||
var _expect_path := ""
|
||
var _expect: Dictionary = {}
|
||
var _failed := 0
|
||
var _started := false
|
||
var _done := false
|
||
var _exit_code := 0
|
||
## 看门狗:协程里一旦出现运行时错误,`_finish()` 就不会被调用,进程会永远不退出
|
||
## (调试时表现为「无输出卡住」,很难看出问题)。超过时限就自己判失败退出。
|
||
const WATCHDOG_MS := 120_000
|
||
var _start_ms := 0
|
||
|
||
|
||
func _initialize() -> void:
|
||
var args := OS.get_cmdline_user_args()
|
||
var i := 0
|
||
while i < args.size():
|
||
if args[i] == "--api-base" and i + 1 < args.size():
|
||
_api_base = args[i + 1]
|
||
i += 2
|
||
elif args[i] == "--expect" and i + 1 < args.size():
|
||
_expect_path = args[i + 1]
|
||
i += 2
|
||
else:
|
||
i += 1
|
||
|
||
|
||
## 必须在 _process 里跑:HTTPRequest 需要跨帧等待(同 tests/run_tests.gd)
|
||
func _process(_delta: float) -> bool:
|
||
if not _started:
|
||
_started = true
|
||
_start_ms = Time.get_ticks_msec()
|
||
_run()
|
||
if not _done and Time.get_ticks_msec() - _start_ms > WATCHDOG_MS:
|
||
print(" ✗ 看门狗超时(%d ms):协程可能因运行时错误中断" % WATCHDOG_MS)
|
||
_exit_code = 1
|
||
_done = true
|
||
quit(_exit_code)
|
||
return _done
|
||
|
||
|
||
func _check(ok: bool, text: String) -> void:
|
||
print(" %s %s" % ["✓" if ok else "✗", text])
|
||
if not ok:
|
||
_failed += 1
|
||
|
||
|
||
func _near(a: float, b: float, tol := 0.0001) -> bool:
|
||
return absf(a - b) <= tol
|
||
|
||
|
||
func _load_expect() -> bool:
|
||
if _expect_path.is_empty() or not FileAccess.file_exists(_expect_path):
|
||
print(" ✗ 找不到期望值文件:%s" % _expect_path)
|
||
return false
|
||
var file := FileAccess.open(_expect_path, FileAccess.READ)
|
||
if file == null:
|
||
print(" ✗ 无法读取期望值文件:%s" % _expect_path)
|
||
return false
|
||
var parsed: Variant = JSON.parse_string(file.get_as_text())
|
||
file.close()
|
||
if typeof(parsed) != TYPE_DICTIONARY:
|
||
print(" ✗ 期望值文件不是 JSON 对象")
|
||
return false
|
||
_expect = parsed
|
||
return true
|
||
|
||
|
||
func _fail(text: String) -> void:
|
||
print(" ✗ %s" % text)
|
||
_failed += 1
|
||
|
||
|
||
func _run() -> void:
|
||
print("[integration] Windows 包内客户端 × 真实 Web 服务端:%s" % _api_base)
|
||
if _api_base.is_empty() or not _load_expect():
|
||
_finish()
|
||
return
|
||
|
||
# autoload 单例在 `--script` 模式下不能当全局标识符用(编译期找不到 AppConfig /
|
||
# CatalogStore 这两个名字),要从场景树里取节点,再用动态调用访问。
|
||
var app_config = root.get_node_or_null("AppConfig")
|
||
var catalog_store = root.get_node_or_null("CatalogStore")
|
||
if app_config == null or catalog_store == null:
|
||
_fail("场景树里没有 AppConfig / CatalogStore(autoload 未加载)")
|
||
_finish()
|
||
return
|
||
app_config.api_base_url = _api_base
|
||
var client = catalog_store.client()
|
||
client.set_base_url(_api_base)
|
||
|
||
# ---------- 1. /health ----------
|
||
var health: Dictionary = await client.health()
|
||
if not health["ok"]:
|
||
_fail("GET /health 失败:%s" % health["error"])
|
||
_finish()
|
||
return
|
||
var health_data: Dictionary = health["data"]
|
||
_check(health["status"] == 200, "GET /health 200(实际 %d)" % health["status"])
|
||
_check(health_data.get("version", "") != "", "服务端版本 = %s" % health_data.get("version", ""))
|
||
var contract: Dictionary = health_data.get("contractVersion", {})
|
||
# 期望值文件缺键时给可读错误,而不是在运行时抛 "Invalid access to property"
|
||
var want_contract: Dictionary = _expect.get("contractVersions", {})
|
||
if want_contract.is_empty():
|
||
_fail("期望值文件缺少 contractVersions(请重新播种,或用本仓 seed-catalog.mjs 生成)")
|
||
for key in want_contract.keys():
|
||
_check(contract.get(key, "") == want_contract[key],
|
||
"契约 %s = %s(服务端 %s)" % [key, want_contract[key], contract.get(key, "缺失")])
|
||
|
||
# ---------- 2/3. 目录 ----------
|
||
var refresh: Dictionary = await catalog_store.refresh()
|
||
if not refresh["ok"]:
|
||
_fail("GET /api/catalog/export 失败:%s" % refresh.get("error", ""))
|
||
_finish()
|
||
return
|
||
var bundle: CatalogTypes.CatalogBundle = catalog_store.bundle
|
||
_check(catalog_store.revision != "", "目录指纹非空:%s" % catalog_store.revision)
|
||
if not want_contract.is_empty():
|
||
_check(catalog_store.contract_version.get("rest-api", "") == want_contract.get("rest-api", ""),
|
||
"目录内嵌契约版本与 /health 一致")
|
||
|
||
var songs: Array = catalog_store.songs()
|
||
var motions: Array = catalog_store.motions()
|
||
var models: Array = catalog_store.models()
|
||
var scenes: Array = catalog_store.scenes()
|
||
_check(songs.size() >= 1 and motions.size() >= 1 and models.size() >= 1 and scenes.size() >= 1,
|
||
"目录四类资源齐备:songs=%d motions=%d models=%d scenes=%d" % [
|
||
songs.size(), motions.size(), models.size(), scenes.size()])
|
||
if not _expect.has("modelId") or not _expect.has("sceneId") or not _expect.has("motionId"):
|
||
_fail("期望值文件缺少 modelId / sceneId / motionId")
|
||
_finish()
|
||
return
|
||
_check(bundle.default_model_id == _expect["modelId"],
|
||
"defaultModelId 解析为 %d(期望 %d)" % [bundle.default_model_id, _expect["modelId"]])
|
||
|
||
# ---------- 4. ⑤ 场景选择 + 物理参数落地 ----------
|
||
var scene: CatalogTypes.Scene = bundle.find_scene(_expect["sceneId"])
|
||
if scene == null:
|
||
_fail("目录里找不到场景 #%d" % _expect["sceneId"])
|
||
else:
|
||
var cfg: CatalogTypes.SceneConfig = scene.config
|
||
var want: Dictionary = _expect["sceneConfig"]
|
||
var w_phys: Dictionary = want["physics"]
|
||
_check(scene.glb_url.begins_with("/static/"), "场景 glbUrl = %s" % scene.glb_url)
|
||
_check(scene.file_size > 0, "场景 fileSize = %d" % scene.file_size)
|
||
_check(_near(cfg.physics.gravity.y, w_phys["gravity"]["y"]),
|
||
"重力 y = %.2f(期望 %.2f)" % [cfg.physics.gravity.y, w_phys["gravity"]["y"]])
|
||
_check(_near(cfg.physics.wind.base.x, w_phys["wind"]["base"]["x"])
|
||
and _near(cfg.physics.wind.base.z, w_phys["wind"]["base"]["z"]),
|
||
"基础风 = (%.1f, %.1f, %.1f)" % [cfg.physics.wind.base.x, cfg.physics.wind.base.y, cfg.physics.wind.base.z])
|
||
_check(_near(cfg.physics.wind.gust_amplitude, w_phys["wind"]["gustAmplitude"])
|
||
and _near(cfg.physics.wind.gust_frequency, w_phys["wind"]["gustFrequency"]),
|
||
"阵风幅度/频率 = %.1f m/s @ %.2f Hz" % [cfg.physics.wind.gust_amplitude, cfg.physics.wind.gust_frequency])
|
||
_check(cfg.physics.wind.space == w_phys["wind"]["space"],
|
||
"风坐标系 = %s" % cfg.physics.wind.space)
|
||
_check(_near(cfg.physics.ground.friction, w_phys["ground"]["friction"])
|
||
and _near(cfg.physics.ground.slope_limit, w_phys["ground"]["slopeLimit"]),
|
||
"地面摩擦/坡度 = %.2f / %.0f°" % [cfg.physics.ground.friction, cfg.physics.ground.slope_limit])
|
||
_check(cfg.physics.colliders == w_phys["colliders"],
|
||
"碰撞体模式 = %s" % cfg.physics.colliders)
|
||
_check(cfg.spawn_points.size() >= 1 and _near(cfg.spawn_points[0].yaw, want["spawnPoints"][0]["yaw"]),
|
||
"出生点 yaw = %.0f°" % (cfg.spawn_points[0].yaw if cfg.spawn_points.size() > 0 else -999.0))
|
||
_check(cfg.camera.mode == want["camera"]["mode"]
|
||
and _near(cfg.camera.distance, want["camera"]["distance"])
|
||
and _near(cfg.camera.height, want["camera"]["height"]),
|
||
"相机 %s / 距离 %.1f / 高度 %.1f" % [cfg.camera.mode, cfg.camera.distance, cfg.camera.height])
|
||
_check(_near(cfg.environment.ambient_intensity, want["environment"]["ambientIntensity"])
|
||
and _near(cfg.environment.fog_density, want["environment"]["fogDensity"]),
|
||
"环境光强度 %.1f / 雾 %.2f" % [cfg.environment.ambient_intensity, cfg.environment.fog_density])
|
||
# 阵风采样:t=0 应等于基础风,t=1/(4f) 应叠加一个完整峰值
|
||
if not cfg.physics.wind.is_calm():
|
||
var s0: Vector3 = cfg.physics.wind.sample(0.0)
|
||
_check(s0.is_equal_approx(cfg.physics.wind.base), "风采样 t=0 等于基础风")
|
||
# 场景 GLB 真的能下载
|
||
var glb: Dictionary = await client.get_binary(scene.glb_url)
|
||
if glb["ok"]:
|
||
var bytes: PackedByteArray = glb["data"]
|
||
var magic := bytes.slice(0, 4).get_string_from_ascii()
|
||
_check(magic == "glTF" and bytes.size() == scene.file_size,
|
||
"场景 GLB 可下载且魔数正确(%s,%d 字节)" % [magic, bytes.size()])
|
||
else:
|
||
_fail("场景 GLB 下载失败:%s" % glb["error"])
|
||
|
||
# ---------- 5. ② 动作内部标准跨端闭环 ----------
|
||
var motion: CatalogTypes.Motion = bundle.find_motion(_expect["motionId"])
|
||
if motion == null:
|
||
_fail("目录里找不到动作 #%d" % _expect["motionId"])
|
||
else:
|
||
_check(motion.keypoint_format == _expect["keypointFormat"],
|
||
"动作内部标准 = %s" % motion.keypoint_format)
|
||
_check(motion.keypoint_frames == _expect["motionFrames"],
|
||
"服务端帧数 = %d(期望 %d)" % [motion.keypoint_frames, _expect["motionFrames"]])
|
||
_check(motion.is_scorable(), "动作可评分(预处理已完成)")
|
||
var seq_result: Dictionary = await catalog_store.fetch_sequence(_expect["motionId"])
|
||
if not seq_result["ok"]:
|
||
_fail("关键点序列拉取/解析失败:%s" % seq_result.get("error", ""))
|
||
else:
|
||
var seq: KeypointSequence.ParsedSequence = seq_result["sequence"]
|
||
_check(seq.frame_count() == _expect["motionFrames"],
|
||
"客户端解析帧数 = %d(期望 %d)" % [seq.frame_count(), _expect["motionFrames"]])
|
||
_check(seq.skipped_lines == 0, "无解析失败行(skipped=%d)" % seq.skipped_lines)
|
||
_check(seq.frames.size() > 0 and seq.frames[0].points.size() == KeypointFrame.KEYPOINT_COUNT,
|
||
"首帧点数 = %d" % (seq.frames[0].points.size() if seq.frames.size() > 0 else 0))
|
||
_check(seq.server_angles.size() == seq.frame_count(), "每帧都带服务端关节角")
|
||
_check(_near(seq.estimated_fps(), 30.0, 0.5),
|
||
"时间轴推导帧率 = %.2f fps" % seq.estimated_fps())
|
||
var duration_s := (seq.times_ms[seq.times_ms.size() - 1] - seq.times_ms[0]) / 1000.0
|
||
_check(duration_s > 90.0 and duration_s < 100.0, "动作时长 = %.2f s" % duration_s)
|
||
# 缩略图(③ 预览资产)可下载且为 PNG
|
||
var thumb: Dictionary = await client.get_binary(motion.thumbnail_url)
|
||
if thumb["ok"]:
|
||
var png: PackedByteArray = thumb["data"]
|
||
_check(png.slice(0, 8).hex_encode() == "89504e470d0a1a0a",
|
||
"动作缩略图可下载且为 PNG(%d 字节)" % png.size())
|
||
else:
|
||
_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. ① 模型资源可下载 ----------
|
||
var model: CatalogTypes.Model = bundle.find_model(_expect["modelId"])
|
||
if model == null:
|
||
_fail("目录里找不到模型 #%d" % _expect["modelId"])
|
||
else:
|
||
_check(model.format == "vrm", "模型格式 = %s" % model.format)
|
||
_check(model.is_primary, "模型为默认模型")
|
||
var model_bytes: Dictionary = await client.get_binary(model.file_url)
|
||
if model_bytes["ok"]:
|
||
var payload: PackedByteArray = model_bytes["data"]
|
||
_check(payload.slice(0, 4).get_string_from_ascii() == "glTF"
|
||
and payload.size() == model.file_size,
|
||
"模型文件可下载且为 glTF 容器(%d 字节)" % payload.size())
|
||
else:
|
||
_fail("模型下载失败:%s" % model_bytes["error"])
|
||
|
||
_finish()
|
||
|
||
|
||
func _finish() -> void:
|
||
if _failed > 0:
|
||
print("[integration] ✗ 跨端集成探针发现 %d 项不符" % _failed)
|
||
_exit_code = 1
|
||
else:
|
||
print("[integration] ✓ 跨端集成探针全部通过")
|
||
_exit_code = 0
|
||
_done = true
|
||
quit(_exit_code)
|