diff --git a/.gitignore b/.gitignore index 88bf7b29..260f51b0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,16 @@ compile_commands.json *.a .env.live-smoke.local +# playable network tests: local scenario/fixture configs, credential files and +# run outputs never enter git. Only scenario.example.json and the static gate +# fixtures in test/playable are committed. Run outputs live under /build/playable. +/test/playable/*.local.json +/test/playable/*.local.txt +/test/playable/runs/ +*.credentials +*.credentials.json +.env.*.local + # clangd /.cache/ diff --git a/docs/FIRST-MAC-PLAYABLE-STATUS.md b/docs/FIRST-MAC-PLAYABLE-STATUS.md new file mode 100644 index 00000000..90403109 --- /dev/null +++ b/docs/FIRST-MAC-PLAYABLE-STATUS.md @@ -0,0 +1,113 @@ +# 首个 Mac 联网内测版:实施状态与已知问题(REL-01) + +日期:2026-09-11。对应计划:`docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md`。 + +本文只记录已核对的事实。**当前没有可放行的候选包,也没有任何实网证据**;所有依赖服务器、测试账号、环境负责人确认或人工签核的条目均为 BLOCKED。离线/假进程测试 PASS 只证明门禁和状态机按契约工作,不能代替联网验收。 + +## 1. 结论 + +| 项 | 状态 | 说明 | +| --- | --- | --- | +| INF-01 契约与配置校验 | 离线 PASS | `playable_harness_test.gd` 负向用例、`validate_playable_report.mjs` 静态夹具全部通过 | +| INF-02 运行器与退出门禁 | 离线 PASS | `script/playable_gate_test.sh` 用假签名 arm64 包覆盖 PASS/退出码/信号/超时 TERM→10s→KILL/残留 FIFO/RID 与泄漏警告/凭据脱敏/聚合 | +| NET-01 真实联网闭环 | **BLOCKED** | 包内入口已实现;缺 `scenario.local.json`、测试账号、环境负责人确认的路线/怪物/掉落,未联网运行 | +| CBT-01 四职业技能矩阵 | **BLOCKED** | `test/playable/skill-cases.json` 全部 `unconfirmed`;脚本不补技能 ID | +| MAP-01 树怪资源与离线 Metal 截图 | 离线 PASS,**待人工签核** | 12 个候选树怪 × CPU/GPU × wait/run/attack/damage/dead 全部组装通过;截图需人工看过 | +| MAP-02 实际地图定位与落地 | **BLOCKED** | `forest-viewpoints.example.json` 机位均 `unconfirmed`;坡地脚/根接触需人工检查 | +| STB-01 2 小时稳定性 | 工具已实现,**未实跑**,BLOCKED | `script/playable_soak.sh` 可运行(见 §4);真实断网与切图需环境确认 | +| REL-01 放行 | **BLOCKED** | 无重建候选包;上述必测项均未产生实网证据 | + +## 2. 候选包与环境 + +- 计划中的候选包 `build/export-native-trees-20260911/mtgodot-poc.app` 和自编译引擎 `build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64` 在当前工作树**不存在**。仅有 `build/export/mtgodot-poc.app`(2026-09-08),早于本轮全部 playable 代码,**不能**作为候选包。 +- 本轮修改了 GDScript(包内测试入口、流程、指标、窗口适配),**必须重新导出 PCK**;若最终修复涉及 C++,还需 `./build.sh Release` 重建扩展、重新签名打包。重建后对最终包重新跑退出门禁。 +- 官方 Release 模板的粒子退出警告未修复;必须显式使用经过验证的自编译 arm64 引擎。不宣称已修复官方模板或 Intel 平台。 +- 数据盘剩余约 7.5 GiB(99% 已用)。2 小时 soak 的 `events.jsonl`、`rss.jsonl`、截图与日志会持续写盘,实跑前需由用户腾出空间;脚本不会自行清理任何文件或系统缓存。 + +## 3. 已知问题与限制 + +### 3.1 树怪动作(MAP-01) + +- `ent_trent`(2301/2311)的 motlist 只有 WAIT、WAIT1、NORMAL_ATTACK、NORMAL_ATTACK1、FRONT_DAMAGE、FRONT_DEAD,**没有 RUN/WALK**。按旧客户端 `CRaceData::GetMotionKey` 未命中→`SetLoopMotion` 直接返回的规则,移动时继续播放当前循环(`reference_keep_current_motion`)。`forest_mob_render_test` 报告记为 4 条 fallback(2 个 race × CPU/GPU),标注“需实网确认:服务器移动该种族时位置变化且保持原循环”。未经实网确认前不能宣称行走表现与原版一致。 +- 七种 ent 目录的 motlist 均只有 FRONT_DEAD,没有 BACK_DEAD。背后击杀时的死亡动作按旧客户端回退规则判定,需实网对照确认。死亡姿态、材质、轮廓已进入接触表截图,**必须人工签核**;AABB 底部不是脚部接触,离线测试不能证明坡地脚/根贴地。 +- `mob_winding_test.gd` 仍固定覆盖 101/110/20001;树怪由 `forest_mob_render_test.gd` 单独覆盖。 + +### 3.2 帧时间(离线合成 suite,非实网) + +`rendering_scenario_test.gd`(Metal,suite=`offline_synthetic`,FakeClient + 真实资源)本轮结果: + +- 689 帧,>50ms 14 帧,全部已归因(unattributed=0);>100ms:loading 6、gameplay 2。 +- gameplay 的 2 帧(119.5ms、237.0ms)都带 `viewport_resize` 标记,发生在测试主动切换窗口尺寸时。它们不属于正常游玩的重复交互,但在实网 soak 中要继续按“同一交互 3 次中 2 次 >100ms 即阻断”规则判定。 +- loading 段:同步模型构建 447–746ms,切图 1861ms 与 4489ms。首次加载预算尚未固定(计划要求基线采集后再定,不临时放宽)。 +- CPU 蒙皮 8/16 角色时 p99 分别为 70.1ms/50.5ms(90 帧窗口,p99 即最大值);GPU 蒙皮 1/8/16 角色 p99 ≤ 26.1ms。 +- 视口 1280×720、1920×1080、2560×1440 均按请求尺寸生效(screen_scale=1);Retina 缩放下的物理/逻辑换算由 `testing/playable_window.gd` 记录,未在 Retina 屏实测。 +- 冷启动:进程冷启动/系统缓存状态未知,测试不清空系统缓存。 +- 显存:`gpu_memory_kib=null`(unavailable)。Godot RenderingDevice 计数 843,616 KiB 不是系统显存,不据此判断显存泄漏。探针开销:每次采样 CPU 约 1.26µs;开/关探针 p50 差 −0.018ms,处于帧节奏噪声内。 + +### 3.3 稳定性工具的边界(STB-01) + +- **切图**:`soak.warp.status` 默认 `unconfirmed`,STB-WARP-01 为 BLOCKED。只有环境负责人确认合法传送入口(`portal_cm`、`return_portal_cm`、`destination_map_key`、`cross_server`、偶数且 ≥20 的往返次数)后才会走传送门;不以直接改客户端节点位置代替。跨服切图须核实测试服支持。 +- **真实断网**:`soak.faults.status` 默认 `unconfirmed`,STB-DISCONNECT-01 为 BLOCKED;主动 `reconnect()`(STB-RECONNECT-01)与真实传输故障分开计数,不能互相替代。确认后由 `run_client_gate.sh` 在 127.0.0.1 上启动 `script/playable_fault_proxy.mjs`,只代理本测试客户端的连接(`server.*` 与 serverlist 必须指向代理端口,`soak.faults.upstream` 为真实测试服)。不修改全局路由、防火墙,也不关闭任何服务。 +- **已知限制**:如果服务器传送时下发了直连的 game 地址,之后的连接会**绕过代理**,故障注入对切图后的连接无效。需要在确认故障条件时一并核实,或在同一地图服务器内完成故障用例。 +- **窗口**:headless、全屏、最大化窗口不可切换尺寸;请求尺寸(含标题栏)超出屏幕可用区域时如实记为 BLOCKED,不缩小目标冒充通过。 +- **内存**:父运行器每秒采样子进程 RSS(KiB),在每轮静置 30s 后取低水位;必须 ≥ warm-up + 10 轮才判定。连续 5 轮增长,且后 5 轮中位数比前 5 轮高出 max(50MiB, 5%) 时 FAIL。低于阈值也不能证明长期无泄漏。 +- 10 次正常退出由 10 个独立进程完成,每次都经过完整退出门禁;任何一次失败,后续运行(包括 2 小时 soak)不会启动,记为 BLOCKED。 + +### 3.4 人工项(不能自动化替代) + +- 真实鼠标/键盘闭环(点击移动、选怪、普攻、技能栏、拾取)需要单独的人工证据。 +- MAP-01/MAP-02 Metal 截图人工签核(`forest_map_render_test` 报告里 `manual_signoff.status` 恒为 `PENDING`,自动 PASS 不代表视觉签核)。 +- CBT-01 技能特效截图需人工看过一次。 + +## 4. 已可运行的命令 + +以下命令在 `metin2-client/` 下执行。账号只通过 `MT_ACCOUNT`/`MT_PASSWORD` 环境变量或隐藏输入提供,禁止写进配置、参数或命令历史。 + +离线(不连服务器,本轮均 PASS): + +```bash +bash script/playable_gate_test.sh +node script/playable_soak_metrics_test.mjs +node script/playable_fault_proxy_test.mjs +node script/audit_playable_maps_test.mjs +(cd project && godot --headless --path . --script playable_harness_test.gd) +(cd project && godot --path . --script forest_mob_render_test.gd) +bash script/rendering_batch_test.sh +``` + +联网(需要重建候选包、`test/playable/scenario.local.json` 与测试账号;**本轮未运行**): + +```bash +bash script/playable_test.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --suite full --allow-gameplay --repeat 3 +bash script/playable_soak.sh --app "$PWD/build/export-playable-rc1/mtgodot-poc.app" --config test/playable/scenario.local.json --allow-gameplay --duration-seconds 7200 +node script/validate_playable_report.mjs --release-dir <本批次输出目录> +``` + +`playable_soak.sh` 行为: + +- 先用 soak suite 校验配置和资源。缺少 `--allow-gameplay` 时只做校验,返回 2,不启动客户端。 +- 依次运行 `exit-1..exit-N`(suite playable,N ≥ 10)和 `soak-1`(suite soak,墙钟时长 = `soak.duration_seconds`,超时 ≥ 时长 + 900s)。 +- 写出只列本批次运行的 `release-manifest.json`(required_runs `{playable:N, soak:1}`),再聚合。 +- 退出码:0 PASS / 1 FAIL / 2 BLOCKED / 124 超时。 + +## 5. 放行检查表对照(计划 §11) + +| 检查项 | 当前 | +| --- | --- | +| INF-01/02 负向用例通过,必测项缺失不能 PASS | ✅ 离线通过 | +| 同一候选包 3 轮联网闭环 + 鼠标键盘证据 | ❌ BLOCKED(无候选包、无账号/夹具、无人工证据) | +| 四职业基础战斗、技能及异常路径 | ❌ BLOCKED(技能矩阵未确认) | +| 首测地图和树怪资源/动作/Metal 画面 | ⚠️ 离线组装通过;机位未确认、人工签核未做、实网未跑 | +| 2 小时、切图、故障恢复、窗口、退出次数 | ❌ BLOCKED(工具就绪,未实跑;切图/断网条件未确认) | +| P0/P1 清零 | ❌ 未知:没有实网运行,无法判定 | +| 所有 BLOCKED 明确处理 | ⚠️ 本文已列出;首测必测项不得以“已知问题”绕过 | +| 包哈希、签名、引擎来源、设备信息一致 | ❌ 需重建候选包后由运行器生成 | +| 已知 P2、未覆盖地图/装备/技能及未验收平台列出 | ⚠️ 本文 §3;未覆盖:首测三图以外的地图、Intel、官方模板 | + +## 6. 需要环境负责人提供 + +1. 自编译 arm64 引擎和重新导出的候选包(含本轮脚本),以及足够的磁盘空间。 +2. `test/playable/scenario.local.json`:由 `scenario.example.json` 复制,填写 serverinfo 中的地址、角色槽位、map_key、安全路线、怪物/掉落 vnum;soak 需 `timeout_seconds ≥ duration_seconds + 900`。 +3. 专用测试账号(等级、装备、已学技能、掉落条件由负责人准备),以及 `skill-cases.local.json` 中各职业的确认记录。 +4. 如需 STB-WARP/STB-DISCONNECT:合法传送入口的确认;确认可在 127.0.0.1 代理后运行,并核实传送是否下发直连地址。 +5. 树怪机位(平地/坡地/密林/传送点)的确认,以及 Metal 截图人工签核人。 diff --git a/project/app_flow.gd b/project/app_flow.gd index 784cdc8e..a5c48a7f 100644 --- a/project/app_flow.gd +++ b/project/app_flow.gd @@ -51,7 +51,13 @@ func _init() -> void: func start(assets_root: String = "", injected_client: Node = null) -> void: _assets = assets_root serverinfo = ServerInfoRes.new() - serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置 + # 联网内测的地址覆盖只在 MT_TEST_MODE=playable 生效;父运行器把同一路径交给 + # 预检,保证预检目标与客户端真实连接目标来自同一个 ServerInfo 解析。 + var test_serverlist := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges() + if OS.get_environment("MT_TEST_MODE") == "playable" and not test_serverlist.is_empty(): + serverinfo.load_file(test_serverlist) + else: + serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置 if injected_client != null: client = injected_client elif ClassDB.class_exists("M2Client"): @@ -84,6 +90,17 @@ func start(assets_root: String = "", injected_client: Node = null) -> void: func state() -> int: return _state +# Playable-test only: choose the server/channel before the deferred autologin +# connect runs, and return the exact address _do_connect() will use. +func select_test_server(server_index: int, channel: int) -> Dictionary: + if OS.get_environment("MT_TEST_MODE") != "playable" or serverinfo == null: + return {} + if serverinfo.server(server_index).is_empty(): + return {} + _sel_server = server_index + _sel_channel = channel + return serverinfo.address(_sel_server, _sel_channel) + # Read-only adapter for the packaged playable test. It deliberately returns # summaries rather than the private UI/game nodes so a report cannot retain a # stale scene reference across reconnect or map reload. @@ -147,14 +164,16 @@ func _on_char_list(list: Array) -> void: if auto_login and not _chars.is_empty(): var idx := int(_chars[0].get("index", 0)) if _auto_char_slot >= 0: + # An explicit MT_CHAR_SLOT that the account lacks must not silently enter + # another character; stay on the select screen instead. + idx = -1 for character in _chars: if int(character.get("index", -1)) == _auto_char_slot: idx = _auto_char_slot break - if client.has_method("enter_game"): - client.enter_game(idx) - elif client.has_method("select_character"): - client.select_character(idx) + if idx >= 0: + # Remember the slot so a later in-game reconnect re-enters the same character. + _enter_character(idx) func _on_char_name_changed(pid: int, name: String) -> void: var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null diff --git a/project/app_flow_lifecycle_test.gd b/project/app_flow_lifecycle_test.gd index 4888e3fb..7f8dcd9c 100644 --- a/project/app_flow_lifecycle_test.gd +++ b/project/app_flow_lifecycle_test.gd @@ -74,6 +74,20 @@ func _init() -> void: _ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true), "playable snapshot reports GAME after reconnect") + # Auto-login with an explicit MT_CHAR_SLOT must never fall back to another + # character, and the chosen slot must be remembered for a later reconnect. + flow._state = AppFlow.LOGIN + flow.auto_login = true + flow._auto_char_slot = 5 + flow._selected_char_slot = -1 + client.selects.clear() + client.char_list.emit([{"index": 0, "name": "Other"}]) + _ck(client.selects.is_empty(), "absent explicit slot does not enter chars[0]") + flow._state = AppFlow.LOGIN + client.char_list.emit([{"index": 0, "name": "Other"}, {"index": 5, "name": "Fixture"}]) + _ck(client.selects == [5] and flow._selected_char_slot == 5, + "explicit slot is entered and remembered for reconnect") + flow.queue_free() await process_frame if _fail == 0: diff --git a/project/combat_fx_test.gd b/project/combat_fx_test.gd index 85618c06..8703c382 100644 --- a/project/combat_fx_test.gd +++ b/project/combat_fx_test.gd @@ -267,6 +267,14 @@ func _run() -> void: np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗 np._process(0.016) _ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared") + # 目标已死:挥击命中窗内也不发 CG_ATTACK(死亡目标不是有效命中) + fc.ents[2000]["dead"] = true + np._do_attack_swing(pcn, {}) + np._hit_dedup.clear() + np._swing_start_t = np._now() - 0.1 + np._process(0.016) + _ck(fc.attacks.size() == 1, "dead victim inside the hit window -> no CG_ATTACK") + fc.ents[2000]["dead"] = false np.net_world = null # --- §3.5 修改 6:武器种类 -> combo_motion_mode(RefreshState 的 SetMotionMode 分支)--- diff --git a/project/effect_playback_test.gd b/project/effect_playback_test.gd index e51f7a76..6ffd7815 100644 --- a/project/effect_playback_test.gd +++ b/project/effect_playback_test.gd @@ -1,6 +1,7 @@ extends SceneTree const EffectPlayer = preload("res://fx/effect_player.gd") +const EffectRegistry = preload("res://fx/effect_registry.gd") var failures := 0 func check(ok: bool, label: String) -> void: @@ -84,5 +85,45 @@ func run() -> void: check(is_equal_approx(live._particle_states[0].clock, stopped_clock), "SceneTree leaves stopped clock unchanged") live.free() + await registry_lifecycle() print("effect_playback_test: failures=%d" % failures) quit(1 if failures else 0) + +# CBT-01 evidence: EffectRegistry reports the real spawn and tree-exit boundaries. +func registry_lifecycle() -> void: + var registry := EffectRegistry.new() + var spec := {"lights": [{"duration": 0.8, "diffuse": [1.0, 0.5, 0.2, 1.0]}]} + registry._path_cache["synthetic"] = "synthetic.mse" + registry._spec_cache["synthetic.mse"] = spec + var spawned: Array = [] + var finished: Array = [] + registry.fx_spawned.connect(func(effect: String, id: int, lifetime: int) -> void: spawned.append([effect, id, lifetime])) + registry.fx_finished.connect(func(effect: String, id: int, lifetime: int, elapsed: int, reason: String) -> void: + finished.append([effect, id, lifetime, elapsed, reason])) + var parent := Node3D.new() + root.add_child(parent) + var once := registry.spawn("synthetic", parent, true) + check(spawned.size() == 1 and spawned[0][0] == "synthetic" and spawned[0][2] == 1000, + "one-shot spawn reports its MSE-defined lifetime: %s" % [spawned]) + check(finished.is_empty(), "spawn alone is not a finish") + await create_timer(1.8).timeout + check(not is_instance_valid(once), "one-shot effect left the tree on its own clock") + check(finished.size() == 1 and finished[0][1] == spawned[0][1] and finished[0][4] == "cleanup", + "tree exit reports the same id with reason cleanup: %s" % [finished]) + if finished.size() == 1: + check(int(finished[0][3]) >= 1000 and int(finished[0][3]) <= 1000 + 1000, + "cleanup happened inside lifetime + 1s (elapsed %dms)" % int(finished[0][3])) + var looping := registry.spawn("synthetic", parent, false) + check(spawned.size() == 2 and spawned[1][2] == -1 and spawned[1][1] != spawned[0][1], + "looping spawn has a fresh id and no defined lifetime") + var early := registry.spawn("synthetic", parent, true) + parent.remove_child(early) + early.free() + check(finished.size() == 2 and finished[1][4] == "removed", "owner removal before cleanup reports reason removed") + parent.free() + check(finished.size() == 3 and finished[2][1] == spawned[1][1], "scene teardown closes the looping effect") + var orphan := Node3D.new() + check(registry.spawn("missing", orphan, true) == null and spawned.size() == 3, + "unresolvable effect emits no spawn event") + orphan.free() + await process_frame diff --git a/project/forest_map_render_test.gd b/project/forest_map_render_test.gd index 4779e4ab..450d077b 100644 --- a/project/forest_map_render_test.gd +++ b/project/forest_map_render_test.gd @@ -1,96 +1,596 @@ extends Node -## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点。 -## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。 +## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)。 +## +## MT_TEST_MODE=forest_render MT_FOREST_MAPS=[,] godot --path project +## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告 +## +## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写), +## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经 +## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、 +## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。 +## +## 判定边界: +## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败; +## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。 +## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。 +## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。 +## +## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表), +## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。 +## +## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0, +## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。 const GameScene = preload("res://game_scene.gd") const Fixtures = preload("res://gamescene_test.gd") -const MobView = preload("res://ui/mob_view.gd") -const Config = preload("res://testing/playable_config.gd") +const Viewpoints = preload("res://testing/forest_viewpoints.gd") const PlayableReport = preload("res://testing/playable_report.gd") -var failures := 0 +## root/npclist.txt 2301–2307 / 2311–2315;未由服务器确认前只是候选。 +const CANDIDATE_RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] +const WAIT_SAMPLES := [0.0, 0.25, 0.5, 0.75] +const GRID_STEP_M := 4.0 +const FOOTPRINT_HALF_M := 1.5 +## 平地判定:怪物包围盒足迹内地形最高/最低差。超过即交人工接触检查,不做自动悬空判定。 +const FLAT_FOOTPRINT_M := 0.10 +## 平地 wait 姿态 AABB 底部与地形高度的允许差(米)。校准记录见 CALIBRATION。 +const GROUND_GAP_M := 0.10 +const CALIBRATION := "GROUND_GAP_M=0.10 m (2026-09-11, Metal, metin2_map_trent auto viewpoints): MobView grounds the bind-pose bottom via Metin2Model ground_offset; over 16 flat placements of the 12 candidate races the largest wait-loop AABB-bottom gap was 0.026 m (ent_trent, sinks slightly). 0.10 m keeps ~4x margin while still catching a missing/doubled ground offset (0.04-0.15 m per race). Changes need a recorded reason." +const ROOT_GAP_M := 0.001 +const MAIN_VID := 1000 +const MOB_VID_BASE := 60000 +const CHAR_TYPE_MONSTER := 2 +const CAMERA_PITCH_DEG := 28.0 +const DEFAULT_TIMEOUT_SECONDS := 900 + +var _out := "" +var _report := PlayableReport.new() +var _evidence := {} +var _finished := false func _ready() -> void: call_deferred("run") -func _fail(message: String) -> void: - failures += 1 - printerr("FOREST MAP FAIL: " + message) - func run() -> void: - var map_key := OS.get_environment("MT_FOREST_MAP") + _out = OS.get_environment("MT_TEST_OUTPUT") + if _out.is_empty(): + _out = OS.get_environment("MT_RENDER_OUTPUT") + if _out.is_empty(): + _out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system()) + DirAccess.make_dir_recursive_absolute(_out) + var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS + get_tree().create_timer(timeout).timeout.connect(_on_watchdog) + var headless := DisplayServer.get_name() == "headless" + _evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence", + "headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION, + "thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M}, + "maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"], + "live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"} + + var config := {} var config_path := OS.get_environment("MT_PLAYABLE_CONFIG") - var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}} - if map_key.is_empty() and loaded_config.ok: - map_key = String(loaded_config.config.get("map_key", "")) - if map_key.is_empty(): - map_key = "outdoortrent/metin2_map_trent" - var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301 - var output := OS.get_environment("MT_TEST_OUTPUT") - if output.is_empty(): - output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system()) - DirAccess.make_dir_recursive_absolute(output) - var playable_report := PlayableReport.new() - playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {}) - get_tree().root.size = Vector2i(1280, 720) - print("FOREST MAP: loading %s" % map_key) - var client := Fixtures.FakeClient.new() - # Server-space values chosen inside the corresponding local map rectangle. - # GameScene/MapCoord performs the actual base-position conversion. - var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0 - var server_x := base_x + 12800.0 - var server_y := 12800.0 - client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true) - var scene := GameScene.new() - get_tree().root.add_child(scene) - await scene.setup(client, AssetRoot.path(), map_key) - print("FOREST MAP: GameScene setup complete") - if not scene._map_loaded(): - _fail("map did not load: %s" % map_key) - if not scene._model_built or scene.player == null: - _fail("local player model is not ready") - var entities := scene.get_node_or_null("Entities") - if entities == null: - _fail("GameScene entity mount is missing") - var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)] - var monster := MobView.new() - monster.name = "ForestTreeMonster_%d" % race - if not monster.build(AssetRoot.path(), null, race): - _fail("tree monster build failed race=%d" % race) + if not config_path.is_empty() and FileAccess.file_exists(config_path): + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path)) + config = parsed if parsed is Dictionary else {} + _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS")) + var maps: Array[String] = [] + for key in OS.get_environment("MT_FOREST_MAPS").split(",", false): + if not key.strip_edges().is_empty(): + maps.append(key.strip_edges()) + if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty(): + maps.append(String(config.map_key).strip_edges()) + _report.set_required_cases(required_case_ids(maps)) + var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS")) + var races := parse_races(OS.get_environment("MT_FOREST_RACES")) + var config_errors: Array[String] = [] + if maps.is_empty(): + config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json") + var ids := {} + for map_key in maps: + if not is_safe_map_key(map_key): + config_errors.append("map_key %s must be a relative asset path without '..'" % map_key) + elif ids.has(map_key.get_file()): + config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()]) + ids[map_key.get_file()] = true + if not vp_file.ok: + config_errors.append_array(vp_file.errors) + if not races.ok: + config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums") + if not config_errors.is_empty(): + _report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors)) else: - entities.add_child(monster) - monster.position = positions[0] - monster.set_anim_state("wait") - client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0)) + _report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps), + "yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"]) + get_tree().root.size = Vector2i(1280, 720) + for map_key in maps: + await _run_map(map_key, vp_file.data, races.races, headless) + _finish() + +static func required_case_ids(maps: Array) -> Array[String]: + var ids: Array[String] = ["MAP-FOREST-CONFIG"] + for map_key in maps: + var map_id := String(map_key).get_file() + ids.append("MAP-FOREST-LOAD-" + map_id) + for kind in Viewpoints.KINDS: + ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind]) + ids.append("MAP-FOREST-MOTION-" + map_id) + return ids + +static func is_safe_map_key(map_key: String) -> bool: + return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \ + and not (".." in map_key.split("/")) + +static func parse_races(text: String) -> Dictionary: + var out := {"ok": true, "races": []} + for part in text.split(",", false): + var value := part.strip_edges() + if not value.is_valid_int() or int(value) <= 0: + out.ok = false + continue + out.races.append(int(value)) + return out + +func _on_watchdog() -> void: + if _finished: + return + _report.add_failure("forest render watchdog: run did not finish (script error or hang)") + _finish() + +func _finish() -> void: + if _finished: + return + _finished = true + var report_path := OS.get_environment("MT_TEST_REPORT") + if report_path.is_empty(): + report_path = _out.path_join("client-report.json") + var final := _report.finish() + _evidence["status"] = final.get("status", "FAIL") + # 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。 + final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json", + "scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"} + _evidence["manual_signoff"] = final.manual_signoff + var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE) + if f: + f.store_string(JSON.stringify(_evidence, "\t")) + f.close() + var written := _report.write(report_path) + var status := String(final.get("status", "FAIL")) + print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status, + final.get("failures", []).size(), final.get("blocked", []).size(), _out]) + for line in final.get("failures", []): + printerr("FOREST MAP FAIL: " + String(line)) + var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2) + if not written: + printerr("FOREST MAP RENDER: cannot write client report") + code = 1 + elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty(): + code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。 + # 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。 + get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code)) + +func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void: + var map_id := map_key.get_file() + var map_ev := {"map_key": map_key, "viewpoints": []} + _evidence.maps.append(map_ev) + var bounds := {} + var probe := GameScene.new() + for name in ["setting.txt", "Setting.txt"]: + var p := AssetRoot.path().path_join(map_key).path_join(name) + if FileAccess.file_exists(p): + bounds = probe._map_bounds(p) + break + probe.free() + if bounds.is_empty(): + _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key) + return + var plan := Viewpoints.plan(vp_data, map_key, bounds) + if not plan.errors.is_empty(): + _report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors)) + return + var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01 + var races: Array = plan.races.duplicate() + var race_source := "fixture" + if not races_override.is_empty(): + races = races_override.duplicate() + race_source = "MT_FOREST_RACES" + if races.is_empty(): + races = CANDIDATE_RACES.duplicate() + race_source = "candidate_only" + map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]} + map_ev["races"] = races + map_ev["race_source"] = race_source + + var client := Fixtures.FakeClient.new() + client.name = "ForestFakeClient" + get_tree().root.add_child(client) + var centre_cm: Vector2 = bounds.base + size_m * 50.0 + client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true) + var scene := GameScene.new() + scene.name = "ForestGameScene" + get_tree().root.add_child(scene) + var started := Time.get_ticks_msec() + await scene.setup(client, AssetRoot.path(), map_key) + var world: Node = scene.world + while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0: await get_tree().process_frame - if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0: - _fail("tree monster has no visible geometry") - if scene.cam and scene.cam.has_method("snap_to_target"): - scene.cam.snap_to_target() + var load_report: Dictionary = world.call("get_load_report") if world else {} + map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path} + var load_errors: Array[String] = [] + if not scene._map_loaded(): + load_errors.append("map did not load") + if scene.map_path != map_key: + load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key]) + for key in ["chunks_failed", "objects_missing_model"]: + if int(load_report.get(key, 0)) > 0: + load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))]) + if scene.net_world == null or scene.proto == null: + load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules") + _report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0, + {"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)), + "reason_code": "ok" if load_errors.is_empty() else "load_failed"}) + _report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS", + "; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [ + int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))]) + if not load_errors.is_empty(): + await _teardown(scene, client) + return + + var viewpoints: Array = plan.viewpoints.duplicate() + var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS) + if not auto_kinds.is_empty(): + var trees := _tree_positions(world) + map_ev["tree_trunks_found"] = trees.size() + if not trees.is_empty(): + var r := Rect2(trees[0], Vector2.ZERO) + for t in trees: + r = r.expand(t) + map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y] + # headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。 + if trees.size() > 1 and r.size.is_zero_approx(): + map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server" + trees = PackedVector2Array() + var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds) + for c: Dictionary in candidates: + viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto", + "confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p, + "server_cm": Viewpoints.to_server_cm(bounds, c.p), + "selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}}) + var motion_errors: Array[String] = [] + var fallbacks: Array = [] + var by_kind := {} + for vp: Dictionary in viewpoints: + var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless) + map_ev.viewpoints.append(vp_ev) + for line: String in vp_ev.motion_errors: + if not (line in motion_errors): + motion_errors.append(line) + fallbacks.append_array(vp_ev.fallbacks) + if not by_kind.has(vp.kind): + by_kind[vp.kind] = [] + by_kind[vp.kind].append(vp_ev) + _report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0, + {"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(), + "evidence": vp_ev.screenshots, "selected": vp.id}) + for kind in Viewpoints.KINDS: + _add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless) + map_ev["reference_fallbacks"] = fallbacks + if viewpoints.is_empty(): + _report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters") + else: + _report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS", + "; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size()) + await _teardown(scene, client) + +## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。 +func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void: + var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind] + var errors: Array[String] = [] + var screenshots: Array = [] + var confirmed: Array[String] = [] + var unconfirmed: Array[String] = [] + for r: Dictionary in results: + screenshots.append_array(r.screenshots) + for e: String in r.errors: + errors.append("%s: %s" % [r.id, e]) + if r.status == "confirmed": + confirmed.append(String(r.id)) + else: + unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm]) + if not errors.is_empty(): + _report.add_case(case_id, "FAIL", "; ".join(errors), screenshots) + elif results.is_empty(): + var why := "no confirmed viewpoint and no usable terrain candidate" + if kind == "warp": + why = "warp points cannot be derived offline; add a confirmed viewpoint" + elif kind == "dense" and map_ev.has("tree_positions"): + why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions + _report.add_case(case_id, "BLOCKED", why) + elif headless: + _report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots) + elif confirmed.is_empty() or not unconfirmed.is_empty(): + _report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots) + else: + _report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots) + +func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary: + var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source, + "server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y], + "camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}), + "mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []} + var centre: Vector2 = vp.local_m + var yaw := deg_to_rad(float(vp.camera_yaw_deg)) + var to_camera := Vector3(sin(yaw), 0.0, cos(yaw)) + var right := Vector3.UP.cross(to_camera).normalized() + var base: Vector2 = vp.server_cm - vp.local_m * 100.0 + # 主角放到怪群侧面,避免挡住机位。 + var player_local := centre + Vector2(right.x, right.z) * 12.0 + client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0) + scene._place_player_at_net_pos(client.ents[MAIN_VID].pos) + + var vids: Array[int] = [] + for i in races.size(): + var vid := MOB_VID_BASE + i + vids.append(vid) + client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER, + "pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0, + "hp": 100, "max_hp": 100, "dead": false} + client.entity_spawned.emit(client.ents[vid]) await get_tree().process_frame - await get_tree().process_frame - var image_name := "forest-map-%s.png" % map_key.get_file() - if DisplayServer.get_name() != "headless": - await RenderingServer.frame_post_draw - if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK: - _fail("map screenshot could not be saved") - var report := {"passed": failures == 0, "failures": failures, "map": map_key, - "tree_monster_race": race, "tree_monster_model": monster.model != null, - "map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "", - "assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless", - "scope": "production GameScene map load plus offline tree-monster placement; no server spawn"} - var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) - if file: - file.store_string(JSON.stringify(report, "\t")) - file.close() - var client_report_path := OS.get_environment("MT_TEST_REPORT") - if not client_report_path.is_empty(): - playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL", - "map=%s tree_monster_race=%d" % [map_key, race]) - playable_report.write(client_report_path) - print("FOREST MAP RENDER: ", JSON.stringify(report)) + # 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。 + var spacing := 3.5 + for vid in vids: + var node: Node3D = scene.net_world.node_for(vid) + if node != null and node.has_method("resolve_motion") and node.model: + # get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。 + var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) + spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0) + var cols := int(ceil(sqrt(float(races.size())))) + var rows := int(ceil(float(races.size()) / float(cols))) + for i in vids.size(): + var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \ + + to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing + client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0) + for _i in 3: + await get_tree().process_frame + + var cam := Camera3D.new() + cam.name = "ForestFixedCamera" + cam.fov = 50.0 + scene.add_child(cam) + cam.current = true + var top := 0.0 + for i in vids.size(): + var node: Node3D = scene.net_world.node_for(vids[i]) + var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev) + ev.mobs.append(mob) + top = maxf(top, float(mob.get("height_m", 0.0))) + var ground := float(world.call("sample_height", centre.x, centre.y)) + var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y) + var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15) + var pitch := deg_to_rad(CAMERA_PITCH_DEG) + cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance + cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5) + cam.look_at(look, Vector3.UP) + ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov, + "distance_m": distance, "grid_spacing_m": spacing} + for i in vids.size(): + var node: Node3D = scene.net_world.node_for(vids[i]) + if node == null: + continue + var anchor := node.global_position + Vector3.UP * 0.5 + var screen := get_viewport().get_visible_rect().grow(-8.0) + if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)): + ev.errors.append("race %d is outside the fixed camera frame" % races[i]) + if not headless: + await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev) + + # 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。 + for i in vids.size(): + var node: Node3D = scene.net_world.node_for(vids[i]) + if node != null and node.has_method("resolve_motion"): + node.anim.set_process(true) + client.ents[vids[i]].moving = true + for _i in 2: + await get_tree().process_frame + for i in vids.size(): + _check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev) + client.ents[vids[i]].moving = false + client.ents[vids[i]].dead = true + client.entity_dead.emit(vids[i]) + await get_tree().create_timer(0.4).timeout + for i in vids.size(): + var node: Node3D = scene.net_world.node_for(vids[i]) + _check_motion(node, int(races[i]), "dead", ev) + if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "": + if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01: + ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x]) + node.anim.set_process(false) + node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0)) + var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) + var h := float(world.call("sample_height", node.global_position.x, node.global_position.z)) + for mob: Dictionary in ev.mobs: + if int(mob.vid) == vids[i]: + mob["dead_end_bottom_gap_m"] = box.position.y - h + if not headless: + await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev) + for vid in vids: + client.ents.erase(vid) + client.entity_despawned.emit(vid) + cam.queue_free() + for _i in 2: + await get_tree().process_frame + return ev + +func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary: + var out := {"race": race, "vid": vid} + if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null: + ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race) + out["model"] = false + return out + out["model"] = true + var anim: Node = node.anim + anim.set("blend_time", 0.0) + anim.set_process(false) + var root := node.global_position + var h_root := float(world.call("sample_height", root.x, root.z)) + out["world_position"] = [root.x, root.y, root.z] + out["sample_height_m"] = h_root + out["root_gap_m"] = root.y - h_root + out["ground_offset_m"] = float(node.model.call("get_ground_offset")) + out["model_local_y_m"] = node.model.position.y + out["wait_resolution"] = node.last_motion_resolution() + out["wait_motion_path"] = String(anim.get("anim_path")) + out["materials"] = _material_report(node.model) + if absf(root.y - h_root) > ROOT_GAP_M: + ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root]) + if String(out.wait_motion_path).is_empty(): + ev.motion_errors.append("race %d has no wait motion after spawn" % race) + if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0: + ev.errors.append("race %d materials: %s" % [race, out.materials]) + var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) + var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5)) + var c := Vector2(box.get_center().x, box.get_center().z) + var heights: Array[float] = [] + for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]: + heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y))) + var footprint_range: float = heights.max() - heights.min() + out["footprint_range_m"] = footprint_range + out["height_m"] = box.size.y + var samples: Array = [] + var off := 0 + var duration := float(anim.call("get_duration")) + for f in WAIT_SAMPLES: + anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0))) + var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB) + var gap := b.position.y - h_root + samples.append({"fraction": f, "aabb_bottom_gap_m": gap}) + if not b.position.is_finite() or not b.size.is_finite(): + ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f]) + if absf(gap) > GROUND_GAP_M: + off += 1 + out["wait_samples"] = samples + anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0))) + if footprint_range <= FLAT_FOOTPRINT_M: + out["contact_check"] = "flat_auto" + if off == WAIT_SAMPLES.size(): + ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples]) + else: + out["contact_check"] = "slope_manual" + return out + +func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void: + if node == null or not node.has_method("resolve_motion"): + return + var res: Dictionary = node.last_motion_resolution() + var path := String(node.anim.get("anim_path")) + if String(res.get("requested_state", "")) != state: + ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")]) + return + if String(res.path).is_empty(): + if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty(): + ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true}) + else: + ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state]) + return + if path != String(res.path): + ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path]) + if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")): + ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")]) + +func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void: + cam.current = true + # 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。 + var hidden: Array[CanvasLayer] = [] + for layer in get_tree().root.find_children("*", "CanvasLayer", true, false): + if (layer as CanvasLayer).visible: + (layer as CanvasLayer).visible = false + hidden.append(layer) + for _i in 3: + await get_tree().process_frame + await RenderingServer.frame_post_draw + var image := get_tree().root.get_texture().get_image() + for layer in hidden: + if is_instance_valid(layer): + layer.visible = true + if image == null or image.is_empty() or not _has_detail(image): + ev.errors.append("screenshot %s is empty or uniform" % file_name) + return + if image.save_png(_out.path_join(file_name)) != OK: + ev.errors.append("screenshot %s could not be saved" % file_name) + return + ev.screenshots.append(file_name) + +func _has_detail(image: Image) -> bool: + var first := image.get_pixel(0, 0) + var differing := 0 + var total := 0 + for y in range(0, image.get_height(), 16): + for x in range(0, image.get_width(), 16): + total += 1 + var p := image.get_pixel(x, y) + if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05: + differing += 1 + return total > 0 and float(differing) / float(total) > 0.1 + +func _material_report(model: Node) -> Dictionary: + var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0} + for node in model.find_children("*", "MeshInstance3D", true, false): + var instance := node as MeshInstance3D + if instance.mesh == null: + continue + for surface in instance.mesh.get_surface_count(): + out.surfaces += 1 + var material := instance.get_active_material(surface) + if material is ShaderMaterial and (material as ShaderMaterial).shader != null: + var sm := material as ShaderMaterial + if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null: + out.textured += 1 + if sm.shader.code.contains("cull_disabled"): + out.two_sided += 1 + elif material is BaseMaterial3D: + if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: + out.textured += 1 + if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: + out.two_sided += 1 + else: + out.invalid += 1 + return out + +func _terrain_samples(world: Node, size_m: Vector2) -> Array: + var out: Array = [] + var x := GRID_STEP_M * 0.5 + while x < size_m.x: + var z := GRID_STEP_M * 0.5 + while z < size_m.y: + var lo := INF + var hi := -INF + for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]: + var h := float(world.call("sample_height", x + d.x, z + d.y)) + lo = minf(lo, h) + hi = maxf(hi, h) + out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0, + "blocked": bool(world.call("is_blocked", x, z))}) + z += GRID_STEP_M + x += GRID_STEP_M + return out + +func _tree_positions(world: Node) -> PackedVector2Array: + var out := PackedVector2Array() + for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false): + var mm := (node as MultiMeshInstance3D).multimesh + if mm == null: + continue + for i in mm.instance_count: + var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin + out.append(Vector2(origin.x, origin.z)) + return out + +func _net_pos(server_cm: Vector2) -> Vector3: + return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01) + +func _teardown(scene: Node, client: Node) -> void: scene.queue_free() client.queue_free() - await get_tree().process_frame - get_tree().quit(1 if failures else 0) + for _i in 3: + await get_tree().process_frame diff --git a/project/forest_mob_render_test.gd b/project/forest_mob_render_test.gd index 87604dd6..a74a9061 100644 --- a/project/forest_mob_render_test.gd +++ b/project/forest_mob_render_test.gd @@ -1,17 +1,36 @@ extends SceneTree -## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。 +## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收(FIRST-MAC-PLAYABLE §8.2)。 ## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。 +## +## 每个 race × CPU/GPU × wait/run/attack/damage/dead,在动作起点/25%/50%/75%/末段取样: +## - 报告 requested_state / resolved_motion / fallback_reason,实际播放路径必须等于解析结果; +## - 资源确无动作时按旧客户端规则(GetMotionKey 失败 -> 保留当前动作)单独判定, +## 只允许移动循环动作走这条回退,并写进 report.fallbacks; +## - 贴图实际加载、材质有 shader、包围盒有限; +## - 同一姿态 CPU/GPU 包围盒差 <= max(1cm, 高度0.5%); +## - 非 headless 时输出正面、侧面和每 race 动作 contact sheet,截图不能是纯背景。 +## env: MT_RENDER_OUTPUT 输出目录;MT_FOREST_RACES 逗号分隔的 race 子集(调试用)。 const MobView = preload("res://ui/mob_view.gd") const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] const STATES := ["wait", "run", "attack", "damage", "dead"] +## 旧客户端 CActorInstance::Move -> SetLoopMotion(RUN/WALK):缺动作时直接返回保留当前循环。 +const MOVE_STATES := ["run"] +const SAMPLES := [0.0, 0.25, 0.5, 0.75, 1.0] const SIZE := 640 +const THUMB := 128 +const BACKGROUND := Color(0.08, 0.1, 0.14) +const TOLERANCE_FLOOR_M := 0.01 +const TOLERANCE_HEIGHT_RATIO := 0.005 +const MIN_FOREGROUND_RATIO := 0.002 var _output := "" var _failures: Array[String] = [] +var _fallbacks: Array[Dictionary] = [] var _pose_bounds := {} var _results: Array[Dictionary] = [] +var _races: Array = RACES func _initialize() -> void: call_deferred("run") @@ -25,6 +44,11 @@ func run() -> void: if _output.is_empty(): _output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system()) DirAccess.make_dir_recursive_absolute(_output) + var subset := OS.get_environment("MT_FOREST_RACES").strip_edges() + if not subset.is_empty(): + _races = [] + for part in subset.split(",", false): + _races.append(int(part)) root.size = Vector2i(SIZE, SIZE) _build_lighting() var cam := Camera3D.new() @@ -34,80 +58,226 @@ func run() -> void: cam.make_current() for gpu in ["0", "1"]: OS.set_environment("MTGODOT_GPUSKIN", gpu) - for race in RACES: - await _check_race(race, gpu, cam) + for race in _races: + await _check_race(int(race), gpu, cam) OS.set_environment("MTGODOT_GPUSKIN", "0") - var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES, - "states": STATES, "gpu_modes": ["0", "1"], "results": _results, - "assets": AssetRoot.path(), "viewport": str(root.size), - "headless": DisplayServer.get_name() == "headless", + var headless := DisplayServer.get_name() == "headless" + var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races, + "states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES, + "tolerance_rule": "max(%.2fm, %.1f%% of posed height)" % [TOLERANCE_FLOOR_M, TOLERANCE_HEIGHT_RATIO * 100.0], + "fallback_rule": "reference CRaceData::GetMotionKey miss -> SetLoopMotion returns and the current loop keeps playing", + "fallbacks": _fallbacks, "results": _results, + "assets": AssetRoot.path(), "viewport": str(root.size), "headless": headless, + "screenshots": not headless, + "manual_review": "contact sheets and front/side views need one human sign-off; AABB bottom is not foot contact", "scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"} var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE) if file: file.store_string(JSON.stringify(report, "\t")) file.close() - print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), "results": _results.size(), "output": _output})) + else: + _fail("cannot write report.json") + print("FOREST MOB RENDER: ", JSON.stringify({"passed": _failures.is_empty(), "failures": _failures.size(), + "fallbacks": _fallbacks.size(), "results": _results.size(), "output": _output})) + # Batch marker (rendering_batch_test.sh); the JSON line above keeps the details. + print("PASS: forest_mob_render_test (manual sign-off pending)" if _failures.is_empty() else "FAIL: forest_mob_render_test") quit(0 if _failures.is_empty() else 1) func _check_race(race: int, gpu: String, cam: Camera3D) -> void: + var tag := "race=%d GPU=%s" % [race, gpu] var view := MobView.new() view.name = "ForestMob_%d_%s" % [race, gpu] root.add_child(view) if not view.build(AssetRoot.path(), null, race): - _fail("race=%d GPU=%s build returned false" % [race, gpu]) + _fail("%s build returned false" % tag) view.queue_free() await process_frame return - var row := {"race": race, "gpu_skin": gpu == "1", "states": {}} - for state in STATES: - var requested := view.get_motion_path(state) - if requested.is_empty() or not FileAccess.file_exists(requested): - _fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state]) - continue + # 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。 + if view.anim: + view.anim.set("blend_time", 0.0) + var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir, + "materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""} + var shoot := DisplayServer.get_name() != "headless" + var sheet: Image = null + if shoot: + sheet = Image.create(THUMB * SAMPLES.size(), THUMB * STATES.size(), false, Image.FORMAT_RGBA8) + sheet.fill(BACKGROUND) + var frame := {} + for state_index in STATES.size(): + var state: String = STATES[state_index] + var resolution: Dictionary = view.resolve_motion(state) + var before := String(view.anim.get("anim_path")) if view.anim else "" view.set_anim_state(state) if view.anim == null: - _fail("race=%d GPU=%s state=%s has no animation player" % [race, gpu, state]) + _fail("%s state=%s has no animation player" % [tag, state]) continue + var entry := {"requested_state": state, "resolved_motion": String(resolution.motion), + "motion_path": String(resolution.path), "fallback_reason": String(resolution.fallback_reason), + "status": "PASS", "samples": []} + row.states[state] = entry + var actual := String(view.anim.get("anim_path")) + if String(resolution.path).is_empty(): + if state in MOVE_STATES and resolution.fallback_reason == "reference_keep_current_motion" and actual == before and not before.is_empty(): + entry.status = "REFERENCE_FALLBACK" + entry["kept_motion_path"] = actual + _fallbacks.append({"race": race, "gpu_skin": gpu == "1", "requested_state": state, + "kept_motion_path": actual, "fallback_reason": resolution.fallback_reason, + "needs_live_confirmation": "server movement of this race shows the kept loop while its position changes"}) + else: + entry.status = "FAIL" + _fail("%s state=%s has no motion (%s; still playing %s)" % [tag, state, resolution.fallback_reason, actual]) + continue + if not FileAccess.file_exists(String(resolution.path)): + entry.status = "FAIL" + _fail("%s state=%s resolved a missing file %s" % [tag, state, resolution.path]) + continue + if actual != String(resolution.path): + entry.status = "FAIL" + _fail("%s state=%s resolved %s but played %s" % [tag, state, resolution.path, actual]) + continue + if state == "dead" and bool(view.anim.get("loop")): + _fail("%s dead motion is looping" % tag) view.anim.set_process(true) await process_frame - var actual := String(view.anim.get("anim_path")) - if actual != requested: - _fail("race=%d GPU=%s state=%s resolved %s but played %s" % [race, gpu, state, requested, actual]) - var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 1.0 - var fraction := 0.5 - if state == "dead": - fraction = 0.95 - view.anim.call("set_time", maxf(0.0, duration * fraction)) view.anim.set_process(false) - await process_frame - var bounds: AABB = view.model.call("get_visual_aabb") - if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: - _fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state]) - _pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB()) - if gpu == "1": - var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB()) - var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005) - if cpu_bounds.size.length() <= 0.0 or cpu_bounds.position.distance_to(bounds.position) > tolerance or cpu_bounds.size.distance_to(bounds.size) > tolerance: - _fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance]) - var image_name := "" - if DisplayServer.get_name() != "headless": - var centre: Vector3 = view.model.global_transform * bounds.get_center() - var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0) - var distance: float = maxf(3.0, visual_size * 1.8) - cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance) - cam.look_at(centre, Vector3.UP) - await RenderingServer.frame_post_draw - image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state] - if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK: - _fail("race=%d state=%s screenshot save failed" % [race, state]) - row.states[state] = {"requested_motion": requested, "resolved_motion": actual, - "duration": duration, "sample_fraction": fraction, - "aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], - "aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_name} + var duration := float(view.anim.call("get_duration")) if view.anim.has_method("get_duration") else 0.0 + entry["duration"] = duration + if duration <= 0.0: + entry.status = "FAIL" + _fail("%s state=%s has zero duration" % [tag, state]) + continue + for sample_index in SAMPLES.size(): + var fraction: float = SAMPLES[sample_index] + # 末段取最后一帧之前,避免非循环动作 set_time(duration) 被播放器夹回/结束。 + var at := clampf(duration * fraction, 0.0, maxf(0.0, duration - 1.0 / 60.0)) + view.anim.call("set_time", at) + await process_frame + var sample := _sample_pose(view, race, gpu, state, fraction, tag) + sample["time"] = at + entry.samples.append(sample) + if not bool(sample.valid): + entry.status = "FAIL" + if shoot: + if frame.is_empty(): + frame = _frame_for(view) + var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0)) + if not _has_foreground(image): + entry.status = "FAIL" + _fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction]) + image.convert(Image.FORMAT_RGBA8) + image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR) + sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB)) + if state == "wait" and is_equal_approx(fraction, 0.5): + for view_name in ["front", "side"]: + var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0) + var shot := await _capture(cam, frame, direction) + var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"] + if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK: + _fail("%s %s view screenshot failed" % [tag, view_name]) + row.views[view_name] = file_name + if shoot: + var sheet_name := "mob-%d-sheet-%s.png" % [race, "gpu" if gpu == "1" else "cpu"] + if not _has_foreground(sheet) or sheet.save_png(_output.path_join(sheet_name)) != OK: + _fail("%s contact sheet save failed" % tag) + row.contact_sheet = sheet_name _results.append(row) view.queue_free() await process_frame +## 同一 race/state/fraction:CPU 先记录,GPU 比较。容差 max(1cm, 高度0.5%),米制世界空间。 +func _sample_pose(view: Node3D, race: int, gpu: String, state: String, fraction: float, tag: String) -> Dictionary: + var local: AABB = view.model.call("get_visual_aabb") + var bounds: AABB = view.model.global_transform * local + var out := {"fraction": fraction, "valid": true, + "aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], + "aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z]} + if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: + out.valid = false + _fail("%s state=%s sample=%.2f has invalid visual AABB" % [tag, state, fraction]) + return out + var key := "%d/%s/%.2f" % [race, state, fraction] + if gpu == "0": + _pose_bounds[key] = bounds + return out + if not _pose_bounds.has(key): + out.valid = false + _fail("%s state=%s sample=%.2f has no CPU pose to compare" % [tag, state, fraction]) + return out + var cpu: AABB = _pose_bounds[key] + var tolerance := maxf(TOLERANCE_FLOOR_M, maxf(cpu.size.y, bounds.size.y) * TOLERANCE_HEIGHT_RATIO) + var delta := maxf(cpu.position.distance_to(bounds.position), cpu.end.distance_to(bounds.end)) + out["cpu_gpu_delta_m"] = delta + out["tolerance_m"] = tolerance + if delta > tolerance: + out.valid = false + _fail("%s state=%s sample=%.2f CPU/GPU AABB differs %.4fm > %.4fm" % [tag, state, fraction, delta, tolerance]) + return out + +## 贴图实际绑定到材质、材质有 shader。双面/混合按资源记录,不做统一强制。 +func _material_report(model: Node, tag: String) -> Dictionary: + var surfaces := 0 + var textured := 0 + var two_sided := 0 + var invalid := 0 + for node in model.find_children("*", "MeshInstance3D", true, false): + var instance := node as MeshInstance3D + if instance.mesh == null: + continue + for surface in instance.mesh.get_surface_count(): + surfaces += 1 + var material := instance.get_active_material(surface) + var shader_material := material as ShaderMaterial + if shader_material != null: + if shader_material.shader == null: + invalid += 1 + continue + if bool(shader_material.get_shader_parameter("use_texture")) and shader_material.get_shader_parameter("albedo_tex") != null: + textured += 1 + if shader_material.shader.code.contains("cull_disabled"): + two_sided += 1 + elif material is BaseMaterial3D: + if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null: + textured += 1 + if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED: + two_sided += 1 + else: + invalid += 1 + if surfaces == 0: + _fail("%s model has no mesh surfaces" % tag) + if invalid > 0: + _fail("%s has %d surfaces without a valid material" % [tag, invalid]) + if textured < surfaces: + _fail("%s only %d/%d surfaces have a loaded texture" % [tag, textured, surfaces]) + return {"surfaces": surfaces, "textured": textured, "two_sided": two_sided, "invalid": invalid} + +## 按首个采样姿态固定机位,同一 race 的所有截图可直接对比。 +func _frame_for(view: Node3D) -> Dictionary: + var bounds: AABB = view.model.global_transform * (view.model.call("get_visual_aabb") as AABB) + var radius := maxf(0.5, bounds.size.length() * 0.5) + return {"centre": bounds.get_center(), "distance": maxf(3.0, radius * 3.6)} + +func _capture(cam: Camera3D, frame: Dictionary, direction: Vector3) -> Image: + var centre: Vector3 = frame.centre + cam.position = centre + direction.normalized() * float(frame.distance) + cam.look_at(centre, Vector3.UP) + await RenderingServer.frame_post_draw + return root.get_texture().get_image() + +func _has_foreground(image: Image) -> bool: + if image == null or image.is_empty(): + return false + var hits := 0 + var step := 4 + var total := 0 + for y in range(0, image.get_height(), step): + for x in range(0, image.get_width(), step): + total += 1 + var c := image.get_pixel(x, y) + if absf(c.r - BACKGROUND.r) + absf(c.g - BACKGROUND.g) + absf(c.b - BACKGROUND.b) > 0.06: + hits += 1 + return total > 0 and float(hits) / float(total) >= MIN_FOREGROUND_RATIO + func _build_lighting() -> void: var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-42, 155, 0) @@ -116,7 +286,7 @@ func _build_lighting() -> void: var env := WorldEnvironment.new() var settings := Environment.new() settings.background_mode = Environment.BG_COLOR - settings.background_color = Color(0.08, 0.1, 0.14) + settings.background_color = BACKGROUND settings.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR settings.ambient_light_color = Color(0.55, 0.58, 0.65) settings.ambient_light_energy = 0.8 diff --git a/project/forest_viewpoints_test.gd b/project/forest_viewpoints_test.gd new file mode 100644 index 00000000..2b54ccfe --- /dev/null +++ b/project/forest_viewpoints_test.gd @@ -0,0 +1,92 @@ +# forest_viewpoints_test —— MAP-02 机位夹具校验与自动候选挑选(不加载地图)。 +# godot --headless --path project --script forest_viewpoints_test.gd +extends SceneTree + +const Viewpoints = preload("res://testing/forest_viewpoints.gd") + +const BOUNDS := {"base": Vector2(1049600, 0), "size": Vector2i(2, 2)} +var _fail := 0 +var _done := false + +func _ck(ok: bool, message: String) -> void: + if not ok: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + _run() + _ck(_done, "all viewpoint assertions executed") + print("forest_viewpoints_test: failures=%d" % _fail) + if _fail == 0: + print("PASS: forest_viewpoints_test") + quit(1 if _fail else 0) + +func _has_error(result: Dictionary, needle: String) -> bool: + return result.errors.any(func(e: String) -> bool: return needle in e) + +func _run() -> void: + # No fixture file: nothing is confirmed, every kind is missing, nothing fails. + var empty := Viewpoints.plan({}, "m", BOUNDS) + _ck(empty.errors.is_empty() and empty.viewpoints.is_empty(), "missing fixture is not an error") + _ck(empty.missing_kinds == Viewpoints.KINDS, "missing fixture leaves all four kinds unconfirmed") + _ck(Viewpoints.load_file("").ok and not Viewpoints.load_file("/nonexistent/vp.json").ok, + "empty path means no fixture; a named but absent file is an error") + + var good := {"schema_version": 1, "maps": {"m": {"races": [2301, 2306], "viewpoints": [ + {"id": "flat-01", "kind": "flat", "status": "confirmed", "confirmed_by": "test-env-owner", + "server_cm": [1062400, 12800], "camera_yaw_deg": 90}, + {"id": "warp-01", "kind": "warp", "status": "unconfirmed", "server_cm": [1100000, 40000]}, + ]}}} + var planned := Viewpoints.plan(good, "m", BOUNDS) + _ck(planned.errors.is_empty(), "valid fixture accepted: %s" % [planned.errors]) + _ck(planned.missing_kinds == ["slope", "dense"], "configured kinds are not reported missing") + _ck(planned.races == [2301, 2306], "races come from the fixture") + _ck(planned.viewpoints[0].local_m.is_equal_approx(Vector2(128, 128)), "server cm converts through BasePosition") + _ck(Viewpoints.to_server_cm(BOUNDS, Vector2(128, 128)) == Vector2(1062400, 12800), "local metres convert back to server cm") + + var bad := {"schema_version": 2, "maps": {"m": {"races": [0], "viewpoints": [ + {"id": "a", "kind": "flat", "status": "confirmed", "server_cm": [1062400, 12800]}, + {"id": "a", "kind": "cave", "status": "unconfirmed", "confirmed_by": "x", "server_cm": [1062400, 12800]}, + {"id": "b", "kind": "slope", "status": "maybe", "server_cm": [10, 10]}, + {"id": "c", "kind": "dense", "status": "unconfirmed", "server_cm": [1.5, 2]}, + ]}, "password": "x"}} + var rejected := Viewpoints.plan(bad, "m", BOUNDS) + for needle in ["schema_version", "confirmed_by (role) is empty", "duplicates a", "kind must be one of", + "unconfirmed but carries confirmed_by", "status must be", "outside m", "server_cm must be", + "races must contain positive", "credentials must not be stored"]: + _ck(_has_error(rejected, needle), "invalid fixture reports: %s (got %s)" % [needle, rejected.errors]) + + # Candidate picking is deterministic over synthetic terrain samples. + var samples: Array = [] + for x in range(0, 101, 4): + for z in range(0, 101, 4): + var p := Vector2(x, z) + var range_m := 0.02 if p.distance_to(Vector2(40, 40)) < 3.0 else 0.3 + if p.distance_to(Vector2(80, 30)) < 3.0: + range_m = 1.2 # steep but walkable (0.4) + if p.distance_to(Vector2(20, 80)) < 3.0: + range_m = 6.0 # cliff (2.0) must lose to a walkable slope + samples.append({"p": p, "range_m": range_m, "span_m": 3.0, "blocked": p == Vector2(44, 40)}) + var trees := PackedVector2Array() + for i in 8: + trees.append(Vector2(60, 72) + Vector2(4, 0).rotated(TAU * i / 8.0)) + trees.append(Vector2(41, 41)) # tree trunk right next to the flattest point + var picked := Viewpoints.pick_candidates(samples, trees, Vector2(100, 100), Viewpoints.AUTO_KINDS) + var by_kind := {} + for c: Dictionary in picked: + by_kind[c.kind] = c + _ck(by_kind.has("flat") and by_kind.flat.range_m <= 0.3 and by_kind.flat.p.distance_to(Vector2(41, 41)) >= Viewpoints.AUTO_TREE_CLEARANCE_M, + "flat candidate keeps clear of tree trunks: %s" % [by_kind.get("flat", {})]) + _ck(by_kind.has("slope") and (by_kind.slope.p as Vector2).distance_to(Vector2(80, 30)) < 3.0, + "slope candidate is the steepest walkable point, not the cliff: %s" % [by_kind.get("slope", {})]) + _ck(by_kind.has("dense") and by_kind.dense.p.distance_to(Vector2(60, 72)) < Viewpoints.AUTO_DENSITY_RADIUS_M, + "dense candidate sits inside the tree cluster: %s" % [by_kind.get("dense", {})]) + for a: Dictionary in picked: + for b: Dictionary in picked: + _ck(a == b or (a.p as Vector2).distance_to(b.p) >= Viewpoints.AUTO_SEPARATION_M, "candidates are separated") + _ck(a.p.x >= Viewpoints.AUTO_EDGE_MARGIN_M and a.p.y >= Viewpoints.AUTO_EDGE_MARGIN_M, "candidates avoid map edges") + _ck(not picked.any(func(c: Dictionary) -> bool: return c.kind == "warp"), "warp points are never guessed offline") + var flat_only := Viewpoints.pick_candidates([{"p": Vector2(50, 50), "range_m": 0.0, "span_m": 3.0, "blocked": false}], + PackedVector2Array(), Vector2(100, 100), Viewpoints.AUTO_KINDS) + _ck(flat_only.size() == 1 and flat_only[0].kind == "flat", "flat terrain yields no fake slope or dense candidate") + _done = true diff --git a/project/fx/effect_player.gd b/project/fx/effect_player.gd index 26635883..fd92bc36 100644 --- a/project/fx/effect_player.gd +++ b/project/fx/effect_player.gd @@ -29,6 +29,10 @@ var _light_states: Array[Dictionary] = [] var _bsphere_r := 0.0 var _playing := false var _cleanup_remaining := -1.0 +## MSE-defined playback length of the last play(); -1 while looping (owner-bound). +var defined_lifetime_ms := -1 +## Why the node left the tree: "cleanup" when its own playback clock freed it. +var finish_reason := "" static var _glow_tex: Texture2D @@ -64,6 +68,7 @@ func _process(delta: float) -> void: if _cleanup_remaining >= 0.0: _cleanup_remaining -= maxf(delta, 0.0) if _cleanup_remaining <= 0.0: + finish_reason = "cleanup" queue_free() func play(force_one_shot := false) -> void: @@ -71,6 +76,7 @@ func play(force_one_shot := false) -> void: # may start or delete an effect after stop/replay, or while it is paused. _playing = true _cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0 + defined_lifetime_ms = roundi(_longest_life() * 1000.0) if (force_one_shot or one_shot) else -1 for state in _particle_states: state["clock"] = 0.0 state["emission_stopped"] = false @@ -97,6 +103,7 @@ func stop() -> void: # Existing particles may finish their lifetime; mesh/light emission stops now. _playing = false _cleanup_remaining = -1.0 + defined_lifetime_ms = -1 for e in _emitters: e.emitting = false for mesh in _mesh_nodes: diff --git a/project/fx/effect_registry.gd b/project/fx/effect_registry.gd index 28cc898b..e739c25c 100644 --- a/project/fx/effect_registry.gd +++ b/project/fx/effect_registry.gd @@ -6,12 +6,21 @@ # var pv := fxr.spawn_at("hit_spark", world, global_pos) extends RefCounted +## Lifecycle boundaries of every effect this registry creates. lifetime_ms is the +## MSE-defined length (-1 = looping, lives as long as its owner). fx_finished is +## emitted when the node actually leaves the tree; reason "cleanup" means its own +## playback clock freed it, "removed" means the owner/scene removed it earlier. +signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int) +signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String) + const Mse = preload("res://fx/mse.gd") const EffectPlayer = preload("res://fx/effect_player.gd") var assets_root := "" var _path_cache := {} # name -> abs .mse path ("" = not found) var _spec_cache := {} # abs path -> parsed spec +# Process-wide so ids stay unique when GameScene rebuilds its registry. +static var _next_fx_id := 0 func setup(assets: String) -> void: assets_root = assets @@ -89,8 +98,23 @@ func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D: fx.build(s, assets_root) parent.add_child(fx) fx.play(one_shot) + _track(fx, name) return fx +func _track(fx: Node3D, name: String) -> void: + _next_fx_id += 1 + var fx_id := _next_fx_id + var lifetime_ms: int = fx.defined_lifetime_ms + fx.tree_exiting.connect(_on_fx_exiting.bind(fx, name, fx_id, lifetime_ms, Time.get_ticks_msec()), + CONNECT_ONE_SHOT) + fx_spawned.emit(name, fx_id, lifetime_ms) + +func _on_fx_exiting(fx: Node3D, name: String, fx_id: int, lifetime_ms: int, spawned_ms: int) -> void: + var reason := "removed" + if is_instance_valid(fx) and not String(fx.finish_reason).is_empty(): + reason = fx.finish_reason + fx_finished.emit(name, fx_id, lifetime_ms, Time.get_ticks_msec() - spawned_ms, reason) + # 在世界某点播一次性 func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D: var fx := spawn(name, world_parent, one_shot) diff --git a/project/game_scene.gd b/project/game_scene.gd index 96bc8a26..01606747 100644 --- a/project/game_scene.gd +++ b/project/game_scene.gd @@ -1287,7 +1287,7 @@ func get_playable_context() -> Dictionary: return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path, "net_play": net_play, "net_world": net_world, "world": world, "player": player, "pc": pc, "hud": hud, "quickbar": quickbar, - "ground_items": ground_items, "skill_fx": skill_fx} + "ground_items": ground_items, "skill_fx": skill_fx, "fx": fx} var _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试) var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性) diff --git a/project/mob_view_test.gd b/project/mob_view_test.gd index 995a794f..d9ab604e 100644 --- a/project/mob_view_test.gd +++ b/project/mob_view_test.gd @@ -7,6 +7,7 @@ const MobView = preload("res://ui/mob_view.gd") const Audio = preload("res://audio.gd") var _fail := 0 +var _resolution_done := false func _ck(c: bool, m: String) -> void: if not c: _fail += 1 @@ -21,7 +22,45 @@ func _init() -> void: printerr("%d check(s) failed" % _fail) quit(1) +class FakeAnim: + extends Node + var anim_path := "" + var loop := false + +# 纯解析:不需要扩展和资产。旧客户端 CRaceData::GetMotionKey 找不到动作时 +# SetLoopMotion/InterceptMotion 直接返回、保留当前动作;不能静默换成 WAIT。 +func _check_motion_resolution() -> void: + var mv: Node3D = MobView.new() + mv._dir = "res://__no_such_mob_dir__" + mv._motions = {"WAIT": "wait.msa", "NORMAL_ATTACK": "attack.msa", "FRONT_DEAD": "dead.msa"} + var dead: Dictionary = mv.resolve_motion("dead") + _ck(dead.get("requested_state") == "dead" and dead.get("motion") == "FRONT_DEAD" \ + and dead.get("path") == "dead.msa" and dead.get("fallback_reason") == "", "dead -> FRONT_DEAD 无回退") + var run: Dictionary = mv.resolve_motion("run") + _ck(run.get("path") == "" and run.get("motion") == "" \ + and run.get("fallback_reason") == "reference_keep_current_motion", "缺 RUN/WALK -> 保留当前动作(%s)" % run) + _ck(mv.get_motion_path("run") == "", "缺 RUN/WALK 时 get_motion_path 不返回 WAIT") + mv._motions = {"WAIT": "wait.msa"} + _ck(mv.get_motion_path("dead") == "", "缺死亡动作时不能解析成 WAIT") + mv._motions = {"WAIT": "wait.msa", "WALK": "walk.msa"} + var alias: Dictionary = mv.resolve_motion("run") + _ck(alias.get("path") == "walk.msa" and alias.get("fallback_reason") == "client_alias:WALK", "run -> WALK 标注别名回退") + mv._motions = {"WAIT": "wait.msa"} + var anim := FakeAnim.new() + mv.anim = anim + mv.set_anim_state("wait") + mv.set_anim_state("run") + _ck(anim.anim_path == "wait.msa" and anim.loop, "缺 run 时保留当前 WAIT 播放") + _ck(mv.last_motion_resolution().get("fallback_reason") == "reference_keep_current_motion", "记录最后一次解析的回退原因") + mv.anim = null + anim.free() + mv.free() + _resolution_done = true + func _run() -> void: + _check_motion_resolution() + # 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。 + _ck(_resolution_done, "动作解析断言全部执行") if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): print(" (skip: 扩展未注册)") return diff --git a/project/mob_winding_test.gd b/project/mob_winding_test.gd index b79e9992..b6f05d8a 100644 --- a/project/mob_winding_test.gd +++ b/project/mob_winding_test.gd @@ -6,7 +6,8 @@ func _init() -> void: func run() -> void: for mode in ["0", "1"]: OS.set_environment("MTGODOT_GPUSKIN", mode) - for race in [101, 110, 20001]: + # 101/110/20001 是原全怪物回归基线;2301-2315 是首测鬼木林树怪(MAP-01)。 + for race in [101, 110, 20001, 2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]: var view := MobView.new() if not view.build(AssetRoot.path(), null, race): failures += 1 diff --git a/project/net_play.gd b/project/net_play.gd index b85f13df..861fb801 100644 --- a/project/net_play.gd +++ b/project/net_play.gd @@ -894,8 +894,27 @@ func skill_context() -> Dictionary: "arrow_count": _arrow_count, "cur_hp": cur_hp, "cur_sp": cur_sp, + "bow_distance": int(_bow_distance_bonus), + "target_distance": Callable(self, "skill_target_distance_cm"), } +# rkInstMain.GetDistance(&rkInstTarget)(含 IS_HUGE_RACE −200)——施法射程判定与 +# MODE_USE_SKILL 预约趋近共用同一量度,保证预约触发时 __UseSkill 必然在射程内。 +func skill_target_distance_cm(vid: int) -> float: + if pc == null or pc.player == null or client == null: + return -1.0 + var tnode: Node3D = net_world.node_for(vid) if net_world else null + if tnode == null: + return -1.0 + var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM + if _is_huge_race(int(client.get_entity(vid).get("race", 0))): + dist_cm -= HUGE_RACE_DIST_FIX_CM + return dist_cm + +# __IsReservedUseSkill(dwSkillSlotIndex) +func is_use_skill_reserved(slot: int) -> bool: + return _reserved_mode == ReservedMode.USE_SKILL and _skill_slot_reserved == slot + func _process(dt: float) -> void: # 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。 if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \ @@ -1160,9 +1179,7 @@ func _reserve_process_use_skill() -> void: if rv.is_empty() or tnode == null: _clear_reserved() return - var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM - if _is_huge_race(int(rv.get("race", 0))): - dist_cm -= HUGE_RACE_DIST_FIX_CM + var dist_cm := skill_target_distance_cm(_vid_reserved) if dist_cm < _skill_range_reserved: if _target_vid != _vid_reserved: _target_vid = _vid_reserved diff --git a/project/net_world.gd b/project/net_world.gd index 1eec351d..d9cff7f6 100644 --- a/project/net_world.gd +++ b/project/net_world.gd @@ -1072,11 +1072,14 @@ func _on_dead(vid: int) -> void: var n: Node3D = _by_vid.get(vid, null) if n == null: return - # 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除) - var t := create_tween() - t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3) + # CActorInstance::Die 只 InterceptOnceMotion(NAME_DEAD),不倾倒模型; + # 只有没有动作的占位胶囊才用倒地作为唯一的死亡表现。 if n.has_method("set_anim_state"): + n.set_meta("dead", true) n.call("set_anim_state", "dead") + else: + var t := create_tween() + t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3) # P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在 # GC_CREATE_FLY 之前可能只收到 FLY_TARGETING(target VID=0 时是坐标),所以这里也消费 @@ -1284,17 +1287,22 @@ func _process(dt: float) -> void: var f := int(e.get("func", FUNC_WAIT)) var moving := bool(e.get("moving", false)) var walk_mode := int(e.get("walk_mode", 1)) + var dead := bool(e.get("dead", false)) if f != int(n.get_meta("func", -1)) or moving != bool(n.get_meta("moving", false)) \ - or walk_mode != int(n.get_meta("walk_mode", -1)): + or walk_mode != int(n.get_meta("walk_mode", -1)) or dead != bool(n.get_meta("dead", false)): n.set_meta("func", f) n.set_meta("moving", moving) n.set_meta("walk_mode", walk_mode) - _apply_anim(n, f, moving, walk_mode) + n.set_meta("dead", dead) + _apply_anim(n, f, moving, walk_mode, dead) _update_hp_bars() -func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1) -> void: +func _apply_anim(n: Node3D, f: int, moving: bool, walk_mode := 1, dead := false) -> void: var state := "wait" - if f == FUNC_MOVE or (f == FUNC_WAIT and moving): + if dead: + # CActorInstance::__SetMotion:IsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。 + state = "dead" + elif f == FUNC_MOVE or (f == FUNC_WAIT and moving): state = "walk" if walk_mode == 0 else "run" elif f == FUNC_ATTACK or f == FUNC_COMBO: state = "attack" diff --git a/project/net_world_vis_test.gd b/project/net_world_vis_test.gd index 294f18eb..f652c215 100644 --- a/project/net_world_vis_test.gd +++ b/project/net_world_vis_test.gd @@ -124,3 +124,38 @@ func _run() -> void: await process_frame _ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade") _ck(int(added.get(2, 0)) == 2, "entity 2 rebuilt exactly once on re-entry") + + await _dead_motion(fc, nw) + +class AnimView extends Node3D: + var states: Array[String] = [] + func set_anim_state(s: String) -> void: + states.append(s) + +# MAP-02 / CActorInstance::Die: a model plays NAME_DEAD only (no scene tilt), and +# __SetMotion rejects every non-dead motion while the actor is dead — the +# func/moving edge that arrives with the death must not restart WAIT. +func _dead_motion(fc: FakeClient, nw: Node) -> void: + nw.set_model_factory(func(d: Dictionary) -> Node3D: + return AnimView.new() if int(d.get("race", 0)) == 2301 else null) + var ent := fc.add(6, NEAR_CM + Vector2(300, 0), {"race": 2301, "ch_type": 2, "moving": true}) + fc.entity_spawned.emit(ent) + await process_frame + var view := nw.node_for(6) as AnimView + _ck(view != null and view.states.back() == "run", "moving tree monster -> run requested") + if view == null: + return + ent["moving"] = false + ent["dead"] = true + fc.entity_dead.emit(6) + await create_tween().tween_interval(0.4).finished + await process_frame + _ck(view.states.back() == "dead", "death while moving keeps DEAD (reference __SetMotion ignores WAIT when dead): %s" % [view.states]) + _ck(is_zero_approx(view.rotation.x) and is_zero_approx(view.rotation.z), "model with motions is not tilted on death (rotation.x=%.2f)" % view.rotation.x) + var capsule := fc.add(7, NEAR_CM + Vector2(600, 0), {"ch_type": 2}) + fc.entity_spawned.emit(capsule) + var placeholder: Node3D = nw.node_for(7) + capsule["dead"] = true + fc.entity_dead.emit(7) + await create_tween().tween_interval(0.4).finished + _ck(placeholder != null and placeholder.rotation.x < -1.0, "placeholder capsule (no motions) still falls over as its only death cue") diff --git a/project/netplay_test.gd b/project/netplay_test.gd index 4f6e1a3e..d1ec0510 100644 --- a/project/netplay_test.gd +++ b/project/netplay_test.gd @@ -289,8 +289,15 @@ func _run() -> void: var hook_calls: Array = [] np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true pc.player.position = Vector3.ZERO + _ck(is_equal_approx(np.skill_target_distance_cm(4100), 3000.0), "skill_target_distance_cm = node distance in cm") + _ck(np.skill_target_distance_cm(424242) < 0.0, "skill_target_distance_cm unknown vid -> -1") + var ctx_distance: Callable = np.skill_context().get("target_distance", Callable()) + _ck(ctx_distance.is_valid() and is_equal_approx(float(ctx_distance.call(4100)), 3000.0), + "skill_context exposes the same distance to the PlayerSkill gate") + _ck(not np.is_use_skill_reserved(5), "no reservation yet") np.reserve_use_skill(4100, 5, 800.0) _ck(is_equal_approx(np._skill_range_reserved, 790.0), "reserve_use_skill trims range >100 by 10") + _ck(np.is_use_skill_reserved(5) and not np.is_use_skill_reserved(6), "__IsReservedUseSkill matches the reserved slot") pc.walk_calls.clear() fc.calls.set_target.clear() await process_frame diff --git a/project/playable_combat_test.gd b/project/playable_combat_test.gd new file mode 100644 index 00000000..ce17e61e --- /dev/null +++ b/project/playable_combat_test.gd @@ -0,0 +1,355 @@ +extends SceneTree + +## CBT-01 离线事件与输入断言:真实 Quickbar + PlayerSkill + SkillTable 发出的请求, +## 经真实 PlayableProbe / PlayableFlow 归档。只有假客户端记录请求,不连服务器。 +## +## godot --headless --path project --script playable_combat_test.gd +## +## 覆盖 §7.2:拒绝不发有效施法请求、正常请求不重复、无目标、超距、冷却、资源不足、 +## 目标死亡、自身死亡、断线/切图、施法中换目标。 + +const SkillTable = preload("res://ui/skill_table.gd") +const Quickbar = preload("res://ui/quickbar.gd") +const Probe = preload("res://testing/playable_probe.gd") +const Flow = preload("res://testing/playable_flow.gd") +const Report = preload("res://testing/playable_report.gd") +const Config = preload("res://testing/playable_config.gd") +const Matrix = preload("res://playable_skill_matrix.gd") + +const MAIN := 1000 +const MOB := 2000 +const MOB_B := 2001 +const MOB_RACE := 101 +## Warrior "Three-Way Cut": attack skill, needs a target, TargetRange 400cm. +const SKILL := 1 +const SLOT := 7 + +class FakeClient extends Node: + signal skill_cooldown_end(skill: int) + signal quickslots_changed() + signal skills_changed() + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + signal disconnected(reason: String) + signal entered_game() + var target := 0 + var in_game := true + var entities := { + MAIN: {"vid": MAIN, "kind": 0, "ch_type": 0, "pos_cm": Vector3.ZERO, "dead": false}, + MOB: {"vid": MOB, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(300, 0, 0), "dead": false, "hp": 100}, + MOB_B: {"vid": MOB_B, "kind": 2, "ch_type": 2, "race": MOB_RACE, "pos_cm": Vector3(350, 0, 0), "dead": false, "hp": 100}, + } + var skills := [{"id": SKILL, "level": 5, "master": 0}] + ## Every outgoing request, in order: [name, args...]. + var requests: Array = [] + func is_in_game() -> bool: return in_game + func get_main_vid() -> int: return MAIN + func get_entity(vid) -> Dictionary: return entities.get(int(vid), {}) + func get_entities() -> Array: return entities.values() + func get_ground_items() -> Array: return [] + func get_target() -> Dictionary: return {"vid": target} + func get_skills() -> Array: return skills + func get_inventory() -> Array: return [] + func get_quickslots() -> Array: return [] + func use_skill(id, vid) -> bool: + requests.append(["use_skill", int(id), int(vid)]) + return in_game + func cast_skill(motion, rot, x, y) -> bool: + requests.append(["cast_skill", int(motion)]) + return in_game + func quickslot_add(pos, type, ref) -> bool: + requests.append(["quickslot_add", pos, type, ref]) + return true + +## Only the NetPlay surface Quickbar/PlayableFlow touch. Range reservation itself +## (walk in, then use_skill_hook) is covered by netplay_test. +class FakeNetPlay extends Node: + var client: FakeClient + var context := {} + var reservations: Array = [] + func select_target(vid: int) -> bool: + client.target = vid + return not client.get_entity(vid).is_empty() + func skill_context() -> Dictionary: + return context.merged({"target_distance": Callable(self, "skill_target_distance_cm")}) + func skill_target_distance_cm(vid: int) -> float: + var e: Dictionary = client.get_entity(vid) + var me: Dictionary = client.get_entity(MAIN) + if e.is_empty() or me.is_empty(): + return -1.0 + return Vector3(e.pos_cm).distance_to(Vector3(me.pos_cm)) + func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: + reservations.append([vid, slot, int(range_cm)]) + func is_use_skill_reserved(slot: int) -> bool: + return not reservations.is_empty() and int(reservations[-1][1]) == slot + func set_attack_key(_down: bool) -> void: + pass + +class FakeApp extends Node: + var context := {} + func get_playable_context() -> Dictionary: return context + func get_playable_snapshot() -> Dictionary: return {"scene_ready": true} + +class Rig: + var client: FakeClient + var net_play: FakeNetPlay + var app: FakeApp + var quickbar: Node + var ui: Control + var player: Node3D + var probe: Node + var report: RefCounted + var flow: RefCounted + var events: Array = [] + var now := 1_000_000 + func observed(kind: String) -> Array: + return events.filter(func(e: Array) -> bool: return e[0] == kind) + func count(name: String) -> int: + return client.requests.filter(func(r: Array) -> bool: return r[0] == name).size() + +var failures := 0 +var table: RefCounted +var _rigs: Array = [] + +func _init() -> void: + call_deferred("run") + +func check(ok: bool, message: String) -> void: + if not ok: + failures += 1 + printerr("FAIL: " + message) + +func run() -> void: + table = SkillTable.new() + var loaded := false + for lang in ["en", "common"]: + if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)): + loaded = true + break + if not loaded: + # Missing skill data makes every assertion meaningless; never report a silent pass. + check(false, "skilldesc.txt is required for playable_combat_test") + else: + check(table.target_range(SKILL) == 400 and table.is_need_target(SKILL), "fixture skill 1 is a 400cm targeted attack") + test_single_request_per_activation() + test_cooldown_holds_without_request() + test_no_target_rejected_without_request() + test_out_of_range_reserves_without_request() + test_not_enough_sp_rejected_without_request() + test_dead_target_rejected_without_request() + test_dead_self_holds_without_request() + test_disconnect_fails_case_and_stops_requests() + test_target_switch_keeps_cast_target() + test_skill_matrix() + for rig in _rigs: + rig.probe.disconnect_all() + rig.flow.clock_us = Callable() + rig.flow = null + for node in [rig.quickbar, rig.ui, rig.player, rig.probe, rig.net_play, rig.app, rig.client]: + node.free() + _rigs.clear() + print("playable_combat_test: failures=%d" % failures) + quit(1 if failures else 0) + +func make_rig(repeats := 2) -> Rig: + var rig := Rig.new() + rig.client = FakeClient.new() + root.add_child(rig.client) + rig.net_play = FakeNetPlay.new() + rig.net_play.client = rig.client + rig.net_play.context = {"cur_sp": 500, "cur_hp": 500} + rig.player = Node3D.new() + root.add_child(rig.player) + rig.ui = Control.new() + root.add_child(rig.ui) + rig.quickbar = Quickbar.new() + rig.quickbar.net_play = rig.net_play + root.add_child(rig.quickbar) + rig.quickbar.setup(rig.client, table, rig.ui, func() -> Node: return rig.player) + rig.app = FakeApp.new() + rig.app.context = {"quickbar": rig.quickbar, "net_play": rig.net_play} + rig.probe = Probe.new() + root.add_child(rig.probe) + rig.probe.setup(rig.app, rig.client) + rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started") + rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected") + rig.probe.observed.connect(func(kind: String, data: Dictionary) -> void: rig.events.append([kind, data])) + var config := {"allowed_mob_vnums": [MOB_RACE], "timeout_seconds": 900, + "skill_cases": [{"case_id": "three-way-cut", "skill_id": SKILL, "target": "enemy", + "repeats": repeats, "required_evidence": ["cast_started", "damage"]}]} + rig.report = Report.new() + rig.report.begin("combat-%d" % _rigs.size(), "full", {}) + rig.report.set_required_cases(["CBT-three-way-cut"]) + rig.flow = Flow.new() + rig.flow.report = rig.report + rig.flow.probe = rig.probe + rig.flow.flow = rig.app + rig.flow.client = rig.client + rig.flow.config = config + rig.flow.suite = "full" + rig.flow.allow_gameplay = true + rig.flow.clock_us = func() -> int: return rig.now + rig.probe.observed.connect(rig.flow.on_observed) + # Enter the skill stage the way the closed loop leaves PICKUP: bound main + epoch. + rig.flow._start_us = rig.now + rig.flow._main_vid = MAIN + rig.flow._bound_epoch = rig.probe.connection_epoch + rig.flow._begin_skill_case() + _rigs.append(rig) + return rig + +func tick(rig: Rig, advance_us := 0) -> void: + rig.now += advance_us + rig.flow.tick() + +# --- input assertions ---------------------------------------------------------- + +func test_single_request_per_activation() -> void: + var rig := make_rig() + check(rig.client.requests.is_empty(), "assigning the test slot sends no CG_QUICKSLOT_ADD") + tick(rig) + check(rig.count("use_skill") == 1 and rig.count("cast_skill") == 1, + "one activation sends exactly one use_skill and one cast_skill: %s" % [rig.client.requests]) + check(rig.client.requests[0] == ["use_skill", SKILL, MOB], "use_skill carries the resolved target") + var casts := rig.observed("cast_started") + check(casts.size() == 1 and int(casts[0][1].target_vid) == MOB and casts[0][1].source == "local", + "cast_started is a local event bound to the cast target") + tick(rig, 100_000) + check(rig.count("use_skill") == 1, "a pending cast is not re-requested while waiting for server evidence") + +func test_cooldown_holds_without_request() -> void: + var rig := make_rig() + tick(rig) + rig.client.damage.emit(MOB, 30, 1) + tick(rig) + check(rig.report.case_status("CBT-three-way-cut") == "" and rig.flow._skill_successes == 1, "first cast verified by server damage") + tick(rig, 600_000) + check(rig.count("use_skill") == 1 and rig.observed("skill_rejected").is_empty(), + "local cooldown holds the retry without a request or a rejection event") + rig.client.skill_cooldown_end.emit(SKILL) + tick(rig, 600_000) + check(rig.count("use_skill") == 2 and rig.count("cast_skill") == 2, "GC_SKILL_COOLTIME_END unlocks exactly one new request") + +func test_no_target_rejected_without_request() -> void: + var rig := make_rig() + rig.client.target = 0 + rig.quickbar.activate(SLOT) + var rejected := rig.observed("skill_rejected") + check(rejected.size() == 1 and rejected[0][1].code == "NEED_TARGET", "no target -> NEED_TARGET") + check(rig.client.requests.is_empty(), "NEED_TARGET sends no skill request") + +func test_out_of_range_reserves_without_request() -> void: + var rig := make_rig() + rig.client.entities[MOB].pos_cm = Vector3(900, 0, 0) + rig.client.entities[MOB_B].pos_cm = Vector3(950, 0, 0) + tick(rig) + check(rig.count("use_skill") == 0 and rig.count("cast_skill") == 0, "target beyond TargetRange sends no skill request") + check(rig.net_play.reservations.size() == 1 and rig.net_play.reservations[0][0] == MOB, + "out-of-range cast becomes a MODE_USE_SKILL reservation: %s" % [rig.net_play.reservations]) + check(rig.observed("skill_rejected").is_empty() and rig.observed("cast_started").is_empty(), + "reservation is neither a rejection nor a cast") + # NetPlay walked into range and fired use_skill_hook -> activate_reserved. + rig.client.entities[MOB].pos_cm = Vector3(300, 0, 0) + var global_slot: int = rig.net_play.reservations[0][1] if not rig.net_play.reservations.is_empty() else SLOT + check(bool(rig.quickbar.activate_reserved(global_slot)), "reserved skill fires once in range") + check(rig.count("use_skill") == 1 and rig.observed("cast_started").size() == 1, "in-range reserved cast sends exactly one request") + rig.client.damage.emit(MOB, 30, 1) + tick(rig) + check(rig.flow._skill_successes == 1, "the asynchronous reserved cast is credited to the case") + +func test_not_enough_sp_rejected_without_request() -> void: + var rig := make_rig() + rig.net_play.context = {"cur_sp": 10, "cur_hp": 500} + tick(rig) + var rejected := rig.observed("skill_rejected") + check(rejected.size() == 1 and rejected[0][1].code == "NOT_ENOUGH_SP", "SP below GetNeedSP -> NOT_ENOUGH_SP") + check(rig.client.requests.is_empty(), "NOT_ENOUGH_SP sends no skill request") + check(rig.report.case_status("CBT-three-way-cut") == "", "resource shortage is retried, not a fixture BLOCKED") + +func test_dead_target_rejected_without_request() -> void: + var rig := make_rig() + rig.client.entities[MOB].dead = true + rig.client.target = MOB + rig.quickbar.activate(SLOT) + var rejected := rig.observed("skill_rejected") + check(rejected.size() == 1 and rejected[0][1].code == "CANNOT_ATTACK", "dead target -> CANNOT_ATTACK") + check(rig.client.requests.is_empty(), "dead target sends no skill request") + tick(rig, 600_000) + check(rig.client.requests.size() == 2 and rig.client.requests[0] == ["use_skill", SKILL, MOB_B], + "flow re-targets a living monster instead of the corpse: %s" % [rig.client.requests]) + +func test_dead_self_holds_without_request() -> void: + var rig := make_rig() + rig.client.entities[MAIN].dead = true + tick(rig) + check(rig.client.requests.is_empty() and rig.observed("skill_rejected").is_empty(), "dead main character sends nothing") + rig.client.entities[MAIN].dead = false + rig.net_play.context = {"cur_sp": 500, "can_act": false} + tick(rig, 600_000) + check(rig.client.requests.is_empty(), "stunned/knocked-down (CANNOT_ACT) sends nothing") + +func test_disconnect_fails_case_and_stops_requests() -> void: + var rig := make_rig() + tick(rig) + check(rig.count("use_skill") == 1, "cast requested before the disconnect") + rig.client.in_game = false + rig.client.disconnected.emit("socket closed") + check(rig.report.case_status("CBT-three-way-cut") == "FAIL" and rig.flow.done, "disconnect during a skill case fails that case") + # Server damage after reconnect belongs to the new epoch and the finished case. + rig.client.in_game = true + rig.client.entered_game.emit() + rig.client.damage.emit(MOB, 30, 1) + tick(rig, 600_000) + check(rig.count("use_skill") == 1 and rig.flow._skill_successes == 0, "no request or credit after the disconnect") + +func test_target_switch_keeps_cast_target() -> void: + var rig := make_rig(1) + tick(rig) + check(rig.observed("cast_started").size() == 1 and int(rig.observed("cast_started")[0][1].target_vid) == MOB, "cast bound to target A") + rig.net_play.select_target(MOB_B) + rig.client.damage.emit(MOB_B, 30, 1) + tick(rig) + check(rig.flow._skill_successes == 0, "damage on the newly selected target B is not evidence for the cast on A") + rig.client.damage.emit(MOB, 30, 1) + tick(rig) + check(rig.flow._skill_successes == 1 and rig.report.case_status("CBT-three-way-cut") == "PASS", + "damage on the cast-time target A completes the case") + check(rig.count("use_skill") == 1, "switching the selection never re-sends the cast") + +# --- test/playable/skill-cases.json --------------------------------------------- + +func test_skill_matrix() -> void: + var committed := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path() + for job in Config.SKILL_MATRIX_JOBS: + var r: Dictionary = Matrix.check(committed, job) + check(r.status == "BLOCKED" and r.errors.is_empty() and r.blocked.size() == 4 and r.skill_cases.is_empty(), + "committed matrix carries no invented skill ids and blocks %s: %s" % [job, r]) + var evidence := ["cast_started", "damage"] + var matrix := {"schema_version": 1, "jobs": {"WARRIOR": { + "single_target": {"status": "confirmed", "skill_id": SKILL, "target": "enemy", "required_evidence": evidence}, + "area": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}, + "self_buff": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}, + "flying": {"status": "not_applicable", "checklist_ref": "REL-01 checklist row"}}}} + var ok: Dictionary = Config.skill_matrix_cases(matrix, "WARRIOR", table) + check(ok.errors.is_empty() and ok.blocked.is_empty() and ok.cases.size() == 1, "confirmed matrix resolves: %s" % [ok]) + if ok.cases.size() == 1: + check(ok.cases[0].case_id == "warrior-single-target" and int(ok.cases[0].repeats) == Config.SKILL_MATRIX_REPEATS, + "matrix case defaults to 10 successful casts") + check("CBT-warrior-single-target" in Config.required_cases({"skill_cases": ok.cases}, "full"), + "matrix case becomes a required full-suite case") + var bad := [ + ["unconfirmed entry with a skill id", "area", {"status": "unconfirmed", "skill_id": 3}], + ["skill from another job", "single_target", {"status": "confirmed", "skill_id": 32, "required_evidence": evidence}], + ["fewer than 10 casts", "single_target", {"status": "confirmed", "skill_id": SKILL, "repeats": 3, "required_evidence": evidence}], + ["local-only evidence", "single_target", {"status": "confirmed", "skill_id": SKILL, "required_evidence": ["cast_started", "fx_spawned"]}], + ["not applicable without checklist record", "area", {"status": "not_applicable"}], + ["unknown status", "flying", {"status": "skip"}], + ] + for row in bad: + var mutated: Dictionary = matrix.duplicate(true) + mutated.jobs.WARRIOR[row[1]] = row[2] + check(not Config.skill_matrix_cases(mutated, "WARRIOR", table).errors.is_empty(), "matrix rejects %s" % row[0]) + var none: Dictionary = matrix.duplicate(true) + none.jobs.WARRIOR.single_target = {"status": "not_applicable", "checklist_ref": "x"} + check(not Config.skill_matrix_cases(none, "WARRIOR", table).errors.is_empty(), "a job with nothing confirmed is not READY") + check(not Config.skill_matrix_cases(matrix, "SURA", table).errors.is_empty(), "a job absent from the matrix is an error") diff --git a/project/playable_config_gate.gd b/project/playable_config_gate.gd index 63086f69..3bfec386 100644 --- a/project/playable_config_gate.gd +++ b/project/playable_config_gate.gd @@ -1,14 +1,75 @@ extends SceneTree +## Parent-runner precondition gate. Runs headless before the packaged client +## starts; it never opens a network connection or reads credentials. +## +## env: +## MT_PLAYABLE_VALIDATE_CONFIG scenario JSON (required) +## MT_PLAYABLE_SUITE playable|full|soak (default playable) +## MT_PLAYABLE_ASSETS asset root used for map/waypoint checks +## MT_PLAYABLE_SERVERLIST test-mode serverlist override (same value is +## handed to the client, so both resolve alike) +## MT_PLAYABLE_GATE_OUTPUT directory for required-cases.json / server-address.json +## exit: 0 = ready, 2 = configuration/precondition blocked. + const Config = preload("res://testing/playable_config.gd") +const ServerInfoRes = preload("res://net/serverinfo.gd") func _init() -> void: var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") + var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges() + if suite.is_empty(): + suite = "playable" + var errors: Array = [] + if not (suite in Config.SUITES): + errors.append("unknown suite: %s" % suite) var result := Config.load_file(path) - if not result.ok: - for error in result.errors: + errors.append_array(result.errors) + # A present soak block is validated for every suite: its confirmed fault proxy also + # fronts the playable exit runs that share the scenario. + if result.ok and (suite == "soak" or result.config.has("soak")): + errors.append_array(Config.validate_soak(result.config, suite == "soak").errors) + var address := {} + var bounds := {} + if result.ok: + var serverinfo := ServerInfoRes.new() + var override := OS.get_environment("MT_PLAYABLE_SERVERLIST").strip_edges() + if not override.is_empty(): + if not serverinfo.load_file(override): + errors.append("MT_PLAYABLE_SERVERLIST cannot be loaded") + else: + serverinfo.load_file("res://serverlist.txt") + var resolved := Config.resolve_server(result.config, serverinfo) + errors.append_array(resolved.errors) + address = resolved.address + var assets := OS.get_environment("MT_PLAYABLE_ASSETS").strip_edges() + if assets.is_empty(): + errors.append("MT_PLAYABLE_ASSETS is not set; resource preconditions cannot be checked") + else: + var resources := Config.check_resources(result.config, assets) + errors.append_array(resources.errors) + bounds = resources.bounds + if not errors.is_empty(): + for error in errors: printerr("CONFIG: " + String(error)) quit(2) return - print("PLAYABLE CONFIG: PASS " + path) + var out_dir := OS.get_environment("MT_PLAYABLE_GATE_OUTPUT").strip_edges() + if not out_dir.is_empty(): + var cases := Config.required_cases(result.config, suite) + if not _write_json(out_dir.path_join("required-cases.json"), {"schema_version": 1, "suite": suite, "cases": cases}) \ + or not _write_json(out_dir.path_join("server-address.json"), address) \ + or not _write_json(out_dir.path_join("map-bounds.json"), bounds): + printerr("CONFIG: cannot write gate output") + quit(2) + return + print("PLAYABLE CONFIG: PASS") quit(0) + +func _write_json(path: String, value: Variant) -> bool: + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_string(JSON.stringify(value, " ") + "\n") + file.close() + return true diff --git a/project/playable_harness_test.gd b/project/playable_harness_test.gd index d7c745bb..9023af4a 100644 --- a/project/playable_harness_test.gd +++ b/project/playable_harness_test.gd @@ -1,9 +1,186 @@ extends SceneTree +## INF-01 / NET-01 / CBT-01 离线门禁:配置契约、报告契约,以及用假客户端驱动 +## 真实 PlayableProbe + PlayableFlow 的正反例。时间由注入时钟推进,不等待真实超时。 + const Config = preload("res://testing/playable_config.gd") const Report = preload("res://testing/playable_report.gd") +const Probe = preload("res://testing/playable_probe.gd") +const Flow = preload("res://testing/playable_flow.gd") +const Metrics = preload("res://testing/playable_metrics.gd") + +const MAIN := 1001 +const MOB := 2001 +const MOB_RACE := 101 +const DROP_VNUM := 19 +const SKILL_ID := 5 + +class FakeClient extends Node: + signal login_failed(reason: String) + signal char_list(chars: Array) + signal entered_game() + signal disconnected(reason: String) + signal entity_main_set(vid: int) + signal entity_spawned(entity: Dictionary) + signal entity_despawned(vid: int) + signal entity_moved(vid: int) + signal entity_dead(vid: int) + signal damage(vid: int, amount: int, flag: int) + signal motion(vid: int, victim_vid: int, motion: int) + signal vitals_changed(vid: int) + signal points_changed(points: Dictionary) + signal target_info(vid: int, hp_percent: int) + signal ground_item_added(item: Dictionary) + signal ground_item_removed(vid: int) + signal item_picked_up(vnum: int, count: int, from: String) + signal inventory_changed(window: int, cell: int) + signal affect_added(affect: Dictionary) + signal effect_cue(vid: int, name: String, special: int) + signal fly_cue(kind: int, start_vid: int, end_vid: int) + var main_vid := 0 + var entities := {} + var ground: Array = [] + var inventory: Array = [] + var reconnect_ok := true + var reconnect_calls := 0 + var calls: Array = [] + func is_in_game() -> bool: return main_vid > 0 + func get_main_vid() -> int: return main_vid + func get_entity(vid: int) -> Dictionary: return entities.get(vid, {}) + func get_entities() -> Array: return entities.values() + func get_ground_items() -> Array: return ground.duplicate() + func get_inventory() -> Array: return inventory.duplicate() + func reconnect() -> bool: + reconnect_calls += 1 + calls.append("reconnect") + return reconnect_ok + +class FakeHud extends Node: + pass + +class FakePC extends Node: + var player: Node3D + var client: FakeClient + var walks: Array = [] + var move_on_walk := true + func walk_to(world_pos: Vector3) -> void: + walks.append(world_pos) + if move_on_walk: + player.position = world_pos + var cm := MapCoord.to_server_cm(world_pos) + client.entities[MAIN].pos_cm = Vector3(cm.x, cm.y, 0) + +class FakeNetPlay extends Node: + var client: FakeClient + var attack_states: Array = [] + var selected: Array = [] + var picks: Array = [] + var _target_vid := 0 + var _attack_key_down := false + func select_target(vid: int) -> bool: + selected.append(vid) + client.calls.append("select_target") + var e: Dictionary = client.get_entity(vid) + return not e.is_empty() and not bool(e.get("dead", false)) + func set_attack_key(down: bool) -> void: + attack_states.append(down) + client.calls.append("attack:%s" % down) + func pick_ground_item(iid: int) -> void: + picks.append(iid) + client.calls.append("pick") + +class FakeQuickbar extends Node: + signal skill_cast_started(skill_id: int, target_vid: int) + signal skill_rejected(skill_id: int, code: String) + var client: FakeClient + var net_play: FakeNetPlay + var assigned: Array = [] + var activations := 0 + var reject_code := "" + func assign(slot: int, kind: String, id: int, persist := true) -> bool: + assigned.append([slot, kind, id, persist]) + return true + func activate(_slot: int) -> void: + activations += 1 + var skill: int = int(assigned[-1][2]) + if not reject_code.is_empty(): + skill_rejected.emit(skill, reject_code) + return + skill_cast_started.emit(skill, int(net_play.selected[-1]) if not net_play.selected.is_empty() else 0) + +## Same lifecycle signals as fx/effect_registry.gd. +class FakeFx extends RefCounted: + signal fx_spawned(effect: String, fx_id: int, lifetime_ms: int) + signal fx_finished(effect: String, fx_id: int, lifetime_ms: int, elapsed_ms: int, reason: String) + +class FakeAppFlow extends Node: + var address := {} + var scene_ready := false + var map_key := "" + var context := {} + func select_test_server(_index: int, _channel: int) -> Dictionary: return address + func get_playable_snapshot() -> Dictionary: + return {"stage": "GAME", "scene_ready": scene_ready, "map_key": map_key, "character_count": 1, "reconnecting": false} + func get_playable_context() -> Dictionary: return context + +## Same registry shape as net_world.gd: entity nodes under `parent` with meta "vid". +class FakeNetWorld extends Node: + var parent := Node3D.new() + var _fading := {} + func _init() -> void: + add_child(parent) + func add_entity_node(vid: int) -> Node3D: + var node := Node3D.new() + node.set_meta("vid", vid) + parent.add_child(node) + return node + +## Window adapter contract used by STB-01 resize (testing/playable_window.gd is the live one). +class FakeWindow extends RefCounted: + var supported := true + var reason := "" + var scale := 2.0 + var logical := Vector2i(1280, 720) + var max_logical := Vector2i(4000, 4000) + var stuck := false + var requests: Array = [] + func availability() -> Dictionary: return {"ok": supported, "reason": reason} + func request_logical_size(size: Vector2i) -> bool: + requests.append(size) + if size.x > max_logical.x or size.y > max_logical.y: + return false + if not stuck: + logical = size + return true + func logical_size() -> Vector2i: return logical + func physical_size() -> Vector2i: return Vector2i(roundi(logical.x * scale), roundi(logical.y * scale)) + func viewport_size() -> Vector2i: return logical + func screen_scale() -> float: return scale + +class Rig: + var client: FakeClient + var app: FakeAppFlow + var pc: FakePC + var net_play: FakeNetPlay + var quickbar: FakeQuickbar + var fx: FakeFx + var net_world: FakeNetWorld + var window: FakeWindow + var probe: Node + var report: RefCounted + var flow: RefCounted + var now := 1_000_000 + var events_path := "" + var status := "" + func advance(us: int) -> void: + now += us + flow.tick() + func step(times := 1) -> void: + for i in times: + flow.tick() var failures := 0 +var _rigs: Array = [] func _init() -> void: call_deferred("run") @@ -13,38 +190,1047 @@ func check(ok: bool, message: String) -> void: failures += 1 printerr("FAIL: " + message) -func run() -> void: - var example_path := ProjectSettings.globalize_path("res://../test/playable/scenario.example.json") - var example := Config.load_file(example_path) - check(not example.ok, "empty example configuration is rejected") - check(example.errors.size() > 0, "invalid configuration has reasons") - var valid := { +func valid_config() -> Dictionary: + return { "schema_version": 1, "scenario_id": "harness", "protocol": "classic", - "server": {"server_index": 0, "channel_index": 0}, "character_slot": 0, - "map_key": "OutdoorA1/metin2_map_a1", "waypoints_cm": [[454600, 934000]], - "allowed_mob_vnums": [101], "allowed_drop_vnums": [19], - "skill_cases": [{"case_id": "skill-1", "skill_id": 1, "required_evidence": ["damage"]}], - "resolution": [1280, 720], "loops": 1, "timeout_seconds": 120, + "server": {"server_index": 0, "channel": 1, "auth_host": "127.0.0.1", "auth_port": 11000, + "game_host": "127.0.0.1", "game_port": 13002}, + "character_slot": 2, "map_key": "outdoortrent/metin2_map_trent", + "waypoints_cm": [[1000, 2000], [1600, 2000]], + "allowed_mob_vnums": [MOB_RACE], "allowed_drop_vnums": [DROP_VNUM], + "skill_cases": [{"case_id": "strike", "skill_id": SKILL_ID, "target": "enemy", "repeats": 2, + "required_evidence": ["cast_started", "damage"]}], + "resolution": [1280, 720], "loops": 1, "timeout_seconds": 900, } - check(Config.validate(valid).ok, "complete configuration is accepted") - var secret := valid.duplicate(true) - secret["password"] = "must-be-rejected" - check(not Config.validate(secret).ok, "credentials in configuration are rejected") - var report := Report.new() - report.begin("harness-run", "playable", valid) - report.add_case("CONFIG-01", "PASS", "validated") - report.add_case("EVENT-01", "PASS", "received") - var finished := report.finish() - check(finished.status == "PASS", "all passing cases produce client PASS") - check(finished.coverage.required == 2 and finished.coverage.passed == 2, "coverage is exact") - var blocked := Report.new() - blocked.begin("blocked-run", "playable", valid) - blocked.add_case("FIXTURE-01", "BLOCKED", "fixture_not_ready") - check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS") - var out := "user://playable-harness-report.json" - check(report.write(out), "report can be serialized") - var json: Variant = JSON.parse_string(FileAccess.get_file_as_string(out)) - check(json is Dictionary and json.get("run_id", "") == "harness-run", "serialized report is readable") +func run() -> void: + MapCoord.set_base(Vector2.ZERO) + test_config_contract() + test_report_contract() + test_happy_path() + test_gameplay_not_allowed() + test_absent_slot_fails_without_select() + test_disconnect_during_select() + test_address_mismatch() + test_server_correction() + test_reconnect_unavailable_blocks_confirm() + test_stale_epoch_ignored() + test_target_despawn_releases_and_retargets() + test_attack_timeout_releases_key() + test_drop_taken_by_other() + test_inventory_full_timeout() + test_foreign_owner_drop_rejected() + test_local_only_skill_evidence_fails() + test_skill_not_learned_blocked() + test_skill_case_waits_for_effect_cleanup() + test_skill_screenshots_follow_effect_times() + test_effect_outliving_lifetime_fails() + test_effect_finished_late_fails() + test_effect_from_passed_case_fails_run() + test_looping_effect_not_timed() + test_soak_config_contract() + test_soak_rounds_until_wall_clock_duration() + test_soak_duration_is_wall_clock_not_rounds() + test_soak_later_round_failure() + test_soak_later_round_fixture_blocked() + test_soak_faults_confirmed() + test_soak_fault_without_disconnect_fails() + test_soak_warp_confirmed() + test_soak_warp_without_server_reconnect_fails() + test_soak_rebind_checks() + test_soak_resize_contract() + test_soak_frames_gate() + test_soak_gameplay_not_allowed() + for rig in _rigs: + rig.probe.disconnect_all() + for node in [rig.client, rig.app, rig.pc, rig.net_play, rig.quickbar, rig.net_world, rig.probe]: + node.free() + # Break Rig <-> flow lambda cycles so the run exits leak-free. + rig.flow.clock_us = Callable() + rig.flow.capture = Callable() + rig.flow.wall_ms = Callable() + if rig.flow.metrics != null: + rig.flow.metrics.clock_us = Callable() + rig.flow.metrics = null + rig.flow.window = null + rig.window = null + rig.fx = null + rig.flow = null + rig.report = null + _rigs.clear() print("playable_harness_test: failures=%d" % failures) quit(1 if failures else 0) + +# --- config / report contracts ---------------------------------------------------- + +func test_config_contract() -> void: + var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json")) + check(not example.ok and example.errors.size() > 0, "empty example configuration is rejected with reasons") + var valid := valid_config() + check(Config.validate(valid).ok, "complete configuration is accepted: %s" % [Config.validate(valid).errors]) + var old := valid.duplicate(true) + old.server = {"server_index": 0, "channel_index": 0} + check(not Config.validate(old).ok, "old server {server_index, channel_index} schema is rejected") + for key in ["password", "account", "login_token"]: + var secret := valid.duplicate(true) + secret[key] = "x" + check(not Config.validate(secret).ok, "top-level %s is rejected" % key) + var nested := valid.duplicate(true) + nested.server["user_password"] = "x" + check(not Config.validate(nested).ok, "nested credential key is rejected") + var gold := valid.duplicate(true) + gold.allowed_drop_vnums = [1, DROP_VNUM] + check(not Config.validate(gold).ok, "gold vnum is not a pickup fixture") + var local_only := valid.duplicate(true) + local_only.skill_cases[0].required_evidence = ["cast_started", "fx_spawned"] + check(not Config.validate(local_only).ok, "local-only skill evidence is rejected") + var dup := valid.duplicate(true) + dup.skill_cases.append(dup.skill_cases[0].duplicate(true)) + check(not Config.validate(dup).ok, "duplicate skill case_id is rejected") + var fractional := valid.duplicate(true) + fractional.waypoints_cm = [[1000.5, 2000]] + check(not Config.validate(fractional).ok, "fractional waypoint is rejected") + var escape := valid.duplicate(true) + escape.map_key = "../etc" + check(not Config.validate(escape).ok, "map_key path escape is rejected") + var cases := Config.required_cases(valid, "playable") + check(cases.size() == 11 and cases.has("NET-MOVE-02") and not cases.has("CBT-strike"), "playable required cases: %s" % [cases]) + check(Config.required_cases(valid, "full").has("CBT-strike"), "full suite adds skill cases") + + var info := ServerInfo.new() + check(Config.resolve_server(valid, info).ok == false, "built-in serverinfo address differs from the harness config") + var list := "user://playable-harness-serverlist.txt" + var f := FileAccess.open(list, FileAccess.WRITE) + f.store_string("fixture\t127.0.0.1\t11000\t127.0.0.1\t13002\t1,2\t10\t0\n") + f.close() + check(info.load_file(list), "fixture serverlist loads") + check(Config.resolve_server(valid, info).ok, "matching serverlist resolves") + var ch2 := valid.duplicate(true) + ch2.server.channel = 2 + check(not Config.resolve_server(ch2, info).ok, "channel 2 needs game_port + port_step") + ch2.server.game_port = 13012 + check(Config.resolve_server(ch2, info).ok, "channel 2 resolves with the stepped port") + + var assets := ProjectSettings.globalize_path("user://playable-harness-assets") + DirAccess.make_dir_recursive_absolute(assets.path_join(valid.map_key)) + f = FileAccess.open(assets.path_join(valid.map_key).path_join("Setting.txt"), FileAccess.WRITE) + f.store_string("ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n") + f.close() + check(Config.check_resources(valid, assets).ok, "waypoints inside Setting.txt bounds pass") + var outside := valid.duplicate(true) + outside.waypoints_cm = [[60000, 100]] + check(not Config.check_resources(outside, assets).ok, "waypoint outside map bounds is rejected") + var missing := valid.duplicate(true) + missing.map_key = "outdoortrent/missing" + check(not Config.check_resources(missing, assets).ok, "missing Setting.txt is rejected") + +func test_report_contract() -> void: + OS.set_environment("MT_ACCOUNT", "harness-acct") + OS.set_environment("MT_PASSWORD", "harness-pass") + var events := ProjectSettings.globalize_path("user://playable-harness-contract.jsonl") + if FileAccess.file_exists(events): + DirAccess.remove_absolute(events) + var report := Report.new() + report.begin("contract-run", "playable", valid_config(), events) + report.set_required_cases(["CONFIG-01", "NET-LOGIN-01"]) + report.add_case("CONFIG-01", "PASS", "validated") + var event: Dictionary = report.record("login_failed", "LOGIN", "NET-LOGIN-01", 1, 0, 0, + {"reason": "bad harness-acct / harness-pass", "name": "SomePlayer", "pos_cm": Vector2(1, 2)}) + check(not event.payload.has("name"), "non-whitelisted payload key is dropped") + check(String(event.payload.reason).find("harness-acct") < 0 and String(event.payload.reason).find("harness-pass") < 0, + "credential literals are redacted from payload strings") + check(event.payload.pos_cm == [1.0, 2.0], "vectors are serialized as arrays") + for key in Report.EVENT_KEYS: + check(event.has(key), "event has contract key " + key) + report.add_case("CONFIG-01", "PASS", "again") + var finished: Dictionary = report.finish() + check(finished.status == "FAIL", "duplicate case result fails the report") + check("NET-LOGIN-01" in finished.required_cases and str(finished.blocked).find("not_reached") >= 0, + "missing required case becomes BLOCKED not_reached") + check(finished.dropped_payload_keys.has("name"), "dropped payload keys are listed") + var blocked := Report.new() + blocked.begin("blocked-run", "playable", valid_config()) + blocked.set_required_cases(["CONFIG-01"]) + blocked.add_case("CONFIG-01", "BLOCKED", "fixture_not_ready") + check(blocked.finish().status == "BLOCKED", "blocked fixture cannot become PASS") + var empty := Report.new() + empty.begin("empty-run", "playable", {}) + check(empty.finish().status == "BLOCKED", "empty case list is never PASS") + var out := ProjectSettings.globalize_path("user://playable-harness-report.json") + check(report.write(out), "report can be serialized") + var text := FileAccess.get_file_as_string(out) + check(text.find("harness-acct") < 0 and text.find("harness-pass") < 0, "serialized report has no credential literal") + check(FileAccess.get_file_as_string(events).find("harness-pass") < 0, "events file has no credential literal") + var json: Variant = JSON.parse_string(text) + check(json is Dictionary and json.get("run_id", "") == "contract-run", "serialized report is readable") + OS.set_environment("MT_ACCOUNT", "") + OS.set_environment("MT_PASSWORD", "") + +# --- state machine rig -------------------------------------------------------------- + +func make_rig(suite := "playable", allow := true, config := {}) -> Rig: + var rig := Rig.new() + var cfg: Dictionary = config if not config.is_empty() else valid_config() + rig.client = FakeClient.new() + rig.app = FakeAppFlow.new() + rig.app.address = cfg.server.duplicate() + rig.app.map_key = cfg.map_key + var player := Node3D.new() + rig.pc = FakePC.new() + rig.pc.player = player + rig.pc.client = rig.client + rig.pc.add_child(player) + rig.net_play = FakeNetPlay.new() + rig.net_play.client = rig.client + rig.quickbar = FakeQuickbar.new() + rig.quickbar.client = rig.client + rig.quickbar.net_play = rig.net_play + rig.fx = FakeFx.new() + rig.net_world = FakeNetWorld.new() + rig.app.context = {"player": player, "pc": rig.pc, "net_play": rig.net_play, "hud": rig.app, "quickbar": rig.quickbar, "fx": rig.fx, + "net_world": rig.net_world} + rig.probe = Probe.new() + rig.probe.setup(rig.app, rig.client) + rig.probe.watch_local(rig.quickbar, "skill_cast_started", "cast_started") + rig.probe.watch_local(rig.quickbar, "skill_rejected", "skill_rejected") + rig.probe.watch_local(rig.fx, "fx_spawned", "fx_spawned") + rig.probe.watch_local(rig.fx, "fx_finished", "fx_finished") + rig.events_path = ProjectSettings.globalize_path("user://playable-harness-%d.jsonl" % _rigs.size()) + if FileAccess.file_exists(rig.events_path): + DirAccess.remove_absolute(rig.events_path) + rig.report = Report.new() + rig.report.begin("rig-%d" % _rigs.size(), suite, cfg, rig.events_path) + rig.flow = Flow.new() + rig.flow.report = rig.report + rig.flow.probe = rig.probe + rig.flow.flow = rig.app + rig.flow.client = rig.client + rig.flow.config = cfg + rig.flow.suite = suite + rig.flow.allow_gameplay = allow + rig.flow.clock_us = func() -> int: return rig.now + rig.flow.capture = func(case_id: String) -> String: return "%s.png" % case_id.to_lower() + rig.flow.finished.connect(func(s: String) -> void: rig.status = s) + rig.probe.observed.connect(rig.flow.on_observed) + _rigs.append(rig) + rig.flow.start() + return rig + +func enter_world(rig: Rig) -> void: + rig.client.char_list.emit([{"index": 0}, {"index": 2}]) + rig.client.main_vid = MAIN + rig.client.entities[MAIN] = {"vid": MAIN, "ch_type": 0, "name": "Hero", "pos_cm": Vector3(900, 2000, 0), "hp": 100, "dead": false} + rig.client.entered_game.emit() + rig.step() + rig.app.scene_ready = true + rig.step() + +func walk_route(rig: Rig) -> void: + rig.step(3) + +## Simulate the re-login: fast-path reconnect emits a second entered_game. +func confirm_position(rig: Rig) -> void: + rig.client.entered_game.emit() + rig.step() + +func spawn_mob(rig: Rig, vid := MOB) -> void: + rig.client.entities[vid] = {"vid": vid, "ch_type": 2, "race": MOB_RACE, "hp": 100, "max_hp": 100, "dead": false, "pos_cm": Vector3(1700, 2000, 0)} + +func kill_mob(rig: Rig, vid := MOB) -> void: + rig.client.damage.emit(vid, 40, 1) + rig.client.entities[vid].dead = true + rig.client.entity_dead.emit(vid) + rig.step() + +func drop_item(rig: Rig, iid := 7001, owner := "") -> void: + rig.client.ground.append({"vid": iid, "vnum": DROP_VNUM, "owner": owner, "pos": Vector3(17.0, 0.0, -20.0)}) + rig.step() + +func case_status(rig: Rig, id: String) -> String: + return rig.report.case_status(id) + +func test_happy_path() -> void: + var rig := make_rig("full") + check(case_status(rig, "CONFIG-01") == "PASS", "config/address case passes") + enter_world(rig) + check(case_status(rig, "NET-LOGIN-01") == "PASS", "login passes on char_list with slot") + check(case_status(rig, "NET-SELECT-01") == "PASS", "select passes on entered_game + main entity") + check(case_status(rig, "NET-WORLD-01") == "PASS", "world passes when scene/map/player/HUD ready") + walk_route(rig) + check(rig.pc.walks.size() == 2, "each waypoint uses PlayerController.walk_to") + check(case_status(rig, "NET-MOVE-01") == "PASS" and case_status(rig, "NET-MOVE-02") == "PASS", "waypoints pass") + check(rig.client.reconnect_calls == 1 and rig.flow.stage == "MOVE_CONFIRM", "route end re-logs to read server position") + confirm_position(rig) + check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "server position after re-login confirms the route") + spawn_mob(rig) + rig.step() + check(case_status(rig, "NET-TARGET-01") == "PASS" and rig.net_play.selected == [MOB], "target selected through NetPlay") + check(rig.net_play.attack_states == [true], "attack key pressed once") + kill_mob(rig) + check(case_status(rig, "NET-ATTACK-01") == "PASS", "damage + death passes attack") + check(rig.net_play.attack_states == [true, false], "attack key released after the kill") + drop_item(rig) + check(case_status(rig, "NET-DROP-01") == "PASS" and rig.net_play.picks == [7001], "new allowed drop is picked") + rig.client.ground.clear() + rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1, "cell": 3}) + rig.step() + check(case_status(rig, "NET-PICKUP-01") == "PASS", "ground removal + inventory increase passes pickup") + check(rig.flow.stage == "SKILL" and rig.quickbar.assigned == [[7, "skill", SKILL_ID, false]], "skill assigned without persistence") + spawn_mob(rig, MOB + 10) + rig.step() + check(rig.quickbar.activations == 1, "first cast requested") + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + rig.advance(600_000) + check(rig.quickbar.activations == 2, "second cast requested after retry gap") + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + check(case_status(rig, "CBT-strike") == "PASS", "every cast with server evidence passes the skill case") + check(rig.status == "PASS" and rig.flow.done, "full closed loop reports PASS (status=%s, failures=%s, blocked=%s)" % [rig.status, rig.report.finish().failures, rig.report.finish().blocked]) + var lines := FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n") + check(lines.size() > 10, "contract events persisted") + var first: Variant = JSON.parse_string(lines[0]) + check(first is Dictionary and first.run_id == "rig-0" and first.has("connection_epoch"), "event lines carry run_id and epoch") + check(FileAccess.get_file_as_string(rig.events_path).find("Hero") < 0, "character name never reaches events") + +func test_gameplay_not_allowed() -> void: + var rig := make_rig("playable", false) + enter_world(rig) + check(rig.flow.done and rig.status == "BLOCKED", "without allow_gameplay the run is BLOCKED after world") + check(case_status(rig, "NET-MOVE-01") == "BLOCKED" and str(rig.report.finish().blocked).find("gameplay_not_allowed") >= 0, + "remaining cases are BLOCKED gameplay_not_allowed") + check(rig.pc.walks.is_empty() and rig.net_play.attack_states.is_empty(), "no gameplay input without permission") + +func test_absent_slot_fails_without_select() -> void: + var rig := make_rig() + rig.client.char_list.emit([{"index": 0}]) + check(case_status(rig, "NET-LOGIN-01") == "FAIL" and rig.flow.done, "absent configured slot fails login") + check(rig.status == "FAIL", "absent slot run is FAIL") + +func test_disconnect_during_select() -> void: + var rig := make_rig() + rig.client.char_list.emit([{"index": 2}]) + rig.client.disconnected.emit("reset") + check(case_status(rig, "NET-SELECT-01") == "FAIL" and rig.flow.done, "disconnect while entering fails select") + rig.client.entered_game.emit() + rig.app.scene_ready = true + rig.step(3) + check(rig.client.calls.is_empty() and rig.pc.walks.is_empty(), "no operations after a finished run") + +func test_address_mismatch() -> void: + var cfg := valid_config() + var rig := make_rig("playable", true, cfg) + check(case_status(rig, "CONFIG-01") == "PASS", "matching address control") + var client := FakeClient.new() + var app := FakeAppFlow.new() + app.address = cfg.server.duplicate() + app.address.game_port = 13003 + var report := Report.new() + report.begin("mismatch", "playable", cfg) + var flow := Flow.new() + flow.report = report + flow.flow = app + flow.client = client + flow.config = cfg + flow.clock_us = func() -> int: return 0 + flow.start() + check(report.case_status("CONFIG-01") == "FAIL" and flow.done, "client address differing from precheck fails CONFIG-01") + client.free() + app.free() + +func test_server_correction() -> void: + var rig := make_rig() + rig.pc.move_on_walk = false + enter_world(rig) + rig.step() + rig.client.entities[MAIN].pos_cm = Vector3(9000, 9000, 0) + rig.client.entity_moved.emit(MAIN) + check(case_status(rig, "NET-MOVE-01") == "FAIL", "server correction beyond tolerance fails the waypoint") + var rig2 := make_rig() + rig2.pc.move_on_walk = false + enter_world(rig2) + rig2.advance(21_000_000) + check(case_status(rig2, "NET-MOVE-01") == "FAIL" and rig2.status == "FAIL", "waypoint timeout fails via injected clock") + +func test_reconnect_unavailable_blocks_confirm() -> void: + var rig := make_rig() + rig.client.reconnect_ok = false + enter_world(rig) + walk_route(rig) + check(case_status(rig, "NET-MOVE-CONFIRM-01") == "BLOCKED", "no reconnect -> confirm BLOCKED, not PASS") + check(rig.flow.stage == "TARGET", "run continues to targeting after a blocked confirm") + +func test_stale_epoch_ignored() -> void: + var rig := make_rig() + enter_world(rig) + walk_route(rig) + # Old-connection damage/death arriving after the reconnect was requested. + spawn_mob(rig) + rig.client.disconnected.emit("relogin") + rig.client.damage.emit(MOB, 50, 1) + confirm_position(rig) + check(case_status(rig, "NET-MOVE-CONFIRM-01") == "PASS", "expected reconnect disconnect does not fail the run") + rig.step() + check(rig.flow.stage == "ATTACK", "target bound in new epoch") + check(int(rig.probe.connection_epoch) == 2, "disconnect bumps the connection epoch") + # Deliver an old-epoch death directly; it must be marked stale and ignored. + rig.flow.on_observed("entity_dead", {"source": "server", "connection_epoch": 1, "actor_vid": 0, "target_vid": MOB}) + rig.step() + check(case_status(rig, "NET-ATTACK-01") == "", "old-epoch death does not pass attack") + check(FileAccess.get_file_as_string(rig.events_path).find("stale_epoch") >= 0, "stale event is recorded as stale_epoch") + +func test_target_despawn_releases_and_retargets() -> void: + var rig := make_rig() + enter_world(rig) + walk_route(rig) + confirm_position(rig) + spawn_mob(rig) + rig.step() + rig.client.entities.erase(MOB) + rig.client.entity_despawned.emit(MOB) + check(rig.net_play.attack_states == [true, false], "despawn releases the attack key") + spawn_mob(rig, MOB + 1) + rig.client.entities[MOB] = {"vid": MOB, "ch_type": 2, "race": MOB_RACE, "hp": 100, "dead": false, "pos_cm": Vector3(1000, 2000, 0)} + rig.step() + check(rig.net_play.selected == [MOB, MOB + 1], "abandoned VID is not reselected (selected=%s)" % [rig.net_play.selected]) + +func test_attack_timeout_releases_key() -> void: + var rig := make_rig() + enter_world(rig) + walk_route(rig) + confirm_position(rig) + spawn_mob(rig) + rig.step() + rig.advance(91_000_000) + check(case_status(rig, "NET-ATTACK-01") == "FAIL" and rig.net_play.attack_states == [true, false], "attack timeout fails and releases input") + var rig2 := make_rig() + enter_world(rig2) + walk_route(rig2) + confirm_position(rig2) + spawn_mob(rig2) + rig2.step() + rig2.client.entities[MOB].dead = true + rig2.client.entity_dead.emit(MOB) + rig2.step() + check(case_status(rig2, "NET-ATTACK-01") == "FAIL", "death without damage/HP evidence fails") + +func to_pickup(rig: Rig) -> void: + enter_world(rig) + walk_route(rig) + confirm_position(rig) + spawn_mob(rig) + rig.step() + kill_mob(rig) + drop_item(rig) + +func test_drop_taken_by_other() -> void: + var rig := make_rig() + to_pickup(rig) + rig.client.ground.clear() + rig.client.ground_item_removed.emit(7001) + rig.step() + rig.advance(2_500_000) + check(case_status(rig, "NET-PICKUP-01") == "FAIL", "item removed without inventory increase fails pickup") + +func test_inventory_full_timeout() -> void: + var rig := make_rig() + to_pickup(rig) + rig.advance(16_000_000) + check(case_status(rig, "NET-PICKUP-01") == "FAIL" and rig.status == "FAIL", "pickup never accepted (inventory full) fails") + +func test_foreign_owner_drop_rejected() -> void: + var rig := make_rig() + enter_world(rig) + walk_route(rig) + confirm_position(rig) + spawn_mob(rig) + rig.step() + kill_mob(rig) + drop_item(rig, 7002, "SomeoneElse") + check(rig.net_play.picks.is_empty() and case_status(rig, "NET-DROP-01") == "", "foreign-owner drop is not picked") + rig.advance(31_000_000) + check(rig.flow.stage == "TARGET", "no acceptable drop re-targets while kills < 3") + +func to_skill(rig: Rig) -> void: + to_pickup(rig) + rig.client.ground.clear() + rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1}) + rig.step() + spawn_mob(rig, MOB + 10) + +func test_local_only_skill_evidence_fails() -> void: + var rig := make_rig("full") + to_skill(rig) + check(rig.flow.stage == "SKILL", "reached skill stage") + rig.step() + check(rig.quickbar.activations == 1, "skill activated through Quickbar") + rig.advance(11_000_000) + check(case_status(rig, "CBT-strike") == "FAIL", "cast_started without server damage fails") + +func test_skill_not_learned_blocked() -> void: + var rig := make_rig("full") + to_skill(rig) + rig.quickbar.reject_code = "NOT_YET_LEARN" + rig.step() + check(case_status(rig, "CBT-strike") == "BLOCKED" and rig.status == "BLOCKED", "unlearned skill is a fixture BLOCKED") + +# --- CBT-01: effect lifecycle ------------------------------------------------------ + +## Two casts with server damage; the first cast spawns effect 41 (lifetime 1500ms). +func cast_twice_with_effect(rig: Rig) -> void: + to_skill(rig) + rig.step() + rig.fx.fx_spawned.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500) + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + rig.advance(600_000) + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + +func test_skill_case_waits_for_effect_cleanup() -> void: + var rig := make_rig("full") + cast_twice_with_effect(rig) + check(rig.quickbar.activations == 2 and case_status(rig, "CBT-strike") == "", "skill case is held while its effect is still alive") + rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 1900, "cleanup") + rig.step() + check(case_status(rig, "CBT-strike") == "PASS" and rig.status == "PASS", "effect cleaned inside lifetime+1s lets the skill case pass") + var text := FileAccess.get_file_as_string(rig.events_path) + check(text.find("\"fx_id\":41") >= 0 and text.find("\"elapsed_ms\":1900") >= 0 and text.find("blow_1") >= 0, + "effect spawn/finish boundaries are persisted with id, lifetime and elapsed") + +func case_evidence(rig: Rig, id: String) -> Array: + for item in rig.report.finish().cases: + if String(item.id) == id: + return item.evidence + return [] + +func test_skill_screenshots_follow_effect_times() -> void: + # Effect finishes before its half-life tick: peak falls back to the last live frame. + var rig := make_rig("full") + cast_twice_with_effect(rig) + rig.fx.fx_finished.emit("d:/ymir work/effect/hit/blow_1.mse", 41, 1500, 700, "cleanup") + rig.step() + check(case_evidence(rig, "CBT-strike") == ["cbt-strike-before.png", "cbt-strike-trigger.png", "cbt-strike-peak.png", "cbt-strike-end.png"], + "first cast is captured before / trigger / peak / end: %s" % [case_evidence(rig, "CBT-strike")]) + # Peak is taken on the tick the half-life falls due, not after a fixed second. + var timed := make_rig("full") + to_skill(timed) + timed.step() + timed.fx.fx_spawned.emit("blow_1", 41, 400) + timed.advance(150_000) + check(not ("cbt-strike-peak.png" in timed.flow._shots), "no peak before the effect's half-life") + timed.advance(60_000) + check("cbt-strike-peak.png" in timed.flow._shots and not ("cbt-strike-end.png" in timed.flow._shots), + "peak captured at 200ms of a 400ms effect, end still pending") + timed.fx.fx_finished.emit("blow_1", 41, 400, 400, "cleanup") + check(not ("cbt-strike-end.png" in timed.flow._shots), "end frame waits for the tick after tree exit") + timed.step() + check(timed.flow._shots.back() == "cbt-strike-end.png", "end captured one tick after the effect left the tree") + var text := FileAccess.get_file_as_string(timed.events_path) + check(text.find("\"skill_capture\"") >= 0 and text.find("\"phase\":\"peak\"") >= 0, "captures are correlated in events.jsonl") + +func test_effect_outliving_lifetime_fails() -> void: + var rig := make_rig("full") + cast_twice_with_effect(rig) + rig.advance(1_000_000) + check(rig.status == "", "effect inside lifetime+1s does not fail yet") + rig.advance(1_000_000) + check(case_status(rig, "CBT-strike") == "FAIL" and rig.status == "FAIL", "effect still in the tree after lifetime+1s fails the skill case") + check(str(rig.report.finish().failures).find("blow_1") >= 0, "failure names the effect") + +func test_effect_finished_late_fails() -> void: + var rig := make_rig("full") + cast_twice_with_effect(rig) + rig.fx.fx_finished.emit("blow_1", 41, 1500, 2600, "cleanup") + check(case_status(rig, "CBT-strike") == "FAIL", "effect reporting elapsed > lifetime+1s fails") + +func test_effect_from_passed_case_fails_run() -> void: + var rig := make_rig() + enter_world(rig) + walk_route(rig) + confirm_position(rig) + spawn_mob(rig) + rig.step() + check(rig.flow.stage == "ATTACK", "attacking before the hit effect") + rig.fx.fx_spawned.emit("hit_spark", 77, 800) + kill_mob(rig) + check(case_status(rig, "NET-ATTACK-01") == "PASS" and rig.flow.stage == "DROP", "attack case passed while its effect is alive") + rig.advance(1_900_000) + check(rig.status == "FAIL" and str(rig.report.finish().failures).find("NET-ATTACK-01") >= 0, + "effect of an already-passed case outliving lifetime+1s makes the run FAIL") + +func test_looping_effect_not_timed() -> void: + var rig := make_rig("full") + to_skill(rig) + rig.fx.fx_spawned.emit("aura_loop", 90, -1) + rig.step() + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + rig.advance(600_000) + rig.client.damage.emit(MOB + 10, 30, 1) + rig.step() + check(case_status(rig, "CBT-strike") == "PASS", "looping owner-bound effect does not hold or fail the case") + +# --- STB-01: soak rounds ------------------------------------------------------------- +# Time is the injected monotonic clock; the soak ends on elapsed wall-clock duration, +# never on a round or frame count. Warp / real faults / resize need a confirmed +# environment or a window, otherwise they stay BLOCKED. + +const WALL_ORIGIN_MS := 1_700_000_000_000 +const SOAK_REST_US := 600_000_000 +const SETTLE_US := 1_100_000 + +func soak_config() -> Dictionary: + var cfg := valid_config() + cfg.timeout_seconds = 9000 + cfg.soak = {"duration_seconds": 7200, "rest_seconds": 30, "warmup_rounds": 1, "min_rounds": 10, + "reconnects": 10, "exits": 10, + "warp": {"status": "unconfirmed"}, + "faults": {"status": "unconfirmed"}, + "resolutions": {"sizes": [[1280, 720], [1440, 900], [1600, 1000]], "switches_per_size": 10}} + return cfg + +func confirmed_warp(cfg: Dictionary) -> Dictionary: + cfg.soak.warp = {"status": "confirmed", "required": 20, "portal_cm": [1500, 2600], "destination_map_key": "outdoortrent/metin2_map_trent02", + "return_portal_cm": [52000, 1200], "cross_server": false} + return cfg + +func confirmed_faults(cfg: Dictionary) -> Dictionary: + # The client connects to server.* on 127.0.0.1 (the proxy); upstream is the real test server. + cfg.soak.faults = {"status": "confirmed", "mode": "local_proxy", "per_type": 1, "unreachable_seconds": 10, + "upstream": {"auth_host": "192.0.2.10", "auth_port": 11000, "game_host": "192.0.2.10", "game_port": 13002}} + return cfg + +func make_soak_rig(cfg := {}, with_window := true) -> Rig: + var rig := make_rig("soak", true, cfg if not cfg.is_empty() else soak_config()) + rig.flow.wall_ms = func() -> int: return WALL_ORIGIN_MS + rig.now / 1000 + if with_window: + rig.window = FakeWindow.new() + rig.flow.window = rig.window + return rig + +func events_of(rig: Rig, kind: String) -> Array: + var out: Array = [] + for line in FileAccess.get_file_as_string(rig.events_path).strip_edges().split("\n"): + var event: Variant = JSON.parse_string(line) + if event is Dictionary and event.kind == kind: + out.append(event) + return out + +func finished_case(rig: Rig, id: String) -> Dictionary: + for item in rig.report.finish().cases: + if String(item.id) == id: + return item + return {} + +## MOVE x2 -> MOVE_CONFIRM re-login -> settle -> TARGET -> ATTACK. +func round_to_attack(rig: Rig, round: int) -> void: + walk_route(rig) + confirm_position(rig) + rig.advance(SETTLE_US) + spawn_mob(rig, MOB + 100 * (round + 1)) + rig.step() + +## ATTACK -> DROP -> PICKUP passes; the flow moves on to FAULT / WARP / RESIZE / REST. +func round_attack_to_pickup(rig: Rig, round: int) -> void: + kill_mob(rig, MOB + 100 * (round + 1)) + drop_item(rig, 7001 + round) + rig.client.ground.clear() + rig.client.inventory.append({"vnum": DROP_VNUM, "count": 1}) + rig.step() + +func resize_all(rig: Rig) -> void: + for i in 3: + if rig.flow.stage == "RESIZE": + rig.advance(600_000) + +func soak_round(rig: Rig, round: int, rest_us := SOAK_REST_US) -> void: + round_to_attack(rig, round) + round_attack_to_pickup(rig, round) + resize_all(rig) + check(rig.flow.stage == "REST" or rig.flow.done, "round %d reaches REST (stage=%s)" % [round, rig.flow.stage]) + rig.advance(rest_us) + +func test_soak_config_contract() -> void: + check("soak" in Config.SUITES, "soak is a known suite") + var cfg := soak_config() + check(Config.validate(cfg).ok and Config.validate_soak(cfg).ok, "complete soak config is accepted: %s" % [Config.validate_soak(cfg).errors]) + var cases := Config.required_cases(cfg, "soak") + for id in ["NET-PICKUP-01", "STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", "STB-DISCONNECT-01", "STB-RESIZE-01"]: + check(cases.has(id), "soak required cases include %s" % id) + check(not cases.has("CBT-strike") and not cases.has("STB-MEMORY-01"), "soak has no skill cases; memory is a runner case") + check(confirmed_warp(soak_config()).soak.warp.status == "confirmed" and Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).ok, + "confirmed warp route and local fault proxy are accepted: %s" % [Config.validate_soak(confirmed_warp(confirmed_faults(soak_config()))).errors]) + var bad := { + "no soak block": func(c: Dictionary) -> void: c.erase("soak"), + "rest below 30s": func(c: Dictionary) -> void: c.soak.rest_seconds = 10, + "duration below 2h": func(c: Dictionary) -> void: c.soak.duration_seconds = 600, + "fractional duration": func(c: Dictionary) -> void: c.soak.duration_seconds = 7200.5, + "fewer than 10 rounds": func(c: Dictionary) -> void: c.soak.min_rounds = 5, + "fewer than 10 reconnects": func(c: Dictionary) -> void: c.soak.reconnects = 3, + "fewer than 10 exits": func(c: Dictionary) -> void: c.soak.exits = 1, + "two resolutions": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1440, 900]], + "duplicate resolution": func(c: Dictionary) -> void: c.soak.resolutions.sizes = [[1280, 720], [1280, 720], [1600, 1000]], + "switches below 10": func(c: Dictionary) -> void: c.soak.resolutions.switches_per_size = 2, + "timeout without soak margin": func(c: Dictionary) -> void: c.timeout_seconds = 7300, + "unknown warp status": func(c: Dictionary) -> void: c.soak.warp.status = "maybe", + "confirmed warp without portal": func(c: Dictionary) -> void: c.soak.warp = {"status": "confirmed", "required": 20}, + "odd warp count": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 21, + "warp below 20": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.required = 10, + "warp to the same map": func(c: Dictionary) -> void: confirmed_warp(c).soak.warp.destination_map_key = c.map_key, + "fault mode other than local proxy": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.mode = "pf_firewall", + "unreachable longer than reconnect UI retries": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.unreachable_seconds = 60, + "credential inside soak": func(c: Dictionary) -> void: c.soak["proxy_password"] = "x", + "confirmed faults without upstream": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.erase("upstream"), + "fault proxy on a non-loopback listen address": func(c: Dictionary) -> void: confirmed_faults(c).server.game_host = "192.0.2.10", + "fault proxy upstream loops back to itself": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream = c.server.duplicate(), + "fault upstream port out of range": func(c: Dictionary) -> void: confirmed_faults(c).soak.faults.upstream.auth_port = 70000, + } + for name in bad: + var broken := soak_config() + bad[name].call(broken) + check(not (Config.validate(broken).ok and Config.validate_soak(broken).ok), "soak config rejects: %s" % name) + var confirmed := confirmed_faults(confirmed_warp(soak_config())) + var confirmed_check := Config.validate_soak(confirmed) + check(Config.validate(confirmed).ok and confirmed_check.ok, "confirmed warp + fault proxy config is valid: %s" % [confirmed_check.errors]) + var exit_run := soak_config() + exit_run.timeout_seconds = 900 + check(not Config.validate_soak(exit_run).ok and Config.validate_soak(exit_run, false).ok, + "the soak timeout margin applies to the soak client only, not to exit runs sharing the scenario") + var exit_run_bad := confirmed_faults(soak_config()) + exit_run_bad.soak.faults.upstream.auth_port = 70000 + check(not Config.validate_soak(exit_run_bad, false).ok, "exit runs still reject an invalid fault proxy upstream") + var live_window = preload("res://testing/playable_window.gd").new(null) + var live_available: Dictionary = live_window.availability() + check(not live_available.ok and "headless" in String(live_available.reason) and live_window.viewport_size() == Vector2i.ZERO, + "live window adapter reports headless as unavailable") + var example := Config.load_file(ProjectSettings.globalize_path("res://../test/playable/scenario.example.json")) + var example_soak: Dictionary = example.config.get("soak", {}) + check(example_soak.get("warp", {}).get("status", "") == "unconfirmed" and example_soak.get("faults", {}).get("status", "") == "unconfirmed", + "example scenario ships an unconfirmed soak block") + +func test_soak_rounds_until_wall_clock_duration() -> void: + var rig := make_soak_rig() + enter_world(rig) + check(rig.flow.stage == "MOVE" and events_of(rig, "soak_round").size() == 1, "soak starts round 0 after the world is ready") + var rounds := 0 + while not rig.flow.done and rounds < 20: + soak_round(rig, rounds) + rounds += 1 + var report: Dictionary = rig.report.finish() + check(rig.flow.done and rounds == 12, "soak ends after the 7200s wall-clock budget, not a fixed round count (rounds=%d)" % rounds) + check(report.failures.is_empty(), "clean soak has no failures (no duplicate case results): %s" % [report.failures]) + for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01", "STB-FRAMES-01"]: + pass + for id in ["NET-MOVE-01", "NET-MOVE-CONFIRM-01", "NET-PICKUP-01", "STB-DURATION-01", "STB-RECONNECT-01", "STB-RESIZE-01"]: + check(case_status(rig, id) == "PASS", "%s passes after 12 rounds (%s)" % [id, finished_case(rig, id)]) + check(case_status(rig, "STB-FRAMES-01") == "BLOCKED" and String(finished_case(rig, "STB-FRAMES-01").reason).find("metrics") >= 0, + "no frame metrics attached -> STB-FRAMES-01 BLOCKED") + check(case_status(rig, "STB-WARP-01") == "BLOCKED" and String(finished_case(rig, "STB-WARP-01").reason).find("not confirmed") >= 0, + "unconfirmed warp route -> STB-WARP-01 BLOCKED") + var disconnect_reason := String(finished_case(rig, "STB-DISCONNECT-01").reason) + check(case_status(rig, "STB-DISCONNECT-01") == "BLOCKED" and disconnect_reason.find("active reconnect") >= 0, + "no confirmed fault condition -> STB-DISCONNECT-01 BLOCKED, active reconnect is no substitute: %s" % disconnect_reason) + check(report.status == "BLOCKED", "required BLOCKED soak cases keep the run from PASS") + check(rig.client.reconnect_calls == 12 and rig.pc.walks.size() == 24, "each round re-walks the route and re-logs once") + var soak: Dictionary = report.get("soak", {}) + check(soak.get("rounds", -1) == 12 and soak.get("active_reconnects", -1) == 12 and soak.get("real_faults", {}) == {"close": 0, "unreachable": 0}, + "soak summary counts active reconnects separately from real faults: %s" % [soak]) + check(soak.get("resizes", {}) == {"1280x720": 11, "1440x900": 12, "1600x1000": 12}, "a size already in effect is not counted as a switch: %s" % [soak.get("resizes", {})]) + check(soak.get("elapsed_seconds", 0.0) >= 7200.0 and String(soak.get("cold_start", "")).find("未知") >= 0, + "summary records elapsed wall-clock seconds and the unknown cold-start cache state") + var rests := events_of(rig, "soak_rest") + check(rests.size() == 24, "each round has a rest start and end event (%d)" % rests.size()) + var ok_rest := true + for i in range(0, rests.size() - 1, 2): + var start: Dictionary = rests[i].payload + var end: Dictionary = rests[i + 1].payload + ok_rest = ok_rest and start.state == "start" and end.state == "end" and start.round == end.round and start.round == i / 2 \ + and int(end.wall_ms) - int(start.wall_ms) >= 30000 + check(ok_rest, "rest windows carry round and unix wall_ms and last at least 30s") + check(rig.window.requests.size() == 36 and events_of(rig, "window_resized").size() == 36, "three logical sizes are requested every round") + var resized: Dictionary = events_of(rig, "window_resized")[1].payload + check(resized.requested_size == [1440.0, 900.0] and resized.logical_size == [1440.0, 900.0] and resized.physical_size == [2880.0, 1800.0] \ + and resized.screen_scale == 2.0, "Retina scale: logical matches the request while physical pixels are recorded: %s" % [resized]) + +func test_soak_duration_is_wall_clock_not_rounds() -> void: + var rig := make_soak_rig() + enter_world(rig) + for round in 12: + soak_round(rig, round, 31_000_000) + check(not rig.flow.done and rig.flow.stage == "MOVE", "12 short rounds do not end a 2h soak") + soak_round(rig, 12, 7_200_000_000) + check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "PASS", "the round that crosses the wall-clock deadline ends the soak") + check(rig.report.finish().soak.rounds == 13, "13 completed rounds recorded") + var short := make_soak_rig() + enter_world(short) + soak_round(short, 0, 7_300_000_000) + check(short.flow.done and case_status(short, "STB-DURATION-01") == "BLOCKED" \ + and String(finished_case(short, "STB-DURATION-01").reason).find("10") >= 0, "duration reached with fewer than min_rounds is BLOCKED") + check(case_status(short, "STB-RECONNECT-01") == "BLOCKED", "one reconnect of ten is BLOCKED, not PASS") + +func test_soak_later_round_failure() -> void: + var rig := make_soak_rig() + enter_world(rig) + soak_round(rig, 0) + soak_round(rig, 1) + round_to_attack(rig, 2) + kill_mob(rig, MOB + 300) + drop_item(rig, 7010) + rig.client.ground.clear() + rig.step() + rig.advance(2_500_000) + var report: Dictionary = rig.report.finish() + check(rig.flow.done and report.status == "FAIL", "a pickup failure in round 2 fails the soak") + check(case_status(rig, "NET-PICKUP-01") == "PASS" and str(report.failures).find("soak round 2") >= 0, + "the round-0 case result stays; the later failure is a run failure naming the round: %s" % [report.failures]) + check(str(report.failures).find("duplicate") < 0, "later rounds never write duplicate case results") + check(case_status(rig, "STB-DURATION-01") == "FAIL", "soak ended early by a failure -> STB-DURATION-01 FAIL") + +func test_soak_later_round_fixture_blocked() -> void: + var rig := make_soak_rig() + enter_world(rig) + soak_round(rig, 0) + walk_route(rig) + confirm_position(rig) + rig.advance(SETTLE_US) + rig.advance(31_000_000) + var report: Dictionary = rig.report.finish() + check(rig.flow.done and report.status == "BLOCKED" and report.failures.is_empty(), "no monster in a later round ends the soak BLOCKED") + check(case_status(rig, "STB-DURATION-01") == "BLOCKED" and String(finished_case(rig, "STB-DURATION-01").reason).find("round 1") >= 0, + "STB-DURATION-01 names the blocked round: %s" % [finished_case(rig, "STB-DURATION-01")]) + +func test_soak_faults_confirmed() -> void: + var rig := make_soak_rig(confirmed_faults(soak_config()), false) + enter_world(rig) + round_to_attack(rig, 0) + round_attack_to_pickup(rig, 0) + check(rig.flow.stage == "FAULT", "confirmed fault condition runs after the pickup") + var requests := events_of(rig, "fault_request") + check(requests.size() == 1 and requests[0].payload.type == "close" and requests[0].payload.attempt == 1, + "fault request is written to events.jsonl for the local proxy: %s" % [requests]) + check(rig.client.reconnect_calls == 1, "a real fault is not produced by calling reconnect()") + rig.client.disconnected.emit("closed by peer") + rig.step() + check(not rig.flow.done and rig.flow.stage == "FAULT", "the requested disconnect is tolerated") + rig.client.entered_game.emit() + rig.step() + rig.advance(SETTLE_US) + check(rig.flow.stage == "REST", "recovered through the production reconnect path, then rests (stage=%s)" % rig.flow.stage) + check(rig.client.reconnect_calls == 1 and events_of(rig, "fault_recovered").size() == 1, "recovery counted without an extra active reconnect") + rig.advance(SOAK_REST_US) + round_to_attack(rig, 1) + round_attack_to_pickup(rig, 1) + check(rig.flow.stage == "FAULT" and events_of(rig, "fault_request")[-1].payload.type == "unreachable", "second fault covers short unreachability") + rig.client.disconnected.emit("connect refused") + rig.client.login_failed.emit("connect refused") + rig.client.disconnected.emit("connect refused") + rig.advance(5_000_000) + rig.client.entered_game.emit() + rig.step() + rig.advance(SETTLE_US) + check(rig.flow.stage == "REST" and not rig.flow.done, "unreachable window with failed retries recovers") + rig.advance(7_300_000_000) + var soak: Dictionary = rig.report.finish().soak + check(soak.real_faults == {"close": 1, "unreachable": 1} and soak.active_reconnects == 2, "real faults and active reconnects are counted apart: %s" % [soak]) + check(case_status(rig, "STB-DISCONNECT-01") == "PASS", "both fault types recovered -> STB-DISCONNECT-01 PASS") + +func test_soak_fault_without_disconnect_fails() -> void: + var rig := make_soak_rig(confirmed_faults(soak_config()), false) + enter_world(rig) + round_to_attack(rig, 0) + round_attack_to_pickup(rig, 0) + rig.advance(31_000_000) + check(rig.flow.done and case_status(rig, "STB-DISCONNECT-01") == "FAIL", "no disconnect observed after a fault request fails") + var late := make_soak_rig(confirmed_faults(soak_config()), false) + enter_world(late) + round_to_attack(late, 0) + round_attack_to_pickup(late, 0) + late.client.disconnected.emit("closed by peer") + late.advance(131_000_000) + check(late.flow.done and case_status(late, "STB-DISCONNECT-01") == "FAIL", "no recovery within the reconnect budget fails") + +func test_soak_warp_confirmed() -> void: + var cfg := confirmed_warp(soak_config()) + var rig := make_soak_rig(cfg, false) + enter_world(rig) + round_to_attack(rig, 0) + round_attack_to_pickup(rig, 0) + check(rig.flow.stage == "WARP", "confirmed warp route runs after the pickup") + var portal := MapCoord.to_world(Vector3(15.0, 0.0, -26.0)) + check(rig.pc.walks[-1].is_equal_approx(portal), "warp walks into the configured portal through PlayerController.walk_to") + rig.app.map_key = cfg.soak.warp.destination_map_key + rig.client.disconnected.emit("warp") + rig.client.entered_game.emit() + rig.step() + rig.advance(SETTLE_US) + check(rig.flow.stage == "WARP" and events_of(rig, "warp_arrived").size() == 1, "arrival in the destination starts the return leg") + rig.app.map_key = cfg.map_key + rig.client.disconnected.emit("warp") + rig.client.entered_game.emit() + rig.step() + rig.advance(SETTLE_US) + check(rig.flow.stage == "REST" and events_of(rig, "warp_arrived").size() == 2, "out-and-back counts two map changes") + rig.advance(7_300_000_000) + var soak: Dictionary = rig.report.finish().soak + check(soak.warps == 2 and case_status(rig, "STB-WARP-01") == "BLOCKED", "2 of 20 warps is BLOCKED, not PASS (%s)" % [soak]) + var wrong := make_soak_rig(confirm_copy(cfg), false) + enter_world(wrong) + round_to_attack(wrong, 0) + round_attack_to_pickup(wrong, 0) + wrong.client.entered_game.emit() + wrong.step() + wrong.advance(SETTLE_US) + check(wrong.flow.done and case_status(wrong, "STB-WARP-01") == "FAIL", "a reconnect that stays on the old map fails the warp") + +func confirm_copy(cfg: Dictionary) -> Dictionary: + return cfg.duplicate(true) + +func test_soak_warp_without_server_reconnect_fails() -> void: + var cfg := confirmed_warp(soak_config()) + var rig := make_soak_rig(cfg, false) + enter_world(rig) + round_to_attack(rig, 0) + round_attack_to_pickup(rig, 0) + rig.app.map_key = cfg.soak.warp.destination_map_key + rig.advance(SETTLE_US) + rig.advance(121_000_000) + check(rig.flow.done and case_status(rig, "STB-WARP-01") == "FAIL", "a map change without a new server connection epoch is not a warp") + +func test_soak_rebind_checks() -> void: + var dup := make_soak_rig() + enter_world(dup) + walk_route(dup) + dup.net_world.add_entity_node(3001) + dup.net_world.add_entity_node(3001) + confirm_position(dup) + dup.advance(SETTLE_US) + check(dup.flow.done and case_status(dup, "STB-RECONNECT-01") == "FAIL" and String(finished_case(dup, "STB-RECONNECT-01").reason).find("duplicate entity") >= 0, + "duplicate entity nodes after re-login fail STB-RECONNECT-01") + var queued := make_soak_rig() + enter_world(queued) + walk_route(queued) + queued.net_world.add_entity_node(3002) + var fading := queued.net_world.add_entity_node(3002) + fading.queue_free() + confirm_position(queued) + queued.advance(SETTLE_US) + check(queued.flow.stage == "TARGET", "a node already queued for deletion is not a duplicate") + var residue := make_soak_rig() + enter_world(residue) + walk_route(residue) + residue.net_play._target_vid = 99999 + confirm_position(residue) + residue.advance(SETTLE_US) + check(residue.flow.done and String(finished_case(residue, "STB-RECONNECT-01").reason).find("target") >= 0, "old target VID kept after re-login fails") + var twice := make_soak_rig() + enter_world(twice) + walk_route(twice) + twice.client.entered_game.emit() + twice.client.entered_game.emit() + twice.step() + check(twice.flow.done and String(finished_case(twice, "STB-RECONNECT-01").reason).find("duplicate entered_game") >= 0, + "two entered_game signals without a disconnect fail as a duplicate signal") + var stray := make_soak_rig() + enter_world(stray) + round_to_attack(stray, 0) + stray.client.entered_game.emit() + check(stray.flow.done and stray.report.finish().status == "FAIL", "an unrequested entered_game during combat fails the soak") + var settling := make_soak_rig() + enter_world(settling) + walk_route(settling) + confirm_position(settling) + check(settling.flow.stage == "MOVE_CONFIRM", "rebind waits for the scene to settle before checking") + settling.advance(SETTLE_US) + check(settling.flow.stage == "TARGET", "settled rebind continues") + +func test_soak_resize_contract() -> void: + var none := make_soak_rig({}, false) + enter_world(none) + soak_round(none, 0, 7_300_000_000) + check(String(finished_case(none, "STB-RESIZE-01").reason).find("window") >= 0 and case_status(none, "STB-RESIZE-01") == "BLOCKED", + "no window adapter -> STB-RESIZE-01 BLOCKED") + var headless := make_soak_rig() + headless.window.supported = false + headless.window.reason = "headless display server has no window" + enter_world(headless) + soak_round(headless, 0, 7_300_000_000) + check(case_status(headless, "STB-RESIZE-01") == "BLOCKED" and String(finished_case(headless, "STB-RESIZE-01").reason).find("headless") >= 0, + "headless window -> BLOCKED with the adapter reason") + var small := make_soak_rig() + small.window.max_logical = Vector2i(1500, 950) + enter_world(small) + soak_round(small, 0, 7_300_000_000) + var reason := String(finished_case(small, "STB-RESIZE-01").reason) + check(case_status(small, "STB-RESIZE-01") == "BLOCKED" and reason.find("1600x1000") >= 0, "a size larger than the usable screen is BLOCKED and named: %s" % reason) + check(events_of(small, "window_size_rejected").size() == 1 and small.report.finish().failures.is_empty(), "screen-limited size is recorded, not a failure") + var stuck := make_soak_rig() + stuck.window.stuck = true + enter_world(stuck) + round_to_attack(stuck, 0) + round_attack_to_pickup(stuck, 0) + stuck.advance(600_000) + stuck.advance(5_100_000) + check(stuck.flow.done and case_status(stuck, "STB-RESIZE-01") == "FAIL", "window that never reaches the requested logical size fails") + +## Push one frame interval tagged with a phase, as playable_live_test does each frame. +func push_frame(metrics: RefCounted, rig: Rig, phase: String, ms: int, marker := "") -> void: + metrics.set_tags({"phase": phase, "map": "m", "actor_count": "1-8", "gpu_skin": false}) + if not marker.is_empty(): + metrics.mark(marker, {}) + metrics.add_interval_us(ms * 1000, rig.now) + +func metrics_rig() -> Array: + var rig := make_soak_rig() + var metrics: RefCounted = Metrics.new() + metrics.clock_us = func() -> int: return rig.now + metrics.configure({"window_seconds": 3600}) + rig.flow.metrics = metrics + return [rig, metrics] + +func test_soak_frames_gate() -> void: + var pair := metrics_rig() + var rig: Rig = pair[0] + var metrics: RefCounted = pair[1] + enter_world(rig) + for round in 3: + round_to_attack(rig, round) + check(rig.flow.stage == "ATTACK", "round %d attacking" % round) + if round != 1: + push_frame(metrics, rig, "attack", 150, "model_build") + push_frame(metrics, rig, "loading", 400) + round_attack_to_pickup(rig, round) + resize_all(rig) + rig.advance(SOAK_REST_US if round < 2 else 7_300_000_000) + var frames := finished_case(rig, "STB-FRAMES-01") + check(frames.get("status", "") == "FAIL" and String(frames.reason).find("attack") >= 0, + "attack with >100ms frames in 2 of 3 consecutive rounds fails STB-FRAMES-01: %s" % [frames]) + var pair2 := metrics_rig() + var clean: Rig = pair2[0] + var clean_metrics: RefCounted = pair2[1] + enter_world(clean) + for round in 3: + round_to_attack(clean, round) + push_frame(clean_metrics, clean, "loading", 400) + push_frame(clean_metrics, clean, "attack", 150 if round == 0 else 16, "model_build") + round_attack_to_pickup(clean, round) + resize_all(clean) + clean.advance(SOAK_REST_US if round < 2 else 7_300_000_000) + check(case_status(clean, "STB-FRAMES-01") == "PASS", "attributed single severe frame and loading frames pass: %s" % [finished_case(clean, "STB-FRAMES-01")]) + var pair3 := metrics_rig() + var loose: Rig = pair3[0] + var loose_metrics: RefCounted = pair3[1] + enter_world(loose) + round_to_attack(loose, 0) + push_frame(loose_metrics, loose, "attack", 60) + round_attack_to_pickup(loose, 0) + resize_all(loose) + loose.advance(7_300_000_000) + var loose_case := finished_case(loose, "STB-FRAMES-01") + check(loose_case.get("status", "") == "BLOCKED" and String(loose_case.reason).find("attribution") >= 0, + "a gameplay frame >50ms without attribution keeps STB-FRAMES-01 BLOCKED: %s" % [loose_case]) + +func test_soak_gameplay_not_allowed() -> void: + var rig := make_rig("soak", false, soak_config()) + enter_world(rig) + check(rig.flow.done and case_status(rig, "STB-DURATION-01") == "BLOCKED" and rig.pc.walks.is_empty(), "soak without allow_gameplay is BLOCKED with no input") diff --git a/project/playable_live_test.gd b/project/playable_live_test.gd index b8fc503b..d3de9de9 100644 --- a/project/playable_live_test.gd +++ b/project/playable_live_test.gd @@ -1,373 +1,181 @@ extends Node -## 首个 Mac 内测的真实联网闭环。 +## 首个 Mac 内测的真实联网闭环(NET-01 / CBT-01)。 ## -## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController, -## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT -## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只做登录/进场 -## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。 +## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、 +## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经 +## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场。 +## +## env: +## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据) +## MT_PLAYABLE_SUITE playable|full|soak(soak = STB-01 墙钟长跑,挂帧指标与窗口适配器) +## MT_PLAYABLE_RUN_ID 父脚本生成的 run_id +## MT_PLAYABLE_ALLOW_GAMEPLAY 1 = 允许移动/攻击/拾取/施法 +## MT_TEST_REPORT client-report.json 路径 +## MT_TEST_EVENTS events.jsonl 路径(默认与报告同目录) +## MT_TEST_OUTPUT 失败截图目录(默认与报告同目录) +## MTGODOT_GPUSKIN 仅作为帧指标分段标签 gpu_skin 记录 const Config = preload("res://testing/playable_config.gd") const Report = preload("res://testing/playable_report.gd") const Probe = preload("res://testing/playable_probe.gd") +const Flow = preload("res://testing/playable_flow.gd") +const Metrics = preload("res://testing/playable_metrics.gd") +const PlayableWindow = preload("res://testing/playable_window.gd") +## Kinds that do heavy work on the main thread; a long frame right after one is attributed to it. +const MARKED_KINDS := ["entered_game", "disconnected", "entity_spawned", "ground_item_added"] -const WAIT_WORLD_SECONDS := 60.0 -const MOVE_SECONDS := 20.0 -const TARGET_SECONDS := 30.0 -const ATTACK_SECONDS := 90.0 -const DROP_SECONDS := 30.0 -const PICKUP_SECONDS := 15.0 - -var _flow: Node -var _client: Node -var _config: Dictionary +var _app_flow: Node +var _client: Object var _report: RefCounted var _probe: Node +var _machine: RefCounted var _report_path := "" -var _events_path := "" -var _state := "boot" -var _state_since := 0.0 -var _started := 0.0 +var _output_dir := "" +var _watched_quickbar: Object +var _watched_fx: Object var _finished := false -var _allow_gameplay := false -var _entered_count := 0 -var _main_vid := 0 -var _server_move_seen := false -var _waypoint_index := 0 -var _expected_position := Vector3.ZERO -var _target_vid := 0 -var _target_initial_hp := -1 -var _target_damaged := false -var _target_dead := false -var _drop_vid := 0 -var _drop_vnum := 0 -var _pickup_before := 0 -var _cases := {} +var _metrics: RefCounted +var _tick_us := 0 +var _tick_max_us := 0 +var _ticks := 0 +var _memory_start := {} -func setup(flow: Node, m2client: Node, config_path := "") -> void: - _flow = flow +func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void: + _app_flow = app_flow _client = m2client - _started = Time.get_ticks_msec() / 1000.0 - _state_since = _started - _allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1" _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges() if _report_path.is_empty(): _report_path = "user://playable-client-report.json" - _events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges() - if _events_path.is_empty(): - _events_path = _report_path.get_base_dir().path_join("events.jsonl") - var path := config_path if not config_path.is_empty() else OS.get_environment("MT_PLAYABLE_CONFIG") - var loaded := Config.load_file(path) + var events_path := OS.get_environment("MT_TEST_EVENTS").strip_edges() + if events_path.is_empty(): + events_path = _report_path.get_base_dir().path_join("events.jsonl") + _output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges() + if _output_dir.is_empty(): + _output_dir = _report_path.get_base_dir() + var suite := OS.get_environment("MT_PLAYABLE_SUITE").strip_edges() + if suite.is_empty(): + suite = "playable" + var errors: Array = [] + if config.is_empty(): + var loaded := Config.load_file(OS.get_environment("MT_PLAYABLE_CONFIG")) + config = loaded.config + errors = loaded.errors + else: + errors = Config.validate(config).errors + if not (suite in Config.SUITES): + errors.append("unknown suite") _report = Report.new() - _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {}) - if not loaded.ok: - _case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors)) + _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path) + if not errors.is_empty(): + _report.set_required_cases(["CONFIG-01"]) + _report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors)) _finish_later() return - _config = loaded.config - _case("CONFIG-01", "PASS", "validated") if _client == null: - _case("NET-BOOT-01", "FAIL", "M2Client was not created") + _report.set_required_cases(Config.required_cases(config, suite)) + _report.add_case("CONFIG-01", "FAIL", "M2Client was not created") _finish_later() return _probe = Probe.new() _probe.name = "PlayableProbe" add_child(_probe) - _probe.setup(_flow, _client) - _probe.observed.connect(_on_observed) - _bind("char_list", Callable(self, "_on_char_list")) - _bind("entered_game", Callable(self, "_on_entered_game")) - _bind("disconnected", Callable(self, "_on_disconnected")) - _bind("entity_moved", Callable(self, "_on_entity_moved")) - _bind("damage", Callable(self, "_on_damage")) - _bind("entity_dead", Callable(self, "_on_entity_dead")) - _bind("ground_item_added", Callable(self, "_on_ground_item_added")) - _bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) - _bind("item_picked_up", Callable(self, "_on_item_picked_up")) - _bind("inventory_changed", Callable(self, "_on_inventory_changed")) - _set_state("WAIT_LOGIN") - _record("harness_started", {"allow_gameplay": _allow_gameplay}) + _probe.setup(_app_flow, _client) + _machine = Flow.new() + _machine.report = _report + _machine.probe = _probe + _machine.flow = _app_flow + _machine.client = _client + _machine.config = config + _machine.suite = suite + _machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1" + _machine.capture = Callable(self, "_capture") + _machine.finished.connect(_on_machine_finished) + _probe.observed.connect(_machine.on_observed) + if suite == "soak": + _metrics = Metrics.new() + _metrics.configure({"window_seconds": 60}) + _machine.metrics = _metrics + _machine.window = PlayableWindow.new(get_viewport()) + _memory_start = Metrics.memory_snapshot() + _probe.observed.connect(_mark_observed) + _machine.start() func _process(_delta: float) -> void: - if _finished: + if _machine == null or _finished: return - var now := Time.get_ticks_msec() / 1000.0 - if now - _started > float(_config.get("timeout_seconds", 120)): - _fail_current("overall timeout in state %s" % _state) - _finish_later() + _watch_scene_signals() + if _metrics != null: + var tags: Dictionary = _machine.frame_tags() + tags["gpu_skin"] = OS.get_environment("MTGODOT_GPUSKIN").strip_edges() + _metrics.set_tags(tags) + _metrics.frame() + var started := Time.get_ticks_usec() + _machine.tick() + var spent := Time.get_ticks_usec() - started + _tick_us += spent + _tick_max_us = maxi(_tick_max_us, spent) + _ticks += 1 return - match _state: - "WAIT_LOGIN": - if _entered_count > 0: - _set_state("WAIT_WORLD") - elif now - _state_since > 30.0: - _fail_case("NET-LOGIN-01", "login/character selection timed out") - _finish_later() - "WAIT_WORLD": - var snap: Dictionary = _flow.get_playable_snapshot() if _flow and _flow.has_method("get_playable_snapshot") else {} - if bool(snap.get("scene_ready", false)): - _case("NET-LOGIN-01", "PASS", "character list received") - _case("NET-SELECT-01", "PASS", "entered_game received") - _case("NET-WORLD-01", "PASS", "game scene and map are ready") - _main_vid = int(_client.get_main_vid()) if _client.has_method("get_main_vid") else 0 - if _main_vid <= 0: - _fail_case("NET-WORLD-02", "main VID is invalid") - _finish_later() - elif not _allow_gameplay: - _case("GAMEPLAY-ALLOW-01", "BLOCKED", "--allow-gameplay was not supplied") - _finish_later() - else: - _begin_move() - elif now - _state_since > WAIT_WORLD_SECONDS: - _fail_case("NET-WORLD-01", "scene did not become ready") - _finish_later() - "MOVE": - if _move_reached(): - if _server_move_seen: - _case("NET-MOVE-%02d" % (_waypoint_index + 1), "PASS", "server entity_moved observed") - _waypoint_index += 1 - if _waypoint_index >= _config.waypoints_cm.size(): - _begin_target() - else: - _send_waypoint() - else: - _block_current("local arrival without server movement evidence") - _finish_later() - elif now - _state_since > MOVE_SECONDS: - _fail_current("waypoint not reached") - _finish_later() - "TARGET": - if _target_vid > 0: - _case("NET-TARGET-01", "PASS", "allowed live monster selected") - _begin_attack() - elif now - _state_since > TARGET_SECONDS: - _fail_case("NET-TARGET-01", "no allowed live monster found") - _finish_later() - "ATTACK": - if _target_dead or (_target_damaged and _target_hp_decreased()): - if _client.has_method("get_entity") and not _client.get_entity(_target_vid).is_empty(): - _case("NET-ATTACK-01", "PASS", "server damage/death evidence observed") - else: - _block_current("target result arrived after entity removal") - _stop_attack() - _set_state("DROP") - elif now - _state_since > ATTACK_SECONDS: - _stop_attack() - _fail_case("NET-ATTACK-01", "no server damage or death result") - _finish_later() - "DROP": - if _drop_vid > 0: - _case("NET-DROP-01", "PASS", "allowed ground item added by server") - _begin_pickup() - elif now - _state_since > DROP_SECONDS: - _block_current("deterministic drop fixture was not observed") - _finish_later() - "PICKUP": - if _pickup_succeeded(): - _case("NET-PICKUP-01", "PASS", "item_picked_up and inventory delta observed") - _case("NET-EXIT-01", "PASS", "client test state completed") - _finish_later() - elif now - _state_since > PICKUP_SECONDS: - _fail_case("NET-PICKUP-01", "pickup result did not include inventory delta") - _finish_later() + _machine.tick() -func _bind(signal_name: String, callback: Callable) -> void: - if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback): - _client.connect(signal_name, callback) +func _mark_observed(kind: String, _data: Dictionary) -> void: + if kind in MARKED_KINDS: + _metrics.mark(kind) -func _set_state(next: String) -> void: - _state = next - _state_since = Time.get_ticks_msec() / 1000.0 - _record("state", {"name": next}) - -func _on_observed(kind: String, payload: Dictionary) -> void: - _record(kind, payload) - -func _record(kind: String, payload := {}) -> void: - if _report == null: +## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily. +func _watch_scene_signals() -> void: + if _app_flow == null or not _app_flow.has_method("get_playable_context"): return - var data: Dictionary = payload.duplicate(true) - data["monotonic_us"] = Time.get_ticks_usec() - data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID") - data["case_id"] = _state - _report.add_event(data, _events_path) + var context: Dictionary = _app_flow.get_playable_context() + var quickbar: Object = context.get("quickbar", null) + if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar: + _watched_quickbar = quickbar + _probe.watch_local(quickbar, "skill_cast_started", "cast_started") + _probe.watch_local(quickbar, "skill_rejected", "skill_rejected") + # EffectRegistry spawn / tree-exit boundaries (CBT-01 effect lifetime evidence). + var registry: Object = context.get("fx", null) + if registry != null and registry != _watched_fx: + _watched_fx = registry + _probe.watch_local(registry, "fx_spawned", "fx_spawned") + _probe.watch_local(registry, "fx_finished", "fx_finished") -func _on_char_list(characters: Array) -> void: - _record("char_list", {"count": characters.size()}) - var slot := int(_config.get("character_slot", -1)) - var found := false - for character in characters: - if int(character.get("index", -1)) == slot: - found = true - break - if not found: - _fail_case("NET-LOGIN-01", "configured character slot is absent") - _finish_later() +func _capture(case_id: String) -> String: + if DisplayServer.get_name() == "headless" or _output_dir.is_empty(): + return "" + var image := get_viewport().get_texture().get_image() + if image == null or image.is_empty(): + return "" + var file_name := "%s.png" % case_id.to_lower() + if image.save_png(_output_dir.path_join(file_name)) != OK: + return "" + return file_name -func _on_entered_game() -> void: - _entered_count += 1 - _record("entered_game", {"count": _entered_count}) - -func _on_disconnected(reason: String) -> void: - _record("disconnected", {"reason": reason}) - if _state != "WAIT_LOGIN" and _state != "WAIT_WORLD": - _fail_current("unexpected disconnect: %s" % reason) - _finish_later() - -func _on_entity_moved(vid: int) -> void: - if vid == _main_vid: - _server_move_seen = true - _record("main_entity_moved", {"vid": vid}) - -func _on_damage(vid: int, amount: int, flag: int) -> void: - if vid == _target_vid and amount > 0: - _target_damaged = true - _record("target_damage", {"vid": vid, "amount": amount, "flag": flag}) - -func _on_entity_dead(vid: int) -> void: - if vid == _target_vid: - _target_dead = true - _record("target_dead", {"vid": vid}) - -func _on_ground_item_added(item: Dictionary) -> void: - var vnum := int(item.get("vnum", 0)) - if _state == "DROP" and vnum in _config.get("allowed_drop_vnums", []): - _drop_vid = int(item.get("vid", 0)) - _drop_vnum = vnum - _record("allowed_drop", {"vid": _drop_vid, "vnum": vnum}) - -func _on_ground_item_removed(vid: int) -> void: - _record("ground_item_removed", {"vid": vid}) - -func _on_item_picked_up(vnum: int, count: int, source: String) -> void: - if _state == "PICKUP" and vnum == _drop_vnum: - _record("item_picked_up", {"vnum": vnum, "count": count, "source": source}) - -func _on_inventory_changed(window: int, cell: int) -> void: - _record("inventory_changed", {"window": window, "cell": cell}) - -func _begin_move() -> void: - _waypoint_index = 0 - _send_waypoint() - -func _send_waypoint() -> void: - var point: Array = _config.waypoints_cm[_waypoint_index] - var net := Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01) - _expected_position = MapCoord.to_world(net) - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var controller: Node = context.get("pc", null) - _server_move_seen = false - if controller == null or not controller.has_method("walk_to"): - _fail_case("NET-MOVE-%02d" % (_waypoint_index + 1), "PlayerController.walk_to is unavailable") - _finish_later() - return - controller.walk_to(_expected_position) - _set_state("MOVE") - _record("move_requested", {"waypoint_index": _waypoint_index, "x_cm": point[0], "y_cm": point[1]}) - -func _move_reached() -> bool: - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var player: Node3D = context.get("player", null) - return is_instance_valid(player) and player.global_position.distance_to(_expected_position) <= 1.0 - -func _begin_target() -> void: - _target_vid = 0 - var allowed: Array = _config.get("allowed_mob_vnums", []) - var best_distance := INF - var player_pos := Vector3.ZERO - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var player: Node3D = context.get("player", null) - if is_instance_valid(player): - player_pos = player.global_position - for entity in _client.get_entities(): - var race := int(entity.get("race", 0)) - var kind := int(entity.get("kind", entity.get("ch_type", -1))) - if race not in allowed or bool(entity.get("dead", false)) or kind != 2: - continue - var pos: Variant = entity.get("pos", null) - var distance := 0.0 - if pos is Vector3: - distance = player_pos.distance_to(MapCoord.to_world(pos)) - if distance < best_distance: - best_distance = distance - _target_vid = int(entity.get("vid", 0)) - if _target_vid > 0: - var net_play: Node = context.get("net_play", null) - if net_play == null or not net_play.has_method("select_target") or not net_play.select_target(_target_vid): - _target_vid = 0 - _set_state("TARGET") - -func _begin_attack() -> void: - var entity: Dictionary = _client.get_entity(_target_vid) - _target_initial_hp = int(entity.get("hp", -1)) - _target_damaged = false - _target_dead = false - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var net_play: Node = context.get("net_play", null) - if net_play == null or not net_play.has_method("set_attack_key"): - _fail_case("NET-ATTACK-01", "NetPlay.set_attack_key is unavailable") - _finish_later() - return - net_play.set_attack_key(true) - _set_state("ATTACK") - -func _stop_attack() -> void: - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var net_play: Node = context.get("net_play", null) - if net_play and net_play.has_method("set_attack_key"): - net_play.set_attack_key(false) - -func _target_hp_decreased() -> bool: - if not _client.has_method("get_entity"): - return false - var e: Dictionary = _client.get_entity(_target_vid) - return not e.is_empty() and _target_initial_hp > 0 and int(e.get("hp", _target_initial_hp)) < _target_initial_hp - -func _begin_pickup() -> void: - _pickup_before = _inventory_count(_drop_vnum) - var context: Dictionary = _flow.get_playable_context() if _flow.has_method("get_playable_context") else {} - var net_play: Node = context.get("net_play", null) - if net_play == null or not net_play.has_method("pick_ground_item"): - _fail_case("NET-PICKUP-01", "NetPlay.pick_ground_item is unavailable") - _finish_later() - return - net_play.pick_ground_item(_drop_vid) - _set_state("PICKUP") - -func _inventory_count(vnum: int) -> int: - if not _client.has_method("get_inventory"): - return -1 - var total := 0 - for item in _client.get_inventory(): - if int(item.get("vnum", 0)) == vnum: - total += int(item.get("count", 0)) - return total - -func _pickup_succeeded() -> bool: - return _drop_vnum > 0 and _inventory_count(_drop_vnum) > _pickup_before - -func _case(case_id: String, status: String, reason: String) -> void: - if _cases.has(case_id): - return - _cases[case_id] = true - _report.add_case(case_id, status, reason) - -func _fail_case(case_id: String, reason: String) -> void: - _case(case_id, "FAIL", reason) - -func _block_current(reason: String) -> void: - _case("BLOCK-%s" % _state, "BLOCKED", reason) - -func _fail_current(reason: String) -> void: - _fail_case("FAIL-%s" % _state, reason) +func _on_machine_finished(_status: String) -> void: + _finish_later() func _finish_later() -> void: if _finished: return _finished = true - _stop_attack() - _report.write(_report_path) - call_deferred("_quit") + call_deferred("_write_and_quit") -func _quit() -> void: +func _write_and_quit() -> void: + if _machine != null: + _machine.cancel_inputs() + if _metrics != null: + _report.set_section("frame_metrics", _metrics.summary()) + _report.set_section("client_memory", {"start": _memory_start, "end": Metrics.memory_snapshot(), + "note": "MEMORY_STATIC 是 Godot 分配器计数,不是进程 RSS;RSS 由父运行器按 PID 外部采样"}) + _report.set_section("probe_overhead", {"sampler": Metrics.measure_overhead(), + "flow_tick": {"ticks": _ticks, "total_us": _tick_us, "max_us": _tick_max_us, + "mean_us": float(_tick_us) / maxf(1.0, _ticks)}}) + if _probe != null: + _probe.disconnect_all() + if not _report.write(_report_path): + printerr("PLAYABLE: cannot write client report") + get_tree().quit(1) + return + print("PLAYABLE CLIENT: %s" % _report.finish().status) + # Normal quit path: the parent runner owns the final exit gate. get_tree().quit(0) diff --git a/project/playable_metrics_test.gd b/project/playable_metrics_test.gd new file mode 100644 index 00000000..f9eed695 --- /dev/null +++ b/project/playable_metrics_test.gd @@ -0,0 +1,136 @@ +# playable_metrics_test —— STB-01 §9.1 帧间隔/长帧指标的离线自检(注入时钟,不等真实时间)。 +# godot --headless --path project --script playable_metrics_test.gd +extends SceneTree + +const Metrics = preload("res://testing/playable_metrics.gd") + +var _fail := 0 +var _done := false +var _now := 0 + +func _ck(ok: bool, message: String) -> void: + if not ok: + _fail += 1 + printerr("FAIL: " + message) + +func _init() -> void: + _run() + _ck(_done, "all metrics assertions executed") + print("playable_metrics_test: failures=%d" % _fail) + if _fail == 0: + print("PASS: playable_metrics_test") + quit(1 if _fail else 0) + +func _metrics(options := {}) -> RefCounted: + var m: RefCounted = Metrics.new() + m.clock_us = func() -> int: return _now + m.configure(options) + return m + +## Advance the injected clock by `ms` and close one frame. +func _frame(m: RefCounted, ms: float) -> void: + _now += int(ms * 1000.0) + m.frame() + +func _run() -> void: + # Percentiles are nearest-rank over the window samples; max and counts are exact. + var p: RefCounted = _metrics({"window_seconds": 3600}) + p.set_tags({"map": "m", "phase": "play", "actor_count": 8, "gpu_skin": true}) + p.frame() + for i in range(1, 101): + _frame(p, float(i)) + var s: Dictionary = p.summary() + var w: Dictionary = s.windows[0] + _ck(w.count == 100 and w.sampled == 100 and not w.truncated, "one window with 100 samples: %s" % [w]) + _ck(w.p50_ms == 50.0 and w.p95_ms == 95.0 and w.p99_ms == 99.0 and w.max_ms == 100.0, + "nearest-rank p50/p95/p99/max: %s" % [w]) + _ck(w.over_50ms == 50 and w.over_100ms == 0, ">50ms and >100ms are strict thresholds: %s" % [w]) + _ck(s.print_frames == false and p.print_frames == false, "per-frame printing is off by default") + _ck(s.long_frames.size() == 50 and s.long_frames[0].ms == 51.0, "only samples above 50ms become long-frame events") + _ck(s.long_frames[0].tags == {"map": "m", "phase": "play", "actor_count": "8", "gpu_skin": "true"}, + "long frames carry map/phase/actor_count/gpu_skin tags: %s" % [s.long_frames[0].tags]) + _ck(s.frames == 100, "first frame() only arms the clock") + + # Fixed-size ring: a window longer than the capacity keeps exact counters but + # bounded sample storage, and says so. + var ring: RefCounted = _metrics({"capacity": 64, "window_seconds": 3600, "max_long_frames": 8, "max_markers": 4}) + ring.set_tags({"phase": "play"}) + ring.frame() + for i in 10000: + _frame(ring, 120.0 if i == 17 else (60.0 if i % 1000 == 0 else 16.0)) + if i % 100 == 0: + ring.mark("entity_batch", {"count": i}) + _ck(ring.stored_sample_count() <= 64, "ring buffer never grows past capacity (%d)" % ring.stored_sample_count()) + var rs: Dictionary = ring.summary() + var rw: Dictionary = rs.windows[0] + _ck(rw.count == 10000 and rw.sampled == 64 and rw.truncated, "overlong window is marked truncated: %s" % [rw]) + _ck(rw.max_ms == 120.0 and rw.over_100ms == 1 and rw.over_50ms == 11, "counters stay exact beyond capacity: %s" % [rw]) + _ck(rs.long_frames.size() == 8 and rs.long_frames_dropped == 3, "long-frame events are bounded with a drop count") + _ck(rs.markers.size() == 4 and rs.markers_dropped == 96, "markers are bounded with a drop count") + _ck(ring.long_unattributed_by_phase() == {"play": 11} and rs.long_frames.size() == 8, + "unattributed counts stay exact while the long-frame list is bounded: %s" % [ring.long_unattributed_by_phase()]) + + # Wall-clock windows close on the injected clock, not on a frame count. + var timed: RefCounted = _metrics({"window_seconds": 1, "max_windows": 3}) + timed.set_tags({"phase": "play"}) + timed.frame() + for i in 400: + _frame(timed, 16.0) + var ts: Dictionary = timed.summary() + _ck(ts.windows.size() == 3 and ts.windows_dropped >= 3, "closed windows are bounded (%d kept, %d dropped)" % [ts.windows.size(), ts.windows_dropped]) + _ck(ts.windows.all(func(x: Dictionary) -> bool: return x.end_ms - x.start_ms <= 1016), "each window spans about one wall-clock second") + + # A tag change closes the window and opens a separate segment; segments are capped. + var seg: RefCounted = _metrics({"max_segments": 2, "window_seconds": 3600}) + seg.set_tags({"phase": "loading", "map": "a"}) + seg.frame() + _frame(seg, 300.0) + seg.set_tags({"phase": "play", "map": "a"}) + _frame(seg, 16.0) + seg.set_tags({"phase": "play", "map": "b"}) + _frame(seg, 16.0) + var ss: Dictionary = seg.summary() + _ck(ss.windows.size() == 3 and ss.windows[0].tags.phase == "loading", "tag changes split windows: %s" % [ss.windows]) + _ck(ss.segments.size() == 2 and ss.unsegmented_frames == 1, "segment table is capped: %s" % [ss.segments]) + _ck(ss.segments[0].max_ms == 300.0 and ss.segments[0].over_100ms == 1, "loading segment is reported separately") + _ck(seg.severe_by_phase() == {"loading": 1}, "severe counts by phase: %s" % [seg.severe_by_phase()]) + + # Markers from the frame that was measured (and the one before it) attribute a long frame. + var att: RefCounted = _metrics({"window_seconds": 3600}) + att.set_tags({"phase": "play"}) + att.frame() + _frame(att, 16.0) + att.mark("model_build", {"race": 2301, "ms": 70.0}) + _frame(att, 16.0) + _frame(att, 80.0) + _frame(att, 16.0) + _frame(att, 16.0) + _frame(att, 75.0) + var at: Dictionary = att.summary() + _ck(at.long_frames.size() == 2 and at.long_frames[0].markers == ["model_build"], "long frame attributed to the recent marker: %s" % [at.long_frames]) + _ck(at.long_frames[1].markers.is_empty(), "later long frame without a marker stays unattributed") + _ck(att.unattributed_long_frames().size() == 1, "unattributed long frames are listed for analysis") + _ck(att.long_unattributed_by_phase() == {"play": 1} and at.long_unattributed_by_phase == {"play": 1}, + "unattributed long frames are counted per phase: %s" % [att.long_unattributed_by_phase()]) + _ck(at.markers[0].detail == {"race": 2301, "ms": 70.0}, "marker detail keeps only whitelisted keys") + att.mark("model_build", {"name": "Hero", "count": 2}) + _ck(not att.summary().markers[-1].detail.has("name"), "non-whitelisted marker detail is dropped") + + _ck([0, 1, 8, 9, 16, 17, 64, 65].map(func(n: int) -> String: return Metrics.actor_bucket(n)) + == ["0", "1-8", "1-8", "9-16", "9-16", "17-32", "33-64", "65+"], "actor counts are bucketed") + + # §9.3: the same interaction repeated 3 times with >100ms in at least 2 blocks. + _ck(Metrics.repeated_severe({"ATTACK": [true, false, true], "MOVE": [true, false, false, true], "DROP": [false, true, true]}) + == ["ATTACK", "DROP"], "2-of-3 consecutive repetitions with a >100ms frame are flagged") + _ck(Metrics.repeated_severe({"ATTACK": [true, true]}).is_empty(), "fewer than 3 repetitions cannot be judged yet") + + # Memory: MEMORY_STATIC is recorded; GPU memory is never reported as 0. + var mem: Dictionary = Metrics.memory_snapshot() + _ck(mem.memory_static_kib is int and mem.memory_static_kib > 0, "MEMORY_STATIC in KiB: %s" % [mem]) + _ck(mem.gpu_memory_kib == null and mem.gpu_memory_status == "unavailable", "GPU memory is null/unavailable") + _ck(mem.godot_video_mem_kib == null or int(mem.godot_video_mem_kib) > 0, "Godot video allocation is null when unknown, not 0") + + # Probe cost is measured, not assumed. + var cost: Dictionary = Metrics.measure_overhead(2000) + _ck(cost.iterations == 2000 and cost.per_sample_us > 0.0 and cost.per_sample_us < 1000.0, "per-sample probe cost measured: %s" % [cost]) + _done = true diff --git a/project/playable_skill_matrix.gd b/project/playable_skill_matrix.gd new file mode 100644 index 00000000..d2d6b243 --- /dev/null +++ b/project/playable_skill_matrix.gd @@ -0,0 +1,64 @@ +extends SceneTree + +## CBT-01 技能矩阵检查:读取 test/playable/skill-cases.json(或 --matrix 指定的本地副本), +## 按职业校验并输出可直接放入场景配置的 skill_cases。只读数据,不连网络、不读凭据。 +## +## godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR [--matrix PATH] +## +## stdout: 一行 "SKILL_MATRIX {json}"。exit 0 = READY,1 = 矩阵错误,2 = 存在未确认条目(BLOCKED)。 + +const Config = preload("res://testing/playable_config.gd") +const SkillTable = preload("res://ui/skill_table.gd") + +func _init() -> void: + var args := OS.get_cmdline_user_args() + var job := "" + var matrix_path := ProjectSettings.globalize_path("res://").path_join("../test/playable/skill-cases.json").simplify_path() + var i := 0 + while i < args.size(): + match args[i]: + "--job": + i += 1 + job = String(args[i]).to_upper() if i < args.size() else "" + "--matrix": + i += 1 + matrix_path = args[i] if i < args.size() else "" + "--help": + print("usage: playable_skill_matrix.gd -- --job WARRIOR|ASSASSIN|SURA|SHAMAN [--matrix PATH]") + quit(0) + return + _: + printerr("SKILL_MATRIX: unknown argument %s" % args[i]) + quit(1) + return + i += 1 + var result := check(matrix_path, job) + print("SKILL_MATRIX " + JSON.stringify(result)) + quit({"READY": 0, "BLOCKED": 2}.get(result.status, 1)) + +static func check(matrix_path: String, job: String) -> Dictionary: + var out := {"status": "FAIL", "job": job, "errors": [], "blocked": [], "not_applicable": [], "skill_cases": []} + var file := FileAccess.open(matrix_path, FileAccess.READ) + if file == null: + out.errors.append("skill matrix cannot be opened") + return out + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + out.errors.append("skill matrix is not a JSON object") + return out + var table := SkillTable.new() + var loaded := false + for lang in ["en", "common"]: + if table.load_file(AssetRoot.path().path_join("locale/locale/%s/skilldesc.txt" % lang)): + loaded = true + break + var resolved := Config.skill_matrix_cases(parsed, job, table if loaded else null) + out.errors = resolved.errors + if not loaded: + out.errors.append("skilldesc.txt is unavailable; skill ownership cannot be checked") + out.blocked = resolved.blocked + out.not_applicable = resolved.not_applicable + out.skill_cases = resolved.cases + if out.errors.is_empty(): + out.status = "BLOCKED" if not out.blocked.is_empty() else "READY" + return out diff --git a/project/player_skill.gd b/project/player_skill.gd index c8455cbb..3f693a91 100644 --- a/project/player_skill.gd +++ b/project/player_skill.gd @@ -56,6 +56,9 @@ var arrow_count := -1 # 装备箭袋数量;-1 未知/无限, var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000) var cur_sp := -1 # GetStatus(POINT_SP);-1 = 未知(不判 NOT_ENOUGH_SP) var cur_hp := -1 # GetStatus(POINT_HP);-1 = 未知(不判 NOT_ENOUGH_HP) +var bow_distance := 0 # GetStatus(POINT_BOW_DISTANCE) —— __GetSkillTargetRange 加成 +## vid -> 主角到目标的距离 cm(已含 IS_HUGE_RACE −200 修正);<0 = 未知。未注入则不判射程。 +var target_distance := Callable() func setup(m2client: Object, skill_table: RefCounted) -> void: client = m2client @@ -69,6 +72,24 @@ func _now() -> float: func _ok(target_vid := 0) -> Dictionary: return {"ok": true, "code": "OK", "target_vid": target_vid} +# __GetSkillTargetRange —— PythonPlayerSkill.cpp:390 +func _skill_target_range(skill_id: int) -> int: + return int(table.target_range(skill_id)) + bow_distance * 100 + +# 射程外:不施法,交给调用方 __ReserveUseSkill(走近后再 __UseSkill)。 +# charge = 参考端「蓄力技且未预约时先 __SendUseSkill(slot, 0)」。 +func _out_of_range(skill_id: int, tvid: int) -> Dictionary: + var range_cm := _skill_target_range(skill_id) + if range_cm <= 0 or not target_distance.is_valid(): + return {} + var distance := float(target_distance.call(tvid)) + if distance < 0.0 or distance < float(range_cm): + return {} + var r := _no("RESERVED", tvid) + r["range_cm"] = range_cm + r["charge"] = bool(table.is_charge_skill(skill_id)) + return r + func _no(code: String, target_vid := 0) -> Dictionary: return {"ok": false, "code": code, "target_vid": target_vid} @@ -358,8 +379,8 @@ func use_skill(skill_id: int, is_active := false) -> Dictionary: return _resolve_target(skill_id) # __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。 -# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9, -# 这里只判目标「类型」是否合法并回传解析到的 vid。 +# 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge), +# 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9。 func _resolve_target(skill_id: int) -> Dictionary: var main_vid := _main_vid() var tvid := 0 @@ -389,6 +410,11 @@ func _resolve_target(skill_id: int) -> Dictionary: return _ok(main_vid) # 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法 if _entity_kind(te) == 0 and not _is_attackable(te): + # PythonPlayerSkill.cpp:560:友方 PC 在射程外 -> __ReserveUseSkill(无蓄力预发)。 + var far := _out_of_range(skill_id, tvid) + if not far.is_empty(): + far["charge"] = false + return far return _ok(tvid) if table.can_use_for_me(skill_id): return _ok(main_vid) # 目标非法 -> 回落自身 @@ -398,6 +424,10 @@ func _resolve_target(skill_id: int) -> Dictionary: if bool(te.get("in_safe", false)): return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") if _is_attackable(te): + # __ProcessEnemySkillTargetRange(PythonPlayerSkill.cpp:395) + var far := _out_of_range(skill_id, tvid) + if not far.is_empty(): + return far return _ok(tvid) return _no("CANNOT_ATTACK") diff --git a/project/player_skill_test.gd b/project/player_skill_test.gd index 3e7e7ba8..c515368d 100644 --- a/project/player_skill_test.gd +++ b/project/player_skill_test.gd @@ -56,6 +56,8 @@ func _reset(g: RefCounted) -> void: g.slot_cd_end = 0.0 g.cur_hp = -1 g.cur_sp = -1 + g.bow_distance = 0 + g.target_distance = Callable() func _run() -> void: var assets := AssetRoot.path() @@ -227,6 +229,33 @@ func _run() -> void: fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC _ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK") fc.entities[2000] = {"kind": 2} + # __ProcessEnemySkillTargetRange:distance >= TargetRange(+弓距) -> 预约,不施法 + var range1: int = st.target_range(1) + if range1 > 0: + var dist := {"cm": float(range1)} + g.target_distance = func(_vid: int) -> float: return float(dist.cm) + var far := g.use_skill(1) + _ck(not far.ok and far.code == "RESERVED" and far.target_vid == 2000 and far.range_cm == range1, + "(d) 目标恰在射程边界 -> RESERVED(range=%d): %s" % [range1, far]) + _ck(PlayerSkill.is_silent_code("RESERVED"), "RESERVED 不弹 OnCannotUseSkill") + dist.cm = range1 - 1.0 + _ck(g.use_skill(1).ok, "(d) 射程内 1cm -> ok") + g.bow_distance = 2 + dist.cm = range1 + 150.0 + _ck(g.use_skill(1).ok, "(d) POINT_BOW_DISTANCE*100 计入射程") + dist.cm = -1.0 + _ck(g.use_skill(1).ok, "(d) 距离未知 -> 不预约") + _reset(g) + # 蓄力技在射程外要求调用方先发 use_skill(slot, 0) + for id in st._by_id.keys(): + if st.is_charge_skill(int(id)) and st.target_range(int(id)) > 0 and st.is_need_target(int(id)): + fc.skills.append({"id": int(id), "level": 1, "master": 0}) + g.target_distance = func(_vid: int) -> float: return 100000.0 + var rc := g.use_skill(int(id)) + _ck(rc.code != "RESERVED" or rc.charge, "(d) 蓄力技 %d 射程外 -> RESERVED charge=true: %s" % [id, rc]) + fc.skills.pop_back() + _reset(g) + break # 结盟技 109 Cure:CAN_USE_FOR_ME|ONLY_FOR_ALLIANCE if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109): _reset(g) diff --git a/project/print_server_address.gd b/project/print_server_address.gd new file mode 100644 index 00000000..449fd20d --- /dev/null +++ b/project/print_server_address.gd @@ -0,0 +1,35 @@ +extends SceneTree + +## Prints the address AppFlow would connect to, resolved through the same +## ServerInfo (built-in default + res://serverlist.txt). Used by +## script/live_smoke_test.sh so the port precheck can never drift from the +## client connect target. Never opens a connection or reads credentials. +## +## env: MT_SERVER_INDEX (default 0), MT_SERVER_CHANNEL (default: the first +## channel listed for that server, matching the login screen default). +## stdout: one line "SERVER_ADDRESS {json}". exit 2 when unresolvable. + +const ServerInfoRes = preload("res://net/serverinfo.gd") + +func _init() -> void: + var serverinfo := ServerInfoRes.new() + serverinfo.load_file("res://serverlist.txt") + var index := int(OS.get_environment("MT_SERVER_INDEX")) if OS.has_environment("MT_SERVER_INDEX") else 0 + var server: Dictionary = serverinfo.server(index) + if server.is_empty(): + printerr("SERVER_ADDRESS: server index %d is not in serverinfo" % index) + quit(2) + return + var channels: Array = server.get("channels", [1]) + var channel := int(channels[0]) if not channels.is_empty() else 1 + if OS.has_environment("MT_SERVER_CHANNEL"): + channel = int(OS.get_environment("MT_SERVER_CHANNEL")) + if not channels.has(channel): + printerr("SERVER_ADDRESS: channel %d is not listed for server %d" % [channel, index]) + quit(2) + return + var address: Dictionary = serverinfo.address(index, channel) + address["server_index"] = index + address["channel"] = channel + print("SERVER_ADDRESS " + JSON.stringify(address)) + quit(0) diff --git a/project/rendering_scenario_test.gd b/project/rendering_scenario_test.gd index df11cd3c..8f61ab15 100644 --- a/project/rendering_scenario_test.gd +++ b/project/rendering_scenario_test.gd @@ -1,15 +1,19 @@ # Offline real-resource stress/viewport report; NOT 40250 visual parity or # server reconnect acceptance. Run with a renderer, --quit-after 3600. +# suite=offline_synthetic: FakeClient + real assets. STB-01 live numbers only come +# from the networked soak suite (script/playable_soak.sh); never merge the two. extends SceneTree const GameScene = preload("res://game_scene.gd") const Fixtures = preload("res://gamescene_test.gd") const PlayerView = preload("res://ui/player_view.gd") const MobView = preload("res://ui/mob_view.gd") +const Metrics = preload("res://testing/playable_metrics.gd") class ScenarioClient extends Fixtures.FakeClient: signal warp(pos: Vector3, same_server: bool) signal world_reset() var failures: Array[String] = [] var output := "" +var metrics: RefCounted func check(ok: bool, label: String) -> void: if not ok: failures.append(label) @@ -27,8 +31,13 @@ func run() -> void: DirAccess.make_dir_recursive_absolute(output) root.size = Vector2i(1280, 720) var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity", + "suite": "offline_synthetic", "suite_note": "离线合成:FakeClient + 真实资源,无服务器;不能代替 soak 实网 suite", "engine": Engine.get_version_info(), "backend": DisplayServer.get_name(), "samples": [], - "sample_frames": sample_frames, "model_builds": []} + "sample_frames": sample_frames, "model_builds": [], "viewports": [], + "percentile": "nearest-rank over window-frame intervals", + "cold_start_note": "进程冷启动/系统缓存状态未知;测试不清空系统缓存"} + metrics = Metrics.new() + metrics.configure({"window_seconds": 3600}) var client := ScenarioClient.new() root.add_child(client) var scene := GameScene.new() @@ -41,24 +50,32 @@ func run() -> void: for i in 30: await process_frame check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded") var centre: Vector3 = scene.player.position + report["probe_overhead"] = await _probe_overhead(sample_frames) + metrics.reset_frame_clock() + metrics.frame() for mode in ["0", "1"]: OS.set_environment("MTGODOT_GPUSKIN", mode) var crowd := Node3D.new() scene.add_child(crowd) for count in [1, 8, 16]: + _tags("OutdoorA1", "loading", count, mode == "1") while crowd.get_child_count() < count: var index := crowd.get_child_count() var view := PlayerView.new() var build_start := Time.get_ticks_usec() check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8)) - report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, - "build_ms": (Time.get_ticks_usec() - build_start) / 1000.0, + var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0 + metrics.mark("model_build", {"race": index % 8, "ms": build_ms}) + report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, "build_ms": build_ms, "note": "synchronous build only; OS/shader caches may already be warm"}) crowd.add_child(view) view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2) view.position.y = scene.world.sample_height(view.position.x, view.position.z) view.set_anim_state("run") - for i in 12: await process_frame + for i in 12: + await process_frame + metrics.frame() + _tags("OutdoorA1", "gameplay", count, mode == "1") var times: Array[float] = [] var switches: Array[float] = [] var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC) @@ -69,7 +86,9 @@ func run() -> void: var state: String = ["run", "attack", "wait"][(i / 30) % 3] for view in crowd.get_children(): view.set_anim_state(state) switches.append((Time.get_ticks_usec() - switch_start) / 1000.0) + metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]}) await process_frame + metrics.frame() var now := Time.get_ticks_usec() times.append((now - previous) / 1000.0) previous = now @@ -79,21 +98,32 @@ func run() -> void: "finite posed bounds after run/attack/wait blend") times.sort() switches.sort() + var sorted := PackedFloat32Array(times) report.samples.append({"gpu_skin": mode == "1", "extra_players": count, - "median_ms": times[sample_frames / 2], "p95_ms": times[int((sample_frames - 1) * 0.95)], "max_ms": times[-1], + "median_ms": Metrics.percentile(sorted, 0.5), "p95_ms": Metrics.percentile(sorted, 0.95), + "p99_ms": Metrics.percentile(sorted, 0.99), "max_ms": sorted[-1], + "p99_note": "nearest-rank; below 100 samples p99 equals max", + "over_50ms": times.filter(func(ms: float) -> bool: return ms > Metrics.LONG_MS).size(), + "over_100ms": times.filter(func(ms: float) -> bool: return ms > Metrics.SEVERE_MS).size(), "motion_switches": switches.size(), "motion_switch_max_ms": switches[-1], "memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC), "clip_cache": Metin2AnimPlayer.get_clip_cache_stats(), "note": "%d window-frame intervals with repeated motion transitions; not GPU timestamp, RSS or FPS guarantee" % sample_frames}) - print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d max_ms=%.3f" % [mode, count, sample_frames, times[-1]]) + check(report.samples[-1].p99_ms <= report.samples[-1].max_ms and report.samples[-1].p99_ms > 0.0, "p99 recorded for gpu=%s count=%d" % [mode, count]) + print("SCENARIO_SAMPLE gpu=%s count=%d frames=%d p99_ms=%.3f max_ms=%.3f" % [mode, count, sample_frames, report.samples[-1].p99_ms, times[-1]]) crowd.free() await process_frame + metrics.frame() OS.set_environment("MTGODOT_GPUSKIN", "0") var mobs := Node3D.new() scene.add_child(mobs) + _tags("OutdoorA1", "loading", 3, false) for race in [101, 110, 20001]: var view := MobView.new() - if not view.build(AssetRoot.path(), null, race): + var build_start := Time.get_ticks_usec() + var built: bool = view.build(AssetRoot.path(), null, race) + metrics.mark("model_build", {"race": race, "ms": (Time.get_ticks_usec() - build_start) / 1000.0}) + if not built: check(false, "NPC/monster build race=%d" % race) view.free() continue @@ -102,9 +132,17 @@ func run() -> void: view.position.y = scene.world.sample_height(view.position.x, view.position.z) view.set_anim_state("wait") check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry") + _tags("OutdoorA1", "gameplay", 3, false) for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]: root.size = size - for i in 5: await process_frame + metrics.mark("viewport_resize", {"size": "%dx%d" % [size.x, size.y]}) + for i in 5: + await process_frame + metrics.frame() + # Logical viewport and physical window pixels are both recorded; Retina scale is not a size error. + report.viewports.append({"logical": [size.x, size.y], "visible": [int(root.get_visible_rect().size.x), int(root.get_visible_rect().size.y)], + "window_physical": [DisplayServer.window_get_size().x, DisplayServer.window_get_size().y], + "screen_scale": DisplayServer.screen_get_scale()}) if scene.quickbar and scene.quickbar._root: var rect: Rect2 = scene.quickbar._root.get_global_rect() check(Rect2(Vector2.ZERO, root.get_visible_rect().size).encloses(rect), "quickbar inside viewport %s" % size) @@ -114,11 +152,26 @@ func run() -> void: # Late server location update exercises the production map-correction path. client.ents[1000].pos_cm = Vector2(959109, 269267) client.ents[1000].pos = Vector3(9591.09, 0, -2692.67) + _tags("OutdoorC1", "loading", 3, false) + metrics.mark("map_switch", {"map": "OutdoorC1"}) client.world_reset.emit() client.warp.emit(client.ents[1000].pos, false) - for i in 60: await process_frame + for i in 60: + await process_frame + metrics.frame() check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction") report["final_map"] = scene.map_path + var summary: Dictionary = metrics.summary() + report["frame_metrics"] = summary + report["unattributed_long_frames"] = metrics.unattributed_long_frames().size() + report["memory"] = Metrics.memory_snapshot() + check(int(summary.frames) > sample_frames * 6 and not summary.segments.is_empty() and not summary.markers.is_empty(), "frame metrics recorded with markers") + var phases := {} + for row in summary.segments: phases[row.tags.phase] = true + check(phases.has("loading") and phases.has("gameplay"), "loading and gameplay are separate segments: %s" % [phases.keys()]) + check(report.memory.gpu_memory_kib == null, "GPU memory is reported unavailable, never 0") + print("SCENARIO_METRICS frames=%d long=%d unattributed=%d severe=%s" % [summary.frames, summary.long_frames.size(), + report.unattributed_long_frames, summary.severe_by_phase]) scene.free() client.free() await process_frame @@ -127,3 +180,31 @@ func run() -> void: FileAccess.open(output.path_join("report.json"), FileAccess.WRITE).store_string(JSON.stringify(report, "\t")) print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output]) quit(1 if not failures.is_empty() else 0) +func _tags(map: String, phase: String, actors: int, gpu_skin: bool) -> void: + metrics.set_tags({"map": map, "phase": phase, "actor_count": Metrics.actor_bucket(actors), "gpu_skin": gpu_skin}) +## Short alternating baselines with the probe off and on (same scene, same frames), +## plus the per-sample CPU cost; the difference is reported, never assumed zero. +func _probe_overhead(frames: int) -> Dictionary: + var rounds: Array = [] + var probe: RefCounted = Metrics.new() + probe.configure({"window_seconds": 3600}) + probe.set_tags({"map": "OutdoorA1", "phase": "probe", "actor_count": "0", "gpu_skin": false}) + for i in 4: + var enabled := i % 2 == 1 + var times := PackedFloat32Array() + var previous := Time.get_ticks_usec() + for f in frames: + await process_frame + if enabled: + probe.frame() + var now := Time.get_ticks_usec() + times.append((now - previous) / 1000.0) + previous = now + times.sort() + rounds.append({"probe": enabled, "frames": frames, "p50_ms": Metrics.percentile(times, 0.5), + "p99_ms": Metrics.percentile(times, 0.99), "max_ms": times[-1]}) + var off: Array = rounds.filter(func(r: Dictionary) -> bool: return not r.probe) + var on: Array = rounds.filter(func(r: Dictionary) -> bool: return r.probe) + return {"rounds": rounds, "cpu": Metrics.measure_overhead(20000), + "p50_delta_ms": (on[0].p50_ms + on[1].p50_ms - off[0].p50_ms - off[1].p50_ms) / 2.0, + "note": "frame pacing noise can exceed the probe cost; per_sample_us is the direct CPU cost"} diff --git a/project/skill_test.gd b/project/skill_test.gd index 30709b86..b67be12c 100644 --- a/project/skill_test.gd +++ b/project/skill_test.gd @@ -8,6 +8,17 @@ const Quickbar = preload("res://ui/quickbar.gd") const UiAssets = preload("res://ui/ui_assets.gd") const UiManager = preload("res://ui/ui_manager.gd") +# __ReserveUseSkill 的最小 NetPlay 面:射程上下文 + 预约记录。 +class FakeRangeNetPlay extends Node: + var distance_cm := 100000.0 + var reservations := [] + func skill_context() -> Dictionary: + return {"bow_distance": 2, "target_distance": func(_vid: int) -> float: return distance_cm} + func reserve_use_skill(vid: int, slot: int, range_cm: float) -> void: + reservations.append([vid, slot, int(range_cm)]) + func is_use_skill_reserved(slot: int) -> bool: + return not reservations.is_empty() and int(reservations[-1][1]) == slot + class FakeClient extends Node: signal skills_changed() signal skill_cooldown_end(skill: int) @@ -327,6 +338,37 @@ func _run() -> void: "mobile skill action assigns to first free quickbar slot") sk.close() + # 射程外(__ProcessEnemySkillTargetRange):不 cast、预约;蓄力技仅首次预约前发 use_skill(id, 0) + if st.has(5) and st.is_charge_skill(5): + var np := FakeRangeNetPlay.new() + get_root().add_child(np) + fc.skills.append({"id": 5, "level": 1, "master": 0}) + var qb3: Node = Quickbar.new() + qb3.net_play = np + get_root().add_child(qb3) + qb3.setup(fc, st, ui, func() -> Node: return pl) + qb3.assign(5, "skill", 5, false) + var uses0: int = fc.skill_uses.size() + var casts0: int = fc.casts.size() + var rejected := [] + qb3.skill_rejected.connect(func(sid: int, code: String): rejected.append([sid, code])) + qb3.activate(5) + var range5: int = st.target_range(5) + 200 + _ck(fc.casts.size() == casts0 and fc.skill_uses.slice(uses0) == [[5, 0]], + "charge skill out of range -> only use_skill(5, 0), no cast: %s" % [fc.skill_uses.slice(uses0)]) + _ck(np.reservations == [[2000, 5, range5]], "out of range -> reserve_use_skill(target, slot, TargetRange+bow*100)") + _ck(rejected.is_empty(), "reservation is silent (no OnCannotUseSkill)") + qb3.activate(5) + _ck(fc.skill_uses.size() == uses0 + 1 and np.reservations.size() == 2, + "already reserved charge skill -> no second use_skill(5, 0)") + _ck(not qb3.activate_reserved(5), "reserved fire still out of range keeps the new reservation") + np.distance_cm = 10.0 + _ck(qb3.activate_reserved(5), "reserved fire in range consumes the reservation") + _ck(fc.casts.size() == casts0 + 1 and fc.skill_uses.back() == [5, 2000], "in range -> use_skill(5, target) + one cast") + fc.skills.pop_back() + qb3.free() + np.free() + # 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格 var qb2: Node = Quickbar.new() get_root().add_child(qb2) diff --git a/project/target_effect_test.gd b/project/target_effect_test.gd index 59cfedd5..4eeefdc4 100644 --- a/project/target_effect_test.gd +++ b/project/target_effect_test.gd @@ -54,6 +54,27 @@ func run() -> void: target.free() game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) check(registry.seen.size() == 1, "despawned target not replaced by caster") + game._on_skill_cast_started(1, 0) + game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) + check(registry.seen.size() == 1, "cast without a target spawns no target effect") + # A following effect whose target dies/despawns mid-life is freed, never left at a stale position. + var victim := Node3D.new() + root.add_child(victim) + var outside := Node3D.new() + var outside_anchor := Anchor.new() + check(not outside_anchor.configure(outside, Vector3.ZERO, true), "target outside the tree is rejected") + outside_anchor.free() + outside.free() + var follower := Node3D.new() + root.add_child(follower) + var follow_anchor := Anchor.new() + follower.add_child(follow_anchor) + check(follow_anchor.configure(victim, Vector3.ZERO, true), "follower configured") + var follower_ref: WeakRef = weakref(follower) + victim.free() + await process_frame + await process_frame + check(follower_ref.get_ref() == null, "follower freed after its target left the tree") game.free() effect.free() await process_frame diff --git a/project/testing/forest_viewpoints.gd b/project/testing/forest_viewpoints.gd new file mode 100644 index 00000000..379ea57f --- /dev/null +++ b/project/testing/forest_viewpoints.gd @@ -0,0 +1,195 @@ +extends RefCounted + +## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints..local.json)。 +## +## 机位必须由测试环境负责人在实服上确认(服务器全局 cm)。未配置的 flat/slope/dense +## 只能由地形/树木密度自动挑“候选”机位:照样截图和测量,但用例保持 BLOCKED, +## 不能冒充“经确认的机位”。传送点无法离线推导,缺配置即 BLOCKED。 + +const Config = preload("res://testing/playable_config.gd") + +const SCHEMA_VERSION := 1 +const KINDS := ["flat", "slope", "dense", "warp"] +const AUTO_KINDS := ["flat", "slope", "dense"] +const STATUSES := ["confirmed", "unconfirmed"] +const MAP_CELL_CM := 25600.0 +const DEFAULT_YAW_DEG := 45.0 +## 自动候选:点位离地图边缘、彼此之间和最近树干的最小距离(米),以及坡地的可行走上限。 +const AUTO_EDGE_MARGIN_M := 12.0 +const AUTO_SEPARATION_M := 20.0 +const AUTO_TREE_CLEARANCE_M := 3.0 +const AUTO_DENSITY_RADIUS_M := 12.0 +const AUTO_MAX_SLOPE_RATIO := 0.6 + +static func load_file(path: String) -> Dictionary: + var result := {"ok": false, "data": {}, "errors": []} + if path.strip_edges().is_empty(): + result.ok = true # 没有夹具文件:全部机位走候选/BLOCKED,而不是失败 + return result + if not FileAccess.file_exists(path): + result.errors.append("viewpoint file does not exist: %s" % path) + return result + var file := FileAccess.open(path, FileAccess.READ) + var parsed: Variant = JSON.parse_string(file.get_as_text()) if file else null + if not (parsed is Dictionary): + result.errors.append("viewpoint file root must be a JSON object") + return result + result.data = parsed + result.ok = true + return result + +## bounds = {"base": Vector2 cm, "size": Vector2i tiles}(GameScene._map_bounds 的结果)。 +## 返回 {errors, viewpoints, missing_kinds, races};viewpoints 带 local_m(地图本地米)。 +static func plan(data: Dictionary, map_key: String, bounds: Dictionary) -> Dictionary: + var errors: Array[String] = [] + var viewpoints: Array = [] + var races: Array = [] + var out := {"errors": errors, "viewpoints": viewpoints, "missing_kinds": [], "races": races} + if not data.is_empty(): + if not _is_int(data.get("schema_version", null)) or int(data.schema_version) != SCHEMA_VERSION: + errors.append("viewpoint schema_version must be %d" % SCHEMA_VERSION) + if not (data.get("maps", null) is Dictionary): + errors.append("viewpoint maps must be an object keyed by map_key") + Config._reject_secrets(data, "", errors) + var maps: Dictionary = data.get("maps", {}) if data.get("maps", {}) is Dictionary else {} + var entry: Variant = maps.get(map_key, {}) + if not (entry is Dictionary): + errors.append("maps.%s must be an object" % map_key) + entry = {} + var seen := {} + var listed: Variant = entry.get("viewpoints", []) + if not (listed is Array): + errors.append("maps.%s.viewpoints must be an array" % map_key) + listed = [] + for i in listed.size(): + var where := "maps.%s.viewpoints[%d]" % [map_key, i] + var item: Variant = listed[i] + if not (item is Dictionary): + errors.append("%s must be an object" % where) + continue + var id := String(item.get("id", "")).strip_edges() + if id.is_empty() or not id.is_valid_filename() or " " in id: + errors.append("%s.id must be a non-empty file-safe name" % where) + elif seen.has(id): + errors.append("%s.id duplicates %s" % [where, id]) + seen[id] = true + var kind := String(item.get("kind", "")) + if not (kind in KINDS): + errors.append("%s.kind must be one of %s" % [where, KINDS]) + var status := String(item.get("status", "")) + var confirmed_by := String(item.get("confirmed_by", "")).strip_edges() + if not (status in STATUSES): + errors.append("%s.status must be confirmed or unconfirmed" % where) + elif status == "confirmed" and confirmed_by.is_empty(): + errors.append("%s is confirmed but confirmed_by (role) is empty" % where) + elif status == "unconfirmed" and not confirmed_by.is_empty(): + errors.append("%s is unconfirmed but carries confirmed_by" % where) + var cm: Variant = item.get("server_cm", null) + var server_cm := Vector2(-1, -1) + if not (cm is Array) or cm.size() != 2 or not _is_int(cm[0]) or not _is_int(cm[1]) \ + or int(cm[0]) < 0 or int(cm[1]) < 0: + errors.append("%s.server_cm must be [x,y] non-negative integer centimeters" % where) + else: + server_cm = Vector2(int(cm[0]), int(cm[1])) + if not contains_server_cm(bounds, server_cm): + errors.append("%s.server_cm %s is outside %s" % [where, cm, map_key]) + var yaw: Variant = item.get("camera_yaw_deg", DEFAULT_YAW_DEG) + if not (yaw is float or yaw is int): + errors.append("%s.camera_yaw_deg must be a number" % where) + yaw = DEFAULT_YAW_DEG + viewpoints.append({"id": id, "kind": kind, "status": status, "source": "config", + "confirmed_by": confirmed_by, "camera_yaw_deg": float(yaw), "server_cm": server_cm, + "local_m": to_local_m(bounds, server_cm)}) + var race_list: Variant = entry.get("races", []) + if not (race_list is Array): + errors.append("maps.%s.races must be an array of mob vnums" % map_key) + else: + for value in race_list: + if not _is_int(value) or int(value) <= 0: + errors.append("maps.%s.races must contain positive integers" % map_key) + break + races.append(int(value)) + for kind in KINDS: + if not viewpoints.any(func(v: Dictionary) -> bool: return v.kind == kind): + out.missing_kinds.append(kind) + return out + +static func contains_server_cm(bounds: Dictionary, server_cm: Vector2) -> bool: + if bounds.is_empty(): + return false + var base: Vector2 = bounds.base + var size: Vector2i = bounds.size + return server_cm.x >= base.x and server_cm.y >= base.y \ + and server_cm.x < base.x + size.x * MAP_CELL_CM and server_cm.y < base.y + size.y * MAP_CELL_CM + +## 服务器全局 cm -> Metin2World 地图本地米(与 MapCoord.to_world 一致:+Z 朝南)。 +static func to_local_m(bounds: Dictionary, server_cm: Vector2) -> Vector2: + var base: Vector2 = bounds.get("base", Vector2.ZERO) + return (server_cm - base) * 0.01 + +static func to_server_cm(bounds: Dictionary, local_m: Vector2) -> Vector2: + var base: Vector2 = bounds.get("base", Vector2.ZERO) + return Vector2(roundf(local_m.x * 100.0 + base.x), roundf(local_m.y * 100.0 + base.y)) + +## samples: [{p: Vector2 本地米, range_m: 足迹内地形高差, span_m: 足迹宽度, blocked: bool}] +## trees: 树干本地米坐标。只挑 kinds 里请求的种类;挑不出来的种类不返回(调用方记 BLOCKED)。 +static func pick_candidates(samples: Array, trees: PackedVector2Array, size_m: Vector2, kinds: Array) -> Array: + var usable: Array = [] + for s: Dictionary in samples: + var p: Vector2 = s.p + if bool(s.blocked) or p.x < AUTO_EDGE_MARGIN_M or p.y < AUTO_EDGE_MARGIN_M \ + or p.x > size_m.x - AUTO_EDGE_MARGIN_M or p.y > size_m.y - AUTO_EDGE_MARGIN_M: + continue + var nearest := INF + var density := 0 + for t in trees: + var d := t.distance_to(p) + nearest = minf(nearest, d) + if d <= AUTO_DENSITY_RADIUS_M: + density += 1 + if nearest < AUTO_TREE_CLEARANCE_M: + continue + var slope := float(s.range_m) / maxf(float(s.span_m), 0.001) + usable.append({"p": p, "range_m": float(s.range_m), "slope_ratio": slope, "density": density}) + var chosen: Array = [] + var centre := size_m * 0.5 + for kind in ["flat", "dense", "slope"]: + if not (kind in kinds): + continue + var best: Dictionary = {} + for u: Dictionary in usable: + if chosen.any(func(c: Dictionary) -> bool: return (c.p as Vector2).distance_to(u.p) < AUTO_SEPARATION_M): + continue + if best.is_empty() or _better(kind, u, best, centre): + best = u + if best.is_empty(): + continue + if kind == "slope" and float(best.slope_ratio) <= 0.0: + continue + if kind == "dense" and int(best.density) == 0: + continue + var c := best.duplicate() + c["kind"] = kind + chosen.append(c) + return chosen + +static func _better(kind: String, a: Dictionary, b: Dictionary, centre: Vector2) -> bool: + match kind: + "flat": + if not is_equal_approx(float(a.range_m), float(b.range_m)): + return float(a.range_m) < float(b.range_m) + return (a.p as Vector2).distance_to(centre) < (b.p as Vector2).distance_to(centre) + "slope": + var a_ok := float(a.slope_ratio) <= AUTO_MAX_SLOPE_RATIO + var b_ok := float(b.slope_ratio) <= AUTO_MAX_SLOPE_RATIO + if a_ok != b_ok: + return a_ok + return float(a.slope_ratio) > float(b.slope_ratio) if a_ok else float(a.slope_ratio) < float(b.slope_ratio) + "dense": + if int(a.density) != int(b.density): + return int(a.density) > int(b.density) + return float(a.range_m) < float(b.range_m) + return false + +static func _is_int(value: Variant) -> bool: + return value is int or (value is float and is_equal_approx(value, roundf(value))) diff --git a/project/testing/playable_config.gd b/project/testing/playable_config.gd index 8e6d6a7d..4a6edad8 100644 --- a/project/testing/playable_config.gd +++ b/project/testing/playable_config.gd @@ -12,6 +12,17 @@ const REQUIRED_KEYS := [ "waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases", "resolution", "loops", "timeout_seconds", ] +const SERVER_KEYS := ["server_index", "channel", "auth_host", "auth_port", "game_host", "game_port"] +const SUITES := ["playable", "full", "soak"] +const MAP_CELL_CM := 25600.0 +const GOLD_VNUM := 1 + +## 技能证据分两类:只有接收端分发(M2Client 信号)才算服务端结果。 +## 本地施法开始、特效节点生成只能作为补充证据,单独出现不能让用例 PASS。 +const SERVER_EVIDENCE := ["damage", "target_dead", "server_motion", "effect_cue", + "fly_cue", "affect_added", "sp_changed"] +const LOCAL_EVIDENCE := ["cast_started", "fx_spawned", "fx_finished"] +const SKILL_TARGETS := ["enemy", "self", "ground"] static func load_file(path: String) -> Dictionary: var result := {"ok": false, "config": {}, "errors": []} @@ -41,7 +52,7 @@ static func validate(config: Dictionary) -> Dictionary: for key in REQUIRED_KEYS: if not config.has(key): errors.append("missing required key: %s" % key) - if int(config.get("schema_version", -1)) != SCHEMA_VERSION: + if not _is_int(config.get("schema_version", null)) or int(config.get("schema_version", -1)) != SCHEMA_VERSION: errors.append("schema_version must be %d" % SCHEMA_VERSION) if String(config.get("scenario_id", "")).strip_edges().is_empty(): errors.append("scenario_id must not be empty") @@ -52,21 +63,35 @@ static func validate(config: Dictionary) -> Dictionary: if not (server is Dictionary): errors.append("server must be an object selected from serverinfo") else: - for key in ["server_index", "channel_index"]: - if not server.has(key) or int(server[key]) < 0: - errors.append("server.%s must be a non-negative integer" % key) - if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0: + for key in SERVER_KEYS: + if not server.has(key): + errors.append("server.%s is missing; copy the address selected from serverinfo" % key) + if not _is_int(server.get("server_index", null)) or int(server.get("server_index", -1)) < 0: + errors.append("server.server_index must be a non-negative integer") + if not _is_int(server.get("channel", null)) or int(server.get("channel", 0)) < 1: + errors.append("server.channel must be a channel number (>= 1)") + for key in ["auth_host", "game_host"]: + if String(server.get(key, "")).strip_edges().is_empty(): + errors.append("server.%s must not be empty" % key) + for key in ["auth_port", "game_port"]: + if not _is_int(server.get(key, null)) or int(server.get(key, 0)) <= 0 or int(server.get(key, 0)) > 65535: + errors.append("server.%s must be 1..65535" % key) + if not _is_int(config.get("character_slot", null)) or int(config.get("character_slot", -1)) < 0: errors.append("character_slot must be a non-negative integer") - if String(config.get("map_key", "")).strip_edges().is_empty(): + var map_key := String(config.get("map_key", "")).strip_edges() + if map_key.is_empty(): errors.append("map_key is empty; resolve it from the server/map fixture") + elif map_key.begins_with("/") or ".." in map_key.split("/") or "\\" in map_key: + errors.append("map_key must be a relative asset directory such as outdoortrent/metin2_map_trent") var waypoints: Variant = config.get("waypoints_cm", null) if not (waypoints is Array) or waypoints.is_empty(): errors.append("waypoints_cm must contain at least one [x,y] centimeter point") else: for i in waypoints.size(): var point: Variant = waypoints[i] - if not (point is Array) or point.size() != 2 or not _is_number(point[0]) or not _is_number(point[1]): - errors.append("waypoints_cm[%d] must be [x,y] numbers" % i) + if not (point is Array) or point.size() != 2 or not _is_int(point[0]) or not _is_int(point[1]) \ + or int(point[0]) < 0 or int(point[1]) < 0: + errors.append("waypoints_cm[%d] must be [x,y] non-negative integer centimeters" % i) var mobs: Variant = config.get("allowed_mob_vnums", null) if not (mobs is Array) or mobs.is_empty(): errors.append("allowed_mob_vnums must contain confirmed server mob vnums") @@ -77,10 +102,347 @@ static func validate(config: Dictionary) -> Dictionary: errors.append("allowed_drop_vnums must contain a deterministic pickup fixture") else: _validate_positive_int_array(drops, "allowed_drop_vnums", errors) - var skills: Variant = config.get("skill_cases", null) + for value in drops: + if _is_int(value) and int(value) == GOLD_VNUM: + errors.append("allowed_drop_vnums must not contain gold (vnum 1); money is a separate points case") + _validate_skill_cases(config.get("skill_cases", null), errors) + var resolution: Variant = config.get("resolution", null) + if not (resolution is Array) or resolution.size() != 2 or not _is_int(resolution[0]) or not _is_int(resolution[1]) \ + or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: + errors.append("resolution must be [width,height]") + if not _is_int(config.get("loops", null)) or int(config.get("loops", 0)) < 1: + errors.append("loops must be at least 1") + if not _is_int(config.get("timeout_seconds", null)) or int(config.get("timeout_seconds", 0)) < 1: + errors.append("timeout_seconds must be positive") + # This config is deliberately data-only. Reject the common accidental secret paths anywhere in the tree. + _reject_secrets(config, "", errors) + return {"ok": errors.is_empty(), "errors": errors} + +## test/playable/skill-cases.json:每职业 × {单体, 范围, 自身增益, 飞行效果} 的实际技能矩阵。 +## 条目只能是 unconfirmed(缺夹具 -> BLOCKED)、confirmed(环境负责人确认的已学技能)或 +## not_applicable(附清单确认记录)。脚本从不补技能 ID;confirmed 的 skill_id 必须属于该职业 +## 且不是被动技,每技能至少成功施放 SKILL_MATRIX_REPEATS 次。 +const SKILL_MATRIX_TYPES := ["single_target", "area", "self_buff", "flying"] +const SKILL_MATRIX_JOBS := ["WARRIOR", "ASSASSIN", "SURA", "SHAMAN"] +const SKILL_MATRIX_REPEATS := 10 + +static func skill_matrix_cases(matrix: Dictionary, job: String, skill_table: RefCounted = null) -> Dictionary: + var errors: Array[String] = [] + var blocked: Array[String] = [] + var not_applicable: Array[String] = [] + var cases: Array = [] + var result := {"errors": errors, "blocked": blocked, "not_applicable": not_applicable, "cases": cases} + if not _is_int(matrix.get("schema_version", null)) or int(matrix.get("schema_version", -1)) != SCHEMA_VERSION: + errors.append("skill matrix schema_version must be %d" % SCHEMA_VERSION) + var jobs: Variant = matrix.get("jobs", null) + if not (jobs is Dictionary): + errors.append("skill matrix jobs must be an object") + return result + for name in jobs: + if not (String(name) in SKILL_MATRIX_JOBS): + errors.append("skill matrix has unknown job: %s" % name) + if not (job in SKILL_MATRIX_JOBS) or not (jobs.get(job, null) is Dictionary): + errors.append("skill matrix has no entry for job %s" % job) + return result + var row: Dictionary = jobs[job] + for type in row: + if not (String(type) in SKILL_MATRIX_TYPES): + errors.append("%s has unknown skill type: %s" % [job, type]) + for type in SKILL_MATRIX_TYPES: + var where := "%s/%s" % [job, type] + var entry: Variant = row.get(type, null) + if not (entry is Dictionary): + errors.append("%s is missing" % where) + continue + match String(entry.get("status", "")): + "unconfirmed": + if entry.has("skill_id"): + errors.append("%s is unconfirmed but carries a skill_id" % where) + blocked.append(where) + "not_applicable": + if String(entry.get("checklist_ref", "")).strip_edges().is_empty(): + errors.append("%s not_applicable needs checklist_ref" % where) + not_applicable.append(where) + "confirmed": + var skill_id := int(entry.skill_id) if _is_int(entry.get("skill_id", null)) else 0 + if skill_table != null and skill_id > 0: + if String(skill_table.category_of(skill_id)) != job: + errors.append("%s skill_id %d does not belong to %s" % [where, skill_id, job]) + elif skill_table.is_passive(skill_id): + errors.append("%s skill_id %d is passive" % [where, skill_id]) + if entry.has("repeats") and _is_int(entry.repeats) and int(entry.repeats) < SKILL_MATRIX_REPEATS: + errors.append("%s repeats must be at least %d" % [where, SKILL_MATRIX_REPEATS]) + cases.append({"case_id": "%s-%s" % [job.to_lower(), type.replace("_", "-")], + "skill_id": entry.get("skill_id", null), "target": entry.get("target", "enemy"), + "repeats": entry.get("repeats", SKILL_MATRIX_REPEATS), + "required_evidence": entry.get("required_evidence", null)}) + _: + errors.append("%s status must be unconfirmed, confirmed or not_applicable" % where) + if not cases.is_empty(): + _validate_skill_cases(cases, errors) + elif blocked.is_empty(): + errors.append("%s has no confirmed skill case" % job) + return result + +## 必测用例清单由配置推导,客户端与父运行器共用同一份结果, +## 运行器在启动前落盘,校验器据此拒绝“少报一个用例”的 PASS。 +static func required_cases(config: Dictionary, suite: String) -> Array[String]: + var out: Array[String] = ["CONFIG-01", "NET-LOGIN-01", "NET-SELECT-01", "NET-WORLD-01"] + var waypoints: Variant = config.get("waypoints_cm", []) + if waypoints is Array: + for i in waypoints.size(): + out.append("NET-MOVE-%02d" % (i + 1)) + out.append_array(["NET-MOVE-CONFIRM-01", "NET-TARGET-01", "NET-ATTACK-01", "NET-DROP-01", "NET-PICKUP-01"]) + if suite == "full": + var skills: Variant = config.get("skill_cases", []) + if skills is Array: + for item in skills: + if item is Dictionary: + out.append(skill_case_id(item)) + if suite == "soak": + # STB-MEMORY-01 is a runner case (external RSS sampler); exits are counted by the runner. + out.append_array(SOAK_CASES) + return out + +## STB-01 §9.2 soak block. The minimums are the release acceptance numbers, not +## defaults: a shorter or smaller soak is rejected instead of silently reported as PASS. +## Warp routes and real transport faults need environment-owner confirmation; the +## unconfirmed status is valid config but the matching case stays BLOCKED. +const SOAK_CASES := ["STB-DURATION-01", "STB-FRAMES-01", "STB-RECONNECT-01", "STB-WARP-01", + "STB-DISCONNECT-01", "STB-RESIZE-01"] +const SOAK_MIN_DURATION_S := 7200 +const SOAK_MIN_REST_S := 30 +const SOAK_MIN_ROUNDS := 10 +const SOAK_MIN_RECONNECTS := 10 +const SOAK_MIN_EXITS := 10 +const SOAK_MIN_WARPS := 20 +const SOAK_RESOLUTION_COUNT := 3 +const SOAK_MIN_SWITCHES := 10 +const SOAK_TIMEOUT_MARGIN_S := 900 +const SOAK_MAX_UNREACHABLE_S := 20 +const SOAK_FAULT_TYPES := ["close", "unreachable"] + +## soak_client=false validates a soak block seen by another suite (the playable exit runs +## sharing the scenario): everything except the client timeout margin, which only the +## 2 h soak client needs. +static func validate_soak(config: Dictionary, soak_client := true) -> Dictionary: + var errors: Array[String] = [] + var soak: Variant = config.get("soak", null) + if not (soak is Dictionary): + errors.append("soak must be an object for the soak suite") + return {"ok": false, "errors": errors} + var minimums := {"duration_seconds": SOAK_MIN_DURATION_S, "rest_seconds": SOAK_MIN_REST_S, "warmup_rounds": 0, + "min_rounds": SOAK_MIN_ROUNDS, "reconnects": SOAK_MIN_RECONNECTS, "exits": SOAK_MIN_EXITS} + for key in minimums: + if not _is_int(soak.get(key, null)) or int(soak.get(key, -1)) < int(minimums[key]): + errors.append("soak.%s must be an integer >= %d" % [key, minimums[key]]) + var duration := int(soak.get("duration_seconds", 0)) if _is_int(soak.get("duration_seconds", null)) else 0 + if soak_client and _is_int(config.get("timeout_seconds", null)) and int(config.timeout_seconds) < duration + SOAK_TIMEOUT_MARGIN_S: + errors.append("timeout_seconds must be at least soak.duration_seconds + %d" % SOAK_TIMEOUT_MARGIN_S) + var resolutions: Variant = soak.get("resolutions", null) + if not (resolutions is Dictionary): + errors.append("soak.resolutions must be an object") + else: + var sizes: Variant = resolutions.get("sizes", null) + var seen := {} + if not (sizes is Array) or sizes.size() != SOAK_RESOLUTION_COUNT: + errors.append("soak.resolutions.sizes must list exactly %d logical sizes" % SOAK_RESOLUTION_COUNT) + else: + for i in sizes.size(): + var size: Variant = sizes[i] + if not (size is Array) or size.size() != 2 or not _is_int(size[0]) or not _is_int(size[1]) \ + or int(size[0]) <= 0 or int(size[1]) <= 0: + errors.append("soak.resolutions.sizes[%d] must be [width,height] logical points" % i) + continue + var key := "%dx%d" % [int(size[0]), int(size[1])] + if seen.has(key): + errors.append("soak.resolutions.sizes[%d] duplicates %s" % [i, key]) + seen[key] = true + if not _is_int(resolutions.get("switches_per_size", null)) or int(resolutions.get("switches_per_size", 0)) < SOAK_MIN_SWITCHES: + errors.append("soak.resolutions.switches_per_size must be >= %d" % SOAK_MIN_SWITCHES) + var warp: Variant = soak.get("warp", null) + if not (warp is Dictionary): + errors.append("soak.warp must be an object with status unconfirmed|confirmed") + else: + match String(warp.get("status", "")): + "unconfirmed": + pass + "confirmed": + if not _is_int(warp.get("required", null)) or int(warp.get("required", 0)) < SOAK_MIN_WARPS or int(warp.get("required", 0)) % 2 != 0: + errors.append("soak.warp.required must be an even integer >= %d (out-and-back legs)" % SOAK_MIN_WARPS) + for key in ["portal_cm", "return_portal_cm"]: + if not _is_cm_point(warp.get(key, null)): + errors.append("soak.warp.%s must be [x,y] non-negative integer centimeters" % key) + var destination := String(warp.get("destination_map_key", "")).strip_edges() + if destination.is_empty() or destination.begins_with("/") or ".." in destination.split("/") or "\\" in destination: + errors.append("soak.warp.destination_map_key must be a relative asset directory") + elif destination == String(config.get("map_key", "")): + errors.append("soak.warp.destination_map_key must differ from map_key") + if not (warp.get("cross_server", null) is bool): + errors.append("soak.warp.cross_server must be true or false as verified on the test server") + _: + errors.append("soak.warp.status must be unconfirmed or confirmed") + var faults: Variant = soak.get("faults", null) + if not (faults is Dictionary): + errors.append("soak.faults must be an object with status unconfirmed|confirmed") + else: + match String(faults.get("status", "")): + "unconfirmed": + pass + "confirmed": + # Only a test-connection proxy on 127.0.0.1; never global routes or firewall rules. + if String(faults.get("mode", "")) != "local_proxy": + errors.append("soak.faults.mode must be local_proxy") + if not _is_int(faults.get("per_type", null)) or int(faults.get("per_type", 0)) < 1: + errors.append("soak.faults.per_type must be a positive integer") + var seconds: Variant = faults.get("unreachable_seconds", null) + if not _is_int(seconds) or int(seconds) < 1 or int(seconds) > SOAK_MAX_UNREACHABLE_S: + errors.append("soak.faults.unreachable_seconds must be 1..%d" % SOAK_MAX_UNREACHABLE_S) + _check_fault_upstream(config, faults.get("upstream", null), errors) + _: + errors.append("soak.faults.status must be unconfirmed or confirmed") + _reject_secrets(soak, "soak.", errors) + return {"ok": errors.is_empty(), "errors": errors} + +## The proxy (script/playable_fault_proxy.mjs) listens on server.* which must be loopback, +## and forwards to upstream (the real test server). It only carries this client's connection. +static func _check_fault_upstream(config: Dictionary, upstream: Variant, errors: Array[String]) -> void: + var server: Variant = config.get("server", {}) + if not (server is Dictionary): + return + for key in ["auth_host", "game_host"]: + if String(server.get(key, "")) != "127.0.0.1": + errors.append("soak.faults local_proxy needs server.%s = 127.0.0.1 (the proxy listen address)" % key) + if not (upstream is Dictionary): + errors.append("soak.faults.upstream must give auth_host/auth_port/game_host/game_port of the real test server") + return + for role in ["auth", "game"]: + var host := String(upstream.get(role + "_host", "")).strip_edges() + var port: Variant = upstream.get(role + "_port", null) + if host.is_empty() or " " in host: + errors.append("soak.faults.upstream.%s_host is missing" % role) + if not _is_int(port) or int(port) < 1 or int(port) > 65535: + errors.append("soak.faults.upstream.%s_port must be 1..65535" % role) + elif host == String(server.get(role + "_host", "")) and int(port) == int(server.get(role + "_port", -1)): + errors.append("soak.faults.upstream.%s points at the proxy itself" % role) + +static func _is_cm_point(point: Variant) -> bool: + return point is Array and point.size() == 2 and _is_int(point[0]) and _is_int(point[1]) \ + and int(point[0]) >= 0 and int(point[1]) >= 0 + +static func skill_case_id(item: Dictionary) -> String: + return "CBT-%s" % String(item.get("case_id", "")).strip_edges() + +## Resolve the configured address through the same ServerInfo object the +## client uses. The config must copy the resolved address verbatim; any drift +## between the precheck target and the client connect target is an error. +static func resolve_server(config: Dictionary, serverinfo: RefCounted) -> Dictionary: + var errors: Array[String] = [] + var server: Dictionary = config.get("server", {}) if config.get("server", null) is Dictionary else {} + var index := int(server.get("server_index", -1)) + var channel := int(server.get("channel", 0)) + var entry: Dictionary = serverinfo.server(index) if serverinfo != null else {} + if entry.is_empty(): + errors.append("server_index %d is not present in serverinfo" % index) + return {"ok": false, "address": {}, "errors": errors} + var channels: Array = entry.get("channels", []) + if not (channel in channels): + errors.append("channel %d is not listed by serverinfo server %d" % [channel, index]) + var address: Dictionary = serverinfo.address(index, channel) + for key in ["auth_host", "game_host"]: + if String(server.get(key, "")) != String(address.get(key, "")): + errors.append("server.%s differs from serverinfo" % key) + for key in ["auth_port", "game_port"]: + if int(server.get(key, -1)) != int(address.get(key, -2)): + errors.append("server.%s differs from serverinfo" % key) + return {"ok": errors.is_empty(), "errors": errors, "address": { + "server_index": index, "channel": channel, + "auth_host": String(address.get("auth_host", "")), "auth_port": int(address.get("auth_port", 0)), + "game_host": String(address.get("game_host", "")), "game_port": int(address.get("game_port", 0)), + }} + +## Resource preconditions: the map must exist in the asset root and every +## waypoint must fall inside the same Setting.txt rectangle GameScene uses to +## resolve a map from the server position. +static func check_resources(config: Dictionary, assets_root: String) -> Dictionary: + var errors: Array[String] = [] + var root := assets_root.trim_suffix("/") + var map_key := String(config.get("map_key", "")) + var setting := root.path_join(map_key).path_join("Setting.txt") + if root.is_empty() or not DirAccess.dir_exists_absolute(root): + errors.append("asset root does not exist") + return {"ok": false, "errors": errors, "bounds": {}} + if not FileAccess.file_exists(setting): + errors.append("map_key has no Setting.txt in the asset root: %s" % map_key) + return {"ok": false, "errors": errors, "bounds": {}} + var bounds := map_bounds(setting) + if bounds.is_empty(): + errors.append("map Setting.txt lacks BasePosition/MapSize: %s" % map_key) + return {"ok": false, "errors": errors, "bounds": {}} + var base: Vector2 = bounds.base + var size: Vector2i = bounds.size + var waypoints: Array = config.get("waypoints_cm", []) + for i in waypoints.size(): + var point: Array = waypoints[i] + var x := float(point[0]) + var y := float(point[1]) + if x < base.x or y < base.y or x >= base.x + size.x * MAP_CELL_CM or y >= base.y + size.y * MAP_CELL_CM: + errors.append("waypoints_cm[%d] is outside %s bounds" % [i, map_key]) + return {"ok": errors.is_empty(), "errors": errors, + "bounds": {"base_cm": [base.x, base.y], "size_cells": [size.x, size.y]}} + +static func map_bounds(setting_path: String) -> Dictionary: + var f := FileAccess.open(setting_path, FileAccess.READ) + if f == null: + return {} + var base := Vector2.ZERO + var size := Vector2i.ZERO + var have_base := false + var have_size := false + while not f.eof_reached(): + var fields := f.get_line().replace("\t", " ").strip_edges().split(" ", false) + if fields.size() < 3: + continue + match String(fields[0]).to_lower(): + "baseposition": + base = Vector2(float(fields[1]), float(fields[2])) + have_base = true + "mapsize": + size = Vector2i(int(fields[1]), int(fields[2])) + have_size = size.x > 0 and size.y > 0 + if not have_base or not have_size: + return {} + return {"base": base, "size": size} + +static func _reject_secrets(value: Variant, prefix: String, errors: Array[String]) -> void: + if value is Dictionary: + for key in value: + var name := String(key).to_lower() + for forbidden in ["account", "username", "user_id", "login", "password", "passwd", "token", "secret"]: + if name == forbidden or name.ends_with("_" + forbidden): + errors.append("credentials must not be stored in config: %s%s" % [prefix, key]) + break + _reject_secrets(value[key], "%s%s." % [prefix, key], errors) + elif value is Array: + for i in value.size(): + _reject_secrets(value[i], "%s%d." % [prefix, i], errors) + +static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void: + for i in values.size(): + if not _is_int(values[i]) or int(values[i]) <= 0: + errors.append("%s[%d] must be a positive integer" % [name, i]) + +## JSON numbers arrive as float; accept only integral values. +static func _is_int(value: Variant) -> bool: + if value is int: + return true + return value is float and is_finite(value) and float(value) == floorf(value) + +static func _validate_skill_cases(skills: Variant, errors: Array[String]) -> void: if not (skills is Array) or skills.is_empty(): errors.append("skill_cases must contain at least one confirmed skill case") else: + var seen := {} for i in skills.size(): if not (skills[i] is Dictionary): errors.append("skill_cases[%d] must be an object" % i) @@ -89,29 +451,28 @@ static func validate(config: Dictionary) -> Dictionary: for key in ["case_id", "skill_id", "required_evidence"]: if not item.has(key): errors.append("skill_cases[%d] missing %s" % [i, key]) - if String(item.get("case_id", "")).strip_edges().is_empty(): + var case_id := String(item.get("case_id", "")).strip_edges() + if case_id.is_empty(): errors.append("skill_cases[%d].case_id is empty" % i) - if int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255: + elif seen.has(case_id): + errors.append("skill_cases[%d].case_id is duplicated: %s" % [i, case_id]) + seen[case_id] = true + if not _is_int(item.get("skill_id", null)) or int(item.get("skill_id", -1)) <= 0 or int(item.get("skill_id", -1)) >= 255: errors.append("skill_cases[%d].skill_id must be 1..254" % i) - if not (item.get("required_evidence", null) is Array) or item.required_evidence.is_empty(): + if item.has("target") and not (String(item.target) in SKILL_TARGETS): + errors.append("skill_cases[%d].target must be one of %s" % [i, SKILL_TARGETS]) + if item.has("repeats") and (not _is_int(item.repeats) or int(item.repeats) < 1): + errors.append("skill_cases[%d].repeats must be a positive integer" % i) + var evidence: Variant = item.get("required_evidence", null) + if not (evidence is Array) or evidence.is_empty(): errors.append("skill_cases[%d].required_evidence must be non-empty" % i) - var resolution: Variant = config.get("resolution", null) - if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: - errors.append("resolution must be [width,height]") - if int(config.get("loops", 0)) < 1: - errors.append("loops must be at least 1") - if int(config.get("timeout_seconds", 0)) < 1: - errors.append("timeout_seconds must be positive") - # This config is deliberately data-only. Reject the common accidental secret paths. - for forbidden in ["account", "username", "password", "token", "secret"]: - if config.has(forbidden): - errors.append("credentials must not be stored in config: %s" % forbidden) - return {"ok": errors.is_empty(), "errors": errors} - -static func _validate_positive_int_array(values: Array, name: String, errors: Array[String]) -> void: - for i in values.size(): - if not _is_number(values[i]) or int(values[i]) <= 0 or float(values[i]) != float(int(values[i])): - errors.append("%s[%d] must be a positive integer" % [name, i]) - -static func _is_number(value: Variant) -> bool: - return value is int or value is float + continue + var has_server := false + for kind in evidence: + var name := String(kind) + if name in SERVER_EVIDENCE: + has_server = true + elif not (name in LOCAL_EVIDENCE): + errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name]) + if not has_server: + errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE]) diff --git a/project/testing/playable_flow.gd b/project/testing/playable_flow.gd new file mode 100644 index 00000000..48938268 --- /dev/null +++ b/project/testing/playable_flow.gd @@ -0,0 +1,1357 @@ +class_name PlayableFlow +extends RefCounted + +## NET-01 / CBT-01 联网闭环状态机(纯逻辑,可由假客户端驱动)。 +## +## 只经生产入口操作:AppFlow 自动登录/选角、PlayerController.walk_to、 +## NetPlay.select_target / set_attack_key / pick_ground_item、Quickbar.assign/activate。 +## 成功证据只来自 PlayableProbe 的 source=server 观察或 M2Client 状态查询; +## 本地位置、施法开始、特效节点只作补充,不单独构成服务端结果。 +## +## 所有时间来自注入的 clock_us,harness 可以不等待真实时间验证超时分支。 +## +## STB-01 soak:同一套 MOVE…PICKUP 按轮重复,按墙钟期限(clock_us 经过的时长)结束, +## 不按轮数或帧数。每轮后依次:真实传输故障(需确认的本地代理)、合法传送门往返 +## (需确认的路线)、三种逻辑分辨率切换(需窗口适配器)、≥30s 休息(内存低水位窗口)。 +## 主动 reconnect() 与真实故障分开计数;未确认的条件保持 BLOCKED,不互相替代。 + +signal finished(status: String) + +const Config = preload("res://testing/playable_config.gd") +const Metrics = preload("res://testing/playable_metrics.gd") + +const LOGIN_TIMEOUT_US := 30_000_000 +const SELECT_TIMEOUT_US := 45_000_000 +const WORLD_TIMEOUT_US := 60_000_000 +const MOVE_TIMEOUT_US := 20_000_000 +const CONFIRM_TIMEOUT_US := 60_000_000 +const TARGET_TIMEOUT_US := 30_000_000 +const ATTACK_TIMEOUT_US := 90_000_000 +const DROP_TIMEOUT_US := 30_000_000 +const PICKUP_TIMEOUT_US := 15_000_000 +const PICKUP_SETTLE_US := 2_000_000 +const SKILL_CAST_TIMEOUT_US := 10_000_000 +const SKILL_RETRY_US := 500_000 +## Soak: the scene must stay ready this long after a re-login before rebind checks run, +## so a node still fading out from the old connection is not mistaken for a live duplicate. +const REBIND_SETTLE_US := 1_000_000 +const FAULT_DISCONNECT_TIMEOUT_US := 30_000_000 +## Recovery budget after the transport fault ends: ReconnectUI retry delay + login + map load. +const FAULT_RECOVERY_GRACE_US := 120_000_000 +const WARP_TIMEOUT_US := 120_000_000 +const RESIZE_SETTLE_US := 500_000 +const RESIZE_TIMEOUT_US := 5_000_000 +const RESIZE_TOLERANCE_PX := 2 +const FRAME_HISTORY_CAP := 64 +const RECONNECT_STAGES := ["MOVE_CONFIRM", "WARP", "FAULT"] +const SOAK_PLAY_STAGES := ["MOVE", "TARGET", "ATTACK", "DROP", "PICKUP", "RESIZE", "REST"] +const SOAK_STEPS := ["FAULT", "WARP", "RESIZE", "REST"] + +const ARRIVE_M := 1.5 +const CORRECTION_CM := 500.0 +const CONFIRM_CM := 300.0 +const DROP_RADIUS_CM := 600.0 +const MAX_KILLS := 3 +const MONSTER_CH_TYPE := 2 +const SKILL_SLOT := 7 +const DEFAULT_SKILL_REPEATS := 10 +## One-shot effects must leave the tree within their MSE-defined lifetime + this grace. +const FX_GRACE_MS := 1000 +## PlayerSkill codes that mean the fixture (not the client) is missing something. +const FIXTURE_SKILL_CODES := ["NOT_YET_LEARN", "NO_SKILL_DATA", "PASSIVE", "NOT_MATCHABLE_WEAPON", "EMPTY_ARROW", "HAVE_TO_RIDE", "NO_RIDING_SKILL"] + +var report: RefCounted +var probe: Object +var flow: Object +var client: Object +var config: Dictionary = {} +var suite := "playable" +var allow_gameplay := false +var clock_us: Callable = func() -> int: return Time.get_ticks_usec() +## func(name: String) -> String; returns an evidence path relative to the report, or "". +var capture: Callable +## Soak only. PlayableMetrics fed once per frame by the live runner (null offline -> STB-FRAMES-01 BLOCKED). +var metrics: RefCounted +## Soak only. Window adapter (testing/playable_window.gd): availability / request_logical_size / +## logical_size / physical_size / viewport_size / screen_scale. null -> STB-RESIZE-01 BLOCKED. +var window: Object +## Unix wall-clock milliseconds for rest windows matched against the external RSS sampler. +var wall_ms: Callable = func() -> int: return int(Time.get_unix_time_from_system() * 1000.0) + +var stage := "BOOT" +var done := false +var status := "" + +var _stage_since := 0 +var _main_vid := 0 +var _bound_epoch := 0 +var _entered_seen := false +var _waypoint := 0 +var _expected_world := Vector3.ZERO +var _reconnect_epoch := 0 +var _reconnect_entered := 0 +var _rebind_since := -1 +var _confirm_target_cm := Vector2.ZERO +var _confirm_blocked := false +var _kills := 0 +var _abandoned := {} +var _target := {} +var _attack_down := false +var _damaged := false +var _dead := false +var _kill_pos_cm := Vector2.ZERO +var _pre_attack_iids := {} +var _drop := {} +var _pickup_before := 0 +var _pickup_removed_at := -1 +var _skill_index := 0 +var _skill_successes := 0 +var _skill_cast := {} +var _skill_next_try := 0 +var _skill_draining := false +var _open_fx := {} # fx_id -> {effect, lifetime_ms, spawned_us, case_id} +## §7.1 item 6: the first cast of each skill case is captured at its own event +## times (before / trigger / peak / end), not on a fixed one-second delay. +var _shots: Array = [] # evidence files attached to the skill case result +var _shot_phases := {} # phase -> true once attempted for this case +var _shot_fx := {} # fx_id -> peak due (us) for first-cast timed effects +var _shot_end_pending := false +var _start_us := 0 +# --- soak --- +var _round := 0 +var _rounds_done := 0 +var _soak_start_us := -1 +var _soak_wall_start_ms := 0 +var _soak_blocked := "" +var _active_reconnects := 0 +var _faults := {"close": 0, "unreachable": 0} +var _fault := {} +var _warps := 0 +var _warp_leg := 0 +var _warp_expected := "" +var _resize_index := -1 +var _resize_since := 0 +var _resize_before := Vector2i.ZERO +var _resize_counts := {} +var _resize_blocked := "" +var _severe_seen := {} +var _frame_history := {} # interaction (stage) -> last FRAME_HISTORY_CAP bools +var _repeated_severe := {} # interaction -> true once 2 of 3 consecutive repetitions had a >100ms frame + +func start() -> void: + _start_us = _now() + report.set_required_cases(Config.required_cases(config, suite)) + var server: Dictionary = config.get("server", {}) + var address: Dictionary = {} + if flow != null and flow.has_method("select_test_server"): + address = flow.select_test_server(int(server.get("server_index", -1)), int(server.get("channel", 0))) + var match_ok := not address.is_empty() + for key in ["auth_host", "game_host"]: + match_ok = match_ok and String(address.get(key, "")) == String(server.get(key, "")) + for key in ["auth_port", "game_port"]: + match_ok = match_ok and int(address.get(key, -1)) == int(server.get(key, -2)) + _event("address_selected", {"server_index": int(server.get("server_index", -1)), + "channel": int(server.get("channel", 0)), "address_match": match_ok}) + if not match_ok: + _finish_case("CONFIG-01", "FAIL", "client serverinfo address differs from the prechecked config address") + _finish() + return + report.add_case("CONFIG-01", "PASS", "config validated and client address matches precheck") + _enter("LOGIN") + +## Probe observation entry. Every observation is persisted as a contract event +## before it is interpreted. +func on_observed(kind: String, data: Dictionary) -> void: + var epoch := int(data.get("connection_epoch", 0)) + var stale := _bound_epoch > 0 and epoch != _bound_epoch and kind != "entered_game" and kind != "disconnected" + var payload: Dictionary = data.duplicate() + payload.erase("connection_epoch") + payload.erase("actor_vid") + payload.erase("target_vid") + payload.erase("received_us") + if stale: + payload["stale_epoch"] = true + report.record(kind, stage, _case_for_stage(), epoch, int(data.get("actor_vid", 0)), + int(data.get("target_vid", 0)), payload) + if done: + return + match kind: + "login_failed": + if stage == "LOGIN" or stage == "SELECT": + _finish_case("NET-LOGIN-01", "FAIL", "login rejected by server") + _finish() + "char_list": + if stage == "LOGIN": + var slot := int(config.get("character_slot", -1)) + if slot in data.get("slots", []): + report.add_case("NET-LOGIN-01", "PASS", "server char_list contains the configured slot") + _enter("SELECT") + else: + _finish_case("NET-LOGIN-01", "FAIL", "configured character slot is absent") + _finish() + "entered_game": + _entered_seen = true + _on_entered_game(epoch) + "disconnected": + _on_disconnected() + "entity_moved": + if stage == "MOVE" and not stale and int(data.get("actor_vid", 0)) == _main_vid: + _check_server_correction(data.get("pos_cm", null)) + "damage": + if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("amount", 0)) > 0: + _damaged = true + if stage == "SKILL" and not stale: + _skill_evidence("damage", int(data.get("target_vid", 0))) + "vitals_changed": + if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))): + var hp := int(data.get("hp", -1)) + if hp >= 0 and int(_target.get("hp", -1)) > 0 and hp < int(_target.hp): + _damaged = true + _target["hp"] = hp + "target_info": + if stage == "ATTACK" and not stale and _is_target(int(data.get("target_vid", 0))) and int(data.get("hp_percent", 100)) < 100: + _damaged = true + "entity_dead": + var dead_vid := int(data.get("target_vid", 0)) + if stage == "ATTACK" and not stale and _is_target(dead_vid): + _dead = true + if stage == "SKILL" and not stale: + _skill_evidence("target_dead", dead_vid) + "entity_despawned": + if stage == "ATTACK" and not stale and _is_target(int(data.get("actor_vid", 0))) and not _dead: + _target_lost("target despawned before a server death result") + "server_motion": + if stage == "SKILL" and not stale and int(data.get("actor_vid", 0)) == _main_vid: + _skill_evidence("server_motion", int(data.get("target_vid", 0))) + "effect_cue", "fly_cue", "affect_added", "sp_changed": + if stage == "SKILL" and not stale: + var actor := int(data.get("actor_vid", 0)) + if kind in ["affect_added", "sp_changed"] or actor == _main_vid or actor == int(_skill_cast.get("target_vid", -1)): + _skill_evidence(kind, int(data.get("target_vid", 0))) + "cast_started": + if stage == "SKILL" and String(data.get("source", "")) == "local": + _on_cast_started(int(data.get("skill_id", 0)), int(data.get("target_vid", 0))) + "skill_rejected": + if stage == "SKILL": + _on_skill_rejected(int(data.get("skill_id", 0)), String(data.get("code", ""))) + "fx_spawned": + _on_fx_spawned(data) + if stage == "SKILL": + _skill_evidence(kind, 0) + _sample_fx_spawned(data) + "fx_finished": + if not _on_fx_finished(data): + return + if stage == "SKILL": + _skill_evidence(kind, 0) + _sample_fx_finished(data) + +func tick() -> void: + if done: + return + var now := _now() + var timeout_us := int(config.get("timeout_seconds", 120)) * 1_000_000 + if now - _start_us > timeout_us: + _fail_stage("overall scenario timeout") + return + if not _check_open_fx(now): + return + if stage == "SKILL": + _sample_due(now) + match stage: + "LOGIN": + if now - _stage_since > LOGIN_TIMEOUT_US: + _finish_case("NET-LOGIN-01", "FAIL", "no server char_list within 30s") + _finish() + "SELECT": + if _entered_seen: + var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 + var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {} + if vid > 0 and not main.is_empty(): + _main_vid = vid + _bound_epoch = _probe_epoch() + report.add_case("NET-SELECT-01", "PASS", "entered_game received and main entity is present") + _enter("WORLD") + return + if now - _stage_since > SELECT_TIMEOUT_US: + _finish_case("NET-SELECT-01", "FAIL", "entered_game/main entity not observed within 45s") + _finish() + "WORLD": + var ready := _world_ready() + if ready.ready: + if String(ready.map_key) != String(config.get("map_key", "")): + _event("map_mismatch", {"map_key": ready.map_key, "expected_map_key": config.map_key}) + _finish_case("NET-WORLD-01", "FAIL", "loaded map_key differs from the configured map") + _finish() + return + _pass("NET-WORLD-01", "scene, map, player and HUD ready") + if not allow_gameplay: + _block_remaining("gameplay_not_allowed") + _finish() + return + if suite == "soak": + _begin_soak(now) + return + _begin_move(0) + elif now - _stage_since > WORLD_TIMEOUT_US: + _event("world_not_ready", ready) + _finish_case("NET-WORLD-01", "FAIL", "scene/map/player/HUD not ready within 60s") + _finish() + "MOVE": + var player: Node3D = _context().get("player", null) + if player != null and is_instance_valid(player) \ + and Vector2(player.position.x, player.position.z).distance_to(Vector2(_expected_world.x, _expected_world.z)) <= ARRIVE_M: + _pass(_move_case(), "local arrival without server correction") + if _waypoint + 1 < config.waypoints_cm.size(): + _begin_move(_waypoint + 1) + else: + _begin_confirm() + elif now - _stage_since > MOVE_TIMEOUT_US: + _fail_stage("waypoint not reached within 20s") + "MOVE_CONFIRM": + _tick_confirm(now) + "TARGET": + _tick_target(now) + "ATTACK": + _tick_attack(now) + "DROP": + _tick_drop(now) + "PICKUP": + _tick_pickup(now) + "SKILL": + _tick_skill(now) + "FAULT": + _tick_fault(now) + "WARP": + _tick_warp(now) + "RESIZE": + _tick_resize(now) + "REST": + _tick_rest(now) + +## Cancel all pending input immediately. Safe to call repeatedly. +func cancel_inputs() -> void: + if _attack_down: + var net_play: Object = _context().get("net_play", null) + if net_play != null and is_instance_valid(net_play) and net_play.has_method("set_attack_key"): + net_play.set_attack_key(false) + _attack_down = false + _target = {} + _skill_cast = {} + +# --- stages ----------------------------------------------------------------- + +func _enter(next: String) -> void: + _track_frames(stage) + stage = next + _stage_since = _now() + var snap: Dictionary = probe.snapshot() if probe != null and probe.has_method("snapshot") else {} + var flow_snap := _flow_snapshot() + snap["state"] = next + snap["scene_ready"] = bool(flow_snap.get("scene_ready", false)) + snap["reconnecting"] = bool(flow_snap.get("reconnecting", false)) + snap["character_count"] = int(flow_snap.get("character_count", 0)) + _event("state_enter", snap) + +func _begin_move(index: int) -> void: + _waypoint = index + var point: Array = config.waypoints_cm[index] + _expected_world = MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01)) + _enter("MOVE") + var controller: Object = _context().get("pc", null) + if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"): + _fail_stage("PlayerController.walk_to is unavailable") + return + controller.walk_to(_expected_world) + _event("move_requested", {"waypoint_index": index, "pos_cm": Vector2(float(point[0]), float(point[1]))}) + +func _check_server_correction(pos: Variant) -> void: + var player: Node3D = _context().get("player", null) + if not (pos is Vector3) or player == null or not is_instance_valid(player): + return + var local_cm := MapCoord.to_server_cm(player.position) + var distance := local_cm.distance_to(Vector2(pos.x, pos.y)) + if distance > CORRECTION_CM: + _event("server_correction", {"distance_cm": distance, "tolerance_cm": CORRECTION_CM, "waypoint_index": _waypoint}) + _fail_stage("server corrected the main character position by %.0fcm" % distance) + +func _begin_confirm() -> void: + var last: Array = config.waypoints_cm[config.waypoints_cm.size() - 1] + _confirm_target_cm = Vector2(float(last[0]), float(last[1])) + _arm_reconnect() + _confirm_blocked = false + _enter("MOVE_CONFIRM") + cancel_inputs() + if not client.has_method("reconnect") or not bool(client.reconnect()): + _confirm_blocked = true + _finish_case("NET-MOVE-CONFIRM-01", "BLOCKED", "reconnect is unavailable; no server position source") + if suite == "soak": + _finish_case("STB-RECONNECT-01", "BLOCKED", "reconnect is unavailable in round %d" % _round) + _finish() + return + _enter_target() + +func _tick_confirm(now: int) -> void: + var bound := _rebound(now) + if bound < 0: + return + if bound > 0: + var pos: Variant = client.get_entity(_main_vid).get("pos_cm", null) + var distance := Vector2(pos.x, pos.y).distance_to(_confirm_target_cm) + _event("server_position", {"main_vid": _main_vid, "pos_cm": Vector2(pos.x, pos.y), "distance_cm": distance, "tolerance_cm": CONFIRM_CM}) + if distance <= CONFIRM_CM: + _pass("NET-MOVE-CONFIRM-01", "server spawn position after re-login matches the last waypoint") + else: + _finish_case("NET-MOVE-CONFIRM-01", "FAIL", "server position after re-login is %.0fcm from the last waypoint" % distance) + _finish() + return + if suite == "soak": + _active_reconnects += 1 + _event("reconnect_verified", {"round": _round, "count": _active_reconnects}) + _enter_target() + return + if now - _stage_since > CONFIRM_TIMEOUT_US: + _finish_case("NET-MOVE-CONFIRM-01", "FAIL", "re-login did not return a main entity/scene within 60s") + _finish() + +func _enter_target() -> void: + _target = {} + _damaged = false + _dead = false + _enter("TARGET") + +func _tick_target(now: int) -> void: + var candidate := _pick_monster() + if not candidate.is_empty(): + var net_play: Object = _context().get("net_play", null) + if net_play == null or not is_instance_valid(net_play) or not net_play.has_method("select_target"): + _fail_stage("NetPlay.select_target is unavailable") + return + if bool(net_play.select_target(int(candidate.vid))): + _target = candidate + _target["epoch"] = _bound_epoch + _event("target_selected", {"race": candidate.race, "pos_cm": candidate.pos_cm, "attempt": _kills + 1}) + if not report.has_case("NET-TARGET-01"): + report.add_case("NET-TARGET-01", "PASS", "allowed live monster selected in the current connection epoch") + if stage == "TARGET": + _begin_attack() + return + _abandoned[int(candidate.vid)] = true + if now - _stage_since > TARGET_TIMEOUT_US: + if not report.has_case("NET-TARGET-01") or (suite == "soak" and _kills == 0): + _finish_case("NET-TARGET-01", "BLOCKED", "no allowed live monster in range (fixture)") + else: + _finish_case("NET-DROP-01", "BLOCKED", "no further allowed monster after %d kills" % _kills) + _finish() + +func _pick_monster() -> Dictionary: + if not client.has_method("get_entities"): + return {} + var allowed: Array = config.get("allowed_mob_vnums", []).map(func(v): return int(v)) + var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {} + var origin: Variant = main.get("pos_cm", Vector3.ZERO) + var best := {} + var best_distance := INF + for entity in client.get_entities(): + var vid := int(entity.get("vid", 0)) + if vid <= 0 or _abandoned.has(vid) or bool(entity.get("dead", false)): + continue + if int(entity.get("ch_type", -1)) != MONSTER_CH_TYPE or not (int(entity.get("race", 0)) in allowed): + continue + var pos: Variant = entity.get("pos_cm", Vector3.ZERO) + var distance := Vector2(pos.x, pos.y).distance_to(Vector2(origin.x, origin.y)) if pos is Vector3 and origin is Vector3 else 0.0 + if distance < best_distance: + best_distance = distance + best = {"vid": vid, "race": int(entity.get("race", 0)), "hp": int(entity.get("hp", -1)), + "pos_cm": Vector2(pos.x, pos.y) if pos is Vector3 else Vector2.ZERO} + return best + +func _begin_attack() -> void: + _damaged = false + _dead = false + _pre_attack_iids = _ground_iids() + _enter("ATTACK") + var net_play: Object = _context().get("net_play", null) + if net_play == null or not net_play.has_method("set_attack_key"): + _fail_stage("NetPlay.set_attack_key is unavailable") + return + net_play.set_attack_key(true) + _attack_down = true + +func _tick_attack(now: int) -> void: + if _target.is_empty(): + return + var entity: Dictionary = client.get_entity(int(_target.vid)) if client.has_method("get_entity") else {} + var pos: Variant = entity.get("pos_cm", null) + if pos is Vector3: + _target["pos_cm"] = Vector2(pos.x, pos.y) + if _dead: + if not _damaged: + # A death without any HP/damage evidence is not a verified combat result. + cancel_inputs() + _fail_stage("target died without a received damage/HP change") + return + _kills += 1 + _kill_pos_cm = _target.get("pos_cm", Vector2.ZERO) + _abandoned[int(_target.vid)] = true + cancel_inputs() + _event("target_killed", {"attempt": _kills, "pos_cm": _kill_pos_cm}) + if not report.has_case("NET-ATTACK-01"): + report.add_case("NET-ATTACK-01", "PASS", "received damage/HP change and death for the selected target") + _drop = {} + _enter("DROP") + return + if now - _stage_since > ATTACK_TIMEOUT_US: + cancel_inputs() + _fail_stage("no server damage and death result within 90s") + +## NET-04: target vanished before death -> release input, never chase the old VID. +func _target_lost(reason: String) -> void: + _event("target_lost", {"attempt": _kills + 1, "reason": reason}) + _abandoned[int(_target.get("vid", 0))] = true + cancel_inputs() + _kills += 1 + if _kills >= MAX_KILLS: + _finish_case("NET-ATTACK-01", "BLOCKED", "target lost before death on %d attempts" % _kills) + _finish() + return + _enter_target() + +func _tick_drop(now: int) -> void: + var allowed: Array = config.get("allowed_drop_vnums", []).map(func(v): return int(v)) + for item in _ground_items(): + var iid := int(item.get("vid", 0)) + if _pre_attack_iids.has(iid): + continue + var vnum := int(item.get("vnum", 0)) + var pos_cm := _ground_pos_cm(item) + var distance := pos_cm.distance_to(_kill_pos_cm) + if vnum == Config.GOLD_VNUM: + _pre_attack_iids[iid] = true + _event("gold_drop_seen", {"iid": iid, "distance_cm": distance}) + continue + if not (vnum in allowed): + continue + var owner_ok := _owner_matches(String(item.get("owner", ""))) + if not owner_ok or distance > DROP_RADIUS_CM: + _pre_attack_iids[iid] = true + _event("drop_rejected", {"iid": iid, "vnum": vnum, "owner_match": owner_ok, "distance_cm": distance, "tolerance_cm": DROP_RADIUS_CM}) + continue + _drop = {"iid": iid, "vnum": vnum} + _event("drop_accepted", {"iid": iid, "vnum": vnum, "owner_match": true, "distance_cm": distance}) + _pass("NET-DROP-01", "new allowed ground item, pickable owner, near the killed target") + _begin_pickup() + return + if now - _stage_since > DROP_TIMEOUT_US: + if _kills < MAX_KILLS: + _event("no_drop", {"attempt": _kills}) + _enter_target() + else: + _finish_case("NET-DROP-01", "BLOCKED", "no allowed pickable drop after %d kills (fixture)" % _kills) + _finish() + +func _begin_pickup() -> void: + _pickup_before = _inventory_total(int(_drop.vnum)) + _pickup_removed_at = -1 + _enter("PICKUP") + var net_play: Object = _context().get("net_play", null) + if net_play == null or not net_play.has_method("pick_ground_item"): + _fail_stage("NetPlay.pick_ground_item is unavailable") + return + net_play.pick_ground_item(int(_drop.iid)) + _event("pickup_requested", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before}) + +func _tick_pickup(now: int) -> void: + var present := _ground_iids().has(int(_drop.iid)) + var after := _inventory_total(int(_drop.vnum)) + if not present and _pickup_removed_at < 0: + _pickup_removed_at = now + if not present and after > _pickup_before: + _event("pickup_verified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after, "delta": after - _pickup_before}) + _pass("NET-PICKUP-01", "ground item removed and inventory vnum total increased") + _after_closed_loop() + return + if _pickup_removed_at >= 0 and now - _pickup_removed_at > PICKUP_SETTLE_US: + _event("pickup_unverified", {"iid": _drop.iid, "vnum": _drop.vnum, "before": _pickup_before, "after": after}) + _fail_stage("ground item vanished without an inventory increase (taken by another player or inventory full)") + return + if now - _stage_since > PICKUP_TIMEOUT_US: + _fail_stage("pickup not accepted within 15s (inventory full, ownership or range)") + +func _after_closed_loop() -> void: + if suite == "soak": + _soak_continue("") + return + if suite == "full" and not config.get("skill_cases", []).is_empty(): + _skill_index = 0 + _begin_skill_case() + return + _finish() + +# --- CBT-01: skills ------------------------------------------------------------ + +func _begin_skill_case() -> void: + _skill_successes = 0 + _skill_cast = {} + _skill_next_try = 0 + _skill_draining = false + _shots = [] + _shot_phases = {} + _shot_fx = {} + _shot_end_pending = false + _enter("SKILL") + var item: Dictionary = config.skill_cases[_skill_index] + var quickbar: Object = _context().get("quickbar", null) + if quickbar == null or not is_instance_valid(quickbar) or not quickbar.has_method("assign"): + _fail_stage("Quickbar is unavailable") + return + # Local-only slot: no CG_QUICKSLOT_ADD persistence on the account. + if not bool(quickbar.assign(SKILL_SLOT, "skill", int(item.skill_id), false)): + _fail_stage("Quickbar.assign rejected the skill") + +func _tick_skill(now: int) -> void: + var item: Dictionary = config.skill_cases[_skill_index] + if _skill_draining: + # The case passes only after its one-shot effects actually left the tree; + # _check_open_fx fails it once one outlives lifetime + grace. + if _open_fx_for(_case_for_stage()).is_empty(): + _skill_draining = false + report.add_case(Config.skill_case_id(item), "PASS", "server evidence observed for every cast; effects cleaned up", + _shots.duplicate(), float(now - _stage_since) / 1000.0) + _skill_index += 1 + if _skill_index >= config.skill_cases.size(): + _finish() + else: + _begin_skill_case() + return + if not _skill_cast.is_empty(): + if _skill_missing().is_empty(): + _skill_successes += 1 + _event("skill_verified", {"skill_id": item.skill_id, "count": _skill_successes}) + _skill_cast = {} + _skill_next_try = now + SKILL_RETRY_US + if _skill_successes >= int(item.get("repeats", DEFAULT_SKILL_REPEATS)): + _skill_draining = true + _tick_skill(now) + return + if now - int(_skill_cast.started_us) > SKILL_CAST_TIMEOUT_US: + _fail_stage("cast %d missing evidence: %s" % [_skill_successes + 1, ",".join(_skill_missing())]) + return + if now - _stage_since > SKILL_CAST_TIMEOUT_US * (int(item.get("repeats", DEFAULT_SKILL_REPEATS)) + 2): + _fail_stage("skill case did not complete in its time budget") + return + if now < _skill_next_try: + return + if String(item.get("target", "enemy")) == "enemy": + var target := _pick_monster() + var net_play: Object = _context().get("net_play", null) + if target.is_empty() or net_play == null or not bool(net_play.select_target(int(target.vid))): + _skill_next_try = now + SKILL_RETRY_US + return + var quickbar: Object = _context().get("quickbar", null) + if _skill_successes == 0: + _shoot("before") + _skill_cast = {"pending": true, "skill_id": int(item.skill_id), "started_us": now, "evidence": {}, "casts": 0} + quickbar.activate(SKILL_SLOT) + if _skill_cast.get("pending", false) and int(_skill_cast.casts) == 0: + # No cast_started emitted synchronously: cooldown / gate silently held it. + _skill_cast = {} + _skill_next_try = now + SKILL_RETRY_US + +func _on_cast_started(skill_id: int, target_vid: int) -> void: + if _skill_cast.is_empty() and stage == "SKILL" and not _skill_draining \ + and skill_id == int(config.skill_cases[_skill_index].skill_id): + # An earlier activation was out of range and reserved (__ReserveUseSkill); + # NetPlay walked in and the reserved slot fired now. Track it as this case's cast. + _skill_cast = {"pending": true, "skill_id": skill_id, "started_us": _now(), "evidence": {}, "casts": 0, "reserved": true} + if _skill_cast.is_empty() or skill_id != int(_skill_cast.skill_id): + return + _skill_cast.casts = int(_skill_cast.casts) + 1 + if int(_skill_cast.casts) > 1: + _fail_stage("duplicate cast request for one activation") + return + _skill_cast.pending = false + if _skill_successes == 0: + _shoot("trigger") + # Bind the case to the target at cast time; later selection changes do not re-route evidence. + _skill_cast.target_vid = target_vid + _skill_cast.evidence["cast_started"] = true + +func _on_skill_rejected(skill_id: int, code: String) -> void: + var item: Dictionary = config.skill_cases[_skill_index] + if skill_id != int(item.skill_id): + return + _skill_cast = {} + if code in FIXTURE_SKILL_CODES: + _finish_case(Config.skill_case_id(item), "BLOCKED", "skill fixture not ready: %s" % code) + _finish() + return + _skill_next_try = _now() + SKILL_RETRY_US + +func _skill_evidence(kind: String, target_vid: int) -> void: + if _skill_cast.is_empty() or bool(_skill_cast.get("pending", false)): + return + if kind in ["damage", "target_dead"] and target_vid != int(_skill_cast.get("target_vid", 0)): + return + # Local kinds are recorded too, but validate() requires at least one server kind per case. + _skill_cast.evidence[kind] = true + +func _skill_missing() -> Array: + var missing: Array = [] + var item: Dictionary = config.skill_cases[_skill_index] + for kind in item.required_evidence: + if not _skill_cast.get("evidence", {}).has(String(kind)): + missing.append(String(kind)) + return missing + +# --- CBT-01: effect lifecycle ---------------------------------------------------- +# Evidence comes from EffectRegistry spawn / tree-exit boundaries, never node counts. + +func _on_fx_spawned(data: Dictionary) -> void: + var lifetime_ms := int(data.get("lifetime_ms", -1)) + if lifetime_ms < 0 or String(data.get("source", "")) != "local": + return # looping effects live as long as their owner + _open_fx[int(data.get("fx_id", 0))] = {"effect": String(data.get("effect", "")), + "lifetime_ms": lifetime_ms, "spawned_us": _now(), "case_id": _case_for_stage()} + +## Returns false when the finish violated the lifetime contract (the run is over). +func _on_fx_finished(data: Dictionary) -> bool: + var fx_id := int(data.get("fx_id", 0)) + var info: Dictionary = _open_fx.get(fx_id, {}) + _open_fx.erase(fx_id) + var lifetime_ms := int(data.get("lifetime_ms", -1)) + var elapsed_ms := int(data.get("elapsed_ms", -1)) + if lifetime_ms >= 0 and elapsed_ms > lifetime_ms + FX_GRACE_MS: + var case_id := String(info.get("case_id", _case_for_stage())) + _fx_overdue(case_id, String(data.get("effect", "")), lifetime_ms, elapsed_ms) + return false + return true + +func _check_open_fx(now: int) -> bool: + for fx_id in _open_fx.keys(): + var info: Dictionary = _open_fx[fx_id] + var elapsed_ms := (now - int(info.spawned_us)) / 1000 + if elapsed_ms > int(info.lifetime_ms) + FX_GRACE_MS: + _open_fx.erase(fx_id) + _fx_overdue(String(info.case_id), String(info.effect), int(info.lifetime_ms), elapsed_ms) + return false + return true + +func _shoot(phase: String) -> void: + if _shot_phases.has(phase) or not capture.is_valid() or _skill_index >= config.skill_cases.size(): + return + _shot_phases[phase] = true + var file := String(capture.call("%s-%s" % [Config.skill_case_id(config.skill_cases[_skill_index]), phase])) + _event("skill_capture", {"phase": phase, "screenshot": file}) + if not file.is_empty(): + _shots.append(file) + +## Peak = half of the effect's own defined lifetime, sampled on the tick it falls due. +func _sample_fx_spawned(data: Dictionary) -> void: + var lifetime_ms := int(data.get("lifetime_ms", -1)) + if lifetime_ms < 0 or _skill_successes > 0 or _skill_cast.is_empty() or _shot_phases.has("end"): + return + _shot_fx[int(data.get("fx_id", 0))] = _now() + lifetime_ms * 500 + +func _sample_fx_finished(data: Dictionary) -> void: + var fx_id := int(data.get("fx_id", 0)) + if not _shot_fx.has(fx_id): + return + if int(_shot_fx[fx_id]) > 0: + _shoot("peak") # finished before its half-life tick: take the last live frame + _shot_fx.erase(fx_id) + _shot_end_pending = _shot_fx.is_empty() + +func _sample_due(now: int) -> void: + for fx_id in _shot_fx: + if int(_shot_fx[fx_id]) > 0 and now >= int(_shot_fx[fx_id]): + _shoot("peak") + _shot_fx[fx_id] = 0 + if _shot_end_pending: + # One tick after the last first-cast effect left the tree. + _shot_end_pending = false + _shoot("end") + +func _open_fx_for(case_id: String) -> Array: + var out: Array = [] + for fx_id in _open_fx: + if String(_open_fx[fx_id].case_id) == case_id: + out.append(fx_id) + return out + +func _fx_overdue(case_id: String, effect: String, lifetime_ms: int, elapsed_ms: int) -> void: + var message := "effect %s not cleaned within lifetime %dms + %dms (elapsed %dms)" % [effect, lifetime_ms, FX_GRACE_MS, elapsed_ms] + _event("fx_overdue", {"effect": effect, "lifetime_ms": lifetime_ms, "elapsed_ms": elapsed_ms, + "open_effects": _open_fx.size()}) + cancel_inputs() + if report.has_case(case_id): + report.add_failure("%s: %s" % [case_id, message]) + else: + _finish_case(case_id, "FAIL", message) + _finish() + +# --- reconnect binding (NET-01 confirm, STB-01 soak) ------------------------------ + +func _arm_reconnect() -> void: + _reconnect_epoch = _probe_epoch() + _reconnect_entered = 0 + _rebind_since = -1 + +func _on_entered_game(epoch: int) -> void: + if stage in RECONNECT_STAGES: + if suite == "soak" and stage == "FAULT" and int(_fault.get("disconnected_us", -1)) < 0: + _soak_fail("STB-DISCONNECT-01", "FAIL", "entered_game before the requested transport fault disconnected") + return + _reconnect_entered += 1 + if suite == "soak" and _reconnect_entered > 1: + _soak_fail(_rebind_case(), "FAIL", "duplicate entered_game without a disconnect during %s" % stage) + return + if epoch > _reconnect_epoch: + _bound_epoch = epoch + elif suite == "soak" and stage in SOAK_PLAY_STAGES: + _fail_stage("unexpected entered_game during %s (no reconnect was requested)" % stage) + +## 0 = still waiting, 1 = main VID / scene / UI re-acquired in the new epoch, -1 = run finished. +## Soak additionally waits REBIND_SETTLE_US of continuous readiness, then checks for +## duplicate entity nodes and a target / attack key left over from the old connection. +func _rebound(now: int) -> int: + if _bound_epoch <= _reconnect_epoch: + _rebind_since = -1 + return 0 + var vid := int(client.get_main_vid()) if client.has_method("get_main_vid") else 0 + var main: Dictionary = client.get_entity(vid) if vid > 0 and client.has_method("get_entity") else {} + var pos: Variant = main.get("pos_cm", null) + var ready := _world_ready() + if vid <= 0 or not (pos is Vector3) or (pos.x == 0.0 and pos.y == 0.0) or not ready.ready \ + or bool(_flow_snapshot().get("reconnecting", false)): + _rebind_since = -1 + return 0 + _main_vid = vid + if suite != "soak": + return 1 + if _rebind_since < 0: + _rebind_since = now + if now - _rebind_since < REBIND_SETTLE_US: + return 0 + var checks := _rebind_checks() + _event("rebind_checked", {"main_vid": vid, "round": _round, "duplicate_vids": checks.duplicate_vids, + "target_residue": checks.target_residue, "map_key": ready.map_key, "state": checks.status}) + if checks.status != "PASS": + _soak_fail(_rebind_case(), checks.status, checks.reason) + return -1 + return 1 + +func _rebind_checks() -> Dictionary: + var out := {"status": "PASS", "reason": "", "duplicate_vids": [], "target_residue": []} + var context := _context() + var net_world: Variant = context.get("net_world", null) + var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null + if parent == null or not is_instance_valid(parent): + out.status = "BLOCKED" + out.reason = "NetWorld entity registry is unavailable; duplicate entity check not possible" + return out + var seen := {} + for child in parent.get_children(): + if child.is_queued_for_deletion() or not child.has_meta("vid"): + continue + var vid := int(child.get_meta("vid")) + if seen.has(vid) and not (vid in out.duplicate_vids): + out.duplicate_vids.append(vid) + seen[vid] = true + var net_play: Variant = context.get("net_play", null) + if net_play != null and is_instance_valid(net_play): + var target_vid := int(net_play.get("_target_vid")) if "_target_vid" in net_play else 0 + if target_vid > 0 and client.get_entity(target_vid).is_empty(): + out.target_residue.append("target_vid") + if "_attack_key_down" in net_play and bool(net_play.get("_attack_key_down")): + out.target_residue.append("attack_key_down") + if not out.duplicate_vids.is_empty(): + out.status = "FAIL" + out.reason = "duplicate entity nodes after re-login: %s" % [out.duplicate_vids] + elif not out.target_residue.is_empty(): + out.status = "FAIL" + out.reason = "old target/attack state kept after re-login: %s" % [out.target_residue] + return out + +func _rebind_case() -> String: + match stage: + "FAULT": return "STB-DISCONNECT-01" + "WARP": return "STB-WARP-01" + return "STB-RECONNECT-01" + +# --- STB-01: soak rounds ------------------------------------------------------------ + +func _soak_cfg() -> Dictionary: + var soak: Variant = config.get("soak", {}) + return soak if soak is Dictionary else {} + +func _begin_soak(now: int) -> void: + var checked := Config.validate_soak(config) + if not checked.ok: + _block_remaining("soak config invalid: %s" % ", ".join(checked.errors)) + _finish() + return + _soak_start_us = now + _soak_wall_start_ms = _wall_ms() + _round = 0 + for size in _soak_cfg().resolutions.sizes: + _resize_counts["%dx%d" % [int(size[0]), int(size[1])]] = 0 + _begin_round() + +func _begin_round() -> void: + _kills = 0 + _abandoned = {} + _event("soak_round", {"round": _round, "wall_ms": _wall_ms()}) + _begin_move(0) + +## Runs the post-pickup steps in SOAK_STEPS order, starting after `after`. +func _soak_continue(after: String) -> void: + var soak := _soak_cfg() + var start := SOAK_STEPS.find(after) + 1 + for i in range(start, SOAK_STEPS.size()): + match SOAK_STEPS[i]: + "FAULT": + if String(soak.faults.get("status", "")) == "confirmed" and not _next_fault_type().is_empty(): + _begin_fault() + return + "WARP": + var warp: Dictionary = soak.warp + if String(warp.get("status", "")) == "confirmed" and _warps < int(warp.required): + _begin_warp(0) + return + "RESIZE": + var available: Dictionary = window.availability() if window != null and window.has_method("availability") else {} + if window == null: + _resize_blocked = "no window adapter: logical resolution switching needs a windowed display" + elif not bool(available.get("ok", false)): + _resize_blocked = "window resize unavailable: %s" % String(available.get("reason", "unknown")) + else: + _begin_resize() + return + "REST": + _begin_rest() + return + +func _next_fault_type() -> String: + var per := int(_soak_cfg().faults.get("per_type", 0)) + var best := "" + for type in Config.SOAK_FAULT_TYPES: + if int(_faults[type]) < per and (best.is_empty() or int(_faults[type]) < int(_faults[best])): + best = type + return best + +## The fault itself is produced outside the client (script/playable_fault_proxy.mjs reads +## this event); the flow never calls reconnect() here — ReconnectUI's own retry recovers. +func _begin_fault() -> void: + var faults: Dictionary = _soak_cfg().faults + var type := _next_fault_type() + _fault = {"type": type, "disconnected_us": -1} + _arm_reconnect() + cancel_inputs() + _enter("FAULT") + _event("fault_request", {"type": type, "attempt": int(_faults[type]) + 1, "round": _round, + "timeout_ms": FAULT_DISCONNECT_TIMEOUT_US / 1000, + "unreachable_seconds": int(faults.unreachable_seconds) if type == "unreachable" else 0}) + +func _tick_fault(now: int) -> void: + var type := String(_fault.type) + if int(_fault.disconnected_us) < 0: + if now - _stage_since > FAULT_DISCONNECT_TIMEOUT_US: + _soak_fail("STB-DISCONNECT-01", "FAIL", "requested %s fault produced no disconnect within 30s" % type) + return + var bound := _rebound(now) + if bound < 0: + return + if bound > 0: + _faults[type] = int(_faults[type]) + 1 + _event("fault_recovered", {"type": type, "round": _round, "count": _faults[type], + "elapsed_ms": (now - int(_fault.disconnected_us)) / 1000}) + _fault = {} + _soak_continue("FAULT") + return + var budget := int(_soak_cfg().faults.unreachable_seconds) * 1_000_000 + FAULT_RECOVERY_GRACE_US + if now - int(_fault.disconnected_us) > budget: + _soak_fail("STB-DISCONNECT-01", "FAIL", "%s fault not recovered by the reconnect path within %ds" % [type, budget / 1_000_000]) + +## Out-and-back through the configured portals. Only a new server connection epoch that +## lands on the expected map counts; the client never moves nodes to fake a map change. +func _begin_warp(leg: int) -> void: + var warp: Dictionary = _soak_cfg().warp + _warp_leg = leg + var point: Array = warp.portal_cm if leg == 0 else warp.return_portal_cm + _warp_expected = String(warp.destination_map_key) if leg == 0 else String(config.map_key) + _arm_reconnect() + cancel_inputs() + _enter("WARP") + var controller: Object = _context().get("pc", null) + if controller == null or not is_instance_valid(controller) or not controller.has_method("walk_to"): + _soak_fail("STB-WARP-01", "FAIL", "PlayerController.walk_to is unavailable") + return + controller.walk_to(MapCoord.to_world(Vector3(float(point[0]) * 0.01, 0.0, -float(point[1]) * 0.01))) + _event("warp_requested", {"leg": leg, "round": _round, "portal_cm": Vector2(float(point[0]), float(point[1])), + "expected_map_key": _warp_expected, "cross_server": bool(warp.cross_server)}) + +func _tick_warp(now: int) -> void: + var bound := _rebound(now) + if bound < 0: + return + var map_key := String(_flow_snapshot().get("map_key", "")) + if bound > 0: + if map_key != _warp_expected: + _event("map_mismatch", {"map_key": map_key, "expected_map_key": _warp_expected, "leg": _warp_leg}) + _soak_fail("STB-WARP-01", "FAIL", "server reconnect after the portal loaded %s instead of %s" % [map_key, _warp_expected]) + return + _warps += 1 + _event("warp_arrived", {"leg": _warp_leg, "round": _round, "count": _warps, "map_key": map_key, + "elapsed_ms": (now - _stage_since) / 1000}) + if _warp_leg == 0: + _begin_warp(1) + else: + _soak_continue("WARP") + return + if now - _stage_since > WARP_TIMEOUT_US: + var why := " (map_key changed without a new server connection)" if map_key == _warp_expected else "" + _soak_fail("STB-WARP-01", "FAIL", "no server reconnect into %s within 120s%s" % [_warp_expected, why]) + +func _begin_resize() -> void: + _resize_index = -1 + _enter("RESIZE") + _next_resize(_now()) + +func _next_resize(now: int) -> void: + var sizes: Array = _soak_cfg().resolutions.sizes + while true: + _resize_index += 1 + if _resize_index >= sizes.size(): + _soak_continue("RESIZE") + return + var size := _resize_target() + if metrics != null: + metrics.mark("resize", {"size": "%dx%d" % [size.x, size.y]}) + _resize_before = window.logical_size() + _resize_since = now + if bool(window.request_logical_size(size)): + return + var key := "%dx%d" % [size.x, size.y] + if _resize_blocked.is_empty(): + _resize_blocked = "logical size %s rejected by the window adapter (larger than the usable screen)" % key + _event("window_size_rejected", {"requested_size": size, "round": _round, "screen_scale": float(window.screen_scale())}) + +func _resize_target() -> Vector2i: + var item: Array = _soak_cfg().resolutions.sizes[_resize_index] + return Vector2i(int(item[0]), int(item[1])) + +func _tick_resize(now: int) -> void: + if now - _resize_since < RESIZE_SETTLE_US: + return + var size := _resize_target() + var logical: Vector2i = window.logical_size() + var viewport: Vector2i = window.viewport_size() + if absi(logical.x - size.x) <= RESIZE_TOLERANCE_PX and absi(logical.y - size.y) <= RESIZE_TOLERANCE_PX \ + and viewport.x > 0 and viewport.y > 0 and _world_ready().ready: + # Physical pixels differ from logical points on Retina; only the logical size is judged. + var switched := absi(_resize_before.x - size.x) > RESIZE_TOLERANCE_PX or absi(_resize_before.y - size.y) > RESIZE_TOLERANCE_PX + var key := "%dx%d" % [size.x, size.y] + if switched: + _resize_counts[key] = int(_resize_counts.get(key, 0)) + 1 + _event("window_resized", {"requested_size": size, "logical_size": logical, "physical_size": window.physical_size(), + "viewport_size": viewport, "screen_scale": float(window.screen_scale()), "round": _round, + "count": int(_resize_counts.get(key, 0)), "accepted": switched}) + _next_resize(now) + return + if now - _resize_since > RESIZE_TIMEOUT_US: + _event("window_resize_timeout", {"requested_size": size, "logical_size": logical, "viewport_size": viewport, + "physical_size": window.physical_size(), "screen_scale": float(window.screen_scale()), "round": _round}) + _soak_fail("STB-RESIZE-01", "FAIL", "window stayed at logical %dx%d after requesting %dx%d" % [logical.x, logical.y, size.x, size.y]) + +func _begin_rest() -> void: + cancel_inputs() + _enter("REST") + _event("soak_rest", {"round": _round, "state": "start", "wall_ms": _wall_ms()}) + +func _tick_rest(now: int) -> void: + if now - _stage_since < int(_soak_cfg().rest_seconds) * 1_000_000: + return + _event("soak_rest", {"round": _round, "state": "end", "wall_ms": _wall_ms()}) + _rounds_done = _round + 1 + if now - _soak_start_us >= int(_soak_cfg().duration_seconds) * 1_000_000: + _finish() + return + _round += 1 + _begin_round() + +func _soak_fail(case_id: String, case_status: String, reason: String) -> void: + cancel_inputs() + _finish_case(case_id, case_status, reason) + _finish() + +## §9.3: one repetition of an interaction = one stay in a stage; it is "severe" when +## a >100ms frame tagged with that phase arrived during the stay. +func _track_frames(previous: String) -> void: + if suite != "soak" or metrics == null or _soak_start_us < 0 or previous.is_empty(): + return + var phase := previous.to_lower() + var total := int(metrics.severe_by_phase().get(phase, 0)) + var delta := total - int(_severe_seen.get(phase, 0)) + _severe_seen[phase] = total + var history: Array = _frame_history.get(phase, []) + history.append(delta > 0) + if history.size() > FRAME_HISTORY_CAP: + history.pop_front() + _frame_history[phase] = history + if delta > 0: + _event("frame_severe", {"interaction": phase, "severe": delta, "round": _round}) + if history.size() >= 3 and not Metrics.repeated_severe({phase: history.slice(-3)}).is_empty(): + _repeated_severe[phase] = true + +func _close_soak() -> void: + var now := _now() + var soak := _soak_cfg() + var elapsed := now - _soak_start_us + var duration_s := int(soak.duration_seconds) + var failures: Array = report.failures() + if not report.has_case("STB-DURATION-01"): + if not failures.is_empty(): + report.add_case("STB-DURATION-01", "FAIL", "soak ended by a failure after %d rounds (%.0fs of %ds)" % [_rounds_done, elapsed / 1e6, duration_s]) + elif not _soak_blocked.is_empty(): + report.add_case("STB-DURATION-01", "BLOCKED", "soak ended early: %s" % _soak_blocked) + elif elapsed < duration_s * 1_000_000: + report.add_case("STB-DURATION-01", "BLOCKED", "wall-clock %.0fs is below the %ds soak" % [elapsed / 1e6, duration_s]) + elif _rounds_done < int(soak.min_rounds): + report.add_case("STB-DURATION-01", "BLOCKED", "%d rounds completed, need at least %d" % [_rounds_done, int(soak.min_rounds)]) + else: + report.add_case("STB-DURATION-01", "PASS", "%d move/combat/pickup rounds over %.0fs wall clock" % [_rounds_done, elapsed / 1e6]) + if not report.has_case("STB-FRAMES-01"): + if metrics == null: + report.add_case("STB-FRAMES-01", "BLOCKED", "no frame metrics attached to this run") + elif not _repeated_severe.is_empty(): + report.add_case("STB-FRAMES-01", "FAIL", ">100ms frames in 2 of 3 consecutive repetitions of: %s" % ", ".join(_repeated_severe.keys())) + else: + var unattributed: Dictionary = metrics.long_unattributed_by_phase() + unattributed.erase("loading") + var count := 0 + for phase in unattributed: + count += int(unattributed[phase]) + if count > 0: + report.add_case("STB-FRAMES-01", "BLOCKED", "%d gameplay frames >50ms need attribution: %s" % [count, unattributed]) + else: + report.add_case("STB-FRAMES-01", "PASS", "no repeated >100ms interaction; every gameplay frame >50ms has a marker") + if not report.has_case("STB-RECONNECT-01"): + var required := int(soak.reconnects) + if _active_reconnects >= required: + report.add_case("STB-RECONNECT-01", "PASS", "%d active reconnects re-acquired main VID, scene and UI without residue" % _active_reconnects) + else: + report.add_case("STB-RECONNECT-01", "BLOCKED", "%d of %d active reconnects completed" % [_active_reconnects, required]) + if not report.has_case("STB-WARP-01"): + var warp: Dictionary = soak.warp + if String(warp.get("status", "")) != "confirmed": + report.add_case("STB-WARP-01", "BLOCKED", "warp route not confirmed by the environment owner (portal, destination, cross-server support)") + elif _warps >= int(warp.required): + report.add_case("STB-WARP-01", "PASS", "%d map changes through configured portals, each on a new server connection" % _warps) + else: + report.add_case("STB-WARP-01", "BLOCKED", "%d of %d portal map changes completed" % [_warps, int(warp.required)]) + if not report.has_case("STB-DISCONNECT-01"): + var faults: Dictionary = soak.faults + if String(faults.get("status", "")) != "confirmed": + report.add_case("STB-DISCONNECT-01", "BLOCKED", "real transport faults (connection closed, short unreachability) need a confirmed local test proxy; active reconnect is not a substitute") + elif _next_fault_type().is_empty(): + report.add_case("STB-DISCONNECT-01", "PASS", "recovered from real faults %s" % [_faults]) + else: + report.add_case("STB-DISCONNECT-01", "BLOCKED", "real faults recovered %s, need %d per type" % [_faults, int(faults.per_type)]) + if not report.has_case("STB-RESIZE-01"): + var short: Array = [] + for key in _resize_counts: + if int(_resize_counts[key]) < int(soak.resolutions.switches_per_size): + short.append(key) + if not _resize_blocked.is_empty(): + report.add_case("STB-RESIZE-01", "BLOCKED", _resize_blocked) + elif short.is_empty(): + report.add_case("STB-RESIZE-01", "PASS", "switched to each logical size at least %d times: %s" % [int(soak.resolutions.switches_per_size), _resize_counts]) + else: + report.add_case("STB-RESIZE-01", "BLOCKED", "logical size switches %s below %d for %s" % [_resize_counts, int(soak.resolutions.switches_per_size), short]) + var interactions := {} + for phase in _frame_history: + var hits := 0 + for hit in _frame_history[phase]: + hits += int(bool(hit)) + interactions[phase] = {"recent": _frame_history[phase].size(), "severe": hits} + report.set_section("soak", {"duration_seconds": duration_s, "elapsed_seconds": elapsed / 1e6, "rounds": _rounds_done, + "rest_seconds": int(soak.rest_seconds), "warmup_rounds": int(soak.warmup_rounds), + "active_reconnects": _active_reconnects, "real_faults": _faults.duplicate(), "fault_mode": String(soak.faults.get("status", "")), + "warps": _warps, "warp_status": String(soak.warp.get("status", "")), "cross_server": soak.warp.get("cross_server", null), + "resizes": _resize_counts.duplicate(), "wall_start_ms": _soak_wall_start_ms, "wall_end_ms": _wall_ms(), + "interactions": interactions, "repeated_severe": _repeated_severe.keys(), + "exits": "counted by the runner (script/playable_soak.sh)", + "cold_start": "进程冷启动/系统缓存状态未知;未执行系统缓存清空"}) + +func _wall_ms() -> int: + return int(wall_ms.call()) if wall_ms.is_valid() else int(Time.get_unix_time_from_system() * 1000.0) + +# --- failure / teardown --------------------------------------------------------- + +func _on_disconnected() -> void: + if stage == "MOVE_CONFIRM" or (suite == "soak" and stage in ["WARP", "FAULT"]): + # Expected during a requested reconnect / portal / transport fault; the next + # entered_game after a disconnect is not a duplicate signal. + _reconnect_entered = 0 + if stage == "FAULT" and not _fault.is_empty() and int(_fault.disconnected_us) < 0: + _fault.disconnected_us = _now() + _event("fault_disconnected", {"type": _fault.type, "round": _round, + "elapsed_ms": (int(_fault.disconnected_us) - _stage_since) / 1000}) + return + cancel_inputs() + match stage: + "LOGIN": + _finish_case("NET-LOGIN-01", "FAIL", "disconnected before char_list") + "SELECT", "WORLD": + # NET-03: a disconnect while entering must end the run without half-loaded operations. + _finish_case(_case_for_stage(), "FAIL", "disconnected while entering the game") + _: + _finish_case(_case_for_stage(), "FAIL", "unexpected disconnect during %s" % stage) + _finish() + +func _fail_stage(reason: String) -> void: + cancel_inputs() + _finish_case(_case_for_stage(), "FAIL", reason) + _finish() + +func _finish_case(case_id: String, case_status: String, reason: String) -> void: + var soak_active := suite == "soak" and _soak_start_us >= 0 + if soak_active and case_status == "BLOCKED" and _soak_blocked.is_empty(): + _soak_blocked = "soak round %d %s: %s" % [_round, case_id, reason] + if report.has_case(case_id): + # A later soak round repeats cases that already have a result: record the round + # failure against the run instead of a duplicate case result. + if soak_active and case_status == "FAIL": + report.add_failure("soak round %d %s: %s" % [_round, case_id, reason]) + return + var evidence: Array = [] + if stage == "SKILL" and case_id == _case_for_stage(): + evidence.append_array(_shots) + if capture.is_valid(): + var path := String(capture.call(case_id)) + if not path.is_empty(): + evidence.append(path) + report.add_case(case_id, case_status, reason, evidence, float(_now() - _stage_since) / 1000.0) + +## PASS result; a soak round repeating an already-passed case records nothing new. +func _pass(case_id: String, reason: String) -> void: + if suite == "soak" and report.has_case(case_id): + return + report.add_case(case_id, "PASS", reason) + +func _block_remaining(reason: String) -> void: + for id in report.required_cases(): + if not report.has_case(String(id)): + report.add_case(String(id), "BLOCKED", reason) + +func _finish() -> void: + if done: + return + cancel_inputs() + if suite == "soak" and _soak_start_us >= 0: + _close_soak() + done = true + _event("finished", {"state": stage}) + status = String(report.finish().status) + finished.emit(status) + +# --- helpers --------------------------------------------------------------------- + +func _case_for_stage() -> String: + match stage: + "BOOT": return "CONFIG-01" + "LOGIN": return "NET-LOGIN-01" + "SELECT": return "NET-SELECT-01" + "WORLD": return "NET-WORLD-01" + "MOVE": return _move_case() + "MOVE_CONFIRM": return "NET-MOVE-CONFIRM-01" + "TARGET": return "NET-TARGET-01" if not report.has_case("NET-TARGET-01") else "NET-DROP-01" + "ATTACK": return "NET-ATTACK-01" if not report.has_case("NET-ATTACK-01") else "NET-DROP-01" + "DROP": return "NET-DROP-01" + "PICKUP": return "NET-PICKUP-01" + "SKILL": + var skills: Array = config.get("skill_cases", []) + return Config.skill_case_id(skills[_skill_index]) if _skill_index < skills.size() else "CBT" + "FAULT": return "STB-DISCONNECT-01" + "WARP": return "STB-WARP-01" + "RESIZE": return "STB-RESIZE-01" + "REST": return "STB-DURATION-01" + return stage + +func _move_case() -> String: + return "NET-MOVE-%02d" % (_waypoint + 1) + +func _event(kind: String, payload: Dictionary) -> void: + report.record(kind, stage, _case_for_stage(), _probe_epoch(), _main_vid, int(_target.get("vid", 0)), payload) + +func _now() -> int: + return int(clock_us.call()) + +func _probe_epoch() -> int: + return int(probe.connection_epoch) if probe != null else 0 + +## §9.1 segment tags for the live frame sampler; frames before the world is ready are "loading". +func frame_tags() -> Dictionary: + var ready := _world_ready() + var net_world: Variant = _context().get("net_world", null) + var parent: Variant = net_world.get("parent") if net_world != null and is_instance_valid(net_world) and "parent" in net_world else null + var actors: int = parent.get_child_count() if parent != null and is_instance_valid(parent) else 0 + return {"map": ready.map_key, "phase": stage.to_lower() if ready.ready else "loading", + "actor_count": Metrics.actor_bucket(actors)} + +func _is_target(vid: int) -> bool: + return not _target.is_empty() and vid == int(_target.vid) and int(_target.get("epoch", -1)) == _bound_epoch + +func _flow_snapshot() -> Dictionary: + if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_snapshot"): + return flow.get_playable_snapshot() + return {} + +## Borrowed references: re-read every use, never cached across reconnect/scene reload. +func _context() -> Dictionary: + if flow != null and is_instance_valid(flow) and flow.has_method("get_playable_context"): + return flow.get_playable_context() + return {} + +func _world_ready() -> Dictionary: + var snap := _flow_snapshot() + var context := _context() + var player: Variant = context.get("player", null) + var hud: Variant = context.get("hud", null) + var player_ready: bool = player != null and is_instance_valid(player) + var hud_ready: bool = hud != null and is_instance_valid(hud) + var scene_ready := bool(snap.get("scene_ready", false)) + return {"ready": scene_ready and player_ready and hud_ready, "scene_ready": scene_ready, + "player_ready": player_ready, "hud_ready": hud_ready, "map_key": String(snap.get("map_key", ""))} + +func _ground_items() -> Array: + return client.get_ground_items() if client.has_method("get_ground_items") else [] + +func _ground_iids() -> Dictionary: + var out := {} + for item in _ground_items(): + out[int(item.get("vid", 0))] = true + return out + +func _ground_pos_cm(item: Dictionary) -> Vector2: + var pos: Variant = item.get("pos", null) + if pos is Vector3: + return Vector2(pos.x * 100.0, -pos.z * 100.0) + return Vector2(INF, INF) + +func _owner_matches(owner: String) -> bool: + if owner.is_empty(): + return true + var main: Dictionary = client.get_entity(_main_vid) if client.has_method("get_entity") else {} + return String(main.get("name", "")) == owner + +## Stacks are summed over every inventory cell, so a merge into an existing stack counts. +func _inventory_total(vnum: int) -> int: + if not client.has_method("get_inventory"): + return -1 + var total := 0 + for item in client.get_inventory(): + if int(item.get("vnum", 0)) == vnum: + total += maxi(1, int(item.get("count", 1))) + return total diff --git a/project/testing/playable_metrics.gd b/project/testing/playable_metrics.gd new file mode 100644 index 00000000..fb8fa1fa --- /dev/null +++ b/project/testing/playable_metrics.gd @@ -0,0 +1,292 @@ +class_name PlayableMetrics +extends RefCounted + +## STB-01 §9.1 帧间隔与长帧指标(纯数据,时钟可注入)。 +## +## - frame() 每帧调用一次,记录与上一帧的 monotonic 间隔;首个调用只起表。 +## - 所有存储都有固定上限:当前窗口的样本在 capacity 大小的环形缓冲里, +## 已关闭窗口 / 长帧事件 / 标记 / 分段表各有上限,超出只计丢弃数, +## 测试自身不会因为长跑而无限增长内存。 +## - 窗口按注入时钟(墙钟期限)关闭,或在分段标签变化时关闭;不按帧数。 +## 窗口样本超过 capacity 时分位数只来自最近 capacity 个样本并标 truncated, +## max 与 >50ms / >100ms 计数始终精确。 +## - 分段标签只取 map / phase / actor_count / gpu_skin;actor_count 请用 actor_bucket() 分桶。 +## - 默认不逐帧打印,只保留长帧事件(>50ms),附带本帧和上一帧内的标记用于归因。 +## - GPU 内存拿不到时写 null + "unavailable",从不写 0。 + +const TAG_KEYS := ["map", "phase", "actor_count", "gpu_skin"] +const LONG_MS := 50.0 +const SEVERE_MS := 100.0 +## 9.3 标记:同步模型 build、动作 reload、GR2 解析、材质初次使用、实体批量生成、缓存命中等。 +const MARKER_DETAIL_KEYS := ["race", "count", "ms", "state", "map", "hits", "misses", "size", "round", "kind"] +const MAX_PENDING_MARKERS := 16 +const MAX_MARKER_KIND_LENGTH := 32 + +var capacity := 4096 +var window_us := 60_000_000 +var max_windows := 512 +var max_long_frames := 512 +var max_markers := 1024 +var max_segments := 64 +var print_frames := false +var clock_us: Callable = func() -> int: return Time.get_ticks_usec() + +var _ring := PackedFloat32Array() +var _ring_head := 0 +var _ring_len := 0 +var _window_count := 0 +var _window_over_long := 0 +var _window_over_severe := 0 +var _window_max := 0.0 +var _window_start_us := -1 +var _origin_us := -1 +var _last_frame_us := -1 +var _tags := {} +var _tag_key := "" +var _frames := 0 +var _windows: Array = [] +var _windows_dropped := 0 +var _segments := {} +var _unsegmented_frames := 0 +var _severe_by_phase := {} +var _long_unattributed_by_phase := {} +var _long_frames: Array = [] +var _long_frames_dropped := 0 +var _markers: Array = [] +var _markers_dropped := 0 +var _pending_markers: Array[String] = [] +var _previous_markers: Array[String] = [] + +func _init() -> void: + _ring.resize(capacity) + +func configure(options: Dictionary) -> void: + capacity = maxi(1, int(options.get("capacity", capacity))) + window_us = maxi(1, int(float(options.get("window_seconds", window_us / 1_000_000.0)) * 1_000_000.0)) + max_windows = maxi(1, int(options.get("max_windows", max_windows))) + max_long_frames = maxi(1, int(options.get("max_long_frames", max_long_frames))) + max_markers = maxi(1, int(options.get("max_markers", max_markers))) + max_segments = maxi(1, int(options.get("max_segments", max_segments))) + print_frames = bool(options.get("print_frames", false)) + _ring.resize(capacity) + _ring_head = 0 + _ring_len = 0 + +## Switches the segment. A different tag set closes the current window first. +func set_tags(tags: Dictionary) -> void: + var normalized := {} + for key in TAG_KEYS: + if tags.has(key): + normalized[key] = String(str(tags[key])).left(80) + var key := JSON.stringify(normalized) + if key == _tag_key: + return + if _window_count > 0: + close_window() + _tags = normalized + _tag_key = key + +func tags() -> Dictionary: + return _tags.duplicate() + +## Call once per rendered frame. The first call only arms the clock. +func frame(now_us := -1) -> void: + var now := now_us if now_us >= 0 else int(clock_us.call()) + if _last_frame_us >= 0: + add_interval_us(now - _last_frame_us, now) + else: + _arm(now) + _last_frame_us = now + +## Forget the previous frame time (e.g. before a deliberately unmeasured pause). +func reset_frame_clock() -> void: + _last_frame_us = -1 + +func add_interval_us(interval_us: int, now_us: int) -> void: + if _origin_us < 0: + _arm(now_us - interval_us) + if _window_start_us < 0: + _window_start_us = now_us - interval_us + var ms := float(interval_us) / 1000.0 + _ring[_ring_head] = ms + _ring_head = (_ring_head + 1) % capacity + _ring_len = mini(_ring_len + 1, capacity) + _window_count += 1 + _frames += 1 + _window_max = maxf(_window_max, ms) + var segment := _segment() + if not segment.is_empty(): + segment.frames = int(segment.frames) + 1 + segment.max_ms = maxf(float(segment.max_ms), ms) + var attribution: Array[String] = [] + for kind in _previous_markers + _pending_markers: + if not (kind in attribution): + attribution.append(kind) + _previous_markers = _pending_markers + _pending_markers = [] + if ms > LONG_MS: + _window_over_long += 1 + if not segment.is_empty(): + segment.over_50ms = int(segment.over_50ms) + 1 + _push_bounded(_long_frames, {"t_ms": _t_ms(now_us), "ms": ms, "tags": _tags.duplicate(), + "markers": attribution}, max_long_frames, "_long_frames_dropped") + if attribution.is_empty(): + var long_phase := String(_tags.get("phase", "")) + if _long_unattributed_by_phase.has(long_phase) or _long_unattributed_by_phase.size() < max_segments: + _long_unattributed_by_phase[long_phase] = int(_long_unattributed_by_phase.get(long_phase, 0)) + 1 + if ms > SEVERE_MS: + _window_over_severe += 1 + if not segment.is_empty(): + segment.over_100ms = int(segment.over_100ms) + 1 + var phase := String(_tags.get("phase", "")) + if _severe_by_phase.has(phase) or _severe_by_phase.size() < max_segments: + _severe_by_phase[phase] = int(_severe_by_phase.get(phase, 0)) + 1 + if print_frames: + print("FRAME ms=%.3f tags=%s" % [ms, _tag_key]) + if now_us - _window_start_us >= window_us: + close_window(now_us) + +## Attribution marker (model build, motion reload, GR2 parse, material first use, entity batch, ...). +func mark(kind: String, detail := {}) -> void: + var name := kind.left(MAX_MARKER_KIND_LENGTH) + var clean := {} + for key in detail: + if String(key) in MARKER_DETAIL_KEYS: + var value: Variant = detail[key] + clean[String(key)] = value if (value is int or value is float or value is bool) else String(str(value)).left(80) + var now := int(clock_us.call()) + _push_bounded(_markers, {"t_ms": _t_ms(now), "kind": name, "tags": _tags.duplicate(), "detail": clean}, + max_markers, "_markers_dropped") + if _pending_markers.size() < MAX_PENDING_MARKERS and not (name in _pending_markers): + _pending_markers.append(name) + +func close_window(now_us := -1) -> void: + if _window_count == 0: + return + var end := now_us if now_us >= 0 else maxi(_last_frame_us, _window_start_us) + var sorted := PackedFloat32Array() + sorted.resize(_ring_len) + for i in _ring_len: + sorted[i] = _ring[(_ring_head - _ring_len + i + capacity) % capacity] + sorted.sort() + var window := {"tags": _tags.duplicate(), "start_ms": _t_ms(_window_start_us), "end_ms": _t_ms(end), + "count": _window_count, "sampled": _ring_len, "truncated": _window_count > _ring_len, + "p50_ms": percentile(sorted, 0.50), "p95_ms": percentile(sorted, 0.95), "p99_ms": percentile(sorted, 0.99), + "max_ms": _window_max, "over_50ms": _window_over_long, "over_100ms": _window_over_severe} + _push_bounded(_windows, window, max_windows, "_windows_dropped") + var segment: Dictionary = _segments.get(_tag_key, {}) + if not segment.is_empty(): + segment.windows = int(segment.windows) + 1 + segment.worst_p99_ms = maxf(float(segment.worst_p99_ms), float(window.p99_ms)) + _ring_head = 0 + _ring_len = 0 + _window_count = 0 + _window_over_long = 0 + _window_over_severe = 0 + _window_max = 0.0 + _window_start_us = end + +func summary() -> Dictionary: + close_window() + var segments: Array = [] + for key in _segments: + segments.append(_segments[key].duplicate(true)) + return {"schema_version": 1, "capacity": capacity, "window_seconds": window_us / 1_000_000.0, + "thresholds_ms": [LONG_MS, SEVERE_MS], "print_frames": print_frames, "frames": _frames, + "segments": segments, "unsegmented_frames": _unsegmented_frames, + "windows": _windows.duplicate(true), "windows_dropped": _windows_dropped, + "long_frames": _long_frames.duplicate(true), "long_frames_dropped": _long_frames_dropped, + "markers": _markers.duplicate(true), "markers_dropped": _markers_dropped, + "severe_by_phase": _severe_by_phase.duplicate(), + "long_unattributed_by_phase": _long_unattributed_by_phase.duplicate()} + +func stored_sample_count() -> int: + return _ring_len + +## Cumulative >100ms counts per phase tag; diff two snapshots to judge one repetition. +func severe_by_phase() -> Dictionary: + return _severe_by_phase.duplicate() + +## Exact per-phase count of >50ms frames without a marker (not limited by max_long_frames). +func long_unattributed_by_phase() -> Dictionary: + return _long_unattributed_by_phase.duplicate() + +## Retained long frames that have no marker in their frame or the one before. They +## need manual attribution (§9.3); the automated gate never explains them away. +func unattributed_long_frames() -> Array: + return _long_frames.filter(func(item: Dictionary) -> bool: return item.markers.is_empty()) + +## Nearest-rank percentile of an ascending array. +static func percentile(sorted: PackedFloat32Array, q: float) -> float: + if sorted.is_empty(): + return 0.0 + var index := clampi(int(ceil(q * sorted.size())) - 1, 0, sorted.size() - 1) + return sorted[index] + +static func actor_bucket(count: int) -> String: + if count <= 0: + return "0" + for limit in [8, 16, 32, 64]: + if count <= limit: + return "%d-%d" % [limit / 2 + 1 if limit > 8 else 1, limit] + return "65+" + +## §9.3: an interaction repeated at least 3 times where 2 of any 3 consecutive +## repetitions contained a >100ms frame. history: interaction -> Array[bool]. +static func repeated_severe(history: Dictionary) -> Array[String]: + var out: Array[String] = [] + for key in history: + var runs: Array = history[key] + for i in range(0, runs.size() - 2): + var hits := int(bool(runs[i])) + int(bool(runs[i + 1])) + int(bool(runs[i + 2])) + if hits >= 2: + out.append(String(key)) + break + return out + +## MEMORY_STATIC is Godot's own allocator counter, not process RSS (the runner +## samples RSS externally). GPU memory has no reliable source here. +static func memory_snapshot() -> Dictionary: + var video := int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)) + return {"memory_static_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC)) / 1024, + "memory_static_max_kib": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)) / 1024, + "gpu_memory_kib": null, "gpu_memory_status": "unavailable", + "godot_video_mem_kib": video / 1024 if video > 0 else null, + "godot_video_mem_note": "Godot RenderingDevice allocation counter; not system GPU memory"} + +## Cost of one sample on this machine; compare with the frame budget in reports. +static func measure_overhead(iterations := 20000) -> Dictionary: + var probe := new() + probe.configure({"capacity": 4096, "window_seconds": 3600}) + probe.set_tags({"map": "overhead", "phase": "probe", "actor_count": "1-8", "gpu_skin": false}) + var started := Time.get_ticks_usec() + for i in iterations: + probe.add_interval_us(16_000 + (i % 7) * 1000, (i + 1) * 16_000) + var elapsed := Time.get_ticks_usec() - started + return {"iterations": iterations, "total_us": elapsed, "per_sample_us": float(elapsed) / maxf(1.0, iterations)} + +func _arm(now_us: int) -> void: + if _origin_us < 0: + _origin_us = now_us + if _window_start_us < 0: + _window_start_us = now_us + +func _t_ms(us: int) -> float: + return float(us - maxi(_origin_us, 0)) / 1000.0 + +func _segment() -> Dictionary: + if _segments.has(_tag_key): + return _segments[_tag_key] + if _segments.size() >= max_segments: + _unsegmented_frames += 1 + return {} + var row := {"tags": _tags.duplicate(), "frames": 0, "windows": 0, "max_ms": 0.0, "worst_p99_ms": 0.0, + "over_50ms": 0, "over_100ms": 0} + _segments[_tag_key] = row + return row + +func _push_bounded(list: Array, item: Dictionary, limit: int, dropped_field: String) -> void: + if list.size() >= limit: + list.pop_front() + set(dropped_field, int(get(dropped_field)) + 1) + list.append(item) diff --git a/project/testing/playable_probe.gd b/project/testing/playable_probe.gd index 7411a648..8bb16b65 100644 --- a/project/testing/playable_probe.gd +++ b/project/testing/playable_probe.gd @@ -3,113 +3,236 @@ extends Node ## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入, ## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。 +## +## 每条观察都带 source: +## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调) +## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据 +## +## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次 +## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch +## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。 signal observed(kind: String, payload: Dictionary) -var client: Node -var flow: Node -var connection_epoch := 0 -var _connections: Array[Dictionary] = [] +const SERVER_SIGNALS := { + "entered_game": "_on_entered_game", + "disconnected": "_on_disconnected", + "login_failed": "_on_login_failed", + "char_list": "_on_char_list", + "entity_main_set": "_on_entity_main_set", + "entity_spawned": "_on_entity_spawned", + "entity_despawned": "_on_entity_despawned", + "entity_moved": "_on_entity_moved", + "entity_dead": "_on_entity_dead", + "damage": "_on_damage", + "motion": "_on_motion", + "vitals_changed": "_on_vitals", + "points_changed": "_on_points_changed", + "target_info": "_on_target_info", + "ground_item_added": "_on_ground_item_added", + "ground_item_removed": "_on_ground_item_removed", + "item_picked_up": "_on_item_picked_up", + "inventory_changed": "_on_inventory_changed", + "affect_added": "_on_affect_added", + "effect_cue": "_on_effect_cue", + "fly_cue": "_on_fly_cue", + "fly_targeting": "_on_fly_targeting", +} -func setup(app_flow: Node, m2client: Node) -> void: +var client: Object +var flow: Object +var connection_epoch := 1 +var _entered_in_epoch := false +var _connections: Array[Dictionary] = [] +var _last_sp := -1 + +func setup(app_flow: Object, m2client: Object) -> void: flow = app_flow client = m2client if client == null: return - _bind("entered_game", Callable(self, "_on_entered_game")) - _bind("disconnected", Callable(self, "_on_disconnected")) - _bind("char_list", Callable(self, "_on_char_list")) - _bind("entity_spawned", Callable(self, "_on_entity_spawned")) - _bind("entity_despawned", Callable(self, "_on_entity_despawned")) - _bind("entity_moved", Callable(self, "_on_entity_moved")) - _bind("entity_dead", Callable(self, "_on_entity_dead")) - _bind("damage", Callable(self, "_on_damage")) - _bind("vitals_changed", Callable(self, "_on_vitals")) - _bind("ground_item_added", Callable(self, "_on_ground_item_added")) - _bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) - _bind("item_picked_up", Callable(self, "_on_item_picked_up")) - _bind("inventory_changed", Callable(self, "_on_inventory_changed")) - _bind("effect_cue", Callable(self, "_on_effect_cue")) - _bind("fly_cue", Callable(self, "_on_fly_cue")) - _bind("fly_targeting", Callable(self, "_on_fly_targeting")) + for signal_name in SERVER_SIGNALS: + _bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name])) + +## Subscribe to a local presentation object (quickbar / skill fx). Its events +## are tagged source=local and never satisfy a server-evidence requirement. +func watch_local(source: Object, signal_name: String, kind: String) -> void: + if source == null or not source.has_signal(signal_name): + return + var callback := func(a: Variant = null, b: Variant = null, c: Variant = null, + d: Variant = null, e: Variant = null) -> void: + _emit_local(kind, [a, b, c, d, e]) + _bind(source, signal_name, callback) func snapshot() -> Dictionary: - var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game() - var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0 - var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0 - var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0 - var state := int(flow.state()) if flow != null and flow.has_method("state") else -1 + var alive := client != null and is_instance_valid(client) + var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game() + var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0 + var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0 + var ground_count: int = client.get_ground_items().size() if alive and client.has_method("get_ground_items") else 0 + var flow_alive := flow != null and is_instance_valid(flow) + var state := int(flow.state()) if flow_alive and flow.has_method("state") else -1 return {"in_game": in_game, "main_vid": main_vid, "entity_count": entity_count, - "ground_item_count": ground_count, "app_state": state, "connection_epoch": connection_epoch} + "ground_item_count": ground_count, "app_state": state} func disconnect_all() -> void: for entry in _connections: + var source: Object = entry.source var signal_name: String = entry.signal var callback: Callable = entry.callback - if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback): - client.disconnect(signal_name, callback) + if source != null and is_instance_valid(source) and source.has_signal(signal_name) \ + and source.is_connected(signal_name, callback): + source.disconnect(signal_name, callback) _connections.clear() +func bound_count() -> int: + return _connections.size() + func _exit_tree() -> void: disconnect_all() -func _bind(signal_name: String, callback: Callable) -> void: - if client.has_signal(signal_name) and not client.is_connected(signal_name, callback): - client.connect(signal_name, callback) - _connections.append({"signal": signal_name, "callback": callback}) +func _bind(source: Object, signal_name: String, callback: Callable) -> void: + if source.has_signal(signal_name) and not source.is_connected(signal_name, callback): + source.connect(signal_name, callback) + _connections.append({"source": source, "signal": signal_name, "callback": callback}) -func _emit(kind: String, payload := {}) -> void: +func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void: var data: Dictionary = payload.duplicate(true) + data["source"] = "server" data["connection_epoch"] = connection_epoch + data["actor_vid"] = actor_vid + data["target_vid"] = target_vid data["received_us"] = Time.get_ticks_usec() observed.emit(kind, data) +func _emit_local(kind: String, args: Array) -> void: + var data := {"source": "local", "connection_epoch": connection_epoch, + "actor_vid": 0, "target_vid": 0, "received_us": Time.get_ticks_usec()} + match kind: + "cast_started": + data["skill_id"] = int(args[0]) if args[0] != null else 0 + data["target_vid"] = int(args[1]) if args[1] != null else 0 + "skill_rejected": + data["skill_id"] = int(args[0]) if args[0] != null else 0 + data["code"] = String(args[1]) if args[1] != null else "" + "fx_spawned", "fx_finished": + # EffectRegistry lifecycle: (effect, fx_id, lifetime_ms[, elapsed_ms, reason]). + data["effect"] = String(args[0]).get_file().get_basename().left(80) if args[0] != null else "" + data["fx_id"] = int(args[1]) if args[1] != null else 0 + data["lifetime_ms"] = int(args[2]) if args[2] != null else -1 + if kind == "fx_finished": + data["elapsed_ms"] = int(args[3]) if args[3] != null else -1 + data["reason"] = String(args[4]) if args[4] != null else "" + _: + if args[0] is int: + data["skill_id"] = args[0] + observed.emit(kind, data) + func _on_entered_game() -> void: - connection_epoch += 1 + if _entered_in_epoch: + connection_epoch += 1 + _entered_in_epoch = true + _last_sp = -1 _emit("entered_game") func _on_disconnected(reason: String) -> void: - _emit("disconnected", {"reason": reason}) + _emit("disconnected", {"reason": reason.left(120)}) + connection_epoch += 1 + _entered_in_epoch = false + _last_sp = -1 + +func _on_login_failed(reason: String) -> void: + _emit("login_failed", {"reason": reason.left(120)}) func _on_char_list(chars: Array) -> void: - _emit("char_list", {"count": chars.size()}) + var slots: Array = [] + for character in chars: + if character is Dictionary: + slots.append(int(character.get("index", -1))) + _emit("char_list", {"count": chars.size(), "slots": slots}) + +func _on_entity_main_set(vid: int) -> void: + _emit("entity_main_set", {}, vid) func _on_entity_spawned(entity: Dictionary) -> void: - _emit("entity_spawned", {"vid": int(entity.get("vid", 0)), "race": int(entity.get("race", 0)), "kind": int(entity.get("kind", -1))}) + _emit("entity_spawned", {"race": int(entity.get("race", 0)), "ch_type": int(entity.get("ch_type", -1)), + "dead": bool(entity.get("dead", false)), "pos_cm": entity.get("pos_cm", Vector3.ZERO)}, + int(entity.get("vid", 0))) func _on_entity_despawned(vid: int) -> void: - _emit("entity_despawned", {"vid": vid}) + _emit("entity_despawned", {}, vid) func _on_entity_moved(vid: int) -> void: - _emit("entity_moved", {"vid": vid}) + var pos: Variant = Vector3.ZERO + if client != null and client.has_method("get_entity"): + pos = client.get_entity(vid).get("pos_cm", Vector3.ZERO) + _emit("entity_moved", {"pos_cm": pos}, vid) func _on_entity_dead(vid: int) -> void: - _emit("entity_dead", {"vid": vid}) + _emit("entity_dead", {}, 0, vid) func _on_damage(vid: int, amount: int, flag: int) -> void: - _emit("damage", {"vid": vid, "amount": amount, "flag": flag}) + _emit("damage", {"amount": amount, "flag": flag}, 0, vid) + +func _on_motion(vid: int, victim_vid: int, motion: int) -> void: + _emit("server_motion", {"motion": motion}, vid, victim_vid) func _on_vitals(vid: int) -> void: - _emit("vitals_changed", {"vid": vid}) + var entity: Dictionary = client.get_entity(vid) if client != null and client.has_method("get_entity") else {} + _emit("vitals_changed", {"hp": int(entity.get("hp", -1)), "max_hp": int(entity.get("max_hp", -1)), + "dead": bool(entity.get("dead", false))}, vid) + +func _on_points_changed(points: Dictionary) -> void: + # points_changed carries the main character's point table. Only a change in + # SP is interesting as skill-resource evidence. + var sp := int(points.get("sp", points.get(8, -1))) if points.has("sp") or points.has(8) else -1 + if sp >= 0 and _last_sp >= 0 and sp != _last_sp: + _emit("sp_changed", {"before": _last_sp, "after": sp, "delta": sp - _last_sp}) + if sp >= 0: + _last_sp = sp + +func _on_target_info(vid: int, hp_percent: int) -> void: + _emit("target_info", {"hp_percent": hp_percent}, 0, vid) func _on_ground_item_added(item: Dictionary) -> void: - _emit("ground_item_added", {"vid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), "owner": String(item.get("owner", ""))}) + _emit("ground_item_added", {"iid": int(item.get("vid", 0)), "vnum": int(item.get("vnum", 0)), + "owner_match": _owner_matches(String(item.get("owner", ""))), "pos_cm": _ground_pos_cm(item)}) func _on_ground_item_removed(vid: int) -> void: - _emit("ground_item_removed", {"vid": vid}) + _emit("ground_item_removed", {"iid": vid}) func _on_item_picked_up(vnum: int, count: int, source: String) -> void: - _emit("item_picked_up", {"vnum": vnum, "count": count, "source": source}) + # `source` may be a player name; keep only whether it was attributed at all. + _emit("item_picked_up", {"vnum": vnum, "count": count, "selected": not source.is_empty()}) func _on_inventory_changed(window: int, cell: int) -> void: _emit("inventory_changed", {"window": window, "cell": cell}) +func _on_affect_added(affect: Dictionary) -> void: + _emit("affect_added", {"type": int(affect.get("type", 0))}) + func _on_effect_cue(vid: int, name: String, special: int) -> void: - _emit("effect_cue", {"vid": vid, "name": name, "special": special}) + _emit("effect_cue", {"effect": name.get_file(), "special": special}, vid) func _on_fly_cue(kind: int, start_vid: int, end_vid: int) -> void: - _emit("fly_cue", {"type": kind, "start_vid": start_vid, "end_vid": end_vid}) + _emit("fly_cue", {"type": kind}, start_vid, end_vid) -func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, append: bool) -> void: - _emit("fly_targeting", {"shooter_vid": shooter_vid, "target_vid": target_vid, - "target_cm": [target_cm.x, target_cm.y], "append": append}) +func _on_fly_targeting(shooter_vid: int, target_vid: int, target_cm: Vector2, _append: bool) -> void: + _emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid) + +## Ownership is evaluated in-process so the owner's name never reaches a report: +## an empty owner is free-for-all, otherwise it must equal the main character. +func _owner_matches(owner: String) -> bool: + if owner.is_empty(): + return true + if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"): + return false + var main: Dictionary = client.get_entity(int(client.get_main_vid())) + return String(main.get("name", "")) == owner + +func _ground_pos_cm(item: Dictionary) -> Vector2: + var pos: Variant = item.get("pos", null) + if pos is Vector3: + # M2Client ground pos is Godot metres (x, y-up, -y_cm); convert back to server cm. + return Vector2(roundf(pos.x * 100.0), roundf(-pos.z * 100.0)) + return Vector2(-1, -1) diff --git a/project/testing/playable_report.gd b/project/testing/playable_report.gd index baff601d..83b0fce4 100644 --- a/project/testing/playable_report.gd +++ b/project/testing/playable_report.gd @@ -2,15 +2,54 @@ class_name PlayableReport ## 联网测试进程内的报告构建器。 ## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 +## +## 事件契约(events.jsonl 每行):monotonic_us、run_id、case_id、connection_epoch、 +## stage、kind、actor_vid、target_vid、payload。payload 只保留 PAYLOAD_KEYS +## 白名单字段;其它字段丢弃并计数,避免把角色名、聊天内容或环境变量写进报告。 const SCHEMA_VERSION := 1 const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] +const EVENT_KEYS := ["monotonic_us", "run_id", "case_id", "connection_epoch", "stage", + "kind", "actor_vid", "target_vid", "payload"] +const PAYLOAD_KEYS := [ + "source", "count", "race", "ch_type", "amount", "flag", "vnum", "window", "cell", + "special", "type", "motion", "reason_code", "hp", "max_hp", "hp_percent", "dead", + "pos_cm", "local_pos_cm", "distance_cm", "tolerance_cm", "waypoint_index", "skill_id", + "code", "state", "map_key", "expected_map_key", "iid", "before", "after", "delta", + "attempt", "owner_match", "allow_gameplay", "main_vid", "entity_count", + "ground_item_count", "app_state", "scene_ready", "hud_ready", "player_ready", + "in_game", "reconnecting", "character_count", "slot_present", "stale_epoch", + "elapsed_ms", "timeout_ms", "server_index", "channel", "address_match", "evidence", + "screenshot", "suite", "effect", "lifetime_ms", "selected", "accepted", "reason", "slots", + "fx_id", "open_effects", "phase", + # STB-01 soak + "round", "wall_ms", "requested_size", "logical_size", "physical_size", "viewport_size", + "screen_scale", "duplicate_vids", "target_residue", "portal_cm", "unreachable_seconds", + "cross_server", "interaction", "severe", "elapsed_s", "leg", "required", "size", +] +## Top-level report sections a suite may attach; anything else is rejected. +const SECTIONS := ["soak", "frame_metrics", "client_memory", "probe_overhead"] +## Values that look like credentials are masked even inside whitelisted keys. +const SECRET_KEYS := ["password", "passwd", "token", "secret", "account", "login", "username"] var _report: Dictionary = {} var _started_us := 0 +var _events_path := "" +var _event_count := 0 +var _dropped_payload_keys := {} +var _redact_literals: Array[String] = [] -func begin(run_id: String, suite: String, config: Dictionary) -> void: +func begin(run_id: String, suite: String, config: Dictionary, events_path := "") -> void: _started_us = Time.get_ticks_usec() + _events_path = events_path + _event_count = 0 + _dropped_payload_keys = {} + # Account/password literals may appear inside server messages; never persist them. + _redact_literals.clear() + for name in ["MT_ACCOUNT", "MT_PASSWORD"]: + var literal := OS.get_environment(name) + if literal.length() >= 2: + _redact_literals.append(literal) _report = { "schema_version": SCHEMA_VERSION, "run_id": run_id, @@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void: "build": _build_info(), "environment": _environment_info(), "cases": [], - "events": "events.jsonl", + "required_cases": [], + "events": events_path.get_file() if not events_path.is_empty() else "", + "event_count": 0, "failures": [], "blocked": [], "coverage": {"required": 0, "passed": 0}, @@ -28,37 +69,97 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void: # Keep configuration useful for reproducing a run without copying any secret. _report["scenario_id"] = String(config.get("scenario_id", "")) +## Declares the case IDs that must appear; missing ones become BLOCKED on finish(). +func set_required_cases(ids: Array) -> void: + var out: Array = [] + for id in ids: + out.append(String(id)) + _report.required_cases = out + +func required_cases() -> Array: + return _report.get("required_cases", []).duplicate() + +func has_case(case_id: String) -> bool: + for item in _report.get("cases", []): + if String(item.id) == case_id: + return true + return false + +func case_status(case_id: String) -> String: + for item in _report.get("cases", []): + if String(item.id) == case_id: + return String(item.status) + return "" + func add_case(case_id: String, status: String, reason := "", evidence: Array = [], duration_ms := 0.0) -> void: + if has_case(case_id): + _fail("duplicate case result for %s" % case_id) + return var normalized := status.to_upper() if not VALID_STATUSES.has(normalized): normalized = "FAIL" _fail("invalid case status for %s" % case_id) var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, - "reason": reason, "evidence": _sanitize(evidence)} + "reason": _redact_text(reason), "evidence": _sanitize(evidence)} _report.cases.append(item) _report.coverage.required = int(_report.coverage.required) + 1 if normalized == "PASS": _report.coverage.passed = int(_report.coverage.passed) + 1 elif normalized == "FAIL": - _fail("%s: %s" % [case_id, reason if not reason.is_empty() else "failed"]) + _fail("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "failed"]) elif normalized == "BLOCKED": - _report.blocked.append(reason if not reason.is_empty() else case_id) + _report.blocked.append("%s: %s" % [case_id, item.reason if not String(item.reason).is_empty() else "blocked"]) -func add_event(event: Dictionary, events_path: String) -> void: - var safe: Variant = _sanitize(event) - var file := FileAccess.open(events_path, FileAccess.READ_WRITE) +## A run-level failure that belongs to no open case (e.g. an effect from an +## already-passed case outliving its lifetime). Any failure makes the run FAIL. +func add_failure(message: String) -> void: + _fail(message) + +## Attach a whitelisted top-level section (sanitized like payloads). Returns false for unknown names. +func set_section(name: String, value: Dictionary) -> bool: + if _report.is_empty() or not (name in SECTIONS): + return false + _report[name] = _sanitize(value) + return true + +func failures() -> Array: + return _report.get("failures", []).duplicate() + +## Append one contract event. Returns the event actually written (for tests). +func record(kind: String, stage: String, case_id: String, connection_epoch: int, + actor_vid := 0, target_vid := 0, payload := {}) -> Dictionary: + var event := { + "monotonic_us": Time.get_ticks_usec(), + "run_id": String(_report.get("run_id", "")), + "case_id": case_id, + "connection_epoch": connection_epoch, + "stage": stage, + "kind": kind, + "actor_vid": actor_vid, + "target_vid": target_vid, + "payload": _whitelist(payload), + } + _event_count += 1 + _report.event_count = _event_count + if _events_path.is_empty(): + return event + var file := FileAccess.open(_events_path, FileAccess.READ_WRITE) if file == null: - file = FileAccess.open(events_path, FileAccess.WRITE) + file = FileAccess.open(_events_path, FileAccess.WRITE) if file == null: _fail("cannot open events file") - return + return event file.seek_end() - file.store_line(JSON.stringify(safe)) + file.store_line(JSON.stringify(event)) file.close() + return event func finish() -> Dictionary: if _report.is_empty(): return {} + for required in _report.required_cases: + if not has_case(String(required)): + add_case(String(required), "BLOCKED", "not_reached") var required := int(_report.coverage.required) var passed := int(_report.coverage.passed) var failures: Array = _report.failures @@ -68,6 +169,8 @@ func finish() -> Dictionary: _report.status = "BLOCKED" else: _report.status = "PASS" + if not _dropped_payload_keys.is_empty(): + _report["dropped_payload_keys"] = _dropped_payload_keys.keys() _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0 return _report @@ -83,7 +186,7 @@ func write(path: String) -> bool: return true func _fail(message: String) -> void: - _report.failures.append(message) + _report.failures.append(_redact_text(message)) func _build_info() -> Dictionary: return { @@ -94,19 +197,40 @@ func _build_info() -> Dictionary: } func _environment_info() -> Dictionary: - var size := DisplayServer.window_get_size() if DisplayServer.get_name() != "headless" else Vector2i.ZERO + var headless := DisplayServer.get_name() == "headless" + var size := DisplayServer.window_get_size() if not headless else Vector2i.ZERO + var logical := size + if not headless and DisplayServer.screen_get_scale() > 0.0: + logical = Vector2i(roundi(size.x / DisplayServer.screen_get_scale()), roundi(size.y / DisplayServer.screen_get_scale())) return { "os": OS.get_name(), "renderer": RenderingServer.get_video_adapter_name(), "resolution": [size.x, size.y], + "logical_resolution": [logical.x, logical.y], } +func _whitelist(payload: Dictionary) -> Dictionary: + var out := {} + for key in payload: + var name := String(key) + if name in PAYLOAD_KEYS: + out[name] = _sanitize(payload[key]) + else: + _dropped_payload_keys[name] = true + return out + +func _redact_text(text: String) -> String: + var out := text + for literal in _redact_literals: + out = out.replace(literal, "[redacted]") + return out + func _sanitize(value: Variant) -> Variant: if value is Dictionary: var out := {} for key in value: var name := String(key).to_lower() - if name in ["password", "passwd", "token", "secret", "account"]: + if name in SECRET_KEYS: out[key] = "[redacted]" else: out[key] = _sanitize(value[key]) @@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant: for item in value: out_array.append(_sanitize(item)) return out_array - if value is Vector2: + if value is String or value is StringName: + return _redact_text(String(value)) + if value is Vector2 or value is Vector2i: return [value.x, value.y] - if value is Vector3: + if value is Vector3 or value is Vector3i: return [value.x, value.y, value.z] if value is Object: return "[object]" diff --git a/project/testing/playable_window.gd b/project/testing/playable_window.gd new file mode 100644 index 00000000..822bb5e9 --- /dev/null +++ b/project/testing/playable_window.gd @@ -0,0 +1,65 @@ +extends RefCounted + +## STB-RESIZE-01 的真实窗口适配器(PlayableFlow.window)。 +## +## macOS Retina 下 DisplayServer 的窗口尺寸是物理像素,逻辑尺寸 = 物理像素 / 屏幕缩放。 +## 流程只按逻辑尺寸判定;物理像素、缩放和视口尺寸一并写进事件,Retina 的 2x 不是分辨率错误。 +## 请求尺寸超出当前屏幕可用区域(含标题栏)时如实拒绝(request_logical_size 返回 false), +## 由流程记为 BLOCKED,不缩小目标冒充通过。headless / 全屏 / 最大化窗口不可调整。 + +var _viewport: Viewport + +func _init(viewport: Viewport = null) -> void: + _viewport = viewport + +func availability() -> Dictionary: + if DisplayServer.get_name() == "headless": + return {"ok": false, "reason": "headless display server has no window"} + match DisplayServer.window_get_mode(): + DisplayServer.WINDOW_MODE_FULLSCREEN, DisplayServer.WINDOW_MODE_EXCLUSIVE_FULLSCREEN: + return {"ok": false, "reason": "window is fullscreen; logical size switching needs windowed mode"} + DisplayServer.WINDOW_MODE_MAXIMIZED: + return {"ok": false, "reason": "window is maximized; logical size switching needs windowed mode"} + DisplayServer.WINDOW_MODE_MINIMIZED: + return {"ok": false, "reason": "window is minimized"} + if screen_scale() <= 0.0: + return {"ok": false, "reason": "screen scale unavailable"} + return {"ok": true, "reason": ""} + +func screen_scale() -> float: + return DisplayServer.screen_get_scale(DisplayServer.window_get_current_screen()) + +func physical_size() -> Vector2i: + return DisplayServer.window_get_size() + +func logical_size() -> Vector2i: + var scale := screen_scale() + var size := physical_size() + if scale <= 0.0: + return size + return Vector2i(roundi(size.x / scale), roundi(size.y / scale)) + +func viewport_size() -> Vector2i: + if _viewport == null or not is_instance_valid(_viewport): + return Vector2i.ZERO + return Vector2i(_viewport.get_visible_rect().size) + +## Returns false (no request sent) when the size cannot fit the usable screen area. +func request_logical_size(size: Vector2i) -> bool: + var scale := screen_scale() + if scale <= 0.0 or size.x <= 0 or size.y <= 0: + return false + var target := Vector2i(roundi(size.x * scale), roundi(size.y * scale)) + var screen := DisplayServer.window_get_current_screen() + var usable := DisplayServer.screen_get_usable_rect(screen) + var decorations := DisplayServer.window_get_size_with_decorations() - DisplayServer.window_get_size() + if target.x + decorations.x > usable.size.x or target.y + decorations.y > usable.size.y: + return false + DisplayServer.window_set_size(target) + # Keep the whole window on the usable area so the OS does not clamp the size back. + var position := DisplayServer.window_get_position() + var clamped := Vector2i(clampi(position.x, usable.position.x, usable.end.x - target.x - decorations.x), + clampi(position.y, usable.position.y, usable.end.y - target.y - decorations.y)) + if clamped != position: + DisplayServer.window_set_position(clamped) + return true diff --git a/project/ui/mob_view.gd b/project/ui/mob_view.gd index 5ff34b76..1fe6a5ad 100644 --- a/project/ui/mob_view.gd +++ b/project/ui/mob_view.gd @@ -28,6 +28,7 @@ var anim: Node # Metin2AnimPlayer var _dir := "" var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _state := "" +var _last_resolution := {} var _audio: Node var _sound_instances: Array = [] var _sound_frame := -1 @@ -125,8 +126,10 @@ func set_anim_state(s: String) -> void: if s == _state or anim == null: return _state = s - var msa := _motion_for(s) + _last_resolution = resolve_motion(s) + var msa := String(_last_resolution.path) if msa == "": + # 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。 return anim.set("loop", s in ["wait", "walk", "run"]) anim.set("anim_path", msa) @@ -136,7 +139,35 @@ func set_anim_state(s: String) -> void: # the selected .msa before playback lets the test distinguish a real action # from a silent "keep the previous animation" fallback. func get_motion_path(state: String) -> String: - return _motion_for(state) + return String(resolve_motion(state).path) + +## requested_state / motion / path / fallback_reason。fallback_reason: +## "" 首选动作名命中 +## "client_alias:" 本客户端的同类动作别名(如 run -> WALK) +## "motlist_missing:" motlist 未列出,按目录里的 .msa 兜底 +## "reference_keep_current_motion" 资源没有任何候选动作;与旧客户端一致保留当前动作 +func resolve_motion(state: String) -> Dictionary: + var names: Array = STATE_MOTIONS.get(state, []) + var out := {"requested_state": state, "motion": "", "path": "", "fallback_reason": ""} + for mo: String in names: + if _motions.has(mo): + out.motion = mo + out.path = String(_motions[mo]) + if mo != String(names[0]): + out.fallback_reason = "client_alias:" + mo + return out + for mo: String in names: + var p := _dir.path_join(mo.to_lower() + ".msa") + if FileAccess.file_exists(p): + out.motion = mo + out.path = p + out.fallback_reason = "motlist_missing:" + p.get_file() + return out + out.fallback_reason = "reference_keep_current_motion" + return out + +func last_motion_resolution() -> Dictionary: + return _last_resolution.duplicate() func _process(_delta: float) -> void: if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ @@ -194,17 +225,6 @@ func _folder_candidates(proto: Node, race: int) -> Array: out.append(c) return out -func _motion_for(state: String) -> String: - for mo: String in STATE_MOTIONS.get(state, []): - if _motions.has(mo): - return _motions[mo] - # 兜底:目录里有 .msa 直接用 - for mo: String in STATE_MOTIONS.get(state, []): - var p := _dir.path_join(mo.to_lower() + ".msa") - if FileAccess.file_exists(p): - return p - return _motions.get("WAIT", "") - func _load_motlist() -> void: var ml := _dir.path_join("motlist.txt") if not FileAccess.file_exists(ml): diff --git a/project/ui/quickbar.gd b/project/ui/quickbar.gd index cb949804..da77fd61 100644 --- a/project/ui/quickbar.gd +++ b/project/ui/quickbar.gd @@ -47,6 +47,7 @@ var _slots := [] # 当前页 UI:[{btn, cd, lbl, icon, grade}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session var _page := 0 +var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外) var _move_from := -1 var _mobile_mode := false @@ -194,6 +195,17 @@ func _activate_slot(slot: int, screen_direction: Vector2) -> void: elif not PlayerSkill.is_silent_code(fishing_result): skill_rejected.emit(int(s.id), fishing_result) return + if code == "RESERVED": + # __ProcessEnemySkillTargetRange:射程外不发施法,蓄力技未预约时先 + # __SendUseSkill(slot, 0),再 __ReserveUseSkill 交给 NetPlay 趋近。 + _last_activation_reserved = true + if net_play and net_play.has_method("reserve_use_skill"): + var already: bool = net_play.has_method("is_use_skill_reserved") \ + and bool(net_play.is_use_skill_reserved(global)) + if bool(gate.get("charge", false)) and not already and client.has_method("use_skill"): + client.use_skill(int(s.id), 0) + net_play.reserve_use_skill(int(gate.get("target_vid", 0)), global, float(gate.get("range_cm", 0))) + return if code == "TOGGLE_OFF": if client.has_method("use_skill"): client.use_skill(int(s.id), 0) @@ -284,8 +296,10 @@ func activate_reserved(global_slot: int) -> bool: var s: Dictionary = _state[global_slot] if s.kind != "skill" or s.id == 0 or _now() < s.cd_end: return false + _last_activation_reserved = false activate(local) - return true + # 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。 + return not _last_activation_reserved # client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。 func _skill_level(sid: int) -> int: diff --git a/script/audit_playable_maps.mjs b/script/audit_playable_maps.mjs index 2824e775..3ffcae2a 100644 --- a/script/audit_playable_maps.mjs +++ b/script/audit_playable_maps.mjs @@ -1,130 +1,445 @@ #!/usr/bin/env node -/* Static prerequisite audit for the first playable-map fixture. It does not - * connect to a server and cannot prove which monsters a live server spawned. */ +/* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1). + * It reads the real map setting, TextureSet, AreaData/Property, npclist, MSM and motlist, + * resolves every referenced file the way the runtime does, and lists missing items. + * It does not connect to a server and cannot prove which monsters a live server spawned: + * tree monsters are reported as candidates only. + * + * exit: 0 = no unexplained gaps, 1 = missing resources, 2 = config/precondition blocked. */ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; +const USAGE = `usage: audit_playable_maps.mjs [--config SCENARIO.json | --maps DIR[,DIR...]] [--assets DIR] [--races N[,N...]] [--output FILE] + --config scenario JSON; its map_key is audited (credentials are never read from it) + --maps explicit map directories relative to the asset root + --assets asset root (default: assets) + --races candidate monster races (default: npclist entries 2301-2315) + --output report path (default: build/playable/map-assets.json)`; + +const DEFAULT_MAPS = ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02']; +const PROPERTY_EXTENSIONS = ['.prb', '.prt', '.pre', '.prd', '.pra']; +// Keep in sync with project/ui/mob_view.gd STATE_MOTIONS (first name = primary motion). +const STATE_MOTIONS = { + wait: ['WAIT', 'WAIT1'], + run: ['RUN', 'WALK'], + attack: ['NORMAL_ATTACK', 'NORMAL_ATTACK1', 'SPECIAL_1'], + damage: ['FRONT_DAMAGE', 'DAMAGE', 'BACK_DAMAGE'], + dead: ['FRONT_DEAD', 'DEAD', 'BACK_DEAD'], +}; +// Reference CActorInstance::Move -> SetLoopMotion(RUN/WALK): a GetMotionKey miss returns early +// and the current loop keeps playing. Only these states may be explained by that rule. +const REFERENCE_KEEP_CURRENT_STATES = ['run']; +const TILE_FILES = ['areadata.txt', 'height.raw', 'tile.raw', 'attr.atr']; + +class Blocked extends Error {} + function parseArgs(argv) { - const out = { assets: 'assets', output: '', maps: ['outdoortrent/metin2_map_trent', 'outdoortrent02/metin2_map_trent02'] }; + const out = { assets: 'assets', output: 'build/playable/map-assets.json', maps: '', config: '', races: '' }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; - if (a === '--help') { console.log('usage: audit_playable_maps.mjs [--assets DIR] [--maps DIR[,DIR...]] [--output FILE]'); process.exit(0); } - if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`); + if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); } + if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`); const k = a.slice(2).replaceAll('-', '_'); + if (!(k in out)) throw new Blocked(`unknown option: ${a}`); out[k] = argv[++i]; } - if (typeof out.maps === 'string') out.maps = out.maps.split(',').map(s => s.trim()).filter(Boolean); + if (out.config && out.maps) throw new Blocked('use either --config or --maps, not both'); return out; } -function filesUnder(root) { - if (!fs.existsSync(root)) return []; - const result = []; - const visit = dir => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) visit(full); else result.push(full); - } - }; - visit(root); - return result; -} - -const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/'); -const read = file => fs.readFileSync(file, 'utf8'); +const posix = value => value.replaceAll('\\', '/'); +const read = file => fs.readFileSync(file, 'latin1'); const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); -function parseProperties(allFiles) { - const byId = new Map(); - for (const file of allFiles.filter(f => f.toLowerCase().endsWith('.prt'))) { - const lines = read(file).split(/\r?\n/); - const id = lines[1]?.trim(); - if (!/^\d+$/.test(id || '')) continue; - const text = lines.join('\n'); - const tree = text.match(/^\s*treefile\s+"([^"]+)"/im)?.[1] || ''; - const type = text.match(/^\s*propertytype\s+"([^"]+)"/im)?.[1] || ''; - byId.set(id, { id: Number(id), file, type, treefile: tree }); +// Mirrors fmt::AssetResolver: files are indexed by their asset-root relative path and by the +// suffix after "ymir work/", with the pack priority deciding which real file wins. +class AssetIndex { + constructor(root) { + this.root = root; + this.byRel = new Map(); + this.byYmir = new Map(); + const priority = ['zone', 'terrain', 'etc', 'pc', 'tree', 'property', 'effect', 'monster', 'npc', 'season2', 'season3_eu']; + const rank = rel => { + const top = rel.split('/')[0].toLowerCase(); + const i = priority.indexOf(top); + if (i >= 0) return i; + return top.startsWith('metin2_patch') ? 2000 : 1000; + }; + const files = []; + const visit = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) visit(full); else files.push(posix(path.relative(root, full))); + } + }; + if (fs.existsSync(root)) visit(root); + files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0)); + for (const rel of files) { + const lower = rel.toLowerCase(); + if (!this.byRel.has(lower)) this.byRel.set(lower, rel); + const at = lower.indexOf('ymir work/'); + if (at >= 0) { + const key = lower.slice(at + 'ymir work/'.length); + if (!this.byYmir.has(key)) this.byYmir.set(key, rel); + } + } + this.files = files; + this.dirs = new Set(); + for (const rel of files) { + const parts = rel.toLowerCase().split('/'); + for (let i = 1; i < parts.length; i += 1) this.dirs.add(parts.slice(0, i).join('/')); + } + this.topDirs = [...new Set(files.filter(rel => rel.includes('/')).map(rel => rel.split('/')[0]))].sort(); } - return byId; + + hasDir(relDir) { return this.dirs.has(AssetIndex.normalize(relDir)); } + + static normalize(virtualPath) { + let s = posix(String(virtualPath || '')).toLowerCase(); + if (/^[a-z]:/.test(s)) s = s.slice(2); + return s.replace(/^\/+/, '').replace(/\/{2,}/g, '/'); + } + + // Returns the asset-root relative real path, or ''. + resolve(virtualPath) { + const s = AssetIndex.normalize(virtualPath); + if (!s) return ''; + const at = s.indexOf('ymir work/'); + if (at >= 0) return this.byYmir.get(s.slice(at + 'ymir work/'.length)) || ''; + return this.byRel.get(s) || ''; + } + + rel(relPath) { return this.byRel.get(AssetIndex.normalize(relPath)) || ''; } + abs(rel) { return path.join(this.root, rel); } } -function parseArea(file) { - const text = read(file); +function parseKeyValues(text) { + const out = {}; + for (const line of text.split(/\r?\n/)) { + const m = line.trim().match(/^([A-Za-z_][\w]*)\s+(.*)$/); + if (m && !(m[1].toLowerCase() in out)) out[m[1].toLowerCase()] = m[2].trim(); + } + return out; +} + +// Property file: line 1 "YPRT", line 2 CRC, then key "value" pairs (formats/property.h). +function parseProperty(index, rel) { + const lines = read(index.abs(rel)).split(/\r?\n/); + const crc = lines[1]?.trim(); + if (!/^\d+$/.test(crc || '')) return null; + const kv = {}; + for (const line of lines.slice(2)) { + const m = line.trim().match(/^(\S+)\s+"([^"]*)"/); + if (m) kv[m[1].toLowerCase()] = m[2]; + } + return { crc: Number(crc), file: rel, type: kv.propertytype || '', name: kv.propertyname || '', kv }; +} + +function propertyRegistries(index) { + const runtime = new Map(); + const outside = new Map(); + let collisions = 0; + for (const rel of index.files) { + if (!PROPERTY_EXTENSIONS.includes(path.extname(rel).toLowerCase())) continue; + const prop = parseProperty(index, rel); + if (!prop) continue; + // PropertyRegistry::scan_list only accepts the top-level Property/ directory. + const target = rel.toLowerCase().startsWith('property/') ? runtime : outside; + if (target.has(prop.crc)) { if (target === runtime) collisions += 1; continue; } + target.set(prop.crc, prop); + } + return { runtime, outside, collisions }; +} + +function parseObjects(text) { const objects = []; - const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g; - for (const match of text.matchAll(blockRe)) { + for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) { const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean); - if (lines.length < 2) continue; - const id = Number(lines[1]); - if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] }); + if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue; + objects.push({ crc: Number(lines[1]), position: lines[0] }); } return objects; } -function parseNpcList(file) { - const result = new Map(); - if (!fs.existsSync(file)) return result; - for (const line of read(file).split(/\r?\n/)) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]); +function parseTextureSet(text) { + const entries = []; + for (const match of text.matchAll(/Start Texture(\d+)\r?\n([^]*?)\r?\nEnd Texture/g)) { + const file = match[2].match(/"([^"]*)"/)?.[1] ?? ''; + entries.push({ index: Number(match[1]), file }); } - return result; + return entries; } -function mobRecord(assets, code, race, allFiles) { - const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase()) - .filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`)); - const msm = candidates[0] || ''; - const dir = msm ? path.dirname(msm) : ''; - const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : ''; - const motlist = sibling('motlist.txt'); - const actions = motlist ? [...read(motlist).matchAll(/^\s*\S+\s+([A-Z0-9_]+)\s+([^\s]+\.msa)/gmi)].map(m => ({ name: m[1], file: m[2], exists: fs.existsSync(path.join(dir, m[2])) })) : []; - return { - race, code, directory: dir ? path.relative(assets, dir).replaceAll('\\', '/') : '', - files: { msm: Boolean(msm), gr2: Boolean(sibling(`${code}.gr2`)), dds: Boolean(sibling(`${code}.dds`)), motlist: Boolean(motlist) }, - action_count: actions.length, actions, - required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])), - source_sha256: msm ? sha256(msm) : null, - }; +function parseNpcList(index) { + const result = new Map(); + for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) { + const rel = index.rel(candidate); + if (!rel) continue; + for (const line of read(index.abs(rel)).split(/\r?\n/)) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]); + } + return { file: rel, entries: result }; + } + return { file: '', entries: result }; +} + +function auditMap(index, registries, key, source, missing) { + const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra }); + const rootRel = index.rel(path.posix.join(key, 'setting.txt')); + const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null, + environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [], + unresolved_properties: [], property_count: 0 }; + if (!rootRel) { + note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root'); + return map; + } + map.exists = true; + map.setting = rootRel; + const dirRel = path.posix.dirname(rootRel); + const setting = parseKeyValues(read(index.abs(rootRel))); + const [sizeX, sizeY] = (setting.mapsize || '').split(/\s+/).map(Number); + map.setting_values = { map_size: [sizeX || 0, sizeY || 0], base_position: (setting.baseposition || '').split(/\s+/).map(Number), + cell_scale: Number(setting.cellscale || 0), texture_set: setting.textureset || '', environment: setting.environment || '' }; + if (!(sizeX > 0 && sizeY > 0)) note('map_setting', rootRel, 'MapSize is missing or invalid'); + + // Tiles: the runtime loads / for every MapSize cell (m2coord::tile_dir). + const objects = []; + const ambienceObjects = []; + for (let tx = 0; tx < (sizeX || 0); tx += 1) { + for (let ty = 0; ty < (sizeY || 0); ty += 1) { + const tile = String(tx * 1000 + ty).padStart(6, '0'); + const files = Object.fromEntries(TILE_FILES.map(name => [name, Boolean(index.rel(`${dirRel}/${tile}/${name}`))])); + map.tiles.push({ tile, files }); + for (const [name, present] of Object.entries(files)) if (!present) note('tile_file', `${dirRel}/${tile}/${name}`, 'tile file missing'); + const area = index.rel(`${dirRel}/${tile}/areadata.txt`); + if (area) objects.push(...parseObjects(read(index.abs(area))).map(o => ({ ...o, tile }))); + const ambience = index.rel(`${dirRel}/${tile}/areaambiencedata.txt`); + if (ambience) ambienceObjects.push(...parseObjects(read(index.abs(ambience))).map(o => ({ ...o, tile }))); + } + } + map.object_count = objects.length; + map.ambience_object_count = ambienceObjects.length; + + // TextureSet: runtime reads /textureset/. + if (setting.textureset) { + const tsRel = index.rel(`textureset/${posix(setting.textureset).toLowerCase()}`); + map.texture_set = { ref: setting.textureset, file: tsRel, textures: [] }; + if (!tsRel) note('texture_set', setting.textureset, 'TextureSet file not found under textureset/'); + else { + map.texture_set.textures = parseTextureSet(read(index.abs(tsRel))).map(entry => { + const real = entry.file ? index.resolve(entry.file) : ''; + if (entry.file && !real) note('terrain_texture', entry.file, 'TextureSet entry does not resolve'); + return { ...entry, resolved: real }; + }); + if (map.texture_set.textures.length === 0) note('texture_set', tsRel, 'TextureSet has no texture entries'); + } + } else note('texture_set', rootRel, 'Setting.txt has no TextureSet'); + + if (setting.environment) { + const real = index.resolve(`d:/ymir work/environment/${setting.environment}`); + map.environment = { ref: setting.environment, resolved: real }; + if (!real) note('environment', setting.environment, 'environment .msenv does not resolve'); + } + + const regenRel = index.rel(`${dirRel}/regen.txt`); + const regenEntries = regenRel ? read(index.abs(regenRel)).split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('//')).length : 0; + map.regen = { file: regenRel, entries: regenEntries, + note: regenEntries === 0 ? 'no client-side spawn data; server spawn membership is unknown' : 'client regen.txt is not proof of live server spawns' }; + + const manifestRel = index.rel('TreeGeometry/manifest.json'); + const manifest = manifestRel ? JSON.parse(fs.readFileSync(index.abs(manifestRel), 'utf8')) : { trees: {} }; + const nativeBySource = new Map(Object.values(manifest.trees || {}).map(item => [index.resolve(item.source) || AssetIndex.normalize(item.source), item])); + + const groups = new Map(); + const counts = new Map(); + for (const o of [...objects, ...ambienceObjects]) counts.set(o.crc, (counts.get(o.crc) || 0) + 1); + map.property_count = counts.size; + for (const [crc, instances] of counts) { + const prop = registries.runtime.get(crc); + if (!prop) { + const elsewhere = registries.outside.get(crc); + const item = { property_id: crc, instances, found_outside_runtime_scope: elsewhere ? elsewhere.file : '' }; + map.unresolved_properties.push(item); + note('property', String(crc), elsewhere ? 'CRC only exists outside Property/, which the runtime registry does not scan' + : 'CRC not found in any property file', { instances }); + continue; + } + const base = { property_id: crc, instances, property_file: prop.file, property_type: prop.type, name: prop.name }; + const type = prop.type.toLowerCase(); + if (type === 'tree') { + const ref = prop.kv.treefile || ''; + const spt = ref ? index.resolve(ref) : ''; + const native = spt ? nativeBySource.get(spt) : undefined; + const glb = native ? index.rel(`TreeGeometry/${native.glb}`) : ''; + const item = { ...base, treefile: ref, spt, native_glb: glb, source_sha256: native?.source_sha256 || null }; + map.static_spt.push(item); + if (!spt) note('tree_spt', ref || prop.file, 'treefile does not resolve', { instances }); + else if (!glb) note('tree_native_geometry', spt, 'no extracted TreeGeometry entry for this SPT', { instances }); + } else if (type === 'building' || type === 'dungeonblock') { + const ref = prop.kv[type === 'building' ? 'buildingfile' : 'dungeonblockfile'] || ''; + const gr2 = ref ? index.resolve(ref) : ''; + map.static_gr2.push({ ...base, model: ref, gr2, sha256: gr2 ? sha256(index.abs(gr2)) : null }); + if (!gr2) note('building_gr2', ref || prop.file, 'model file does not resolve', { instances }); + } else if (type === 'effect') { + const ref = prop.kv.effectfile || ''; + const mse = ref ? index.resolve(ref) : ''; + // Metin2World skips Effect objects for static rendering; still audit the file. + map.effects.push({ ...base, effectfile: ref, mse, rendered_by_world_loader: false }); + if (!mse) note('effect_mse', ref || prop.file, 'effect file does not resolve', { instances }); + } else if (type === 'ambience') { + const refs = (prop.kv.ambiencesoundvector || '').split(/[,;]/).map(s => s.trim()).filter(Boolean); + const sounds = refs.map(ref => ({ ref, resolved: index.resolve(ref) || index.rel(ref) || index.rel(`sound/${ref}`) })); + map.ambience.push({ ...base, sounds }); + for (const s of sounds) if (!s.resolved) note('ambience_sound', s.ref, 'ambience sound does not resolve', { instances }); + } else { + map.unresolved_properties.push({ ...base, unsupported_type: true }); + note('property_type', prop.file, `unsupported property type "${prop.type}"`, { instances }); + } + } + map.static_spt_instances = map.static_spt.reduce((n, item) => n + item.instances, 0); + map.static_gr2_instances = map.static_gr2.reduce((n, item) => n + item.instances, 0); + return map; +} + +// Mirrors MobView._find_dir + the npclist stem segment fallback (bear_brown -> bear). +function findMobDir(index, code) { + const segs = code.split('_'); + for (let cut = segs.length; cut > 0; cut -= 1) { + const folder = segs.slice(0, cut).join('_'); + const rels = ['ymir work/monster', 'ymir work/monster2', 'ymir work/npc', 'ymir work/npc2', + 'Monster/ymir work/monster', 'Monster/ymir work/monster2', 'NPC/ymir work/npc', 'NPC/ymir work/npc2'].map(r => `${r}/${folder}`); + for (const rel of rels) if (index.hasDir(rel)) return rel; + for (const sub of index.topDirs) for (const rel of rels) if (index.hasDir(`${sub}/${rel}`)) return `${sub}/${rel}`; + } + return ''; +} + +function auditMob(index, race, code, missing, explained) { + const scope = `race:${race}`; + const note = (kind, ref, reason) => missing.push({ scope, kind, ref, reason }); + const record = { race, code, candidate_only: true, directory: '', files: {}, msm: null, textures: [], motions: [], states: {} }; + if (!code) { note('npclist', String(race), 'race is not listed in npclist'); return record; } + const dir = findMobDir(index, code); + record.directory = dir; + if (!dir) { note('mob_directory', code, 'no monster/npc directory for this code'); return record; } + const inDir = name => index.rel(`${dir}/${name}`); + const gr2 = inDir(`${code}.gr2`) || inDir(`${path.posix.basename(dir)}.gr2`); + const msmRel = inDir(`${code}.msm`); + record.files = { gr2, msm: msmRel, motlist: inDir('motlist.txt') }; + record.source_sha256 = gr2 ? sha256(index.abs(gr2)) : null; + if (!gr2) note('mob_gr2', `${dir}/${code}.gr2`, 'base model GR2 missing (dynamic skinned model)'); + if (msmRel) { + const base = read(index.abs(msmRel)).match(/BaseModelFileName\s+"([^"]+)"/i)?.[1] || ''; + const resolved = base ? index.resolve(base) : ''; + record.msm = { file: msmRel, base_model: base, resolved, matches_runtime_gr2: Boolean(resolved && gr2 && resolved.toLowerCase() === gr2.toLowerCase()) }; + if (base && !resolved) note('msm_base_model', base, 'MSM BaseModelFileName does not resolve'); + } else note('mob_msm', `${dir}/${code}.msm`, 'MSM missing'); + const dirPrefix = `${dir.toLowerCase()}/`; + record.textures = index.files.filter(f => f.toLowerCase().startsWith(dirPrefix) && !f.slice(dirPrefix.length).includes('/') && f.toLowerCase().endsWith('.dds')); + if (record.textures.length === 0) note('mob_texture', dir, 'no .dds texture next to the model'); + + const motions = new Map(); + if (record.files.motlist) { + for (const line of read(index.abs(record.files.motlist)).split(/\r?\n/)) { + const parts = line.trim().split(/\s+/); + if (parts.length < 3 || !parts[2].toLowerCase().endsWith('.msa')) continue; + const file = inDir(parts[2]); + record.motions.push({ group: parts[0], name: parts[1], file: parts[2], exists: Boolean(file), weight: Number(parts[3] || 0) }); + if (!file) note('motion_file', `${dir}/${parts[2]}`, `motlist ${parts[1]} references a missing .msa`); + else if (!motions.has(parts[1])) motions.set(parts[1], file); + } + } else note('motlist', `${dir}/motlist.txt`, 'motlist missing'); + + for (const [state, names] of Object.entries(STATE_MOTIONS)) { + let entry = null; + for (const name of names) { + if (motions.has(name)) { entry = { motion: name, file: motions.get(name), fallback_reason: name === names[0] ? '' : `client_alias:${name}` }; break; } + } + if (!entry) { + for (const name of names) { + const file = inDir(`${name.toLowerCase()}.msa`); + if (file) { entry = { motion: name, file, fallback_reason: `motlist_missing:${path.posix.basename(file)}` }; break; } + } + } + if (!entry) { + entry = { motion: '', file: '', fallback_reason: 'reference_keep_current_motion' }; + if (REFERENCE_KEEP_CURRENT_STATES.includes(state)) { + explained.push({ scope, kind: 'motion', ref: state, reason: `no ${names.join('/')} motion; reference SetLoopMotion keeps the current loop`, + needs_live_confirmation: 'a moving server instance of this race keeps its idle loop while its position changes' }); + } else note('motion', `${code}:${state}`, `no ${names.join('/')} motion for a required state`); + } + record.states[state] = entry; + } + return record; +} + +function mapsFromConfig(configPath) { + if (!fs.existsSync(configPath)) throw new Blocked(`config not found: ${configPath}`); + let config; + try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (error) { throw new Blocked(`config is not valid JSON: ${error.message}`); } + const mapKey = String(config.map_key || '').trim(); + if (!mapKey) throw new Blocked('config map_key is empty; resolve it from the server/map fixture'); + if (mapKey.startsWith('/') || mapKey.includes('\\') || mapKey.split('/').includes('..')) throw new Blocked('config map_key must be a relative asset directory'); + return { maps: [mapKey], scenario_id: String(config.scenario_id || '') }; } function main() { const options = parseArgs(process.argv.slice(2)); const assets = path.resolve(options.assets); - const allFiles = filesUnder(assets); - const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json'); - const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} }; - const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item])); - const properties = parseProperties(allFiles); - const maps = options.maps.map(key => { - const root = path.join(assets, key); - const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt'); - const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') }))); - const propertyIds = [...new Set(objects.map(item => item.property_id))]; - const resolved = propertyIds.map(id => { - const prop = properties.get(String(id)); - if (!prop) return { property_id: id, resolved: false }; - const record = sourceByName.get(norm(prop.treefile)); - return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'), - treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null }; - }); - const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree); - return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')), - area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length, - resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id), - tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length, - properties: resolved }; - }); - const npc = parseNpcList(path.join(assets, 'root/npclist.txt')); - const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race)); - const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles)); - const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets, - maps, tree_monster_candidates: mobs, - caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' }; - if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); } - console.log(JSON.stringify({ maps: maps.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, native_tree_objects: m.native_tree_object_count, unresolved: m.unresolved_property_ids.length })), tree_monsters: mobs.length })); - if (maps.some(m => !m.exists || !m.setting || m.area_files === 0) || mobs.some(m => !m.files.msm || !m.files.gr2 || !m.files.dds || !m.files.motlist)) process.exitCode = 1; + if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`); + let mapSource = 'default'; + let maps = DEFAULT_MAPS; + let scenarioId = ''; + if (options.config) { + const fromConfig = mapsFromConfig(options.config); + maps = fromConfig.maps; + scenarioId = fromConfig.scenario_id; + mapSource = 'config'; + } else if (options.maps) { + maps = options.maps.split(',').map(s => s.trim()).filter(Boolean); + mapSource = 'argument'; + } + const index = new AssetIndex(assets); + const registries = propertyRegistries(index); + const missing = []; + const explained = []; + const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing)); + const npc = parseNpcList(index); + if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' }); + const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger) + : [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race)); + if (races.length === 0) missing.push({ scope: 'global', kind: 'tree_monster_candidates', ref: '2301-2315', reason: 'no candidate races found' }); + const mobs = races.map(race => auditMob(index, race, npc.entries.get(race) || '', missing, explained)); + + const report = { + schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(), + assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file, + property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions }, + maps: mapReports, tree_monster_candidates: mobs, + missing, explained, + status: missing.length === 0 ? 'PASS' : 'FAIL', + caveat: 'Static map AreaData and local resources only; tree monsters are candidates, not proof of live server spawns. Visual correctness requires the render tests and manual sign-off.', + }; + const output = path.resolve(options.output); + fs.mkdirSync(path.dirname(output), { recursive: true }); + fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); + console.log(`MAP_ASSETS ${JSON.stringify({ status: report.status, output, maps: mapReports.map(m => ({ key: m.key, exists: m.exists, objects: m.object_count, + spt: m.static_spt_instances || 0, gr2: m.static_gr2_instances || 0, unresolved: m.unresolved_properties.length })), + tree_monsters: mobs.length, missing: missing.length, explained: explained.length })}`); + for (const item of missing) console.error(`MISSING ${item.scope} ${item.kind} ${item.ref}: ${item.reason}`); + process.exitCode = missing.length === 0 ? 0 : 1; } -main(); +try { + main(); +} catch (error) { + if (error instanceof Blocked) { + console.error(`MAP_ASSETS BLOCKED: ${error.message}`); + process.exitCode = 2; + } else { + console.error(`MAP_ASSETS ERROR: ${error.stack || error}`); + process.exitCode = 1; + } +} diff --git a/script/audit_playable_maps_test.mjs b/script/audit_playable_maps_test.mjs new file mode 100644 index 00000000..0e703ab3 --- /dev/null +++ b/script/audit_playable_maps_test.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/* MAP-01 audit regression on a throw-away asset tree. Proves that unresolved + * property CRCs, missing model/texture/motion files and an empty config map_key + * are reported (exit 1 / 2) instead of passing, and that a resource-level + * RUN/WALK gap is only "explained" by the reference keep-current-motion rule. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'audit_playable_maps.mjs'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-map-audit.')); +let failures = 0; +const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; + +const write = (rel, text) => { const file = path.join(root, rel); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); }; +const object = (i, crc) => `Start Object${String(i).padStart(3, '0')}\n 100.0 -100.0 0.0\n ${crc}\n 0.0#0.0#0.0\n 0\nEnd Object\n`; +const prop = (crc, type, key, value) => `YPRT\n${crc}\n${key}\t\t"${value}"\npropertyname\t\t"p${crc}"\npropertytype\t\t"${type}"\n`; +const motlist = names => names.map(([name, file]) => `GENERAL ${name} ${file} 50`).join('\n') + '\n'; + +function buildFixture({ broken }) { + fs.rmSync(root, { recursive: true, force: true }); + write('mapa/setting.txt', 'ScriptType\tMapSetting\nCellScale\t200\nMapSize\t1\t1\nBasePosition\t0\t0\nTextureSet\ttextureset\\mapa.txt\nEnvironment\ta.msenv\n'); + write('mapa/000000/height.raw', 'x'); + write('mapa/000000/tile.raw', 'x'); + write('mapa/000000/attr.atr', 'x'); + const objects = [object(0, 11), object(1, 22), object(2, 33)]; + if (broken) objects.push(object(3, 44), object(4, 55)); + write('mapa/000000/areadata.txt', `AreaDataFile\n\nObjectCount ${objects.length}\n${objects.join('')}`); + write('textureset/textureset/mapa.txt', 'TextureSet\n\nTextureCount 1\n\nStart Texture001\n "d:\\ymir work\\terrainmaps\\a\\field.dds"\n 5.0\nEnd Texture001\n'); + write('Terrain/ymir work/terrainmaps/a/field.dds', 'x'); + write('ETC/ymir work/environment/a.msenv', 'x'); + write('Property/property/t/tree.prt', prop(11, 'Tree', 'treefile', 'd:/ymir work/tree/a.spt')); + write('Tree/ymir work/tree/a.spt', 'spt'); + write('TreeGeometry/manifest.json', JSON.stringify({ trees: { h: { source: 'Tree/ymir work/tree/a.spt', glb: 'h.glb', source_sha256: 'h' } } })); + write('TreeGeometry/h.glb', 'glb'); + write('Property/property/b/house.prb', prop(22, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2')); + if (!broken) write('Zone/ymir work/zone/house.gr2', 'gr2'); + write('Property/property/e/fx.pre', prop(33, 'Effect', 'effectfile', 'd:/ymir work/effect/fx.mse')); + write('Effect/ymir work/effect/fx.mse', 'mse'); + // 44 only exists outside Property/ (the runtime registry never sees it); 55 exists nowhere. + write('Zone/property/stray.prb', prop(44, 'Building', 'buildingfile', 'd:/ymir work/zone/house.gr2')); + write('root/npclist.txt', '2301\tent_a\n2302\tent_b\n'); + const mob = (code, names) => { + const dir = `monster2/ymir work/monster2/${code}`; + write(`${dir}/${code}.gr2`, 'gr2'); + write(`${dir}/${code}.dds`, 'dds'); + write(`${dir}/${code}.msm`, `BaseModelFileName "d:\\ymir work\\monster2\\${code}\\${code}.gr2"\n`); + write(`${dir}/motlist.txt`, motlist(names)); + for (const [, file] of names) write(`${dir}/${file}`, 'msa'); + }; + mob('ent_a', [['WAIT', '00.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa'], ['FRONT_DEAD', '31.msa']]); + const b = [['WAIT', '00.msa'], ['RUN', '10.msa'], ['NORMAL_ATTACK', '20.msa'], ['FRONT_DAMAGE', '30.msa']]; + if (!broken) b.push(['FRONT_DEAD', '31.msa']); + mob('ent_b', b); +} + +function run(args) { + const output = path.join(root, 'out', 'map-assets.json'); + const result = spawnSync(process.execPath, [script, '--assets', root, '--output', output, ...args], { encoding: 'utf8' }); + const report = fs.existsSync(output) ? JSON.parse(fs.readFileSync(output, 'utf8')) : null; + return { code: result.status, report, stderr: result.stderr }; +} + +try { + buildFixture({ broken: false }); + let r = run(['--maps', 'mapa']); + check(r.code === 0 && r.report?.status === 'PASS', `complete fixture passes (exit ${r.code}) ${r.stderr}`); + check(r.report?.explained.some(e => e.scope === 'race:2301' && e.ref === 'run'), 'missing RUN/WALK is explained by the reference rule'); + const map = r.report?.maps[0]; + check(map?.static_spt[0]?.native_glb === 'TreeGeometry/h.glb' && map?.static_gr2[0]?.gr2 === 'Zone/ymir work/zone/house.gr2', 'tree SPT and building GR2 resolve'); + check(map?.texture_set?.textures[0]?.resolved === 'Terrain/ymir work/terrainmaps/a/field.dds', 'TextureSet entries resolve through ymir work'); + check(map?.effects[0]?.mse === 'Effect/ymir work/effect/fx.mse', 'effect property resolves'); + check(r.report?.tree_monster_candidates.every(m => m.candidate_only && m.msm?.matches_runtime_gr2), 'MSM base model matches runtime GR2 and races stay candidates'); + + buildFixture({ broken: true }); + r = run(['--maps', 'mapa']); + const kinds = new Set((r.report?.missing || []).map(m => `${m.kind}:${m.ref}`)); + check(r.code === 1 && r.report?.status === 'FAIL', `broken fixture fails (exit ${r.code})`); + check(kinds.has('building_gr2:d:/ymir work/zone/house.gr2'), 'missing building GR2 is listed'); + check(kinds.has('property:44') && r.report?.maps[0].unresolved_properties.some(p => p.property_id === 44 && p.found_outside_runtime_scope === 'Zone/property/stray.prb'), 'CRC outside Property/ is listed with its location'); + check(kinds.has('property:55'), 'unknown CRC is listed'); + check(kinds.has('motion:ent_b:dead'), 'missing death motion is not explained away'); + check(!kinds.has('motion:ent_a:run'), 'RUN/WALK gap is explained, not missing'); + + fs.rmSync(path.join(root, 'mapa', '000000', 'attr.atr')); + r = run(['--maps', 'mapa']); + check((r.report?.missing || []).some(m => m.kind === 'tile_file'), 'missing tile file is listed'); + + write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '' })); + r = run(['--config', path.join(root, 'scenario.local.json')]); + check(r.code === 2 && /map_key is empty/.test(r.stderr), `empty config map_key is BLOCKED (exit ${r.code})`); + write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: '../mapa' })); + r = run(['--config', path.join(root, 'scenario.local.json')]); + check(r.code === 2, 'map_key escaping the asset root is BLOCKED'); + write('scenario.local.json', JSON.stringify({ scenario_id: 'x', map_key: 'mapa' })); + r = run(['--config', path.join(root, 'scenario.local.json')]); + check(r.report?.map_source === 'config' && r.report?.maps[0].key === 'mapa', 'config map_key is the audited map'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} +console.log(`audit_playable_maps_test: failures=${failures}`); +process.exitCode = failures ? 1 : 0; diff --git a/script/forest_map_render_test.sh b/script/forest_map_render_test.sh new file mode 100755 index 00000000..71394ed5 --- /dev/null +++ b/script/forest_map_render_test.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report. +# +# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the +# child environment; nothing here reads or writes them. Do not add `set -x`. +# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills. +# - Maps come only from --maps or the config map_key (filled from the +# audit_playable_maps result); no map path is guessed here. +# - The client writes client-report.json/events/screenshots; this script writes +# report.json only after the real process exit and the full-log gate. +# - Metal screenshot sign-off stays a separate manual release item. +# +# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout. +set -euo pipefail +set +x + +cd "$(dirname "$0")/.." +repo="$PWD" +app_path="" +maps="" +config_path="" +viewpoints="" +races="" +output_dir="" +timeout_seconds=900 +required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}" +kill_grace_seconds=10 +suite="forest_render" + +usage() { + cat <<'EOF' +用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项] +选项: + --app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行 + --maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key) + --config FILE playable 配置;只读取 map_key/scenario_id + --viewpoints FILE 机位夹具(test/playable/forest-viewpoints..local.json);不给则只有自动候选 + --races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪 + --output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-) + --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL + --help 显示本帮助 +说明: + 自动候选机位、未确认机位、缺失传送点都会使对应用例 BLOCKED; + 即使自动用例 PASS,Metal 截图仍需人工签核(发布清单单独的 manual 项)。 +环境变量: + MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64 +退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时 +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --app) app_path="${2:-}"; shift 2 ;; + --maps) maps="${2:-}"; shift 2 ;; + --config) config_path="${2:-}"; shift 2 ;; + --viewpoints) viewpoints="${2:-}"; shift 2 ;; + --races) races="${2:-}"; shift 2 ;; + --output) output_dir="${2:-}"; shift 2 ;; + --timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;; + --help) usage; exit 0 ;; + *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; + esac +done + +output_ready=0 +blocked() { + echo "FOREST RENDER GATE: BLOCKED $*" >&2 + if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi + exit 2 +} + +abs_file() { echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"; } + +[ -n "$app_path" ] || blocked "需要 --app" +[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数" +engine="$app_path/Contents/MacOS/mtgodot-poc" +[ -x "$engine" ] || blocked "找不到可执行包:$app_path" +if [ -n "$config_path" ]; then + [ -f "$config_path" ] || blocked "配置不存在:$config_path" + config_path="$(abs_file "$config_path")" +fi +if [ -n "$viewpoints" ]; then + [ -f "$viewpoints" ] || blocked "机位夹具不存在:$viewpoints" + viewpoints="$(abs_file "$viewpoints")" +fi +if [ -n "$races" ] && ! [[ "$races" =~ ^[1-9][0-9]*(,[1-9][0-9]*)*$ ]]; then + blocked "--races 必须是逗号分隔的正整数 vnum" +fi + +# Resolve the map list exactly as the client does (MT_FOREST_MAPS, else config map_key). +map_list="$(node -e ' +const fs = require("node:fs"); +const [maps, config] = process.argv.slice(1); +let keys = maps.split(",").map((s) => s.trim()).filter(Boolean); +if (keys.length === 0 && config) { + try { const key = String(JSON.parse(fs.readFileSync(config, "utf8")).map_key ?? "").trim(); if (key) keys = [key]; } catch {} +} +process.stdout.write(keys.join(",")); +' "$maps" "$config_path")" +[ -n "$map_list" ] || blocked "没有地图:给 --maps,或在配置里填 map-assets.json 审计出的 map_key" + +if [ -z "$output_dir" ]; then + output_dir="$repo/build/forest/run-$(date +%Y%m%d-%H%M%S)-$$" +fi +if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + blocked "输出目录已有内容,拒绝复用历史报告:$output_dir" +fi +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" +output_ready=1 + +if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then + blocked "签名校验失败:codesign --verify --strict $app_path" +fi +archs="$(lipo -archs "$engine" 2>/dev/null || true)" +case " $archs " in + *" $required_arch "*) ;; + *) blocked "包架构 [$archs] 不含 $required_arch" ;; +esac + +run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')" +log="$output_dir/client.log" +client_report="$output_dir/client-report.json" +final_report="$output_dir/report.json" +events="$output_dir/events.jsonl" + +# Same formula as forest_map_render_test.gd required_case_ids(); the validator +# fails the run if the client declares a different list. +node -e ' +const [file, suite, maps] = process.argv.slice(1); +const cases = ["MAP-FOREST-CONFIG"]; +for (const key of maps.split(",")) { + const id = key.split("/").pop(); + cases.push(`MAP-FOREST-LOAD-${id}`); + for (const kind of ["flat", "slope", "dense", "warp"]) cases.push(`MAP-FOREST-VP-${id}-${kind}`); + cases.push(`MAP-FOREST-MOTION-${id}`); +} +require("node:fs").writeFileSync(file, JSON.stringify({ schema_version: 1, suite, cases }, null, 2) + "\n"); +' "$output_dir/required-cases.json" "$suite" "$map_list" + +bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json" +build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; } + +started_ms="$(($(date +%s) * 1000))" +set +e +# No credentials reach this child, so its log needs no redactor. +env -u MT_ASSETS -u MT_ACCOUNT -u MT_PASSWORD \ + MT_TEST_MODE=forest_render \ + MT_FOREST_MAPS="$map_list" \ + MT_FOREST_VIEWPOINTS="$viewpoints" \ + MT_FOREST_RACES="$races" \ + MT_FOREST_TIMEOUT_SECONDS="$timeout_seconds" \ + MT_PLAYABLE_CONFIG="$config_path" \ + MT_PLAYABLE_RUN_ID="$run_id" \ + MT_TEST_REPORT="$client_report" \ + MT_TEST_EVENTS="$events" \ + MT_TEST_OUTPUT="$output_dir" \ + MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" \ + MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" \ + MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" \ + MT_BUILD_ARCH="$(build_field arch)" \ + "$engine" >"$log" 2>&1 /dev/null; do + if [ "$SECONDS" -ge "$timeout_seconds" ]; then + timed_out=1 + kill -TERM "$child_pid" 2>/dev/null + grace=0 + while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do + sleep 1 + grace=$((grace + 1)) + done + killed=0 + if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi + echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log" + break + fi + sleep 1 +done +wait "$child_pid" +process_code=$? + +node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ + --log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \ + --suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \ + --events "$events" --started-ms "$started_ms" --require-pass +gate_status=$? +set -e + +echo "FOREST RENDER GATE: Metal 截图人工签核仍是单独的 manual 项($output_dir/forest-*.png + forest-map-evidence.json)" +if [ "$timed_out" -eq 1 ]; then + echo "FOREST RENDER GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2 + exit 124 +fi +case "$gate_status" in + 0) echo "FOREST RENDER GATE: PASS run_id=$run_id report=$final_report" ;; + 2) echo "FOREST RENDER GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;; + *) echo "FOREST RENDER GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;; +esac +exit "$gate_status" diff --git a/script/live_smoke_test.sh b/script/live_smoke_test.sh index eabcecad..4d70f00b 100755 --- a/script/live_smoke_test.sh +++ b/script/live_smoke_test.sh @@ -8,7 +8,10 @@ # ./script/live_smoke_test.sh --no-reconnect # # 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。 +# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。 +# 不要在本脚本中启用 set -x。 set -euo pipefail +set +x SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -37,6 +40,7 @@ usage() { 环境变量: MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入 MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色 + MT_GODOT 解析 serverinfo 用的 godot,默认 godot EOF } @@ -61,7 +65,8 @@ if [ -f "$CONFIG_FILE" ]; then fi if [ -z "${MT_ACCOUNT:-}" ]; then - read -r -p "账号: " MT_ACCOUNT + read -r -s -p "账号: " MT_ACCOUNT + echo fi if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "密码: " MT_PASSWORD @@ -85,9 +90,19 @@ if [ ! -x "$EXECUTABLE" ]; then fi echo "== check server ports ==" -for port in 11000 13002; do - if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then - echo "服务器端口不可达:192.168.21.203:$port" >&2 +# 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。 +ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)" +if [ -z "$ADDRESS_LINE" ]; then + echo "无法从 serverinfo 解析服务器地址" >&2 + exit 2 +fi +read -r AUTH_HOST AUTH_PORT GAME_HOST GAME_PORT <<<"$(node -e ' +const a = JSON.parse(process.argv[1].slice("SERVER_ADDRESS ".length)); +process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" ")); +' "$ADDRESS_LINE")" +for endpoint in "$AUTH_HOST:$AUTH_PORT" "$GAME_HOST:$GAME_PORT"; do + if ! nc -G 3 -z "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then + echo "服务器端口不可达:$endpoint" >&2 exit 3 fi done @@ -111,7 +126,8 @@ echo "== run live smoke ==" echo "log: $LOG" echo "report: $REPORT" set +e -"$EXECUTABLE" 2>&1 | tee "$LOG" +# 日志落盘前脱敏;PIPESTATUS[0] 仍是 APP 的真实退出码。 +env -u MT_ASSETS "$EXECUTABLE" 2>&1 | node script/redact_stream.mjs | tee "$LOG" APP_STATUS=${PIPESTATUS[0]} set -e @@ -140,6 +156,12 @@ if [ "$APP_STATUS" -ne 0 ]; then exit "$APP_STATUS" fi +# 与 package_render_test.sh / validate_playable_report.mjs 相同的完整日志门禁。 +if grep -nE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$LOG"; then + echo "LIVE_SMOKE RESULT: FAIL (log exit gate)" >&2 + exit 1 +fi + if [ "$REPORT_STATUS" != "PASS" ]; then echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2 exit 1 diff --git a/script/package_render_test.sh b/script/package_render_test.sh index f6b7fdd3..25f70373 100644 --- a/script/package_render_test.sh +++ b/script/package_render_test.sh @@ -10,8 +10,8 @@ mkdir -p "$output_dir" status=0 env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \ "$app_path/Contents/MacOS/mtgodot-poc" --quit-after 1800 >"$output_dir/client.log" 2>&1 || status=$? -if [ "$status" -ne 0 ] || ! rg -q '^PKGRENDER: PASS$' "$output_dir/client.log" || \ - rg -q 'SCRIPT ERROR|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then +if [ "$status" -ne 0 ] || ! grep -qE '^PKGRENDER: PASS$' "$output_dir/client.log" || \ + grep -qE 'SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed' "$output_dir/client.log"; then echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log" exit 1 fi diff --git a/script/playable_build_info.sh b/script/playable_build_info.sh new file mode 100755 index 00000000..e218bdf6 --- /dev/null +++ b/script/playable_build_info.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Print the candidate package identity as JSON (engine/extension/pck sha256 + +# architectures). Shared by run_client_gate.sh and playable_test.sh so the +# per-run build.json and the release manifest are computed the same way. +set -euo pipefail + +if [ "${1:-}" = "--help" ] || [ "$#" -ne 1 ]; then + echo "用法: script/playable_build_info.sh APP" >&2 + [ "${1:-}" = "--help" ] && exit 0 + exit 2 +fi +app="$1" +engine="$app/Contents/MacOS/mtgodot-poc" +pck="$app/Contents/Resources/mtgodot-poc.pck" +dylib="$(find "$app/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' 2>/dev/null | LC_ALL=C sort | head -n 1)" + +sha256_of() { + if [ -n "$1" ] && [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else printf ''; fi +} +archs="" +if [ -f "$engine" ]; then archs="$(lipo -archs "$engine" 2>/dev/null | tr ' ' '+' || true)"; fi + +node -e ' +const [engine, extension, pck, arch] = process.argv.slice(1); +process.stdout.write(JSON.stringify({ engine_sha256: engine, extension_sha256: extension, pck_sha256: pck, arch }) + "\n"); +' "$(sha256_of "$engine")" "$(sha256_of "$dylib")" "$(sha256_of "$pck")" "$archs" diff --git a/script/playable_config_gate.gd b/script/playable_config_gate.gd deleted file mode 100644 index 63086f69..00000000 --- a/script/playable_config_gate.gd +++ /dev/null @@ -1,14 +0,0 @@ -extends SceneTree - -const Config = preload("res://testing/playable_config.gd") - -func _init() -> void: - var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") - var result := Config.load_file(path) - if not result.ok: - for error in result.errors: - printerr("CONFIG: " + String(error)) - quit(2) - return - print("PLAYABLE CONFIG: PASS " + path) - quit(0) diff --git a/script/playable_fault_proxy.mjs b/script/playable_fault_proxy.mjs new file mode 100644 index 00000000..1a7d2af3 --- /dev/null +++ b/script/playable_fault_proxy.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node +/* STB-DISCONNECT-01 local transport-fault proxy for ONE test client connection. + * + * playable_fault_proxy.mjs --route LISTEN_PORT=UPSTREAM_HOST:UPSTREAM_PORT [--route ...] + * --events events.jsonl --log fault-proxy.jsonl [--run-id RUN_ID] [--poll-ms 200] + * + * - Listens on 127.0.0.1 only and forwards bytes to the upstream test server. The + * scenario's server.* points the client at these loopback ports; nothing else on + * the machine is touched: no routes, firewall rules, other clients or production. + * - Tails the client's events.jsonl and acts on each `fault_request` exactly once: + * close destroy the proxied connections ("connection closed") + * unreachable destroy them AND stop listening for payload.unreachable_seconds + * (capped at 20 s) so reconnect attempts are refused, then listen again + * - Every action is appended to --log as JSON (wall_ms, action, type, attempt, ...). + * The client, not this proxy, decides whether recovery happened. + * - Exits 0 on SIGTERM/SIGINT after closing its sockets. It never signals any process. + * + * Known limit: a server-side warp that hands out a direct game address bypasses the proxy. + * exit: 0 stopped, 2 usage error or a listen port could not be opened. */ +import fs from 'node:fs'; +import net from 'node:net'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const LISTEN_HOST = '127.0.0.1'; +const MAX_UNREACHABLE_S = 20; + +function usage(stream = process.stderr) { + stream.write('usage: playable_fault_proxy.mjs --route LISTEN_PORT=HOST:PORT [--route ...] --events events.jsonl --log fault-proxy.jsonl [--run-id ID] [--poll-ms 200]\n' + + ' listens on 127.0.0.1 only; acts on fault_request events (close | unreachable)\n'); +} + +const isPort = (value) => Number.isInteger(value) && value >= 1 && value <= 65535; + +export function parseArgs(argv) { + const options = { routes: [], poll_ms: 200 }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help') { options.help = true; continue; } + const value = argv[i + 1]; + if (value === undefined) throw new Error(`missing value for ${arg}`); + i += 1; + if (arg === '--route') { + const match = /^(\d+)=([A-Za-z0-9.:-]+):(\d+)$/.exec(value); + if (!match) throw new Error(`bad --route ${value}`); + const route = { listen: Number(match[1]), host: match[2], port: Number(match[3]) }; + if (!isPort(route.listen) || !isPort(route.port)) throw new Error(`bad port in --route ${value}`); + if (['127.0.0.1', 'localhost', '::1'].includes(route.host) && route.port === route.listen) throw new Error(`route ${value} loops back to itself`); + if (options.routes.some((r) => r.listen === route.listen)) throw new Error(`duplicate listen port ${route.listen}`); + options.routes.push(route); + } else if (arg === '--events') options.events = value; + else if (arg === '--log') options.log = value; + else if (arg === '--run-id') options.run_id = value; + else if (arg === '--poll-ms') options.poll_ms = Number(value); + else throw new Error(`unknown option ${arg}`); + } + if (!options.help && (options.routes.length === 0 || !options.events || !options.log + || !Number.isInteger(options.poll_ms) || options.poll_ms < 20)) throw new Error('--route, --events and --log are required'); + return options; +} + +/** Incremental JSONL reader: returns complete new lines since the last call. */ +export function tailer(file) { + let offset = 0; + let partial = ''; + return () => { + let size; + try { size = fs.statSync(file).size; } catch { return []; } + if (size < offset) { offset = 0; partial = ''; } + if (size === offset) return []; + const fd = fs.openSync(file, 'r'); + const buffer = Buffer.alloc(size - offset); + fs.readSync(fd, buffer, 0, buffer.length, offset); + fs.closeSync(fd); + offset = size; + const text = partial + buffer.toString('utf8'); + const lines = text.split('\n'); + partial = lines.pop(); + return lines.filter((line) => line.trim() !== ''); + }; +} + +export class FaultProxy { + constructor(options) { + this.options = options; + this.servers = new Map(); + this.pairs = new Set(); + this.seen = new Set(); + this.unreachableTimer = null; + this.stopped = false; + this.read = tailer(options.events); + } + + log(action, detail = {}) { + fs.appendFileSync(this.options.log, `${JSON.stringify({ wall_ms: Date.now(), action, ...detail })}\n`); + } + + listen(route) { + return new Promise((resolve, reject) => { + const server = net.createServer((client) => this.accept(route, client)); + server.once('error', reject); + server.listen(route.listen, LISTEN_HOST, () => { + server.off('error', reject); + this.servers.set(route.listen, server); + resolve(); + }); + }); + } + + accept(route, client) { + const upstream = net.connect(route.port, route.host); + const pair = { client, upstream }; + this.pairs.add(pair); + const drop = () => { + if (!this.pairs.delete(pair)) return; + client.destroy(); + upstream.destroy(); + }; + client.on('error', drop).on('close', drop); + upstream.on('error', drop).on('close', drop); + client.pipe(upstream); + upstream.pipe(client); + this.log('accepted', { listen_port: route.listen, open: this.pairs.size }); + } + + dropAll() { + const count = this.pairs.size; + for (const pair of [...this.pairs]) { + this.pairs.delete(pair); + pair.client.destroy(); + pair.upstream.destroy(); + } + return count; + } + + /** Stops accepting, drops open connections (server.close waits for them), returns the drop count. */ + async closeListeners() { + const closing = [...this.servers.values()].map((server) => new Promise((resolve) => server.close(resolve))); + this.servers.clear(); + const dropped = this.dropAll(); + await Promise.all(closing); + return dropped; + } + + async start() { + for (const route of this.options.routes) await this.listen(route); + this.log('listening', { listen_ports: this.options.routes.map((r) => r.listen) }); + this.poller = setInterval(() => { this.poll().catch((error) => this.log('error', { reason: String(error.message).slice(0, 120) })); }, this.options.poll_ms); + } + + async poll() { + // setInterval does not wait for an async fault; never run two polls at once. + if (this.polling) return; + this.polling = true; + try { await this.drain(); } finally { this.polling = false; } + } + + async drain() { + for (const line of this.read()) { + let event; + try { event = JSON.parse(line); } catch { continue; } + if (event?.kind !== 'fault_request') continue; + if (this.options.run_id && event.run_id !== this.options.run_id) { this.log('ignored_foreign_run', {}); continue; } + const payload = event.payload ?? {}; + const key = `${event.monotonic_us}:${payload.type}:${payload.attempt}`; + if (this.seen.has(key)) continue; + this.seen.add(key); + await this.fault(payload); + } + } + + async fault(payload) { + const detail = { type: payload.type, attempt: payload.attempt, round: payload.round }; + if (payload.type === 'close') { + this.log('close', { ...detail, closed: this.dropAll() }); + } else if (payload.type === 'unreachable') { + if (this.unreachableTimer) { this.log('rejected', { ...detail, reason: 'unreachable window already active' }); return; } + const seconds = Math.min(MAX_UNREACHABLE_S, Math.max(1, Number.parseInt(payload.unreachable_seconds, 10) || 1)); + const closed = await this.closeListeners(); + this.log('unreachable_start', { ...detail, closed, seconds }); + this.unreachableTimer = setTimeout(async () => { + this.unreachableTimer = null; + if (this.stopped) return; + try { + for (const route of this.options.routes) await this.listen(route); + this.log('unreachable_end', detail); + } catch (error) { + this.log('error', { ...detail, reason: `relisten failed: ${error.code || error.message}` }); + } + }, seconds * 1000); + } else { + this.log('rejected', { ...detail, reason: 'unknown fault type' }); + } + } + + async stop() { + if (this.stopped) return; + this.stopped = true; + clearInterval(this.poller); + if (this.unreachableTimer) clearTimeout(this.unreachableTimer); + this.dropAll(); + await this.closeListeners(); + this.log('stopped', {}); + } +} + +async function main() { + let options; + try { options = parseArgs(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } + if (options.help) { usage(process.stdout); return 0; } + const proxy = new FaultProxy(options); + try { await proxy.start(); } catch (error) { + console.error(`FAULT PROXY: cannot listen (${error.code || error.message})`); + await proxy.stop(); + return 2; + } + await new Promise((resolve) => { + process.once('SIGTERM', resolve); + process.once('SIGINT', resolve); + }); + await proxy.stop(); + return 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main(); diff --git a/script/playable_fault_proxy_test.mjs b/script/playable_fault_proxy_test.mjs new file mode 100644 index 00000000..a94f96fa --- /dev/null +++ b/script/playable_fault_proxy_test.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/* STB-DISCONNECT-01 fault proxy regression. Uses only 127.0.0.1 ephemeral ports and a + * local echo server as the "upstream"; never contacts the test server. Proves that the + * proxy forwards bytes, closes the connection on `close`, refuses connections for the + * `unreachable` window and listens again, acts on each request once, ignores foreign + * run_ids, and stops cleanly on SIGTERM. */ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { parseArgs } from './playable_fault_proxy.mjs'; + +const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_fault_proxy.mjs'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-fault-proxy.')); +let failures = 0; +const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function until(predicate, timeoutMs, stepMs = 50) { + const end = Date.now() + timeoutMs; + while (Date.now() < end) { + if (await predicate()) return true; + await delay(stepMs); + } + return false; +} + +function freePort() { + return new Promise((resolve) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); }); + }); +} + +function connect(port) { + return new Promise((resolve) => { + const socket = net.connect(port, '127.0.0.1'); + const state = { socket, closed: false, data: '', error: null }; + socket.on('data', (chunk) => { state.data += chunk.toString(); }); + socket.on('close', () => { state.closed = true; }); + socket.once('connect', () => resolve(state)); + socket.once('error', (error) => { state.error = error.code; resolve(state); }); + }); +} + +const logRows = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []); +const actions = (file, action) => logRows(file).filter((row) => row.action === action); +let usClock = 1000; +const request = (file, type, attempt, extra = {}) => fs.appendFileSync(file, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', + case_id: 'STB-DISCONNECT-01', connection_epoch: 2, stage: 'FAULT', kind: 'fault_request', actor_vid: 0, target_vid: 0, + payload: { type, attempt, round: 0, timeout_ms: 30000, unreachable_seconds: type === 'unreachable' ? 2 : 0, ...extra } })}\n`); + +// Argument contract. +{ + const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' }); + check(help.status === 0 && /127\.0\.0\.1/.test(help.stdout), '--help documents loopback-only listening'); + for (const [label, args] of [ + ['no routes', ['--events', 'e', '--log', 'l']], + ['malformed route', ['--route', 'abc', '--events', 'e', '--log', 'l']], + ['route to itself', ['--route', '4000=127.0.0.1:4000', '--events', 'e', '--log', 'l']], + ['port out of range', ['--route', '70000=10.0.0.1:1', '--events', 'e', '--log', 'l']], + ['unknown option', ['--route', '4000=10.0.0.1:1', '--events', 'e', '--log', 'l', '--listen-host', '0.0.0.0']], + ]) { + const run = spawnSync(process.execPath, [script, ...args], { encoding: 'utf8' }); + check(run.status === 2, `usage error exits 2: ${label}`); + } + check(parseArgs(['--route', '4000=192.0.2.1:11000', '--events', 'e', '--log', 'l']).routes[0].host === '192.0.2.1', 'route parses upstream host'); +} + +// Live behaviour against a local echo upstream. +const upstream = net.createServer((socket) => socket.pipe(socket)); +await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve)); +const upstreamPort = upstream.address().port; +const listenPort = await freePort(); +const events = path.join(root, 'events.jsonl'); +const log = path.join(root, 'fault-proxy.jsonl'); +const proxy = spawn(process.execPath, [script, '--route', `${listenPort}=127.0.0.1:${upstreamPort}`, '--events', events, + '--log', log, '--run-id', 'run-a', '--poll-ms', '50'], { stdio: ['ignore', 'ignore', 'pipe'] }); +let exited = null; +proxy.on('exit', (code) => { exited = code; }); + +check(await until(() => actions(log, 'listening').length === 1, 5000), 'proxy logs listening before any fault'); +let a = await connect(listenPort); +a.socket.write('ping'); +check(await until(() => a.data === 'ping', 2000), 'bytes are forwarded to the upstream and back'); + +request(events, 'close', 1); +check(await until(() => a.closed, 2000), 'close request drops the proxied connection'); +check(actions(log, 'close')[0]?.closed === 1, 'close action records the dropped connection count'); +let b = await connect(listenPort); +b.socket.write('again'); +check(!b.error && await until(() => b.data === 'again', 2000), 'the listener stays open after close'); + +// The same line again (same monotonic_us/type/attempt) is not a second fault. +const lines = fs.readFileSync(events, 'utf8').trim().split('\n'); +fs.appendFileSync(events, `${lines.at(-1)}\n`); +await delay(300); +check(!b.closed && actions(log, 'close').length === 1, 'a repeated request line is acted on once'); + +fs.appendFileSync(events, `${JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'other-run', kind: 'fault_request', payload: { type: 'close', attempt: 9 } })}\n`); +await delay(300); +check(!b.closed && actions(log, 'ignored_foreign_run').length === 1, 'fault requests from another run are ignored'); + +// Partial line: only acted on once the newline arrives. +const partial = JSON.stringify({ monotonic_us: (usClock += 1000), run_id: 'run-a', kind: 'fault_request', + payload: { type: 'unreachable', attempt: 1, unreachable_seconds: 2 } }); +fs.appendFileSync(events, partial.slice(0, 20)); +await delay(300); +check(!b.closed, 'a half-written event line is not parsed early'); +const started = Date.now(); +fs.appendFileSync(events, `${partial.slice(20)}\n`); +check(await until(() => b.closed, 2000), 'unreachable request drops the connection'); +await until(() => actions(log, 'unreachable_start').length === 1, 1000); +const refused = await connect(listenPort); +check(refused.error === 'ECONNREFUSED', `connections are refused during the unreachable window (${refused.error})`); +check(await until(() => actions(log, 'unreachable_end').length === 1, 5000), 'listener comes back after unreachable_seconds'); +const elapsed = Date.now() - started; +check(elapsed >= 1900 && elapsed < 4500, `unreachable window lasts about 2 s (${elapsed} ms)`); +const c = await connect(listenPort); +c.socket.write('back'); +check(!c.error && await until(() => c.data === 'back', 2000), 'forwarding works again after the window'); +c.socket.destroy(); + +proxy.kill('SIGTERM'); +check(await until(() => exited !== null, 5000) && exited === 0, 'SIGTERM stops the proxy with exit 0'); +check(actions(log, 'stopped').length === 1, 'stop is logged'); +const after = await connect(listenPort); +check(after.error === 'ECONNREFUSED', 'no listener remains after stop'); +check(!fs.readFileSync(log, 'utf8').includes('ping'), 'the action log never contains payload bytes'); + +// A port already in use is a startup error, not a silent run. +{ + const busy = net.createServer(); + await new Promise((resolve) => busy.listen(0, '127.0.0.1', resolve)); + const run = spawnSync(process.execPath, [script, '--route', `${busy.address().port}=127.0.0.1:${upstreamPort}`, '--events', events, + '--log', path.join(root, 'busy.jsonl')], { encoding: 'utf8', timeout: 10000 }); + check(run.status === 2 && /cannot listen/.test(run.stderr), 'busy listen port exits 2'); + busy.close(); +} + +upstream.close(); +fs.rmSync(root, { recursive: true, force: true }); +console.log(failures === 0 ? 'PASS: playable_fault_proxy_test' : `FAIL: playable_fault_proxy_test (${failures})`); +process.exitCode = failures === 0 ? 0 : 1; diff --git a/script/playable_gate_test.sh b/script/playable_gate_test.sh index d4d2fcf8..ff465ed5 100755 --- a/script/playable_gate_test.sh +++ b/script/playable_gate_test.sh @@ -1,44 +1,409 @@ #!/usr/bin/env bash -# INF-02 negative/positive checks. Uses only static fixtures and a temporary -# output directory; it never starts the game or contacts the server. +# INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64 +# app whose executable execs test/playable/fake_client.sh, listens on a local +# 127.0.0.1 port, and drives script/run_client_gate.sh through PASS and every +# failure path. Never starts the real client and never contacts the test server. set -euo pipefail +set +x cd "$(dirname "$0")/.." +repo="$PWD" tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")" -trap 'rm -R "$tmp_dir"' EXIT +listener_pid="" +cleanup() { + if [ -n "$listener_pid" ]; then kill "$listener_pid" 2>/dev/null || true; fi + if [ -z "${MT_GATE_TEST_KEEP:-}" ]; then rm -R "$tmp_dir"; else echo "kept $tmp_dir"; fi +} +trap cleanup EXIT -node script/validate_playable_report.mjs \ - --report test/playable/report.valid.json \ - --output "$tmp_dir/valid.json" \ - --log test/playable/clean.log --process-code 0 --require-pass +failures=0 +pass() { echo "ok - $*"; } +fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); } +expect_code() { # expected actual label + if [ "$1" -eq "$2" ]; then pass "$3 (exit $2)"; else fail "$3: expected exit $1, got $2"; fi +} -if node script/validate_playable_report.mjs \ - --report test/playable/report.blocked.json \ - --output "$tmp_dir/blocked.json" \ - --log test/playable/clean.log --process-code 0 --require-pass; then - echo "FAIL: BLOCKED fixture was accepted" >&2 - exit 1 +# ---------- validator unit checks on static fixtures ---------- +fixture="$repo/test/playable" +validate_fixture() { # report log process_code timed_out [extra...] + local report="$1" log="$2" code="$3" timed="$4" + shift 4 + node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \ + --process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \ + --required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \ + --events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1 +} +reset_fixture() { + rm -rf "$tmp_dir/v" + mkdir -p "$tmp_dir/v" + cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json" + cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json" + cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json" + cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl" +} +mutate() { # js expression body operating on `r` + node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json" +} +code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; } +quiet() { "$@" >/dev/null 2>&1; } + +reset_fixture +expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture" +sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")" +[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed" +cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json" +expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable" +reset_fixture +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed" +mutate 'r.schema_version=2' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema" +reset_fixture; mutate 'r.suite="full"' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch" +reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases" +reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case" +reset_fixture; mutate 'r.build.pck_sha256=""' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash" +reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json" +reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file" +reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape" +reset_fixture; mutate 'r.event_count=5' +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch" +reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events" +reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list" +reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json" +expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime" +reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"' +code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" +expect_code 1 "$code" "validator: credential literal in client report" +if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi + +# ---------- log redactor ---------- +redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)" +[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted" + +# ---------- scripts never trace ---------- +if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then + fail "a launch script enables set -x" +else + pass "launch scripts never enable set -x" fi -if node script/validate_playable_report.mjs \ - --report test/playable/report.valid.json \ - --output "$tmp_dir/exit.json" \ - --log test/playable/clean.log --process-code 1 --require-pass; then - echo "FAIL: non-zero child exit was accepted" >&2 - exit 1 +# ---------- example config is rejected ---------- +if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \ + "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then + fail "empty example scenario accepted" +else + pass "empty example scenario rejected" fi -if node script/validate_playable_report.mjs \ - --report test/playable/report.valid.json \ - --output "$tmp_dir/rid.json" \ - --log test/playable/rid-warning.log --process-code 0 --require-pass; then - echo "FAIL: renderer warning was accepted" >&2 - exit 1 +# ---------- fake signed arm64 app ---------- +app="$tmp_dir/Fake.app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources" +cat >"$tmp_dir/fake.c" <<'EOF' +#include +#include +int main(void) { + const char *script = getenv("MT_FAKE_CHILD_SCRIPT"); + if (script == NULL) return 64; + execl("/bin/sh", "sh", script, (char *)0); + return 127; +} +EOF +printf 'int fake_extension(void) { return 1; }\n' >"$tmp_dir/ext.c" +cc -arch arm64 -o "$app/Contents/MacOS/mtgodot-poc" "$tmp_dir/fake.c" +cc -arch arm64 -dynamiclib -o "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" "$tmp_dir/ext.c" +printf 'fake pck\n' >"$app/Contents/Resources/mtgodot-poc.pck" +cat >"$app/Contents/Info.plist" <<'EOF' + + + +CFBundleExecutablemtgodot-poc +CFBundleIdentifiertest.mt.playable-gate +CFBundlePackageTypeAPPL + +EOF +codesign --force -s - "$app/Contents/Frameworks/libmtgodot.macos.template_release.dylib" >/dev/null 2>&1 +codesign --force -s - "$app" >/dev/null 2>&1 + +# Local listener so the TCP precheck has a real endpoint; nothing reads from it. +node -e ' +const server = require("node:net").createServer((socket) => socket.destroy()); +server.listen(0, "127.0.0.1", () => console.log(server.address().port)); +' >"$tmp_dir/port" & +listener_pid=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$tmp_dir/port" ] && break; sleep 0.2; done +port="$(tr -d '\n' <"$tmp_dir/port")" +printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$port" "$port" >"$tmp_dir/serverlist.txt" +map_key="outdoortrent/metin2_map_trent" +mkdir -p "$tmp_dir/assets/$map_key" +printf 'ScriptType\tMapSetting\nBasePosition\t0\t0\nMapSize\t2\t2\n' >"$tmp_dir/assets/$map_key/Setting.txt" +node -e ' +const [file, port, key] = process.argv.slice(1); +require("node:fs").writeFileSync(file, JSON.stringify({ + schema_version: 1, scenario_id: "gate-fixture", protocol: "classic", + server: { server_index: 0, channel: 1, auth_host: "127.0.0.1", auth_port: Number(port), game_host: "127.0.0.1", game_port: Number(port) }, + character_slot: 0, map_key: key, waypoints_cm: [[1000, 2000], [1600, 2000]], + allowed_mob_vnums: [101], allowed_drop_vnums: [19], + skill_cases: [{ case_id: "strike", skill_id: 5, target: "enemy", repeats: 2, required_evidence: ["cast_started", "damage"] }], + resolution: [1280, 720], loops: 1, timeout_seconds: 900, +}, null, 2)); +' "$tmp_dir/scenario.local.json" "$port" "$map_key" + +export MT_ACCOUNT="gate-fixture-account" +export MT_PASSWORD="gate-fixture-password" +export MT_FAKE_CHILD_SCRIPT="$repo/test/playable/fake_client.sh" +run_gate() { # scenario output [extra args...] + local scenario="$1" out="$2" + shift 2 + MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ + --output "$out" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" >"$out.stdout" 2>&1 +} +report_status() { node -e 'try{process.stdout.write(require(process.argv[1]).status)}catch{process.stdout.write("MISSING")}' "$1/report.json"; } + +expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: PASS" +[ "$(report_status "$tmp_dir/run-pass")" = "PASS" ] && pass "runner: sealed report PASS" || fail "runner: sealed report not PASS" +expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-pass-2")" "runner: second independent PASS" +expect_code 0 "$(code_of run_gate pass "$tmp_dir/run-full" --suite full)" "runner: full suite PASS" +expect_code 1 "$(code_of run_gate exit3 "$tmp_dir/run-exit3")" "runner: exit 3 after client PASS" +[ "$(report_status "$tmp_dir/run-exit3")" = "FAIL" ] && pass "runner: exit 3 sealed FAIL" || fail "runner: exit 3 not sealed FAIL" +expect_code 1 "$(code_of run_gate signal "$tmp_dir/run-signal")" "runner: child killed by signal" +expect_code 1 "$(code_of run_gate stale "$tmp_dir/run-stale")" "runner: stale run_id" +expect_code 1 "$(code_of run_gate no-report "$tmp_dir/run-noreport")" "runner: missing client report" +expect_code 1 "$(code_of run_gate rid "$tmp_dir/run-rid")" "runner: RID warning after PASS" +expect_code 1 "$(code_of run_gate leak "$tmp_dir/run-leak")" "runner: leak warning after PASS" +expect_code 1 "$(code_of run_gate missing-case "$tmp_dir/run-missing-case")" "runner: missing required case" +expect_code 1 "$(code_of run_gate missing-evidence "$tmp_dir/run-missing-evidence")" "runner: missing evidence file" +expect_code 2 "$(code_of run_gate blocked "$tmp_dir/run-blocked")" "runner: BLOCKED cases" +expect_code 0 "$(code_of run_gate print-secret "$tmp_dir/run-secret")" "runner: printed credentials are redacted before disk" +if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/run-secret" "$tmp_dir/run-secret.stdout"; then + fail "runner: credential literal reached the run directory" +else + pass "runner: no credential literal in run directory" fi -if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \ - godot --headless --path project --script playable_config_gate.gd; then - echo "FAIL: empty scenario configuration was accepted" >&2 +expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed" +if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then + pass "runner: TERM grace honoured before KILL" +else + fail "runner: timeout sequence: $(tr '\n' ' ' <"$tmp_dir/run-hang/gate.log" 2>/dev/null)" +fi +[ "$(report_status "$tmp_dir/run-hang")" = "FAIL" ] && pass "runner: timeout sealed FAIL" || fail "runner: timeout not sealed FAIL" + +expect_code 1 "$(code_of run_gate fifo-holder "$tmp_dir/run-fifo")" "runner: grandchild holding the log pipe" + +set +e +MT_FAKE_SCENARIO=exit3 bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ + --output "$tmp_dir/run-pipe" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" 2>&1 | cat >/dev/null +piped=$? +set -e +expect_code 1 "$piped" "runner: exit code survives | cat under pipefail" + +mkdir -p "$tmp_dir/run-nocreds" +code="$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" \ + --config "$tmp_dir/scenario.local.json" --output "$tmp_dir/run-nocreds" --assets "$tmp_dir/assets" \ + --serverlist "$tmp_dir/serverlist.txt" /dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-arch" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: missing required architecture" +printf '0\t127.0.0.1\t1\t127.0.0.1\t1\t1\t10\t0\n' >"$tmp_dir/serverlist-drift.txt" +expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-drift" "$tmp_dir/assets" "$tmp_dir/serverlist-drift.txt")" "runner: config address differs from serverlist" +[ ! -e "$tmp_dir/run-drift/client.log" ] && pass "runner: client not started on address drift" || fail "runner: client started on address drift" +cp -R "$app" "$tmp_dir/Tampered.app" +printf 'tampered\n' >>"$tmp_dir/Tampered.app/Contents/Resources/mtgodot-poc.pck" +expect_code 2 "$(code_of env bash -c 'MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$1" --config "$2" --output "$3" --assets "$4" --serverlist "$5" >/dev/null 2>&1' _ "$tmp_dir/Tampered.app" "$tmp_dir/scenario.local.json" "$tmp_dir/run-tampered" "$tmp_dir/assets" "$tmp_dir/serverlist.txt")" "runner: broken signature" + +# ---------- release aggregation: explicit run list only ---------- +release="$tmp_dir/release" +mkdir -p "$release" +cp -R "$tmp_dir/run-pass" "$release/run-1" +cp -R "$tmp_dir/run-pass-2" "$release/run-2" +cp -R "$tmp_dir/run-exit3" "$release/run-bad" +bash script/playable_build_info.sh "$app" >"$tmp_dir/candidate.json" +write_manifest() { # required runs-json [candidate-override-js] + node -e ' +const fs = require("node:fs"); +const [dir, required, runs, candidateFile, tweak] = process.argv.slice(1); +const candidate = JSON.parse(fs.readFileSync(candidateFile, "utf8")); +if (tweak) eval(tweak); +const list = JSON.parse(runs).map((name) => ({ run_id: JSON.parse(fs.readFileSync(`${dir}/${name}/report.json`, "utf8")).run_id, suite: "playable", path: name })); +fs.writeFileSync(`${dir}/release-manifest.json`, JSON.stringify({ schema_version: 1, candidate, required_runs: { playable: Number(required) }, runs: list })); +' "$release" "$1" "$2" "$tmp_dir/candidate.json" "${3:-}" +} +release_code() { code_of quiet node script/validate_playable_report.mjs --release-dir "$release"; } +write_manifest 2 '["run-1","run-2"]' +expect_code 0 "$(release_code)" "release: two listed PASS runs" +write_manifest 2 '["run-1"]' +expect_code 1 "$(release_code)" "release: unlisted PASS runs are never counted" +write_manifest 2 '["run-1","run-2","run-bad"]' +expect_code 1 "$(release_code)" "release: a listed FAIL run fails the release" +write_manifest 2 '["run-1","run-2"]' 'candidate.pck_sha256 = "e".repeat(64)' +expect_code 1 "$(release_code)" "release: runs from another candidate" +write_manifest 2 '["run-1","run-1"]' +expect_code 1 "$(release_code)" "release: the same run listed twice" +node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs[0].path="../run-pass";fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json" +expect_code 1 "$(release_code)" "release: run path escapes the release directory" + +mkdir -p "$release/run-blocked" +printf 'BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端\n' >"$release/run-blocked/gate.log" +node -e 'const fs=require("fs");const f=process.argv[1];const m=JSON.parse(fs.readFileSync(f));m.runs=[m.runs[1],{run_id:"run-blocked-unsealed",suite:"playable",path:"run-blocked"}];m.required_runs={playable:2};fs.writeFileSync(f,JSON.stringify(m));' "$release/release-manifest.json" +expect_code 2 "$(release_code)" "release: precondition-blocked run is BLOCKED, not PASS" + +# ---------- batch entry: manifest written from the runs it just made ---------- +expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ + --output "$tmp_dir/batch" --suite playable --repeat 2 --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: two runs aggregate to PASS" +batch_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>r.path).join(","))' "$tmp_dir/batch/release-manifest.json")" +[ "$batch_runs" = "run-1,run-2" ] && pass "batch: manifest lists exactly its own runs" || fail "batch: manifest runs $batch_runs" +expect_code 1 "$(code_of quiet env MT_FAKE_SCENARIO=rid bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ + --output "$tmp_dir/batch-rid" --suite playable --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "batch: a failing run fails the batch" +expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD bash script/playable_test.sh --app "$app" --config "$tmp_dir/scenario.local.json" \ + --output "$tmp_dir/batch-nocreds" "$out.stdout" 2>&1 +} +expect_code 0 "$(code_of run_forest no-creds "$tmp_dir/forest-pass")" "forest: PASS without credentials in the child" +forest_cases="$(node -e 'process.stdout.write(require(process.argv[1]).cases.join(","))' "$tmp_dir/forest-pass/required-cases.json")" +[ "$forest_cases" = "MAP-FOREST-CONFIG,MAP-FOREST-LOAD-metin2_map_trent,MAP-FOREST-VP-metin2_map_trent-flat,MAP-FOREST-VP-metin2_map_trent-slope,MAP-FOREST-VP-metin2_map_trent-dense,MAP-FOREST-VP-metin2_map_trent-warp,MAP-FOREST-MOTION-metin2_map_trent" ] \ + && pass "forest: required cases follow required_case_ids()" || fail "forest: required cases $forest_cases" +grep -q "人工签核" "$tmp_dir/forest-pass.stdout" && pass "forest: manual screenshot sign-off stays pending" || fail "forest: sign-off reminder missing" +expect_code 2 "$(code_of run_forest blocked "$tmp_dir/forest-blocked")" "forest: BLOCKED viewpoints are not releasable" +expect_code 1 "$(code_of run_forest missing-case "$tmp_dir/forest-missing-case")" "forest: missing required case" +expect_code 1 "$(code_of run_forest leak "$tmp_dir/forest-leak")" "forest: leak warning after PASS" +expect_code 124 "$(code_of run_forest hang "$tmp_dir/forest-hang" --timeout-seconds 2)" "forest: TERM-ignoring child is killed" +mkdir -p "$tmp_dir/forest-nomap" +expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/forest_map_render_test.sh --app "$app" --output "$tmp_dir/forest-nomap")" "forest: no map key" +[ ! -e "$tmp_dir/forest-nomap/client.log" ] && pass "forest: client not started without a map" || fail "forest: client started without a map" +expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-races" --races 2301,abc)" "forest: invalid race list" +expect_code 2 "$(code_of run_forest pass "$tmp_dir/forest-pass")" "forest: refuses a non-empty output directory" + +# ---------- STB-01 soak runner (offline; fake client, no real 2 h run) ---------- +soak_config() { # output-file server-port [faults-js] + node -e ' +const fs = require("node:fs"); +const [base, out, port, faults] = process.argv.slice(1); +const c = JSON.parse(fs.readFileSync(base, "utf8")); +c.timeout_seconds = 8100; +c.server.auth_port = Number(port); c.server.game_port = Number(port); +c.soak = { duration_seconds: 7200, rest_seconds: 30, warmup_rounds: 1, min_rounds: 10, reconnects: 10, exits: 10, + resolutions: { sizes: [[1280, 720], [1440, 900], [1600, 1000]], switches_per_size: 10 }, + warp: { status: "unconfirmed" }, faults: { status: "unconfirmed" } }; +if (faults) c.soak.faults = JSON.parse(faults); +fs.writeFileSync(out, JSON.stringify(c, null, 2)); +' "$tmp_dir/scenario.local.json" "$1" "$2" "${3:-}" +} +soak_config "$tmp_dir/soak.local.json" "$port" +run_soak_gate() { # scenario output config serverlist [extra args...] + local scenario="$1" out="$2" config="$3" list="$4" + shift 4 + MT_FAKE_SCENARIO="$scenario" bash script/run_client_gate.sh --app "$app" --config "$config" --output "$out" \ + --assets "$tmp_dir/assets" --serverlist "$list" --suite soak --allow-gameplay "$@" >"$out.stdout" 2>&1 +} +mkdir -p "$tmp_dir/soak-short" +expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-short" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 900)" "soak runner: timeout below duration+900" +[ ! -e "$tmp_dir/soak-short/client.log" ] && pass "soak runner: client not started with a short timeout" || fail "soak runner: client started with a short timeout" +mkdir -p "$tmp_dir/soak-noblock" +expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-noblock" "$tmp_dir/scenario.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: scenario without a soak block" +[ ! -e "$tmp_dir/soak-noblock/client.log" ] && pass "soak runner: client not started without a soak block" || fail "soak runner: client started without a soak block" + +expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-run" "$tmp_dir/soak.local.json" "$tmp_dir/serverlist.txt" --timeout-seconds 8100)" "soak runner: fake client without soak rounds is BLOCKED, not PASS" +soak_seal="$(node -e ' +const fs = require("node:fs"); +const dir = process.argv[1]; +const r = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")); +const memory = (r.runner_cases || []).find((c) => c.id === "STB-MEMORY-01"); +const rows = fs.readFileSync(`${dir}/rss.jsonl`, "utf8").trim().split("\n").filter(Boolean).length; +process.stdout.write([r.status, memory ? memory.status : "none", rows > 0 ? "rss" : "no-rss", r.exit_gate.checked].join(",")); +' "$tmp_dir/soak-run" 2>/dev/null || echo unreadable)" +[ "$soak_seal" = "BLOCKED,BLOCKED,rss,true" ] && pass "soak runner: RSS sampled for the child and STB-MEMORY-01 sealed" || fail "soak runner: seal $soak_seal" +[ ! -e "$tmp_dir/soak-run/fault-proxy.jsonl" ] && pass "soak runner: no fault proxy while faults are unconfirmed" || fail "soak runner: proxy started for unconfirmed faults" + +# Confirmed faults: client points at a loopback proxy port, proxy forwards to the local listener. +proxy_port="$(node -e 'const s=require("node:net").createServer();s.listen(0,"127.0.0.1",()=>{const p=s.address().port;s.close(()=>process.stdout.write(String(p)));})')" +printf 'fixture\t127.0.0.1\t%s\t127.0.0.1\t%s\t1\t10\t0\n' "$proxy_port" "$proxy_port" >"$tmp_dir/serverlist-proxy.txt" +soak_config "$tmp_dir/soak-proxy.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":$port,\"game_host\":\"127.0.0.1\",\"game_port\":$port}}" +expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy" "$tmp_dir/soak-proxy.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: confirmed faults run through the loopback proxy" +proxy_actions="$(node -e 'const rows=require("node:fs").readFileSync(process.argv[1],"utf8").trim().split("\n").map((l)=>JSON.parse(l).action);process.stdout.write(String(rows[0]==="listening"&&rows.at(-1)==="stopped"))' "$tmp_dir/soak-proxy/fault-proxy.jsonl" 2>/dev/null || echo false)" +[ "$proxy_actions" = "true" ] && pass "soak runner: proxy listened before the client and stopped after it" || fail "soak runner: proxy log incomplete" +proxy_pid="$(sed -n 's/^FAULT PROXY pid=\([0-9]*\) .*/\1/p' "$tmp_dir/soak-proxy/gate.log")" +if [ -n "$proxy_pid" ] && ! kill -0 "$proxy_pid" 2>/dev/null; then pass "soak runner: proxy process is gone after the run"; else fail "soak runner: proxy pid [$proxy_pid] still alive or unknown"; fi +proxy_playable() { # output: an exit run (suite playable) with the same confirmed-faults config still reaches the server + MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-proxy.local.json" --output "$1" \ + --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt" --allow-gameplay >"$1.stdout" 2>&1 +} +expect_code 0 "$(code_of proxy_playable "$tmp_dir/soak-proxy-exit")" "soak runner: playable exit run behind the confirmed proxy" +grep -q '"action":"stopped"' "$tmp_dir/soak-proxy-exit/fault-proxy.jsonl" 2>/dev/null && [ ! -e "$tmp_dir/soak-proxy-exit/rss.jsonl" ] \ + && pass "soak runner: exit run forwards through the proxy without soak sampling" || fail "soak runner: exit run proxy/sampler state" +node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.timeout_seconds=900;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak.local.json" "$tmp_dir/soak-short-timeout.local.json" +expect_code 0 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$tmp_dir/soak-short-timeout.local.json" \ + --output "$tmp_dir/soak-block-playable" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt")" "soak runner: playable suite ignores the soak-only timeout margin" +bad_soak="$tmp_dir/soak-bad-playable.local.json" +node -e 'const fs=require("fs");const c=JSON.parse(fs.readFileSync(process.argv[1]));c.soak.faults.upstream.auth_port=70000;fs.writeFileSync(process.argv[2],JSON.stringify(c));' "$tmp_dir/soak-proxy.local.json" "$bad_soak" +mkdir -p "$tmp_dir/soak-bad-playable" +expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=pass bash script/run_client_gate.sh --app "$app" --config "$bad_soak" --output "$tmp_dir/soak-bad-playable" \ + --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist-proxy.txt")" "soak runner: an invalid soak block is rejected for every suite" +grep -q "soak.faults.upstream.auth_port" "$tmp_dir/soak-bad-playable/config.log" 2>/dev/null && [ ! -e "$tmp_dir/soak-bad-playable/client.log" ] \ + && pass "soak runner: config gate names the invalid upstream before any client" || fail "soak runner: invalid soak block not caught by the config gate" +mkdir -p "$tmp_dir/soak-proxy-down" +soak_config "$tmp_dir/soak-proxy-down.local.json" "$proxy_port" "{\"status\":\"confirmed\",\"mode\":\"local_proxy\",\"per_type\":1,\"unreachable_seconds\":5,\"upstream\":{\"auth_host\":\"127.0.0.1\",\"auth_port\":1,\"game_host\":\"127.0.0.1\",\"game_port\":1}}" +expect_code 2 "$(code_of run_soak_gate soak-pass "$tmp_dir/soak-proxy-down" "$tmp_dir/soak-proxy-down.local.json" "$tmp_dir/serverlist-proxy.txt" --timeout-seconds 8100)" "soak runner: unreachable fault-proxy upstream" +[ ! -e "$tmp_dir/soak-proxy-down/client.log" ] && pass "soak runner: client not started when the upstream is down" || fail "soak runner: client started with upstream down" + +# ---------- STB-01 public entry: script/playable_soak.sh ---------- +run_soak() { # scenario output [extra args...] + local scenario="$1" out="$2" + shift 2 + MT_FAKE_SCENARIO="$scenario" bash script/playable_soak.sh --app "$app" --config "$tmp_dir/soak.local.json" --output "$out" \ + --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" "$@" "$out.stdout" 2>&1 +} +expect_code 0 "$(code_of quiet bash script/playable_soak.sh --help)" "soak: --help" +expect_code 2 "$(code_of quiet bash script/playable_soak.sh --app "$app" /dev/null)" ]; then fail "soak: $dir started a client"; fi +done +expect_code 2 "$(code_of quiet env -u MT_ACCOUNT -u MT_PASSWORD MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" \ + --config "$tmp_dir/soak.local.json" --output "$tmp_dir/soak-nocreds" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay /dev/null \ + && pass "soak: 2 h soak not started after a failed exit run" || fail "soak: soak run started after a failed exit run" + +expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: fake batch stays BLOCKED without real soak rounds" +soak_runs="$(node -e 'const m=require(process.argv[1]);process.stdout.write(m.runs.map((r)=>`${r.path}:${r.suite}`).join(",")+"|"+JSON.stringify(m.required_runs))' "$tmp_dir/soak-batch/release-manifest.json" 2>/dev/null || echo missing)" +expected_runs="$(for i in 1 2 3 4 5 6 7 8 9 10; do printf 'exit-%s:playable,' "$i"; done)soak-1:soak|{\"playable\":10,\"soak\":1}" +[ "$soak_runs" = "$expected_runs" ] && pass "soak: manifest lists 10 exit runs and one soak run" || fail "soak: manifest $soak_runs" +exit_passes="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.counts.playable.passed)+"/"+r.status)' "$tmp_dir/soak-batch/release-report.json" 2>/dev/null || echo missing)" +[ "$exit_passes" = "10/BLOCKED" ] && pass "soak: 10 independent exit runs PASS, release BLOCKED by memory evidence" || fail "soak: release $exit_passes" +if grep -rqF -e "$MT_ACCOUNT" -e "$MT_PASSWORD" "$tmp_dir/soak-batch" "$tmp_dir/soak-batch.stdout"; then + fail "soak: credential literal reached the batch directory" +else + pass "soak: no credential literal in the batch directory" +fi +expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-batch" --allow-gameplay)" "soak: refuses a non-empty output directory" + +if [ "$failures" -ne 0 ]; then + echo "playable_gate_test: FAIL ($failures)" >&2 exit 1 fi - echo "playable_gate_test: PASS" diff --git a/script/playable_soak.sh b/script/playable_soak.sh new file mode 100644 index 00000000..ac0c5665 --- /dev/null +++ b/script/playable_soak.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs. +# +# Layout under --output (must be empty): +# exit-/ N independent client processes, suite playable, each through +# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings) +# soak-1/ one client process, suite soak: move/combat/pickup rounds until the +# wall-clock duration, active reconnects, resolution switches, and — +# only when the scenario confirms them — portal warps and real +# transport faults through the 127.0.0.1 fault proxy; RSS is sampled +# for that child PID and sealed as STB-MEMORY-01 +# release-manifest.json listing exactly these runs; aggregated with +# validate_playable_report.mjs --release-dir (no historical reports are scanned). +# +# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak) +# are not started and are recorded as BLOCKED so the release cannot pass. +# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt. +# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches. +# +# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout. +set -euo pipefail +set +x +cd "$(dirname "$0")/.." +repo="$PWD" +app="${MT_PLAYABLE_APP:-}" +config="" +output="" +duration="" +exits="" +timeout="" +exit_timeout="900" +allow=0 +assets="" +serverlist="${MT_PLAYABLE_SERVERLIST:-}" + +usage() { + cat <<'EOF' +用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项] + --app APP 候选导出包(所有 run 必须同一哈希) + --config CONFIG 场景 JSON(含 soak 块;不含凭据) + --output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-) + --duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200) + --exits N 独立正常退出运行次数(默认配置 soak.exits,>= 10) + --timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小) + --exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900 + --assets DIR 资源预检根目录(透传 run_client_gate.sh) + --serverlist FILE 测试模式 serverlist 覆盖(透传) + --allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端) +环境变量: + MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次) +说明: + 切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed; + 未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。 +退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时 +EOF +} + +blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; } +positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; } + +while [ "$#" -gt 0 ]; do + case "$1" in + --app) app="${2:-}"; shift 2 ;; + --config) config="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + --duration-seconds) duration="${2:-}"; shift 2 ;; + --exits) exits="${2:-}"; shift 2 ;; + --timeout-seconds) timeout="${2:-}"; shift 2 ;; + --exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;; + --assets) assets="${2:-}"; shift 2 ;; + --serverlist) serverlist="${2:-}"; shift 2 ;; + --allow-gameplay) allow=1; shift ;; + --help) usage; exit 0 ;; + *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包" +[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG" +config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")" +for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do + value="${pair#*:}" + if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi +done +positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数" + +# Config validation before any prompt or process: the same gate the runner uses. +if [ -z "$assets" ]; then + if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi +fi +if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \ + MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then + printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true + blocked "配置/资源预检失败(suite soak)" +fi +read -r cfg_duration cfg_exits <<<"$(node -e ' +const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak; +process.stdout.write(`${s.duration_seconds} ${s.exits}`); +' "$config")" +if [ -z "$duration" ]; then duration="$cfg_duration"; fi +[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖" +if [ -z "$exits" ]; then exits="$cfg_exits"; fi +[ "$exits" -ge 10 ] || blocked "--exits 至少 10" +minimum_timeout=$((duration + 900)) +if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi +[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900(${minimum_timeout})" +# Without explicit consent only the configuration is checked; no client, no gameplay request. +[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端" + +root="$output" +if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi +if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + blocked "输出目录已有内容,拒绝混入历史运行:$root" +fi +mkdir -p "$root" +root="$(cd "$root" && pwd)" + +if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then + if [ -t 0 ]; then + if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi + if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi + fi +fi +[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" +export MT_ACCOUNT MT_PASSWORD + +bash script/playable_build_info.sh "$app" >"$root/candidate.json" + +runs_json="[]" +timed_out=0 +stop_reason="" +list_run() { # name suite + runs_json="$(node -e ' +const fs = require("node:fs"); +const [runs, dir, name, suite] = process.argv.slice(1); +let runId = `${name}-unsealed`; +try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {} +const list = JSON.parse(runs); +list.push({ run_id: runId, suite, path: name }); +process.stdout.write(JSON.stringify(list)); +' "$runs_json" "$root/$1" "$1" "$2")" +} +run_one() { # name suite timeout + local name="$1" run_suite="$2" run_timeout="$3" code + if [ -n "$stop_reason" ]; then + mkdir -p "$root/$name" + echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log" + list_run "$name" "$run_suite" + return + fi + local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay) + if [ -n "$assets" ]; then args+=(--assets "$assets"); fi + if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi + set +e + bash script/run_client_gate.sh "${args[@]}" c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`); +' "$root/soak-1/report.json" || true +fi +if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi +case "$release_code" in + 0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;; + 2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;; + *) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;; +esac +exit "$release_code" diff --git a/script/playable_soak_metrics.mjs b/script/playable_soak_metrics.mjs new file mode 100644 index 00000000..1caeab13 --- /dev/null +++ b/script/playable_soak_metrics.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +/* STB-01 §9.1 external memory probe and leak gate. + * + * playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000] + * Appends {wall_ms, rss_kib} for exactly one PID (the runner's own child) via + * `ps -o stat=,rss= -p PID` once per interval and exits when that PID is gone or a zombie. It + * never signals the PID. A failed read is written as rss_kib null, not 0. + * + * playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl + * [--warmup-rounds 1] [--min-rounds 10] + * Reads the client's soak_rest start/end events (payload.round, payload.wall_ms), + * takes the RSS low water inside each >=30 s rest window, applies the leak rule + * and writes report.memory + runner_cases[STB-MEMORY-01] back into the sealed + * report. The verdict can only downgrade the report status. + * + * Leak rule: after warm-up, FAIL when the low water grew in >=5 consecutive rounds + * AND median(last 5) - median(first 5) > max(50 MiB, 5% of median(first 5)). + * Below the threshold is not proof that there is no long-term leak. + * + * exit: 0 PASS, 1 FAIL, 2 BLOCKED or usage error. */ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const REST_MIN_MS = 30000; +const STREAK = 5; +const ABS_THRESHOLD_KIB = 50 * 1024; +const PCT_THRESHOLD = 0.05; +const NOTE = '阈值以下不能证明长期无泄漏;GPU 内存不可得(null/unavailable),不据此判断显存。'; +const CASE_ID = 'STB-MEMORY-01'; +const RANK = { PASS: 0, BLOCKED: 1, FAIL: 2 }; + +export function median(values) { + const sorted = [...values].sort((a, b) => a - b); + if (sorted.length === 0) return null; + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function parseRss(text) { + const samples = []; + let badLines = 0; + for (const line of String(text).split('\n')) { + if (line.trim() === '') continue; + try { + const row = JSON.parse(line); + if (!Number.isFinite(row.wall_ms) || !(row.rss_kib === null || Number.isInteger(row.rss_kib))) badLines += 1; + else samples.push({ wall_ms: row.wall_ms, rss_kib: row.rss_kib }); + } catch { badLines += 1; } + } + return { samples, bad_lines: badLines }; +} + +/** Pair soak_rest start/end events per round. Unpaired or reordered events are errors. */ +export function restWindows(eventsText) { + const open = new Map(); + const windows = []; + const errors = []; + for (const line of String(eventsText).split('\n')) { + if (line.trim() === '') continue; + let event; + try { event = JSON.parse(line); } catch { continue; } + if (event?.kind !== 'soak_rest') continue; + const { round, state, wall_ms: wallMs } = event.payload ?? {}; + if (!Number.isInteger(round) || !Number.isFinite(wallMs)) { errors.push('soak_rest event without integer round/wall_ms'); continue; } + if (state === 'start') { + if (open.has(round)) errors.push(`round ${round}: rest started twice`); + open.set(round, wallMs); + } else if (state === 'end') { + if (!open.has(round)) { errors.push(`round ${round}: rest ended without a start`); continue; } + const start = open.get(round); + open.delete(round); + if (wallMs < start) errors.push(`round ${round}: rest ends before it starts`); + else windows.push({ round, start_ms: start, end_ms: wallMs, rest_ms: wallMs - start }); + } + } + for (const round of open.keys()) errors.push(`round ${round}: rest never ended`); + windows.sort((a, b) => a.round - b.round); + return { windows, errors }; +} + +export function roundLowWaters(windows, samples) { + return windows.map((window) => { + const inside = samples.filter((s) => s.wall_ms >= window.start_ms && s.wall_ms <= window.end_ms && Number.isInteger(s.rss_kib)); + // At least one sample per two seconds of rest; a sparse window is not a measured low water. + const needed = Math.max(1, Math.floor(window.rest_ms / 2000)); + return { + round: window.round, + rest_ms: window.rest_ms, + samples: inside.length, + low_water_kib: inside.length >= needed ? Math.min(...inside.map((s) => s.rss_kib)) : null, + }; + }); +} + +export function leakVerdict(lowWaters, { warmupRounds = 1, minRounds = 10 } = {}) { + const verdict = { + status: 'BLOCKED', reason: '', unit: 'KiB', warmup_rounds: warmupRounds, rounds_total: lowWaters.length, rounds_analysed: 0, + first5_median_kib: null, last5_median_kib: null, growth_kib: null, threshold_kib: null, max_growth_streak: 0, + rule: `FAIL iff >=${STREAK} consecutive growing rounds AND last5 median - first5 median > max(50MiB, 5%)`, + warnings: [], note: NOTE, + }; + const short = lowWaters.find((row) => row.rest_ms < REST_MIN_MS); + if (short) return { ...verdict, status: 'FAIL', reason: `round ${short.round}: rest ${short.rest_ms} ms is shorter than 30 s` }; + const rows = lowWaters.slice(warmupRounds); + verdict.rounds_analysed = rows.length; + if (rows.length < minRounds) return { ...verdict, reason: `need at least ${minRounds} rounds after warm-up, got ${rows.length}` }; + const missing = rows.find((row) => row.low_water_kib === null); + if (missing) return { ...verdict, reason: `round ${missing.round}: no RSS low water (sampler gap)` }; + const values = rows.map((row) => row.low_water_kib); + let streak = 0; + for (let i = 1; i < values.length; i += 1) { + streak = values[i] > values[i - 1] ? streak + 1 : 0; + verdict.max_growth_streak = Math.max(verdict.max_growth_streak, streak); + } + const first = Math.round(median(values.slice(0, STREAK))); + const last = Math.round(median(values.slice(-STREAK))); + verdict.first5_median_kib = first; + verdict.last5_median_kib = last; + verdict.growth_kib = last - first; + verdict.threshold_kib = Math.max(ABS_THRESHOLD_KIB, Math.ceil(PCT_THRESHOLD * first)); + const overThreshold = verdict.growth_kib > verdict.threshold_kib; + const streakHit = verdict.max_growth_streak >= STREAK; + if (overThreshold && streakHit) { + verdict.status = 'FAIL'; + verdict.reason = `low water grew ${verdict.max_growth_streak} rounds in a row and +${verdict.growth_kib} KiB > ${verdict.threshold_kib} KiB; analyse before release`; + return verdict; + } + if (overThreshold) verdict.warnings.push(`median growth +${verdict.growth_kib} KiB exceeds the threshold without ${STREAK} consecutive growing rounds; needs analysis`); + verdict.status = 'PASS'; + verdict.reason = 'no leak signal at the gate threshold'; + return verdict; +} + +export function sealMemory(report, { eventsText, rssText, warmupRounds = 1, minRounds = 10 }) { + const rss = parseRss(rssText ?? ''); + const rest = restWindows(eventsText ?? ''); + const rounds = roundLowWaters(rest.windows, rss.samples); + let verdict = leakVerdict(rounds, { warmupRounds, minRounds }); + if (rest.errors.length > 0) verdict = { ...verdict, status: 'FAIL', reason: `rest events: ${rest.errors.slice(0, 5).join('; ')}` }; + else if (rest.windows.length === 0) verdict = { ...verdict, status: 'BLOCKED', reason: 'no soak_rest windows in events' }; + // The client's own MEMORY_STATIC snapshots stay separate from the external RSS. + const godot = report.memory?.godot ?? report.memory ?? null; + report.memory = { + sampler: 'external ps -o stat=,rss= on the runner-held child PID, 1 Hz', + unit: 'KiB', + sample_count: rss.samples.length, + samples_null: rss.samples.filter((s) => s.rss_kib === null).length, + bad_lines: rss.bad_lines, + rounds, + gpu_memory_kib: null, + gpu_memory_status: 'unavailable', + godot, + verdict, + }; + report.runner_cases = (Array.isArray(report.runner_cases) ? report.runner_cases : []).filter((c) => c?.id !== CASE_ID); + report.runner_cases.push({ id: CASE_ID, status: verdict.status, reason: verdict.reason }); + if (!VALID_STATUS(report.status)) report.status = 'FAIL'; + if (RANK[verdict.status] > RANK[report.status]) report.status = verdict.status; + report.exit_gate = report.exit_gate && typeof report.exit_gate === 'object' ? report.exit_gate : { checked: false, errors: [] }; + if (!Array.isArray(report.exit_gate.errors)) report.exit_gate.errors = []; + if (verdict.status !== 'PASS') report.exit_gate.errors.push(`${CASE_ID}: ${verdict.status} ${verdict.reason}`); + return { report, code: { PASS: 0, FAIL: 1, BLOCKED: 2 }[report.status] ?? 1 }; +} + +function VALID_STATUS(status) { return Object.hasOwn(RANK, status); } + +function usage(stream = process.stderr) { + stream.write('usage: playable_soak_metrics.mjs --sample-rss --pid PID --output rss.jsonl [--interval-ms 1000]\n' + + ' playable_soak_metrics.mjs --seal --report report.json --rss rss.jsonl --events events.jsonl [--warmup-rounds 1] [--min-rounds 10]\n'); +} + +function argsOf(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (['--sample-rss', '--seal', '--help'].includes(arg)) out[arg.slice(2).replace('-', '_')] = true; + else if (arg.startsWith('--') && i + 1 < argv.length) out[arg.slice(2).replaceAll('-', '_')] = argv[++i]; + else throw new Error(`bad argument: ${arg}`); + } + return out; +} + +function readRss(pid) { + return new Promise((resolve) => { + execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 5000 }, (error, stdout) => { + if (error) { resolve({ alive: false }); return; } + const [stat = '', rss = ''] = String(stdout).trim().split(/\s+/); + // An exited child the runner has not reaped yet is a zombie: it is gone, not 0 KiB. + if (stat.startsWith('Z')) { resolve({ alive: false }); return; } + const value = Number.parseInt(rss, 10); + resolve({ alive: true, rss_kib: Number.isInteger(value) && value > 0 ? value : null }); + }); + }); +} + +async function sampleRss(options) { + const pid = Number(options.pid); + const interval = Number(options.interval_ms ?? 1000); + if (!Number.isInteger(pid) || pid <= 0 || !options.output || !Number.isInteger(interval) || interval < 100) { usage(); return 2; } + let stop = false; + process.on('SIGTERM', () => { stop = true; }); + process.on('SIGINT', () => { stop = true; }); + while (!stop) { + const tick = Date.now(); + const row = await readRss(pid); + if (!row.alive) break; + fs.appendFileSync(options.output, `${JSON.stringify({ wall_ms: tick, rss_kib: row.rss_kib })}\n`); + await new Promise((resolve) => setTimeout(resolve, Math.max(0, interval - (Date.now() - tick)))); + } + return 0; +} + +function seal(options) { + for (const key of ['report', 'rss', 'events']) if (!options[key]) { usage(); return 2; } + let report; + try { report = JSON.parse(fs.readFileSync(options.report, 'utf8')); } + catch { console.error(`PLAYABLE MEMORY: FAIL report unreadable ${path.basename(options.report)}`); return 1; } + const read = (file) => (fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''); + const { report: sealed, code } = sealMemory(report, { + eventsText: read(options.events), rssText: read(options.rss), + warmupRounds: Number(options.warmup_rounds ?? 1), minRounds: Number(options.min_rounds ?? 10), + }); + fs.writeFileSync(options.report, `${JSON.stringify(sealed, null, 2)}\n`); + const v = sealed.memory.verdict; + const line = `PLAYABLE MEMORY: ${v.status} ${v.reason} (first5=${v.first5_median_kib} last5=${v.last5_median_kib} KiB)`; + if (v.status === 'PASS') console.log(line); else console.error(line); + return code; +} + +async function main() { + let options; + try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } + if (options.help) { usage(process.stdout); return 0; } + if (options.sample_rss) return sampleRss(options); + if (options.seal) return seal(options); + usage(); + return 2; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = await main(); diff --git a/script/playable_soak_metrics_test.mjs b/script/playable_soak_metrics_test.mjs new file mode 100644 index 00000000..17b83c48 --- /dev/null +++ b/script/playable_soak_metrics_test.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +/* STB-01 §9.1 memory gate regression. Synthetic RSS/rest-window data proves the + * leak rule (5 consecutive growing low waters AND last-5 median above + * max(50MiB, 5%) of the first-5 median), that missing data is BLOCKED rather + * than PASS, and that the external sampler records KiB for one PID only. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import { parseRss, restWindows, roundLowWaters, leakVerdict, median, sealMemory } from './playable_soak_metrics.mjs'; + +const script = path.join(path.dirname(new URL(import.meta.url).pathname), 'playable_soak_metrics.mjs'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-soak-metrics.')); +let failures = 0; +const check = (ok, label) => { if (ok) console.log(`ok - ${label}`); else { failures += 1; console.error(`not ok - ${label}`); } }; + +const MIB = 1024; +const event = (round, state, wallMs, us) => JSON.stringify({ monotonic_us: us, run_id: 'r', case_id: 'STB-MEMORY-01', connection_epoch: 1, + stage: 'REST', kind: 'soak_rest', actor_vid: 0, target_vid: 0, payload: { round, state, wall_ms: wallMs } }); + +/** Build rounds of rest windows (30 s each, 1 Hz samples) whose low water follows `lows` (KiB). */ +function fixture(lows, { restMs = 30000, gapMs = 60000, dropSamplesInRound = -1 } = {}) { + const events = []; + const rss = []; + let t = 1_700_000_000_000; + lows.forEach((low, round) => { + for (let s = 1; s < gapMs / 1000; s += 1) rss.push({ wall_ms: t + s * 1000, rss_kib: low + 80 * MIB }); + t += gapMs; + events.push(event(round, 'start', t, round * 10 + 1)); + for (let s = 0; s <= restMs / 1000; s += 1) { + if (round !== dropSamplesInRound) rss.push({ wall_ms: t + s * 1000, rss_kib: low + (s === 20 ? 0 : 3 * MIB) }); + } + t += restMs; + events.push(event(round, 'end', t, round * 10 + 2)); + }); + return { eventsText: `${events.join('\n')}\n`, rssText: `${rss.map((r) => JSON.stringify(r)).join('\n')}\n` }; +} + +const flat = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i % 2) * MIB); +const leaking = Array.from({ length: 12 }, (_, i) => 900 * MIB + (i < 6 ? 0 : (i - 5) * 20 * MIB)); +const sawtooth = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 30 * MIB - (i % 3 === 0 ? 40 * MIB : 0)); +const smallCreep = Array.from({ length: 12 }, (_, i) => 900 * MIB + i * 2 * MIB); + +check(median([3, 1, 2]) === 2 && median([4, 1, 3, 2]) === 2.5, 'median of odd and even lists'); +{ + const rows = parseRss('{"wall_ms":1,"rss_kib":10}\nnot json\n{"wall_ms":2,"rss_kib":null}\n'); + check(rows.samples.length === 2 && rows.bad_lines === 1 && rows.samples[1].rss_kib === null, 'rss parser keeps null samples and counts bad lines'); +} +{ + const { eventsText, rssText } = fixture(flat); + const windows = restWindows(eventsText); + check(windows.windows.length === 12 && windows.windows[0].round === 0 && windows.windows[0].rest_ms === 30000, 'rest windows come from soak_rest start/end events'); + const lows = roundLowWaters(windows.windows, parseRss(rssText).samples); + check(lows[3].low_water_kib === flat[3] && lows[3].samples === 31, 'low water is the minimum RSS inside the rest window'); + const verdict = leakVerdict(lows, { warmupRounds: 1 }); + check(verdict.status === 'PASS' && verdict.rounds_analysed === 11, `flat memory passes (${verdict.status} ${verdict.reason})`); + check(/不能证明长期无泄漏/.test(verdict.note), 'a pass still says it cannot prove no long-term leak'); + check(verdict.unit === 'KiB' && Number.isInteger(verdict.first5_median_kib) && Number.isInteger(verdict.last5_median_kib), 'first/last 5 medians are recorded in KiB'); +} +{ + const { eventsText, rssText } = fixture(leaking); + const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); + check(verdict.status === 'FAIL' && verdict.max_growth_streak >= 5 && verdict.growth_kib > verdict.threshold_kib, `steady growth fails (${JSON.stringify(verdict)})`); + check(verdict.threshold_kib === 50 * MIB, 'threshold is max(50MiB, 5%) — 50MiB for a 900MiB baseline'); +} +{ + // Large growth without 5 consecutive rising rounds is reported but does not fail the gate. + const verdict = leakVerdict(sawtooth.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); + check(verdict.status === 'PASS' && verdict.max_growth_streak < 5 && verdict.growth_kib > verdict.threshold_kib && verdict.warnings.length === 1, + `median growth without a 5-round streak is a warning (${JSON.stringify(verdict)})`); +} +{ + const verdict = leakVerdict(smallCreep.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); + check(verdict.status === 'PASS' && verdict.max_growth_streak >= 5 && verdict.growth_kib <= verdict.threshold_kib, 'a monotonic creep below the threshold passes with the note'); +} +{ + const big = Array.from({ length: 12 }, (_, i) => 4096 * MIB + i * 60 * MIB); + const verdict = leakVerdict(big.map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); + check(verdict.threshold_kib > 50 * MIB && verdict.threshold_kib === Math.ceil(0.05 * verdict.first5_median_kib), '5% wins for a large baseline'); +} +{ + const verdict = leakVerdict(flat.slice(0, 10).map((low, round) => ({ round, low_water_kib: low, samples: 31, rest_ms: 30000 })), { warmupRounds: 1 }); + check(verdict.status === 'BLOCKED' && /10/.test(verdict.reason), 'fewer than 10 rounds after warm-up is BLOCKED'); +} +{ + const { eventsText, rssText } = fixture(flat, { dropSamplesInRound: 4 }); + const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); + check(verdict.status === 'BLOCKED' && /round 4/.test(verdict.reason), `a rest window without RSS samples is BLOCKED (${verdict.reason})`); +} +{ + const { eventsText, rssText } = fixture(flat, { restMs: 10000 }); + const verdict = leakVerdict(roundLowWaters(restWindows(eventsText).windows, parseRss(rssText).samples), { warmupRounds: 1 }); + check(verdict.status === 'FAIL' && /30/.test(verdict.reason), 'a rest window shorter than 30 s violates the contract'); +} +{ + const unmatched = `${event(0, 'start', 1000, 1)}\n${event(1, 'start', 2000, 2)}\n`; + const windows = restWindows(unmatched); + check(windows.windows.length === 0 && windows.errors.length === 2, 'unmatched rest events are errors, not windows'); +} + +// Seal: the memory verdict downgrades the sealed report and never upgrades it. +{ + const { eventsText, rssText } = fixture(leaking); + const base = { schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } }; + const sealed = sealMemory(structuredClone(base), { eventsText, rssText, warmupRounds: 1 }); + check(sealed.report.status === 'FAIL' && sealed.code === 1, 'leak downgrades PASS to FAIL'); + check(sealed.report.runner_cases?.[0]?.id === 'STB-MEMORY-01' && sealed.report.runner_cases[0].status === 'FAIL', 'runner case STB-MEMORY-01 is recorded'); + check(sealed.report.memory.gpu_memory_kib === null && sealed.report.memory.gpu_memory_status === 'unavailable', 'GPU memory stays null/unavailable'); + check(sealed.report.exit_gate.errors.some((e) => e.startsWith('STB-MEMORY-01')), 'memory verdict reaches exit_gate.errors'); + const blocked = sealMemory(structuredClone(base), { eventsText: '', rssText, warmupRounds: 1 }); + check(blocked.report.status === 'BLOCKED' && blocked.code === 2, 'no rest windows downgrades PASS to BLOCKED'); + const failed = sealMemory({ ...structuredClone(base), status: 'FAIL' }, { ...fixture(flat), warmupRounds: 1 }); + check(failed.report.status === 'FAIL' && failed.code === 1 && failed.report.runner_cases[0].status === 'PASS', 'a clean memory verdict never upgrades a FAIL'); +} + +// CLI seal writes in place; unknown input is a usage error. +{ + const { eventsText, rssText } = fixture(flat); + const dir = path.join(root, 'seal'); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'events.jsonl'), eventsText); + fs.writeFileSync(path.join(dir, 'rss.jsonl'), rssText); + fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify({ schema_version: 1, run_id: 'r', suite: 'soak', status: 'PASS', exit_gate: { checked: true, errors: [] } })); + const run = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json'), '--rss', path.join(dir, 'rss.jsonl'), + '--events', path.join(dir, 'events.jsonl')], { encoding: 'utf8' }); + const out = JSON.parse(fs.readFileSync(path.join(dir, 'report.json'), 'utf8')); + check(run.status === 0 && out.status === 'PASS' && out.memory.rounds.length === 12, `CLI seal PASS (${run.status} ${run.stderr})`); + const bad = spawnSync(process.execPath, [script, '--seal', '--report', path.join(dir, 'report.json')], { encoding: 'utf8' }); + check(bad.status === 2, 'missing seal arguments exit 2'); + const help = spawnSync(process.execPath, [script, '--help'], { encoding: 'utf8' }); + check(help.status === 0 && /--sample-rss/.test(help.stdout + help.stderr), '--help documents the sampler'); +} + +// Sampler: 1 Hz ps samples of one PID, KiB integers, stops by itself when that PID exits. +{ + const target = spawn('/bin/sleep', ['3'], { stdio: 'ignore' }); + const output = path.join(root, 'sampler.jsonl'); + const started = Date.now(); + const sampler = spawnSync(process.execPath, [script, '--sample-rss', '--pid', String(target.pid), '--output', output, '--interval-ms', '500'], + { encoding: 'utf8', timeout: 15000 }); + const rows = parseRss(fs.readFileSync(output, 'utf8')).samples; + check(sampler.status === 0 && Date.now() - started < 12000, `sampler exits after the PID is gone (${sampler.status} ${sampler.stderr})`); + check(rows.length >= 3 && rows.every((r) => Number.isInteger(r.rss_kib) && r.rss_kib > 0 && Number.isInteger(r.wall_ms)), `sampler writes KiB rows (${rows.length})`); + const refused = spawnSync(process.execPath, [script, '--sample-rss', '--pid', '0', '--output', output], { encoding: 'utf8' }); + check(refused.status === 2, 'sampler refuses a non-positive PID'); +} + +fs.rmSync(root, { recursive: true, force: true }); +console.log(failures === 0 ? 'PASS: playable_soak_metrics_test' : `FAIL: playable_soak_metrics_test (${failures})`); +process.exitCode = failures === 0 ? 0 : 1; diff --git a/script/playable_test.sh b/script/playable_test.sh index 5bf80b3e..fce8a154 100755 --- a/script/playable_test.sh +++ b/script/playable_test.sh @@ -1,56 +1,130 @@ #!/usr/bin/env bash -# Public entry for the first playable client gate. It delegates process -# ownership and post-exit validation to run_client_gate.sh. +# Public entry for the first playable client gate (INF-02 / REL-01). +# +# Runs N independent client processes through run_client_gate.sh into +# ROOT/run-, writes ROOT/release-manifest.json listing exactly those runs and +# the candidate package hashes, then aggregates ONLY the listed runs. Historical +# PASS reports elsewhere are never scanned. Credentials are asked once (hidden +# TTY prompt) or taken from MT_ACCOUNT/MT_PASSWORD; never from arguments/files. +# Do not add `set -x` to this script. +# +# exit: 0 PASS, 1 FAIL, 2 BLOCKED, 124 a run hit the wall-clock timeout. set -euo pipefail +set +x cd "$(dirname "$0")/.." -app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}" +repo="$PWD" +app="${MT_PLAYABLE_APP:-}" config="" output="" mode="playable" -timeout="180" +timeout="900" allow=0 repeat=1 suite="full" +assets="" +serverlist="${MT_PLAYABLE_SERVERLIST:-}" usage() { cat <<'EOF' -用法: script/playable_test.sh --config CONFIG [选项] - --app APP 导出包路径 - --output DIR 单次运行输出目录(repeat=1) - --suite full 当前固定为 full/playable - --allow-gameplay 允许发送游戏状态变更请求 - --repeat N 独立进程运行次数,默认 1 - --timeout-seconds N 每次墙钟超时,默认 180 +用法: script/playable_test.sh --app APP --config CONFIG [选项] + --app APP 导出包路径(候选包;所有 run 必须是同一哈希) + --output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>) + --suite playable|full 必测用例集合,默认 full + --repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md) + --timeout-seconds N 每次墙钟超时,默认 900 + --assets DIR 资源预检根目录(透传 run_client_gate.sh) + --serverlist FILE 测试模式 serverlist 覆盖(透传) + --allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求 +退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时 EOF } while [ "$#" -gt 0 ]; do case "$1" in - --app) app="$2"; shift 2 ;; - --config) config="$2"; shift 2 ;; - --output) output="$2"; shift 2 ;; - --suite) suite="$2"; shift 2 ;; + --app) app="${2:-}"; shift 2 ;; + --config) config="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + --suite) suite="${2:-}"; shift 2 ;; + --repeat) repeat="${2:-}"; shift 2 ;; + --timeout-seconds) timeout="${2:-}"; shift 2 ;; + --assets) assets="${2:-}"; shift 2 ;; + --serverlist) serverlist="${2:-}"; shift 2 ;; --allow-gameplay) allow=1; shift ;; - --repeat) repeat="$2"; shift 2 ;; - --timeout-seconds) timeout="$2"; shift 2 ;; --help) usage; exit 0 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; esac done -if [ -z "$config" ] || [ ! -f "$config" ]; then echo "需要 --config CONFIG" >&2; exit 2; fi +if [ -z "$app" ] || [ ! -d "$app" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --app 指向候选导出包" >&2; exit 2; fi +if [ -z "$config" ] || [ ! -f "$config" ]; then echo "PLAYABLE SUITE: BLOCKED 需要 --config CONFIG" >&2; exit 2; fi if [ "$suite" != "full" ] && [ "$suite" != "playable" ]; then echo "--suite 只支持 full/playable" >&2; exit 2; fi if ! [[ "$repeat" =~ ^[1-9][0-9]*$ ]]; then echo "--repeat 必须是正整数" >&2; exit 2; fi -if [ "$repeat" -gt 1 ] && [ -n "$output" ]; then echo "repeat>1 时不能指定单一 --output" >&2; exit 2; fi root="$output" -if [ -z "$root" ]; then root="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$}"; fi +if [ -z "$root" ]; then root="$repo/build/playable/suite-$(date +%Y%m%d-%H%M%S)-$$"; fi +if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "PLAYABLE SUITE: BLOCKED 输出目录已有内容,拒绝混入历史运行:$root" >&2 + exit 2 +fi mkdir -p "$root" -failed=0 +root="$(cd "$root" && pwd)" + +# Ask once for the whole batch; child runners inherit the environment only. +if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then + if [ -t 0 ]; then + if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi + if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi + fi +fi +if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then + echo "PLAYABLE SUITE: BLOCKED 缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" >&2 + exit 2 +fi +export MT_ACCOUNT MT_PASSWORD + +# The candidate identity is computed once, before any run, from the same package. +bash script/playable_build_info.sh "$app" >"$root/candidate.json" + +runs_json="[]" +timed_out=0 for index in $(seq 1 "$repeat"); do - run_dir="$root" - if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi - args=(--app "$app" --config "$config" --output "$run_dir" --mode "$mode" --timeout-seconds "$timeout") + run_dir="$root/run-$index" + args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout") + if [ -n "$assets" ]; then args+=(--assets "$assets"); fi + if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi if [ "$allow" -eq 1 ]; then args+=(--allow-gameplay); fi - if ! bash script/run_client_gate.sh "${args[@]}"; then failed=$((failed + 1)); fi + set +e + bash script/run_client_gate.sh "${args[@]}" &2; exit 1; fi -echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" + +node -e ' +const fs = require("node:fs"); +const [root, suite, repeat, runs] = process.argv.slice(1); +const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8")); +fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({ + schema_version: 1, candidate, required_runs: { [suite]: Number(repeat) }, runs: JSON.parse(runs), +}, null, 2) + "\n"); +' "$root" "$suite" "$repeat" "$runs_json" + +set +e +node script/validate_playable_report.mjs --release-dir "$root" +release_code=$? +set -e +if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SUITE: TIMEOUT root=$root" >&2; exit 124; fi +case "$release_code" in + 0) echo "PLAYABLE SUITE: PASS runs=$repeat root=$root" ;; + 2) echo "PLAYABLE SUITE: BLOCKED root=$root" >&2 ;; + *) echo "PLAYABLE SUITE: FAIL root=$root" >&2; release_code=1 ;; +esac +exit "$release_code" diff --git a/script/redact_stream.mjs b/script/redact_stream.mjs new file mode 100755 index 00000000..1dfc289c --- /dev/null +++ b/script/redact_stream.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/* Copy stdin to stdout line by line, replacing the test credentials before any + * byte reaches disk. Secrets come only from MT_ACCOUNT / MT_PASSWORD in the + * environment and are never printed, logged or passed as arguments. + * + * node script/redact_stream.mjs client.log + * + * Uses blocking reads on fd 0: async stdin on a macOS FIFO does not reliably + * report EOF, which would leave the runner waiting for the redactor. */ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const MIN_LENGTH = 2; + +export function secretLiterals(env = process.env) { + return ['MT_PASSWORD', 'MT_ACCOUNT'] + .map((name) => env[name] || '') + .filter((value) => value.length >= MIN_LENGTH) + // Longest first so an account contained in a password is not half-replaced. + .sort((a, b) => b.length - a.length); +} + +export function redact(line, literals = secretLiterals()) { + let out = line; + for (const literal of literals) out = out.split(literal).join('[redacted]'); + return out; +} + +function pump() { + const literals = secretLiterals(); + const buffer = Buffer.alloc(64 * 1024); + let pending = ''; + const write = (text) => { + const bytes = Buffer.from(text); + let offset = 0; + while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset); + }; + for (;;) { + let count; + try { + count = fs.readSync(0, buffer, 0, buffer.length, null); + } catch (error) { + if (error.code === 'EAGAIN') continue; + if (error.code === 'EOF') break; + throw error; + } + if (count === 0) break; + pending += buffer.toString('utf8', 0, count); + const lines = pending.split('\n'); + pending = lines.pop(); + if (lines.length > 0) write(lines.map((line) => `${redact(line, literals)}\n`).join('')); + } + // A trailing partial line is still redacted as a whole. + if (pending !== '') write(`${redact(pending, literals)}\n`); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) { + if (process.argv.includes('--help')) { + console.log('usage: node script/redact_stream.mjs < INPUT > OUTPUT (reads MT_ACCOUNT/MT_PASSWORD from env)'); + } else { + pump(); + } +} diff --git a/script/rendering_batch_test.sh b/script/rendering_batch_test.sh index 9bc11a66..2bc39bdf 100644 --- a/script/rendering_batch_test.sh +++ b/script/rendering_batch_test.sh @@ -11,15 +11,15 @@ run_test() { if [ "$mode" = headless ]; then options+=(--headless); fi "${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1 local status=$? - if [ "$status" -ne 0 ] || rg -q 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! rg -q 'PASS|failures=0' "$log"; then + if [ "$status" -ne 0 ] || grep -qE 'SCRIPT ERROR|Parse Error|^FAIL:|check\(s\) failed' "$log" || ! grep -qE 'PASS|failures=0' "$log"; then echo "FAIL $mode $test_script (exit=$status): $log" failed=$((failed + 1)) else echo "PASS $mode $test_script" fi - if rg -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi + if grep -q 'leaked at exit' "$log"; then echo "WARN resource leak: $log"; fi } -for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd; do +for test_script in animation_cache_test.gd target_effect_test.gd effect_space_test.gd effect_faces_test.gd motion_effect_anchor_test.gd mob_winding_test.gd water_reference_test.gd effect_texture_animation_test.gd dds_lifecycle_test.gd effect_color_operation_test.gd effect_color_test.gd effect_scale_test.gd effect_rotation_test.gd effect_surface_test.gd effect_emission_test.gd effect_render_regression_test.gd effect_playback_test.gd fx_test.gd skill_fx_test.gd player_skill_test.gd skill_test.gd damage_effect_test.gd combat_fx_test.gd app_flow_lifecycle_test.gd playable_harness_test.gd playable_adapter_test.gd playable_combat_test.gd gamescene_test.gd equip_model_test.gd equip_rules_test.gd race_motion_assembly_test.gd character_winding_test.gd mob_view_test.gd net_world_vis_test.gd forest_viewpoints_test.gd playable_metrics_test.gd; do run_test headless "$test_script" done for test_script in effect_faces_test.gd effect_lie_test.gd effect_texture_animation_test.gd effect_color_operation_test.gd effect_surface_test.gd effect_emission_test.gd effect_rotation_test.gd model_render_test.gd gpu_lod_attachment_test.gd gpu_pose_bounds_test.gd forest_mob_render_test.gd; do diff --git a/script/run_client_gate.sh b/script/run_client_gate.sh index 742a331a..2f486042 100755 --- a/script/run_client_gate.sh +++ b/script/run_client_gate.sh @@ -1,125 +1,308 @@ #!/usr/bin/env bash -# Run one exported client process and seal its report after exit. -# This script owns exactly one child PID; it never pkills other clients. +# INF-02: run ONE exported client process and seal its report after it exits. +# +# - Owns exactly one child PID (plus its own log redactor); never pkills other +# clients, never touches routes/firewall/system caches. +# - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never +# from arguments or files. Do not add `set -x` to this script. +# - The client writes client-report.json; this script writes the final +# report.json only after the real process exit and the full-log gate. +# - suite soak (STB-01): also samples RSS of the child PID once per second into +# rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when +# soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for +# this client's connection only (for every suite, since server.* then points at the +# proxy). Sampler and proxy are this script's own children. +# +# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout. set -euo pipefail +set +x cd "$(dirname "$0")/.." -app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}" +repo="$PWD" +app_path="${MT_PLAYABLE_APP:-}" mode="playable" +suite="playable" config_path="" -output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}" -timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}" +assets_root="" +serverlist="${MT_PLAYABLE_SERVERLIST:-}" +output_dir="" +timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}" allow_gameplay=0 +required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}" +godot_bin="${MT_GODOT:-godot}" +kill_grace_seconds=10 usage() { cat <<'EOF' 用法: script/run_client_gate.sh --app APP --config CONFIG [选项] 选项: - --output DIR 本次运行目录,必须不存在或为空 - --mode MODE 默认 playable - --timeout-seconds N 墙钟超时,默认 180 - --allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求 + --output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-) + --suite playable|full|soak 必测用例集合,默认 playable;soak 需 --timeout-seconds >= soak.duration_seconds+900 + --mode MODE 包内 MT_TEST_MODE,默认 playable + --assets DIR 资源预检根目录(默认包内 Contents/Resources/assets) + --serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件 + --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL + --allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求 +环境变量: + MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2) + MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64 +退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时 EOF } while [ "$#" -gt 0 ]; do case "$1" in - --app) app_path="$2"; shift 2 ;; - --config) config_path="$2"; shift 2 ;; - --output) output_dir="$2"; shift 2 ;; - --mode) mode="$2"; shift 2 ;; - --timeout-seconds) timeout_seconds="$2"; shift 2 ;; + --app) app_path="${2:-}"; shift 2 ;; + --config) config_path="${2:-}"; shift 2 ;; + --output) output_dir="${2:-}"; shift 2 ;; + --suite) suite="${2:-}"; shift 2 ;; + --mode) mode="${2:-}"; shift 2 ;; + --assets) assets_root="${2:-}"; shift 2 ;; + --serverlist) serverlist="${2:-}"; shift 2 ;; + --timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;; --allow-gameplay) allow_gameplay=1; shift ;; --help) usage; exit 0 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; esac done -if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then - echo "配置不存在;不会启动客户端:$config_path" >&2 +output_ready=0 +blocked() { + echo "PLAYABLE GATE: BLOCKED $*" >&2 + if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi exit 2 +} + +[ -n "$app_path" ] || blocked "需要 --app" +[ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端" +[ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak" +[[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数" +config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")" +engine="$app_path/Contents/MacOS/mtgodot-poc" +[ -x "$engine" ] || blocked "找不到可执行包:$app_path" +if [ -n "$serverlist" ]; then + [ -f "$serverlist" ] || blocked "serverlist 不存在" + serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")" fi -if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then - echo "找不到可执行包:$app_path" >&2 - exit 2 + +if [ -z "$output_dir" ]; then + output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$" fi -if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then - echo "timeout-seconds 必须是正整数" >&2 - exit 2 -fi -if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then - echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2 - exit 2 +if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + blocked "输出目录已有内容,拒绝复用历史报告:$output_dir" fi mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" +output_ready=1 + +# 2. package identity: signature and architecture before anything runs. +if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then + blocked "签名校验失败:codesign --verify --strict $app_path" +fi +archs="$(lipo -archs "$engine" 2>/dev/null || true)" +case " $archs " in + *" $required_arch "*) ;; + *) blocked "包架构 [$archs] 不含 $required_arch" ;; +esac +run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')" log="$output_dir/client.log" client_report="$output_dir/client-report.json" final_report="$output_dir/report.json" events="$output_dir/events.jsonl" -if ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then - echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2 - exit 2 +if [ -z "$assets_root" ]; then + if [ -d "$app_path/Contents/Resources/assets" ]; then + assets_root="$app_path/Contents/Resources/assets" + else + assets_root="$repo/assets" + fi fi -char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")" -sha256_of() { - if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi +# 3. config + address + resource preconditions; resolves the address the client will use. +if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \ + MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \ + MT_PLAYABLE_GATE_OUTPUT="$output_dir" \ + "$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then + blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log" +fi + +# The config gate validated any soak block (for every suite); read only the fields the runner needs. +read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e ' +const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); +const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {}; +process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" ")); +' "$config_path")" +if [ "$suite" = "soak" ]; then + [ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds})" +fi + +# Credentials: env or hidden prompt only. +if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then + if [ -t 0 ]; then + if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi + if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi + fi +fi +[ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" +export MT_ACCOUNT MT_PASSWORD + +read_address() { + node -e ' +const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); +process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" ")); +' "$output_dir/server-address.json" } -engine="$app_path/Contents/MacOS/mtgodot-poc" -pck="$app_path/Contents/Resources/mtgodot-poc.pck" -dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)" +read -r auth_host auth_port game_host game_port <<<"$(read_address)" + +proxy_pid="" +stop_proxy() { + if [ -n "$proxy_pid" ]; then + kill -TERM "$proxy_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + proxy_pid="" + fi +} +trap stop_proxy EXIT +if [ "$fault_status" = "confirmed" ]; then + # Loopback proxy in front of the test server for this client only. server.* points at it, so + # playable exit runs sharing the scenario go through it too; only the soak client requests faults. + for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do + if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then + blocked "故障代理上游不可达:$endpoint" + fi + done + routes=(--route "$auth_port=$up_auth_host:$up_auth_port") + if [ "$game_port" != "$auth_port" ]; then + routes+=(--route "$game_port=$up_game_host:$up_game_port") + elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then + blocked "auth/game 共用代理端口,但上游地址不同" + fi + node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \ + --run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 /dev/null; do + if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then + blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log" + fi + sleep 0.1 + proxy_wait=$((proxy_wait + 1)) + done + echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log" +fi +for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do + if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then + blocked "服务器端口不可达:$endpoint" + fi +done +char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")" + +# 1. identity of this run. +bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json" +build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; } + export MT_TEST_MODE="$mode" +export MT_PLAYABLE_SUITE="$suite" export MT_PLAYABLE_CONFIG="$config_path" +export MT_PLAYABLE_RUN_ID="$run_id" +export MT_PLAYABLE_SERVERLIST="$serverlist" export MT_TEST_REPORT="$client_report" export MT_TEST_EVENTS="$events" export MT_TEST_OUTPUT="$output_dir" export MT_PROTOCOL="classic" export MT_AUTOLOGIN=1 export MT_CHAR_SLOT="$char_slot" -export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")" -export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")" -export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")" -export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")" -file_description="$(file "$engine")" -case "$file_description" in - *arm64*) export MT_BUILD_ARCH="arm64" ;; - *x86_64*) export MT_BUILD_ARCH="x86_64" ;; - *) export MT_BUILD_ARCH="unknown" ;; -esac -if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi +export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" +export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" +export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" +export MT_BUILD_ARCH="$(build_field arch)" +export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay" -start_epoch="$(date +%s)" +# 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is +# the engine itself (redirections are applied before exec), so `wait` returns +# its real status regardless of the log pipe. +fifo="$output_dir/.client.fifo" +mkfifo "$fifo" +started_ms="$(($(date +%s) * 1000))" set +e -env -u MT_ASSETS "$engine" >"$log" 2>&1 & +node script/redact_stream.mjs <"$fifo" >"$log" & +redactor_pid=$! +env -u MT_ASSETS "$engine" >"$fifo" 2>&1 >"$output_dir/gate.log" 2>&1 & + sampler_pid=$! +fi +SECONDS=0 timed_out=0 while kill -0 "$child_pid" 2>/dev/null; do - now="$(date +%s)" - if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then + if [ "$SECONDS" -ge "$timeout_seconds" ]; then timed_out=1 - kill -TERM "$child_pid" 2>/dev/null || true - for _ in 1 2 3 4 5 6 7 8 9 10; do - kill -0 "$child_pid" 2>/dev/null || break + kill -TERM "$child_pid" 2>/dev/null + grace=0 + while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do sleep 1 + grace=$((grace + 1)) done - kill -KILL "$child_pid" 2>/dev/null || true + killed=0 + if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi + echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log" break fi sleep 1 done wait "$child_pid" -app_status=$? -set -e -if [ "$timed_out" -eq 1 ]; then app_status=124; fi - -set +e -node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ - --log "$log" --process-code "$app_status" --require-pass -gate_status=$? -set -e -if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then - echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2 - exit 1 +process_code=$? +# The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open. +redactor_wait=0 +while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do + sleep 1 + redactor_wait=$((redactor_wait + 1)) +done +if kill -0 "$redactor_pid" 2>/dev/null; then + kill -TERM "$redactor_pid" 2>/dev/null + echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log" fi -echo "PLAYABLE GATE: PASS report=$final_report log=$log" +wait "$redactor_pid" +redactor_code=$? +rm -f "$fifo" +if [ -n "$sampler_pid" ]; then + # The sampler stops by itself once the child is gone; bound the wait anyway. + sampler_wait=0 + while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do + sleep 1 + sampler_wait=$((sampler_wait + 1)) + done + if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi + wait "$sampler_pid" +fi +stop_proxy + +node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ + --log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \ + --suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \ + --events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass +gate_status=$? +if [ "$suite" = "soak" ]; then + node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \ + --warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds" + memory_status=$? + if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1 + elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1 + elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2 + fi +fi +set -e + +if [ "$timed_out" -eq 1 ]; then + echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2 + exit 124 +fi +case "$gate_status" in + 0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;; + 2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;; + *) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;; +esac +exit "$gate_status" diff --git a/script/validate_playable_report.mjs b/script/validate_playable_report.mjs index af40acb9..d1d1ab05 100755 --- a/script/validate_playable_report.mjs +++ b/script/validate_playable_report.mjs @@ -1,15 +1,37 @@ #!/usr/bin/env node -/* Validate the machine-readable playable report and, when requested, seal it - * with the parent process' exit/log gate. The client cannot mark a run PASS by - * itself: the final report is written only after the child has exited. */ +/* INF-02 / REL-01 report gate. + * + * Run mode (called by run_client_gate.sh after the child exited): + * --report client-report.json --output report.json --log client.log + * --process-code N --timed-out 0|1 --run-id ID --suite S + * --required-cases required-cases.json --build build.json --events events.jsonl + * --started-ms MS [--redactor-code N] [--require-pass] + * Seals the final report. The client can never mark a run PASS by itself. + * + * Release mode: + * --release-dir DIR reads DIR/release-manifest.json, checks every explicitly + * listed sealed run (same candidate hashes, PASS, counts), writes + * DIR/release-report.json. History is never scanned. + * + * exit: 0 PASS, 1 FAIL, 2 BLOCKED (only BLOCKED/SKIP findings, clean process+log). + * Credential literals are read from MT_ACCOUNT/MT_PASSWORD and never printed. */ import fs from 'node:fs'; import path from 'node:path'; const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']); -const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/m; +const SUITES = new Set(['playable', 'full', 'soak', 'forest_render']); +// Same rule as script/package_render_test.sh, plus parse errors. No RID allow-list. +const ERROR_RE = /SCRIPT ERROR|Parse Error|^ERROR:|leaked at exit|shaders of type .* were never freed/; +const EVENT_KEYS = ['monotonic_us', 'run_id', 'case_id', 'connection_epoch', 'stage', 'kind', 'actor_vid', 'target_vid', 'payload']; +const HASH_KEYS = ['engine_sha256', 'extension_sha256', 'pck_sha256']; +const HEX64 = /^[0-9a-f]{64}$/; +const SECRET_MIN_SCAN = 4; +const MTIME_SLACK_MS = 1000; function usage() { - console.error('usage: validate_playable_report.mjs --report PATH [--output PATH] [--log PATH] [--process-code N] [--run-id ID] [--require-pass]'); + console.error('usage: validate_playable_report.mjs --report PATH --output PATH --log PATH --process-code N --run-id ID ' + + '--suite S --required-cases PATH --build PATH --events PATH --started-ms MS [--timed-out 0|1] [--require-pass]\n' + + ' validate_playable_report.mjs --release-dir DIR'); } function argsOf(argv) { @@ -17,97 +39,281 @@ function argsOf(argv) { for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--require-pass') out.require_pass = true; + else if (arg === '--help') out.help = true; else if (arg.startsWith('--')) { const key = arg.slice(2).replaceAll('-', '_'); if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`); out[key] = argv[++i]; } else throw new Error(`unknown argument: ${arg}`); } - if (!out.report) throw new Error('--report is required'); + if (out.help) return out; + if (!out.release_dir) { + for (const key of ['report', 'output', 'log', 'process_code', 'run_id', 'suite', 'required_cases', 'build', 'events', 'started_ms']) { + if (out[key] === undefined) throw new Error(`--${key.replaceAll('_', '-')} is required`); + } + } return out; } function readJson(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } - catch (error) { return { __read_error: `${file}: ${error.message}` }; } + catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; } } -function validate(report, options) { - const errors = []; - const readError = report?.__read_error; - if (readError) errors.push(readError); +function secretLiterals() { + return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN); +} + +function scanSecrets(label, text, errors) { + for (const literal of secretLiterals()) { + if (text.includes(literal)) errors.fail.push(`${label} contains a credential literal`); + } +} + +class Findings { + constructor() { this.fail = []; this.blocked = []; } + get all() { return [...new Set([...this.fail, ...this.blocked])]; } +} + +function checkBuild(build, expected, prefix, errors) { + for (const key of HASH_KEYS) { + if (typeof build?.[key] !== 'string' || !HEX64.test(build[key])) errors.fail.push(`${prefix}build.${key} is missing or not sha256`); + else if (expected && build[key] !== expected[key]) errors.fail.push(`${prefix}build.${key} differs from the candidate`); + } + if (typeof build?.arch !== 'string' || build.arch.trim() === '') errors.fail.push(`${prefix}build.arch is missing`); + else if (expected && build.arch !== expected.arch) errors.fail.push(`${prefix}build.arch differs from the candidate`); +} + +function validateReport(report, options, errors) { + if (report.__read_error) { + errors.fail.push(`client report unreadable: ${report.__read_error}`); + return; + } if (!report || typeof report !== 'object' || Array.isArray(report)) { - errors.push('report root must be an object'); - return errors; + errors.fail.push('report root must be an object'); + return; } - if (report.schema_version !== 1) errors.push('schema_version must be 1'); - if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing'); - if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run'); - if (report.suite !== 'playable') errors.push('suite must be playable'); - if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`); - for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) { - if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`); - } - if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty'); + if (report.schema_version !== 1) errors.fail.push('schema_version must be 1'); + if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing'); + else if (report.run_id !== options.run_id) errors.fail.push('run_id does not match this run (stale or foreign report)'); + if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`); + if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`); + const expectedBuild = readJson(options.build); + if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`); + checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors); + + const required = readJson(options.required_cases); + const requiredIds = Array.isArray(required?.cases) ? required.cases : null; + if (!requiredIds || requiredIds.length === 0) errors.fail.push('required-cases.json is missing or empty'); + else if (required.suite !== options.suite) errors.fail.push('required-cases.json suite mismatch'); const cases = Array.isArray(report.cases) ? report.cases : []; + if (cases.length === 0) errors.fail.push('cases must be non-empty'); + const seen = new Map(); let passed = 0; for (const item of cases) { if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') { - errors.push('case id is missing'); + errors.fail.push('case id is missing'); continue; } - if (!VALID.has(item.status)) errors.push(`${item.id}: invalid status`); + if (seen.has(item.id)) errors.fail.push(`${item.id}: duplicated case`); + seen.set(item.id, item); + if (!VALID.has(item.status)) errors.fail.push(`${item.id}: invalid status`); if (item.status === 'PASS') passed += 1; - if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`); - if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`); + if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim()); + if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.blocked.push(`${item.id}: ${item.status} is not releasable (${item.reason || 'no reason'})`); + if (!Array.isArray(item.evidence)) errors.fail.push(`${item.id}: evidence must be an array`); for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) { - if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`); - else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${evidence}`); + const resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : ''; + if (!resolved || !resolved.startsWith(path.resolve(path.dirname(options.report)) + path.sep)) errors.fail.push(`${item.id}: evidence path escapes the run directory`); + else if (!fs.existsSync(resolved)) errors.fail.push(`${item.id}: missing evidence ${evidence}`); } } + if (requiredIds) { + for (const id of requiredIds) if (!seen.has(id)) errors.fail.push(`${id}: required case missing from report`); + for (const id of seen.keys()) if (!requiredIds.includes(id)) errors.fail.push(`${id}: case is not in the required list`); + if (JSON.stringify(report.required_cases) !== JSON.stringify(requiredIds)) errors.fail.push('report required_cases differs from the runner list'); + } const coverage = report.coverage; - if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases'); - if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays'); - if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report'); - if (options.process_code !== undefined && Number(report.exit_gate?.process_code) !== Number(options.process_code)) errors.push('exit_gate.process_code does not match child process'); - if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0'); - if (options.log) { - if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`); - else { - const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/); - lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); }); + if (!coverage || coverage.required !== cases.length || coverage.passed !== passed + || (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases'); + if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays'); + else if (report.failures.length > 0) errors.fail.push('client report contains failures'); + if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases'); + + // Staleness: the client report must have been written during this run. + try { + const mtime = fs.statSync(options.report).mtimeMs; + if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)'); + } catch { /* unreadable already reported */ } + + // Event contract. + let lines = []; + if (fs.existsSync(options.events)) { + const text = fs.readFileSync(options.events, 'utf8'); + scanSecrets('events', text, errors); + lines = text.split('\n').filter((line) => line.trim() !== ''); + } + if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events'); + if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`); + let badEvents = 0; + let lastUs = -Infinity; + for (const line of lines) { + let event; + try { event = JSON.parse(line); } catch { badEvents += 1; continue; } + if (!EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id + || typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload) + || !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1; + else lastUs = event.monotonic_us; + } + if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`); +} + +function validateProcess(options, errors) { + const code = Number(options.process_code); + if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`); + if (code !== 0) errors.fail.push(`child process exit code ${code}`); + if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly'); + if (!fs.existsSync(options.log)) { + errors.fail.push('client log does not exist'); + return; + } + const text = fs.readFileSync(options.log, 'utf8'); + scanSecrets('client log', text, errors); + const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line)); + // Report the line numbers and the matched rule, not arbitrary log text. + for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`); + if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`); +} + +function sealRun(options) { + const errors = new Findings(); + const input = readJson(options.report); + validateReport(input, options, errors); + validateProcess(options, errors); + const report = input.__read_error ? { + schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL', + build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [], + coverage: { required: 0, passed: 0 }, + } : input; + let status = 'PASS'; + if (errors.fail.length > 0) status = 'FAIL'; + else if (errors.blocked.length > 0) status = 'BLOCKED'; + if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); } + report.status = status; + report.exit_gate = { + checked: true, + process_code: Number(options.process_code), + timed_out: options.timed_out === '1', + errors: errors.all, + }; + const text = `${JSON.stringify(report, null, 2)}\n`; + const leak = new Findings(); + scanSecrets('final report', text, leak); + const output = options.output; + fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true }); + if (leak.fail.length > 0) { + // Never persist a report that would carry the secret; keep only the verdict. + fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL', + exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`); + console.error('PLAYABLE REPORT: FAIL (credential literal in report)'); + return 1; + } + fs.writeFileSync(output, text); + if (status !== 'PASS') { + console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`); + errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`)); + return status === 'BLOCKED' ? 2 : 1; + } + console.log(`PLAYABLE REPORT: PASS ${output}`); + return 0; +} + +function sealRelease(dir) { + const errors = new Findings(); + const root = path.resolve(dir); + const manifest = readJson(path.join(root, 'release-manifest.json')); + const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] }; + if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`); + else { + if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1'); + checkBuild(manifest.candidate, null, 'manifest.', errors); + summary.candidate = manifest.candidate; + const runs = Array.isArray(manifest.runs) ? manifest.runs : []; + if (runs.length === 0) errors.fail.push('manifest lists no runs'); + const ids = new Set(); + const passedBySuite = {}; + const blockedBySuite = {}; + for (const run of runs) { + const label = `run ${run?.run_id ?? '?'}`; + if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) { + errors.fail.push(`${label}: run_id/path/suite missing`); + continue; + } + if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`); + ids.add(run.run_id); + const reportPath = path.resolve(root, run.path, 'report.json'); + if (!reportPath.startsWith(root + path.sep)) { + errors.fail.push(`${label}: path escapes the release directory`); + continue; + } + const report = readJson(reportPath); + const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' }; + summary.runs.push(entry); + if (report.__read_error) { + // run_client_gate.sh records precondition stops in gate.log before any client starts. + const gateLog = path.resolve(root, run.path, 'gate.log'); + const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8')); + if (preconditionBlocked) { + entry.status = 'BLOCKED'; + blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1; + errors.blocked.push(`${label}: precondition BLOCKED before launch`); + } else errors.fail.push(`${label}: sealed report missing`); + continue; + } + if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`); + if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`); + if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`); + checkBuild(report.build, manifest.candidate, `${label}: `, errors); + if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1; + else if (report.status === 'BLOCKED') { + blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1; + errors.blocked.push(`${label}: BLOCKED`); + } + else errors.fail.push(`${label}: ${report.status}`); + } + const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {}; + if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty'); + for (const [suite, count] of Object.entries(requiredRuns)) { + const got = passedBySuite[suite] || 0; + summary.counts[suite] = { required: count, passed: got }; + if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`); + else if (got < count) { + // Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL. + const message = `${suite}: ${got}/${count} passing runs`; + if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message); + else errors.fail.push(message); + } } } - if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`); - if (report.status === 'FAIL') errors.push('client report already contains failures'); - return [...new Set(errors)]; + summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS'); + summary.errors = errors.all; + fs.writeFileSync(path.join(root, 'release-report.json'), `${JSON.stringify(summary, null, 2)}\n`); + if (summary.status !== 'PASS') { + console.error(`PLAYABLE RELEASE: ${summary.status}`); + summary.errors.forEach((error) => console.error(`- ${error}`)); + return summary.status === 'BLOCKED' ? 2 : 1; + } + console.log(`PLAYABLE RELEASE: PASS ${path.join(root, 'release-report.json')}`); + return 0; } function main() { let options; try { options = argsOf(process.argv.slice(2)); } catch (error) { usage(); console.error(error.message); return 2; } - const input = readJson(options.report); - const report = input.__read_error ? { - schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL', - build: { engine_sha256: '', extension_sha256: '', pck_sha256: '', arch: '' }, cases: [], - failures: [input.__read_error], blocked: [], coverage: { required: 0, passed: 0 }, - } : input; - const exitCode = options.process_code === undefined ? null : Number(options.process_code); - report.exit_gate = { checked: true, process_code: exitCode, errors: [] }; - const errors = validate(report, options); - report.exit_gate.errors = errors; - if (errors.length > 0) report.status = 'FAIL'; - const output = options.output || options.report; - fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true }); - fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`); - if (errors.length) { - console.error(`PLAYABLE REPORT: FAIL (${errors.length} errors)`); - errors.forEach((error) => console.error(`- ${error}`)); - return 1; - } - console.log(`PLAYABLE REPORT: PASS ${output}`); - return 0; + if (options.help) { usage(); return 0; } + if (options.release_dir) return sealRelease(options.release_dir); + return sealRun(options); } process.exitCode = main(); diff --git a/test/playable/build.fixture.json b/test/playable/build.fixture.json new file mode 100644 index 00000000..2f3c07b1 --- /dev/null +++ b/test/playable/build.fixture.json @@ -0,0 +1,6 @@ +{ + "engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "arch": "arm64" +} diff --git a/test/playable/events.valid.jsonl b/test/playable/events.valid.jsonl new file mode 100644 index 00000000..c6f3fe2a --- /dev/null +++ b/test/playable/events.valid.jsonl @@ -0,0 +1 @@ +{"monotonic_us":1000,"run_id":"fixture-run","case_id":"FIXTURE-01","connection_epoch":1,"stage":"LOGIN","kind":"phase_changed","actor_vid":0,"target_vid":0,"payload":{"source":"server"}} diff --git a/test/playable/fake_client.sh b/test/playable/fake_client.sh new file mode 100755 index 00000000..32c40eed --- /dev/null +++ b/test/playable/fake_client.sh @@ -0,0 +1,54 @@ +#!/bin/sh +# Stand-in for the packaged client used by script/playable_gate_test.sh. +# The fake app's Mach-O execs this file, so it runs as the runner's own child +# PID. It never contacts a server; MT_FAKE_SCENARIO picks the behaviour. +scenario="${MT_FAKE_SCENARIO:-pass}" + +write_report() { + # $1 = PASS|BLOCKED, $2 = drop-last|evidence-missing|wrong-run|"" + node -e ' +const fs = require("node:fs"); +const path = require("node:path"); +const [status, variant] = process.argv.slice(1); +const out = process.env.MT_TEST_OUTPUT; +const required = JSON.parse(fs.readFileSync(path.join(out, "required-cases.json"), "utf8")).cases; +const runId = variant === "wrong-run" ? "previous-run-0000" : process.env.MT_PLAYABLE_RUN_ID; +const ids = variant === "drop-last" ? required.slice(0, -1) : required; +const cases = ids.map((id) => ({ id, status, duration_ms: 1, reason: status === "PASS" ? "fake" : "fixture_not_ready", + evidence: variant === "evidence-missing" && id === "NET-LOGIN-01" ? ["net-login-01.png"] : [] })); +const events = ids.map((id, i) => JSON.stringify({ monotonic_us: 1000 + i, run_id: runId, case_id: id, connection_epoch: 1, + stage: "LOGIN", kind: "phase_changed", actor_vid: 0, target_vid: 0, payload: { source: "server" } })); +fs.writeFileSync(process.env.MT_TEST_EVENTS, events.join("\n") + "\n"); +const report = { + schema_version: 1, run_id: runId, suite: process.env.MT_PLAYABLE_SUITE || process.env.MT_TEST_MODE, status, + build: { engine_sha256: process.env.MT_BUILD_ENGINE_SHA256, extension_sha256: process.env.MT_BUILD_EXTENSION_SHA256, + pck_sha256: process.env.MT_BUILD_PCK_SHA256, arch: process.env.MT_BUILD_ARCH }, + environment: { os: "macOS", renderer: "fake", resolution: [1280, 720] }, + cases, required_cases: ids, events: "events.jsonl", event_count: events.length, failures: [], + blocked: status === "BLOCKED" ? ids.map((id) => `${id}: fixture_not_ready`) : [], + coverage: { required: cases.length, passed: status === "PASS" ? cases.length : 0 }, + exit_gate: { checked: false, process_code: null, errors: [] }, +}; +fs.writeFileSync(process.env.MT_TEST_REPORT, JSON.stringify(report, null, 2)); +' "$1" "${2:-}" +} + +case "$scenario" in + pass) write_report PASS; echo "PLAYABLE CLIENT: PASS" ;; + soak-pass) write_report PASS; if [ "${MT_PLAYABLE_SUITE:-}" = "soak" ]; then sleep 3; fi; echo "PLAYABLE CLIENT: PASS" ;; + exit3) write_report PASS; echo "PLAYABLE CLIENT: PASS"; exit 3 ;; + signal) write_report PASS; kill -KILL $$ ;; + hang) trap '' TERM; echo "ignoring TERM"; while :; do sleep 1; done ;; + stale) write_report PASS wrong-run ;; + no-report) echo "exited before writing a report" ;; + rid) write_report PASS; echo "WARNING: 3 shaders of type CanvasItem were never freed." ;; + leak) write_report PASS; echo "WARNING: ObjectDB instances leaked at exit (run with --verbose for details)." ;; + missing-case) write_report PASS drop-last ;; + missing-evidence) write_report PASS evidence-missing ;; + blocked) write_report BLOCKED ;; + print-secret) write_report PASS; echo "login request for $MT_ACCOUNT with $MT_PASSWORD" ;; + fifo-holder) write_report PASS; sleep 13 & exit 0 ;; + no-creds) if [ -n "${MT_ACCOUNT:-}${MT_PASSWORD:-}" ]; then echo "SCRIPT ERROR: credentials reached an offline child"; fi; write_report PASS ;; + *) echo "unknown fake scenario" >&2; exit 64 ;; +esac +exit 0 diff --git a/test/playable/forest-viewpoints.example.json b/test/playable/forest-viewpoints.example.json new file mode 100644 index 00000000..4f0a3f3b --- /dev/null +++ b/test/playable/forest-viewpoints.example.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "maps": { + "": { + "races": [], + "viewpoints": [ + { + "id": "flat-01", + "kind": "flat", + "status": "unconfirmed", + "server_cm": [0, 0], + "camera_yaw_deg": 45 + } + ] + } + }, + "notes": "Copy to test/playable/forest-viewpoints..local.json (ignored). kind is flat|slope|dense|warp; server_cm is the confirmed in-game global position; status becomes confirmed only with confirmed_by set to the confirming role (not a person's name). races lists server-confirmed mob vnums for the map; empty means the 12 candidate tree monsters are placed as candidates only. Never add credentials here." +} diff --git a/test/playable/report.blocked.json b/test/playable/report.blocked.json index 087eaf69..4af14d32 100644 --- a/test/playable/report.blocked.json +++ b/test/playable/report.blocked.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "run_id": "fixture-blocked", + "run_id": "fixture-run", "suite": "playable", "status": "BLOCKED", "build": { @@ -9,11 +9,39 @@ "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "arch": "arm64" }, - "environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, - "cases": [{"id": "FIXTURE-01", "status": "BLOCKED", "duration_ms": 0, "reason": "fixture_not_ready", "evidence": []}], + "environment": { + "os": "macOS", + "renderer": "Metal", + "resolution": [ + 1280, + 720 + ] + }, + "cases": [ + { + "id": "FIXTURE-01", + "status": "BLOCKED", + "duration_ms": 0, + "reason": "fixture_not_ready", + "evidence": [] + } + ], + "required_cases": [ + "FIXTURE-01" + ], "events": "events.jsonl", + "event_count": 1, "failures": [], - "blocked": ["fixture_not_ready"], - "coverage": {"required": 1, "passed": 0}, - "exit_gate": {"checked": false, "process_code": null, "errors": []} + "blocked": [ + "FIXTURE-01: fixture_not_ready" + ], + "coverage": { + "required": 1, + "passed": 0 + }, + "exit_gate": { + "checked": false, + "process_code": null, + "errors": [] + } } diff --git a/test/playable/report.valid.json b/test/playable/report.valid.json index 727dcb1c..43763abd 100644 --- a/test/playable/report.valid.json +++ b/test/playable/report.valid.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "run_id": "fixture-valid", + "run_id": "fixture-run", "suite": "playable", "status": "PASS", "build": { @@ -9,11 +9,37 @@ "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "arch": "arm64" }, - "environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, - "cases": [{"id": "FIXTURE-01", "status": "PASS", "duration_ms": 1, "reason": "ok", "evidence": []}], + "environment": { + "os": "macOS", + "renderer": "Metal", + "resolution": [ + 1280, + 720 + ] + }, + "cases": [ + { + "id": "FIXTURE-01", + "status": "PASS", + "duration_ms": 1, + "reason": "ok", + "evidence": [] + } + ], + "required_cases": [ + "FIXTURE-01" + ], "events": "events.jsonl", + "event_count": 1, "failures": [], "blocked": [], - "coverage": {"required": 1, "passed": 1}, - "exit_gate": {"checked": false, "process_code": null, "errors": []} + "coverage": { + "required": 1, + "passed": 1 + }, + "exit_gate": { + "checked": false, + "process_code": null, + "errors": [] + } } diff --git a/test/playable/required-cases.fixture.json b/test/playable/required-cases.fixture.json new file mode 100644 index 00000000..31b5fdaa --- /dev/null +++ b/test/playable/required-cases.fixture.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "suite": "playable", + "cases": [ + "FIXTURE-01" + ] +} diff --git a/test/playable/scenario.example.json b/test/playable/scenario.example.json index 0afb401c..3e7076fe 100644 --- a/test/playable/scenario.example.json +++ b/test/playable/scenario.example.json @@ -4,7 +4,11 @@ "protocol": "classic", "server": { "server_index": 0, - "channel_index": 0 + "channel": 1, + "auth_host": "", + "auth_port": 0, + "game_host": "", + "game_port": 0 }, "character_slot": 0, "map_key": "", @@ -14,6 +18,26 @@ "skill_cases": [], "resolution": [1280, 720], "loops": 3, - "timeout_seconds": 120, - "notes": "Copy to a local ignored file and fill from the confirmed server fixture. Never add credentials here." + "timeout_seconds": 900, + "soak": { + "duration_seconds": 7200, + "rest_seconds": 30, + "warmup_rounds": 1, + "min_rounds": 10, + "reconnects": 10, + "exits": 10, + "resolutions": { + "sizes": [[1280, 720], [1440, 900], [1600, 1000]], + "switches_per_size": 10 + }, + "warp": { + "status": "unconfirmed", + "notes": "Set status=confirmed only after the environment owner verifies a legal portal: required (even, >=20 out-and-back legs), portal_cm, destination_map_key, return_portal_cm, cross_server." + }, + "faults": { + "status": "unconfirmed", + "notes": "Set status=confirmed only with a confirmed 127.0.0.1 test proxy (mode local_proxy, per_type, unreachable_seconds 1..20, upstream {auth_host,auth_port,game_host,game_port} = the real test server). server.* and the serverlist entry must then be 127.0.0.1 proxy ports; script/run_client_gate.sh starts script/playable_fault_proxy.mjs for this client only. A server warp that hands out a direct game address bypasses the proxy. Never change global routes or firewall; active reconnect is not a substitute." + } + }, + "notes": "Copy to test/playable/.local.json (ignored) and fill from the confirmed server fixture: server.* must equal the serverinfo entry for server_index/channel; map_key is the asset directory holding Setting.txt; waypoints are server cm. Never add credentials here. The soak suite (STB-01) additionally needs timeout_seconds >= soak.duration_seconds + 900." } diff --git a/test/playable/skill-cases.json b/test/playable/skill-cases.json new file mode 100644 index 00000000..781fe58a --- /dev/null +++ b/test/playable/skill-cases.json @@ -0,0 +1,68 @@ +{ + "schema_version": 1, + "notes": [ + "每职业从测试角色真实已学技能中选择:single_target 单体、area 范围、self_buff 自身增益、flying 飞行效果。", + "status=unconfirmed:尚未由测试环境负责人确认,对应 CBT 用例为 BLOCKED,不得发布。", + "status=confirmed:填 skill_id(该职业已学、非被动)、target(enemy|self|ground)、repeats(>=10)、required_evidence(至少一个服务端证据)。", + "status=not_applicable:该职业确无此类技能,必须填 checklist_ref 指向发布清单中的确认记录。", + "脚本和测试从不补技能 ID;只改本地副本 test/playable/skill-cases.local.json 时同样适用。检查:godot --headless --path project --script playable_skill_matrix.gd -- --job WARRIOR" + ], + "jobs": { + "WARRIOR": { + "single_target": { + "status": "unconfirmed" + }, + "area": { + "status": "unconfirmed" + }, + "self_buff": { + "status": "unconfirmed" + }, + "flying": { + "status": "unconfirmed" + } + }, + "ASSASSIN": { + "single_target": { + "status": "unconfirmed" + }, + "area": { + "status": "unconfirmed" + }, + "self_buff": { + "status": "unconfirmed" + }, + "flying": { + "status": "unconfirmed" + } + }, + "SURA": { + "single_target": { + "status": "unconfirmed" + }, + "area": { + "status": "unconfirmed" + }, + "self_buff": { + "status": "unconfirmed" + }, + "flying": { + "status": "unconfirmed" + } + }, + "SHAMAN": { + "single_target": { + "status": "unconfirmed" + }, + "area": { + "status": "unconfirmed" + }, + "self_buff": { + "status": "unconfirmed" + }, + "flying": { + "status": "unconfirmed" + } + } + } +}