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"]) # ---------- 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)