# 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