diff --git a/src/editor/build_ops.gd b/src/editor/build_ops.gd index ae4bb18..b5251c4 100644 --- a/src/editor/build_ops.gd +++ b/src/editor/build_ops.gd @@ -350,3 +350,105 @@ static func _ray_tri(origin: Vector3, dir: Vector3, a: Vector3, b: Vector3, c: V if v < 0.0 or u + v > 1.0: return -1.0 return e2.dot(q) * inv + + +# ------------------------------------------------------ Symmetrie (M4) ------ +## Karte t_ffa207b1 (Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md): Im +## Symmetrie-Modus bekommt jedes gesetzte Bau-Teil automatisch einen Zwilling +## auf der anderen Kartenseite — gleiche Snap-Regeln (Position wird 1:1 +## gespiegelt, Parität „ecke"/„mitte" bleibt bei zentrierter Karte erhalten), +## gleiche kind_id, gleiche Punkte/Details. Gekoppelt über das `sym`-Feld aus +## M1 (SymmetryCore). ALLE Funktionen sind pure Statics → headless testbar. +## Aufrufer ist build_tool.gd (dort leben die Klick-Mutationen); die Historie +## snapshotet den Block „gebaeude" um den Aufruf herum → EIN Undo-Schritt +## nimmt Original UND Zwilling zurück. + +## V9-Grenzen (M4): dieselbe Regel wie der Loader-Kern (svmap_io.gd, +## _inside_bounds mit ±1 m Toleranz). true = gespiegeltes Teil darf gespeichert +## werden; false → Aufrufer meldet V9 im Status statt still zu speichern. +static func inside_match_bounds(pos: Vector3, doc: Dictionary) -> bool: + var bounds: Dictionary = doc.get("bounds", {}) + if bounds.is_empty(): + return true + return SvmapIO._inside_bounds([pos.x, pos.y, pos.z], bounds) + + +## Zwilling eines frisch gesetzten Bauteils anlegen (Symmetrie an). `teil` muss +## bereits im doc hängen (der sym-Link wird auf beide Einträge gesetzt). +## Rückgabe: Klartext-Zusatz für die Statuszeile ("" = still gespiegelt). +static func place_mirrored(doc: Dictionary, teil: Dictionary, mode: int) -> String: + if mode == SymmetryCore.Mode.OFF: + return "" + var label := kind_label(str(teil.get("id", ""))) + var pos := _pos_of(teil) + var center := SymmetryCore.map_center(doc) + if SymmetryCore.maps_to_self(mode, pos, center): + return "%s liegt auf der Symmetrie-Mitte — nicht gedoppelt." % label + var twin := teil.duplicate(true) # tief: punkte/details dürfen nicht aliasen + var mirror_pos := SymmetryCore.mirror_point(mode, pos, center) + twin["pos"] = [mirror_pos.x, mirror_pos.y, mirror_pos.z] + twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(teil.get("rot", 0.0))) + if not inside_match_bounds(mirror_pos, doc): + return "❌ V9: Gespiegeltes %s läge außerhalb der Match-Grenzen — nur das Original wurde gesetzt." % label + var key := SymmetryCore.next_sym_key(doc) + SymmetryCore.set_sym(teil, key) + SymmetryCore.set_sym(twin, key) + ensure_block(doc).append(twin) + return "" + + +## Zwilling nach Drehen/Anheben/Verschieben/Auf-Boden neu ausrichten: er bekommt +## immer die gespiegelte Form der NEUEN Transform des Originals (Drehung über +## mirror_rot_y, Position über mirror_point, y bleibt Skalar). Teile ohne +## sym-Partner (z. B. bei ausgeschalteter Symmetrie gebaut) bleiben unberührt. +## Läge der gespiegelte Zielpunkt außerhalb der V9-Grenzen, bleibt der Zwilling +## UNVERÄNDERT und die Meldung geht in den Status. Rückgabe: Klartext ("" = ok). +static func update_mirrored(doc: Dictionary, index: int, mode: int) -> String: + if mode == SymmetryCore.Mode.OFF: + return "" + var list := pieces(doc) + if index < 0 or index >= list.size() or not (list[index] is Dictionary): + return "" + var teil := list[index] as Dictionary + var key := SymmetryCore.sym_of(teil) + if key.is_empty(): + return "" + var partner_idx := SymmetryCore.find_partner_index(list, key, index) + if partner_idx < 0 or not (list[partner_idx] is Dictionary): + return "" + var mirror_pos := SymmetryCore.mirror_point(mode, _pos_of(teil), SymmetryCore.map_center(doc)) + if not inside_match_bounds(mirror_pos, doc): + return "❌ V9: Gespiegeltes Bauteil läge außerhalb der Match-Grenzen — Zwilling nicht verschoben." + var twin := list[partner_idx] as Dictionary + twin["pos"] = [mirror_pos.x, mirror_pos.y, mirror_pos.z] + twin["rot"] = SymmetryCore.mirror_rot_y(mode, float(teil.get("rot", 0.0))) + return "" + + +## Bauteil löschen — bei eingeschalteter Symmetrie den sym-Partner mit +## (entfernen → beide weg). Index-Verschiebung sicher: der HÖHERE Index wird +## zuerst entfernt. Modus AUS = exakt das alte Verhalten (nur ein Teil weg). +## Rückgabe: Klartext-Status ("" = nur das eine Teil entfernt). +static func remove_mirrored(doc: Dictionary, index: int, mode: int) -> String: + var list := pieces(doc) + if index < 0 or index >= list.size() or not (list[index] is Dictionary): + return "" + var teil := list[index] as Dictionary + var partner_idx := -1 + if mode != SymmetryCore.Mode.OFF: + partner_idx = SymmetryCore.find_partner_index(list, SymmetryCore.sym_of(teil), index) + if partner_idx < 0: + list.remove_at(index) + return "" + if partner_idx > index: + list.remove_at(partner_idx) + list.remove_at(index) + else: + list.remove_at(index) + list.remove_at(partner_idx) + return "Der gespiegelte Zwilling wurde mit entfernt." + + +static func _pos_of(teil: Dictionary) -> Vector3: + var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) + return Vector3(float(pos[0]), float(pos[1]), float(pos[2])) diff --git a/src/editor/build_tool.gd b/src/editor/build_tool.gd index 5486435..1db4420 100644 --- a/src/editor/build_tool.gd +++ b/src/editor/build_tool.gd @@ -200,10 +200,14 @@ func _place_piece() -> void: return BuildOps.ensure_block(main.doc).append(teil) var label := BuildOps.kind_label(kind_id) + # Symmetrie (M4, t_ffa207b1): Zwilling anlegen — innerhalb desselben + # Undo-Schritts (Snapshot läuft um beide Mutationen herum). + var mirror_msg: String = BuildOps.place_mirrored(main.doc, teil, _sym_mode()) main.history.commit("%s gesetzt" % label, before, main.history.snapshot(["gebaeude"])) _after_build_change() - main.call("_set_status", "%s gesetzt (%d Bauteile). Weiter klicken zum Bauen — R dreht, Strg+Z nimmt das letzte Teil zurück." % [ - label, BuildOps.piece_count(main.doc)]) + main.call("_set_status", "%s gesetzt (%d Bauteile).%s Weiter klicken zum Bauen — R dreht, Strg+Z nimmt das letzte Teil zurück." % [ + label, BuildOps.piece_count(main.doc), + "" if mirror_msg.is_empty() else " " + mirror_msg]) func _select_at(viewport_pos: Vector2) -> void: @@ -238,6 +242,8 @@ func rotate_selected() -> void: var teil: Dictionary = list[selected_index] var before: Dictionary = main.history.snapshot(["gebaeude"]) teil["rot"] = wrapf(float(teil.get("rot", 0.0)) + 90.0, 0.0, 360.0) + # Symmetrie (M4): Zwilling bekommt die gespiegelte neue Drehung. + BuildOps.update_mirrored(main.doc, selected_index, _sym_mode()) main.history.commit("%s gedreht (90°)" % _piece_label(selected_index), before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index) _after_build_change() @@ -255,6 +261,8 @@ func lift_selected(delta_m: float) -> void: var pos: Array = teil.get("pos", [0.0, 0.0, 0.0]) var before: Dictionary = main.history.snapshot(["gebaeude"]) teil["pos"] = [float(pos[0]), BuildOps.snap_y(float(pos[1]) + delta_m), float(pos[2])] + # Symmetrie (M4): Zwilling heben/absenken mit (y bleibt gespiegelt identisch). + BuildOps.update_mirrored(main.doc, selected_index, _sym_mode()) main.history.commit("%s %s" % [_piece_label(selected_index), "angehoben" if delta_m > 0.0 else "abgesenkt"], before, main.history.snapshot(["gebaeude"]), "bauen-lift:%d" % selected_index) @@ -280,6 +288,8 @@ func nudge_selected(delta: Vector3) -> void: var nx := float(pos[0]) + delta.x var nz := float(pos[2]) + delta.z teil["pos"] = [nx, float(pos[1]), nz] + # Symmetrie (M4): Zwilling wandert gespiegelt mit (Raster-Parität bleibt). + BuildOps.update_mirrored(main.doc, selected_index, _sym_mode()) main.history.commit("%s verschoben (Pfeiltaste)" % _piece_label(selected_index), before, main.history.snapshot(["gebaeude"])) _after_build_change() @@ -306,6 +316,8 @@ func snap_selected_to_floor() -> void: return var before: Dictionary = main.history.snapshot(["gebaeude"]) teil["pos"] = [float(pos[0]), target, float(pos[2])] + # Symmetrie (M4): Zwilling mit absenken (y bleibt gespiegelt identisch). + BuildOps.update_mirrored(main.doc, selected_index, _sym_mode()) main.history.commit("%s auf den Boden gesetzt" % _piece_label(selected_index), before, main.history.snapshot(["gebaeude"])) _after_build_change() @@ -323,11 +335,14 @@ func delete_selected() -> void: return var label := _piece_label(selected_index) var before: Dictionary = main.history.snapshot(["gebaeude"]) - list.remove_at(selected_index) + # Symmetrie (M4): bei eingeschalteter Symmetrie den sym-Zwilling mit löschen. + var mirror_msg: String = BuildOps.remove_mirrored(main.doc, selected_index, _sym_mode()) main.history.commit("%s gelöscht" % label, before, main.history.snapshot(["gebaeude"])) selected_index = -1 _after_build_change() - main.call("_set_status", "%s — Strg+Z holt es zurück." % label.replace(" (Teil", " gelöscht (Teil")) + main.call("_set_status", "%s — Strg+Z holt es zurück.%s" % [ + label.replace(" (Teil", " gelöscht (Teil"), + "" if mirror_msg.is_empty() else " " + mirror_msg]) func _after_build_change() -> void: @@ -365,11 +380,21 @@ func demo_place(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> Dictionar var before: Dictionary = main.history.snapshot(["gebaeude"]) var teil := BuildOps.make_piece(p_kind, pos, rot_deg) BuildOps.ensure_block(main.doc).append(teil) + # Symmetrie (M4): Zwilling im selben Undo-Schritt (wie beim Klick). + BuildOps.place_mirrored(main.doc, teil, _sym_mode()) main.history.commit("%s gesetzt" % BuildOps.kind_label(p_kind), before, main.history.snapshot(["gebaeude"])) return teil +## Aktiver Symmetrie-Modus aus main (M1-Schalter). Defensive über main.get(): +## Mock-Mains (Tests) haben sym_mode nicht — dann gilt OFF (altes Verhalten), +## statt mit SCRIPT ERROR abzustürzen (Lektion: SCRIPT ERROR in -s-Suiten = rot). +func _sym_mode() -> int: + var v: Variant = main.get("sym_mode") if main != null else null + return SymmetryCore.Mode.OFF if v == null else int(v) + + ## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus). func demo_park_ghost(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> void: set_kind(p_kind) diff --git a/src/editor/spawn_tool.gd b/src/editor/spawn_tool.gd index d5a08a3..493d9da 100644 --- a/src/editor/spawn_tool.gd +++ b/src/editor/spawn_tool.gd @@ -18,6 +18,11 @@ extends Node3D const MODE_HELP := "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Entf: löschen ·\nRadius/Team rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück" +## Symmetrie (M4, Karte t_ffa207b1): Partner-Kopplung gespiegelter Spawn-Zonen. +## Liegt in design_notes (Loader-frei, rundtrip-sicher — wie spawn_radius_m): +## {"A5": "B3", "B3": "A5"} — direkte Partner-IDs, beide Richtungen gesetzt. +const SPAWN_MIRROR_KEY := "spawn_mirror" + var main # Hauptfenster (script-erweitert, Variant) var camera: Camera3D var view_builder # Welt-Bauer (script-erweitert, Variant) @@ -265,12 +270,15 @@ func _on_left_release() -> void: pos.z = clampf(pos.z, -half.y, half.y) var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc) SpawnOps.move_point(main.doc, selected_team, selected_id, pos.x, pos.z) + # Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position. + var mirror_msg: String = spawn_mirror_move(main.doc, selected_team, selected_id, _sym_mode()) main.history.commit("%s verschoben" % selected_id, before, SpawnOps.snapshot_blocks(main.history, main.doc)) main.call("_after_structure_change") _point_selection_at_doc() view_builder.call("set_spawn_selected", selected_team, selected_id) - main.call("_set_status", "%s verschoben — Strg+Z macht das rückgängig." % selected_id) + main.call("_set_status", "%s verschoben — Strg+Z macht das rückgängig.%s" % [ + selected_id, "" if mirror_msg.is_empty() else " " + mirror_msg]) _drag_moved = false @@ -289,6 +297,8 @@ func _place_zone(viewport_pos: Vector2) -> void: var point := SpawnOps.add_zone(main.doc, team, pos.x, pos.z) var pid := str(point["id"]) SpawnOps.set_radius(main.doc, pid, zone_radius) + # Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts. + var mirror_res: Dictionary = spawn_mirror_add(main.doc, team, pid, _sym_mode()) main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(team)], before, SpawnOps.snapshot_blocks(main.history, main.doc)) main.call("_after_structure_change") @@ -297,8 +307,9 @@ func _place_zone(viewport_pos: Vector2) -> void: selected_id = pid view_builder.call("set_spawn_selected", selected_team, selected_id) refresh_selection_panel() - main.call("_set_status", "Spawn-Zone %s (%s) gesetzt. Radius: Regler rechts · verschieben: klicken+ziehen · Entf löscht · Strg+Z macht alles rückgängig." % [ - pid, SpawnOps.team_label(team)]) + var mirror_text := str(mirror_res["status"]) + main.call("_set_status", "Spawn-Zone %s (%s) gesetzt.%s Radius: Regler rechts · verschieben: klicken+ziehen · Entf löscht · Strg+Z macht alles rückgängig." % [ + pid, SpawnOps.team_label(team), "" if mirror_text.is_empty() else " " + mirror_text]) ## Kursor-Strahl → getroffene Zone ({team, id}) oder {}. @@ -357,12 +368,15 @@ func delete_selected() -> void: if not SpawnOps.remove_point(main.doc, selected_team, selected_id): _clear_selection() return + # Symmetrie (M4): Zwilling mit entfernen (innerhalb desselben Undo-Schritts). + var mirror_msg: String = spawn_mirror_delete(main.doc, selected_id, _sym_mode()) main.history.commit("%s löschen" % label, before, SpawnOps.snapshot_blocks(main.history, main.doc)) _clear_selection() main.call("_after_structure_change") - main.call("_set_status", "Spawn-Zone %s gelöscht — Strg+Z holt sie zurück. %s braucht mindestens %d Zonen!" % [ - label, SpawnOps.team_label(team), SvmapIO.MIN_SPAWN_POINTS_PER_TEAM]) + main.call("_set_status", "Spawn-Zone %s gelöscht — Strg+Z holt sie zurück.%s %s braucht mindestens %d Zonen!" % [ + label, "" if mirror_msg.is_empty() else " " + mirror_msg, + SpawnOps.team_label(team), SvmapIO.MIN_SPAWN_POINTS_PER_TEAM]) ## Panel-Zeile rechts (Welche Zone ist an?) synchron halten — ohne Statuszeile anzufassen. @@ -371,6 +385,138 @@ func refresh_selection_panel() -> void: main.call("_sync_spawn_selection") +# ------------------------------------------------- Symmetrie (M4) Statics --- +## Karte t_ffa207b1: Gespiegelte Spawn-Zonen. ENTScheidung (Spec vc27, +## „Teams bleiben einander gegenüber fair"): der Zwilling landet im GEGNER- +## Team — Blau (West) ↔ Rot (Ost), denn genau auf den Gegenseiten liegen die +## Default-Streifen der beiden Teams. Jede gesetzte Zone gibt dem anderen Team +## automatisch die faire Gegenstelle. Alles pure Statics (kein +## UI, kein Node-Zugriff) → headless testbar; spawn_ops.gd bleibt unangetastet. + +## Partner-ID einer Spawn-Zone ("" = keiner). +static func spawn_mirror_partner(doc: Dictionary, pid: String) -> String: + var m := _mirror_map_of(doc) + return str(m.get(pid, "")) + + +static func _mirror_map_of(doc: Dictionary) -> Dictionary: + var notes = doc.get("design_notes", {}) + if not (notes is Dictionary): + return {} + var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {}) + return m if m is Dictionary else {} + + +static func _mirror_map_set(doc: Dictionary, pid_a: String, pid_b: String) -> void: + var notes = doc.get("design_notes", {}) + if not (notes is Dictionary): + notes = {} + doc["design_notes"] = notes + var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {}) + if not (m is Dictionary): + m = {} + (notes as Dictionary)[SPAWN_MIRROR_KEY] = m + m[pid_a] = pid_b + m[pid_b] = pid_a + + +static func _mirror_map_clear(doc: Dictionary, pid_a: String, pid_b: String) -> void: + var m := _mirror_map_of(doc) + if m.is_empty(): + return + m.erase(pid_a) + m.erase(pid_b) + + +## Gespiegelte Spawn-Zone zu `pid` anlegen (Symmetrie an). Rückgabe: +## {"ok": bool, "status": Klartext}. ok=false + V9-Text = Zwilling NICHT +## gespeichert (Grenzen scharf — Spec vc27). Auf der Symmetrie-Mitte liegende +## Zonen werden nicht gedoppelt (Spec). +static func spawn_mirror_add(doc: Dictionary, team: int, pid: String, mode: int) -> Dictionary: + if mode == SymmetryCore.Mode.OFF: + return {"ok": true, "status": ""} + var original := _spawn_point_of(doc, team, pid) + if original.is_empty(): + return {"ok": false, "status": "Spawn-Zone %s nicht gefunden — Zwilling nicht gesetzt." % pid} + var pos := _spawn_pos_of(original) + var center := SymmetryCore.map_center(doc) + if SymmetryCore.maps_to_self(mode, pos, center): + return {"ok": true, "status": "Spawn-Zone %s liegt auf der Symmetrie-Mitte — nicht gedoppelt." % pid} + var mirror_pos := SymmetryCore.mirror_point(mode, pos, center) + var bounds: Dictionary = doc.get("bounds", {}) + if not bounds.is_empty() and not SvmapIO._inside_bounds( + [mirror_pos.x, mirror_pos.y, mirror_pos.z], bounds): + return {"ok": false, "status": "❌ V9: Gespiegelter Spawn läge außerhalb der Match-Grenzen — nicht gesetzt."} + var twin := SpawnOps.add_zone(doc, 1 - team, mirror_pos.x, mirror_pos.z) + var twin_id := str(twin["id"]) + SpawnOps.set_radius(doc, twin_id, SpawnOps.zone_radius(doc, pid)) + _mirror_map_set(doc, pid, twin_id) + return {"ok": true, "status": "Gespiegelte Zone %s (%s) gesetzt." % [ + twin_id, SpawnOps.team_label(1 - team)]} + + +## Verschiebung spiegeln: Zwilling bekommt die gespiegelte Position der NEUEN +## Position des Originals (y = Terrain-Höhe am gespiegelten Punkt, wie beim +## normalen Verschieben). Rückgabe: Klartext-Status ("" = nichts zu tun). +static func spawn_mirror_move(doc: Dictionary, team: int, pid: String, mode: int) -> String: + if mode == SymmetryCore.Mode.OFF: + return "" + var partner := spawn_mirror_partner(doc, pid) + if partner.is_empty(): + return "" + var original := _spawn_point_of(doc, team, pid) + if original.is_empty(): + return "" + var pos := _spawn_pos_of(original) + var center := SymmetryCore.map_center(doc) + if SymmetryCore.maps_to_self(mode, pos, center): + return "" + var mirror_pos := SymmetryCore.mirror_point(mode, pos, center) + var partner_team := _team_of_point(doc, partner) + if partner_team < 0: + return "" + if not SpawnOps.move_point(doc, partner_team, partner, mirror_pos.x, mirror_pos.z): + return "" + return "Gespiegelte Zone %s mitverschoben." % partner + + +## Löschung spiegeln (entfernen → beide weg, inkl. Radius-Eintrag und +## Partner-Map). Rückgabe: Klartext-Status ("" = nur das Original war weg). +static func spawn_mirror_delete(doc: Dictionary, pid: String, mode: int) -> String: + if mode == SymmetryCore.Mode.OFF: + return "" + var partner := spawn_mirror_partner(doc, pid) + if partner.is_empty(): + return "" + var partner_team := _team_of_point(doc, partner) + if partner_team < 0: + _mirror_map_clear(doc, pid, partner) + return "" + if SpawnOps.remove_point(doc, partner_team, partner): + _mirror_map_clear(doc, pid, partner) + return "Gespiegelte Zone %s (%s) mit entfernt." % [partner, SpawnOps.team_label(partner_team)] + return "" + + +static func _spawn_point_of(doc: Dictionary, team: int, pid: String) -> Dictionary: + for p in SpawnOps.points(doc, team): + if p is Dictionary and str(p.get("id", "")) == pid: + return p as Dictionary + return {} + + +static func _team_of_point(doc: Dictionary, pid: String) -> int: + for team in [0, 1]: + if not _spawn_point_of(doc, team, pid).is_empty(): + return team + return -1 + + +static func _spawn_pos_of(point: Dictionary) -> Vector3: + var pos: Array = point.get("pos", [0.0, 0.0, 0.0]) + return Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + + # ------------------------------------------------------------ Ghost-Optik --- func _refresh_ghost_look() -> void: @@ -417,6 +563,8 @@ func demo_place(zone_team: int, x: float, z: float, radius: float) -> String: var pid := str(point["id"]) if radius > 0.0: SpawnOps.set_radius(main.doc, pid, radius) + # Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts. + spawn_mirror_add(main.doc, zone_team, pid, _sym_mode()) main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(zone_team)], before, SpawnOps.snapshot_blocks(main.history, main.doc)) return pid @@ -426,10 +574,20 @@ func demo_place(zone_team: int, x: float, z: float, radius: float) -> String: func demo_move(zone_team: int, pid: String, x: float, z: float) -> void: var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc) SpawnOps.move_point(main.doc, zone_team, pid, x, z) + # Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position. + spawn_mirror_move(main.doc, zone_team, pid, _sym_mode()) main.history.commit("%s verschoben" % pid, before, SpawnOps.snapshot_blocks(main.history, main.doc)) +## Aktiver Symmetrie-Modus aus main (M1-Schalter). Defensive über main.get(): +## Mock-Mains (Tests) haben sym_mode nicht — dann gilt OFF (altes Verhalten), +## statt mit SCRIPT ERROR abzustürzen (Lektion: SCRIPT ERROR in -s-Suiten = rot). +func _sym_mode() -> int: + var v: Variant = main.get("sym_mode") if main != null else null + return SymmetryCore.Mode.OFF if v == null else int(v) + + ## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus). func demo_park_ghost(x: float, z: float, team_i: int, radius: float) -> void: set_team(team_i) diff --git a/tests/probe_m4_mirror_render.gd b/tests/probe_m4_mirror_render.gd new file mode 100644 index 0000000..079b7e7 --- /dev/null +++ b/tests/probe_m4_mirror_render.gd @@ -0,0 +1,124 @@ +extends SceneTree +## M4-Beweis (t_ffa207b1): Bau-Teile + Spawn-Zonen werden im Symmetrie-Modus +## gespiegelt. Ein Blick von oben auf eine 256-m-Karte: links Original +## (Fundament + Wand, blaue Zone), rechts der Zwilling (gleiche kind_id, +## gespiegelte Drehung; rote Zone im Gegner-Team). +## +## xvfb-run -a /opt/data/home/bin/godot4 --rendering-driver opengl3 --path . \ +## --resolution 1600x900 -s res://tests/probe_m4_mirror_render.gd +## +## Ausgabe: /tmp/m4_mirror_beweis.png + Prüfzeilen (Zwillinge vorhanden, +## Positionen gespiegelt). Nur DER Beweis-Lauf nutzt die echte Code-Kette +## (BuildOps.place_mirrored / SpawnTool.spawn_mirror_add) — genau wie die Suite. + +const SpawnTool := preload("res://src/editor/spawn_tool.gd") + +const POINT := SymmetryCore.Mode.POINT + + +func _initialize() -> void: + var manifest: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://library/objects.json")) + var doc: Dictionary = SvmapDocument.create_new("M4-Symmetrie-Beweis", 256.0, "mittag") + + # --- Bau-Teile: Original West, Zwilling Ost (echte M4-Code-Kette) -------- + var fund := BuildOps.make_piece("fundament", Vector3(-21.0, 0.0, 3.0), 0.0) + BuildOps.ensure_block(doc).append(fund) + var fund_msg: String = BuildOps.place_mirrored(doc, fund, POINT) + var wand := BuildOps.make_piece("wand", Vector3(-20.0, 0.2, -2.0), 90.0) + BuildOps.ensure_block(doc).append(wand) + var wand_msg: String = BuildOps.place_mirrored(doc, wand, POINT) + + # --- Spawn: blaue Zone West → rote Zwilling-Zone Ost --------------------- + var point := SpawnOps.add_zone(doc, 0, -100.0, 30.0) + var spawn_res: Dictionary = SpawnTool.spawn_mirror_add(doc, 0, str(point["id"]), POINT) + + # --- Prüfzeilen (echte Werte, keine Behauptungen) ------------------------- + var fehler: Array = [] + _check(BuildOps.piece_count(doc) == 4, "4 Bauteile (2 Paare)", fehler) + _check(fund_msg == "" and wand_msg == "", "beide Bau-Teile still gespiegelt", fehler) + var twin_fund := _piece_at_pos(doc, 21.0) + _check(not twin_fund.is_empty(), "Fundament-Zwilling bei +21", fehler) + var twin_wand := _wand_twin(doc) + _check(not twin_wand.is_empty(), "Wand-Zwilling vorhanden", fehler) + if not twin_wand.is_empty(): + var rot: float = float(twin_wand.get("rot", 0.0)) + _check(SymmetryCore.sym_of(wand) == SymmetryCore.sym_of(twin_wand), "Wand-Paar via sym gekoppelt", fehler) + print("WAND-ZWILLING: pos=%.1f/%.1f rot=%.0f (Original 90° → gespiegelt %.0f)" % [ + (float(twin_wand["pos"][0])), float(twin_wand["pos"][2]), rot, rot]) + _check(bool(spawn_res["ok"]), "Spawn gespiegelt: %s" % str(spawn_res["status"]), fehler) + _check(SpawnOps.count_points(doc, 1) == 5, "Team Rot: 4 Default + 1 Zwilling", fehler) + print("BAU-TEILE: %d | SPAWN-STATUS: %s" % [BuildOps.piece_count(doc), str(spawn_res["status"])]) + + # --- Render --------------------------------------------------------------- + var bauer: Node3D = load("res://src/viewport/map_view_builder.gd").new() + root.add_child(bauer) + await process_frame + bauer.rebuild(doc, manifest) + await process_frame + + # Symmetrie-Achse sichtbar machen (dünne halbtransparente Linie bei x = 0). + var achse := MeshInstance3D.new() + var achse_mesh := BoxMesh.new() + achse_mesh.size = Vector3(0.3, 3.0, 90.0) + achse.mesh = achse_mesh + var achse_mat := StandardMaterial3D.new() + achse_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + achse_mat.albedo_color = Color(1.0, 0.95, 0.2, 0.55) + achse_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + achse.material_override = achse_mat + achse.position = Vector3(0.0, 1.5, 0.0) + root.add_child(achse) + + var cam := Camera3D.new() + cam.fov = 55.0 + root.add_child(cam) + cam.current = true + cam.make_current() + + # Bild 1: TOTAL — gespiegelte Spawn-Zonen (blau links, rot rechts). + cam.position = Vector3(0.0, 120.0, 130.0) + await process_frame + cam.look_at(Vector3(0.0, 0.0, -10.0), Vector3(0.0, 0.0, -1.0)) + await process_frame + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("/tmp/m4_beweis_spawns.png") + + # Bild 2: NAH — Bauteil-Paar links/rechts der Achse (beide voll im Rahmen). + cam.position = Vector3(0.0, 14.0, 36.0) + await process_frame + cam.look_at(Vector3(0.0, 0.0, 0.0), Vector3(0.0, 0.0, -1.0)) + await process_frame + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("/tmp/m4_beweis_bauteile.png") + for f in fehler: + print(" FEHLER: %s" % f) + print("PROBE_M4_MIRROR_%s" % ("FAIL" if not fehler.is_empty() else "OK")) + quit(1 if not fehler.is_empty() else 0) + + +func _check(cond: bool, label: String, fehler: Array) -> void: + print(" %s: %s" % ["ok" if cond else "FEHLER", label]) + if not cond: + fehler.append(label) + + +func _piece_at_pos(doc: Dictionary, x: float) -> Dictionary: + for t in BuildOps.pieces(doc): + if not (t is Dictionary): + continue + var pos: Array = (t as Dictionary).get("pos", [0.0, 0.0, 0.0]) + if absf(float(pos[0]) - x) < 0.01 and str((t as Dictionary).get("id", "")) == "fundament": + return t as Dictionary + return {} + + +func _wand_twin(doc: Dictionary) -> Dictionary: + for t in BuildOps.pieces(doc): + if not (t is Dictionary): + continue + var pos: Array = (t as Dictionary).get("pos", [0.0, 0.0, 0.0]) + if str((t as Dictionary).get("id", "")) == "wand" and float(pos[0]) > 0.0: + return t as Dictionary + return {} diff --git a/tests/probe_m4_mirror_render.gd.uid b/tests/probe_m4_mirror_render.gd.uid new file mode 100644 index 0000000..ce8195c --- /dev/null +++ b/tests/probe_m4_mirror_render.gd.uid @@ -0,0 +1 @@ +uid://tcxqt666i3bi diff --git a/tests/test_build_spawn_mirror.gd b/tests/test_build_spawn_mirror.gd new file mode 100644 index 0000000..f953b32 --- /dev/null +++ b/tests/test_build_spawn_mirror.gd @@ -0,0 +1,440 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_ffa207b1 — M4 Bau-Teile + 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 (BuildOps/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_snap_parity() + _test_build_remove() + _test_build_update_follows() + _test_build_v9_bounds() + _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 _piece_at(doc: Dictionary, index: int) -> Dictionary: + var list := BuildOps.pieces(doc) + if index < 0 or index >= list.size() or not (list[index] is Dictionary): + return {} + return list[index] as Dictionary + + +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 + + +# ------------------------------------------------------- Bau-Teile: setzen --- + +func _test_build_place_point() -> void: + print("Testgruppe: Bau-Teil setzen → Zwilling (Punktspiegelung)") + var doc := _new_doc() + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.2, 20.0), 90.0) + BuildOps.ensure_block(doc).append(teil) + var msg: String = BuildOps.place_mirrored(doc, teil, POINT) + _check(msg == "", "Punktspiegelung: still gespiegelt (keine Meldung)") + _check(BuildOps.piece_count(doc) == 2, "2 Bauteile nach dem Setzen") + var twin := _piece_at(doc, 1) + _check(str(twin.get("id", "")) == "wand", "Zwilling hat gleiche kind_id") + _check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).y, 0.2) and _near(_pos3(twin).z, -20.0), + "Zwilling-Position gespiegelt (−10, 0.2, −20)") + _check(_near(float(twin.get("rot", 0.0)), 270.0), "Zwilling-Drehung 90° + 180° = 270°") + var key := SymmetryCore.sym_of(teil) + _check(not key.is_empty() and SymmetryCore.sym_of(twin) == key, + "beide Teile tragen dieselbe sym-Kennung") + # V16-Realität: GENAU 2 Einträge pro Kennung. + var n := 0 + for t in BuildOps.pieces(doc): + if SymmetryCore.sym_of(t as Dictionary) == key: + n += 1 + _check(n == 2, "sym-Kennung verbindet genau 2 Teile") + # Zwilling ist eine tiefe Kopie (keine Alias-Falle bei punkte). + var first_x: float = (teil["punkte"] as Array)[0][0] + (teil["punkte"] as Array)[0][0] = 123.0 + _check(_near(float((twin["punkte"] as Array)[0][0]), first_x), + "Zwilling-Punkte sind Kopien (kein Aliasing)") + + +func _test_build_place_axes() -> void: + print("Testgruppe: Bau-Teil setzen → Zwilling (Achse X / Achse Z)") + var doc := _new_doc() + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) + BuildOps.ensure_block(doc).append(teil) + BuildOps.place_mirrored(doc, teil, AXIS_X) + var twin := _piece_at(doc, 1) + _check(_near(_pos3(twin).x, -10.0) and _near(_pos3(twin).z, 20.0), + "Achse X: x gespiegelt, z bleibt") + _check(_near(float(twin.get("rot", 0.0)), 270.0), "Achse X: Drehung −90° = 270°") + var doc2 := _new_doc() + var teil2 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) + BuildOps.ensure_block(doc2).append(teil2) + BuildOps.place_mirrored(doc2, teil2, AXIS_Z) + var twin2 := _piece_at(doc2, 1) + _check(_near(_pos3(twin2).x, 10.0) and _near(_pos3(twin2).z, -20.0), + "Achse Z: z gespiegelt, x bleibt") + _check(_near(float(twin2.get("rot", 0.0)), 270.0), "Achse Z: Drehung −90° = 270°") + # Auf der Mitte: nicht gedoppelt (Spec vc27). + var doc3 := _new_doc() + var mitte := BuildOps.make_piece("wand", Vector3(0.0, 0.0, 0.0), 0.0) + BuildOps.ensure_block(doc3).append(mitte) + var msg: String = BuildOps.place_mirrored(doc3, mitte, POINT) + _check(msg.contains("nicht gedoppelt"), "Bauteil auf der Mitte: Meldung statt Zwilling") + _check(BuildOps.piece_count(doc3) == 1, "Auf der Mitte: nur 1 Bauteil") + _check(SymmetryCore.sym_of(mitte).is_empty(), "Auf der Mitte: kein sym-Link") + + +## Gleiche Snap-Regeln: Spiegelung erhält die Raster-Parität +## (Raster „ecke" = gerade Meter, „mitte" = ungerade Meter) bei zentrierter Karte. +func _test_build_snap_parity() -> void: + print("Testgruppe: Snap-Parität bleibt erhalten") + var doc := _new_doc() + var ecke := BuildOps.make_piece("wand", Vector3(2.0, 0.0, 4.0), 0.0) + BuildOps.ensure_block(doc).append(ecke) + BuildOps.place_mirrored(doc, ecke, POINT) + _check(_near(_pos3(_piece_at(doc, 1)).x, -2.0) and _near(_pos3(_piece_at(doc, 1)).z, -4.0), + "ecke-Teil: gerade Meter bleiben gerade (−2, −4)") + var doc2 := _new_doc() + var mitte := BuildOps.make_piece("fundament", Vector3(3.0, 0.0, 3.0), 0.0) + BuildOps.ensure_block(doc2).append(mitte) + BuildOps.place_mirrored(doc2, mitte, POINT) + _check(_near(_pos3(_piece_at(doc2, 1)).x, -3.0) and _near(_pos3(_piece_at(doc2, 1)).z, -3.0), + "mitte-Teil: ungerade Meter bleiben ungerade (−3, −3)") + + +# ----------------------------------------------------- Bau-Teile: entfernen --- + +func _test_build_remove() -> void: + print("Testgruppe: Bau-Teil entfernen → beide weg") + var doc := _new_doc() + var teil := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0) + BuildOps.ensure_block(doc).append(teil) + BuildOps.place_mirrored(doc, teil, POINT) + _check(BuildOps.piece_count(doc) == 2, "Vorbereitung: 2 Bauteile") + var msg: String = BuildOps.remove_mirrored(doc, 0, POINT) + _check(BuildOps.piece_count(doc) == 0, "Original löschen → Zwilling auch weg") + _check(msg.contains("Zwilling"), "Status meldet den mitgelöschten Zwilling") + # Umgekehrt: Zwilling löschen (Index 1) → Original mit weg. + var doc2 := _new_doc() + var teil2 := BuildOps.make_piece("boden", Vector3(11.0, 0.0, 21.0), 0.0) + BuildOps.ensure_block(doc2).append(teil2) + BuildOps.place_mirrored(doc2, teil2, POINT) + BuildOps.remove_mirrored(doc2, 1, POINT) + _check(BuildOps.piece_count(doc2) == 0, "Zwilling löschen → Original auch weg") + # Paar + Fremdteil: Löschung des Fremdteils lässt das Paar unangetastet. + var doc3 := _new_doc() + var a := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 0.0), 0.0) + BuildOps.ensure_block(doc3).append(a) + BuildOps.place_mirrored(doc3, a, POINT) + var fremd := BuildOps.make_piece("boden", Vector3(30.0, 0.0, 0.0), 0.0) + BuildOps.ensure_block(doc3).append(fremd) + BuildOps.remove_mirrored(doc3, 2, POINT) + _check(BuildOps.piece_count(doc3) == 2, "Fremdteil weg, Paar bleibt (2 Teile)") + _check(not _piece_at(doc3, 0).is_empty() and not _piece_at(doc3, 1).is_empty(), + "Paar-Indizes unverändert verschoben") + + +# ------------------------------------- Bau-Teile: verschieben/drehen/anheben --- + +func _test_build_update_follows() -> void: + print("Testgruppe: Verschieben/Drehen/Anheben — Zwilling folgt") + var doc := _new_doc() + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) + BuildOps.ensure_block(doc).append(teil) + BuildOps.place_mirrored(doc, teil, POINT) + # Verschieben (Pfeiltaste-Muster): Original auf neue Position, dann sync. + teil["pos"] = [14.0, 0.0, 20.0] + BuildOps.update_mirrored(doc, 0, POINT) + var twin := _piece_at(doc, 1) + _check(_near(_pos3(twin).x, -14.0) and _near(_pos3(twin).z, -20.0), + "Zwilling folgt der verschobenen Position gespiegelt") + # Drehen: +90° am Original → Zwilling = gespiegelte NEUE Drehung. + teil["rot"] = 180.0 + BuildOps.update_mirrored(doc, 0, POINT) + _check(_near(float((_piece_at(doc, 1) as Dictionary).get("rot", 0.0)), 0.0), + "Zwilling-Drehung: 180° + 180° = 0° (modulo 360)") + # Anheben: y wandert 1:1 mit (Höhe ist ein Skalar). + teil["pos"] = [14.0, 2.4, 20.0] + BuildOps.update_mirrored(doc, 0, POINT) + _check(_near(_pos3(_piece_at(doc, 1)).y, 2.4), "Zwilling-y folgt 1:1 (Skalar)") + # Teil OHNE sym-Partner (außerhalb des Modus gebaut) bleibt unberührt. + var doc2 := _new_doc() + var solo := BuildOps.make_piece("wand", Vector3(6.0, 0.0, 0.0), 0.0) + BuildOps.ensure_block(doc2).append(solo) + _check(BuildOps.update_mirrored(doc2, 0, POINT) == "", "Teil ohne sym: kein Zwilling, keine Meldung") + _check(BuildOps.piece_count(doc2) == 1, "Teil ohne sym: nichts wurde gedoppelt") + + +# ------------------------------------------------------- Bau-Teile: V9 ------- + +## V9-Realität (deshalb so getestet): bei zentrierten bounds bildet die +## Spiegelung gültiger Elemente bildet IMMER auf gültige Stellen ab — der V9-Fall +## entsteht, wenn das ORIGINAL bereits außerhalb liegt (Editor erzwingt die +## Grenzen beim Nudge/Baken nicht) oder Grenzen asymmetrisch sind. Dann +## verweigert der Zwilling die Speicherung mit Klartext-Meldung. +func _test_build_v9_bounds() -> void: + print("Testgruppe: Bau-Teil — V9-Grenzen scharf") + var doc := _new_doc() + # Original außerhalb (z = 200 > 128 + 1 Toleranz): auch der Zwilling läge + # außerhalb → Meldung statt still zu speichern. + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 200.0), 0.0) + BuildOps.ensure_block(doc).append(teil) + var msg: String = BuildOps.place_mirrored(doc, teil, POINT) + _check(msg.contains("V9"), "V9-Meldung statt still zu speichern") + _check(msg.contains("Match-Grenzen"), "Meldung nennt die Match-Grenzen") + _check(BuildOps.piece_count(doc) == 1, "V9-Fall: NUR das Original gespeichert") + _check(SymmetryCore.sym_of(teil).is_empty(), "V9-Fall: kein halber sym-Link zurückbleibt") + # Grenz-Nähe INNERHALB (Toleranz ±1 m): spiegeln bleibt erlaubt. + var doc2 := _new_doc() + var nah := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 127.5), 0.0) + BuildOps.ensure_block(doc2).append(nah) + _check(BuildOps.place_mirrored(doc2, nah, POINT) == "", + "nahe der Grenze (|z| ≤ 128 + 1): gespiegelt ok") + _check(BuildOps.piece_count(doc2) == 2, "Grenz-Nähe: Zwilling vorhanden") + # update_mirrored: Original aus den Grenzen verschoben → Zwilling bleibt unverändert. + var doc3 := _new_doc() + var t3 := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 30.0), 0.0) + BuildOps.ensure_block(doc3).append(t3) + BuildOps.place_mirrored(doc3, t3, POINT) + t3["pos"] = [10.0, 85.0, 30.0] # y = 85 > 80 + 1 → gespiegelt ebenfalls draußen + var msg3: String = BuildOps.update_mirrored(doc3, 0, POINT) + _check(msg3.contains("V9"), "update: V9-Meldung statt Zwilling verschieben") + _check(_near(_pos3(_piece_at(doc3, 1)).z, -30.0) and _near(_pos3(_piece_at(doc3, 1)).y, 0.0), + "update-V9: Zwilling blieb UNVERÄNDERT (−30 / y 0)") + + +# ------------------------------------------------- Bau-Teile: Modus AUS ----- + +func _test_build_off_gegenprobe() -> void: + print("Testgruppe: Modus AUS = Verhalten wie bisher (Bau-Teile)") + var doc := _new_doc() + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) + BuildOps.ensure_block(doc).append(teil) + _check(BuildOps.place_mirrored(doc, teil, OFF) == "", "OFF: keine Meldung, kein Zwilling") + _check(BuildOps.piece_count(doc) == 1, "OFF: nur 1 Bauteil") + _check(SymmetryCore.sym_of(teil).is_empty(), "OFF: kein sym-Feld gesetzt") + teil["pos"] = [12.0, 0.0, 20.0] + BuildOps.update_mirrored(doc, 0, OFF) + _check(BuildOps.piece_count(doc) == 1, "OFF: Verschieben dupliziert nicht") + var msg: String = BuildOps.remove_mirrored(doc, 0, OFF) + _check(BuildOps.piece_count(doc) == 0 and msg == "", "OFF: Löschen entfernt genau 1 Teil") + + +## EIN Undo-Schritt: Original + Zwilling springen zusammen zurück. +func _test_build_one_undo_step() -> void: + print("Testgruppe: Bau-Teile — EIN Undo-Schritt für beide Seiten") + var doc := _new_doc() + var hist := ActionHistory.new() + hist.begin(doc) + var before: Dictionary = hist.snapshot(["gebaeude"]) + var teil := BuildOps.make_piece("wand", Vector3(10.0, 0.0, 20.0), 90.0) + BuildOps.ensure_block(doc).append(teil) + BuildOps.place_mirrored(doc, teil, POINT) + hist.commit("Wand gesetzt", before, hist.snapshot(["gebaeude"])) + _check(BuildOps.piece_count(doc) == 2, "commit: beide Teile drin") + hist.undo() + _check(BuildOps.piece_count(doc) == 0, "EIN undo: Original UND Zwilling weg") + hist.redo() + _check(BuildOps.piece_count(doc) == 2, "EIN 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) diff --git a/tests/test_build_spawn_mirror.gd.uid b/tests/test_build_spawn_mirror.gd.uid new file mode 100644 index 0000000..ecfd0c8 --- /dev/null +++ b/tests/test_build_spawn_mirror.gd.uid @@ -0,0 +1 @@ +uid://bguw2rta4tlam