Compare commits
3
Commits
374d4165d8
...
96c29d2bb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96c29d2bb7 | ||
|
|
cc5573ddc1 | ||
|
|
f39a55fdd5 |
+10
@@ -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/
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 首个 Mac 联网内测版:实施状态与已知问题(REL-01)
|
||||
|
||||
日期:2026-09-11。对应计划:`docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md`。
|
||||
|
||||
后续审查:提交 `f39a55fd` 的观察器缓存保留、运行取消清理、外部内存门禁与异常 JSON 处理问题,见 [审查与修复记录](REVIEW-f39a55fd.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 截图人工签核人。
|
||||
@@ -0,0 +1,38 @@
|
||||
# f39a55fd 审查与修复
|
||||
|
||||
审查对象:`f39a55fdd5f14cb2d14d67929cf45f438f572a06`。修复基于该提交,位于本地分支 `fix/playable-f39a55fd`。
|
||||
|
||||
## 已确认的问题
|
||||
|
||||
| 优先级 | 问题与触发条件 | 影响 | 修复 |
|
||||
| --- | --- | --- | --- |
|
||||
| P1 | `PlayableProbe._connections` 用强引用保存 `EffectRegistry`;重连或切图重建注册器 | 旧注册器及其解析缓存一直保留,观察器自身污染长跑内存结果 | 弱引用订阅源;重新绑定和统计时移除失效订阅 |
|
||||
| P1 | 终止联网/地图运行器或批量入口时,没有完整的子进程清理 | 客户端可能继续联网执行操作,代理、日志处理器、采样器可能残留 | 共用进程清理模块;保留信号退出码,仅对仍持有的子 PID 执行 TERM、有界等待、KILL 和 wait;批量入口转发终止 |
|
||||
| P1 | 外部 RSS 检查尚未写回时,已有干净退出的中间 `report.json`;之后直接聚合 | 缺失 `STB-MEMORY-01` 的 soak 仍可被发布汇总判 PASS | 汇总 soak PASS 时必须同时存在唯一的内存 PASS 用例和 PASS verdict |
|
||||
| P2 | 客户端报告、事件行或发布清单包含合法 JSON `null` 等错误类型 | 校验器抛出异常,无法生成应有的失败报告 | 校验 JSON 根类型、事件类型和单调时间字段;错误输入写出 FAIL 报告 |
|
||||
|
||||
## 复现证据与回归入口
|
||||
|
||||
- 注册器释放用例:模拟 30 次注册器替换。原实现出现 59 项断言失败;修复后 `playable_harness_test: failures=0`。
|
||||
- 取消运行用例:原联网和地图运行器均在收到 TERM 后留下客户端;回归检查终止入口后客户端已被回收,入口退出码为 143,并覆盖普通批量与 soak 入口。
|
||||
- 发布汇总用例:`node script/playable_release_test.mjs --baseline` 读取原提交的校验器,复现缺少内存检查仍然 PASS(该命令预期断言失败)。正常命令使用修复后的校验器。
|
||||
- 异常 JSON 用例:检查返回失败码的同时,要求实际生成失败报告,避免只根据异常退出码误判测试通过。
|
||||
|
||||
可重复运行:
|
||||
|
||||
```bash
|
||||
godot --headless --path project --script playable_harness_test.gd
|
||||
bash script/playable_gate_test.sh
|
||||
node script/playable_release_test.mjs
|
||||
node script/playable_fault_proxy_test.mjs
|
||||
node script/playable_soak_metrics_test.mjs
|
||||
node script/audit_playable_maps_test.mjs
|
||||
bash script/rendering_batch_test.sh
|
||||
git diff --check
|
||||
```
|
||||
|
||||
本轮修复后结果:门禁夹具 126 项检查通过;渲染批处理 53 项通过、`failures=0`;注册器/联网状态机、发布汇总、故障代理、内存指标、地图资源审计测试通过;Shell/Node 语法检查及 `git diff --check` 通过。
|
||||
|
||||
渲染批处理日志:`build/rendering/batch-20260911-211958-82865/`。
|
||||
|
||||
这些是离线、回环代理和假签名客户端测试。实网战斗、真实两小时长跑、最终新包与人工画面对照仍需独立验收;本次审查不会将其改成已通过。
|
||||
+24
-5
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 分支)---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<map_key>[,<map_key>] 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 真模型可用性)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-3
@@ -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
|
||||
|
||||
+15
-7
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
+1230
-30
File diff suppressed because it is too large
Load Diff
+139
-331
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+32
-2
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
extends RefCounted
|
||||
|
||||
## MAP-02 地图内验收的机位夹具(test/playable/forest-viewpoints.<name>.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)))
|
||||
@@ -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])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -3,113 +3,245 @@ 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.get_ref()
|
||||
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:
|
||||
_prune_connections()
|
||||
return _connections.size()
|
||||
|
||||
func _prune_connections() -> void:
|
||||
for i in range(_connections.size() - 1, -1, -1):
|
||||
if _connections[i].source.get_ref() == null:
|
||||
_connections.remove_at(i)
|
||||
|
||||
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:
|
||||
# Registries are RefCounted and own parsed effect caches. Observing a scene
|
||||
# must not keep it alive after reconnect; discard dead bindings on rebind.
|
||||
_prune_connections()
|
||||
if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
|
||||
source.connect(signal_name, callback)
|
||||
_connections.append({"source": weakref(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)
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
+33
-13
@@ -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:<NAME>" 本客户端的同类动作别名(如 run -> WALK)
|
||||
## "motlist_missing:<file>" motlist 未列出,按目录里的 <name>.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]
|
||||
# 兜底:目录里有 <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):
|
||||
|
||||
+15
-1
@@ -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:
|
||||
|
||||
+410
-95
@@ -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 <tx*1000+ty padded to 6>/ 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 <assets>/textureset/<TextureSet lowercased>.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/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"
|
||||
source script/playable_process_cleanup.sh
|
||||
|
||||
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.<name>.local.json);不给则只有自动候选
|
||||
--races R1[,R2] 覆盖夹具里的怪物 vnum;不给且夹具为空时放 12 个候选树怪
|
||||
--output DIR 本次运行目录,必须不存在或为空(默认 build/forest/run-<时间>-<pid>)
|
||||
--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 &
|
||||
child_pid=$!
|
||||
SECONDS=0
|
||||
timed_out=0
|
||||
while kill -0 "$child_pid" 2>/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=$?
|
||||
child_pid=""
|
||||
|
||||
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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+26
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
+438
-29
@@ -1,44 +1,453 @@
|
||||
#!/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
|
||||
rm -f "$tmp_dir/sealed.json"
|
||||
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"
|
||||
for root_value in null '[]' '"invalid"' 42; do
|
||||
reset_fixture
|
||||
printf '%s\n' "$root_value" >"$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: invalid root $root_value"
|
||||
if [ -f "$tmp_dir/sealed.json" ]; then pass "validator: invalid root sealed as FAIL"; else fail "validator: invalid root crashed before sealing"; fi
|
||||
done
|
||||
reset_fixture
|
||||
printf 'null\n' >"$tmp_dir/v/events.jsonl"
|
||||
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: null event"
|
||||
[ -f "$tmp_dir/sealed.json" ] && pass "validator: null event sealed as FAIL" || fail "validator: null event crashed before sealing"
|
||||
reset_fixture
|
||||
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 <stdlib.h>
|
||||
#include <unistd.h>
|
||||
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'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>CFBundleExecutable</key><string>mtgodot-poc</string>
|
||||
<key>CFBundleIdentifier</key><string>test.mt.playable-gate</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
</dict></plist>
|
||||
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"; }
|
||||
|
||||
cancel_gate() { # script output [runner args...]
|
||||
local runner="$1" out="$2" owner child="" code pid_file
|
||||
shift 2
|
||||
MT_FAKE_SCENARIO=cancel bash "$runner" --app "$app" --output "$out" "$@" >"$out.stdout" 2>&1 &
|
||||
owner=$!
|
||||
for ((attempt=0; attempt<100; attempt++)); do
|
||||
pid_file="$(find "$out" -name fake-child.pid -print -quit 2>/dev/null || true)"
|
||||
if [ -n "$pid_file" ] && [ -s "$pid_file" ]; then child="$(<"$pid_file")"; break; fi
|
||||
kill -0 "$owner" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
kill -TERM "$owner" 2>/dev/null || true
|
||||
set +e
|
||||
wait "$owner"
|
||||
code=$?
|
||||
set -e
|
||||
expect_code 143 "$code" "cancel: $runner preserves signal exit"
|
||||
if [ -n "$child" ] && ! kill -0 "$child" 2>/dev/null; then
|
||||
pass "cancel: $runner reaps its client"
|
||||
else
|
||||
fail "cancel: $runner left a client alive or never started one"
|
||||
if [ -n "$child" ]; then kill -KILL "$child" 2>/dev/null || true; fi
|
||||
fi
|
||||
}
|
||||
cancel_gate script/run_client_gate.sh "$tmp_dir/run-cancel" --config "$tmp_dir/scenario.local.json" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt"
|
||||
cancel_gate script/forest_map_render_test.sh "$tmp_dir/forest-cancel" --maps "$map_key"
|
||||
cancel_gate script/playable_test.sh "$tmp_dir/batch-cancel" --config "$tmp_dir/scenario.local.json" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt"
|
||||
|
||||
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)"
|
||||
expect_code 2 "$code" "runner: no credentials and no TTY"
|
||||
[ ! -e "$tmp_dir/run-nocreds/client.log" ] && pass "runner: client not started without credentials" || fail "runner: client started without credentials"
|
||||
|
||||
expect_code 2 "$(code_of run_gate pass "$tmp_dir/run-pass")" "runner: refuses a non-empty output directory"
|
||||
expect_code 2 "$(code_of env MT_PLAYABLE_REQUIRED_ARCH=x86_64 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-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" </dev/null)" "batch: no credentials and no TTY"
|
||||
|
||||
# ---------- MAP-02 forest render runner (offline, same exit gate) ----------
|
||||
run_forest() { # scenario output [extra args...]
|
||||
local scenario="$1" out="$2"
|
||||
shift 2
|
||||
MT_FAKE_SCENARIO="$scenario" bash script/forest_map_render_test.sh --app "$app" --maps "$map_key" --output "$out" "$@" >"$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 ----------
|
||||
cancel_gate script/playable_soak.sh "$tmp_dir/soak-cancel" --config "$tmp_dir/soak.local.json" \
|
||||
--assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay
|
||||
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" "$@" </dev/null >"$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)" "soak: no config"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-nogameplay")" "soak: refuses to start without --allow-gameplay"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-duration" --allow-gameplay --duration-seconds 60)" "soak: --duration-seconds must match the config"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-exits" --allow-gameplay --exits 9)" "soak: fewer than 10 exit runs"
|
||||
expect_code 2 "$(code_of run_soak soak-pass "$tmp_dir/soak-timeout" --allow-gameplay --timeout-seconds 7300)" "soak: timeout below duration+900"
|
||||
expect_code 2 "$(code_of quiet env MT_FAKE_SCENARIO=soak-pass bash script/playable_soak.sh --app "$app" --config "$tmp_dir/scenario.local.json" \
|
||||
--output "$tmp_dir/soak-noblock-entry" --assets "$tmp_dir/assets" --serverlist "$tmp_dir/serverlist.txt" --allow-gameplay </dev/null)" "soak: config without a soak block"
|
||||
for dir in soak-nogameplay soak-duration soak-exits soak-timeout soak-noblock-entry; do
|
||||
if [ -n "$(find "$tmp_dir/$dir" -name client.log 2>/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)" "soak: no credentials and no TTY"
|
||||
|
||||
expect_code 1 "$(code_of run_soak rid "$tmp_dir/soak-exitfail" --allow-gameplay)" "soak: a failing exit run fails the release"
|
||||
[ ! -e "$tmp_dir/soak-exitfail/soak-1/client.log" ] && grep -q "^BLOCKED not started: exit-1" "$tmp_dir/soak-exitfail/soak-1/gate.log" 2>/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"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sourced by runners. Only signal children whose PIDs this runner still owns.
|
||||
# Clear each PID immediately after wait, so EXIT cannot signal a reused PID.
|
||||
child_pid=""
|
||||
redactor_pid=""
|
||||
sampler_pid=""
|
||||
proxy_pid=""
|
||||
|
||||
cleanup_playable_processes() {
|
||||
local status=$? pid round alive
|
||||
trap - EXIT
|
||||
trap '' HUP INT TERM
|
||||
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
|
||||
if [ -n "$pid" ]; then kill -TERM "$pid" 2>/dev/null || true; fi
|
||||
done
|
||||
for ((round=0; round<${process_cleanup_grace_seconds:-10}; round++)); do
|
||||
alive=0
|
||||
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then alive=1; fi
|
||||
done
|
||||
[ "$alive" -eq 1 ] || break
|
||||
sleep 1
|
||||
done
|
||||
for pid in "$child_pid" "$redactor_pid" "$sampler_pid" "$proxy_pid"; do
|
||||
if [ -n "$pid" ]; then
|
||||
kill -KILL "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [ -n "${fifo:-}" ] && [ -p "$fifo" ]; then rm -f "$fifo"; fi
|
||||
return "$status"
|
||||
}
|
||||
|
||||
trap cleanup_playable_processes EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
// Release aggregation must reject the intermediate report before the RSS seal.
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mt-playable-release-'));
|
||||
const write = (name, value) => fs.writeFileSync(path.join(root, name), JSON.stringify(value));
|
||||
const fixture = JSON.parse(fs.readFileSync(path.join(repo, 'test/playable/report.valid.json'), 'utf8'));
|
||||
const manifest = { schema_version: 1, candidate: fixture.build, required_runs: { soak: 1 },
|
||||
runs: [{ run_id: fixture.run_id, suite: 'soak', path: 'run-1' }] };
|
||||
const report = { ...fixture, suite: 'soak', exit_gate: { checked: true, process_code: 0, timed_out: false, errors: [] } };
|
||||
const baseline = process.argv.includes('--baseline')
|
||||
? spawnSync('git', ['show', 'f39a55fd:script/validate_playable_report.mjs'], { cwd: repo, encoding: 'utf8' }).stdout : null;
|
||||
function check(expected, label) {
|
||||
const args = baseline ? ['--input-type=module', '-'] : [path.join(repo, 'script/validate_playable_report.mjs')];
|
||||
const result = spawnSync(process.execPath, [...args, '--release-dir', root], { input: baseline, encoding: 'utf8' });
|
||||
assert.equal(result.status, expected, `${label}: ${result.stdout} ${result.stderr}`);
|
||||
assert.equal(JSON.parse(fs.readFileSync(path.join(root, 'release-report.json'))).status, expected === 0 ? 'PASS' : 'FAIL');
|
||||
console.log(`ok - ${label}`);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'run-1'));
|
||||
write('release-manifest.json', manifest);
|
||||
write('run-1/report.json', report);
|
||||
check(1, 'soak PASS without external memory seal is rejected');
|
||||
report.runner_cases = [{ id: 'STB-MEMORY-01', status: 'PASS' }];
|
||||
write('run-1/report.json', report);
|
||||
check(1, 'memory case alone cannot replace the verdict');
|
||||
report.memory = { verdict: { status: 'FAIL' } };
|
||||
write('run-1/report.json', report);
|
||||
check(1, 'contradictory memory verdict is rejected');
|
||||
report.memory.verdict.status = 'PASS';
|
||||
write('run-1/report.json', report);
|
||||
check(0, 'clean exit and external memory PASS can be aggregated');
|
||||
write('run-1/report.json', null);
|
||||
check(1, 'null run report produces a sealed release failure');
|
||||
write('release-manifest.json', null);
|
||||
check(1, 'null manifest produces a sealed release failure');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/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>/ 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"
|
||||
process_cleanup_grace_seconds=15 # Allow the child runner's 10-second cleanup to finish.
|
||||
source script/playable_process_cleanup.sh
|
||||
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-<时间>-<pid>)
|
||||
--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[@]}" </dev/null &
|
||||
child_pid=$!
|
||||
wait "$child_pid"
|
||||
code=$?
|
||||
child_pid=""
|
||||
set -e
|
||||
if [ "$code" -eq 124 ]; then timed_out=1; fi
|
||||
if [ "$code" -ne 0 ] && [ "$run_suite" = "playable" ]; then stop_reason="$name exit $code"; fi
|
||||
list_run "$name" "$run_suite"
|
||||
}
|
||||
|
||||
for index in $(seq 1 "$exits"); do
|
||||
run_one "exit-$index" playable "$exit_timeout"
|
||||
done
|
||||
run_one soak-1 soak "$timeout"
|
||||
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const [root, exits, 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: { playable: Number(exits), soak: 1 }, runs: JSON.parse(runs),
|
||||
}, null, 2) + "\n");
|
||||
' "$root" "$exits" "$runs_json"
|
||||
|
||||
set +e
|
||||
node script/validate_playable_report.mjs --release-dir "$root"
|
||||
release_code=$?
|
||||
set -e
|
||||
if [ -f "$root/soak-1/report.json" ]; then
|
||||
node -e '
|
||||
const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
|
||||
for (const c of [...(r.cases || []).filter((c) => 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"
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
+106
-27
@@ -1,56 +1,135 @@
|
||||
#!/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-<n>, 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"
|
||||
process_cleanup_grace_seconds=15
|
||||
source script/playable_process_cleanup.sh
|
||||
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[@]}" </dev/null &
|
||||
child_pid=$!
|
||||
wait "$child_pid"
|
||||
code=$?
|
||||
child_pid=""
|
||||
set -e
|
||||
if [ "$code" -eq 124 ]; then timed_out=1; fi
|
||||
# A run that never produced a sealed report is still listed so the release fails loudly.
|
||||
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" "$run_dir" "run-$index" "$suite")"
|
||||
done
|
||||
if [ "$failed" -ne 0 ]; then echo "PLAYABLE SUITE: FAIL runs=$failed/$repeat" >&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"
|
||||
|
||||
Executable
+63
@@ -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 <fifo >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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+253
-67
@@ -1,125 +1,311 @@
|
||||
#!/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
|
||||
source script/playable_process_cleanup.sh
|
||||
|
||||
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-<时间>-<pid>)
|
||||
--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
|
||||
}
|
||||
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 &
|
||||
proxy_pid=$!
|
||||
proxy_wait=0
|
||||
until grep -q '"action":"listening"' "$output_dir/fault-proxy.jsonl" 2>/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 </dev/null &
|
||||
child_pid=$!
|
||||
sampler_pid=""
|
||||
if [ "$suite" = "soak" ]; then
|
||||
node script/playable_soak_metrics.mjs --sample-rss --pid "$child_pid" --output "$output_dir/rss.jsonl" \
|
||||
</dev/null >>"$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=$?
|
||||
child_pid=""
|
||||
# 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=$?
|
||||
redactor_pid=""
|
||||
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"
|
||||
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"
|
||||
|
||||
@@ -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,298 @@ 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}` }; }
|
||||
try {
|
||||
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
if (!isObject(value)) return { __read_error: `${path.basename(file)}: root must be an object` };
|
||||
return value;
|
||||
}
|
||||
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 isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
||||
|
||||
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 (!isObject(event) || !EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id
|
||||
|| !Number.isSafeInteger(event.monotonic_us) || event.monotonic_us < 0
|
||||
|| 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`);
|
||||
// The client exit gate runs before the external RSS seal. An interrupted
|
||||
// or crashed memory sealer must not leave a releasable intermediate PASS.
|
||||
if (run.suite === 'soak' && report.status === 'PASS') {
|
||||
const memoryCases = Array.isArray(report.runner_cases)
|
||||
? report.runner_cases.filter((item) => item?.id === 'STB-MEMORY-01') : [];
|
||||
if (memoryCases.length !== 1 || memoryCases[0].status !== 'PASS'
|
||||
|| report.memory?.verdict?.status !== 'PASS') {
|
||||
errors.fail.push(`${label}: STB-MEMORY-01 external memory seal missing or not PASS`);
|
||||
}
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
"arch": "arm64"
|
||||
}
|
||||
@@ -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"}}
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/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
|
||||
cancel) exec node -e 'require("node:fs").writeFileSync(process.env.MT_TEST_OUTPUT + "/fake-child.pid", String(process.pid)); setInterval(() => {}, 1000)' ;;
|
||||
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
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"maps": {
|
||||
"<map_key from map-assets.json>": {
|
||||
"races": [],
|
||||
"viewpoints": [
|
||||
{
|
||||
"id": "flat-01",
|
||||
"kind": "flat",
|
||||
"status": "unconfirmed",
|
||||
"server_cm": [0, 0],
|
||||
"camera_yaw_deg": 45
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"notes": "Copy to test/playable/forest-viewpoints.<name>.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."
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"suite": "playable",
|
||||
"cases": [
|
||||
"FIXTURE-01"
|
||||
]
|
||||
}
|
||||
@@ -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/<name>.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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user