Map-Loadout: Loadout-Bereich im Map-Tab (Spieler-Loadout oder festes Map-Loadout mit Aufsaetzen), V18, Undo/Redo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-28 09:10:16 +01:00
parent 8345903ce7
commit bb9eb71770
12 changed files with 3743 additions and 1 deletions

3206
library/loadout_katalog.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,149 @@
class_name LoadoutKatalog
extends RefCounted
## Map-Loadout (Karte „Loadout pro Map"): Waffen-/Aufsatz-Katalog des Editors
## und die Regeln für den optionalen `loadout`-Block der .svmap (V18, 1:1 wie
## MapLoadout im Match-Server, docs/map_format.md).
##
## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby.
## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}}
## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client).
## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py).
const PATH := "res://library/loadout_katalog.json"
const MODE_FIXED := "fixed"
const MODE_PLAYER := "player"
static var _data: Dictionary = {}
static var _loaded := false
static func _ensure() -> void:
if _loaded:
return
_loaded = true
var f := FileAccess.open(PATH, FileAccess.READ)
if f == null:
push_warning("LoadoutKatalog: %s fehlt — Loadout-Auswahl bleibt leer" % PATH)
return
var parsed: Variant = JSON.parse_string(f.get_as_text())
if parsed is Dictionary:
_data = (parsed as Dictionary).get("waffen", {})
## Alle Waffen-IDs, nach Anzeigename sortiert.
static func waffen_ids() -> Array[String]:
_ensure()
var ids: Array[String] = []
for k in _data.keys():
ids.append(str(k))
ids.sort_custom(func(a: String, b: String) -> bool:
return anzeigename(a).naturalnocasecmp_to(anzeigename(b)) < 0)
return ids
static func hat_waffe(id: String) -> bool:
_ensure()
return _data.has(id)
static func anzeigename(id: String) -> String:
_ensure()
return str((_data.get(id, {}) as Dictionary).get("name", id))
## Aufsatz-Slots einer Waffe: {slot: {default, optional, options}} (leer = keine).
static func slots(id: String) -> Dictionary:
_ensure()
return (_data.get(id, {}) as Dictionary).get("slots", {})
static func decode(text: String) -> Dictionary:
var out := {}
for chunk in text.split(";", false):
var kv := chunk.split("=", true, 1)
if kv.size() == 2 and not kv[0].is_empty():
out[kv[0]] = kv[1]
return out
## Auswahl {slot: teil} → Draht-Text, nur Abweichungen vom Standard, Slots sortiert.
static func encode(id: String, sel: Dictionary) -> String:
var sl := slots(id)
var keys: Array = sel.keys()
keys.sort()
var parts: PackedStringArray = []
for s in keys:
if sl.has(s) and str(sel[s]) != str((sl[s] as Dictionary).get("default", "")):
parts.append("%s=%s" % [s, sel[s]])
return ";".join(parts)
## Aktuelle Teil-Wahl je Slot (Standard, überlagert vom gespeicherten Text).
static func auswahl(id: String, wire: String) -> Dictionary:
var out := {}
var sl := slots(id)
var dec := decode(wire)
for s in sl.keys():
out[s] = str(dec.get(s, (sl[s] as Dictionary).get("default", "")))
return out
## Grund, warum der Aufsatz-Text für die Waffe ungültig ist ("" = ok).
static func pruefe_aufsaetze(id: String, wire: String) -> String:
if wire.is_empty():
return ""
var sl := slots(id)
if sl.is_empty():
return "Waffe '%s' hat keine Aufsätze" % id
var dec := decode(wire)
if dec.is_empty():
return "Aufsatz-Text unlesbar"
for s in dec.keys():
if not sl.has(s):
return "Slot '%s' gibt es an '%s' nicht" % [s, id]
var p := str(dec[s])
var sd: Dictionary = sl[s]
if p.is_empty():
if not bool(sd.get("optional", false)):
return "Slot '%s' darf nicht leer sein" % s
elif not (sd.get("options", []) as Array).has(p):
return "Teil '%s' passt nicht an Slot '%s' von '%s'" % [p, s, id]
return ""
## V18-Fehlerliste (leer = gültig oder nicht vorhanden).
static func validate(lo: Variant) -> PackedStringArray:
var errors := PackedStringArray()
if lo == null:
return errors
if not (lo is Dictionary):
errors.append("V18: loadout muss ein Objekt sein.")
return errors
var d: Dictionary = lo
var mode := str(d.get("mode", MODE_PLAYER))
if mode != MODE_FIXED and mode != MODE_PLAYER:
errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode)
return errors
if mode == MODE_PLAYER:
return errors
var prim := str(d.get("primary", "")).strip_edges().to_lower()
var sec := str(d.get("secondary", "")).strip_edges().to_lower()
if prim.is_empty():
errors.append("V18: loadout.primary fehlt (Modus 'fixed').")
elif not hat_waffe(prim):
errors.append("V18: loadout.primary '%s' ist keine bekannte Waffe." % prim)
if not sec.is_empty() and not hat_waffe(sec):
errors.append("V18: loadout.secondary '%s' ist keine bekannte Waffe." % sec)
var att: Variant = d.get("attachments", {})
if not (att is Dictionary):
errors.append("V18: loadout.attachments muss ein Objekt sein.")
return errors
for wid in (att as Dictionary).keys():
var w := str(wid).strip_edges().to_lower()
if w != prim and w != sec:
errors.append("V18: loadout.attachments['%s'] gehört zu keiner Loadout-Waffe." % w)
continue
var why := pruefe_aufsaetze(w, str(att[wid]))
if why != "":
errors.append("V18: loadout.attachments['%s']: %s." % [w, why])
return errors

