Files
mtgodot-poc/project/forest_viewpoints_test.gd
T
shenleiandClaude Opus 5 f39a55fdd5 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
2026-09-11 22:06:48 +09:00

93 lines
5.2 KiB
GDScript

# 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