extends SceneTree ## Editor-Tests (headless): Karte t_c4162c5d — Spawn-Zonen-Werkzeug. ## Ausführen: godot --headless --path . --script res://tests/test_spawns.gd ## Exit 0 = alles grün. Läuft OHNE UI (SpawnOps/SvmapIO/ActionHistory). ## GUI-Feinheiten (Kreis-Optik, Shake, Ghost) verifiziert der Desktop-Smoke ## (--spawns-demo auf Slys PC), nicht hier. var failures := 0 var checks := 0 func _init() -> void: _cleanup() _run_tests() _cleanup() if failures == 0: print("ALL OK: %d checks" % checks) quit(0) else: print("FAILED: %d/%d checks" % [failures, checks]) quit(1) func _cleanup() -> void: for path in ["user://test_spawns.svmap", "user://test_spawns2.svmap"]: if FileAccess.file_exists(path): DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) func _check(cond: bool, label: String) -> void: checks += 1 if cond: print(" ok: %s" % label) else: failures += 1 print(" FAIL: %s" % label) func _manifest() -> Dictionary: var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) return parsed if parsed is Dictionary else {} func _commit_spawn(hist: ActionHistory, doc: Dictionary, desc: String) -> void: hist.commit(desc, SpawnOps.snapshot_blocks(hist, doc), SpawnOps.snapshot_blocks(hist, doc)) func _run_tests() -> void: _test_defaults_and_ids() _test_place_undo_roundtrip() _test_radius_change_and_merge() _test_delete_and_undo() _test_move_and_undo() _test_live_validation_status() _test_ready_report() _test_export_reimport_identical() _test_zone_on_terrain() func _test_defaults_and_ids() -> void: print("Testgruppe: Default-Zonen + ID-Vergabe") var doc := SvmapDocument.create_new("ID-Test", 512.0, "mittag") _check(SpawnOps.count_points(doc, 0) == 4, "Team Blau: 4 Default-Zonen") _check(SpawnOps.count_points(doc, 1) == 4, "Team Rot: 4 Default-Zonen") _check(SpawnOps.next_id(doc, 0) == "A5", "nächste blaue ID = A5 (A1–A4 belegt)") _check(SpawnOps.next_id(doc, 1) == "B5", "nächste rote ID = B5 (B1–B4 belegt)") _check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS, "Radius ohne Eintrag = Default (%d m)" % int(SpawnOps.DEFAULT_ZONE_RADIUS)) ## Zone setzen → undo → redo: spawn_zones UND design_notes kommen beide zurück. func _test_place_undo_roundtrip() -> void: print("Testgruppe: Zone setzen/Undo/Redo") var doc := SvmapDocument.create_new("Place-Test", 512.0, "mittag") var hist := ActionHistory.new() hist.begin(doc) var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc) var point := SpawnOps.add_zone(doc, 0, -30.0, 40.0) SpawnOps.set_radius(doc, str(point["id"]), 9.0) hist.commit("Spawn-Zone %s gesetzt" % point["id"], before, SpawnOps.snapshot_blocks(hist, doc)) _check(SpawnOps.count_points(doc, 0) == 5, "setzen: 5 blaue Zonen") _check(absf(SpawnOps.zone_radius(doc, str(point["id"])) - 9.0) < 0.001, "Radius 9 m im Doc (design_notes)") var pid: String = str(point["id"]) _check(pid == "A5", "automatische ID A5") hist.undo() _check(SpawnOps.count_points(doc, 0) == 4, "undo: Zone weg") var notes = doc.get("design_notes", {}) var radii = (notes as Dictionary).get("spawn_radius_m", {}) if notes is Dictionary else {} _check(not (radii is Dictionary and (radii as Dictionary).has(pid)), "undo: Radius-Eintrag auch weg (kein Geist in design_notes)") hist.redo() _check(SpawnOps.count_points(doc, 0) == 5, "redo: Zone zurück") _check(absf(SpawnOps.zone_radius(doc, pid) - 9.0) < 0.001, "redo: Radius 9 m wieder da") ## Radius-Slider-Ticks dürfen die History NICHT fluten — EIN Eintrag pro Zone. func _test_radius_change_and_merge() -> void: print("Testgruppe: Radius ändern + Merge-Tags") var doc := SvmapDocument.create_new("Radius-Test", 512.0, "mittag") var hist := ActionHistory.new() hist.begin(doc) var entries_before := hist.entries.size() for m in [4.0, 5.0, 6.0]: var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc) SpawnOps.set_radius(doc, "A1", float(m)) hist.commit("Radius von A1: %d m" % int(m), before, SpawnOps.snapshot_blocks(hist, doc), "radius:A1") _check(hist.entries.size() == entries_before + 1, "3 Slider-Ticks → EIN History-Eintrag") _check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Endradius 6 m im Doc") hist.undo() _check(SpawnOps.zone_radius(doc, "A1") == SpawnOps.DEFAULT_ZONE_RADIUS, "Merge-Undo: zurück auf Default") hist.redo() _check(absf(SpawnOps.zone_radius(doc, "A1") - 6.0) < 0.001, "Merge-Redo: wieder 6 m") ## Zone löschen → undo: Punkt MIT Position und Radius-Eintrag zurück. func _test_delete_and_undo() -> void: print("Testgruppe: Zone löschen/Undo") var doc := SvmapDocument.create_new("Delete-Test", 512.0, "mittag") var hist := ActionHistory.new() hist.begin(doc) SpawnOps.set_radius(doc, "A2", 12.0) var pos_before: Array = SpawnOps.points(doc, 0)[1]["pos"] var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc) SpawnOps.remove_point(doc, 0, "A2") hist.commit("A2 löschen", before, SpawnOps.snapshot_blocks(hist, doc)) _check(SpawnOps.count_points(doc, 0) == 3, "löschen: 3 blaue Zonen") _check(SpawnOps.next_id(doc, 0) == "A2", "freigewordene ID wird wieder vergeben") hist.undo() _check(SpawnOps.count_points(doc, 0) == 4, "undo: 4 blaue Zonen") var pos_after: Array = SpawnOps.points(doc, 0)[1]["pos"] _check(float(pos_after[0]) == float(pos_before[0]) and float(pos_after[2]) == float(pos_before[2]), "undo: Position von A2 identisch zurück") _check(absf(SpawnOps.zone_radius(doc, "A2") - 12.0) < 0.001, "undo: Radius 12 m zurück") ## Zone verschieben → undo → redo (Position und Terrain-Höhe). func _test_move_and_undo() -> void: print("Testgruppe: Zone verschieben/Undo") var doc := SvmapDocument.create_new("Move-Test", 512.0, "mittag") var hist := ActionHistory.new() hist.begin(doc) var before: Dictionary = SpawnOps.snapshot_blocks(hist, doc) SpawnOps.move_point(doc, 1, "B1", 111.0, -77.0) hist.commit("B1 verschoben", before, SpawnOps.snapshot_blocks(hist, doc)) var moved: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"] _check(absf(float(moved[0]) - 111.0) < 0.001 and absf(float(moved[2]) + 77.0) < 0.001, "verschieben: neue X/Z im Doc") hist.undo() var back: Array = (SpawnOps.zone_dict(doc, 1)["points"][0])["pos"] _check(absf(float(back[0]) - 204.8) < 1.0, "undo: B1 wieder an der Startkante (x = 0.8 × 256 m)") hist.redo() _check(absf(float(moved[0]) - 111.0) < 0.001, "redo: Verschiebung wieder da") ## Live-Validierung spiegelt die Loader-Regeln: Teams < 4 → ❌, unbekannte ## Objekt-ID → ❌, sonst ✅. Genau diese Meldungen sieht Sly VOR dem Export. func _test_live_validation_status() -> void: print("Testgruppe: Live-Validierungs-Status") var doc := SvmapDocument.create_new("Live-Test", 512.0, "mittag") var manifest := _manifest() var text := SpawnOps.live_status(doc, manifest) _check(text.contains("✅ Spawns Team Blau: 4"), "Live: ✅ Blau 4 (Default)") _check(text.contains("✅ Spawns Team Rot: 4"), "Live: ✅ Rot 4 (Default)") _check(text.contains("✅ Objekt-IDs"), "Live: ✅ Objekt-IDs ohne Objekte (optional)") # 2 rote Zonen löschen (immer den LETZTEN Punkt — next_id liefert nur freie, # nicht existierende IDs und wäre hier die falsche Wahl) → ❌ Team Rot: 2 while SpawnOps.count_points(doc, 1) > 2: var pts := SpawnOps.points(doc, 1) SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"])) text = SpawnOps.live_status(doc, manifest) _check(text.contains("❌ Spawns Team Rot: 2"), "Live: ❌ Rot 2 nach Löschaktionen") var lines := SpawnOps.validate_lines(doc, manifest) var team1_hint := "" for line in lines: if str(line["text"]).contains("Team Rot") and not bool(line["ok"]): team1_hint = str(line["hint"]) _check(team1_hint.contains("Spawns"), "fehlende Zone → hilfreicher Hinweis (wo Zonen malen)") # Objekt mit unbekannter ID → ❌ Objekt-IDs (doc["objects"] as Array).append({"id": "nicht_in_bibliothek", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}) text = SpawnOps.live_status(doc, manifest) _check(text.contains("❌ Objekt-IDs"), "Live: ❌ Objekt-IDs bei unbekannter ID") _check(text.contains("1 von 1 unbekannt"), "Live: Anzahl unbekannter Objekte genannt") ## "Karte prüfen": neue Map = bereit; kaputte Map = Klartext-Probleme. func _test_ready_report() -> void: print("Testgruppe: Map-Fertig-Check (ready_report)") var doc := SvmapDocument.create_new("Check-Test", 512.0, "abend") (doc["info"] as Dictionary)["author"] = "Sly" var report := SpawnOps.ready_report(doc, _manifest()) _check(bool(report["ok"]), "frische Map (4+4 Spawns): bereit zum Export") var all_ok := true for line in report["lines"]: if not bool(line["ok"]): all_ok = false _check(all_ok, "alle Check-Zeilen ✅ bei frischer Map") var has_poly := false for line in report["lines"]: if str(line["text"]).contains("Poly-Budget"): has_poly = true _check(has_poly, "Poly-Budget-Zeile im Klartext-Report") # Kaputt: Name leer + Spawn unter Limit + unbekanntes Objekt. (doc["info"] as Dictionary)["name"] = " " while SpawnOps.count_points(doc, 1) > 1: var pts := SpawnOps.points(doc, 1) SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"])) (doc["objects"] as Array).append({"id": "unicorn_mesh", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}) report = SpawnOps.ready_report(doc, _manifest()) _check(not bool(report["ok"]), "kaputte Map: NICHT bereit") var found := [] for line in report["lines"]: found.append(str(line["text"])) _check(_any_contains(found, "Map-Name"), "Check nennt fehlenden Map-Namen") _check(_any_contains(found, "V11"), "Check nennt V11 (Spawn-Limit) aus dem Loader-Kern") _check(_any_contains(found, "V7"), "Check nennt V7 (unbekannte Objekt-ID) aus dem Loader-Kern") func _any_contains(texts: Array, needle: String) -> bool: for t in texts: if t.contains(needle): return true return false ## Editor-Export → Loader-Re-Import = identisch. Alle Änderungen (Zonen +/−, ## Radien, Objekte) überleben Speichern/Laden; Prüfsumme validiert. func _test_export_reimport_identical() -> void: print("Testgruppe: Export + Loader-Re-Import = identisch") var doc := SvmapDocument.create_new("Export-Test", 512.0, "regen") (doc["info"] as Dictionary)["author"] = "Sly" # Objekte platzieren (bekannte IDs aus der Bibliothek). (doc["objects"] as Array).append({"id": "box_001", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 15.0}) (doc["objects"] as Array).append({"id": "checkpoint_001", "pos": [-40.0, 0.0, -20.0], "scale": 1.0, "rot_y_deg": 0.0}) # Spawn-Zonen umbauen: 2 rote löschen, 2 rote NEU setzen (IDs A5/B5,B6), # Radien setzen. Beide Teams bleiben ≥ 4 → Export gültig. while SpawnOps.count_points(doc, 1) > 2: var pts := SpawnOps.points(doc, 1) SpawnOps.remove_point(doc, 1, str(pts[pts.size() - 1]["id"])) var red1 := SpawnOps.add_zone(doc, 1, -222.0, 11.0) var red2 := SpawnOps.add_zone(doc, 1, -233.0, -11.0) var point := SpawnOps.add_zone(doc, 0, 33.0, -44.0) SpawnOps.set_radius(doc, str(point["id"]), 14.0) SpawnOps.set_radius(doc, "B1", 3.0) SpawnOps.set_radius(doc, str(red1["id"]), 8.0) _check(SpawnOps.count_points(doc, 0) == 5 and SpawnOps.count_points(doc, 1) == 4, "Testdokument: 5 blaue / 4 rote Zonen (beide Teams über dem V11-Limit)") # DER Export: derselbe Code wie beim Speichern (SvmapIO.save_to_file → validate + sign). var save := SvmapIO.save_to_file("user://test_spawns.svmap", doc) _check(save["ok"], "save_to_file ok: %s" % str(save["errors"])) var load := SvmapIO.load_from_file("user://test_spawns.svmap") _check(load["ok"], "load_from_file ok (Prüfsumme stimmt)") var back: Dictionary = load["data"] _check((back["spawn_zones"]["0"]["points"] as Array).size() == 5, "Re-Import: 5 blaue Zonen") _check((back["spawn_zones"]["1"]["points"] as Array).size() == 4, "Re-Import: 4 rote Zonen") var back_points: Array = back["spawn_zones"]["0"]["points"] var found_new := false for p in back_points: if str(p["id"]) == str(point["id"]): found_new = true _check(found_new, "Re-Import: neue Zone %s ist drin" % point["id"]) _check(_radius_from(back, str(point["id"])) == 14.0, "Re-Import: Radius 14 m überlebt (design_notes)") _check(_radius_from(back, "B1") == 3.0, "Re-Import: Radius B1 = 3 m überlebt") _check(_radius_from(back, str(red1["id"])) == 8.0, "Re-Import: Radius der NEUEN roten Zone = 8 m") _check((back["objects"] as Array).size() == 2, "Re-Import: beide Objekte da") # Roundtrip: speichern → laden → speichern → byte-identisch (Kanonisierung). var save2 := SvmapIO.save_to_file("user://test_spawns2.svmap", back) _check(save2["ok"], "Re-Save ok") var text1 := FileAccess.get_file_as_string("user://test_spawns.svmap") var text2 := FileAccess.get_file_as_string("user://test_spawns2.svmap") _check(text1 == text2, "speichern→laden→speichern = byte-identisch") func _radius_from(data: Dictionary, pid: String) -> float: var notes = data.get("design_notes", {}) if notes is Dictionary and (notes as Dictionary).get("spawn_radius_m", {}) is Dictionary: return float((notes["spawn_radius_m"] as Dictionary).get(pid, -1.0)) return -1.0 ## Mit Höhenfeld: neue Zone sitzt AUF dem Boden (y = Terrain-Höhe am Punkt). func _test_zone_on_terrain() -> void: print("Testgruppe: Zone auf Terrain-Höhe") # Gefälltes Terrain: Nordkante 0 m, Südkante 100 m (res 17, size 160). var res := 17 var bytes := PackedByteArray() bytes.resize(res * res * 2) var i := 0 while i < res * res: var row := i / res var raw := int(round(float(row) / float(res - 1) * 65535.0)) bytes[i * 2] = raw & 0xFF bytes[i * 2 + 1] = (raw >> 8) & 0xFF i += 1 var terrain := { "type": "heightmap", "size_x_m": 160.0, "size_z_m": 160.0, "min_height_m": 0.0, "max_height_m": 100.0, "resolution": res, "heightmap_r16_b64": Marshalls.raw_to_base64(bytes), } var doc := SvmapDocument.create_new("Hügel-Test", 160.0, "abend") doc["terrain"] = terrain var point := SpawnOps.add_zone(doc, 1, 0.0, 80.0) _check(absf(float(point["pos"][1]) - 100.0) < 1.0, "Zone auf Südkante: y ≈ 100 m (auf dem Boden)") SpawnOps.move_point(doc, 1, str(point["id"]), 0.0, -80.0) var moved_pos: Variant = null for p in SpawnOps.points(doc, 1): if str(p["id"]) == str(point["id"]): moved_pos = p["pos"] _check(moved_pos != null and absf(float(moved_pos[1]) - 0.0) < 1.0, "verschieben auf Nordkante: y ≈ 0 m")