t_ffa207b1: M4 Bau-Teile + Spawnpunkte spiegeln (Spec vc27)

- BuildOps.place_mirrored / update_mirrored / remove_mirrored /
  inside_match_bounds: pure Statics in build_ops.gd, sym-Kopplung aus M1,
  gleiche Snap-Regeln (Paritaet bleibt), tiefe Kopien (kein Aliasing).
- V9-Grenzen scharf: laege der Zwilling ausserhalb der Match-Grenzen
  (Loader-Regel _inside_bounds ±1 m), wird er NICHT gespeichert und der
  Status meldet V9 im Klartext — auch kein halber sym-Link.
- SpawnTool-Spiegelung im GEGNER-Team (Blau West <-> Rot Ost = faire
  Gegenstelle, Radius 1:1), Partner-Map in design_notes.spawn_mirror
  (Loader-frei, rundtrip-sicher). Auf der Mitte: nicht gedoppelt.
- build_tool.gd: duenne Hooks an den 6 Klick-Mutationen + demo_place —
  EIN Undo-Schritt nimmt Original UND Zwilling zurueck.
- spawn_tool.gd: Hooks in setzen/verschieben/loeschen/demo_place/demo_move;
  _sym_mode() defensiv ueber main.get() (Mock-Mains ohne sym_mode -> OFF,
  statt SCRIPT ERROR — Lektion aus test_shortcuts).
- Neue Suite tests/test_build_spawn_mirror.gd (76 Checks ALL OK) +
  Probe tests/probe_m4_mirror_render.gd (2 Beweis-Bilder).
- Regression: 19/20 Suiten gruen; test_glb_view 15/25 FAIL pre-existing
  (per git-stash gegen 437f6bf verifiziert, Ursache unabhaengig vom Diff).
This commit is contained in:
Hermes (scopedvr-dev) 2026-10-20 06:47:58 +00:00
parent 0adb161386
commit c15c606311
7 changed files with 860 additions and 9 deletions

View file

@ -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: if v < 0.0 or u + v > 1.0:
return -1.0 return -1.0
return e2.dot(q) * inv 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]))

View file

@ -200,10 +200,14 @@ func _place_piece() -> void:
return return
BuildOps.ensure_block(main.doc).append(teil) BuildOps.ensure_block(main.doc).append(teil)
var label := BuildOps.kind_label(kind_id) 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"])) main.history.commit("%s gesetzt" % label, before, main.history.snapshot(["gebaeude"]))
_after_build_change() _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." % [ 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)]) label, BuildOps.piece_count(main.doc),
"" if mirror_msg.is_empty() else " " + mirror_msg])
func _select_at(viewport_pos: Vector2) -> void: func _select_at(viewport_pos: Vector2) -> void:
@ -238,6 +242,8 @@ func rotate_selected() -> void:
var teil: Dictionary = list[selected_index] var teil: Dictionary = list[selected_index]
var before: Dictionary = main.history.snapshot(["gebaeude"]) var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["rot"] = wrapf(float(teil.get("rot", 0.0)) + 90.0, 0.0, 360.0) 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), main.history.commit("%s gedreht (90°)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index) before, main.history.snapshot(["gebaeude"]), "bauen-rot:%d" % selected_index)
_after_build_change() _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 pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var before: Dictionary = main.history.snapshot(["gebaeude"]) var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), BuildOps.snap_y(float(pos[1]) + delta_m), float(pos[2])] 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), main.history.commit("%s %s" % [_piece_label(selected_index),
"angehoben" if delta_m > 0.0 else "abgesenkt"], "angehoben" if delta_m > 0.0 else "abgesenkt"],
before, main.history.snapshot(["gebaeude"]), "bauen-lift:%d" % selected_index) 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 nx := float(pos[0]) + delta.x
var nz := float(pos[2]) + delta.z var nz := float(pos[2]) + delta.z
teil["pos"] = [nx, float(pos[1]), nz] 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), main.history.commit("%s verschoben (Pfeiltaste)" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"])) before, main.history.snapshot(["gebaeude"]))
_after_build_change() _after_build_change()
@ -306,6 +316,8 @@ func snap_selected_to_floor() -> void:
return return
var before: Dictionary = main.history.snapshot(["gebaeude"]) var before: Dictionary = main.history.snapshot(["gebaeude"])
teil["pos"] = [float(pos[0]), target, float(pos[2])] 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), main.history.commit("%s auf den Boden gesetzt" % _piece_label(selected_index),
before, main.history.snapshot(["gebaeude"])) before, main.history.snapshot(["gebaeude"]))
_after_build_change() _after_build_change()
@ -323,11 +335,14 @@ func delete_selected() -> void:
return return
var label := _piece_label(selected_index) var label := _piece_label(selected_index)
var before: Dictionary = main.history.snapshot(["gebaeude"]) 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"])) main.history.commit("%s gelöscht" % label, before, main.history.snapshot(["gebaeude"]))
selected_index = -1 selected_index = -1
_after_build_change() _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: 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 before: Dictionary = main.history.snapshot(["gebaeude"])
var teil := BuildOps.make_piece(p_kind, pos, rot_deg) var teil := BuildOps.make_piece(p_kind, pos, rot_deg)
BuildOps.ensure_block(main.doc).append(teil) 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), main.history.commit("%s gesetzt" % BuildOps.kind_label(p_kind),
before, main.history.snapshot(["gebaeude"])) before, main.history.snapshot(["gebaeude"]))
return teil 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). ## 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: func demo_park_ghost(p_kind: String, pos: Vector3, rot_deg: float = 0.0) -> void:
set_kind(p_kind) set_kind(p_kind)

