- 423 Objekte in 19 deutschen Kategorien (Panzer, Radfahrzeuge, Flugzeuge, Gebaeude, Zelte, Tuerme, Mauern, Sperren, Kisten, Technik, Felsen, Boden, Decals ...); Gruppen "Bauwerke" und "Kisten & Barrieren" samt Test-Asset-Namenslisten entfernt - LibraryBrowser: Gruppen/Namen aus dem Manifest (kategorie, name_de), unbekannte Kategorien bekommen automatisch eine eigene Gruppe - Thumbs neu, library/ synchronisiert (inkl. neuer Kollision) - Neue Identitaetsprobe: Kollision ALLER Objekte Editor = Client = Server - Tests auf Pack-Objekte umgestellt Co-Authored-By: Claude <noreply@anthropic.com>
314 lines
No EOL
14 KiB
GDScript
314 lines
No EOL
14 KiB
GDScript
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") |