From 075735f426f83c99caa56228012dcf14d8a53acd Mon Sep 17 00:00:00 2001 From: slyphax Date: Sun, 25 Oct 2026 21:53:40 +0100 Subject: [PATCH] =?UTF-8?q?Laden:=20gel=C3=B6schte=20Objekte=20(Pilze)=20b?= =?UTF-8?q?eim=20=C3=96ffnen=20ersetzen/=C3=BCberspringen=20statt=20Abbruc?= =?UTF-8?q?h=20(test.svmap=20wieder=20ladbar)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/svmap/svmap_io.gd | 45 +++++++++++++++++++ src/svmap/svmap_migration.gd | 67 +++++++++++++++++++++++++++ src/svmap/svmap_migration.gd.uid | 1 + src/ui/main.gd | 14 +++++- tests/test_migration.gd | 77 ++++++++++++++++++++++++++++++++ tests/test_migration.gd.uid | 1 + 6 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 src/svmap/svmap_migration.gd create mode 100644 src/svmap/svmap_migration.gd.uid create mode 100644 tests/test_migration.gd create mode 100644 tests/test_migration.gd.uid diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index f34331a..ef1f867 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -770,6 +770,51 @@ static func checksum_only_failure(errors: Array) -> bool: return true +## true, wenn ein Load NUR an unbekannten Objekt-IDs (V7) und/oder der Prüfsumme +## (V13) hängt — Kriterium für die Migration beim Öffnen (SvmapMigration). +static func migrierbar(errors: Array) -> bool: + if errors.is_empty(): + return false + var v7 := false + for e in errors: + if str(e).begins_with("V7:"): + v7 = true + elif not str(e).begins_with("V13:"): + return false + return v7 + + +## Laden mit Migration veralteter Objekt-IDs: JSON parsen, unbekannte IDs per +## SvmapMigration ersetzen/überspringen, Kern-Validierung, v2-Prüfsumme erneuern. +## Rückgabe wie load_from_file plus "migration" (Bericht von migriere_objekte). +static func load_from_file_migriert(path: String) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}, "migration": {}} + if not FileAccess.file_exists(path): + _add(result, "Datei nicht gefunden: %s" % path) + return result + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + _add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return result + if fa.get_length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu gross (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) + return result + var parsed: Variant = JSON.parse_string(fa.get_as_text()) + if parsed == null or not (parsed is Dictionary): + _add(result, "V2: Kein gueltiges JSON-Objekt (Datei beschaedigt oder keine .svmap?).") + return result + var data: Dictionary = parsed + result["migration"] = SvmapMigration.migriere_objekte(data, _manifest_or({})) + var errors := _validate_core(data) + if not errors.is_empty(): + result["errors"] = Array(errors) + return result + sign_map(data) + result["ok"] = true + result["data"] = data + return result + + ## Laden mit Pruefsummen-Erneuerung (t_9814f18f, „Trotzdem laden“-Weg): ## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne ## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument diff --git a/src/svmap/svmap_migration.gd b/src/svmap/svmap_migration.gd new file mode 100644 index 0000000..5908844 --- /dev/null +++ b/src/svmap/svmap_migration.gd @@ -0,0 +1,67 @@ +extends RefCounted +class_name SvmapMigration +## Migration veralteter Objekt-IDs beim Laden (Karte 25.10.: test.svmap mit +## gelöschten Pilz-Objekten ließ sich nicht mehr öffnen, V7). +## +## Regel: Eine ID, die nicht (mehr) in der Bibliothek steht, wird per Ersatz-Tabelle +## durch ein passendes Pack-Objekt ersetzt (Position, Drehung, Skalierung, Gruppe +## bleiben). Gibt es keinen Ersatz, wird das Objekt übersprungen und gemeldet — +## nie ein Absturz, nie stilles Verschwinden. Die Datei selbst ändert sich erst +## beim Speichern (Undo/Neu-Laden bleibt möglich). + +## Präfix → Ersatz-ID (Pilze waren kleine kollisionslose Bodendeko → Gras 003). +const ERSATZ_PRAEFIX := { + "pilz_": "grass_003", +} + + +## Ersatz-ID für eine unbekannte ID ("" = keiner). +static func ersatz_fuer(oid: String, lib: Dictionary) -> String: + for praefix in ERSATZ_PRAEFIX: + if oid.begins_with(praefix): + var neu: String = ERSATZ_PRAEFIX[praefix] + return neu if lib.has(neu) else "" + return "" + + +## Migriert data["objects"] in place. Rückgabe: +## {"ersetzt": {alt_id: {"neu": id, "anzahl": n}}, "entfernt": {alt_id: n}, "gesamt": n} +static func migriere_objekte(data: Dictionary, manifest: Dictionary) -> Dictionary: + var bericht := {"ersetzt": {}, "entfernt": {}, "gesamt": 0} + var objects: Variant = data.get("objects", null) + if not (objects is Array): + return bericht + var lib: Dictionary = manifest.get("objects", {}) + var neu_liste: Array = [] + for obj in (objects as Array): + if not (obj is Dictionary) or not (obj.get("id", null) is String) \ + or lib.has(obj["id"]): + neu_liste.append(obj) + continue + var alt: String = obj["id"] + var ersatz := ersatz_fuer(alt, lib) + bericht["gesamt"] += 1 + if ersatz.is_empty(): + bericht["entfernt"][alt] = int(bericht["entfernt"].get(alt, 0)) + 1 + continue + var kopie: Dictionary = (obj as Dictionary).duplicate(true) + kopie["id"] = ersatz + neu_liste.append(kopie) + var eintrag: Dictionary = bericht["ersetzt"].get(alt, {"neu": ersatz, "anzahl": 0}) + eintrag["anzahl"] = int(eintrag["anzahl"]) + 1 + bericht["ersetzt"][alt] = eintrag + if int(bericht["gesamt"]) > 0: + data["objects"] = neu_liste + return bericht + + +## Menschenlesbare Zusammenfassung (deutsch) für die Statuszeile. +static func zusammenfassung(bericht: Dictionary, lib: Dictionary) -> String: + var teile: PackedStringArray = [] + for alt in bericht.get("ersetzt", {}): + var e: Dictionary = bericht["ersetzt"][alt] + var neu_name := str(lib.get(e["neu"], {}).get("name_de", e["neu"])) + teile.append("%d× „%s“ durch „%s“ ersetzt" % [e["anzahl"], alt, neu_name]) + for alt in bericht.get("entfernt", {}): + teile.append("%d× „%s“ übersprungen (kein Ersatz)" % [bericht["entfernt"][alt], alt]) + return ", ".join(teile) diff --git a/src/svmap/svmap_migration.gd.uid b/src/svmap/svmap_migration.gd.uid new file mode 100644 index 0000000..c5742e8 --- /dev/null +++ b/src/svmap/svmap_migration.gd.uid @@ -0,0 +1 @@ +uid://dhfmxbbnonapa diff --git a/src/ui/main.gd b/src/ui/main.gd index 50e43c4..6676497 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1279,6 +1279,13 @@ func _on_new_map_confirmed() -> void: func _on_open_selected(path: String) -> void: var result := SvmapIO.load_from_file(path) + var migration := {} + if not result["ok"] and SvmapIO.migrierbar(result["errors"]): + # Gelöschte Pack-Objekte (z. B. Pilze): ersetzen/überspringen statt Abbruch. + var migriert := SvmapIO.load_from_file_migriert(path) + if migriert["ok"]: + result = migriert + migration = migriert["migration"] if not result["ok"]: # t_9814f18f: NUR die Prüfsumme (V13) schlägt fehl, Kern (V3–V16) grün → # Dialog „Trotzdem laden (Prüfsumme erneuern)“ statt hartem Abbruch. @@ -1297,13 +1304,16 @@ func _on_open_selected(path: String) -> void: # Alte Bau-Sektion „gebaeude“ (vor 25.10.) → normale Bauteil-Objekte in einer Gruppe. var uebernommen := BauOps.migriere_gebaeude(doc) current_path = path - dirty = uebernommen > 0 + dirty = uebernommen > 0 or not migration.is_empty() history.begin(doc) _clear_edit_state() _refresh_all() _test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen var map_name := str(doc.get("info", {}).get("name", "?")) - if uebernommen > 0: + if not migration.is_empty() and int(migration.get("gesamt", 0)) > 0: + _set_status("Map „%s“ geladen. Veraltete Objekte angepasst: %s. Bitte prüfen und speichern — vorher bleibt die Datei unverändert." % [ + map_name, SvmapMigration.zusammenfassung(migration, SvmapIO._manifest_or({}).get("objects", {}))]) + elif uebernommen > 0: _set_status("Map „%s“ geladen. %d alte Bauteile wurden in das neue Bausystem übernommen (Gruppe „Gebäude“) — Wandhöhe ist jetzt 3 m, bitte kurz prüfen und speichern." % [map_name, uebernommen]) else: _set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)]) diff --git a/tests/test_migration.gd b/tests/test_migration.gd new file mode 100644 index 0000000..0b8c00b --- /dev/null +++ b/tests/test_migration.gd @@ -0,0 +1,77 @@ +extends SceneTree +## Suite: Migration veralteter Objekt-IDs beim Laden (test.svmap-Fall, Pilze). +## Ausführen: godot4 --headless --path . --script res://tests/test_migration.gd + +var failures := 0 +var checks := 0 +const PFAD := "user://test_migration.svmap" + + +func _init() -> void: + _run_tests() + if FileAccess.file_exists(PFAD): + DirAccess.remove_absolute(ProjectSettings.globalize_path(PFAD)) + 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: + var lib: Dictionary = SvmapIO._manifest_or({}) + _check(lib.get("objects", {}).has("grass_003"), "Ersatz-Objekt grass_003 steht in der Bibliothek") + _check(not lib["objects"].has("pilz_austernseitling"), "Pilze sind aus der Bibliothek verschwunden") + + # Map mit Pilz (ersetzbar), unbekanntem Objekt (kein Ersatz) und gültigem Objekt + var doc := SvmapDocument.create_new("Migration", 256.0, "mittag") + doc["objects"] = [ + {"id": "barrier_001", "pos": [1.0, 0.0, 1.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "pilz_austernseitling", "pos": [5.0, 0.0, 6.0], "scale": 2.0, "rot_y_deg": 33.0, "group": "g1"}, + {"id": "pilz_steinpilz", "pos": [7.0, 0.0, 8.0], "scale": 1.0, "rot_y_deg": 0.0}, + {"id": "gibt_es_nicht", "pos": [9.0, 0.0, 9.0], "scale": 1.0, "rot_y_deg": 0.0}, + ] + SvmapIO.sign_map(doc) + var f := FileAccess.open(PFAD, FileAccess.WRITE) + f.store_string(JSON.stringify(doc)) + f.close() + + var normal := SvmapIO.load_from_file(PFAD) + _check(not normal["ok"], "normales Laden scheitert an V7") + _check(SvmapIO.migrierbar(normal["errors"]), "Fehler gelten als migrierbar (nur V7)") + + var r := SvmapIO.load_from_file_migriert(PFAD) + _check(r["ok"], "Laden mit Migration klappt: %s" % str(r["errors"])) + var objs: Array = r["data"]["objects"] + _check(objs.size() == 3, "3 Objekte (1 gültig + 2 Pilze ersetzt, 1 unbekanntes übersprungen) – ist %d" % objs.size()) + _check(objs[0]["id"] == "barrier_001", "gültiges Objekt unverändert, Reihenfolge bleibt") + _check(objs[1]["id"] == "grass_003" and objs[2]["id"] == "grass_003", "Pilze → grass_003") + _check(objs[1]["pos"] == [5.0, 0.0, 6.0] and float(objs[1]["scale"]) == 2.0 + and float(objs[1]["rot_y_deg"]) == 33.0 and objs[1]["group"] == "g1", + "Position, Skalierung, Drehung und Gruppe bleiben erhalten") + var m: Dictionary = r["migration"] + _check(int(m["gesamt"]) == 3 and m["entfernt"].get("gibt_es_nicht", 0) == 1 + and m["ersetzt"]["pilz_steinpilz"]["anzahl"] == 1, "Bericht zählt Ersetzte und Übersprungene") + _check(SvmapMigration.zusammenfassung(m, lib["objects"]).contains("übersprungen"), "Zusammenfassung nennt Übersprungene") + _check(SvmapIO.validate_map(r["data"]).is_empty(), "migrierte Map besteht die volle Validierung (inkl. Prüfsumme)") + + # Roundtrip: speichern → laden ohne Migration + var s := SvmapIO.save_to_file(PFAD, r["data"]) + _check(s["ok"], "migrierte Map lässt sich speichern") + _check(SvmapIO.load_from_file(PFAD)["ok"], "gespeicherte Datei lädt danach normal") + + # Echter Fall: lokale test.svmap (falls vorhanden) + if FileAccess.file_exists("res://test.svmap"): + var t := SvmapIO.load_from_file_migriert(ProjectSettings.globalize_path("res://test.svmap")) + _check(t["ok"], "test.svmap lädt mit Migration: %s" % str(t["errors"])) + _check(int(t["migration"]["gesamt"]) == 6, "6 Pilze ersetzt") diff --git a/tests/test_migration.gd.uid b/tests/test_migration.gd.uid new file mode 100644 index 0000000..6f0b077 --- /dev/null +++ b/tests/test_migration.gd.uid @@ -0,0 +1 @@ +uid://pogsae4ri7b0