View file

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

View file

@ -220,6 +220,8 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe
ids.append(e["id"])
errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [
str(weather_sky), ", ".join(ids)])
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
return errors

View file

@ -0,0 +1,225 @@
extends "res://src/ui/panels/editor_panel.gd"
## Loadout-Bereich im Map-Tab (Karte „Loadout pro Map"): pro Map wählbar, ob die
## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout
## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler
## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server
## setzt ein festes Loadout durch, der Client zeigt es an.
##
## Jede Änderung ist ein Undo-Schritt (History-Block "loadout"; fehlt der Block,
## ist das Spieler-Loadout gemeint).
const KEINE := ""
var mode_option: OptionButton
var primary_option: OptionButton
var secondary_option: OptionButton
var fixed_box: VBoxContainer
var attach_box: VBoxContainer
var hint_label: Label
var _loading := false
var _ids: Array[String] = []
static func spec() -> Dictionary:
return {"titel": "Loadout", "gruppe": "map", "order": 25}
func panel_title() -> String:
return "Loadout"
func build() -> void:
add_child(_header_label("Loadout"))
add_child(_muted_label("Legt fest, mit welchen Waffen die Spieler diese Karte spielen."))
mode_option = OptionButton.new()
mode_option.tooltip_text = "Eigenes Loadout: Spieler nehmen ihre Auswahl aus der Lobby mit. Festes Loadout: alle bekommen genau diese Waffen, der Server setzt das durch."
mode_option.add_item("Eigenes Loadout der Spieler (Lobby)")
mode_option.add_item("Festes Map-Loadout")
add_child(mode_option)
fixed_box = VBoxContainer.new()
fixed_box.add_theme_constant_override("separation", UIScale.px(8))
add_child(fixed_box)
fixed_box.add_child(_field_label("Primärwaffe (Rücken)"))
primary_option = OptionButton.new()
fixed_box.add_child(primary_option)
fixed_box.add_child(_field_label("Sekundärwaffe (Hüfte)"))
secondary_option = OptionButton.new()
fixed_box.add_child(secondary_option)
_ids = LoadoutKatalog.waffen_ids()
for id in _ids:
primary_option.add_item(LoadoutKatalog.anzeigename(id))
secondary_option.add_item(LoadoutKatalog.anzeigename(id))
secondary_option.add_item("— keine —")
attach_box = VBoxContainer.new()
attach_box.add_theme_constant_override("separation", UIScale.px(6))
fixed_box.add_child(attach_box)
hint_label = _muted_label("Messer, Minen und Granaten bleiben unverändert.")
fixed_box.add_child(hint_label)
mode_option.item_selected.connect(func(i: int) -> void: set_fixed(i == 1))
primary_option.item_selected.connect(func(i: int) -> void:
set_weapons(_ids[i], _secondary_id()))
secondary_option.item_selected.connect(func(_i: int) -> void:
set_weapons(_primary_id(), _secondary_id()))
refresh_values()
# ------------------------------------------------------------- Lesen -------
func _primary_id() -> String:
return _ids[primary_option.selected] if primary_option.selected >= 0 else ""
func _secondary_id() -> String:
var i := secondary_option.selected
return _ids[i] if i >= 0 and i < _ids.size() else KEINE
func current() -> Dictionary:
return main.doc.get("loadout", {}) if main != null and not main.doc.is_empty() else {}
func is_fixed() -> bool:
return str(current().get("mode", LoadoutKatalog.MODE_PLAYER)) == LoadoutKatalog.MODE_FIXED
# ------------------------------------------------------------- Ändern ------
## Modus wechseln. Auf „fest" ohne vorherige Wahl: M40 + Pistole 1 als Start.
func set_fixed(fixed: bool) -> void:
if main.doc.is_empty():
return
if not fixed:
_commit(null, "Loadout: Spieler-Loadout")
return
var cur := current()
if cur.get("mode", "") == LoadoutKatalog.MODE_FIXED:
return
var prim := "m40" if LoadoutKatalog.hat_waffe("m40") else (_ids[0] if not _ids.is_empty() else "")
var sec := "pistol1" if LoadoutKatalog.hat_waffe("pistol1") else KEINE
_commit({"mode": LoadoutKatalog.MODE_FIXED, "primary": prim, "secondary": sec, "attachments": {}},
"Loadout: festes Map-Loadout")
## Waffen setzen. Aufsätze von Waffen, die nicht mehr im Loadout sind, fallen weg.
func set_weapons(primary: String, secondary: String) -> void:
if main.doc.is_empty() or not is_fixed():
return
if not LoadoutKatalog.hat_waffe(primary) or (secondary != KEINE and not LoadoutKatalog.hat_waffe(secondary)):
return
var lo := current().duplicate(true)
lo["primary"] = primary
lo["secondary"] = secondary
var att: Dictionary = lo.get("attachments", {})
for w in att.keys():
if w != primary and w != secondary:
att.erase(w)
lo["attachments"] = att
_commit(lo, "Loadout: %s / %s" % [LoadoutKatalog.anzeigename(primary),
LoadoutKatalog.anzeigename(secondary) if secondary != KEINE else "keine"])
## Ein Aufsatz-Slot einer Loadout-Waffe setzen (part = Teil-ID, "" = nichts).
func set_part(weapon: String, slot: String, part: String) -> void:
if main.doc.is_empty() or not is_fixed():
return
var lo := current().duplicate(true)
if weapon != str(lo.get("primary", "")) and weapon != str(lo.get("secondary", "")):
return
var att: Dictionary = lo.get("attachments", {})
var sel := LoadoutKatalog.auswahl(weapon, str(att.get(weapon, "")))
if not sel.has(slot):
return
sel[slot] = part
var wire := LoadoutKatalog.encode(weapon, sel)
if LoadoutKatalog.pruefe_aufsaetze(weapon, wire) != "":
return
if wire.is_empty():
att.erase(weapon)
else:
att[weapon] = wire
lo["attachments"] = att
_commit(lo, "Loadout: Aufsatz %s" % slot)
func _commit(neu: Variant, text: String) -> void:
var alt: Variant = main.doc.get("loadout", null)
if alt != null:
alt = (alt as Dictionary).duplicate(true)
if neu == null:
main.doc.erase("loadout")
else:
main.doc["loadout"] = (neu as Dictionary).duplicate(true)
var danach: Variant = main.doc.get("loadout", null)
if danach != null:
danach = (danach as Dictionary).duplicate(true)
if alt == danach:
return
main.history.commit(text, {"loadout": alt}, {"loadout": danach})
main.dirty = true
main.call("_refresh_file_label")
main.call("_refresh_history_buttons")
refresh_values()
main.call("_set_status", "%s." % text)
# ------------------------------------------------------------ Anzeige ------
## UI aus dem doc auffüllen (Laden, Undo/Redo) — löst keinen Schreibzugriff aus.
func refresh_values() -> void:
if mode_option == null or main == null:
return
_loading = true
var lo := current()
var fixed := is_fixed()
mode_option.select(1 if fixed else 0)
fixed_box.visible = fixed
if fixed:
var prim := str(lo.get("primary", ""))
var sec := str(lo.get("secondary", ""))
primary_option.select(maxi(0, _ids.find(prim)))
secondary_option.select(_ids.find(sec) if sec != KEINE and _ids.has(sec) else _ids.size())
_rebuild_attachments(prim, sec, lo.get("attachments", {}))
_loading = false
func _rebuild_attachments(prim: String, sec: String, att: Dictionary) -> void:
for c in attach_box.get_children():
attach_box.remove_child(c)
c.queue_free()
for w in [prim, sec]:
if w == "" or LoadoutKatalog.slots(w).is_empty():
continue
attach_box.add_child(_header_label("Aufsätze: %s" % LoadoutKatalog.anzeigename(w)))
var sel := LoadoutKatalog.auswahl(w, str(att.get(w, "")))
var sl := LoadoutKatalog.slots(w)
var slot_names: Array = sl.keys()
slot_names.sort()
for s in slot_names:
var sd: Dictionary = sl[s]
var row := HBoxContainer.new()
var lab := _field_label(str(s).replace("_", " "))
lab.custom_minimum_size = UIScale.v2(90, 0)
row.add_child(lab)
var ob := OptionButton.new()
ob.name = "Teil_%s_%s" % [w, s]
ob.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var parts: Array = []
if bool(sd.get("optional", false)):
parts.append("")
parts.append_array(sd.get("options", []))
for p in parts:
ob.add_item("— nichts —" if str(p) == "" else str(p))
ob.select(maxi(0, parts.find(sel.get(s, ""))))
ob.item_selected.connect(func(i: int) -> void:
if not _loading:
set_part(w, str(s), str(parts[i])))
row.add_child(ob)
attach_box.add_child(row)

