scopedvr-map-editor/tests/test_build_spawn_mirror.gd
slyphax a76c3ecae7 Bauen-Modus neu: Bauteile als normale Objekte (bau_*), Snap an Kante/Zelle/Ebene, Gebäude = Gruppe
- BauKatalog: 12 Teile (Fundament, Boden, Wände mit Tür/Fenster/Scharte, Brüstung, Pfeiler, Giebel, Dächer, Treppe) auf Basis der CC0-Geometrie des Modular Builder 0.2.0, Wandhöhe 3 m
- BuildSnap: Wände auf Zellkanten, Böden in Zellmitte, Höhe aus Ebenen statt Klickhöhe, Ersetzen (Tür in Wand)
- Gebäude kopieren/auswählen/löschen im Bauen-Tab, Kopien bekommen eigene Gruppe
- Kollision serverkonsistent über Trimesh-Dateien (Türen/Fenster echte Löcher), tools/gen_bauteile.gd erzeugt GLB, Kollision, Registry-Fragment
- Alte gebaeude-Sektion wird beim Laden migriert, altes Bauen-Werkzeug entfernt, Resize behält Gruppennamen
- Tests: test_bauen (258 Checks), Playtest läuft Treppe/Tür

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 16:30:15 +01:00

302 lines
13 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bauteile (jetzt normale Objekte) + Spawnpunkte
## spiegeln (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md, Mathe aus M1
## symmetry_core.gd). Ausführen:
## godot --headless --path . --script res://tests/test_build_spawn_mirror.gd
## Exit 0 = alles grün. Läuft OHNE UI (BauOps/SpawnOps-Statics +
## ActionHistory) — GUI-Feinheiten (Statuszeile, Ghost) verifiziert der
## Desktop-Smoke, nicht hier.
##
## Abdeckung (Karten-Auftrag):
## · Bau-Teil setzen → Zwilling vorhanden (Punkt, Achse X, Achse Z)
## · Bau-Teil entfernen → beide weg
## · Verschieben/Drehen/Anheben spiegelt den Zwilling mit
## · Spawn setzen → gespiegelter Spawn (Gegner-Team, faire Gegenstelle)
## · Spawn nahe der Grenze → V9-Meldung statt Speichern
## · Modus AUS = Gegenprobe (Verhalten exakt wie bisher)
## · EIN Undo-Schritt nimmt Original + Zwilling zurück
var failures := 0
var checks := 0
const OFF := SymmetryCore.Mode.OFF
const POINT := SymmetryCore.Mode.POINT
const AXIS_X := SymmetryCore.Mode.AXIS_X
const AXIS_Z := SymmetryCore.Mode.AXIS_Z
# spawn_tool.gd hat bewusst KEINEN class_name (Node-Werkzeug) — Statics via preload.
const SpawnTool := preload("res://src/editor/spawn_tool.gd")
func _init() -> void:
_run_tests()
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 _run_tests() -> void:
_test_build_place_point()
_test_build_place_axes()
_test_build_replace_follows()
_test_build_off_gegenprobe()
_test_build_one_undo_step()
_test_spawn_add_mirror()
_test_spawn_move_delete_mirror()
_test_spawn_v9_bounds()
_test_spawn_off_gegenprobe()
_test_spawn_roundtrip_safe()
func _new_doc() -> Dictionary:
# 256-m-Karte: bounds ±128 zentriert → Kartenmitte (0, 0).
return SvmapDocument.create_new("Sym-M4-Test", 256.0, "mittag")
func _pos3(p: Dictionary) -> Vector3:
var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
return Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
func _near(a: float, b: float) -> bool:
return absf(a - b) < 0.001
# ---------------------------------------- Bauteile (normale Objekte) --------
func _flach() -> Callable:
return func(_x: float, _z: float) -> float: return 0.0
## Wand per echter Snap-Logik setzen (Hit knapp neben der Kantenmitte).
func _setze(doc: Dictionary, oid: String, hit: Vector3, mode: int, gid: int, spiegel := -1) -> Dictionary:
var snap := BuildSnap.berechne(oid, hit, 0.0, BuildSnap.sammle_teile(doc), _flach())
return BauOps.setze_teil(doc, oid, snap, gid, mode, spiegel)
func _test_build_place_point() -> void:
print("Testgruppe: Bauteil setzen → Zwilling (Punktspiegelung)")
var doc := _new_doc()
var gid := BauOps.gruppe_neu(doc)
var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
var objs: Array = doc["objects"]
_check(objs.size() == 2 and int(r["twin"]) == 1, "Original + Zwilling im Objekt-Block")
var orig: Dictionary = objs[0]
var twin: Dictionary = objs[1]
_check(str(twin["id"]) == "bau_wand", "Zwilling hat gleiche ID")
var op := _pos3(orig)
var tp := _pos3(twin)
_check(_near(tp.x, -op.x) and _near(tp.z, -op.z) and _near(tp.y, op.y), "Zwilling-Position punktgespiegelt")
_check(_near(fposmod(float(twin["rot_y_deg"]) - float(orig["rot_y_deg"]), 360.0), 180.0), "Zwilling um 180° gedreht")
var key := SymmetryCore.sym_of(orig)
_check(not key.is_empty() and SymmetryCore.sym_of(twin) == key, "gleiche sym-Kennung")
_check(GroupOps.group_id(orig) == gid and GroupOps.group_id(twin) == gid + 1, "Zwilling bekommt das Spiegel-Gebäude")
func _test_build_place_axes() -> void:
print("Testgruppe: Bauteil setzen → Zwilling (Achsen X / Z)")
for mode in [AXIS_X, AXIS_Z]:
var doc := _new_doc()
var gid := BauOps.gruppe_neu(doc)
_setze(doc, "bau_fundament", Vector3(11.0, 0.0, 21.0), mode, gid, gid + 1)
var objs: Array = doc["objects"]
_check(objs.size() == 2, "Modus %d: Zwilling da" % mode)
var op := _pos3(objs[0])
var tp := _pos3(objs[1])
if mode == AXIS_X:
_check(_near(tp.x, -op.x) and _near(tp.z, op.z), "Achse X: x gespiegelt, z gleich")
else:
_check(_near(tp.z, -op.z) and _near(tp.x, op.x), "Achse Z: z gespiegelt, x gleich")
func _test_build_replace_follows() -> void:
print("Testgruppe: Tür einsetzen → Zwilling bekommt auch die Tür")
var doc := _new_doc()
var gid := BauOps.gruppe_neu(doc)
_setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
var r := _setze(doc, "bau_wand_tuer", Vector3(11.0, 1.5, 20.1), POINT, gid, gid + 1)
var objs: Array = doc["objects"]
_check(bool(r["ersetzt"]) and objs.size() == 2, "ersetzt statt anhängen")
_check(str(objs[0]["id"]) == "bau_wand_tuer" and str(objs[1]["id"]) == "bau_wand_tuer", "Original UND Zwilling haben die Tür")
func _test_build_off_gegenprobe() -> void:
print("Testgruppe: Symmetrie AUS = ein Teil, kein Zwilling")
var doc := _new_doc()
var gid := BauOps.gruppe_neu(doc)
var r := _setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), OFF, gid)
_check((doc["objects"] as Array).size() == 1 and int(r["twin"]) == -1, "kein Zwilling im Modus AUS")
_check(SymmetryCore.sym_of((doc["objects"] as Array)[0]).is_empty(), "keine sym-Kennung")
func _test_build_one_undo_step() -> void:
print("Testgruppe: EIN Undo-Schritt nimmt Original + Zwilling zurück")
var doc := _new_doc()
var hist := ActionHistory.new()
hist.begin(doc)
var before := hist.snapshot(["objects", "group_names"])
if not before.has("group_names"):
before["group_names"] = null
var gid := BauOps.gruppe_neu(doc)
_setze(doc, "bau_wand", Vector3(11.0, 0.0, 20.1), POINT, gid, gid + 1)
hist.commit("Wand bauen", before, hist.snapshot(["objects", "group_names"]))
_check((doc["objects"] as Array).size() == 2, "nach dem Setzen: 2 Teile")
hist.undo()
_check((doc["objects"] as Array).is_empty(), "EIN Undo: beide weg")
hist.redo()
_check((doc["objects"] as Array).size() == 2, "Redo: beide wieder da")
# -------------------------------------------------------- Spawns: setzen -----
func _test_spawn_add_mirror() -> void:
print("Testgruppe: Spawn setzen → gespiegelter Spawn (Gegner-Team)")
var doc := _new_doc()
var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0)
var pid := str(point["id"])
var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, POINT)
_check(bool(res["ok"]), "Spiegelung ok: %s" % str(res["status"]))
_check(SpawnOps.count_points(doc, 1) == 5, "Team Rot: 4 Default + 1 Zwilling")
var twin := _spawn_by_id(doc, 1, spawn_partner_id(doc, pid))
_check(not twin.is_empty(), "gespiegelter Spawn vorhanden")
_check(_near(_pos3(twin).x, 100.0) and _near(_pos3(twin).z, -40.0),
"Zwilling-Position gespiegelt (100, −40)")
_check(_near(_pos3(twin).y, _pos3(point).y), "Zwilling-y = Terrain-Höhe am gespiegelten Punkt")
_check(_near(SpawnOps.zone_radius(doc, str(twin["id"])), SpawnOps.zone_radius(doc, pid)),
"Zwilling-Radius = Original-Radius")
_check(spawn_partner_id(doc, pid) == str(twin["id"]) and spawn_partner_id(doc, str(twin["id"])) == pid,
"Partner-Map verlinkt beide Richtungen")
# Achse X: z bleibt gleich.
var doc2 := _new_doc()
var p2 := SpawnOps.add_zone(doc2, 1, 100.0, 40.0)
SpawnTool.spawn_mirror_add(doc2, 1, str(p2["id"]), AXIS_X)
var twin2 := _spawn_by_id(doc2, 0, spawn_partner_id(doc2, str(p2["id"])))
_check(_near(_pos3(twin2).x, -100.0) and _near(_pos3(twin2).z, 40.0),
"Achse X: x gespiegelt, z bleibt")
# Auf der Mitte: nicht gedoppelt.
var doc3 := _new_doc()
var p3 := SpawnOps.add_zone(doc3, 0, 0.0, 0.0)
var res3: Dictionary = SpawnTool.spawn_mirror_add(doc3, 0, str(p3["id"]), POINT)
_check(str(res3["status"]).contains("nicht gedoppelt"), "Spawn auf der Mitte: Meldung")
_check(SpawnOps.count_points(doc3, 1) == 4, "Spawn auf der Mitte: kein Zwilling")
# ------------------------------------------- Spawns: verschieben / löschen ----
func _test_spawn_move_delete_mirror() -> void:
print("Testgruppe: Spawn verschieben/löschen — Zwilling folgt")
var doc := _new_doc()
var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0)
var pid := str(point["id"])
SpawnTool.spawn_mirror_add(doc, 0, pid, POINT)
var twin_id := spawn_partner_id(doc, pid)
# Verschieben des Originals → Zwilling auf gespiegelte NEUE Position.
SpawnOps.move_point(doc, 0, pid, -60.0, 20.0)
var msg: String = SpawnTool.spawn_mirror_move(doc, 0, pid, POINT)
_check(msg.contains("mitverschoben"), "Verschieben gespiegelt: %s" % msg)
var twin := _spawn_by_id(doc, 1, twin_id)
_check(_near(_pos3(twin).x, 60.0) and _near(_pos3(twin).z, -20.0),
"Zwilling auf gespiegelter neuer Position (60, −20)")
# Löschen des Originals (macht delete_selected) → Zwilling weg + Map sauber.
SpawnOps.remove_point(doc, 0, pid)
var del_msg: String = SpawnTool.spawn_mirror_delete(doc, pid, POINT)
_check(del_msg.contains("mit entfernt"), "Löschen gespiegelt: %s" % del_msg)
_check(_spawn_by_id(doc, 1, twin_id).is_empty(), "Zwilling-Spawn weg")
_check(SpawnOps.count_points(doc, 0) == 4 and SpawnOps.count_points(doc, 1) == 4,
"beide Teams wieder bei 4 Default-Zonen")
_check(SpawnTool.spawn_mirror_partner(doc, pid).is_empty(),
"Partner-Map-Einträge entfernt (kein Geist)")
# Zwilling löschen (andere Richtung) → Original mit weg.
var doc2 := _new_doc()
var p2 := SpawnOps.add_zone(doc2, 1, 100.0, -40.0)
var pid2 := str(p2["id"])
SpawnTool.spawn_mirror_add(doc2, 1, pid2, POINT)
var orig_id := spawn_partner_id(doc2, pid2)
SpawnTool.spawn_mirror_delete(doc2, pid2, POINT)
_check(_spawn_by_id(doc2, 0, orig_id).is_empty(), "Zwilling gelöscht → Original mit weg")
# --------------------------------------------------------- Spawns: V9 --------
func _test_spawn_v9_bounds() -> void:
print("Testgruppe: Spawn nahe der Grenze → V9-Meldung statt Speichern")
# Original außerhalb der Grenzen (z = 130 > 128 + 1): der gespiegelte
# Zwilling läge ebenfalls draußen → Meldung, NICHT speichern.
var doc := _new_doc()
var point := SpawnOps.add_zone(doc, 0, -100.0, 130.0)
var pid := str(point["id"])
var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, POINT)
_check(not bool(res["ok"]), "ok=false — Zwilling NICHT gespeichert")
_check(str(res["status"]).contains("V9"), "Status nennt V9")
_check(str(res["status"]).contains("Match-Grenzen"), "Status nennt die Match-Grenzen")
_check(SpawnOps.count_points(doc, 1) == 4, "Team Rot unverändert (4 Default)")
_check(SpawnTool.spawn_mirror_partner(doc, pid).is_empty(), "kein halber Partner-Link")
# Grenz-Nähe INNERHALB: spiegeln bleibt erlaubt (Toleranz ±1 m).
var doc2 := _new_doc()
var nah := SpawnOps.add_zone(doc2, 0, -100.0, 127.5)
var res2: Dictionary = SpawnTool.spawn_mirror_add(doc2, 0, str(nah["id"]), POINT)
_check(bool(res2["ok"]), "nahe der Grenze (|z| ≤ 129): gespiegelt ok")
_check(SpawnOps.count_points(doc2, 1) == 5, "Grenz-Nähe: Zwilling vorhanden")
# ---------------------------------------------------- Spawns: Modus AUS ------
func _test_spawn_off_gegenprobe() -> void:
print("Testgruppe: Modus AUS = Verhalten wie bisher (Spawns)")
var doc := _new_doc()
var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0)
var pid := str(point["id"])
var res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, pid, OFF)
_check(bool(res["ok"]) and str(res["status"]) == "", "OFF: still nichts gespiegelt")
_check(SpawnOps.count_points(doc, 1) == 4, "OFF: Team Rot unverändert")
SpawnOps.move_point(doc, 0, pid, -60.0, 20.0)
_check(SpawnTool.spawn_mirror_move(doc, 0, pid, OFF) == "", "OFF: Verschieben spiegelt nicht")
SpawnOps.remove_point(doc, 0, pid)
var del_res: String = SpawnTool.spawn_mirror_delete(doc, pid, OFF)
_check(del_res == "" and SpawnOps.count_points(doc, 0) == 4, "OFF: Löschen entfernt nur das Original")
## design_notes bleibt Loader-frei + rundtrip-sicher (Radius-Muster).
func _test_spawn_roundtrip_safe() -> void:
print("Testgruppe: Spawn-Spiegelung überlebt Speichern/Laden")
var doc := _new_doc()
(doc["info"] as Dictionary)["author"] = "Sly"
var point := SpawnOps.add_zone(doc, 0, -100.0, 40.0)
var pid := str(point["id"])
SpawnTool.spawn_mirror_add(doc, 0, pid, POINT)
var save: Dictionary = SvmapIO.save_to_file("user://test_build_spawn_mirror.svmap", doc)
_check(bool(save["ok"]), "save_to_file ok: %s" % str(save["errors"]))
var load: Dictionary = SvmapIO.load_from_file("user://test_build_spawn_mirror.svmap")
_check(bool(load["ok"]), "load_from_file ok (Prüfsumme stimmt)")
var back: Dictionary = load["data"]
_check(SpawnOps.count_points(back, 1) == 5, "Re-Import: gespiegelter Spawn noch da")
var notes: Dictionary = back.get("design_notes", {})
_check((notes.get(SpawnTool.SPAWN_MIRROR_KEY, {}) as Dictionary).has(pid),
"Re-Import: Partner-Link in design_notes überlebt")
# ------------------------------------------------------------------ Helfer ---
func _spawn_by_id(doc: Dictionary, team: int, pid: String) -> Dictionary:
if pid.is_empty():
return {}
for p in SpawnOps.points(doc, team):
if p is Dictionary and str(p.get("id", "")) == pid:
return p as Dictionary
return {}
func spawn_partner_id(doc: Dictionary, pid: String) -> String:
return SpawnTool.spawn_mirror_partner(doc, pid)