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:
parent
fa2feee780
commit
075735f426
6 changed files with 203 additions and 2 deletions
|
|
@ -770,6 +770,51 @@ static func checksum_only_failure(errors: Array) -> bool:
|
||||||
return true
|
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):
|
## Laden mit Pruefsummen-Erneuerung (t_9814f18f, „Trotzdem laden“-Weg):
|
||||||
## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne
|
## Datei lesen + JSON parsen, dann NUR die Kern-Validierung (V3–V16 ohne
|
||||||
## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument
|
## Pruefsumme). Ist die gruen, traegt sign_map() die v2-Pruefsumme ins Dokument
|
||||||
|
|
|
||||||
67
src/svmap/svmap_migration.gd
Normal file
67
src/svmap/svmap_migration.gd
Normal 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)
|
||||||
1
src/svmap/svmap_migration.gd.uid
Normal file
1
src/svmap/svmap_migration.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dhfmxbbnonapa
|
||||||
|
|
@ -1279,6 +1279,13 @@ func _on_new_map_confirmed() -> void:
|
||||||
|
|
||||||
func _on_open_selected(path: String) -> void:
|
func _on_open_selected(path: String) -> void:
|
||||||
var result := SvmapIO.load_from_file(path)
|
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"]:
|
if not result["ok"]:
|
||||||
# t_9814f18f: NUR die Prüfsumme (V13) schlägt fehl, Kern (V3–V16) grün →
|
# t_9814f18f: NUR die Prüfsumme (V13) schlägt fehl, Kern (V3–V16) grün →
|
||||||
# Dialog „Trotzdem laden (Prüfsumme erneuern)“ statt hartem Abbruch.
|
# 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.
|
# Alte Bau-Sektion „gebaeude“ (vor 25.10.) → normale Bauteil-Objekte in einer Gruppe.
|
||||||
var uebernommen := BauOps.migriere_gebaeude(doc)
|
var uebernommen := BauOps.migriere_gebaeude(doc)
|
||||||
current_path = path
|
current_path = path
|
||||||
dirty = uebernommen > 0
|
dirty = uebernommen > 0 or not migration.is_empty()
|
||||||
history.begin(doc)
|
history.begin(doc)
|
||||||
_clear_edit_state()
|
_clear_edit_state()
|
||||||
_refresh_all()
|
_refresh_all()
|
||||||
_test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen
|
_test_start_reload() # Karte t_96e3c4ee: Sidecar der geladenen Map lesen
|
||||||
var map_name := str(doc.get("info", {}).get("name", "?"))
|
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])
|
_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:
|
else:
|
||||||
_set_status("Map „%s“ geladen (%d Objekte). Klicke ein Objekt in der Karte an, um es anzupassen." % [map_name, SvmapDocument.object_count(doc)])
|
_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
77
tests/test_migration.gd
Normal 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")
|
||||||
1
tests/test_migration.gd.uid
Normal file
1
tests/test_migration.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://pogsae4ri7b0
|
||||||
Loading…
Reference in a new issue