Laden: gelöschte Objekte (Pilze) beim Öffnen ersetzen/überspringen statt Abbruch (test.svmap wieder ladbar)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 21:53:40 +01:00
parent fa2feee780
commit 075735f426
6 changed files with 203 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

77
tests/test_migration.gd Normal file
View file

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

View file

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