View file

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

View file

@ -254,4 +254,8 @@ func refresh_values() -> void:
var wl: Variant = main.doc.get("water_level", null)
prop_water.set_value_no_signal(
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
# Loadout-Bereich (Registry-Gruppe "map") aus dem doc nachziehen.
var loadout_panel: Node = _sections.get_node_or_null("loadout") if _sections != null else null
if loadout_panel != null:
loadout_panel.call("refresh_values")
_loading_ui_values = false

View file

@ -44,6 +44,8 @@ const PANELS: Array = [
"skript": "res://src/ui/panels/check_panel.gd"},
{"id": "teststart", "titel": "Test-Start", "gruppe": "map", "order": 20,
"skript": "res://src/ui/panels/test_start_panel.gd"},
{"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25,
"skript": "res://src/ui/panels/loadout_panel.gd"},
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
"skript": "res://src/ui/panels/symmetry_panel.gd"},
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---

119
tests/test_loadout.gd Normal file
View file

@ -0,0 +1,119 @@
extends SceneTree
## Editor-Test (headless): Map-Loadout (V18) — Katalog, Validierung, Panel mit
## Undo/Redo und Speichern/Laden. Ausführen:
## godot --headless --path . --script res://tests/test_loadout.gd
var failures := 0
var checks := 0
var main: Control
func _initialize() -> void:
_run_all.call_deferred()
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _run_all() -> void:
_test_katalog()
await _test_panel()
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)
func _test_katalog() -> void:
print("Testgruppe: Katalog + Validierung")
_check(LoadoutKatalog.waffen_ids().size() >= 50, "Katalog hat Waffen")
_check(LoadoutKatalog.hat_waffe("m40") and LoadoutKatalog.hat_waffe("pistol1"), "m40 + pistol1 bekannt")
_check(LoadoutKatalog.validate(null).is_empty(), "fehlend gültig")
_check(LoadoutKatalog.validate({"mode": "player"}).is_empty(), "player gültig")
_check(not LoadoutKatalog.validate({"mode": "fixed"}).is_empty(), "fixed ohne Primär ungültig")
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "nix"}).is_empty(), "unbekannte Waffe ungültig")
_check(not LoadoutKatalog.validate({"mode": "x"}).is_empty(), "unbekannter Modus ungültig")
_check(LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1", "secondary": ""}).is_empty(), "fixed ohne Sekundär gültig")
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1",
"attachments": {"ar1": ""}}).is_empty(), "Aufsatz für Fremdwaffe ungültig")
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1",
"attachments": {"sr1": "sight=nix"}}).is_empty(), "unbekanntes Teil ungültig")
var sel := LoadoutKatalog.auswahl("sr1", "")
sel["muzzle"] = "muzzle_suppressor_01"
var wire := LoadoutKatalog.encode("sr1", sel)
_check(wire == "muzzle=muzzle_suppressor_01", "encode nur Abweichungen: " + wire)
_check(LoadoutKatalog.pruefe_aufsaetze("sr1", wire) == "", "encodierter Text gültig")
func _test_panel() -> void:
print("Testgruppe: Panel")
var scene: PackedScene = load("res://scenes/main.tscn")
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
main.doc = SvmapDocument.create_new("LoadoutTest", 512.0, "mittag")
main.history.doc = main.doc
main.call("_refresh_all")
var panel: Node = main.map_mod.sections_host().get_node_or_null("loadout")
_check(panel != null, "Loadout-Panel hängt im Map-Tab")
if panel == null:
return
_check(not panel.is_fixed() and not main.doc.has("loadout"), "Start: Spieler-Loadout, kein Block")
_check(not panel.fixed_box.visible, "Waffen-Auswahl verborgen")
panel.set_fixed(true)
_check(panel.is_fixed() and main.doc["loadout"]["primary"] == "m40", "fest: M40 als Start")
_check(panel.fixed_box.visible, "Waffen-Auswahl sichtbar")
panel.set_weapons("sr1", "")
_check(main.doc["loadout"]["primary"] == "sr1" and main.doc["loadout"]["secondary"] == "", "Waffen gesetzt, Sekundär leer")
panel.set_part("sr1", "muzzle", "muzzle_suppressor_01")
_check(main.doc["loadout"]["attachments"].get("sr1", "") == "muzzle=muzzle_suppressor_01", "Aufsatz gespeichert")
panel.set_part("ar1", "muzzle", "muzzle_suppressor_01")
_check(not main.doc["loadout"]["attachments"].has("ar1"), "Aufsatz für Nicht-Loadout-Waffe ignoriert")
_check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "Map mit Loadout besteht V1–V18")
panel.set_weapons("ar1", "")
_check(not main.doc["loadout"]["attachments"].has("sr1"), "Waffenwechsel verwirft Aufsätze der alten Waffe")
# Undo/Redo
main.history.undo()
main.call("_refresh_all")
_check(main.doc["loadout"]["primary"] == "sr1" and main.doc["loadout"]["attachments"].has("sr1"), "Undo: Waffenwechsel zurück")
main.history.undo()
main.history.undo()
main.history.undo()
main.call("_refresh_all")
_check(not main.doc.has("loadout") and not panel.fixed_box.visible, "Undo bis Start: Block weg, UI nachgezogen")
main.history.redo()
main.call("_refresh_all")
_check(main.doc.has("loadout") and panel.is_fixed(), "Redo: fest wieder da")
# Zurück auf Spieler-Loadout entfernt den Block
panel.set_fixed(false)
_check(not main.doc.has("loadout"), "Spieler-Loadout entfernt den Block")
# Speichern/Laden
panel.set_fixed(true)
panel.set_weapons("sr2", "pistol2")
var path := "user://loadout_test.svmap"
var doc2: Dictionary = main.doc.duplicate(true)
SvmapIO.sign_map(doc2)
var res := SvmapIO.save_to_file(path, doc2)
_check(res["ok"], "Speichern ok: " + str(res["errors"]))
var back := SvmapIO.load_from_file(path)
_check(back["ok"] and back["data"]["loadout"]["primary"] == "sr2", "Laden: Loadout bleibt erhalten")
func _signed(d: Dictionary) -> Dictionary:
var c := d.duplicate(true)
SvmapIO.sign_map(c)
return c

