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
+578 -78
View File
@@ -1,96 +1,596 @@
extends Node
## 离线加载实际鬼木林/赤鬼木林地图,并把树怪放进生产 GameScene 的实体挂载点
## 服务器刷新、选中、战斗和掉落仍由 playable_live_test 负责。
## MAP-02 地图内离线验收:MT_TEST_MODE=forest_render(包内与编辑器都经 client_main 进入)
##
## MT_TEST_MODE=forest_render MT_FOREST_MAPS=<map_key>[,<map_key>] godot --path project
## bash script/forest_map_render_test.sh --help # 包内运行、墙钟超时、最终报告
##
## 地图只来自 MT_FOREST_MAPS 或 MT_PLAYABLE_CONFIG 的 map_key(由 audit_playable_maps 的结果填写),
## 不在脚本里猜地图。每张图:生产 GameScene 装图 -> 机位(夹具确认 / 自动候选)-> 树怪经
## net_world 的真实生成路径(entity_spawned -> MobView 工厂 -> 贴地)-> 记录 sample_height、
## 怪物世界位置、ground offset、材质、实际动作路径 -> 固定机位截图。
##
## 判定边界:
## - 平地(足迹内地形高差 <= FLAT_FOOTPRINT_M)上 wait 各采样帧都悬空/埋地超过 GROUND_GAP_M 才算失败;
## AABB 底部不是足部,坡地只记录数据交人工看脚/根部接触,不据此宣布足部 IK 完成。
## - 自动候选机位、未确认机位、缺失的传送点、headless(无 Metal 截图)一律 BLOCKED,不当 PASS。
## - 这里没有服务器刷新:选中/攻击/死亡/拾取的实网证据由 playable_live_test 单独记录。
##
## 必测用例由 required_case_ids(maps) 决定(script/forest_map_render_test.sh 在启动前写同一份列表),
## Metal 截图的人工签核是发布清单里单独的 manual 项,不由本进程判定。
##
## 退出码:单独运行 0 PASS / 1 FAIL / 2 BLOCKED;由 runner 启动(有 MT_PLAYABLE_RUN_ID)时写出报告即 0
## 判定交给 runner。客户端只写 client-report.json、events 与 forest-map-evidence.json。
const GameScene = preload("res://game_scene.gd")
const Fixtures = preload("res://gamescene_test.gd")
const MobView = preload("res://ui/mob_view.gd")
const Config = preload("res://testing/playable_config.gd")
const Viewpoints = preload("res://testing/forest_viewpoints.gd")
const PlayableReport = preload("res://testing/playable_report.gd")
var failures := 0
## root/npclist.txt 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:
call_deferred("run")
func _fail(message: String) -> void:
failures += 1
printerr("FOREST MAP FAIL: " + message)
func run() -> void:
var map_key := OS.get_environment("MT_FOREST_MAP")
_out = OS.get_environment("MT_TEST_OUTPUT")
if _out.is_empty():
_out = OS.get_environment("MT_RENDER_OUTPUT")
if _out.is_empty():
_out = ProjectSettings.globalize_path("user://forest-map-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(_out)
var timeout := int(OS.get_environment("MT_FOREST_TIMEOUT_SECONDS")) if OS.get_environment("MT_FOREST_TIMEOUT_SECONDS").is_valid_int() else DEFAULT_TIMEOUT_SECONDS
get_tree().create_timer(timeout).timeout.connect(_on_watchdog)
var headless := DisplayServer.get_name() == "headless"
_evidence = {"schema_version": 1, "scope": "offline production GameScene + MobView placement; no server spawn, no live target/attack/pickup evidence",
"headless": headless, "assets": AssetRoot.path(), "calibration": CALIBRATION,
"thresholds": {"flat_footprint_m": FLAT_FOOTPRINT_M, "ground_gap_m": GROUND_GAP_M, "root_gap_m": ROOT_GAP_M},
"maps": [], "manual_review": ["Metal screenshots: tree monster silhouettes, materials, foot/root contact on slopes"],
"live_evidence": "separate: playable_live_test per-map enter/select/attack/dead/pickup/leave"}
var config := {}
var config_path := OS.get_environment("MT_PLAYABLE_CONFIG")
var loaded_config := Config.load_file(config_path) if not config_path.is_empty() else {"ok": false, "config": {}}
if map_key.is_empty() and loaded_config.ok:
map_key = String(loaded_config.config.get("map_key", ""))
if map_key.is_empty():
map_key = "outdoortrent/metin2_map_trent"
var race := int(OS.get_environment("MT_FOREST_MOB_RACE")) if not OS.get_environment("MT_FOREST_MOB_RACE").is_empty() else 2301
var output := OS.get_environment("MT_TEST_OUTPUT")
if output.is_empty():
output = ProjectSettings.globalize_path("res://../build/rendering/forest-map-%d" % Time.get_unix_time_from_system())
DirAccess.make_dir_recursive_absolute(output)
var playable_report := PlayableReport.new()
playable_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "playable", loaded_config.config if loaded_config.ok else {})
get_tree().root.size = Vector2i(1280, 720)
print("FOREST MAP: loading %s" % map_key)
var client := Fixtures.FakeClient.new()
# Server-space values chosen inside the corresponding local map rectangle.
# GameScene/MapCoord performs the actual base-position conversion.
var base_x := 0.0 if map_key == "outdoortrent/metin2_map_trent" else 1049600.0
var server_x := base_x + 12800.0
var server_y := 12800.0
client.spawn(1000, "ForestRender", Vector3(server_x * 0.01, 0.0, -server_y * 0.01), true)
var scene := GameScene.new()
get_tree().root.add_child(scene)
await scene.setup(client, AssetRoot.path(), map_key)
print("FOREST MAP: GameScene setup complete")
if not scene._map_loaded():
_fail("map did not load: %s" % map_key)
if not scene._model_built or scene.player == null:
_fail("local player model is not ready")
var entities := scene.get_node_or_null("Entities")
if entities == null:
_fail("GameScene entity mount is missing")
var positions := [Vector3(12.0, 0.0, 18.0), Vector3(15.0, 0.0, 21.0)]
var monster := MobView.new()
monster.name = "ForestTreeMonster_%d" % race
if not monster.build(AssetRoot.path(), null, race):
_fail("tree monster build failed race=%d" % race)
if not config_path.is_empty() and FileAccess.file_exists(config_path):
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(config_path))
config = parsed if parsed is Dictionary else {}
_report.begin(OS.get_environment("MT_PLAYABLE_RUN_ID"), "forest_render", config, OS.get_environment("MT_TEST_EVENTS"))
var maps: Array[String] = []
for key in OS.get_environment("MT_FOREST_MAPS").split(",", false):
if not key.strip_edges().is_empty():
maps.append(key.strip_edges())
if maps.is_empty() and not String(config.get("map_key", "")).strip_edges().is_empty():
maps.append(String(config.map_key).strip_edges())
_report.set_required_cases(required_case_ids(maps))
var vp_file := Viewpoints.load_file(OS.get_environment("MT_FOREST_VIEWPOINTS"))
var races := parse_races(OS.get_environment("MT_FOREST_RACES"))
var config_errors: Array[String] = []
if maps.is_empty():
config_errors.append("no map: set MT_FOREST_MAPS or config map_key from map-assets.json")
var ids := {}
for map_key in maps:
if not is_safe_map_key(map_key):
config_errors.append("map_key %s must be a relative asset path without '..'" % map_key)
elif ids.has(map_key.get_file()):
config_errors.append("map_key %s repeats map id %s" % [map_key, map_key.get_file()])
ids[map_key.get_file()] = true
if not vp_file.ok:
config_errors.append_array(vp_file.errors)
if not races.ok:
config_errors.append("MT_FOREST_RACES must be a comma list of positive mob vnums")
if not config_errors.is_empty():
_report.add_case("MAP-FOREST-CONFIG", "BLOCKED", "; ".join(config_errors))
else:
entities.add_child(monster)
monster.position = positions[0]
monster.set_anim_state("wait")
client.spawn(23001, "TreeMonster", Vector3(positions[0].x * 100.0, 0.0, -positions[0].z * 100.0))
_report.add_case("MAP-FOREST-CONFIG", "PASS", "maps=%s viewpoint_fixture=%s" % [",".join(maps),
"yes" if not OS.get_environment("MT_FOREST_VIEWPOINTS").is_empty() else "none"])
get_tree().root.size = Vector2i(1280, 720)
for map_key in maps:
await _run_map(map_key, vp_file.data, races.races, headless)
_finish()
static func required_case_ids(maps: Array) -> Array[String]:
var ids: Array[String] = ["MAP-FOREST-CONFIG"]
for map_key in maps:
var map_id := String(map_key).get_file()
ids.append("MAP-FOREST-LOAD-" + map_id)
for kind in Viewpoints.KINDS:
ids.append("MAP-FOREST-VP-%s-%s" % [map_id, kind])
ids.append("MAP-FOREST-MOTION-" + map_id)
return ids
static func is_safe_map_key(map_key: String) -> bool:
return not map_key.is_empty() and not map_key.begins_with("/") and not ("\\" in map_key) \
and not (".." in map_key.split("/"))
static func parse_races(text: String) -> Dictionary:
var out := {"ok": true, "races": []}
for part in text.split(",", false):
var value := part.strip_edges()
if not value.is_valid_int() or int(value) <= 0:
out.ok = false
continue
out.races.append(int(value))
return out
func _on_watchdog() -> void:
if _finished:
return
_report.add_failure("forest render watchdog: run did not finish (script error or hang)")
_finish()
func _finish() -> void:
if _finished:
return
_finished = true
var report_path := OS.get_environment("MT_TEST_REPORT")
if report_path.is_empty():
report_path = _out.path_join("client-report.json")
var final := _report.finish()
_evidence["status"] = final.get("status", "FAIL")
# 自动判定之外仍需人工看图;写进报告,避免把自动 PASS 读成视觉签核完成。
final["manual_signoff"] = {"required": true, "status": "PENDING", "evidence": "forest-*.png + forest-map-evidence.json",
"scope": "tree monster silhouettes/materials, slope foot-root contact, dense-forest occlusion, dead poses"}
_evidence["manual_signoff"] = final.manual_signoff
var f := FileAccess.open(_out.path_join("forest-map-evidence.json"), FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(_evidence, "\t"))
f.close()
var written := _report.write(report_path)
var status := String(final.get("status", "FAIL"))
print("FOREST MAP RENDER: status=%s failures=%d blocked=%d output=%s" % [status,
final.get("failures", []).size(), final.get("blocked", []).size(), _out])
for line in final.get("failures", []):
printerr("FOREST MAP FAIL: " + String(line))
var code := 0 if status == "PASS" else (1 if status == "FAIL" else 2)
if not written:
printerr("FOREST MAP RENDER: cannot write client report")
code = 1
elif not OS.get_environment("MT_PLAYABLE_RUN_ID").is_empty():
code = 0 # 由父 runner 在真实退出后判定;非零只表示客户端没能写出报告。
# 被释放的 AudioStreamPlayer3D 的 playback 由音频线程异步移除;立即 quit 会报 ObjectDB 泄漏。
get_tree().create_timer(0.3, true, false, true).timeout.connect(func() -> void: get_tree().quit(code))
func _run_map(map_key: String, vp_data: Dictionary, races_override: Array, headless: bool) -> void:
var map_id := map_key.get_file()
var map_ev := {"map_key": map_key, "viewpoints": []}
_evidence.maps.append(map_ev)
var bounds := {}
var probe := GameScene.new()
for name in ["setting.txt", "Setting.txt"]:
var p := AssetRoot.path().path_join(map_key).path_join(name)
if FileAccess.file_exists(p):
bounds = probe._map_bounds(p)
break
probe.free()
if bounds.is_empty():
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "Setting.txt with BasePosition/MapSize not found for %s" % map_key)
return
var plan := Viewpoints.plan(vp_data, map_key, bounds)
if not plan.errors.is_empty():
_report.add_case("MAP-FOREST-LOAD-" + map_id, "BLOCKED", "viewpoint fixture invalid: " + "; ".join(plan.errors))
return
var size_m := Vector2(bounds.size) * Viewpoints.MAP_CELL_CM * 0.01
var races: Array = plan.races.duplicate()
var race_source := "fixture"
if not races_override.is_empty():
races = races_override.duplicate()
race_source = "MT_FOREST_RACES"
if races.is_empty():
races = CANDIDATE_RACES.duplicate()
race_source = "candidate_only"
map_ev["bounds"] = {"base_cm": [bounds.base.x, bounds.base.y], "size_tiles": [bounds.size.x, bounds.size.y]}
map_ev["races"] = races
map_ev["race_source"] = race_source
var client := Fixtures.FakeClient.new()
client.name = "ForestFakeClient"
get_tree().root.add_child(client)
var centre_cm: Vector2 = bounds.base + size_m * 50.0
client.spawn(MAIN_VID, "ForestRender", _net_pos(centre_cm), true)
var scene := GameScene.new()
scene.name = "ForestGameScene"
get_tree().root.add_child(scene)
var started := Time.get_ticks_msec()
await scene.setup(client, AssetRoot.path(), map_key)
var world: Node = scene.world
while world and int(world.call("get_load_report").get("stream_queue", 0)) > 0:
await get_tree().process_frame
if monster.model == null or monster.model.call("get_visual_aabb").size.length() <= 0.0:
_fail("tree monster has no visible geometry")
if scene.cam and scene.cam.has_method("snap_to_target"):
scene.cam.snap_to_target()
var load_report: Dictionary = world.call("get_load_report") if world else {}
map_ev["load"] = {"ms": Time.get_ticks_msec() - started, "report": load_report, "scene_map_path": scene.map_path}
var load_errors: Array[String] = []
if not scene._map_loaded():
load_errors.append("map did not load")
if scene.map_path != map_key:
load_errors.append("GameScene resolved %s instead of %s" % [scene.map_path, map_key])
for key in ["chunks_failed", "objects_missing_model"]:
if int(load_report.get(key, 0)) > 0:
load_errors.append("%s=%d" % [key, int(load_report.get(key, 0))])
if scene.net_world == null or scene.proto == null:
load_errors.append("net_world/mob_proto not ready; monsters would fall back to capsules")
_report.record("map_loaded", "forest_render", "MAP-FOREST-LOAD-" + map_id, 0, MAIN_VID, 0,
{"map_key": map_key, "elapsed_ms": map_ev.load.ms, "count": int(load_report.get("chunks_built", 0)),
"reason_code": "ok" if load_errors.is_empty() else "load_failed"})
_report.add_case("MAP-FOREST-LOAD-" + map_id, "FAIL" if not load_errors.is_empty() else "PASS",
"; ".join(load_errors) if not load_errors.is_empty() else "chunks=%d objects=%d trees=%d" % [
int(load_report.get("chunks_built", 0)), int(load_report.get("objects_placed", 0)), int(load_report.get("trees_placed", 0))])
if not load_errors.is_empty():
await _teardown(scene, client)
return
var viewpoints: Array = plan.viewpoints.duplicate()
var auto_kinds: Array = plan.missing_kinds.filter(func(k: String) -> bool: return k in Viewpoints.AUTO_KINDS)
if not auto_kinds.is_empty():
var trees := _tree_positions(world)
map_ev["tree_trunks_found"] = trees.size()
if not trees.is_empty():
var r := Rect2(trees[0], Vector2.ZERO)
for t in trees:
r = r.expand(t)
map_ev["tree_trunk_bounds_m"] = [r.position.x, r.position.y, r.end.x, r.end.y]
# headless 的 RenderingServerDummy 不保存 MultiMesh 实例数据,读回全是单位变换。
if trees.size() > 1 and r.size.is_zero_approx():
map_ev["tree_positions"] = "unavailable: MultiMesh instance transforms are not readable from the headless rendering server"
trees = PackedVector2Array()
var candidates := Viewpoints.pick_candidates(_terrain_samples(world, size_m), trees, size_m, auto_kinds)
for c: Dictionary in candidates:
viewpoints.append({"id": "auto-" + String(c.kind), "kind": c.kind, "status": "auto_candidate", "source": "auto",
"confirmed_by": "", "camera_yaw_deg": Viewpoints.DEFAULT_YAW_DEG, "local_m": c.p,
"server_cm": Viewpoints.to_server_cm(bounds, c.p),
"selection": {"range_m": c.range_m, "slope_ratio": c.slope_ratio, "tree_density": c.density}})
var motion_errors: Array[String] = []
var fallbacks: Array = []
var by_kind := {}
for vp: Dictionary in viewpoints:
var vp_ev := await _run_viewpoint(scene, client, world, vp, races, map_id, headless)
map_ev.viewpoints.append(vp_ev)
for line: String in vp_ev.motion_errors:
if not (line in motion_errors):
motion_errors.append(line)
fallbacks.append_array(vp_ev.fallbacks)
if not by_kind.has(vp.kind):
by_kind[vp.kind] = []
by_kind[vp.kind].append(vp_ev)
_report.record("viewpoint", "forest_render", "MAP-FOREST-VP-%s-%s" % [map_id, vp.kind], 0, 0, 0,
{"map_key": map_key, "state": vp.kind, "reason_code": vp.status, "count": vp_ev.mobs.size(),
"evidence": vp_ev.screenshots, "selected": vp.id})
for kind in Viewpoints.KINDS:
_add_kind_case(map_id, kind, by_kind.get(kind, []), map_ev, headless)
map_ev["reference_fallbacks"] = fallbacks
if viewpoints.is_empty():
_report.add_case("MAP-FOREST-MOTION-" + map_id, "BLOCKED", "no viewpoint to place tree monsters")
else:
_report.add_case("MAP-FOREST-MOTION-" + map_id, "FAIL" if not motion_errors.is_empty() else "PASS",
"; ".join(motion_errors) if not motion_errors.is_empty() else "wait/run/dead via net_world; %d reference keep-current-motion fallbacks need live confirmation" % fallbacks.size())
await _teardown(scene, client)
## 每种机位一个必测用例:任一机位断言失败即 FAIL;只有“经确认 + Metal 截图 + 全部断言通过”才 PASS。
func _add_kind_case(map_id: String, kind: String, results: Array, map_ev: Dictionary, headless: bool) -> void:
var case_id := "MAP-FOREST-VP-%s-%s" % [map_id, kind]
var errors: Array[String] = []
var screenshots: Array = []
var confirmed: Array[String] = []
var unconfirmed: Array[String] = []
for r: Dictionary in results:
screenshots.append_array(r.screenshots)
for e: String in r.errors:
errors.append("%s: %s" % [r.id, e])
if r.status == "confirmed":
confirmed.append(String(r.id))
else:
unconfirmed.append("%s(%s @ server_cm %s)" % [r.id, r.status, r.server_cm])
if not errors.is_empty():
_report.add_case(case_id, "FAIL", "; ".join(errors), screenshots)
elif results.is_empty():
var why := "no confirmed viewpoint and no usable terrain candidate"
if kind == "warp":
why = "warp points cannot be derived offline; add a confirmed viewpoint"
elif kind == "dense" and map_ev.has("tree_positions"):
why = "no confirmed viewpoint; tree density needs a Metal run (%s)" % map_ev.tree_positions
_report.add_case(case_id, "BLOCKED", why)
elif headless:
_report.add_case(case_id, "BLOCKED", "headless run has no Metal screenshot", screenshots)
elif confirmed.is_empty() or not unconfirmed.is_empty():
_report.add_case(case_id, "BLOCKED", "viewpoints not confirmed by the test environment owner: %s" % ", ".join(unconfirmed), screenshots)
else:
_report.add_case(case_id, "PASS", "confirmed %s: %s" % [kind, ", ".join(confirmed)], screenshots)
func _run_viewpoint(scene: Node, client: Node, world: Node, vp: Dictionary, races: Array, map_id: String, headless: bool) -> Dictionary:
var ev := {"id": vp.id, "kind": vp.kind, "status": vp.status, "source": vp.source,
"server_cm": [vp.server_cm.x, vp.server_cm.y], "local_m": [vp.local_m.x, vp.local_m.y],
"camera_yaw_deg": vp.camera_yaw_deg, "selection": vp.get("selection", {}),
"mobs": [], "screenshots": [], "errors": [], "motion_errors": [], "fallbacks": []}
var centre: Vector2 = vp.local_m
var yaw := deg_to_rad(float(vp.camera_yaw_deg))
var to_camera := Vector3(sin(yaw), 0.0, cos(yaw))
var right := Vector3.UP.cross(to_camera).normalized()
var base: Vector2 = vp.server_cm - vp.local_m * 100.0
# 主角放到怪群侧面,避免挡住机位。
var player_local := centre + Vector2(right.x, right.z) * 12.0
client.ents[MAIN_VID].pos = _net_pos(base + player_local * 100.0)
scene._place_player_at_net_pos(client.ents[MAIN_VID].pos)
var vids: Array[int] = []
for i in races.size():
var vid := MOB_VID_BASE + i
vids.append(vid)
client.ents[vid] = {"vid": vid, "name": "Race%d" % races[i], "race": int(races[i]), "ch_type": CHAR_TYPE_MONSTER,
"pos": _net_pos(vp.server_cm), "is_main": false, "func": 0, "moving": false, "angle_deg": 0.0,
"hp": 100, "max_hp": 100, "dead": false}
client.entity_spawned.emit(client.ents[vid])
await get_tree().process_frame
await get_tree().process_frame
var image_name := "forest-map-%s.png" % map_key.get_file()
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
if get_tree().root.get_texture().get_image().save_png(output.path_join(image_name)) != OK:
_fail("map screenshot could not be saved")
var report := {"passed": failures == 0, "failures": failures, "map": map_key,
"tree_monster_race": race, "tree_monster_model": monster.model != null,
"map_loaded": scene._map_loaded(), "screenshot": image_name if DisplayServer.get_name() != "headless" else "",
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
"scope": "production GameScene map load plus offline tree-monster placement; no server spawn"}
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(report, "\t"))
file.close()
var client_report_path := OS.get_environment("MT_TEST_REPORT")
if not client_report_path.is_empty():
playable_report.add_case("MAP-FOREST-01", "PASS" if failures == 0 else "FAIL",
"map=%s tree_monster_race=%d" % [map_key, race])
playable_report.write(client_report_path)
print("FOREST MAP RENDER: ", JSON.stringify(report))
# 按最大足迹排成面向相机的网格,再让 net_world 按新 pos 贴地。
var spacing := 3.5
for vid in vids:
var node: Node3D = scene.net_world.node_for(vid)
if node != null and node.has_method("resolve_motion") and node.model:
# get_visual_aabb 是模型本地单位(未乘 unit_scale),先变到世界米。
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
spacing = maxf(spacing, maxf(box.size.x, box.size.z) + 1.0)
var cols := int(ceil(sqrt(float(races.size()))))
var rows := int(ceil(float(races.size()) / float(cols)))
for i in vids.size():
var offset := right * (float(i % cols) - (cols - 1) * 0.5) * spacing \
+ to_camera * (float(i / cols) - (rows - 1) * 0.5) * spacing
client.ents[vids[i]].pos = _net_pos(base + (centre + Vector2(offset.x, offset.z)) * 100.0)
for _i in 3:
await get_tree().process_frame
var cam := Camera3D.new()
cam.name = "ForestFixedCamera"
cam.fov = 50.0
scene.add_child(cam)
cam.current = true
var top := 0.0
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
var mob := await _measure_mob(node, int(races[i]), vids[i], world, ev)
ev.mobs.append(mob)
top = maxf(top, float(mob.get("height_m", 0.0)))
var ground := float(world.call("sample_height", centre.x, centre.y))
var look := Vector3(centre.x, ground + maxf(1.0, top * 0.5), centre.y)
var distance := maxf(12.0, spacing * float(maxi(cols, rows)) * 1.15)
var pitch := deg_to_rad(CAMERA_PITCH_DEG)
cam.position = look + (to_camera * cos(pitch) + Vector3.UP * sin(pitch)) * distance
cam.position.y = maxf(cam.position.y, float(world.call("sample_height", cam.position.x, cam.position.z)) + 1.5)
cam.look_at(look, Vector3.UP)
ev["camera"] = {"position": [cam.position.x, cam.position.y, cam.position.z], "look_at": [look.x, look.y, look.z], "fov": cam.fov,
"distance_m": distance, "grid_spacing_m": spacing}
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
if node == null:
continue
var anchor := node.global_position + Vector3.UP * 0.5
var screen := get_viewport().get_visible_rect().grow(-8.0)
if cam.is_position_behind(anchor) or not screen.has_point(cam.unproject_position(anchor)):
ev.errors.append("race %d is outside the fixed camera frame" % races[i])
if not headless:
await _screenshot(cam, "forest-%s-%s-wait.png" % [map_id, vp.id], ev)
# 生产路径动作:移动(资源无 RUN/WALK 时按旧客户端保留当前动作)与死亡。
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
if node != null and node.has_method("resolve_motion"):
node.anim.set_process(true)
client.ents[vids[i]].moving = true
for _i in 2:
await get_tree().process_frame
for i in vids.size():
_check_motion(scene.net_world.node_for(vids[i]), int(races[i]), "run", ev)
client.ents[vids[i]].moving = false
client.ents[vids[i]].dead = true
client.entity_dead.emit(vids[i])
await get_tree().create_timer(0.4).timeout
for i in vids.size():
var node: Node3D = scene.net_world.node_for(vids[i])
_check_motion(node, int(races[i]), "dead", ev)
if node != null and node.has_method("resolve_motion") and String(node.last_motion_resolution().path) != "":
if absf(node.rotation.x) > 0.01 or absf(node.rotation.z) > 0.01:
ev.motion_errors.append("race %d dead motion is tilted by the scene (rotation.x=%.2f); reference Die() only plays DEAD" % [races[i], node.rotation.x])
node.anim.set_process(false)
node.anim.call("set_time", maxf(0.0, float(node.anim.call("get_duration")) - 1.0 / 60.0))
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var h := float(world.call("sample_height", node.global_position.x, node.global_position.z))
for mob: Dictionary in ev.mobs:
if int(mob.vid) == vids[i]:
mob["dead_end_bottom_gap_m"] = box.position.y - h
if not headless:
await _screenshot(cam, "forest-%s-%s-dead.png" % [map_id, vp.id], ev)
for vid in vids:
client.ents.erase(vid)
client.entity_despawned.emit(vid)
cam.queue_free()
for _i in 2:
await get_tree().process_frame
return ev
func _measure_mob(node: Node3D, race: int, vid: int, world: Node, ev: Dictionary) -> Dictionary:
var out := {"race": race, "vid": vid}
if node == null or not node.has_method("resolve_motion") or node.model == null or node.anim == null:
ev.errors.append("race %d spawned without a MobView model (placeholder capsule)" % race)
out["model"] = false
return out
out["model"] = true
var anim: Node = node.anim
anim.set("blend_time", 0.0)
anim.set_process(false)
var root := node.global_position
var h_root := float(world.call("sample_height", root.x, root.z))
out["world_position"] = [root.x, root.y, root.z]
out["sample_height_m"] = h_root
out["root_gap_m"] = root.y - h_root
out["ground_offset_m"] = float(node.model.call("get_ground_offset"))
out["model_local_y_m"] = node.model.position.y
out["wait_resolution"] = node.last_motion_resolution()
out["wait_motion_path"] = String(anim.get("anim_path"))
out["materials"] = _material_report(node.model)
if absf(root.y - h_root) > ROOT_GAP_M:
ev.errors.append("race %d root is %.3fm off terrain; net_world grounding broken" % [race, root.y - h_root])
if String(out.wait_motion_path).is_empty():
ev.motion_errors.append("race %d has no wait motion after spawn" % race)
if int(out.materials.surfaces) == 0 or int(out.materials.textured) < int(out.materials.surfaces) or int(out.materials.invalid) > 0:
ev.errors.append("race %d materials: %s" % [race, out.materials])
var box: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var half := Vector2(maxf(0.3, box.size.x * 0.5), maxf(0.3, box.size.z * 0.5))
var c := Vector2(box.get_center().x, box.get_center().z)
var heights: Array[float] = []
for d in [Vector2.ZERO, Vector2(half.x, half.y), Vector2(-half.x, half.y), Vector2(half.x, -half.y), Vector2(-half.x, -half.y)]:
heights.append(float(world.call("sample_height", c.x + d.x, c.y + d.y)))
var footprint_range: float = heights.max() - heights.min()
out["footprint_range_m"] = footprint_range
out["height_m"] = box.size.y
var samples: Array = []
var off := 0
var duration := float(anim.call("get_duration"))
for f in WAIT_SAMPLES:
anim.call("set_time", clampf(duration * f, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
var b: AABB = node.model.global_transform * (node.model.call("get_visual_aabb") as AABB)
var gap := b.position.y - h_root
samples.append({"fraction": f, "aabb_bottom_gap_m": gap})
if not b.position.is_finite() or not b.size.is_finite():
ev.errors.append("race %d wait sample %.2f has non-finite bounds" % [race, f])
if absf(gap) > GROUND_GAP_M:
off += 1
out["wait_samples"] = samples
anim.call("set_time", clampf(duration * 0.5, 0.0, maxf(0.0, duration - 1.0 / 60.0)))
if footprint_range <= FLAT_FOOTPRINT_M:
out["contact_check"] = "flat_auto"
if off == WAIT_SAMPLES.size():
ev.errors.append("race %d floats/buries on flat ground: every wait sample exceeds %.2fm (%s)" % [race, GROUND_GAP_M, samples])
else:
out["contact_check"] = "slope_manual"
return out
func _check_motion(node: Node3D, race: int, state: String, ev: Dictionary) -> void:
if node == null or not node.has_method("resolve_motion"):
return
var res: Dictionary = node.last_motion_resolution()
var path := String(node.anim.get("anim_path"))
if String(res.get("requested_state", "")) != state:
ev.motion_errors.append("race %d: net_world did not request %s (last=%s)" % [race, state, res.get("requested_state", "")])
return
if String(res.path).is_empty():
if state == "run" and String(res.fallback_reason) == "reference_keep_current_motion" and not path.is_empty():
ev.fallbacks.append({"race": race, "state": state, "kept_motion_path": path, "needs_live_confirmation": true})
else:
ev.motion_errors.append("race %d %s has no motion and is not a reference keep-current fallback" % [race, state])
return
if path != String(res.path):
ev.motion_errors.append("race %d %s resolved %s but plays %s" % [race, state, res.path, path])
if state == "dead" and (bool(node.anim.get("loop")) or not String(res.motion).contains("DEAD")):
ev.motion_errors.append("race %d dead plays %s loop=%s" % [race, res.motion, node.anim.get("loop")])
func _screenshot(cam: Camera3D, file_name: String, ev: Dictionary) -> void:
cam.current = true
# 证据图只拍 3D 场景:HUD 与血条层会让“非均匀”检查在场景全坏时仍然通过。
var hidden: Array[CanvasLayer] = []
for layer in get_tree().root.find_children("*", "CanvasLayer", true, false):
if (layer as CanvasLayer).visible:
(layer as CanvasLayer).visible = false
hidden.append(layer)
for _i in 3:
await get_tree().process_frame
await RenderingServer.frame_post_draw
var image := get_tree().root.get_texture().get_image()
for layer in hidden:
if is_instance_valid(layer):
layer.visible = true
if image == null or image.is_empty() or not _has_detail(image):
ev.errors.append("screenshot %s is empty or uniform" % file_name)
return
if image.save_png(_out.path_join(file_name)) != OK:
ev.errors.append("screenshot %s could not be saved" % file_name)
return
ev.screenshots.append(file_name)
func _has_detail(image: Image) -> bool:
var first := image.get_pixel(0, 0)
var differing := 0
var total := 0
for y in range(0, image.get_height(), 16):
for x in range(0, image.get_width(), 16):
total += 1
var p := image.get_pixel(x, y)
if absf(p.r - first.r) + absf(p.g - first.g) + absf(p.b - first.b) > 0.05:
differing += 1
return total > 0 and float(differing) / float(total) > 0.1
func _material_report(model: Node) -> Dictionary:
var out := {"surfaces": 0, "textured": 0, "two_sided": 0, "invalid": 0}
for node in model.find_children("*", "MeshInstance3D", true, false):
var instance := node as MeshInstance3D
if instance.mesh == null:
continue
for surface in instance.mesh.get_surface_count():
out.surfaces += 1
var material := instance.get_active_material(surface)
if material is ShaderMaterial and (material as ShaderMaterial).shader != null:
var sm := material as ShaderMaterial
if bool(sm.get_shader_parameter("use_texture")) and sm.get_shader_parameter("albedo_tex") != null:
out.textured += 1
if sm.shader.code.contains("cull_disabled"):
out.two_sided += 1
elif material is BaseMaterial3D:
if (material as BaseMaterial3D).get_texture(BaseMaterial3D.TEXTURE_ALBEDO) != null:
out.textured += 1
if (material as BaseMaterial3D).cull_mode == BaseMaterial3D.CULL_DISABLED:
out.two_sided += 1
else:
out.invalid += 1
return out
func _terrain_samples(world: Node, size_m: Vector2) -> Array:
var out: Array = []
var x := GRID_STEP_M * 0.5
while x < size_m.x:
var z := GRID_STEP_M * 0.5
while z < size_m.y:
var lo := INF
var hi := -INF
for d in [Vector2.ZERO, Vector2(FOOTPRINT_HALF_M, 0), Vector2(-FOOTPRINT_HALF_M, 0), Vector2(0, FOOTPRINT_HALF_M), Vector2(0, -FOOTPRINT_HALF_M)]:
var h := float(world.call("sample_height", x + d.x, z + d.y))
lo = minf(lo, h)
hi = maxf(hi, h)
out.append({"p": Vector2(x, z), "range_m": hi - lo, "span_m": FOOTPRINT_HALF_M * 2.0,
"blocked": bool(world.call("is_blocked", x, z))})
z += GRID_STEP_M
x += GRID_STEP_M
return out
func _tree_positions(world: Node) -> PackedVector2Array:
var out := PackedVector2Array()
for node in world.find_children("Trees_*", "MultiMeshInstance3D", true, false):
var mm := (node as MultiMeshInstance3D).multimesh
if mm == null:
continue
for i in mm.instance_count:
var origin := ((node as MultiMeshInstance3D).global_transform * mm.get_instance_transform(i)).origin
out.append(Vector2(origin.x, origin.z))
return out
func _net_pos(server_cm: Vector2) -> Vector3:
return Vector3(server_cm.x * 0.01, 0.0, -server_cm.y * 0.01)
func _teardown(scene: Node, client: Node) -> void:
scene.queue_free()
client.queue_free()
await get_tree().process_frame
get_tree().quit(1 if failures else 0)
for _i in 3:
await get_tree().process_frame