feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档

- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+10
View File
@@ -37,6 +37,16 @@ compile_commands.json
*.a *.a
.env.live-smoke.local .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 # clangd
/.cache/ /.cache/
+113
View File
@@ -0,0 +1,113 @@
# 首个 Mac 联网内测版:实施状态与已知问题(REL-01)
日期:2026-09-11。对应计划:`docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md`
本文只记录已核对的事实。**当前没有可放行的候选包,也没有任何实网证据**;所有依赖服务器、测试账号、环境负责人确认或人工签核的条目均为 BLOCKED。离线/假进程测试 PASS 只证明门禁和状态机按契约工作,不能代替联网验收。
## 1. 结论
| 项 | 状态 | 说明 |
| --- | --- | --- |
| INF-01 契约与配置校验 | 离线 PASS | `playable_harness_test.gd` 负向用例、`validate_playable_report.mjs` 静态夹具全部通过 |
| INF-02 运行器与退出门禁 | 离线 PASS | `script/playable_gate_test.sh` 用假签名 arm64 包覆盖 PASS/退出码/信号/超时 TERM→10s→KILL/残留 FIFO/RID 与泄漏警告/凭据脱敏/聚合 |
| NET-01 真实联网闭环 | **BLOCKED** | 包内入口已实现;缺 `scenario.local.json`、测试账号、环境负责人确认的路线/怪物/掉落,未联网运行 |
| CBT-01 四职业技能矩阵 | **BLOCKED** | `test/playable/skill-cases.json` 全部 `unconfirmed`;脚本不补技能 ID |
| MAP-01 树怪资源与离线 Metal 截图 | 离线 PASS**待人工签核** | 12 个候选树怪 × CPU/GPU × wait/run/attack/damage/dead 全部组装通过;截图需人工看过 |
| MAP-02 实际地图定位与落地 | **BLOCKED** | `forest-viewpoints.example.json` 机位均 `unconfirmed`;坡地脚/根接触需人工检查 |
| STB-01 2 小时稳定性 | 工具已实现,**未实跑**,BLOCKED | `script/playable_soak.sh` 可运行(见 §4);真实断网与切图需环境确认 |
| REL-01 放行 | **BLOCKED** | 无重建候选包;上述必测项均未产生实网证据 |
## 2. 候选包与环境
- 计划中的候选包 `build/export-native-trees-20260911/mtgodot-poc.app` 和自编译引擎 `build/godot-particle-diagnostic/bin/godot.macos.template_release.arm64` 在当前工作树**不存在**。仅有 `build/export/mtgodot-poc.app`2026-09-08),早于本轮全部 playable 代码,**不能**作为候选包。
- 本轮修改了 GDScript(包内测试入口、流程、指标、窗口适配),**必须重新导出 PCK**;若最终修复涉及 C++,还需 `./build.sh Release` 重建扩展、重新签名打包。重建后对最终包重新跑退出门禁。
- 官方 Release 模板的粒子退出警告未修复;必须显式使用经过验证的自编译 arm64 引擎。不宣称已修复官方模板或 Intel 平台。
- 数据盘剩余约 7.5 GiB(99% 已用)。2 小时 soak 的 `events.jsonl``rss.jsonl`、截图与日志会持续写盘,实跑前需由用户腾出空间;脚本不会自行清理任何文件或系统缓存。
## 3. 已知问题与限制
### 3.1 树怪动作(MAP-01
- `ent_trent`2301/2311)的 motlist 只有 WAIT、WAIT1、NORMAL_ATTACK、NORMAL_ATTACK1、FRONT_DAMAGE、FRONT_DEAD**没有 RUN/WALK**。按旧客户端 `CRaceData::GetMotionKey` 未命中→`SetLoopMotion` 直接返回的规则,移动时继续播放当前循环(`reference_keep_current_motion`)。`forest_mob_render_test` 报告记为 4 条 fallback2 个 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`Metalsuite=`offline_synthetic`FakeClient + 真实资源)本轮结果:
- 689 帧,>50ms 14 帧,全部已归因(unattributed=0);>100msloading 6、gameplay 2。
- gameplay 的 2 帧(119.5ms、237.0ms)都带 `viewport_resize` 标记,发生在测试主动切换窗口尺寸时。它们不属于正常游玩的重复交互,但在实网 soak 中要继续按“同一交互 3 次中 2 次 >100ms 即阻断”规则判定。
- loading 段:同步模型构建 447746ms,切图 1861ms 与 4489ms。首次加载预算尚未固定(计划要求基线采集后再定,不临时放宽)。
- CPU 蒙皮 8/16 角色时 p99 分别为 70.1ms/50.5ms90 帧窗口,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 playableN ≥ 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、安全路线、怪物/掉落 vnumsoak 需 `timeout_seconds ≥ duration_seconds + 900`
3. 专用测试账号(等级、装备、已学技能、掉落条件由负责人准备),以及 `skill-cases.local.json` 中各职业的确认记录。
4. 如需 STB-WARP/STB-DISCONNECT:合法传送入口的确认;确认可在 127.0.0.1 代理后运行,并核实传送是否下发直连地址。
5. 树怪机位(平地/坡地/密林/传送点)的确认,以及 Metal 截图人工签核人。
+23 -4
View File
@@ -51,6 +51,12 @@ func _init() -> void:
func start(assets_root: String = "", injected_client: Node = null) -> void: func start(assets_root: String = "", injected_client: Node = null) -> void:
_assets = assets_root _assets = assets_root
serverinfo = ServerInfoRes.new() serverinfo = ServerInfoRes.new()
# 联网内测的地址覆盖只在 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") # 有就用,没有用内置 serverinfo.load_file("res://serverlist.txt") # 有就用,没有用内置
if injected_client != null: if injected_client != null:
client = injected_client client = injected_client
@@ -84,6 +90,17 @@ func start(assets_root: String = "", injected_client: Node = null) -> void:
func state() -> int: func state() -> int:
return _state 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 # 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 # summaries rather than the private UI/game nodes so a report cannot retain a
# stale scene reference across reconnect or map reload. # 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(): if auto_login and not _chars.is_empty():
var idx := int(_chars[0].get("index", 0)) var idx := int(_chars[0].get("index", 0))
if _auto_char_slot >= 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: for character in _chars:
if int(character.get("index", -1)) == _auto_char_slot: if int(character.get("index", -1)) == _auto_char_slot:
idx = _auto_char_slot idx = _auto_char_slot
break break
if client.has_method("enter_game"): if idx >= 0:
client.enter_game(idx) # Remember the slot so a later in-game reconnect re-enters the same character.
elif client.has_method("select_character"): _enter_character(idx)
client.select_character(idx)
func _on_char_name_changed(pid: int, name: String) -> void: 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 var sc: Node = _ui.get_node_or_null("CharSelect") if _ui and _state == SELECT else null
+14
View File
@@ -74,6 +74,20 @@ func _init() -> void:
_ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true), _ck(game_snapshot.get("stage", "") == "GAME" and not game_snapshot.get("reconnecting", true),
"playable snapshot reports GAME after reconnect") "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() flow.queue_free()
await process_frame await process_frame
if _fail == 0: if _fail == 0:
+8
View File
@@ -267,6 +267,14 @@ func _run() -> void:
np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗 np._swing_start_t = np._now() - 2.0 # 动作越过所有命中窗
np._process(0.016) np._process(0.016)
_ck(np._hit_windows.is_empty(), "motion past all windows -> hit windows cleared") _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 np.net_world = null
# --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)--- # --- §3.5 修改 6:武器种类 -> combo_motion_modeRefreshState 的 SetMotionMode 分支)---
+41
View File
@@ -1,6 +1,7 @@
extends SceneTree extends SceneTree
const EffectPlayer = preload("res://fx/effect_player.gd") const EffectPlayer = preload("res://fx/effect_player.gd")
const EffectRegistry = preload("res://fx/effect_registry.gd")
var failures := 0 var failures := 0
func check(ok: bool, label: String) -> void: 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), check(is_equal_approx(live._particle_states[0].clock, stopped_clock),
"SceneTree leaves stopped clock unchanged") "SceneTree leaves stopped clock unchanged")
live.free() live.free()
await registry_lifecycle()
print("effect_playback_test: failures=%d" % failures) print("effect_playback_test: failures=%d" % failures)
quit(1 if failures else 0) 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
+575 -75
View File
@@ -1,96 +1,596 @@
extends Node extends Node
## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点 ## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)
## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。 ##
## 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 GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd") const Fixtures = preload("res://gamescene_test.gd")
const MobView = preload("res://ui/mob_view.gd") const Viewpoints = preload("res://testing/forest_viewpoints.gd")
const Config = preload("res://testing/playable_config.gd")
const PlayableReport = preload("res://testing/playable_report.gd") const PlayableReport = preload("res://testing/playable_report.gd")
var failures := 0 ## root/npclist.txt 23012307 / 23112315;未由服务器确认前只是候选。
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: func _ready() -> void:
call_deferred("run") call_deferred("run")
func _fail(message: String) -> void:
failures += 1
printerr("FOREST MAP FAIL: " + message)
func run() -> void: 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 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 not config_path.is_empty() and FileAccess.file_exists(config_path):
if map_key.is_empty() and loaded_config.ok: var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path))
map_key = String(loaded_config.config.get("map_key", "")) config = parsed if parsed is Dictionary else {}
if map_key.is_empty(): _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS"))
map_key = "outdoortrent/metin2_map_trent" var maps: Array[String] = []
var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301 for key in OS.get_environment("MT_FOREST_MAPS").split(",", false):
var output := OS.get_environment("MT_TEST_OUTPUT") if not key.strip_edges().is_empty():
if output.is_empty(): maps.append(key.strip_edges())
output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system()) if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty():
DirAccess.make_dir_recursive_absolute(output) maps.append(String(config.map_key).strip_edges())
var playable_report := PlayableReport.new() _report.set_required_cases(required_case_ids(maps))
playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {}) var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS"))
get_tree().root.size = Vector2i(1280, 720) var races := parse_races(OS.get_environment("MT_FOREST_RACES"))
print("FOREST MAP: loading %s" % map_key) var config_errors: Array[String] = []
var client := Fixtures.FakeClient.new() if maps.is_empty():
# Server-space values chosen inside the corresponding local map rectangle. config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json")
# GameScene/MapCoord performs the actual base-position conversion. var ids := {}
var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0 for map_key in maps:
var server_x := base_x + 12800.0 if not is_safe_map_key(map_key):
var server_y := 12800.0 config_errors.append("map_key %s must be a relative asset path without '..'" % map_key)
client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true) elif ids.has(map_key.get_file()):
var scene := GameScene.new() config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()])
get_tree().root.add_child(scene) ids[map_key.get_file()] = true
await scene.setup(client, AssetRoot.path(), map_key) if not vp_file.ok:
print("FOREST MAP: GameScene setup complete") config_errors.append_array(vp_file.errors)
if not scene._map_loaded(): if not races.ok:
_fail("map did not load: %s" % map_key) config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums")
if not scene._model_built or scene.player == null: if not config_errors.is_empty():
_fail("local player model is not ready") _report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors))
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)
else: else:
entities.add_child(monster) _report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps),
monster.position = positions[0] "yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"])
monster.set_anim_state("wait") get_tree().root.size = Vector2i(1280, 720)
client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0)) 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 await get_tree().process_frame
if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0: var load_report: Dictionary = world.call("get_load_report") if world else {}
_fail("tree monster has no visible geometry") map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path}
if scene.cam and scene.cam.has_method("snap_to_target"): var load_errors: Array[String] = []
scene.cam.snap_to_target() 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
# 按最大足迹排成面向相机的网格,再让 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 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 await RenderingServer.frame_post_draw
if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK: var image := get_tree().root.get_texture().get_image()
_fail("map screenshot could not be saved") for layer in hidden:
var report := {"passed": failures == 0, "failures": failures, "map": map_key, if is_instance_valid(layer):
"tree_monster_race": race, "tree_monster_model": monster.model != null, layer.visible = true
"map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "", if image == null or image.is_empty() or not _has_detail(image):
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless", ev.errors.append("screenshot %s is empty or uniform" % file_name)
"scope": "production GameScene map load plus offline tree-monster placement; no server spawn"} return
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE) if image.save_png(_out.path_join(file_name)) != OK:
if file: ev.errors.append("screenshot %s could not be saved" % file_name)
file.store_string(JSON.stringify(report, "\t")) return
file.close() ev.screenshots.append(file_name)
var client_report_path := OS.get_environment("MT_TEST_REPORT")
if not client_report_path.is_empty(): func _has_detail(image: Image) -> bool:
playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL", var first := image.get_pixel(0, 0)
"map=%s tree_monster_race=%d" % [map_key, race]) var differing := 0
playable_report.write(client_report_path) var total := 0
print("FOREST MAP RENDER: ", JSON.stringify(report)) 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() scene.queue_free()
client.queue_free() client.queue_free()
for _i in 3:
await get_tree().process_frame await get_tree().process_frame
get_tree().quit(1 if failures else 0)
+219 -49
View File
@@ -1,17 +1,36 @@
extends SceneTree extends SceneTree
## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收。 ## 鬼木林 / 赤鬼木林树怪的离线资源和 CPU/GPU 动作验收FIRST-MAC-PLAYABLE §8.2
## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 playable_live_test 承担。 ## 这不是服务器刷新证明;真实地图中的选中、战斗和掉落由 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 MobView = preload("res://ui/mob_view.gd")
const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315] const RACES := [2301, 2302, 2303, 2304, 2305, 2306, 2307, 2311, 2312, 2313, 2314, 2315]
const STATES := ["wait", "run", "attack", "damage", "dead"] 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 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 _output := ""
var _failures: Array[String] = [] var _failures: Array[String] = []
var _fallbacks: Array[Dictionary] = []
var _pose_bounds := {} var _pose_bounds := {}
var _results: Array[Dictionary] = [] var _results: Array[Dictionary] = []
var _races: Array = RACES
func _initialize() -> void: func _initialize() -> void:
call_deferred("run") call_deferred("run")
@@ -25,6 +44,11 @@ func run() -> void:
if _output.is_empty(): if _output.is_empty():
_output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system()) _output = ProjectSettings.globalize_path("res://../build/rendering/forest-mobs-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_output) 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) root.size = Vector2i(SIZE, SIZE)
_build_lighting() _build_lighting()
var cam := Camera3D.new() var cam := Camera3D.new()
@@ -34,80 +58,226 @@ func run() -> void:
cam.make_current() cam.make_current()
for gpu in ["0", "1"]: for gpu in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", gpu) OS.set_environment("MTGODOT_GPUSKIN", gpu)
for race in RACES: for race in _races:
await _check_race(race, gpu, cam) await _check_race(int(race), gpu, cam)
OS.set_environment("MTGODOT_GPUSKIN", "0") OS.set_environment("MTGODOT_GPUSKIN", "0")
var report := {"passed": _failures.is_empty(), "failures": _failures, "races": RACES, var headless := DisplayServer.get_name() == "headless"
"states": STATES, "gpu_modes": ["0", "1"], "results": _results, var report := {"passed": _failures.is_empty(), "failures": _failures, "races": _races,
"assets": AssetRoot.path(), "viewport": str(root.size), "states": STATES, "gpu_modes": ["0", "1"], "sample_fractions": SAMPLES,
"headless": DisplayServer.get_name() == "headless", "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"} "scope": "offline tree-monster assembly; no server spawn or 40250 pixel parity"}
var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE) var file := FileAccess.open(_output.path_join("report.json"), FileAccess.WRITE)
if file: if file:
file.store_string(JSON.stringify(report, "\t")) file.store_string(JSON.stringify(report, "\t"))
file.close() 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) quit(0 if _failures.is_empty() else 1)
func _check_race(race: int, gpu: String, cam: Camera3D) -> void: func _check_race(race: int, gpu: String, cam: Camera3D) -> void:
var tag := "race=%d GPU=%s" % [race, gpu]
var view := MobView.new() var view := MobView.new()
view.name = "ForestMob_%d_%s" % [race, gpu] view.name = "ForestMob_%d_%s" % [race, gpu]
root.add_child(view) root.add_child(view)
if not view.build(AssetRoot.path(), null, race): 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() view.queue_free()
await process_frame await process_frame
return return
var row := {"race": race, "gpu_skin": gpu == "1", "states": {}} # 交叉淡入只在 _process 里推进;取样要看动作本身的姿态,不看上一动作的残留混合。
for state in STATES: if view.anim:
var requested := view.get_motion_path(state) view.anim.set("blend_time", 0.0)
if requested.is_empty() or not FileAccess.file_exists(requested): var row := {"race": race, "gpu_skin": gpu == "1", "directory": view._dir,
_fail("race=%d GPU=%s state=%s has no resolved .msa" % [race, gpu, state]) "materials": _material_report(view.model, tag), "states": {}, "views": {}, "contact_sheet": ""}
continue 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) view.set_anim_state(state)
if view.anim == null: 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 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) view.anim.set_process(true)
await process_frame 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) view.anim.set_process(false)
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 await process_frame
var bounds: AABB = view.model.call("get_visual_aabb") var sample := _sample_pose(view, race, gpu, state, fraction, tag)
if not bounds.position.is_finite() or not bounds.size.is_finite() or bounds.size.length() <= 0.0: sample["time"] = at
_fail("race=%d GPU=%s state=%s has invalid visual AABB" % [race, gpu, state]) entry.samples.append(sample)
_pose_bounds["%d/%s" % [race, state]] = bounds if gpu == "0" else _pose_bounds.get("%d/%s" % [race, state], AABB()) if not bool(sample.valid):
if gpu == "1": entry.status = "FAIL"
var cpu_bounds: AABB = _pose_bounds.get("%d/%s" % [race, state], AABB()) if shoot:
var tolerance := maxf(0.01, maxf(cpu_bounds.size.length(), bounds.size.length()) * 0.005) if frame.is_empty():
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: frame = _frame_for(view)
_fail("race=%d state=%s CPU/GPU AABB differs beyond %.4fm" % [race, state, tolerance]) var image := await _capture(cam, frame, Vector3(0.35, 0.22, 1.0))
var image_name := "" if not _has_foreground(image):
if DisplayServer.get_name() != "headless": entry.status = "FAIL"
var centre: Vector3 = view.model.global_transform * bounds.get_center() _fail("%s state=%s sample=%.2f screenshot is empty" % [tag, state, fraction])
var visual_size: float = bounds.size.length() * view.model.scale.length() / sqrt(3.0) image.convert(Image.FORMAT_RGBA8)
var distance: float = maxf(3.0, visual_size * 1.8) image.resize(THUMB, THUMB, Image.INTERPOLATE_BILINEAR)
cam.position = centre + Vector3(distance * 0.35, distance * 0.22, distance) sheet.blit_rect(image, Rect2i(0, 0, THUMB, THUMB), Vector2i(sample_index * THUMB, state_index * THUMB))
cam.look_at(centre, Vector3.UP) if state == "wait" and is_equal_approx(fraction, 0.5):
await RenderingServer.frame_post_draw for view_name in ["front", "side"]:
image_name = "mob-%d-%s.png" % [race, state] if gpu == "1" else "mob-%d-%s-cpu.png" % [race, state] var direction := Vector3(0.0, 0.18, 1.0) if view_name == "front" else Vector3(1.0, 0.18, 0.0)
if root.get_texture().get_image().save_png(_output.path_join(image_name)) != OK: var shot := await _capture(cam, frame, direction)
_fail("race=%d state=%s screenshot save failed" % [race, state]) var file_name := "mob-%d-%s-%s.png" % [race, view_name, "gpu" if gpu == "1" else "cpu"]
row.states[state] = {"requested_motion": requested, "resolved_motion": actual, if not _has_foreground(shot) or shot.save_png(_output.path_join(file_name)) != OK:
"duration": duration, "sample_fraction": fraction, _fail("%s %s view screenshot failed" % [tag, view_name])
"aabb_position": [bounds.position.x, bounds.position.y, bounds.position.z], row.views[view_name] = file_name
"aabb_size": [bounds.size.x, bounds.size.y, bounds.size.z], "screenshot": image_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) _results.append(row)
view.queue_free() view.queue_free()
await process_frame await process_frame
## 同一 race/state/fractionCPU 先记录,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: func _build_lighting() -> void:
var sun := DirectionalLight3D.new() var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-42, 155, 0) sun.rotation_degrees = Vector3(-42, 155, 0)
@@ -116,7 +286,7 @@ func _build_lighting() -> void:
var env := WorldEnvironment.new() var env := WorldEnvironment.new()
var settings := Environment.new() var settings := Environment.new()
settings.background_mode = Environment.BG_COLOR 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_source = Environment.AMBIENT_SOURCE_COLOR
settings.ambient_light_color = Color(0.55, 0.58, 0.65) settings.ambient_light_color = Color(0.55, 0.58, 0.65)
settings.ambient_light_energy = 0.8 settings.ambient_light_energy = 0.8
+92
View File
@@ -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
+7
View File
@@ -29,6 +29,10 @@ var _light_states: Array[Dictionary] = []
var _bsphere_r := 0.0 var _bsphere_r := 0.0
var _playing := false var _playing := false
var _cleanup_remaining := -1.0 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 static var _glow_tex: Texture2D
@@ -64,6 +68,7 @@ func _process(delta: float) -> void:
if _cleanup_remaining >= 0.0: if _cleanup_remaining >= 0.0:
_cleanup_remaining -= maxf(delta, 0.0) _cleanup_remaining -= maxf(delta, 0.0)
if _cleanup_remaining <= 0.0: if _cleanup_remaining <= 0.0:
finish_reason = "cleanup"
queue_free() queue_free()
func play(force_one_shot := false) -> void: 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. # may start or delete an effect after stop/replay, or while it is paused.
_playing = true _playing = true
_cleanup_remaining = maxf(_longest_life() + 0.5, 1.5) if (force_one_shot or one_shot) else -1.0 _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: for state in _particle_states:
state["clock"] = 0.0 state["clock"] = 0.0
state["emission_stopped"] = false state["emission_stopped"] = false
@@ -97,6 +103,7 @@ func stop() -> void:
# Existing particles may finish their lifetime; mesh/light emission stops now. # Existing particles may finish their lifetime; mesh/light emission stops now.
_playing = false _playing = false
_cleanup_remaining = -1.0 _cleanup_remaining = -1.0
defined_lifetime_ms = -1
for e in _emitters: for e in _emitters:
e.emitting = false e.emitting = false
for mesh in _mesh_nodes: for mesh in _mesh_nodes:
+24
View File
@@ -6,12 +6,21 @@
# var pv := fxr.spawn_at("hit_spark", world, global_pos) # var pv := fxr.spawn_at("hit_spark", world, global_pos)
extends RefCounted 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 Mse = preload("res://fx/mse.gd")
const EffectPlayer = preload("res://fx/effect_player.gd") const EffectPlayer = preload("res://fx/effect_player.gd")
var assets_root := "" var assets_root := ""
var _path_cache := {} # name -> abs .mse path ("" = not found) var _path_cache := {} # name -> abs .mse path ("" = not found)
var _spec_cache := {} # abs path -> parsed spec 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: func setup(assets: String) -> void:
assets_root = assets assets_root = assets
@@ -89,8 +98,23 @@ func spawn(name: String, parent: Node3D, one_shot := true) -> Node3D:
fx.build(s, assets_root) fx.build(s, assets_root)
parent.add_child(fx) parent.add_child(fx)
fx.play(one_shot) fx.play(one_shot)
_track(fx, name)
return fx 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: func spawn_at(name: String, world_parent: Node3D, global_pos: Vector3, one_shot := true) -> Node3D:
var fx := spawn(name, world_parent, one_shot) var fx := spawn(name, world_parent, one_shot)
+1 -1
View File
@@ -1287,7 +1287,7 @@ func get_playable_context() -> Dictionary:
return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path, return {"scene_ready": _model_built and _map_loaded(), "map_path": map_path,
"net_play": net_play, "net_world": net_world, "world": world, "net_play": net_play, "net_world": net_world, "world": world,
"player": player, "pc": pc, "hud": hud, "quickbar": quickbar, "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 _mob_view_cache := {} # race -> bool(该 race 是否有可用模型;失败就别再试)
var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性) var _remote_player_view_cache := {} # race -> bool(远端 PC 真模型可用性)
+39
View File
@@ -7,6 +7,7 @@ const MobView = preload("res://ui/mob_view.gd")
const Audio = preload("res://audio.gd") const Audio = preload("res://audio.gd")
var _fail := 0 var _fail := 0
var _resolution_done := false
func _ck(c: bool, m: String) -> void: func _ck(c: bool, m: String) -> void:
if not c: if not c:
_fail += 1 _fail += 1
@@ -21,7 +22,45 @@ func _init() -> void:
printerr("%d check(s) failed" % _fail) printerr("%d check(s) failed" % _fail)
quit(1) 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: func _run() -> void:
_check_motion_resolution()
# 脚本运行时错误只会中止函数、不计失败;用哨兵保证整段断言都执行过。
_ck(_resolution_done, "动作解析断言全部执行")
if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"): if not ClassDB.class_exists("Metin2Model") or not ClassDB.class_exists("Metin2Proto"):
print(" (skip: 扩展未注册)") print(" (skip: 扩展未注册)")
return return
+2 -1
View File
@@ -6,7 +6,8 @@ func _init() -> void:
func run() -> void: func run() -> void:
for mode in ["0", "1"]: for mode in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", mode) 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() var view := MobView.new()
if not view.build(AssetRoot.path(), null, race): if not view.build(AssetRoot.path(), null, race):
failures += 1 failures += 1
+20 -3
View File
@@ -894,8 +894,27 @@ func skill_context() -> Dictionary:
"arrow_count": _arrow_count, "arrow_count": _arrow_count,
"cur_hp": cur_hp, "cur_hp": cur_hp,
"cur_sp": cur_sp, "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: func _process(dt: float) -> void:
# 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。 # 击退动作播完 -> 解除击退闸门(动作时长驱动,非计时器)。
if _knock_down and player_view and player_view.has_method("is_in_hit_reaction") \ 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: if rv.is_empty() or tnode == null:
_clear_reserved() _clear_reserved()
return return
var dist_cm: float = pc.player.global_position.distance_to(tnode.global_position) * CM var dist_cm := skill_target_distance_cm(_vid_reserved)
if _is_huge_race(int(rv.get("race", 0))):
dist_cm -= HUGE_RACE_DIST_FIX_CM
if dist_cm < _skill_range_reserved: if dist_cm < _skill_range_reserved:
if _target_vid != _vid_reserved: if _target_vid != _vid_reserved:
_target_vid = _vid_reserved _target_vid = _vid_reserved
+15 -7
View File
@@ -1072,11 +1072,14 @@ func _on_dead(vid: int) -> void:
var n: Node3D = _by_vid.get(vid, null) var n: Node3D = _by_vid.get(vid, null)
if n == null: if n == null:
return return
# 倒地 + 变灰(服务端随后会发 GC_CHARACTER_DEL 再真正移除) # 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() var t := create_tween()
t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3) t.tween_property(n, "rotation:x", deg_to_rad(-80.0), 0.3)
if n.has_method("set_anim_state"):
n.call("set_anim_state", "dead")
# P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在 # P6:飞行道具(GC_CREATE_FLY)—— 从 start 实体飞向 end 实体 / 坐标。原客户端在
# GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以这里也消费 # GC_CREATE_FLY 之前可能只收到 FLY_TARGETINGtarget VID=0 时是坐标),所以这里也消费
@@ -1284,17 +1287,22 @@ func _process(dt: float) -> void:
var f := int(e.get("func", FUNC_WAIT)) var f := int(e.get("func", FUNC_WAIT))
var moving := bool(e.get("moving", false)) var moving := bool(e.get("moving", false))
var walk_mode := int(e.get("walk_mode", 1)) 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)) \ 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("func", f)
n.set_meta("moving", moving) n.set_meta("moving", moving)
n.set_meta("walk_mode", walk_mode) 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() _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" var state := "wait"
if f == FUNC_MOVE or (f == FUNC_WAIT and moving): if dead:
# CActorInstance::__SetMotionIsDead() 时拒绝 DEAD/DAMAGE_FLYING 以外的动作。
state = "dead"
elif f == FUNC_MOVE or (f == FUNC_WAIT and moving):
state = "walk" if walk_mode == 0 else "run" state = "walk" if walk_mode == 0 else "run"
elif f == FUNC_ATTACK or f == FUNC_COMBO: elif f == FUNC_ATTACK or f == FUNC_COMBO:
state = "attack" state = "attack"
+35
View File
@@ -124,3 +124,38 @@ func _run() -> void:
await process_frame await process_frame
_ck(nw.node_for(2) != null, "main back in range -> culled entity rebuilt after its fade") _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") _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")
+7
View File
@@ -289,8 +289,15 @@ func _run() -> void:
var hook_calls: Array = [] var hook_calls: Array = []
np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true np.use_skill_hook = func(slot: int) -> bool: hook_calls.append(slot); return true
pc.player.position = Vector3.ZERO 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) 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(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() pc.walk_calls.clear()
fc.calls.set_target.clear() fc.calls.set_target.clear()
await process_frame await process_frame
+355
View File
@@ -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")
+64 -3
View File
@@ -1,14 +1,75 @@
extends SceneTree 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 Config = preload("res://testing/playable_config.gd")
const ServerInfoRes = preload("res://net/serverinfo.gd")
func _init() -> void: func _init() -> void:
var path := OS.get_environment("MT_PLAYABLE_VALIDATE_CONFIG") 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) var result := Config.load_file(path)
if not result.ok: errors.append_array(result.errors)
for error in 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)) printerr("CONFIG: " + String(error))
quit(2) quit(2)
return 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) 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
File diff suppressed because it is too large Load Diff
+139 -331
View File
@@ -1,373 +1,181 @@
extends Node extends Node
## 首个 Mac 内测的真实联网闭环。 ## 首个 Mac 内测的真实联网闭环NET-01 / CBT-01
## ##
## 这个脚本只负责调度生产入口并观察服务端结果:移动仍经 PlayerController ## 本节点只做装配:加载配置、挂 PlayableProbe、把观察转给 PlayableFlow 状态机、
## 目标仍经 NetPlay,拾取仍经 NetPlay.pick_ground_item,技能入口由后续 CBT ## 截图、写 client-report.json 后正常退出。所有游戏操作都在 PlayableFlow 里经
## 用例接入 Quickbar。没有 `MT_PLAYABLE_ALLOW_GAMEPLAY=1` 时只登录/进场 ## 生产入口发出;没有 MT_PLAYABLE_ALLOW_GAMEPLAY=1 时只验证登录/选角/进场
## 观察并把后续必测项标记为 BLOCKED,不会误发游戏操作。 ##
## env:
## MT_PLAYABLE_CONFIG 场景 JSON(不含凭据)
## MT_PLAYABLE_SUITE playable|full|soaksoak = 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 Config = preload("res://testing/playable_config.gd")
const Report = preload("res://testing/playable_report.gd") const Report = preload("res://testing/playable_report.gd")
const Probe = preload("res://testing/playable_probe.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 var _app_flow: Node
const MOVE_SECONDS := 20.0 var _client: Object
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 _report: RefCounted var _report: RefCounted
var _probe: Node var _probe: Node
var _machine: RefCounted
var _report_path := "" var _report_path := ""
var _events_path := "" var _output_dir := ""
var _state := "boot" var _watched_quickbar: Object
var _state_since := 0.0 var _watched_fx: Object
var _started := 0.0
var _finished := false var _finished := false
var _allow_gameplay := false var _metrics: RefCounted
var _entered_count := 0 var _tick_us := 0
var _main_vid := 0 var _tick_max_us := 0
var _server_move_seen := false var _ticks := 0
var _waypoint_index := 0 var _memory_start := {}
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 := {}
func setup(flow: Node, m2client: Node, config_path := "") -> void: func setup(app_flow: Node, m2client: Object, config: Dictionary = {}) -> void:
_flow = flow _app_flow = app_flow
_client = m2client _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() _report_path = OS.get_environment("MT_TEST_REPORT").strip_edges()
if _report_path.is_empty(): if _report_path.is_empty():
_report_path = "user://playable-client-report.json" _report_path = "user://playable-client-report.json"
_events_path = OS.get_environment("MT_TEST_EVENTS").strip_edges() var events_path := OS.get_environment("MT_TEST_EVENTS").strip_edges()
if _events_path.is_empty(): if events_path.is_empty():
_events_path = _report_path.get_base_dir().path_join("events.jsonl") 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") _output_dir = OS.get_environment("MT_TEST_OUTPUT").strip_edges()
var loaded := Config.load_file(path) 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 = Report.new()
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded.config if loaded.ok else {}) _report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), suite, config if errors.is_empty() else {}, events_path)
if not loaded.ok: if not errors.is_empty():
_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(loaded.errors)) _report.set_required_cases(["CONFIG-01"])
_report.add_case("CONFIG-01", "FAIL", "invalid configuration: %s" % "; ".join(errors))
_finish_later() _finish_later()
return return
_config = loaded.config
_case("CONFIG-01", "PASS", "validated")
if _client == null: 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() _finish_later()
return return
_probe = Probe.new() _probe = Probe.new()
_probe.name = "PlayableProbe" _probe.name = "PlayableProbe"
add_child(_probe) add_child(_probe)
_probe.setup(_flow, _client) _probe.setup(_app_flow, _client)
_probe.observed.connect(_on_observed) _machine = Flow.new()
_bind("char_list", Callable(self, "_on_char_list")) _machine.report = _report
_bind("entered_game", Callable(self, "_on_entered_game")) _machine.probe = _probe
_bind("disconnected", Callable(self, "_on_disconnected")) _machine.flow = _app_flow
_bind("entity_moved", Callable(self, "_on_entity_moved")) _machine.client = _client
_bind("damage", Callable(self, "_on_damage")) _machine.config = config
_bind("entity_dead", Callable(self, "_on_entity_dead")) _machine.suite = suite
_bind("ground_item_added", Callable(self, "_on_ground_item_added")) _machine.allow_gameplay = OS.get_environment("MT_PLAYABLE_ALLOW_GAMEPLAY") == "1"
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) _machine.capture = Callable(self, "_capture")
_bind("item_picked_up", Callable(self, "_on_item_picked_up")) _machine.finished.connect(_on_machine_finished)
_bind("inventory_changed", Callable(self, "_on_inventory_changed")) _probe.observed.connect(_machine.on_observed)
_set_state("WAIT_LOGIN") if suite == "soak":
_record("harness_started", {"allow_gameplay": _allow_gameplay}) _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: func _process(_delta: float) -> void:
if _finished: if _machine == null or _finished:
return return
var now := Time.get_ticks_msec() / 1000.0 _watch_scene_signals()
if now - _started > float(_config.get("timeout_seconds", 120)): if _metrics != null:
_fail_current("overall timeout in state %s" % _state) var tags: Dictionary = _machine.frame_tags()
_finish_later() 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 return
match _state: _machine.tick()
"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()
func _bind(signal_name: String, callback: Callable) -> void: func _mark_observed(kind: String, _data: Dictionary) -> void:
if _client.has_signal(signal_name) and not _client.is_connected(signal_name, callback): if kind in MARKED_KINDS:
_client.connect(signal_name, callback) _metrics.mark(kind)
func _set_state(next: String) -> void: ## Quickbar / skill fx live in GameScene and are rebuilt with it; re-bind lazily.
_state = next func _watch_scene_signals() -> void:
_state_since = Time.get_ticks_msec() / 1000.0 if _app_flow == null or not _app_flow.has_method("get_playable_context"):
_record("state", {"name": next})
func _on_observed(kind: String, payload: Dictionary) -> void:
_record(kind, payload)
func _record(kind: String, payload := {}) -> void:
if _report == null:
return return
var data: Dictionary = payload.duplicate(true) var context: Dictionary = _app_flow.get_playable_context()
data["monotonic_us"] = Time.get_ticks_usec() var quickbar: Object = context.get("quickbar", null)
data["run_id"] = OS.get_environment("MT_PLAYABLE_RUN_ID") if quickbar != null and is_instance_valid(quickbar) and quickbar != _watched_quickbar:
data["case_id"] = _state _watched_quickbar = quickbar
_report.add_event(data, _events_path) _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: func _capture(case_id: String) -> String:
_record("char_list", {"count": characters.size()}) if DisplayServer.get_name() == "headless" or _output_dir.is_empty():
var slot := int(_config.get("character_slot", -1)) return ""
var found := false var image := get_viewport().get_texture().get_image()
for character in characters: if image == null or image.is_empty():
if int(character.get("index", -1)) == slot: return ""
found = true var file_name := "%s.png" % case_id.to_lower()
break if image.save_png(_output_dir.path_join(file_name)) != OK:
if not found: return ""
_fail_case("NET-LOGIN-01", "configured character slot is absent") return file_name
func _on_machine_finished(_status: String) -> void:
_finish_later() _finish_later()
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 _finish_later() -> void: func _finish_later() -> void:
if _finished: if _finished:
return return
_finished = true _finished = true
_stop_attack() call_deferred("_write_and_quit")
_report.write(_report_path)
call_deferred("_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) get_tree().quit(0)
+136
View File
@@ -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
+64
View File
@@ -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 = READY1 = 矩阵错误,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
View File
@@ -56,6 +56,9 @@ var arrow_count := -1 # 装备箭袋数量;-1 未知/无限,
var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000 var slot_cd_end := 0.0 # 该槽本地冷却结束时刻(Time.get_ticks_msec()/1000
var cur_sp := -1 # GetStatus(POINT_SP)-1 = 未知(不判 NOT_ENOUGH_SP var cur_sp := -1 # GetStatus(POINT_SP)-1 = 未知(不判 NOT_ENOUGH_SP
var cur_hp := -1 # GetStatus(POINT_HP)-1 = 未知(不判 NOT_ENOUGH_HP 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: func setup(m2client: Object, skill_table: RefCounted) -> void:
client = m2client client = m2client
@@ -69,6 +72,24 @@ func _now() -> float:
func _ok(target_vid := 0) -> Dictionary: func _ok(target_vid := 0) -> Dictionary:
return {"ok": true, "code": "OK", "target_vid": target_vid} 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: func _no(code: String, target_vid := 0) -> Dictionary:
return {"ok": false, "code": code, "target_vid": target_vid} 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) return _resolve_target(skill_id)
# __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。 # __UseSkill 里的目标分支(PythonPlayerSkill.cpp:520-650)。
# 距离 / __ReserveUseSkill / 扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9 # 判目标「类型」是否合法并回传解析到的 vid;射程外回 RESERVED(带 range_cm / charge
# 这里只判目标「类型」是否合法并回传解析到的 vid # 由调用方 __ReserveUseSkill。扇形·圆形多目标 SendAddFlyTargetingPacket 归 §3.4/§3.9
func _resolve_target(skill_id: int) -> Dictionary: func _resolve_target(skill_id: int) -> Dictionary:
var main_vid := _main_vid() var main_vid := _main_vid()
var tvid := 0 var tvid := 0
@@ -389,6 +410,11 @@ func _resolve_target(skill_id: int) -> Dictionary:
return _ok(main_vid) return _ok(main_vid)
# 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法 # 非自己:结盟技打友方(不可攻击的 PC)合法;打敌人不合法
if _entity_kind(te) == 0 and not _is_attackable(te): 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) return _ok(tvid)
if table.can_use_for_me(skill_id): if table.can_use_for_me(skill_id):
return _ok(main_vid) # 目标非法 -> 回落自身 return _ok(main_vid) # 目标非法 -> 回落自身
@@ -398,6 +424,10 @@ func _resolve_target(skill_id: int) -> Dictionary:
if bool(te.get("in_safe", false)): if bool(te.get("in_safe", false)):
return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA") return _no("CANNOT_ATTACK_ENEMY_IN_SAFE_AREA")
if _is_attackable(te): if _is_attackable(te):
# __ProcessEnemySkillTargetRangePythonPlayerSkill.cpp:395
var far := _out_of_range(skill_id, tvid)
if not far.is_empty():
return far
return _ok(tvid) return _ok(tvid)
return _no("CANNOT_ATTACK") return _no("CANNOT_ATTACK")
+29
View File
@@ -56,6 +56,8 @@ func _reset(g: RefCounted) -> void:
g.slot_cd_end = 0.0 g.slot_cd_end = 0.0
g.cur_hp = -1 g.cur_hp = -1
g.cur_sp = -1 g.cur_sp = -1
g.bow_distance = 0
g.target_distance = Callable()
func _run() -> void: func _run() -> void:
var assets := AssetRoot.path() var assets := AssetRoot.path()
@@ -227,6 +229,33 @@ func _run() -> void:
fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC fc.target = {"vid": 2000}; fc.entities[2000] = {"kind": 1} # NPC
_ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK") _ck(g.use_skill(1).code == "CANNOT_ATTACK", "(d) 目标不可攻击 -> CANNOT_ATTACK")
fc.entities[2000] = {"kind": 2} fc.entities[2000] = {"kind": 2}
# __ProcessEnemySkillTargetRangedistance >= 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 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE # 结盟技 109 CureCAN_USE_FOR_ME|ONLY_FOR_ALLIANCE
if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109): if st.has(109) and st.is_only_for_alliance(109) and st.can_use_for_me(109):
_reset(g) _reset(g)
+35
View File
@@ -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)
+90 -9
View File
@@ -1,15 +1,19 @@
# Offline real-resource stress/viewport report; NOT 40250 visual parity or # Offline real-resource stress/viewport report; NOT 40250 visual parity or
# server reconnect acceptance. Run with a renderer, --quit-after 3600. # 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 extends SceneTree
const GameScene = preload("res://game_scene.gd") const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd") const Fixtures = preload("res://gamescene_test.gd")
const PlayerView = preload("res://ui/player_view.gd") const PlayerView = preload("res://ui/player_view.gd")
const MobView = preload("res://ui/mob_view.gd") const MobView = preload("res://ui/mob_view.gd")
const Metrics = preload("res://testing/playable_metrics.gd")
class ScenarioClient extends Fixtures.FakeClient: class ScenarioClient extends Fixtures.FakeClient:
signal warp(pos: Vector3, same_server: bool) signal warp(pos: Vector3, same_server: bool)
signal world_reset() signal world_reset()
var failures: Array[String] = [] var failures: Array[String] = []
var output := "" var output := ""
var metrics: RefCounted
func check(ok: bool, label: String) -> void: func check(ok: bool, label: String) -> void:
if not ok: if not ok:
failures.append(label) failures.append(label)
@@ -27,8 +31,13 @@ func run() -> void:
DirAccess.make_dir_recursive_absolute(output) DirAccess.make_dir_recursive_absolute(output)
root.size = Vector2i(1280, 720) root.size = Vector2i(1280, 720)
var report := {"scope": "offline real maps, CPU/GPU animated crowd and viewport layout; no server writes or 40250 parity", 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": [], "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() var client := ScenarioClient.new()
root.add_child(client) root.add_child(client)
var scene := GameScene.new() var scene := GameScene.new()
@@ -41,24 +50,32 @@ func run() -> void:
for i in 30: await process_frame for i in 30: await process_frame
check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded") check(scene._model_built and scene._map_loaded(), "A1 main character and world loaded")
var centre: Vector3 = scene.player.position 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"]: for mode in ["0", "1"]:
OS.set_environment("MTGODOT_GPUSKIN", mode) OS.set_environment("MTGODOT_GPUSKIN", mode)
var crowd := Node3D.new() var crowd := Node3D.new()
scene.add_child(crowd) scene.add_child(crowd)
for count in [1, 8, 16]: for count in [1, 8, 16]:
_tags("OutdoorA1", "loading", count, mode == "1")
while crowd.get_child_count() < count: while crowd.get_child_count() < count:
var index := crowd.get_child_count() var index := crowd.get_child_count()
var view := PlayerView.new() var view := PlayerView.new()
var build_start := Time.get_ticks_usec() var build_start := Time.get_ticks_usec()
check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8)) check(view.build(AssetRoot.path(), index % 8), "crowd race %d build" % (index % 8))
report.model_builds.append({"gpu_skin": mode == "1", "race": index % 8, var build_ms := (Time.get_ticks_usec() - build_start) / 1000.0
"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"}) "note": "synchronous build only; OS/shader caches may already be warm"})
crowd.add_child(view) crowd.add_child(view)
view.position = centre + Vector3((index % 4 - 1.5) * 2, 0, (index / 4 + 1) * 2) 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.position.y = scene.world.sample_height(view.position.x, view.position.z)
view.set_anim_state("run") 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 times: Array[float] = []
var switches: Array[float] = [] var switches: Array[float] = []
var memory_start := Performance.get_monitor(Performance.MEMORY_STATIC) 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] var state: String = ["run", "attack", "wait"][(i / 30) % 3]
for view in crowd.get_children(): view.set_anim_state(state) for view in crowd.get_children(): view.set_anim_state(state)
switches.append((Time.get_ticks_usec() - switch_start) / 1000.0) switches.append((Time.get_ticks_usec() - switch_start) / 1000.0)
metrics.mark("motion_switch", {"state": state, "count": count, "ms": switches[-1]})
await process_frame await process_frame
metrics.frame()
var now := Time.get_ticks_usec() var now := Time.get_ticks_usec()
times.append((now - previous) / 1000.0) times.append((now - previous) / 1000.0)
previous = now previous = now
@@ -79,21 +98,32 @@ func run() -> void:
"finite posed bounds after run/attack/wait blend") "finite posed bounds after run/attack/wait blend")
times.sort() times.sort()
switches.sort() switches.sort()
var sorted := PackedFloat32Array(times)
report.samples.append({"gpu_skin": mode == "1", "extra_players": count, 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], "motion_switches": switches.size(), "motion_switch_max_ms": switches[-1],
"memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC), "memory_static_start": memory_start, "memory_static_end": Performance.get_monitor(Performance.MEMORY_STATIC),
"clip_cache": Metin2AnimPlayer.get_clip_cache_stats(), "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}) "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() crowd.free()
await process_frame await process_frame
metrics.frame()
OS.set_environment("MTGODOT_GPUSKIN", "0") OS.set_environment("MTGODOT_GPUSKIN", "0")
var mobs := Node3D.new() var mobs := Node3D.new()
scene.add_child(mobs) scene.add_child(mobs)
_tags("OutdoorA1", "loading", 3, false)
for race in [101, 110, 20001]: for race in [101, 110, 20001]:
var view := MobView.new() 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) check(false, "NPC/monster build race=%d" % race)
view.free() view.free()
continue continue
@@ -102,9 +132,17 @@ func run() -> void:
view.position.y = scene.world.sample_height(view.position.x, view.position.z) view.position.y = scene.world.sample_height(view.position.x, view.position.z)
view.set_anim_state("wait") view.set_anim_state("wait")
check(not view.model.get_visual_aabb().size.is_zero_approx(), "NPC/monster real geometry") 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)]: for size in [Vector2i(1280, 720), Vector2i(1920, 1080), Vector2i(2560, 1440)]:
root.size = size 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: if scene.quickbar and scene.quickbar._root:
var rect: Rect2 = scene.quickbar._root.get_global_rect() 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) 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. # Late server location update exercises the production map-correction path.
client.ents[1000].pos_cm = Vector2(959109, 269267) client.ents[1000].pos_cm = Vector2(959109, 269267)
client.ents[1000].pos = Vector3(9591.09, 0, -2692.67) 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.world_reset.emit()
client.warp.emit(client.ents[1000].pos, false) 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") check(scene.map_path == "OutdoorC1/metin2_map_c1" and scene._map_loaded(), "A1 to C1 map correction")
report["final_map"] = scene.map_path 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() scene.free()
client.free() client.free()
await process_frame 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")) 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]) print("rendering_scenario_test: failures=%d report=%s" % [failures.size(), output])
quit(1 if not failures.is_empty() else 0) 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"}
+42
View File
@@ -8,6 +8,17 @@ const Quickbar = preload("res://ui/quickbar.gd")
const UiAssets = preload("res://ui/ui_assets.gd") const UiAssets = preload("res://ui/ui_assets.gd")
const UiManager = preload("res://ui/ui_manager.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: class FakeClient extends Node:
signal skills_changed() signal skills_changed()
signal skill_cooldown_end(skill: int) signal skill_cooldown_end(skill: int)
@@ -327,6 +338,37 @@ func _run() -> void:
"mobile skill action assigns to first free quickbar slot") "mobile skill action assigns to first free quickbar slot")
sk.close() 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 表情 -> 填格 # 服务器 GC_QUICKSLOT_* 恢复:type 2 技能 / type 1 道具 / type 3 表情 -> 填格
var qb2: Node = Quickbar.new() var qb2: Node = Quickbar.new()
get_root().add_child(qb2) get_root().add_child(qb2)
+21
View File
@@ -54,6 +54,27 @@ func run() -> void:
target.free() target.free()
game._on_local_motion_event_detailed({"type": 10, "effect": "full/path.mse", "sound": "", "pos": Vector3.ZERO}) 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") 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() game.free()
effect.free() effect.free()
await process_frame await process_frame
+195
View File
@@ -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)))
+393 -32
View File
@@ -12,6 +12,17 @@ const REQUIRED_KEYS := [
"waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases", "waypoints_cm", "allowed_mob_vnums", "allowed_drop_vnums", "skill_cases",
"resolution", "loops", "timeout_seconds", "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: static func load_file(path: String) -> Dictionary:
var result := {"ok": false, "config": {}, "errors": []} var result := {"ok": false, "config": {}, "errors": []}
@@ -41,7 +52,7 @@ static func validate(config: Dictionary) -> Dictionary:
for key in REQUIRED_KEYS: for key in REQUIRED_KEYS:
if not config.has(key): if not config.has(key):
errors.append("missing required key: %s" % 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) errors.append("schema_version must be %d" % SCHEMA_VERSION)
if String(config.get("scenario_id", "")).strip_edges().is_empty(): if String(config.get("scenario_id", "")).strip_edges().is_empty():
errors.append("scenario_id must not be empty") errors.append("scenario_id must not be empty")
@@ -52,21 +63,35 @@ static func validate(config: Dictionary) -> Dictionary:
if not (server is Dictionary): if not (server is Dictionary):
errors.append("server must be an object selected from serverinfo") errors.append("server must be an object selected from serverinfo")
else: else:
for key in ["server_index", "channel_index"]: for key in SERVER_KEYS:
if not server.has(key) or int(server[key]) < 0: if not server.has(key):
errors.append("server.%s must be a non-negative integer" % key) errors.append("server.%s is missing; copy the address selected from serverinfo" % key)
if not config.has("character_slot") or int(config.get("character_slot", -1)) < 0: 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") 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") 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) var waypoints: Variant = config.get("waypoints_cm", null)
if not (waypoints is Array) or waypoints.is_empty(): if not (waypoints is Array) or waypoints.is_empty():
errors.append("waypoints_cm must contain at least one [x,y] centimeter point") errors.append("waypoints_cm must contain at least one [x,y] centimeter point")
else: else:
for i in waypoints.size(): for i in waypoints.size():
var point: Variant = waypoints[i] 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]): if not (point is Array) or point.size() != 2 or not _is_int(point[0]) or not _is_int(point[1]) \
errors.append("waypoints_cm[%d] must be [x,y] numbers" % i) 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) var mobs: Variant = config.get("allowed_mob_vnums", null)
if not (mobs is Array) or mobs.is_empty(): if not (mobs is Array) or mobs.is_empty():
errors.append("allowed_mob_vnums must contain confirmed server mob vnums") 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") errors.append("allowed_drop_vnums must contain a deterministic pickup fixture")
else: else:
_validate_positive_int_array(drops, "allowed_drop_vnums", errors) _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(): if not (skills is Array) or skills.is_empty():
errors.append("skill_cases must contain at least one confirmed skill case") errors.append("skill_cases must contain at least one confirmed skill case")
else: else:
var seen := {}
for i in skills.size(): for i in skills.size():
if not (skills[i] is Dictionary): if not (skills[i] is Dictionary):
errors.append("skill_cases[%d] must be an object" % i) 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"]: for key in ["case_id", "skill_id", "required_evidence"]:
if not item.has(key): if not item.has(key):
errors.append("skill_cases[%d] missing %s" % [i, 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) 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) 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) errors.append("skill_cases[%d].required_evidence must be non-empty" % i)
var resolution: Variant = config.get("resolution", null) continue
if not (resolution is Array) or resolution.size() != 2 or int(resolution[0]) <= 0 or int(resolution[1]) <= 0: var has_server := false
errors.append("resolution must be [width,height]") for kind in evidence:
if int(config.get("loops", 0)) < 1: var name := String(kind)
errors.append("loops must be at least 1") if name in SERVER_EVIDENCE:
if int(config.get("timeout_seconds", 0)) < 1: has_server = true
errors.append("timeout_seconds must be positive") elif not (name in LOCAL_EVIDENCE):
# This config is deliberately data-only. Reject the common accidental secret paths. errors.append("skill_cases[%d].required_evidence has unknown kind: %s" % [i, name])
for forbidden in ["account", "username", "password", "token", "secret"]: if not has_server:
if config.has(forbidden): errors.append("skill_cases[%d].required_evidence needs at least one server-side kind %s" % [i, SERVER_EVIDENCE])
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
File diff suppressed because it is too large Load Diff
+292
View File
@@ -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_skinactor_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)
+173 -50
View File
@@ -3,113 +3,236 @@ extends Node
## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入, ## 只读网络/场景观察器。所有服务端证据必须从 M2Client 信号或状态快照进入,
## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。 ## 本节点不调用移动、攻击、拾取、施法或其它会改变服务器状态的方法。
##
## 每条观察都带 source
## "server" —— M2Client 接收端派发(EntityStore 变化 / 收包回调)
## "local" —— 本地 UI/表现层信号(施法开始、特效节点),不能单独作为服务端证据
##
## connection_epoch:首个连接为 1;收到 disconnected 或在同一 epoch 内再次
## entered_game(快速重连 / 换服不一定发 disconnected)时加一。VID 只在其 epoch
## 内有意义,状态机必须按新 epoch 重新绑定主角与目标。
signal observed(kind: String, payload: Dictionary) signal observed(kind: String, payload: Dictionary)
var client: Node const SERVER_SIGNALS := {
var flow: Node "entered_game": "_on_entered_game",
var connection_epoch := 0 "disconnected": "_on_disconnected",
var _connections: Array[Dictionary] = [] "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 flow = app_flow
client = m2client client = m2client
if client == null: if client == null:
return return
_bind("entered_game", Callable(self, "_on_entered_game")) for signal_name in SERVER_SIGNALS:
_bind("disconnected", Callable(self, "_on_disconnected")) _bind(client, signal_name, Callable(self, SERVER_SIGNALS[signal_name]))
_bind("char_list", Callable(self, "_on_char_list"))
_bind("entity_spawned", Callable(self, "_on_entity_spawned")) ## Subscribe to a local presentation object (quickbar / skill fx). Its events
_bind("entity_despawned", Callable(self, "_on_entity_despawned")) ## are tagged source=local and never satisfy a server-evidence requirement.
_bind("entity_moved", Callable(self, "_on_entity_moved")) func watch_local(source: Object, signal_name: String, kind: String) -> void:
_bind("entity_dead", Callable(self, "_on_entity_dead")) if source == null or not source.has_signal(signal_name):
_bind("damage", Callable(self, "_on_damage")) return
_bind("vitals_changed", Callable(self, "_on_vitals")) var callback := func(a: Variant = null, b: Variant = null, c: Variant = null,
_bind("ground_item_added", Callable(self, "_on_ground_item_added")) d: Variant = null, e: Variant = null) -> void:
_bind("ground_item_removed", Callable(self, "_on_ground_item_removed")) _emit_local(kind, [a, b, c, d, e])
_bind("item_picked_up", Callable(self, "_on_item_picked_up")) _bind(source, signal_name, callback)
_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"))
func snapshot() -> Dictionary: func snapshot() -> Dictionary:
var in_game: bool = client != null and client.has_method("is_in_game") and client.is_in_game() var alive := client != null and is_instance_valid(client)
var main_vid := int(client.get_main_vid()) if client != null and client.has_method("get_main_vid") else 0 var in_game: bool = alive and client.has_method("is_in_game") and client.is_in_game()
var entity_count: int = client.get_entities().size() if client != null and client.has_method("get_entities") else 0 var main_vid := int(client.get_main_vid()) if alive and client.has_method("get_main_vid") else 0
var ground_count: int = client.get_ground_items().size() if client != null and client.has_method("get_ground_items") else 0 var entity_count: int = client.get_entities().size() if alive and client.has_method("get_entities") else 0
var state := int(flow.state()) if flow != null and flow.has_method("state") else -1 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, 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: func disconnect_all() -> void:
for entry in _connections: for entry in _connections:
var source: Object = entry.source
var signal_name: String = entry.signal var signal_name: String = entry.signal
var callback: Callable = entry.callback var callback: Callable = entry.callback
if client != null and client.has_signal(signal_name) and client.is_connected(signal_name, callback): if source != null and is_instance_valid(source) and source.has_signal(signal_name) \
client.disconnect(signal_name, callback) and source.is_connected(signal_name, callback):
source.disconnect(signal_name, callback)
_connections.clear() _connections.clear()
func bound_count() -> int:
return _connections.size()
func _exit_tree() -> void: func _exit_tree() -> void:
disconnect_all() disconnect_all()
func _bind(signal_name: String, callback: Callable) -> void: func _bind(source: Object, signal_name: String, callback: Callable) -> void:
if client.has_signal(signal_name) and not client.is_connected(signal_name, callback): if source.has_signal(signal_name) and not source.is_connected(signal_name, callback):
client.connect(signal_name, callback) source.connect(signal_name, callback)
_connections.append({"signal": signal_name, "callback": callback}) _connections.append({"source": source, "signal": signal_name, "callback": callback})
func _emit(kind: String, payload := {}) -> void: func _emit(kind: String, payload := {}, actor_vid := 0, target_vid := 0) -> void:
var data: Dictionary = payload.duplicate(true) var data: Dictionary = payload.duplicate(true)
data["source"] = "server"
data["connection_epoch"] = connection_epoch data["connection_epoch"] = connection_epoch
data["actor_vid"] = actor_vid
data["target_vid"] = target_vid
data["received_us"] = Time.get_ticks_usec() data["received_us"] = Time.get_ticks_usec()
observed.emit(kind, data) 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: func _on_entered_game() -> void:
if _entered_in_epoch:
connection_epoch += 1 connection_epoch += 1
_entered_in_epoch = true
_last_sp = -1
_emit("entered_game") _emit("entered_game")
func _on_disconnected(reason: String) -> void: 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: 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: 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: func _on_entity_despawned(vid: int) -> void:
_emit("entity_despawned", {"vid": vid}) _emit("entity_despawned", {}, vid)
func _on_entity_moved(vid: int) -> void: 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: 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: 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: 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: 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: 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: 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: func _on_inventory_changed(window: int, cell: int) -> void:
_emit("inventory_changed", {"window": window, "cell": cell}) _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: 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: 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: 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, _emit("fly_targeting", {"pos_cm": target_cm}, shooter_vid, target_vid)
"target_cm": [target_cm.x, target_cm.y], "append": append})
## 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)
+142 -16
View File
@@ -2,15 +2,54 @@ class_name PlayableReport
## 联网测试进程内的报告构建器。 ## 联网测试进程内的报告构建器。
## 只写非敏感的 client-report.json;最终 process/退出日志门禁由父脚本补齐。 ## 只写非敏感的 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 SCHEMA_VERSION := 1
const VALID_STATUSES := ["PASS", "FAIL", "BLOCKED", "SKIP"] 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 _report: Dictionary = {}
var _started_us := 0 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() _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 = { _report = {
"schema_version": SCHEMA_VERSION, "schema_version": SCHEMA_VERSION,
"run_id": run_id, "run_id": run_id,
@@ -19,7 +58,9 @@ func begin(run_id: String, suite: String, config: Dictionary) -> void:
"build": _build_info(), "build": _build_info(),
"environment": _environment_info(), "environment": _environment_info(),
"cases": [], "cases": [],
"events": "events.jsonl", "required_cases": [],
"events": events_path.get_file() if not events_path.is_empty() else "",
"event_count": 0,
"failures": [], "failures": [],
"blocked": [], "blocked": [],
"coverage": {"required": 0, "passed": 0}, "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. # Keep configuration useful for reproducing a run without copying any secret.
_report["scenario_id"] = String(config.get("scenario_id", "")) _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: 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() var normalized := status.to_upper()
if not VALID_STATUSES.has(normalized): if not VALID_STATUSES.has(normalized):
normalized = "FAIL" normalized = "FAIL"
_fail("invalid case status for %s" % case_id) _fail("invalid case status for %s" % case_id)
var item := {"id": case_id, "status": normalized, "duration_ms": duration_ms, 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.cases.append(item)
_report.coverage.required = int(_report.coverage.required) + 1 _report.coverage.required = int(_report.coverage.required) + 1
if normalized == "PASS": if normalized == "PASS":
_report.coverage.passed = int(_report.coverage.passed) + 1 _report.coverage.passed = int(_report.coverage.passed) + 1
elif normalized == "FAIL": 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": 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: ## A run-level failure that belongs to no open case (e.g. an effect from an
var safe: Variant = _sanitize(event) ## already-passed case outliving its lifetime). Any failure makes the run FAIL.
var file := FileAccess.open(events_path, FileAccess.READ_WRITE) 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: if file == null:
file = FileAccess.open(events_path, FileAccess.WRITE) file = FileAccess.open(_events_path, FileAccess.WRITE)
if file == null: if file == null:
_fail("cannot open events file") _fail("cannot open events file")
return return event
file.seek_end() file.seek_end()
file.store_line(JSON.stringify(safe)) file.store_line(JSON.stringify(event))
file.close() file.close()
return event
func finish() -> Dictionary: func finish() -> Dictionary:
if _report.is_empty(): if _report.is_empty():
return {} 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 required := int(_report.coverage.required)
var passed := int(_report.coverage.passed) var passed := int(_report.coverage.passed)
var failures: Array = _report.failures var failures: Array = _report.failures
@@ -68,6 +169,8 @@ func finish() -> Dictionary:
_report.status = "BLOCKED" _report.status = "BLOCKED"
else: else:
_report.status = "PASS" _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 _report.duration_seconds = float(Time.get_ticks_usec() - _started_us) / 1000000.0
return _report return _report
@@ -83,7 +186,7 @@ func write(path: String) -> bool:
return true return true
func _fail(message: String) -> void: func _fail(message: String) -> void:
_report.failures.append(message) _report.failures.append(_redact_text(message))
func _build_info() -> Dictionary: func _build_info() -> Dictionary:
return { return {
@@ -94,19 +197,40 @@ func _build_info() -> Dictionary:
} }
func _environment_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 { return {
"os": OS.get_name(), "os": OS.get_name(),
"renderer": RenderingServer.get_video_adapter_name(), "renderer": RenderingServer.get_video_adapter_name(),
"resolution": [size.x, size.y], "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: func _sanitize(value: Variant) -> Variant:
if value is Dictionary: if value is Dictionary:
var out := {} var out := {}
for key in value: for key in value:
var name := String(key).to_lower() var name := String(key).to_lower()
if name in ["password", "passwd", "token", "secret", "account"]: if name in SECRET_KEYS:
out[key] = "[redacted]" out[key] = "[redacted]"
else: else:
out[key] = _sanitize(value[key]) out[key] = _sanitize(value[key])
@@ -116,9 +240,11 @@ func _sanitize(value: Variant) -> Variant:
for item in value: for item in value:
out_array.append(_sanitize(item)) out_array.append(_sanitize(item))
return out_array 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] return [value.x, value.y]
if value is Vector3: if value is Vector3 or value is Vector3i:
return [value.x, value.y, value.z] return [value.x, value.y, value.z]
if value is Object: if value is Object:
return "[object]" return "[object]"
+65
View File
@@ -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
View File
@@ -28,6 +28,7 @@ var anim: Node # Metin2AnimPlayer
var _dir := "" var _dir := ""
var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径 var _motions := {} # MOTION_NAME(String) -> 绝对 .msa 路径
var _state := "" var _state := ""
var _last_resolution := {}
var _audio: Node var _audio: Node
var _sound_instances: Array = [] var _sound_instances: Array = []
var _sound_frame := -1 var _sound_frame := -1
@@ -125,8 +126,10 @@ func set_anim_state(s: String) -> void:
if s == _state or anim == null: if s == _state or anim == null:
return return
_state = s _state = s
var msa := _motion_for(s) _last_resolution = resolve_motion(s)
var msa := String(_last_resolution.path)
if msa == "": if msa == "":
# 旧客户端 GetMotionKey 失败时 SetLoopMotion/InterceptMotion 直接返回:保留当前动作。
return return
anim.set("loop", s in ["wait", "walk", "run"]) anim.set("loop", s in ["wait", "walk", "run"])
anim.set("anim_path", msa) 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 # the selected .msa before playback lets the test distinguish a real action
# from a silent "keep the previous animation" fallback. # from a silent "keep the previous animation" fallback.
func get_motion_path(state: String) -> String: 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: func _process(_delta: float) -> void:
if _audio == null or anim == null or _sound_instances.is_empty() or not anim.has_method("get_time") \ 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) out.append(c)
return out 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: func _load_motlist() -> void:
var ml := _dir.path_join("motlist.txt") var ml := _dir.path_join("motlist.txt")
if not FileAccess.file_exists(ml): if not FileAccess.file_exists(ml):
+15 -1
View File
@@ -47,6 +47,7 @@ var _slots := [] # 当前页 UI[{btn, cd, lbl, icon, grade}]
var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}] var _state := [] # 36 个服务端槽:[{kind, id, cd_end:float}]
var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session var _skill_cooldowns := {} # skill identity survives slot moves/restores within this UI session
var _page := 0 var _page := 0
var _last_activation_reserved := false # 最近一次激活落到 __ReserveUseSkill(射程外)
var _move_from := -1 var _move_from := -1
var _mobile_mode := false 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): elif not PlayerSkill.is_silent_code(fishing_result):
skill_rejected.emit(int(s.id), fishing_result) skill_rejected.emit(int(s.id), fishing_result)
return 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 code == "TOGGLE_OFF":
if client.has_method("use_skill"): if client.has_method("use_skill"):
client.use_skill(int(s.id), 0) client.use_skill(int(s.id), 0)
@@ -284,8 +296,10 @@ func activate_reserved(global_slot: int) -> bool:
var s: Dictionary = _state[global_slot] var s: Dictionary = _state[global_slot]
if s.kind != "skill" or s.id == 0 or _now() < s.cd_end: if s.kind != "skill" or s.id == 0 or _now() < s.cd_end:
return false return false
_last_activation_reserved = false
activate(local) activate(local)
return true # 再次落到射程外时新预约已建立,不能让 NetPlay 把它当作已消费清掉。
return not _last_activation_reserved
# client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。 # client.get_skills() 里该技能的当前等级(quickbar 施法时算 target_count 用)。
func _skill_level(sid: int) -> int: func _skill_level(sid: int) -> int:
+404 -89
View File
@@ -1,130 +1,445 @@
#!/usr/bin/env node #!/usr/bin/env node
/* Static prerequisite audit for the first playable-map fixture. It does not /* Static prerequisite audit for the first playable-map fixture (FIRST-MAC-PLAYABLE §8.1).
* connect to a server and cannot prove which monsters a live server spawned. */ * 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import crypto from 'node:crypto'; 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) { 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) { for (let i = 0; i < argv.length; i += 1) {
const a = argv[i]; 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 === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
if (!a.startsWith('--') || i + 1 >= argv.length) throw new Error(`invalid argument: ${a}`); if (!a.startsWith('--') || i + 1 >= argv.length) throw new Blocked(`invalid argument: ${a}`);
const k = a.slice(2).replaceAll('-', '_'); const k = a.slice(2).replaceAll('-', '_');
if (!(k in out)) throw new Blocked(`unknown option: ${a}`);
out[k] = argv[++i]; 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; return out;
} }
function filesUnder(root) { const posix = value => value.replaceAll('\\', '/');
if (!fs.existsSync(root)) return []; const read = file => fs.readFileSync(file, 'latin1');
const result = []; const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
// 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 => { const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name); const full = path.join(dir, entry.name);
if (entry.isDirectory()) visit(full); else result.push(full); if (entry.isDirectory()) visit(full); else files.push(posix(path.relative(root, full)));
} }
}; };
visit(root); if (fs.existsSync(root)) visit(root);
return result; files.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
} for (const rel of files) {
const lower = rel.toLowerCase();
const norm = value => value.replaceAll('\\', '/').toLowerCase().replace(/^.*?ymir work\//, 'ymir work/'); if (!this.byRel.has(lower)) this.byRel.set(lower, rel);
const read = file => fs.readFileSync(file, 'utf8'); const at = lower.indexOf('ymir work/');
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); if (at >= 0) {
const key = lower.slice(at + 'ymir work/'.length);
function parseProperties(allFiles) { if (!this.byYmir.has(key)) this.byYmir.set(key, rel);
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 });
} }
return byId; }
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();
}
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) { function parseKeyValues(text) {
const text = read(file); 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 objects = [];
const blockRe = /Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g; for (const match of text.matchAll(/Start Object\d+\r?\n([^]*?)\r?\nEnd Object/g)) {
for (const match of text.matchAll(blockRe)) {
const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean); const lines = match[1].split(/\r?\n/).map(s => s.trim()).filter(Boolean);
if (lines.length < 2) continue; if (lines.length < 2 || !/^\d+$/.test(lines[1])) continue;
const id = Number(lines[1]); objects.push({ crc: Number(lines[1]), position: lines[0] });
if (Number.isInteger(id)) objects.push({ property_id: id, position: lines[0] });
} }
return objects; return objects;
} }
function parseNpcList(file) { 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 entries;
}
function parseNpcList(index) {
const result = new Map(); const result = new Map();
if (!fs.existsSync(file)) return result; for (const candidate of ['root/npclist.txt', 'npclist.txt', 'locale/npclist.txt']) {
for (const line of read(file).split(/\r?\n/)) { 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+/); const parts = line.trim().split(/\s+/);
if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]); if (parts.length >= 2 && /^\d+$/.test(parts[0])) result.set(Number(parts[0]), parts[1]);
} }
return result; return { file: rel, entries: result };
}
return { file: '', entries: result };
} }
function mobRecord(assets, code, race, allFiles) { function auditMap(index, registries, key, source, missing) {
const candidates = allFiles.filter(file => file.toLowerCase().endsWith('.msm') && path.basename(file, '.msm').toLowerCase() === code.toLowerCase()) const note = (kind, ref, reason, extra = {}) => missing.push({ scope: `map:${key}`, kind, ref, reason, ...extra });
.filter(file => file.toLowerCase().includes(`/monster2/`) || file.toLowerCase().includes(`/monster/`)); const rootRel = index.rel(path.posix.join(key, 'setting.txt'));
const msm = candidates[0] || ''; const map = { key, source, exists: false, setting: '', setting_values: {}, tiles: [], texture_set: null,
const dir = msm ? path.dirname(msm) : ''; environment: null, regen: null, object_count: 0, static_spt: [], static_gr2: [], effects: [], ambience: [],
const sibling = name => dir && fs.existsSync(path.join(dir, name)) ? path.join(dir, name) : ''; unresolved_properties: [], property_count: 0 };
const motlist = sibling('motlist.txt'); if (!rootRel) {
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])) })) : []; note('map_setting', `${key}/Setting.txt`, 'map directory or Setting.txt not found under the asset root');
return { return map;
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) }, map.exists = true;
action_count: actions.length, actions, map.setting = rootRel;
required_states: Object.fromEntries(['WAIT', 'RUN', 'NORMAL_ATTACK', 'FRONT_DAMAGE', 'FRONT_DEAD'].map(state => [state, actions.some(a => a.name === state)])), const dirRel = path.posix.dirname(rootRel);
source_sha256: msm ? sha256(msm) : null, 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() { function main() {
const options = parseArgs(process.argv.slice(2)); const options = parseArgs(process.argv.slice(2));
const assets = path.resolve(options.assets); const assets = path.resolve(options.assets);
const allFiles = filesUnder(assets); if (!fs.existsSync(assets)) throw new Blocked(`asset root not found: ${assets}`);
const manifestPath = path.join(assets, 'TreeGeometry', 'manifest.json'); let mapSource = 'default';
const manifest = fs.existsSync(manifestPath) ? JSON.parse(read(manifestPath)) : { trees: {} }; let maps = DEFAULT_MAPS;
const sourceByName = new Map(Object.values(manifest.trees || {}).map(item => [norm(item.source), item])); let scenarioId = '';
const properties = parseProperties(allFiles); if (options.config) {
const maps = options.maps.map(key => { const fromConfig = mapsFromConfig(options.config);
const root = path.join(assets, key); maps = fromConfig.maps;
const areaFiles = filesUnder(root).filter(f => path.basename(f).toLowerCase() === 'areadata.txt'); scenarioId = fromConfig.scenario_id;
const objects = areaFiles.flatMap(file => parseArea(file).map(item => ({ ...item, file: path.relative(assets, file).replaceAll('\\', '/') }))); mapSource = 'config';
const propertyIds = [...new Set(objects.map(item => item.property_id))]; } else if (options.maps) {
const resolved = propertyIds.map(id => { maps = options.maps.split(',').map(s => s.trim()).filter(Boolean);
const prop = properties.get(String(id)); mapSource = 'argument';
if (!prop) return { property_id: id, resolved: false }; }
const record = sourceByName.get(norm(prop.treefile)); const index = new AssetIndex(assets);
return { property_id: id, resolved: true, property_type: prop.type, property_file: path.relative(assets, prop.file).replaceAll('\\', '/'), const registries = propertyRegistries(index);
treefile: prop.treefile, native_tree: Boolean(record), source_sha256: record?.source_sha256 || null }; const missing = [];
}); const explained = [];
const treeObjects = objects.filter(item => resolved.find(r => r.property_id === item.property_id)?.native_tree); const mapReports = maps.map(key => auditMap(index, registries, key, mapSource, missing));
return { key, exists: fs.existsSync(root), setting: fs.existsSync(path.join(root, 'setting.txt')), const npc = parseNpcList(index);
area_files: areaFiles.length, object_count: objects.length, property_count: propertyIds.length, if (!npc.file) missing.push({ scope: 'global', kind: 'npclist', ref: 'root/npclist.txt', reason: 'npclist not found' });
resolved_properties: resolved.length, unresolved_property_ids: resolved.filter(r => !r.resolved).map(r => r.property_id), const races = options.races ? options.races.split(',').map(Number).filter(Number.isInteger)
tree_object_count: treeObjects.length, native_tree_object_count: treeObjects.length, : [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.entries.has(race));
properties: resolved }; 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 npc = parseNpcList(path.join(assets, 'root/npclist.txt'));
const treeMobRaces = [...Array(15)].map((_, i) => 2301 + i).filter(race => npc.has(race)); const report = {
const mobs = treeMobRaces.map(race => mobRecord(assets, npc.get(race), race, allFiles)); schema_version: 2, suite: 'playable-map-assets', generated_at: new Date().toISOString(),
const report = { schema_version: 1, suite: 'playable-map-assets', assets_root: assets, assets_root: assets, map_source: mapSource, scenario_id: scenarioId, npclist: npc.file,
maps, tree_monster_candidates: mobs, property_registry: { runtime_properties: registries.runtime.size, outside_runtime_scope: registries.outside.size, crc_collisions: registries.collisions },
caveat: 'Static map AreaData and local resources only; server spawn membership and visual correctness require live/map-render tests.' }; maps: mapReports, tree_monster_candidates: mobs,
if (options.output) { fs.mkdirSync(path.dirname(path.resolve(options.output)), { recursive: true }); fs.writeFileSync(options.output, `${JSON.stringify(report, null, 2)}\n`); } missing, explained,
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 })); status: missing.length === 0 ? 'PASS' : 'FAIL',
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; 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;
}
}
+103
View File
@@ -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;
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# MAP-02: run the packaged client in MT_TEST_MODE=forest_render and seal its report.
#
# - Offline: no server, no credentials. MT_ACCOUNT/MT_PASSWORD are removed from the
# child environment; nothing here reads or writes them. Do not add `set -x`.
# - Owns exactly one child PID; TERM, 10 s grace, KILL, then wait. Never pkills.
# - Maps come only from --maps or the config map_key (filled from the
# audit_playable_maps result); no map path is guessed here.
# - The client writes client-report.json/events/screenshots; this script writes
# report.json only after the real process exit and the full-log gate.
# - Metal screenshot sign-off stays a separate manual release item.
#
# exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app_path=""
maps=""
config_path=""
viewpoints=""
races=""
output_dir=""
timeout_seconds=900
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
kill_grace_seconds=10
suite="forest_render"
usage() {
cat <<'EOF'
用法: script/forest_map_render_test.sh --app APP (--maps KEYS | --config CONFIG) [选项]
选项:
--app APP 已签名的 arm64 包(build/export/mtgodot-poc.app);新测试模式必须重新构建后运行
--maps K1[,K2] map-assets.json 里的 map_key,逗号分隔(优先于 --config 的 map_key
--config FILE playable 配置;只读取 map_key/scenario_id
--viewpoints FILE 机位夹具(test/playable/forest-viewpoints.<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=$?
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"
+27 -5
View File
@@ -8,7 +8,10 @@
# ./script/live_smoke_test.sh --no-reconnect # ./script/live_smoke_test.sh --no-reconnect
# #
# 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。 # 账号和密码优先从环境变量读取;未提供时安全地交互输入,不写入脚本。
# 服务器地址与客户端一样经 project/net/serverinfo.gd 解析,不在脚本里写死。
# 不要在本脚本中启用 set -x。
set -euo pipefail set -euo pipefail
set +x
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$SCRIPT_DIR/.." && pwd)" REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -37,6 +40,7 @@ usage() {
环境变量: 环境变量:
MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入 MT_ACCOUNT / MT_PASSWORD 可预先提供账号密码,未提供时交互输入
MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色 MT_CHAR_SLOT=N 指定角色槽位;不设置时自动选择第一个角色
MT_GODOT 解析 serverinfo 用的 godot,默认 godot
EOF EOF
} }
@@ -61,7 +65,8 @@ if [ -f "$CONFIG_FILE" ]; then
fi fi
if [ -z "${MT_ACCOUNT:-}" ]; then if [ -z "${MT_ACCOUNT:-}" ]; then
read -r -p "账号: " MT_ACCOUNT read -r -s -p "账号: " MT_ACCOUNT
echo
fi fi
if [ -z "${MT_PASSWORD:-}" ]; then if [ -z "${MT_PASSWORD:-}" ]; then
read -r -s -p "密码: " MT_PASSWORD read -r -s -p "密码: " MT_PASSWORD
@@ -85,9 +90,19 @@ if [ ! -x "$EXECUTABLE" ]; then
fi fi
echo "== check server ports ==" echo "== check server ports =="
for port in 11000 13002; do # 登录页默认 server 0 / 第一个频道;与 AppFlow 同一 ServerInfo 解析。
if ! nc -G 3 -z 192.168.21.203 "$port" >/dev/null 2>&1; then ADDRESS_LINE="$("${MT_GODOT:-godot}" --headless --path project --script print_server_address.gd 2>/dev/null | grep '^SERVER_ADDRESS ' || true)"
echo "服务器端口不可达:192.168.21.203:$port" >&2 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 exit 3
fi fi
done done
@@ -111,7 +126,8 @@ echo "== run live smoke =="
echo "log: $LOG" echo "log: $LOG"
echo "report: $REPORT" echo "report: $REPORT"
set +e 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]} APP_STATUS=${PIPESTATUS[0]}
set -e set -e
@@ -140,6 +156,12 @@ if [ "$APP_STATUS" -ne 0 ]; then
exit "$APP_STATUS" exit "$APP_STATUS"
fi 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 if [ "$REPORT_STATUS" != "PASS" ]; then
echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2 echo "LIVE_SMOKE RESULT: FAIL (report status=$REPORT_STATUS)" >&2
exit 1 exit 1
+2 -2
View File
@@ -10,8 +10,8 @@ mkdir -p "$output_dir"
status=0 status=0
env -u MT_ASSETS MT_TEST_MODE=render MT_RENDER_OUTPUT="$output_dir" \ 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=$? "$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" || \ if [ "$status" -ne 0 ] || ! grep -qE '^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 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" echo "FAIL: package load/exit gate (exit=$status): $output_dir/client.log"
exit 1 exit 1
fi fi
+26
View File
@@ -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"
-14
View File
@@ -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)
+225
View File
@@ -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();
+145
View File
@@ -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;
+394 -29
View File
@@ -1,44 +1,409 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# INF-02 negative/positive checks. Uses only static fixtures and a temporary # INF-02 runner/exit-gate regression. Builds a throw-away ad-hoc signed arm64
# output directory; it never starts the game or contacts the server. # 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 -euo pipefail
set +x
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
repo="$PWD"
tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/mt-playable-gate.XXXXXX")" 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 \ failures=0
--report test/playable/report.valid.json \ pass() { echo "ok - $*"; }
--output "$tmp_dir/valid.json" \ fail() { echo "not ok - $*" >&2; failures=$((failures + 1)); }
--log test/playable/clean.log --process-code 0 --require-pass 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 \ # ---------- validator unit checks on static fixtures ----------
--report test/playable/report.blocked.json \ fixture="$repo/test/playable"
--output "$tmp_dir/blocked.json" \ validate_fixture() { # report log process_code timed_out [extra...]
--log test/playable/clean.log --process-code 0 --require-pass; then local report="$1" log="$2" code="$3" timed="$4"
echo "FAIL: BLOCKED fixture was accepted" >&2 shift 4
exit 1 node script/validate_playable_report.mjs --report "$report" --output "$tmp_dir/sealed.json" --log "$log" \
--process-code "$code" --timed-out "$timed" --run-id fixture-run --suite playable \
--required-cases "$tmp_dir/v/required-cases.json" --build "$tmp_dir/v/build.json" \
--events "$tmp_dir/v/events.jsonl" --started-ms "$(($(date +%s) * 1000))" --require-pass "$@" >/dev/null 2>&1
}
reset_fixture() {
rm -rf "$tmp_dir/v"
mkdir -p "$tmp_dir/v"
cp "$fixture/report.valid.json" "$tmp_dir/v/client-report.json"
cp "$fixture/required-cases.fixture.json" "$tmp_dir/v/required-cases.json"
cp "$fixture/build.fixture.json" "$tmp_dir/v/build.json"
cp "$fixture/events.valid.jsonl" "$tmp_dir/v/events.jsonl"
}
mutate() { # js expression body operating on `r`
node -e 'const fs=require("fs");const f=process.argv[1];const r=JSON.parse(fs.readFileSync(f,"utf8"));'"$1"';fs.writeFileSync(f,JSON.stringify(r));' "$tmp_dir/v/client-report.json"
}
code_of() { set +e; "$@"; local c=$?; set -e; echo "$c"; }
quiet() { "$@" >/dev/null 2>&1; }
reset_fixture
expect_code 0 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: valid fixture"
sealed_checked="$(node -e 'const r=require(process.argv[1]);process.stdout.write(String(r.exit_gate.checked===true&&r.exit_gate.process_code===0))' "$tmp_dir/sealed.json")"
[ "$sealed_checked" = "true" ] && pass "validator: exit_gate sealed" || fail "validator: exit_gate not sealed"
cp "$fixture/report.blocked.json" "$tmp_dir/v/client-report.json"
expect_code 2 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: BLOCKED case is not releasable"
reset_fixture
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 1 0)" "validator: nonzero child exit"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 1)" "validator: timed out"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/rid-warning.log" 0 0)" "validator: RID warning in log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$tmp_dir/missing.log" 0 0)" "validator: missing log"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/missing.json" "$fixture/clean.log" 0 0)" "validator: missing client report"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0 --redactor-code 143)" "validator: redactor killed"
mutate 'r.schema_version=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: unknown schema"
reset_fixture; mutate 'r.suite="full"'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: suite mismatch"
reset_fixture; mutate 'r.cases=[];r.required_cases=[];r.coverage={required:0,passed:0}'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: empty cases"
reset_fixture; mutate 'r.cases.push(r.cases[0]);r.coverage.required=2;r.coverage.passed=2'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: duplicate case"
reset_fixture; mutate 'r.build.pck_sha256=""'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing hash"
reset_fixture; mutate 'r.build.engine_sha256="d".repeat(64)'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: build differs from runner build.json"
reset_fixture; mutate 'r.cases[0].evidence=["shot.png"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: missing evidence file"
reset_fixture; mutate 'r.cases[0].evidence=["../../etc/hosts"]'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: evidence path escape"
reset_fixture; mutate 'r.event_count=5'
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: event_count mismatch"
reset_fixture; sed 's/fixture-run/other-run/' "$fixture/events.valid.jsonl" >"$tmp_dir/v/events.jsonl"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: foreign run_id in events"
reset_fixture; node -e 'require("fs").writeFileSync(process.argv[1], JSON.stringify({schema_version:1,suite:"playable",cases:["FIXTURE-01","FIXTURE-02"]}))' "$tmp_dir/v/required-cases.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: required count differs from case list"
reset_fixture; touch -t 200001010000 "$tmp_dir/v/client-report.json"
expect_code 1 "$(code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)" "validator: stale report mtime"
reset_fixture; mutate 'r.cases[0].reason="fixture-secret-value"'
code="$(MT_PASSWORD=fixture-secret-value code_of validate_fixture "$tmp_dir/v/client-report.json" "$fixture/clean.log" 0 0)"
expect_code 1 "$code" "validator: credential literal in client report"
if grep -q "fixture-secret-value" "$tmp_dir/sealed.json"; then fail "validator: secret copied into sealed report"; else pass "validator: secret not persisted"; fi
# ---------- log redactor ----------
redacted="$(printf 'user fixture-acct pass fixture-acct-pw\n' | MT_ACCOUNT=fixture-acct MT_PASSWORD=fixture-acct-pw node script/redact_stream.mjs)"
[ "$redacted" = "user [redacted] pass [redacted]" ] && pass "redactor: longest literal first" || fail "redactor output: $redacted"
# ---------- scripts never trace ----------
if grep -nE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*x' script/run_client_gate.sh script/playable_test.sh script/playable_soak.sh script/live_smoke_test.sh script/forest_map_render_test.sh 2>/dev/null; then
fail "a launch script enables set -x"
else
pass "launch scripts never enable set -x"
fi fi
if node script/validate_playable_report.mjs \ # ---------- example config is rejected ----------
--report test/playable/report.valid.json \ if MT_PLAYABLE_VALIDATE_CONFIG="$repo/test/playable/scenario.example.json" MT_PLAYABLE_ASSETS="$tmp_dir" \
--output "$tmp_dir/exit.json" \ "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd >/dev/null 2>&1; then
--log test/playable/clean.log --process-code 1 --require-pass; then fail "empty example scenario accepted"
echo "FAIL: non-zero child exit was accepted" >&2 else
exit 1 pass "empty example scenario rejected"
fi fi
if node script/validate_playable_report.mjs \ # ---------- fake signed arm64 app ----------
--report test/playable/report.valid.json \ app="$tmp_dir/Fake.app"
--output "$tmp_dir/rid.json" \ mkdir -p "$app/Contents/MacOS" "$app/Contents/Frameworks" "$app/Contents/Resources"
--log test/playable/rid-warning.log --process-code 0 --require-pass; then cat >"$tmp_dir/fake.c" <<'EOF'
echo "FAIL: renderer warning was accepted" >&2 #include <stdlib.h>
exit 1 #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"; }
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 fi
if MT_PLAYABLE_VALIDATE_CONFIG="$PWD/test/playable/scenario.example.json" \ expect_code 124 "$(code_of run_gate hang "$tmp_dir/run-hang" --timeout-seconds 2)" "runner: TERM-ignoring child is killed"
godot --headless --path project --script playable_config_gate.gd; then if grep -qE '^TIMEOUT term_grace_s=1[0-9] killed=1$' "$tmp_dir/run-hang/gate.log"; then
echo "FAIL: empty scenario configuration was accepted" >&2 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 ----------
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 exit 1
fi fi
echo "playable_gate_test: PASS" echo "playable_gate_test: PASS"
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs.
#
# Layout under --output (must be empty):
# exit-<n>/ N independent client processes, suite playable, each through
# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings)
# soak-1/ one client process, suite soak: move/combat/pickup rounds until the
# wall-clock duration, active reconnects, resolution switches, and —
# only when the scenario confirms them — portal warps and real
# transport faults through the 127.0.0.1 fault proxy; RSS is sampled
# for that child PID and sealed as STB-MEMORY-01
# release-manifest.json listing exactly these runs; aggregated with
# validate_playable_report.mjs --release-dir (no historical reports are scanned).
#
# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak)
# are not started and are recorded as BLOCKED so the release cannot pass.
# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt.
# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches.
#
# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout.
set -euo pipefail
set +x
cd "$(dirname "$0")/.."
repo="$PWD"
app="${MT_PLAYABLE_APP:-}"
config=""
output=""
duration=""
exits=""
timeout=""
exit_timeout="900"
allow=0
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() {
cat <<'EOF'
用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项]
--app APP 候选导出包(所有 run 必须同一哈希)
--config CONFIG 场景 JSON(含 soak 块;不含凭据)
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-<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
code=$?
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"
+242
View File
@@ -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();
+150
View File
@@ -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;
+101 -27
View File
@@ -1,56 +1,130 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Public entry for the first playable client gate. It delegates process # Public entry for the first playable client gate (INF-02 / REL-01).
# ownership and post-exit validation to run_client_gate.sh. #
# 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 -euo pipefail
set +x
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
app="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}" repo="$PWD"
app="${MT_PLAYABLE_APP:-}"
config="" config=""
output="" output=""
mode="playable" mode="playable"
timeout="180" timeout="900"
allow=0 allow=0
repeat=1 repeat=1
suite="full" suite="full"
assets=""
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
usage() { usage() {
cat <<'EOF' cat <<'EOF'
用法: script/playable_test.sh --config CONFIG [选项] 用法: script/playable_test.sh --app APP --config CONFIG [选项]
--app APP 导出包路径 --app APP 导出包路径(候选包;所有 run 必须是同一哈希)
--output DIR 单次运行输出目录(repeat=1 --output DIR 本批次根目录,必须不存在或为空(默认 build/playable/suite-<时间>
--suite full 当前固定为 full/playable --suite playable|full 必测用例集合,默认 full
--allow-gameplay 允许发送游戏状态变更请求 --repeat N 独立进程运行次数,默认 1(发布要求见 docs/FIRST-MAC-PLAYABLE-IMPLEMENTATION.md
--repeat N 独立进程运行次数,默认 1 --timeout-seconds N 每次墙钟超时,默认 900
--timeout-seconds N 每次墙钟超时,默认 180 --assets DIR 资源预检根目录(透传 run_client_gate.sh
--serverlist FILE 测试模式 serverlist 覆盖(透传)
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
EOF EOF
} }
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--app) app="$2"; shift 2 ;; --app) app="${2:-}"; shift 2 ;;
--config) config="$2"; shift 2 ;; --config) config="${2:-}"; shift 2 ;;
--output) output="$2"; shift 2 ;; --output) output="${2:-}"; shift 2 ;;
--suite) suite="$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 ;; --allow-gameplay) allow=1; shift ;;
--repeat) repeat="$2"; shift 2 ;;
--timeout-seconds) timeout="$2"; shift 2 ;;
--help) usage; exit 0 ;; --help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac esac
done 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 [ "$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" =~ ^[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" 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" 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 for index in $(seq 1 "$repeat"); do
run_dir="$root" run_dir="$root/run-$index"
if [ "$repeat" -gt 1 ]; then run_dir="$root/run-$index"; fi args=(--app "$app" --config "$config" --output "$run_dir" --suite "$suite" --mode "$mode" --timeout-seconds "$timeout")
args=(--app "$app" --config "$config" --output "$run_dir" --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 [ "$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
code=$?
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 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"
+63
View File
@@ -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();
}
}
+3 -3
View File
@@ -11,15 +11,15 @@ run_test() {
if [ "$mode" = headless ]; then options+=(--headless); fi if [ "$mode" = headless ]; then options+=(--headless); fi
"${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1 "${GODOT:-godot}" "${options[@]}" --script "$test_script" --quit-after 1800 >"$log" 2>&1
local status=$? 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" echo "FAIL $mode $test_script (exit=$status): $log"
failed=$((failed + 1)) failed=$((failed + 1))
else else
echo "PASS $mode $test_script" echo "PASS $mode $test_script"
fi 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" run_test headless "$test_script"
done 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 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
+250 -67
View File
@@ -1,125 +1,308 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Run one exported client process and seal its report after exit. # INF-02: run ONE exported client process and seal its report after it exits.
# This script owns exactly one child PID; it never pkills other clients. #
# - 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 -euo pipefail
set +x
cd "$(dirname "$0")/.." 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" mode="playable"
suite="playable"
config_path="" config_path=""
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}" assets_root=""
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}" serverlist="${MT_PLAYABLE_SERVERLIST:-}"
output_dir=""
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}"
allow_gameplay=0 allow_gameplay=0
required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}"
godot_bin="${MT_GODOT:-godot}"
kill_grace_seconds=10
usage() { usage() {
cat <<'EOF' cat <<'EOF'
用法: script/run_client_gate.sh --app APP --config CONFIG [选项] 用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
选项: 选项:
--output DIR 本次运行目录,必须不存在或为空 --output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-<pid>
--mode MODE 默认 playable --suite playable|full|soak 必测用例集合,默认 playablesoak 需 --timeout-seconds >= soak.duration_seconds+900
--timeout-seconds N 墙钟超时,默认 180 --mode MODE 包内 MT_TEST_MODE,默认 playable
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求 --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 EOF
} }
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
--app) app_path="$2"; shift 2 ;; --app) app_path="${2:-}"; shift 2 ;;
--config) config_path="$2"; shift 2 ;; --config) config_path="${2:-}"; shift 2 ;;
--output) output_dir="$2"; shift 2 ;; --output) output_dir="${2:-}"; shift 2 ;;
--mode) mode="$2"; shift 2 ;; --suite) suite="${2:-}"; shift 2 ;;
--timeout-seconds) timeout_seconds="$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 ;; --allow-gameplay) allow_gameplay=1; shift ;;
--help) usage; exit 0 ;; --help) usage; exit 0 ;;
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
esac esac
done done
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then output_ready=0
echo "配置不存在;不会启动客户端:$config_path" >&2 blocked() {
echo "PLAYABLE GATE: BLOCKED $*" >&2
if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi
exit 2 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 fi
if [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
echo "找不到可执行包:$app_path" >&2 if [ -z "$output_dir" ]; then
exit 2 output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$"
fi fi
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "timeout-seconds 必须是正整数" >&2 blocked "输出目录已有内容,拒绝复用历史报告:$output_dir"
exit 2
fi
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
exit 2
fi fi
mkdir -p "$output_dir" 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" log="$output_dir/client.log"
client_report="$output_dir/client-report.json" client_report="$output_dir/client-report.json"
final_report="$output_dir/report.json" final_report="$output_dir/report.json"
events="$output_dir/events.jsonl" 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 if [ -z "$assets_root" ]; then
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2 if [ -d "$app_path/Contents/Resources/assets" ]; then
exit 2 assets_root="$app_path/Contents/Resources/assets"
else
assets_root="$repo/assets"
fi
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() { # 3. config + address + resource preconditions; resolves the address the client will use.
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; fi 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" read -r auth_host auth_port game_host game_port <<<"$(read_address)"
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)" proxy_pid=""
stop_proxy() {
if [ -n "$proxy_pid" ]; then
kill -TERM "$proxy_pid" 2>/dev/null || true
wait "$proxy_pid" 2>/dev/null || true
proxy_pid=""
fi
}
trap stop_proxy EXIT
if [ "$fault_status" = "confirmed" ]; then
# Loopback proxy in front of the test server for this client only. server.* points at it, so
# playable exit runs sharing the scenario go through it too; only the soak client requests faults.
for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do
if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then
blocked "故障代理上游不可达:$endpoint"
fi
done
routes=(--route "$auth_port=$up_auth_host:$up_auth_port")
if [ "$game_port" != "$auth_port" ]; then
routes+=(--route "$game_port=$up_game_host:$up_game_port")
elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then
blocked "auth/game 共用代理端口,但上游地址不同"
fi
node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \
--run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 </dev/null &
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_TEST_MODE="$mode"
export MT_PLAYABLE_SUITE="$suite"
export MT_PLAYABLE_CONFIG="$config_path" 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_REPORT="$client_report"
export MT_TEST_EVENTS="$events" export MT_TEST_EVENTS="$events"
export MT_TEST_OUTPUT="$output_dir" export MT_TEST_OUTPUT="$output_dir"
export MT_PROTOCOL="classic" export MT_PROTOCOL="classic"
export MT_AUTOLOGIN=1 export MT_AUTOLOGIN=1
export MT_CHAR_SLOT="$char_slot" export MT_CHAR_SLOT="$char_slot"
export MT_PLAYABLE_RUN_ID="$(basename "$output_dir")" export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)"
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")" export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)"
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")" export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)"
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")" export MT_BUILD_ARCH="$(build_field arch)"
file_description="$(file "$engine")" export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay"
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
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 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=$! 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 timed_out=0
while kill -0 "$child_pid" 2>/dev/null; do while kill -0 "$child_pid" 2>/dev/null; do
now="$(date +%s)" if [ "$SECONDS" -ge "$timeout_seconds" ]; then
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
timed_out=1 timed_out=1
kill -TERM "$child_pid" 2>/dev/null || true kill -TERM "$child_pid" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do grace=0
kill -0 "$child_pid" 2>/dev/null || break while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do
sleep 1 sleep 1
grace=$((grace + 1))
done 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 break
fi fi
sleep 1 sleep 1
done done
wait "$child_pid" wait "$child_pid"
app_status=$? process_code=$?
set -e # The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open.
if [ "$timed_out" -eq 1 ]; then app_status=124; fi redactor_wait=0
while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do
set +e sleep 1
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ redactor_wait=$((redactor_wait + 1))
--log "$log" --process-code "$app_status" --require-pass done
gate_status=$? if kill -0 "$redactor_pid" 2>/dev/null; then
set -e kill -TERM "$redactor_pid" 2>/dev/null
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log"
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
exit 1
fi fi
echo "PLAYABLE GATE: PASS report=$final_report log=$log" wait "$redactor_pid"
redactor_code=$?
rm -f "$fifo"
if [ -n "$sampler_pid" ]; then
# The sampler stops by itself once the child is gone; bound the wait anyway.
sampler_wait=0
while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do
sleep 1
sampler_wait=$((sampler_wait + 1))
done
if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi
wait "$sampler_pid"
fi
stop_proxy
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \
--suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \
--events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass
gate_status=$?
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \
--warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds"
memory_status=$?
if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1
elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1
elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2
fi
fi
set -e
if [ "$timed_out" -eq 1 ]; then
echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2
exit 124
fi
case "$gate_status" in
0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;;
2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;;
*) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;;
esac
exit "$gate_status"
+267 -61
View File
@@ -1,15 +1,37 @@
#!/usr/bin/env node #!/usr/bin/env node
/* Validate the machine-readable playable report and, when requested, seal it /* INF-02 / REL-01 report gate.
* 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. */ * 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 fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
const VALID = new Set(['PASS', 'FAIL', 'BLOCKED', 'SKIP']); 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() { 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) { function argsOf(argv) {
@@ -17,97 +39,281 @@ function argsOf(argv) {
for (let i = 0; i < argv.length; i += 1) { for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]; const arg = argv[i];
if (arg === '--require-pass') out.require_pass = true; if (arg === '--require-pass') out.require_pass = true;
else if (arg === '--help') out.help = true;
else if (arg.startsWith('--')) { else if (arg.startsWith('--')) {
const key = arg.slice(2).replaceAll('-', '_'); const key = arg.slice(2).replaceAll('-', '_');
if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`); if (i + 1 >= argv.length) throw new Error(`missing value for ${arg}`);
out[key] = argv[++i]; out[key] = argv[++i];
} else throw new Error(`unknown argument: ${arg}`); } 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; return out;
} }
function readJson(file) { function readJson(file) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { return { __read_error: `${file}: ${error.message}` }; } catch (error) { return { __read_error: `${path.basename(file)}: ${error.code || 'invalid JSON'}` }; }
} }
function validate(report, options) { function secretLiterals() {
const errors = []; return ['MT_ACCOUNT', 'MT_PASSWORD'].map((name) => process.env[name] || '').filter((v) => v.length >= SECRET_MIN_SCAN);
const readError = report?.__read_error; }
if (readError) errors.push(readError);
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)) { if (!report || typeof report !== 'object' || Array.isArray(report)) {
errors.push('report root must be an object'); errors.fail.push('report root must be an object');
return errors; return;
} }
if (report.schema_version !== 1) errors.push('schema_version must be 1'); if (report.schema_version !== 1) errors.fail.push('schema_version must be 1');
if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.push('run_id is missing'); if (typeof report.run_id !== 'string' || report.run_id.trim() === '') errors.fail.push('run_id is missing');
if (options.run_id && report.run_id !== options.run_id) errors.push('run_id does not match requested run'); 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 !== 'playable') errors.push('suite must be playable'); if (report.suite !== options.suite) errors.fail.push(`suite ${report.suite} does not match ${options.suite}`);
if (!VALID.has(report.status)) errors.push(`invalid report status: ${report.status}`); if (!VALID.has(report.status)) errors.fail.push(`invalid report status: ${report.status}`);
for (const key of ['engine_sha256', 'extension_sha256', 'pck_sha256', 'arch']) { const expectedBuild = readJson(options.build);
if (typeof report.build?.[key] !== 'string' || report.build[key].trim() === '') errors.push(`build.${key} is missing`); if (expectedBuild.__read_error) errors.fail.push(`build.json unreadable: ${expectedBuild.__read_error}`);
} checkBuild(report.build, expectedBuild.__read_error ? null : expectedBuild, '', errors);
if (!Array.isArray(report.cases) || report.cases.length === 0) errors.push('cases must be non-empty');
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 : []; 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; let passed = 0;
for (const item of cases) { for (const item of cases) {
if (!item || typeof item !== 'object' || typeof item.id !== 'string' || item.id.trim() === '') { 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; 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 === 'PASS') passed += 1;
if (item.status === 'BLOCKED' || item.status === 'SKIP') errors.push(`${item.id}: ${item.status} is not releasable`); if (item.status === 'FAIL') errors.fail.push(`${item.id}: FAIL ${item.reason || ''}`.trim());
if (!Array.isArray(item.evidence)) errors.push(`${item.id}: evidence must be an array`); 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 : [])) { for (const evidence of (Array.isArray(item.evidence) ? item.evidence : [])) {
if (typeof evidence !== 'string' || evidence.trim() === '') errors.push(`${item.id}: empty evidence path`); const resolved = typeof evidence === 'string' ? path.resolve(path.dirname(options.report), evidence) : '';
else if (!fs.existsSync(path.resolve(path.dirname(options.report), evidence))) errors.push(`${item.id}: missing evidence ${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; const coverage = report.coverage;
if (!coverage || coverage.required !== cases.length || coverage.passed !== passed) errors.push('coverage does not match cases'); if (!coverage || coverage.required !== cases.length || coverage.passed !== passed
if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.push('failures and blocked must be arrays'); || (requiredIds && coverage.required !== requiredIds.length)) errors.fail.push('coverage does not match cases');
if (!report.exit_gate || report.exit_gate.checked !== true) errors.push('exit_gate.checked must be true in final report'); if (!Array.isArray(report.failures) || !Array.isArray(report.blocked)) errors.fail.push('failures and blocked must be arrays');
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'); else if (report.failures.length > 0) errors.fail.push('client report contains failures');
if (Number(report.exit_gate?.process_code) !== 0) errors.push('child process did not exit with code 0'); if (report.status === 'PASS' && (passed !== cases.length || report.blocked?.length)) errors.fail.push('client PASS contradicts its cases');
if (options.log) {
if (!fs.existsSync(options.log)) errors.push(`log does not exist: ${options.log}`); // Staleness: the client report must have been written during this run.
try {
const mtime = fs.statSync(options.report).mtimeMs;
if (mtime + MTIME_SLACK_MS < Number(options.started_ms)) errors.fail.push('client report predates this run (stale)');
} catch { /* unreadable already reported */ }
// Event contract.
let lines = [];
if (fs.existsSync(options.events)) {
const text = fs.readFileSync(options.events, 'utf8');
scanSecrets('events', text, errors);
lines = text.split('\n').filter((line) => line.trim() !== '');
}
if (report.status === 'PASS' && lines.length === 0) errors.fail.push('PASS report has no events');
if (Number(report.event_count ?? 0) !== lines.length) errors.fail.push(`event_count ${report.event_count} does not match events lines ${lines.length}`);
let badEvents = 0;
let lastUs = -Infinity;
for (const line of lines) {
let event;
try { event = JSON.parse(line); } catch { badEvents += 1; continue; }
if (!EVENT_KEYS.every((key) => Object.hasOwn(event, key)) || event.run_id !== options.run_id
|| typeof event.payload !== 'object' || event.payload === null || Array.isArray(event.payload)
|| !Number.isInteger(event.connection_epoch) || event.monotonic_us < lastUs) badEvents += 1;
else lastUs = event.monotonic_us;
}
if (badEvents > 0) errors.fail.push(`${badEvents} events violate the contract (keys/run_id/order)`);
}
function validateProcess(options, errors) {
const code = Number(options.process_code);
if (options.timed_out === '1') errors.fail.push(`wall-clock timeout (raw exit ${code})`);
if (code !== 0) errors.fail.push(`child process exit code ${code}`);
if (options.redactor_code !== undefined && Number(options.redactor_code) !== 0) errors.fail.push('log redactor did not finish cleanly');
if (!fs.existsSync(options.log)) {
errors.fail.push('client log does not exist');
return;
}
const text = fs.readFileSync(options.log, 'utf8');
scanSecrets('client log', text, errors);
const hits = text.split(/\r?\n/).map((line, index) => [line, index]).filter(([line]) => ERROR_RE.test(line));
// Report the line numbers and the matched rule, not arbitrary log text.
for (const [line, index] of hits.slice(0, 50)) errors.fail.push(`log:${index + 1}: ${line.match(ERROR_RE)[0]}`);
if (hits.length > 50) errors.fail.push(`log: ${hits.length - 50} more error lines`);
}
function sealRun(options) {
const errors = new Findings();
const input = readJson(options.report);
validateReport(input, options, errors);
validateProcess(options, errors);
const report = input.__read_error ? {
schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
build: readJson(options.build), cases: [], failures: [input.__read_error], blocked: [],
coverage: { required: 0, passed: 0 },
} : input;
let status = 'PASS';
if (errors.fail.length > 0) status = 'FAIL';
else if (errors.blocked.length > 0) status = 'BLOCKED';
if (options.require_pass && status === 'PASS' && report.status !== 'PASS') { status = 'FAIL'; errors.fail.push(`client status ${report.status}`); }
report.status = status;
report.exit_gate = {
checked: true,
process_code: Number(options.process_code),
timed_out: options.timed_out === '1',
errors: errors.all,
};
const text = `${JSON.stringify(report, null, 2)}\n`;
const leak = new Findings();
scanSecrets('final report', text, leak);
const output = options.output;
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
if (leak.fail.length > 0) {
// Never persist a report that would carry the secret; keep only the verdict.
fs.writeFileSync(output, `${JSON.stringify({ schema_version: 1, run_id: options.run_id, suite: options.suite, status: 'FAIL',
exit_gate: { checked: true, process_code: Number(options.process_code), timed_out: options.timed_out === '1', errors: leak.fail } }, null, 2)}\n`);
console.error('PLAYABLE REPORT: FAIL (credential literal in report)');
return 1;
}
fs.writeFileSync(output, text);
if (status !== 'PASS') {
console.error(`PLAYABLE REPORT: ${status} (${errors.all.length} findings)`);
errors.all.slice(0, 40).forEach((error) => console.error(`- ${error}`));
return status === 'BLOCKED' ? 2 : 1;
}
console.log(`PLAYABLE REPORT: PASS ${output}`);
return 0;
}
function sealRelease(dir) {
const errors = new Findings();
const root = path.resolve(dir);
const manifest = readJson(path.join(root, 'release-manifest.json'));
const summary = { schema_version: 1, status: 'FAIL', candidate: null, runs: [], counts: {}, errors: [] };
if (manifest.__read_error) errors.fail.push(`release-manifest.json unreadable: ${manifest.__read_error}`);
else { else {
const lines = fs.readFileSync(options.log, 'utf8').split(/\r?\n/); if (manifest.schema_version !== 1) errors.fail.push('manifest schema_version must be 1');
lines.forEach((line, index) => { if (ERROR_RE.test(line)) errors.push(`log:${index + 1}: ${line}`); }); checkBuild(manifest.candidate, null, 'manifest.', errors);
summary.candidate = manifest.candidate;
const runs = Array.isArray(manifest.runs) ? manifest.runs : [];
if (runs.length === 0) errors.fail.push('manifest lists no runs');
const ids = new Set();
const passedBySuite = {};
const blockedBySuite = {};
for (const run of runs) {
const label = `run ${run?.run_id ?? '?'}`;
if (typeof run?.run_id !== 'string' || typeof run?.path !== 'string' || !SUITES.has(run?.suite)) {
errors.fail.push(`${label}: run_id/path/suite missing`);
continue;
}
if (ids.has(run.run_id)) errors.fail.push(`${label}: listed twice`);
ids.add(run.run_id);
const reportPath = path.resolve(root, run.path, 'report.json');
if (!reportPath.startsWith(root + path.sep)) {
errors.fail.push(`${label}: path escapes the release directory`);
continue;
}
const report = readJson(reportPath);
const entry = { run_id: run.run_id, suite: run.suite, path: run.path, status: report.status ?? 'MISSING' };
summary.runs.push(entry);
if (report.__read_error) {
// run_client_gate.sh records precondition stops in gate.log before any client starts.
const gateLog = path.resolve(root, run.path, 'gate.log');
const preconditionBlocked = fs.existsSync(gateLog) && /^BLOCKED /m.test(fs.readFileSync(gateLog, 'utf8'));
if (preconditionBlocked) {
entry.status = 'BLOCKED';
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: precondition BLOCKED before launch`);
} else errors.fail.push(`${label}: sealed report missing`);
continue;
}
if (report.run_id !== run.run_id) errors.fail.push(`${label}: report run_id mismatch`);
if (report.suite !== run.suite) errors.fail.push(`${label}: report suite mismatch`);
if (report.exit_gate?.checked !== true || report.exit_gate?.process_code !== 0 || report.exit_gate?.timed_out !== false) errors.fail.push(`${label}: report not sealed by a clean exit gate`);
checkBuild(report.build, manifest.candidate, `${label}: `, errors);
if (report.status === 'PASS') passedBySuite[run.suite] = (passedBySuite[run.suite] || 0) + 1;
else if (report.status === 'BLOCKED') {
blockedBySuite[run.suite] = (blockedBySuite[run.suite] || 0) + 1;
errors.blocked.push(`${label}: BLOCKED`);
}
else errors.fail.push(`${label}: ${report.status}`);
}
const requiredRuns = manifest.required_runs && typeof manifest.required_runs === 'object' ? manifest.required_runs : {};
if (Object.keys(requiredRuns).length === 0) errors.fail.push('manifest required_runs is empty');
for (const [suite, count] of Object.entries(requiredRuns)) {
const got = passedBySuite[suite] || 0;
summary.counts[suite] = { required: count, passed: got };
if (!Number.isInteger(count) || count < 1) errors.fail.push(`required_runs.${suite} must be a positive integer`);
else if (got < count) {
// Missing passes explained by BLOCKED runs stay BLOCKED; anything else is a FAIL.
const message = `${suite}: ${got}/${count} passing runs`;
if (got + (blockedBySuite[suite] || 0) >= count) errors.blocked.push(message);
else errors.fail.push(message);
} }
} }
if (options.require_pass && report.status !== 'PASS') errors.push(`report status is ${report.status}, expected PASS`); }
if (report.status === 'FAIL') errors.push('client report already contains failures'); summary.status = errors.fail.length ? 'FAIL' : (errors.blocked.length ? 'BLOCKED' : 'PASS');
return [...new Set(errors)]; 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() { function main() {
let options; let options;
try { options = argsOf(process.argv.slice(2)); } try { options = argsOf(process.argv.slice(2)); }
catch (error) { usage(); console.error(error.message); return 2; } catch (error) { usage(); console.error(error.message); return 2; }
const input = readJson(options.report); if (options.help) { usage(); return 0; }
const report = input.__read_error ? { if (options.release_dir) return sealRelease(options.release_dir);
schema_version: 1, run_id: options.run_id || 'missing-report', suite: 'playable', status: 'FAIL', return sealRun(options);
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;
} }
process.exitCode = main(); process.exitCode = main();
+6
View File
@@ -0,0 +1,6 @@
{
"engine_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"extension_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"arch": "arm64"
}
+1
View File
@@ -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"}}
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Stand-in for the packaged client used by script/playable_gate_test.sh.
# The fake app's Mach-O execs this file, so it runs as the runner's own child
# PID. It never contacts a server; MT_FAKE_SCENARIO picks the behaviour.
scenario="${MT_FAKE_SCENARIO:-pass}"
write_report() {
# $1 = PASS|BLOCKED, $2 = drop-last|evidence-missing|wrong-run|""
node -e '
const fs = require("node:fs");
const path = require("node:path");
const [status, variant] = process.argv.slice(1);
const out = process.env.MT_TEST_OUTPUT;
const required = JSON.parse(fs.readFileSync(path.join(out, "required-cases.json"), "utf8")).cases;
const runId = variant === "wrong-run" ? "previous-run-0000" : process.env.MT_PLAYABLE_RUN_ID;
const ids = variant === "drop-last" ? required.slice(0, -1) : required;
const cases = ids.map((id) => ({ id, status, duration_ms: 1, reason: status === "PASS" ? "fake" : "fixture_not_ready",
evidence: variant === "evidence-missing" && id === "NET-LOGIN-01" ? ["net-login-01.png"] : [] }));
const events = ids.map((id, i) => JSON.stringify({ monotonic_us: 1000 + i, run_id: runId, case_id: id, connection_epoch: 1,
stage: "LOGIN", kind: "phase_changed", actor_vid: 0, target_vid: 0, payload: { source: "server" } }));
fs.writeFileSync(process.env.MT_TEST_EVENTS, events.join("\n") + "\n");
const report = {
schema_version: 1, run_id: runId, suite: process.env.MT_PLAYABLE_SUITE || process.env.MT_TEST_MODE, status,
build: { engine_sha256: process.env.MT_BUILD_ENGINE_SHA256, extension_sha256: process.env.MT_BUILD_EXTENSION_SHA256,
pck_sha256: process.env.MT_BUILD_PCK_SHA256, arch: process.env.MT_BUILD_ARCH },
environment: { os: "macOS", renderer: "fake", resolution: [1280, 720] },
cases, required_cases: ids, events: "events.jsonl", event_count: events.length, failures: [],
blocked: status === "BLOCKED" ? ids.map((id) => `${id}: fixture_not_ready`) : [],
coverage: { required: cases.length, passed: status === "PASS" ? cases.length : 0 },
exit_gate: { checked: false, process_code: null, errors: [] },
};
fs.writeFileSync(process.env.MT_TEST_REPORT, JSON.stringify(report, null, 2));
' "$1" "${2:-}"
}
case "$scenario" in
pass) write_report PASS; echo "PLAYABLE CLIENT: PASS" ;;
soak-pass) write_report PASS; if [ "${MT_PLAYABLE_SUITE:-}" = "soak" ]; then sleep 3; fi; echo "PLAYABLE CLIENT: PASS" ;;
exit3) write_report PASS; echo "PLAYABLE CLIENT: PASS"; exit 3 ;;
signal) write_report PASS; kill -KILL $$ ;;
hang) trap '' TERM; echo "ignoring TERM"; while :; do sleep 1; done ;;
stale) write_report PASS wrong-run ;;
no-report) echo "exited before writing a report" ;;
rid) write_report PASS; echo "WARNING: 3 shaders of type CanvasItem were never freed." ;;
leak) write_report PASS; echo "WARNING: ObjectDB instances leaked at exit (run with --verbose for details)." ;;
missing-case) write_report PASS drop-last ;;
missing-evidence) write_report PASS evidence-missing ;;
blocked) write_report BLOCKED ;;
print-secret) write_report PASS; echo "login request for $MT_ACCOUNT with $MT_PASSWORD" ;;
fifo-holder) write_report PASS; sleep 13 & exit 0 ;;
no-creds) if [ -n "${MT_ACCOUNT:-}${MT_PASSWORD:-}" ]; then echo "SCRIPT ERROR: credentials reached an offline child"; fi; write_report PASS ;;
*) echo "unknown fake scenario" >&2; exit 64 ;;
esac
exit 0
@@ -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."
}
+34 -6
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": 1, "schema_version": 1,
"run_id": "fixture-blocked", "run_id": "fixture-run",
"suite": "playable", "suite": "playable",
"status": "BLOCKED", "status": "BLOCKED",
"build": { "build": {
@@ -9,11 +9,39 @@
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"arch": "arm64" "arch": "arm64"
}, },
"environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, "environment": {
"cases": [{"id": "FIXTURE-01", "status": "BLOCKED", "duration_ms": 0, "reason": "fixture_not_ready", "evidence": []}], "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", "events": "events.jsonl",
"event_count": 1,
"failures": [], "failures": [],
"blocked": ["fixture_not_ready"], "blocked": [
"coverage": {"required": 1, "passed": 0}, "FIXTURE-01: fixture_not_ready"
"exit_gate": {"checked": false, "process_code": null, "errors": []} ],
"coverage": {
"required": 1,
"passed": 0
},
"exit_gate": {
"checked": false,
"process_code": null,
"errors": []
}
} }
+31 -5
View File
@@ -1,6 +1,6 @@
{ {
"schema_version": 1, "schema_version": 1,
"run_id": "fixture-valid", "run_id": "fixture-run",
"suite": "playable", "suite": "playable",
"status": "PASS", "status": "PASS",
"build": { "build": {
@@ -9,11 +9,37 @@
"pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "pck_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"arch": "arm64" "arch": "arm64"
}, },
"environment": {"os": "macOS", "renderer": "Metal", "resolution": [1280, 720]}, "environment": {
"cases": [{"id": "FIXTURE-01", "status": "PASS", "duration_ms": 1, "reason": "ok", "evidence": []}], "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", "events": "events.jsonl",
"event_count": 1,
"failures": [], "failures": [],
"blocked": [], "blocked": [],
"coverage": {"required": 1, "passed": 1}, "coverage": {
"exit_gate": {"checked": false, "process_code": null, "errors": []} "required": 1,
"passed": 1
},
"exit_gate": {
"checked": false,
"process_code": null,
"errors": []
}
} }
@@ -0,0 +1,7 @@
{
"schema_version": 1,
"suite": "playable",
"cases": [
"FIXTURE-01"
]
}
+27 -3
View File
@@ -4,7 +4,11 @@
"protocol": "classic", "protocol": "classic",
"server": { "server": {
"server_index": 0, "server_index": 0,
"channel_index": 0 "channel": 1,
"auth_host": "",
"auth_port": 0,
"game_host": "",
"game_port": 0
}, },
"character_slot": 0, "character_slot": 0,
"map_key": "", "map_key": "",
@@ -14,6 +18,26 @@
"skill_cases": [], "skill_cases": [],
"resolution": [1280, 720], "resolution": [1280, 720],
"loops": 3, "loops": 3,
"timeout_seconds": 120, "timeout_seconds": 900,
"notes": "Copy to a local ignored file and fill from the confirmed server fixture. Never add credentials here." "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."
} }
+68
View File
@@ -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"
}
}
}
}