extends SceneTree ## Editor-Test (headless): Karte t_96e3c4ee — Test-Startpunkt per Klick ## („hinklicken, wo man beim Testen spawnt", statt nur Team-Spawnpunkte). ## Ausführen: godot4 --headless --path . --script res://tests/test_playtest_spawn.gd ## Exit 0 = alles grün. ## ## Deckt die Karten-Pflichten ab: ## 1. Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich) + kein Undo. ## 2. Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm). ## 3. Fallback-Reihenfolge je einmal belegt (Test-Start → Team-Spawn → Mitte). ## 4. Nach „Neustart" wiederhergestellt (Sidecar laden/leeren). ## 5. T im Testlauf versetzt genau dorthin, Augenhöhe 1,70 m, Kollision ## beachtet (blocked = ok=false, Position unverändert; Kisten-Dach = oben). ## 6. Marker existiert nur bei gesetztem Punkt. ## 7. Test-Start taucht in KEINER Spawn-Zone/Analyse auf. var failures := 0 var checks := 0 var main: Control var subvp: SubViewport func _initialize() -> void: _run_all.call_deferred() func _run_all() -> void: # Sauberer Sidecar-Start (Suite darf von früheren Läufen nichts erben). TestStartStore.clear_point("") var scene: PackedScene = load("res://scenes/main.tscn") _check(scene != null, "scenes/main.tscn laedt") if scene == null: _finish() return main = scene.instantiate() root.add_child(main) await process_frame await process_frame main.call("_test_start_reload") # frischer Zustand nach dem Key-Clear var container := _find_viewport_container(main) if container != null: subvp = container.get_child(0) as SubViewport subvp.size = Vector2i(800, 600) # deterministisches Fadenkreuz await process_frame # Test-Map: hügeliges Terrain (Höhe steigt mit x), Team-0-Spawn, Objekt. var doc: Dictionary = SvmapDocument.create_new("Test-Start-Karte", 100.0, "mittag") var terrain: Dictionary = doc.get("terrain", {}) var res := int(terrain.get("resolution", 0)) var heights := PackedFloat32Array() heights.resize(res * res) for zi in res: for xi in res: heights[zi * res + xi] = 2.0 + float(xi) * 0.1 # 2.0 … +Ramp terrain["min_height_m"] = 2.0 terrain["max_height_m"] = 2.0 + float(res - 1) * 0.1 terrain["heightmap_r16_b64"] = TerrainField.encode(heights, 2.0, 2.0 + float(res - 1) * 0.1) doc["objects"] = [ {"id": "crate_wood", "pos": [10.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}, ] # V11-konform: 4 Punkte pro Team (sonst lehnt der Validator das Speichern ab). doc["spawn_zones"] = { "0": {"points": [ {"id": "A1", "pos": [-20.0, 0.0, 0.0]}, {"id": "A2", "pos": [-24.0, 0.0, 2.0]}, {"id": "A3", "pos": [-22.0, 0.0, -3.0]}, {"id": "A4", "pos": [-26.0, 0.0, -2.0]}, ]}, "1": {"points": [ {"id": "B1", "pos": [20.0, 0.0, 0.0]}, {"id": "B2", "pos": [24.0, 0.0, 2.0]}, {"id": "B3", "pos": [22.0, 0.0, -3.0]}, {"id": "B4", "pos": [26.0, 0.0, -2.0]}, ]}, } main.set("doc", doc) main.set("current_path", "") main.set("dirty", false) main.get("history").begin(doc) main.call("_clear_edit_state") main.call("_refresh_all") main.call("_test_start_reload") await process_frame await process_frame _test_svmap_unchanged() _test_height_snaps_to_terrain() _test_fallback_order() _test_sidecar_roundtrip() _test_marker_visibility() _test_t_key_toggles_tool() _test_not_a_game_spawn() await _test_playtest_teleport() _finish() func _finish() -> void: TestStartStore.clear_point("") # Suite hinterlässt keinen Müll if failures == 0: print("ALL OK: %d checks" % checks) quit(0) else: print("FAILED: %d/%d checks" % [failures, checks]) quit(1) func _check(cond: bool, label: String) -> void: checks += 1 if cond: print(" ok: %s" % label) else: failures += 1 print(" FAIL: %s" % label) func _find_viewport_container(node: Node) -> SubViewportContainer: if node is SubViewportContainer: return node as SubViewportContainer for child in node.get_children(): var found := _find_viewport_container(child) if found != null: return found return null func _tool() -> Node: return main.get("test_start_tool") func _marker() -> Node3D: var t := _tool() if t == null: return null return t.get("_marker") ## --- 1: Setzen/Löschen ändert die .svmap nicht ------------------------------ func _test_svmap_unchanged() -> void: print("Testgruppe: Setzen/Löschen ändert die .svmap NICHT (Byte-Vergleich)") var path := "user://test_playtest_spawn_a.svmap" var r1 := SvmapIO.save_to_file(path, main.get("doc")) _check(bool(r1.get("ok")), "Map vor dem Setzen speicherbar (Validator ok)") var bytes_before := FileAccess.get_file_as_bytes(path) var doc_before := str(main.get("doc")) var undo_before: bool = main.get("history").call("can_undo") _tool().call("demo_set", 20.0, -30.0) _check(main.get("test_start_point") != null, "Test-Start gesetzt (main.test_start_point)") var r2 := SvmapIO.save_to_file(path, main.get("doc")) _check(bool(r2.get("ok")), "Map nach dem Setzen speicherbar") var bytes_after_set := FileAccess.get_file_as_bytes(path) _check(bytes_after_set == bytes_before, "Setzen: .svmap byte-identisch") _check(str(main.get("doc")) == doc_before, "Setzen: Dokument unverändert") _check(not main.get("history").call("can_undo"), "Setzen: KEIN Undo-Eintrag") main.call("_test_start_delete") _check(main.get("test_start_point") == null, "Test-Start gelöscht") var bytes_after_del := FileAccess.get_file_as_bytes(path) _check(bytes_after_del == bytes_before, "Löschen: .svmap byte-identisch") _check(str(main.get("doc")) == doc_before, "Löschen: Dokument unverändert") DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) ## --- 2: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm) -------------------- func _test_height_snaps_to_terrain() -> void: print("Testgruppe: Höhe = Terrainhöhe an der Stelle (Toleranz 1 cm)") var doc: Dictionary = main.get("doc") _tool().call("demo_set", 30.0, 5.0) var expected := SvmapDocument.terrain_height_at(doc, 30.0, 5.0) var marker := _marker() _check(marker != null and marker.visible, "Marker sichtbar nach dem Setzen") _check(absf(marker.position.y - expected) <= 0.01, "Marker-Höhe == Terrainhöhe (%.3f == %.3f, Δ %.4f m)" % [ marker.position.y, expected, absf(marker.position.y - expected)]) _check(absf(marker.position.x - 30.0) < 0.001 and absf(marker.position.z - 5.0) < 0.001, "Marker x/z exakt am Klickpunkt") # Klick auf ein Objekt: Punkt auf dem Boden DARUNTER (gleiche Höhe-Regel). _tool().call("demo_set", 10.0, 0.0, "auf dem Boden unter dem Objekt") var expected_obj := SvmapDocument.terrain_height_at(doc, 10.0, 0.0) _check(absf(marker.position.y - expected_obj) <= 0.01, "Objekt-Treffer: Marker auf dem Boden DARUNTER (%.3f == %.3f)" % [ marker.position.y, expected_obj]) var status: String = main.status_label.text _check(status.contains("Boden darunter") or status.contains("unter dem Objekt"), "Statuszeile meldet Klartext „unter dem Objekt“: '%s'" % status) main.call("_test_start_delete") ## --- 3: Fallback-Reihenfolge ------------------------------------------------- func _test_fallback_order() -> void: print("Testgruppe: Fallback-Reihenfolge (Test-Start → Team-Spawn → Mitte)") var doc: Dictionary = main.get("doc") # a) Test-Start gewinnt. var r: Dictionary = TestStart.resolve_start(doc, Vector3(33.0, 0.0, -44.0)) _check(str(r.get("quelle")) == "test", "Fallback 1: gesetzter Test-Start gewinnt") _check(absf((r.get("pos") as Vector3).x - 33.0) < 0.001 and absf((r.get("pos") as Vector3).z + 44.0) < 0.001, "Fallback 1: Position == Test-Start (x/z)") _check(absf((r.get("pos") as Vector3).y - SvmapDocument.terrain_height_at(doc, 33.0, -44.0)) <= 0.01, "Fallback 1: Höhe automatisch vom Terrain") # b) Kein Test-Start → erster Team-Spawnpunkt (Team Blau = „0“). r = TestStart.resolve_start(doc, null) _check(str(r.get("quelle")) == "team", "Fallback 2: erster Team-Spawnpunkt") _check(absf((r.get("pos") as Vector3).x + 20.0) < 0.001, "Fallback 2: Position == Team-Blau-Spawn A1 (-20, 0)") # c) Weder noch → Kartenmitte. var ohne: Dictionary = doc.duplicate(true) ohne["spawn_zones"] = {} r = TestStart.resolve_start(ohne, null) _check(str(r.get("quelle")) == "mitte", "Fallback 3: Kartenmitte") var bmin: Array = doc.get("bounds", {}).get("min", []) var bmax: Array = doc.get("bounds", {}).get("max", []) _check(absf((r.get("pos") as Vector3).x - (float(bmin[0]) + float(bmax[0])) * 0.5) < 0.001, "Fallback 3: Mitte == Bounds-Mitte") # Klartext-Hinweise je Stufe. _check(str(r.get("status")).length() > 0 and str(TestStart.resolve_start(doc, Vector3.ZERO)["status"]).length() > 0, "Jede Fallback-Stufe liefert einen Klartext-Hinweis") ## --- 4: Nach Neustart wiederhergestellt (Sidecar) ---------------------------- func _test_sidecar_roundtrip() -> void: print("Testgruppe: Sidecar — nach Neustart wiederhergestellt") var pfad := "/tmp/scopedvr_test_karte.svmap" TestStartStore.save_point(pfad, Vector3(12.5, 0.0, -7.25)) var loaded: Variant = TestStartStore.load_point(pfad) _check(loaded != null, "Sidecar: Punkt gespeichert + lesbar") if loaded != null: var v := loaded as Vector3 _check(absf(v.x - 12.5) < 0.001 and absf(v.z + 7.25) < 0.001, "Sidecar: x/z exakt (%.2f, %.2f)" % [v.x, v.z]) # editor-Flow: setzen → main verliert den Zustand („Neustart") → reload holt ihn zurück. _tool().call("demo_set", 3.0, 4.0) main.set("test_start_point", null) main.call("_test_start_reload") var p: Variant = main.get("test_start_point") _check(p != null, "Neustart-Simulation: Punkt aus dem Sidecar wieder da") if p != null: var pv := p as Vector3 _check(absf(pv.x - 3.0) < 0.001 and absf(pv.z - 4.0) < 0.001, "Neustart-Simulation: gleiche Position (%.1f, %.1f)" % [pv.x, pv.z]) main.call("_test_start_delete") _check(TestStartStore.load_point("") == null, "Löschen entfernt den Sidecar-Eintrag") ## --- 6: Marker existiert nur bei gesetztem Punkt ------------------------------ func _test_marker_visibility() -> void: print("Testgruppe: Marker existiert nur bei gesetztem Punkt") var marker := _marker() _check(marker != null and not marker.visible, "Ohne Punkt: Marker unsichtbar") _tool().call("demo_set", -5.0, 8.0) _check(marker.visible, "Mit Punkt: Marker sichtbar") main.call("_test_start_delete") _check(not marker.visible, "Nach Löschen: Marker wieder unsichtbar") ## --- 6b: Taste T schaltet das Werkzeug im Editor um --------------------------- func _test_t_key_toggles_tool() -> void: print("Testgruppe: Taste T schaltet das Test-Start-Werkzeug um") _check(str(main.get("tool_mode")) == "objects", "Start im Objekt-Werkzeug (Vorbedingung)") main._unhandled_key_input(_press_key(KEY_T)) _check(str(main.get("tool_mode")) == "teststart", "T: Test-Start-Werkzeug an") var btn: Button = main.get("test_start_btn") _check(btn != null and btn.button_pressed, "Toolbar-Knopf leuchtet mit") var panel: Control = main.get("test_start_panel") _check(panel != null and panel.visible, "Panel (Info + Löschen) sichtbar") main._unhandled_key_input(_press_key(KEY_T)) _check(str(main.get("tool_mode")) == "objects", "T nochmal: zurück zum Objekt-Werkzeug") _check(not panel.visible, "Panel wieder zu") # ESC im Werkzeug beendet es, der Punkt bleibt. main._unhandled_key_input(_press_key(KEY_T)) _tool().call("demo_set", 1.0, 2.0) main._unhandled_key_input(_press_key(KEY_ESCAPE)) _check(str(main.get("tool_mode")) == "objects", "ESC: Werkzeug beendet") _check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt") main.call("_test_start_delete") ## --- 7: Test-Start taucht in keiner Spawn-Zone/Analyse auf -------------------- func _test_not_a_game_spawn() -> void: print("Testgruppe: Kein Spiel-Spawnpunkt (Spawn-Zonen/Analyse unberührt)") var doc: Dictionary = main.get("doc") var zones_before := str(doc.get("spawn_zones", {})) var check_before := SpawnOps.ready_report(doc, main.get("manifest")) _tool().call("demo_set", 44.0, 44.0) _check(str(doc.get("spawn_zones", {})) == zones_before, "Setzen: spawn_zones im Dokument byte-identisch") var check_after := SpawnOps.ready_report(doc, main.get("manifest")) _check(str(check_after) == str(check_before), "Setzen: Map-Check-Ergebnis unverändert") var live_before := SpawnOps.live_status(doc, main.get("manifest")) _tool().call("demo_set", -44.0, 33.0) _check(SpawnOps.live_status(doc, main.get("manifest")) == live_before, "Weiteres Setzen: Live-Spawn-Status unverändert") main.call("_test_start_delete") _check(str(doc.get("spawn_zones", {})) == zones_before, "Löschen: spawn_zones im Dokument byte-identisch") ## --- 5: T im Testlauf versetzt genau dorthin (Augenhöhe, Kollision) ---------- func _test_playtest_teleport() -> void: print("Testgruppe: T im Testlauf — versetzt genau dorthin, Augenhöhe, Kollision") main.set("test_start_point", null) main.call("_test_start_reload") main.call("_on_test_run") await process_frame var pt: Node = main.get("playtest") _check(pt != null and bool(pt.get("active")), "Testlauf läuft (Vorbedingung)") if pt == null: return var body: Node3D = pt.get("body") var doc: Dictionary = main.get("doc") # a) Fadenkreuz-Treffer: Blick 45° nach vorn-unten. Das Ramp-Terrain hängt # nur von x ab — der Strahl (yaw 0) bleibt auf konstantem x, also ist # die Ziel-Höhe analytisch exakt. body.set("yaw_deg", 0.0) # Blick nach -Z body.set("pitch_deg", -45.0) body.call("_apply_look") # yaw/pitch erst durch _apply_look auf die Kamera var eye: Vector3 = body.position + Vector3(0.0, float(body.get("eye_height")), 0.0) var cam: Camera3D = body.get("look_cam") var dir: Vector3 = cam.global_transform.basis * Vector3(0.0, 0.0, -1.0) _check(absf(dir.y + 0.7071) < 0.01, "Kamera-Blick zeigt 45° nach unten (dir.y ≈ -0.707)") var h0: float = body.call("terrain_height", eye.x, eye.z) var th := absf((eye.y - h0) / dir.y) var expected := eye + dir * th main._unhandled_key_input(_press_key(KEY_T)) # ECHTER T-Druck (main-Verdrahtung) await process_frame var np := body.position _check(absf(np.x - expected.x) < 0.15 and absf(np.z - expected.z) < 0.15, "Teleport-Ziel == Fadenkreuz-Punkt (%.2f,%.2f ≈ %.2f,%.2f)" % [ np.x, np.z, expected.x, expected.z]) var ground_here: float = body.call("terrain_height", np.x, np.z) _check(absf(np.y - ground_here) <= 0.01, "Fusspunkt auf Terrainhöhe (Δ %.4f m)" % absf(np.y - ground_here)) _check(absf(float(cam.position.y) - 1.7) < 0.001, "Augenhöhe 1,70 m (look_cam-Offset)") var stored: Variant = main.get("test_start_point") _check(stored != null and absf((stored as Vector3).x - np.x) < 0.01 and absf((stored as Vector3).z - np.z) < 0.01, "T hat den Test-Start AUF den Fadenkreuz-Punkt gesetzt") var msg: String = (pt.get("hud_msg") as Label).text _check(msg.contains("Test-Start"), "HUD meldet Klartext: '%s'" % msg) # b) Kollision: hohe Wand vor dem Spieler, flacher Blick (−10°) zielt HINTER # die Wand ins Terrain — der Wand-AABB-Treffer kommt zuerst → ABGELEHNT. # (Wand ragt weit über die Augenhöhe am Ramp-Terrain: Augen ≈ 7,5 m dort.) var vor := Vector3(body.position) body.obstacles.append(AABB(Vector3(vor.x - 2.0, -5.0, vor.z - 4.0), Vector3(4.0, 20.0, 0.6))) body.set("yaw_deg", 0.0) body.set("pitch_deg", -10.0) body.call("_apply_look") main._unhandled_key_input(_press_key(KEY_T)) await process_frame _check(body.position.distance_to(vor) < 0.001, "Im Objekt: Teleport abgelehnt, Position unverändert") var blocked_msg: String = (pt.get("hud_msg") as Label).text _check(blocked_msg.contains("nicht landen") or blocked_msg.contains("Weg"), "Abgelehnt: Klartext im HUD: '%s'" % blocked_msg) body.obstacles.pop_back() # c) Kisten-Dach: begehbare Block-Decke → oben drauf landen (Boden DARONTER, # nie im Objekt). Strahl von oben senkrecht auf die Kiste. var crate := AABB(Vector3(np.x + 5.0, np.y, np.z), Vector3(1.0, 1.0, 1.0)) body.obstacles.append(crate) var ccenter := crate.get_center() var roof: Dictionary = body.call("teleport_to_ground", Vector3(ccenter.x, crate.end.y + 2.0, ccenter.z), Vector3(0.0, -1.0, 0.0)) _check(bool(roof.get("ok")) and absf(float(roof.get("ground")) - crate.end.y) < 0.01, "Kisten-Dach: auf der Decke gelandet (ground %.2f == %.2f)" % [ float(roof.get("ground")), crate.end.y]) body.obstacles.pop_back() # d) ESC: zurück, Editor-Zustand intakt (Punkt bleibt gesetzt). var doc_before := str(doc) pt.call("handle_escape") await process_frame _check(main.get("playtest") == null, "ESC: zurück im Editor") _check(str(main.get("doc")) == doc_before, "ESC: Dokument byte-identisch") _check(main.get("test_start_point") != null, "ESC: gesetzter Test-Start bleibt") main.call("_test_start_delete") func _press_key(code: Key) -> InputEventKey: var key := InputEventKey.new() key.keycode = code key.physical_keycode = code key.pressed = true return key