View file

@ -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" 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 main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D var camera: Camera3D
var view_builder # Welt-Bauer (script-erweitert, Variant) 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) pos.z = clampf(pos.z, -half.y, half.y)
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc) var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
SpawnOps.move_point(main.doc, selected_team, selected_id, pos.x, pos.z) 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, main.history.commit("%s verschoben" % selected_id,
before, SpawnOps.snapshot_blocks(main.history, main.doc)) before, SpawnOps.snapshot_blocks(main.history, main.doc))
main.call("_after_structure_change") main.call("_after_structure_change")
_point_selection_at_doc() _point_selection_at_doc()
view_builder.call("set_spawn_selected", selected_team, selected_id) 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 _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 point := SpawnOps.add_zone(main.doc, team, pos.x, pos.z)
var pid := str(point["id"]) var pid := str(point["id"])
SpawnOps.set_radius(main.doc, pid, zone_radius) 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)], main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(team)],
before, SpawnOps.snapshot_blocks(main.history, main.doc)) before, SpawnOps.snapshot_blocks(main.history, main.doc))
main.call("_after_structure_change") main.call("_after_structure_change")
@ -297,8 +307,9 @@ func _place_zone(viewport_pos: Vector2) -> void:
selected_id = pid selected_id = pid
view_builder.call("set_spawn_selected", selected_team, selected_id) view_builder.call("set_spawn_selected", selected_team, selected_id)
refresh_selection_panel() 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." % [ var mirror_text := str(mirror_res["status"])
pid, SpawnOps.team_label(team)]) 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 {}. ## 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): if not SpawnOps.remove_point(main.doc, selected_team, selected_id):
_clear_selection() _clear_selection()
return 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, main.history.commit("%s löschen" % label, before,
SpawnOps.snapshot_blocks(main.history, main.doc)) SpawnOps.snapshot_blocks(main.history, main.doc))
_clear_selection() _clear_selection()
main.call("_after_structure_change") 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!" % [ main.call("_set_status", "Spawn-Zone %s gelöscht — Strg+Z holt sie zurück.%s %s braucht mindestens %d Zonen!" % [
label, SpawnOps.team_label(team), SvmapIO.MIN_SPAWN_POINTS_PER_TEAM]) 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. ## 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") 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 --- # ------------------------------------------------------------ Ghost-Optik ---
func _refresh_ghost_look() -> void: 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"]) var pid := str(point["id"])
if radius > 0.0: if radius > 0.0:
SpawnOps.set_radius(main.doc, pid, radius) 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)], main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(zone_team)],
before, SpawnOps.snapshot_blocks(main.history, main.doc)) before, SpawnOps.snapshot_blocks(main.history, main.doc))
return pid 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: func demo_move(zone_team: int, pid: String, x: float, z: float) -> void:
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc) var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
SpawnOps.move_point(main.doc, zone_team, pid, x, z) 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, main.history.commit("%s verschoben" % pid,
before, SpawnOps.snapshot_blocks(main.history, main.doc)) 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). ## 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: func demo_park_ghost(x: float, z: float, team_i: int, radius: float) -> void:
set_team(team_i) set_team(team_i)

View file

@ -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 {}

View file

@ -0,0 +1 @@
uid://tcxqt666i3bi

View file

@ -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)

View file

@ -0,0 +1 @@
uid://bguw2rta4tlam