View file

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

View file

@ -3,7 +3,7 @@
cd "$(dirname "$0")/.."
GODOT=${GODOT:-/opt/data/home/bin/godot4}
rc=0
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_build_spawn_mirror test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale; do
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_build_spawn_mirror test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale test_loadout; do
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
code=$?
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Waffen- und Aufsatz-Katalog fürs Map-Loadout aus dem Match-Server-Repo holen.
Aufruf: python3 tools/sync_loadout_katalog.py [pfad/zu/scopedvr-match-server]
Schreibt library/loadout_katalog.json (Waffen-ID -> Name, Aufsatz-Slots).
Nach neuen Waffen/Aufsätzen im Server erneut ausführen.
"""
import json
import os
import sys
hier = os.path.dirname(os.path.abspath(__file__))
server = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/godot-projects/scopedvr-match-server")
profile = json.load(open(os.path.join(server, "library/weapons.json"), encoding="utf-8"))["profiles"]
aufsaetze = json.load(open(os.path.join(server, "library/attachments.json"), encoding="utf-8"))["weapons"]
waffen = {}
for wid in sorted(profile):
p = profile[wid]
eintrag = {"name": str(p.get("name", wid))}
if wid in aufsaetze:
eintrag["slots"] = {
s: {"default": d.get("default", ""), "optional": bool(d.get("optional", False)),
"options": list(d.get("options", []))}
for s, d in sorted(aufsaetze[wid].items())
}
waffen[wid] = eintrag
ziel = os.path.join(hier, "..", "library", "loadout_katalog.json")
with open(ziel, "w", encoding="utf-8") as f:
json.dump({"version": 1, "waffen": waffen}, f, ensure_ascii=False, indent=1, sort_keys=True)
print("%d Waffen -> %s" % (len(waffen), os.path.normpath(ziel)))