From 48db200d3e4fde504f61ff4ffa54a2acecfacee5 Mon Sep 17 00:00:00 2001 From: slyphax Date: Thu, 29 Oct 2026 00:04:11 +0100 Subject: [PATCH] Server-Sektion im Editor: Client der Admin-API (Server speichern mit verschluesseltem Token, Uebersicht, Einstellungen, Spieler kicken/sperren, Map laden/loeschen, Rotation, Logs) und 'Aktuelle Map auf Server pushen' mit verstaendlicher 422-Fehlerliste; SvmapIO.to_signed_text; Tests inkl. E2E gegen echten Server Co-Authored-By: Claude Sonnet 5.5 --- docs/server-sektion.md | 45 ++ .../thumbs/bau_dach_ecke_aussen.png.import | 40 ++ library/thumbs/bau_dach_ecke_innen.png.import | 40 ++ .../thumbs/bau_dach_giebel_ende.png.import | 40 ++ library/thumbs/bau_dach_plateau.png.import | 40 ++ library/thumbs/bau_dach_schraege.png.import | 40 ++ .../thumbs/container_001_offen1.png.import | 40 ++ .../thumbs/container_001_offen2.png.import | 40 ++ library/thumbs/container_001_ohne.png.import | 40 ++ .../thumbs/container_002_offen1.png.import | 40 ++ .../thumbs/container_002_offen2.png.import | 40 ++ library/thumbs/container_002_ohne.png.import | 40 ++ .../thumbs/container_003_offen1.png.import | 40 ++ .../thumbs/container_003_offen2.png.import | 40 ++ library/thumbs/container_003_ohne.png.import | 40 ++ .../thumbs/container_004_offen1.png.import | 40 ++ .../thumbs/container_004_offen2.png.import | 40 ++ library/thumbs/container_004_ohne.png.import | 40 ++ .../thumbs/container_005_offen1.png.import | 40 ++ .../thumbs/container_005_offen2.png.import | 40 ++ library/thumbs/container_005_ohne.png.import | 40 ++ .../thumbs/container_006_offen1.png.import | 40 ++ .../thumbs/container_006_offen2.png.import | 40 ++ library/thumbs/container_006_ohne.png.import | 40 ++ .../thumbs/container_007_offen1.png.import | 40 ++ .../thumbs/container_007_offen2.png.import | 40 ++ library/thumbs/container_007_ohne.png.import | 40 ++ .../thumbs/container_008_offen1.png.import | 40 ++ .../thumbs/container_008_offen2.png.import | 40 ++ library/thumbs/container_008_ohne.png.import | 40 ++ src/server/admin_client.gd | 112 +++ src/server/admin_client.gd.uid | 1 + src/server/admin_texte.gd | 97 +++ src/server/admin_texte.gd.uid | 1 + src/server/map_push.gd | 40 ++ src/server/map_push.gd.uid | 1 + src/server/server_store.gd | 114 ++++ src/server/server_store.gd.uid | 1 + src/svmap/svmap_io.gd | 26 +- src/ui/panels/registry.gd | 3 + src/ui/panels/server_panel.gd | 642 ++++++++++++++++++ src/ui/panels/server_panel.gd.uid | 1 + tests/run_server_sektion_e2e.sh | 52 ++ tests/server_sektion_e2e.gd | 127 ++++ tests/server_sektion_e2e.gd.uid | 1 + tests/support_fake_main.gd | 3 + tests/support_fake_main.gd.uid | 1 + tests/test_server_sektion.gd | 78 +++ tests/test_server_sektion.gd.uid | 1 + tests/test_ui_layout.gd | 2 +- 50 files changed, 2499 insertions(+), 10 deletions(-) create mode 100644 docs/server-sektion.md create mode 100644 library/thumbs/bau_dach_ecke_aussen.png.import create mode 100644 library/thumbs/bau_dach_ecke_innen.png.import create mode 100644 library/thumbs/bau_dach_giebel_ende.png.import create mode 100644 library/thumbs/bau_dach_plateau.png.import create mode 100644 library/thumbs/bau_dach_schraege.png.import create mode 100644 library/thumbs/container_001_offen1.png.import create mode 100644 library/thumbs/container_001_offen2.png.import create mode 100644 library/thumbs/container_001_ohne.png.import create mode 100644 library/thumbs/container_002_offen1.png.import create mode 100644 library/thumbs/container_002_offen2.png.import create mode 100644 library/thumbs/container_002_ohne.png.import create mode 100644 library/thumbs/container_003_offen1.png.import create mode 100644 library/thumbs/container_003_offen2.png.import create mode 100644 library/thumbs/container_003_ohne.png.import create mode 100644 library/thumbs/container_004_offen1.png.import create mode 100644 library/thumbs/container_004_offen2.png.import create mode 100644 library/thumbs/container_004_ohne.png.import create mode 100644 library/thumbs/container_005_offen1.png.import create mode 100644 library/thumbs/container_005_offen2.png.import create mode 100644 library/thumbs/container_005_ohne.png.import create mode 100644 library/thumbs/container_006_offen1.png.import create mode 100644 library/thumbs/container_006_offen2.png.import create mode 100644 library/thumbs/container_006_ohne.png.import create mode 100644 library/thumbs/container_007_offen1.png.import create mode 100644 library/thumbs/container_007_offen2.png.import create mode 100644 library/thumbs/container_007_ohne.png.import create mode 100644 library/thumbs/container_008_offen1.png.import create mode 100644 library/thumbs/container_008_offen2.png.import create mode 100644 library/thumbs/container_008_ohne.png.import create mode 100644 src/server/admin_client.gd create mode 100644 src/server/admin_client.gd.uid create mode 100644 src/server/admin_texte.gd create mode 100644 src/server/admin_texte.gd.uid create mode 100644 src/server/map_push.gd create mode 100644 src/server/map_push.gd.uid create mode 100644 src/server/server_store.gd create mode 100644 src/server/server_store.gd.uid create mode 100644 src/ui/panels/server_panel.gd create mode 100644 src/ui/panels/server_panel.gd.uid create mode 100755 tests/run_server_sektion_e2e.sh create mode 100644 tests/server_sektion_e2e.gd create mode 100644 tests/server_sektion_e2e.gd.uid create mode 100644 tests/support_fake_main.gd create mode 100644 tests/support_fake_main.gd.uid create mode 100644 tests/test_server_sektion.gd create mode 100644 tests/test_server_sektion.gd.uid diff --git a/docs/server-sektion.md b/docs/server-sektion.md new file mode 100644 index 0000000..fb15912 --- /dev/null +++ b/docs/server-sektion.md @@ -0,0 +1,45 @@ +# Server-Sektion (Tab „Server“) + +Der Editor ist ein Client der Match-Server-Admin-API (`docs/admin-api.md` im +Repo scopedvr-match-server). Damit verwaltet jeder seinen eigenen Server – ohne +Konsole, ohne Tailscale, auf jedem Betriebssystem. + +## Einrichten +1. Auf dem Server in der `server.cfg` `[admin] enabled=true` setzen und starten. + Das Token steht danach in der `state_file` des Servers (Standard `admin.cfg`). +2. Editor → Tab **Server** → **➕ Neu**: Name, Adresse, Port (Standard 24580), Token. + „🔌 Verbindung testen“ prüft Erreichbarkeit und Token. +3. **🔌 Verbinden**. + +**Sicher erreichen:** Die Admin-API spricht Klartext-HTTP und hört standardmäßig nur +auf localhost. Entweder SSH-Tunnel (`ssh -L 24580:127.0.0.1:24580 benutzer@server`, +im Editor dann Adresse `127.0.0.1`) oder ein Reverse-Proxy mit https (Haken +„Sichere Verbindung“). + +## Funktionen +- Mehrere Server speichern; **Token verschlüsselt** (Schlüssel aus Rechner-ID + + Nutzerordner), nie im Klartext in Datei oder Log. Schutz gegen Zufallsleser, nicht + gegen Schadsoftware auf deinem Benutzerkonto. +- **Übersicht:** Name, Map, Modus, Phase, Spieler/Bots, Laufzeit, öffentlich/privat. +- **⬆ Aktuelle Map hochladen:** prüft und signiert die Map im Editor (wie Speichern), + lädt sie hoch, der Server prüft noch einmal. Ungültige Maps werden gar nicht erst + gesendet; 422-Fehler erscheinen als verständliche Liste mit Hilfetexten. + Existiert der Name schon, fragt der Editor, ob ersetzt werden soll. Optional: + direkt in die Rotation aufnehmen. +- **Einstellungen:** Formular direkt aus der Server-Spec; gesendet werden nur + geänderte Werte. Die Antwort zeigt je Wert: sofort / ab nächster Map / nach + Neustart. „↩ Zurücksetzen“ verwirft Änderungen. +- **Spieler:** Kick, Sperren (10 min … dauerhaft), Sperrliste mit „Aufheben“. +- **Match & Maps:** Match neu starten, Map jetzt laden, Rotation per Häkchen, + hochgeladene Maps löschen. Alles Zerstörerische fragt vorher nach. +- **Server-Log** mit Filter. + +## Code +`src/server/` (AdminClient, ServerStore, AdminTexte, MapPush), +`src/ui/panels/server_panel.gd`, `SvmapIO.to_signed_text`. + +## Tests +`tests/test_server_sektion.gd` (Unit), `tests/run_server_sektion_e2e.sh` +(echter Match-Server + echtes Panel: Verbinden, falsches Token, unerreichbar, +Einstellungen, Push gültig/doppelt/ersetzen/lokal ungültig/422, Rotation, +Map laden/löschen, Restart, Kick, Logs). diff --git a/library/thumbs/bau_dach_ecke_aussen.png.import b/library/thumbs/bau_dach_ecke_aussen.png.import new file mode 100644 index 0000000..b13945f --- /dev/null +++ b/library/thumbs/bau_dach_ecke_aussen.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://djm3tfu1hf0wu" +path="res://.godot/imported/bau_dach_ecke_aussen.png-e588c4f52ecd2b419b53da2ed03fba41.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bau_dach_ecke_aussen.png" +dest_files=["res://.godot/imported/bau_dach_ecke_aussen.png-e588c4f52ecd2b419b53da2ed03fba41.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/bau_dach_ecke_innen.png.import b/library/thumbs/bau_dach_ecke_innen.png.import new file mode 100644 index 0000000..24652d0 --- /dev/null +++ b/library/thumbs/bau_dach_ecke_innen.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drk0drrumym47" +path="res://.godot/imported/bau_dach_ecke_innen.png-76568d9aa31ff8a708208e9950258766.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bau_dach_ecke_innen.png" +dest_files=["res://.godot/imported/bau_dach_ecke_innen.png-76568d9aa31ff8a708208e9950258766.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/bau_dach_giebel_ende.png.import b/library/thumbs/bau_dach_giebel_ende.png.import new file mode 100644 index 0000000..2f5c77c --- /dev/null +++ b/library/thumbs/bau_dach_giebel_ende.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d4mwoant504rw" +path="res://.godot/imported/bau_dach_giebel_ende.png-2b3b413e0bbd5a2e891b2db4f2cb998a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bau_dach_giebel_ende.png" +dest_files=["res://.godot/imported/bau_dach_giebel_ende.png-2b3b413e0bbd5a2e891b2db4f2cb998a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/bau_dach_plateau.png.import b/library/thumbs/bau_dach_plateau.png.import new file mode 100644 index 0000000..6e28012 --- /dev/null +++ b/library/thumbs/bau_dach_plateau.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dd36il31xybcr" +path="res://.godot/imported/bau_dach_plateau.png-a214f27a54f2e159ae179f49a5e43f35.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bau_dach_plateau.png" +dest_files=["res://.godot/imported/bau_dach_plateau.png-a214f27a54f2e159ae179f49a5e43f35.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/bau_dach_schraege.png.import b/library/thumbs/bau_dach_schraege.png.import new file mode 100644 index 0000000..1a28f63 --- /dev/null +++ b/library/thumbs/bau_dach_schraege.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chval4fuxxbsh" +path="res://.godot/imported/bau_dach_schraege.png-42e12c1909f5a82025bdac6cbeafbc9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bau_dach_schraege.png" +dest_files=["res://.godot/imported/bau_dach_schraege.png-42e12c1909f5a82025bdac6cbeafbc9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_001_offen1.png.import b/library/thumbs/container_001_offen1.png.import new file mode 100644 index 0000000..7945369 --- /dev/null +++ b/library/thumbs/container_001_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://btjpsdieswfhj" +path="res://.godot/imported/container_001_offen1.png-725108fda9fc4bae4e75d8737617f24a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_001_offen1.png" +dest_files=["res://.godot/imported/container_001_offen1.png-725108fda9fc4bae4e75d8737617f24a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_001_offen2.png.import b/library/thumbs/container_001_offen2.png.import new file mode 100644 index 0000000..3255244 --- /dev/null +++ b/library/thumbs/container_001_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bngbmgxfh4ft7" +path="res://.godot/imported/container_001_offen2.png-ac048e0498e6a7edec41677c95acbe22.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_001_offen2.png" +dest_files=["res://.godot/imported/container_001_offen2.png-ac048e0498e6a7edec41677c95acbe22.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_001_ohne.png.import b/library/thumbs/container_001_ohne.png.import new file mode 100644 index 0000000..e811142 --- /dev/null +++ b/library/thumbs/container_001_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cgdt3h13rqdug" +path="res://.godot/imported/container_001_ohne.png-e8a4d4481a3212fe1399f5abe48dc49f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_001_ohne.png" +dest_files=["res://.godot/imported/container_001_ohne.png-e8a4d4481a3212fe1399f5abe48dc49f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_002_offen1.png.import b/library/thumbs/container_002_offen1.png.import new file mode 100644 index 0000000..97f2faf --- /dev/null +++ b/library/thumbs/container_002_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://chcsxajuvgspk" +path="res://.godot/imported/container_002_offen1.png-61a1ea8088ec6a0d737e55e04ef9603d.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_002_offen1.png" +dest_files=["res://.godot/imported/container_002_offen1.png-61a1ea8088ec6a0d737e55e04ef9603d.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_002_offen2.png.import b/library/thumbs/container_002_offen2.png.import new file mode 100644 index 0000000..4ffab38 --- /dev/null +++ b/library/thumbs/container_002_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dw56ic7fws26" +path="res://.godot/imported/container_002_offen2.png-15b6d9bade0ee7bc41522048722c4faa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_002_offen2.png" +dest_files=["res://.godot/imported/container_002_offen2.png-15b6d9bade0ee7bc41522048722c4faa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_002_ohne.png.import b/library/thumbs/container_002_ohne.png.import new file mode 100644 index 0000000..55426ca --- /dev/null +++ b/library/thumbs/container_002_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwc0jmnhnch4k" +path="res://.godot/imported/container_002_ohne.png-31e74fa68f10023ca6e8312f3b689d2c.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_002_ohne.png" +dest_files=["res://.godot/imported/container_002_ohne.png-31e74fa68f10023ca6e8312f3b689d2c.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_003_offen1.png.import b/library/thumbs/container_003_offen1.png.import new file mode 100644 index 0000000..a1d9c6d --- /dev/null +++ b/library/thumbs/container_003_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dprnj64lwdete" +path="res://.godot/imported/container_003_offen1.png-8d5eab9b669b706ea4aa37ad53c4ba95.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_003_offen1.png" +dest_files=["res://.godot/imported/container_003_offen1.png-8d5eab9b669b706ea4aa37ad53c4ba95.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_003_offen2.png.import b/library/thumbs/container_003_offen2.png.import new file mode 100644 index 0000000..ddb6640 --- /dev/null +++ b/library/thumbs/container_003_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c2kpdcvfpfapd" +path="res://.godot/imported/container_003_offen2.png-7c9deb6f88b261c0b4aecf35b01f8314.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_003_offen2.png" +dest_files=["res://.godot/imported/container_003_offen2.png-7c9deb6f88b261c0b4aecf35b01f8314.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_003_ohne.png.import b/library/thumbs/container_003_ohne.png.import new file mode 100644 index 0000000..ebeee07 --- /dev/null +++ b/library/thumbs/container_003_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://df4oqdfkq2odt" +path="res://.godot/imported/container_003_ohne.png-22a7100a50dcef57430f90c79791e125.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_003_ohne.png" +dest_files=["res://.godot/imported/container_003_ohne.png-22a7100a50dcef57430f90c79791e125.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_004_offen1.png.import b/library/thumbs/container_004_offen1.png.import new file mode 100644 index 0000000..2c88928 --- /dev/null +++ b/library/thumbs/container_004_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ij0up66wkdei" +path="res://.godot/imported/container_004_offen1.png-06087770f1b66fc9590ac9dfceebe7a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_004_offen1.png" +dest_files=["res://.godot/imported/container_004_offen1.png-06087770f1b66fc9590ac9dfceebe7a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_004_offen2.png.import b/library/thumbs/container_004_offen2.png.import new file mode 100644 index 0000000..bf936ee --- /dev/null +++ b/library/thumbs/container_004_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6so0720te2wu" +path="res://.godot/imported/container_004_offen2.png-ae6f991a964a2789eb52bbdca8dc99d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_004_offen2.png" +dest_files=["res://.godot/imported/container_004_offen2.png-ae6f991a964a2789eb52bbdca8dc99d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_004_ohne.png.import b/library/thumbs/container_004_ohne.png.import new file mode 100644 index 0000000..7d66069 --- /dev/null +++ b/library/thumbs/container_004_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://0g2gmbbh2ltg" +path="res://.godot/imported/container_004_ohne.png-5fbe39a60bd4f74e4a899e22aaec7052.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_004_ohne.png" +dest_files=["res://.godot/imported/container_004_ohne.png-5fbe39a60bd4f74e4a899e22aaec7052.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_005_offen1.png.import b/library/thumbs/container_005_offen1.png.import new file mode 100644 index 0000000..598f534 --- /dev/null +++ b/library/thumbs/container_005_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b05yavnc71m3q" +path="res://.godot/imported/container_005_offen1.png-8690fc5534a36230b3eed962443bff34.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_005_offen1.png" +dest_files=["res://.godot/imported/container_005_offen1.png-8690fc5534a36230b3eed962443bff34.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_005_offen2.png.import b/library/thumbs/container_005_offen2.png.import new file mode 100644 index 0000000..9611deb --- /dev/null +++ b/library/thumbs/container_005_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b7sjub4cbkxvg" +path="res://.godot/imported/container_005_offen2.png-e489310c39c72eae0567321deb8ea3cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_005_offen2.png" +dest_files=["res://.godot/imported/container_005_offen2.png-e489310c39c72eae0567321deb8ea3cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_005_ohne.png.import b/library/thumbs/container_005_ohne.png.import new file mode 100644 index 0000000..a9ca61a --- /dev/null +++ b/library/thumbs/container_005_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bnsu2xyjabygv" +path="res://.godot/imported/container_005_ohne.png-cca3bca3fe3c4fb400950a1c006ccd92.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_005_ohne.png" +dest_files=["res://.godot/imported/container_005_ohne.png-cca3bca3fe3c4fb400950a1c006ccd92.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_006_offen1.png.import b/library/thumbs/container_006_offen1.png.import new file mode 100644 index 0000000..42dc471 --- /dev/null +++ b/library/thumbs/container_006_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bxxe0may3yeqc" +path="res://.godot/imported/container_006_offen1.png-e48ae560414dc7eaebdb9ab27cabffe5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_006_offen1.png" +dest_files=["res://.godot/imported/container_006_offen1.png-e48ae560414dc7eaebdb9ab27cabffe5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_006_offen2.png.import b/library/thumbs/container_006_offen2.png.import new file mode 100644 index 0000000..72a0bdb --- /dev/null +++ b/library/thumbs/container_006_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4e8fd4t7r7m6" +path="res://.godot/imported/container_006_offen2.png-625b70d0d67725f796bf54ac92fca681.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_006_offen2.png" +dest_files=["res://.godot/imported/container_006_offen2.png-625b70d0d67725f796bf54ac92fca681.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_006_ohne.png.import b/library/thumbs/container_006_ohne.png.import new file mode 100644 index 0000000..43be22a --- /dev/null +++ b/library/thumbs/container_006_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhrrb85snu41i" +path="res://.godot/imported/container_006_ohne.png-7974c20bd4edf4927c656fe6fc90b81e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_006_ohne.png" +dest_files=["res://.godot/imported/container_006_ohne.png-7974c20bd4edf4927c656fe6fc90b81e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_007_offen1.png.import b/library/thumbs/container_007_offen1.png.import new file mode 100644 index 0000000..a01bf66 --- /dev/null +++ b/library/thumbs/container_007_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://denqy1bllro0l" +path="res://.godot/imported/container_007_offen1.png-e038946d3d569077182fe8624d8d0d02.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_007_offen1.png" +dest_files=["res://.godot/imported/container_007_offen1.png-e038946d3d569077182fe8624d8d0d02.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_007_offen2.png.import b/library/thumbs/container_007_offen2.png.import new file mode 100644 index 0000000..a814fd1 --- /dev/null +++ b/library/thumbs/container_007_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqkr34vaxvy5c" +path="res://.godot/imported/container_007_offen2.png-ded39ed55a00a5b1688c26cad378b653.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_007_offen2.png" +dest_files=["res://.godot/imported/container_007_offen2.png-ded39ed55a00a5b1688c26cad378b653.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_007_ohne.png.import b/library/thumbs/container_007_ohne.png.import new file mode 100644 index 0000000..2c69561 --- /dev/null +++ b/library/thumbs/container_007_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqm02u41bb737" +path="res://.godot/imported/container_007_ohne.png-bfcd83f47c7ec33ca986f3a5ca9daae9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_007_ohne.png" +dest_files=["res://.godot/imported/container_007_ohne.png-bfcd83f47c7ec33ca986f3a5ca9daae9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_008_offen1.png.import b/library/thumbs/container_008_offen1.png.import new file mode 100644 index 0000000..fde932c --- /dev/null +++ b/library/thumbs/container_008_offen1.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cbvq06me7e21h" +path="res://.godot/imported/container_008_offen1.png-3b36a26198ee2840738646efa562a9d7.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_008_offen1.png" +dest_files=["res://.godot/imported/container_008_offen1.png-3b36a26198ee2840738646efa562a9d7.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_008_offen2.png.import b/library/thumbs/container_008_offen2.png.import new file mode 100644 index 0000000..f31c954 --- /dev/null +++ b/library/thumbs/container_008_offen2.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://i6qdinbukrrw" +path="res://.godot/imported/container_008_offen2.png-acbf05005956828cdbebad15f60ba922.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_008_offen2.png" +dest_files=["res://.godot/imported/container_008_offen2.png-acbf05005956828cdbebad15f60ba922.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/library/thumbs/container_008_ohne.png.import b/library/thumbs/container_008_ohne.png.import new file mode 100644 index 0000000..65c45a8 --- /dev/null +++ b/library/thumbs/container_008_ohne.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://yrbfa8pcdvlh" +path="res://.godot/imported/container_008_ohne.png-4b391d87bf6eb80cdc2265187e867d46.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/container_008_ohne.png" +dest_files=["res://.godot/imported/container_008_ohne.png-4b391d87bf6eb80cdc2265187e867d46.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/src/server/admin_client.gd b/src/server/admin_client.gd new file mode 100644 index 0000000..ccc41b2 --- /dev/null +++ b/src/server/admin_client.gd @@ -0,0 +1,112 @@ +class_name AdminClient +extends Node +## Client der Match-Server-Admin-API v1 (Doku: docs/admin-api.md im +## scopedvr-match-server-Repo). Knoten in den Baum hängen, dann +## `var res = await client.call_api("GET", "/status")`. +## +## Antwort: {ok: bool (HTTP 2xx), status: int, body: Dictionary, transport_error: String} +## transport_error ist deutsch und leer, wenn der Server geantwortet hat. +## Das Token geht NUR als Header raus und wird nie geloggt oder zurückgegeben. + +const TIMEOUT_S := 30.0 +const MAX_RESPONSE_BYTES := 8 * 1024 * 1024 + +var host: String = "" +var port: int = 24580 +var tls: bool = false +var token: String = "" + + +## Server-Eintrag aus dem ServerStore übernehmen. +func use(entry: Dictionary) -> void: + host = str(entry.get("host", "")) + port = int(entry.get("port", 24580)) + tls = bool(entry.get("tls", false)) + token = str(entry.get("token", "")) + + +static func base_url(p_host: String, p_port: int, p_tls: bool) -> String: + var h := p_host + if h.contains(":") and not h.begins_with("["): + h = "[%s]" % h # IPv6 + return "%s://%s:%d/api/v1" % ["https" if p_tls else "http", h, p_port] + + +## Prüft die Eingabe für „Server hinzufügen“. Leerer Text = ok. +static func check_address(p_host: String, p_port: int) -> String: + var h := p_host.strip_edges() + if h.is_empty(): + return "Bitte eine Adresse eingeben (IP oder Name)." + if h.contains("/") or h.contains(" ") or h.contains("://"): + return "Nur die Adresse eintragen, ohne http:// und ohne Schrägstriche (z. B. 192.168.1.20)." + if p_port < 1 or p_port > 65535: + return "Der Port muss zwischen 1 und 65535 liegen." + return "" + + +func call_api(method: String, path: String, body: Variant = null, query: Dictionary = {}, raw: PackedByteArray = PackedByteArray()) -> Dictionary: + var err := check_address(host, port) + if err != "": + return _transport_fail(err) + var url := base_url(host, port, tls) + path + if not query.is_empty(): + var parts: PackedStringArray = [] + for k in query.keys(): + parts.append("%s=%s" % [str(k).uri_encode(), str(query[k]).uri_encode()]) + url += "?" + "&".join(parts) + var headers: PackedStringArray = ["Accept: application/json"] + if token != "": + headers.append("Authorization: Bearer " + token) + var payload := raw + if body != null: + payload = JSON.stringify(body).to_utf8_buffer() + headers.append("Content-Type: application/json") + elif not raw.is_empty(): + headers.append("Content-Type: application/octet-stream") + var http := HTTPRequest.new() + http.timeout = TIMEOUT_S + http.body_size_limit = MAX_RESPONSE_BYTES + http.accept_gzip = false + add_child(http) + var m := _method(method) + var rerr := http.request_raw(url, headers, m, payload) + if rerr != OK: + http.queue_free() + return _transport_fail("Anfrage konnte nicht gestartet werden (Fehler %d)." % rerr) + var done: Array = await http.request_completed + http.queue_free() + var result: int = done[0] + if result != HTTPRequest.RESULT_SUCCESS: + return _transport_fail(_result_text(result)) + var code: int = done[1] + var parsed: Variant = JSON.parse_string((done[3] as PackedByteArray).get_string_from_utf8()) + var out: Dictionary = parsed if parsed is Dictionary else {} + return {"ok": code >= 200 and code < 300, "status": code, "body": out, "transport_error": ""} + + +static func _method(m: String) -> int: + match m.to_upper(): + "POST": return HTTPClient.METHOD_POST + "PUT": return HTTPClient.METHOD_PUT + "PATCH": return HTTPClient.METHOD_PATCH + "DELETE": return HTTPClient.METHOD_DELETE + return HTTPClient.METHOD_GET + + +static func _transport_fail(msg: String) -> Dictionary: + return {"ok": false, "status": 0, "body": {}, "transport_error": msg} + + +static func _result_text(result: int) -> String: + match result: + HTTPRequest.RESULT_CANT_CONNECT, HTTPRequest.RESULT_CONNECTION_ERROR: + return "Keine Verbindung zum Server. Läuft er, ist die Admin-API eingeschaltet, und stimmen Adresse und Port?" + HTTPRequest.RESULT_CANT_RESOLVE: + return "Die Adresse wurde nicht gefunden. Tippfehler?" + HTTPRequest.RESULT_TIMEOUT: + return "Der Server antwortet nicht (Zeitüberschreitung)." + HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR: + return "Sichere Verbindung (https) fehlgeschlagen. Gibt der Server wirklich https her?" + HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED: + return "Die Antwort des Servers ist zu groß." + return "Verbindungsfehler (Code %d)." % result diff --git a/src/server/admin_client.gd.uid b/src/server/admin_client.gd.uid new file mode 100644 index 0000000..5985b57 --- /dev/null +++ b/src/server/admin_client.gd.uid @@ -0,0 +1 @@ +uid://dd3mjb50m63hm diff --git a/src/server/admin_texte.gd b/src/server/admin_texte.gd new file mode 100644 index 0000000..6f34e1f --- /dev/null +++ b/src/server/admin_texte.gd @@ -0,0 +1,97 @@ +class_name AdminTexte +extends RefCounted +## Verständliche deutsche Texte zu Admin-API-Antworten (kein Fachchinesisch, +## kein rohes JSON) – inklusive der Fehlerliste (422) beim Map-Upload. + +const TUNNEL_HINWEIS := "Tipp: Die Admin-API hört standardmäßig nur auf dem Server selbst (localhost). Von außen erreichst du sie sicher über einen SSH-Tunnel (z. B. ssh -L 24580:127.0.0.1:24580 benutzer@server, dann hier Adresse 127.0.0.1) oder über einen Reverse-Proxy mit https. Die Verbindung selbst ist sonst unverschlüsselt." + +## Erklärungen für häufige Prüfregeln der Map (Server und Editor nutzen dieselben IDs). +const REGEL_HILFE := { + "V1": "Die Datei ist zu groß.", + "V2": "Die Datei ist keine gültige Map-Datei.", + "V3": "Format-Version passt nicht – Editor und Server müssen zusammenpassen.", + "V4": "Ein Pflichtbereich der Map fehlt.", + "V6": "Das Terrain ist zu groß oder zu detailreich.", + "V8": "Zu viele Objekte oder zu viele Polygone – Objekte entfernen.", + "V11": "Pro Team werden mindestens 4 Spawn-Punkte gebraucht (Spawn-Zonen malen).", + "V14": "Die Map ragt über die erlaubte Größe hinaus.", +} + + +## Antwort → Text für den Nutzer ("" bei Erfolg ohne Besonderheiten). +static func erklaere(res: Dictionary) -> String: + if str(res.get("transport_error", "")) != "": + return str(res["transport_error"]) + "\n\n" + TUNNEL_HINWEIS + var status := int(res.get("status", 0)) + var body: Dictionary = res.get("body", {}) + var server_text := str(body.get("error", "")) + match status: + 401: + return "Das Token stimmt nicht (oder fehlt). Es steht beim Server in der Admin-Datei (state_file) – bitte neu eintragen." + 429: + return "Zu viele falsche Token-Versuche. Der Server sperrt dich kurz – bitte in ein paar Minuten erneut versuchen." + 404: + if str(body.get("code", "")) == "not_found": + return "Diese Funktion kennt der Server nicht. Ist er auf dem neuesten Stand?" + 413: + return "Die Datei ist größer als der Server erlaubt. " + server_text + 422: + return erklaere_map_fehler(body) + if server_text != "": + return server_text + if status >= 200 and status < 300: + return "" + return "Unerwartete Antwort vom Server (Status %d)." % status + + +## Fehlerliste einer abgelehnten Map als Aufzählung mit Hilfetexten. +static func erklaere_map_fehler(body: Dictionary) -> String: + var errs: Array = body.get("errors", []) + var out := "Der Server hat die Map abgelehnt" + out += " (%d Problem%s):\n" % [errs.size(), "" if errs.size() == 1 else "e"] if not errs.is_empty() else ".\n" + var hints := {} + for e in errs: + var t := str(e) + out += "• " + t + "\n" + var rule := t.split(":")[0].strip_edges() + for k in REGEL_HILFE.keys(): + if rule == k or rule.begins_with(k) and not rule.substr(k.length(), 1).is_valid_int(): + hints[k] = REGEL_HILFE[k] + if not hints.is_empty(): + out += "\nSo behebst du es:\n" + for k in hints.keys(): + out += "• %s: %s\n" % [k, hints[k]] + return out.strip_edges() + + +## Dateiname für den Upload: a–z, 0–9, _ und -, höchstens 40 Zeichen (wie der Server). +static func slugify(text: String) -> String: + var t := text.strip_edges().to_lower() + for pair in [["ä", "ae"], ["ö", "oe"], ["ü", "ue"], ["ß", "ss"]]: + t = t.replace(pair[0], pair[1]) + var out := "" + for c in t: + out += c if ((c >= "a" and c <= "z") or (c >= "0" and c <= "9") or c == "-") else "_" + while out.contains("__"): + out = out.replace("__", "_") + out = out.lstrip("_-").rstrip("_") + return out.substr(0, 40) + + +## Wirkung einer Einstellung in Klartext. +static func wirkung(effect: String) -> String: + match effect: + "live": return "sofort" + "next_map": return "ab der nächsten Map" + "restart": return "nach Server-Neustart" + return effect + + +static func sperrdauer_text(minutes: int) -> String: + if minutes <= 0: + return "dauerhaft" + if minutes < 60: + return "%d Minuten" % minutes + if minutes < 1440: + return "%d Stunden" % (minutes / 60) + return "%d Tage" % (minutes / 1440) diff --git a/src/server/admin_texte.gd.uid b/src/server/admin_texte.gd.uid new file mode 100644 index 0000000..88dcc8e --- /dev/null +++ b/src/server/admin_texte.gd.uid @@ -0,0 +1 @@ +uid://baspf3qu07cwi diff --git a/src/server/map_push.gd b/src/server/map_push.gd new file mode 100644 index 0000000..28c0260 --- /dev/null +++ b/src/server/map_push.gd @@ -0,0 +1,40 @@ +class_name MapPush +extends RefCounted +## „Map auf Server pushen“: aktuelle Map lokal prüfen und signieren +## (SvmapIO.to_signed_text), dann per Admin-API hochladen. UI-frei und testbar. + +## opts: {name: String ("" = aus Map-Name), overwrite: bool, add_to_rotation: bool} +## Rückgabe: {ok, stage ("lokal"|"upload"), text (verständliche Meldung), +## id (Server-Map-ID), exists (bool: 409 – Nachfrage nötig), status} +static func push(client: AdminClient, doc: Dictionary, opts: Dictionary = {}) -> Dictionary: + var signed := SvmapIO.to_signed_text(doc) + if not bool(signed["ok"]): + var lines := "Die Map ist noch nicht gültig und wurde nicht hochgeladen:\n" + for e in signed["errors"]: + lines += "• %s\n" % str(e) + return {"ok": false, "stage": "lokal", "text": lines.strip_edges(), "id": "", "exists": false, "status": 0} + var info: Dictionary = doc.get("info", {}) + var slug := str(opts.get("name", "")).strip_edges() + if slug == "": + slug = AdminTexte.slugify(str(info.get("name", ""))) + if slug == "": + return {"ok": false, "stage": "lokal", "text": "Die Map braucht einen Namen (Tab „Map“), bevor sie hochgeladen werden kann.", "id": "", "exists": false, "status": 0} + var bytes: PackedByteArray = str(signed["text"]).to_utf8_buffer() + var query := {"name": slug} + if bool(opts.get("overwrite", false)): + query["overwrite"] = "1" + if bool(opts.get("add_to_rotation", false)): + query["add_to_rotation"] = "1" + var res: Dictionary = await client.call_api("POST", "/maps", null, query, bytes) + var body: Dictionary = res.get("body", {}) + if bool(res.get("ok", false)): + var msg := "Hochgeladen als „%s“ (%s, %d Objekte)." % [str(body.get("name", slug)), _groesse(bytes.size()), int(body.get("objects", 0))] + if bool(body.get("replaced", false)): + msg += " Die vorherige Version wurde ersetzt." + return {"ok": true, "stage": "upload", "text": msg, "id": str(body.get("id", "")), "exists": false, "status": 200} + var exists := int(res.get("status", 0)) == 409 and str(body.get("code", "")) == "exists" + return {"ok": false, "stage": "upload", "text": AdminTexte.erklaere(res), "id": "", "exists": exists, "status": int(res.get("status", 0))} + + +static func _groesse(n: int) -> String: + return "%.1f MB" % (n / 1048576.0) if n >= 1048576 else "%d KB" % maxi(1, n / 1024) diff --git a/src/server/map_push.gd.uid b/src/server/map_push.gd.uid new file mode 100644 index 0000000..f15652d --- /dev/null +++ b/src/server/map_push.gd.uid @@ -0,0 +1 @@ +uid://cmuts8c8t2d5i diff --git a/src/server/server_store.gd b/src/server/server_store.gd new file mode 100644 index 0000000..06eb350 --- /dev/null +++ b/src/server/server_store.gd @@ -0,0 +1,114 @@ +class_name ServerStore +extends RefCounted +## Gespeicherte Admin-Server des Editors (Name, Adresse, Port, https, Token). +## +## Das Token liegt NICHT im Klartext auf der Platte: die Datei ist mit einem +## aus Rechner-ID + Nutzerordner abgeleiteten Schlüssel verschlüsselt. Das hält +## Zufallsleser, Cloud-Sync und Screenshots von Ordnern raus – gegen Schadsoftware +## mit Zugriff auf deinen Benutzer schützt es nicht (dafür gibt es keine +## plattformübergreifende Lösung ohne Passwort-Manager). Tokens werden nie geloggt. + +const DEFAULT_PATH := "user://admin_server.dat" +const DEFAULT_PORT := 24580 +const MAX_SERVERS := 50 + +var path: String = DEFAULT_PATH +var servers: Array = [] # [{id, name, host, port, tls, token}] +var last_error: String = "" + + +func _init(p: String = DEFAULT_PATH) -> void: + path = p + + +func _key() -> String: + return ("svr-editor-admin|%s|%s" % [OS.get_unique_id(), OS.get_user_data_dir()]).sha256_text() + + +func load_store() -> void: + servers.clear() + last_error = "" + if not FileAccess.file_exists(path): + return + var fa := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, _key()) + if fa == null: + last_error = "Gespeicherte Server konnten nicht gelesen werden (andere Installation oder beschädigte Datei)." + return + var parsed: Variant = JSON.parse_string(fa.get_as_text()) + if parsed is Array: + for e in parsed: + if e is Dictionary and str(e.get("host", "")) != "": + servers.append({ + "id": str(e.get("id", _new_id())), "name": str(e.get("name", e["host"])), + "host": str(e["host"]), "port": int(e.get("port", DEFAULT_PORT)), + "tls": bool(e.get("tls", false)), "token": str(e.get("token", ""))}) + + +func save_store() -> bool: + var fa := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, _key()) + if fa == null: + last_error = "Server-Liste konnte nicht gespeichert werden (Fehler %d)." % FileAccess.get_open_error() + return false + fa.store_string(JSON.stringify(servers)) + return true + + +## Neuer oder aktualisierter Eintrag. Rückgabe: id ("" = Eingabe ungültig → last_error). +func upsert(entry: Dictionary) -> String: + var err := AdminClient.check_address(str(entry.get("host", "")), int(entry.get("port", 0))) + if err != "": + last_error = err + return "" + var e := { + "id": str(entry.get("id", "")), "name": str(entry.get("name", "")).strip_edges(), + "host": str(entry["host"]).strip_edges(), "port": int(entry["port"]), + "tls": bool(entry.get("tls", false)), "token": str(entry.get("token", "")).strip_edges()} + if e["name"] == "": + e["name"] = "%s:%d" % [e["host"], e["port"]] + if e["id"] == "": + if servers.size() >= MAX_SERVERS: + last_error = "Maximal %d gespeicherte Server." % MAX_SERVERS + return "" + e["id"] = _new_id() + servers.append(e) + else: + var i := index_of(e["id"]) + if i < 0: + servers.append(e) + else: + servers[i] = e + save_store() + return e["id"] + + +func remove(id: String) -> bool: + var i := index_of(id) + if i < 0: + return false + servers.remove_at(i) + save_store() + return true + + +func index_of(id: String) -> int: + for i in servers.size(): + if str(servers[i]["id"]) == id: + return i + return -1 + + +func get_entry(id: String) -> Dictionary: + var i := index_of(id) + return servers[i].duplicate() if i >= 0 else {} + + +## Für Anzeige/Logs: nie das Token. +func public_view(id: String) -> Dictionary: + var e := get_entry(id) + e.erase("token") + e["has_token"] = str(get_entry(id).get("token", "")) != "" + return e + + +static func _new_id() -> String: + return "s%d_%d" % [int(Time.get_unix_time_from_system()), randi() % 100000] diff --git a/src/server/server_store.gd.uid b/src/server/server_store.gd.uid new file mode 100644 index 0000000..2a1a4cc --- /dev/null +++ b/src/server/server_store.gd.uid @@ -0,0 +1 @@ +uid://dkwfi2v8nu03w diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index a1a7e65..e55894e 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -139,23 +139,31 @@ static func load_from_file(path: String) -> Dictionary: ## der v2-Hash bleibt unter allen Godot-Versionen stabil (FirstFight-Fall: ## ein 15-stelliger Python-Float-Text war unter Legacy-Hash nicht verifizierbar). static func save_to_file(path: String, data: Dictionary) -> Dictionary: - var errors := _validate_core(data) - if not errors.is_empty(): - return {"ok": false, "errors": errors} - # Quantisieren auf einer TIEFEN Kopie (das Eingabe-doc bleibt unangetastet), - # dann signieren und schreiben. - var canonical: Dictionary = _canonicalize(data) - _quantisieren_deep(canonical) - sign_map(canonical) + var signed := to_signed_text(data) + if not bool(signed["ok"]): + return {"ok": false, "errors": signed["errors"]} var fa := FileAccess.open(path, FileAccess.WRITE) if fa == null: var out := {"ok": false, "errors": []} _add(out, "Datei nicht schreibbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) return out - fa.store_string(JSON.stringify(canonical, JSON_INDENT)) + fa.store_string(str(signed["text"])) return {"ok": true, "errors": PackedStringArray()} +## Wie save_to_file, aber ohne Datei: liefert den fertigen, kanonisierten und +## signierten .svmap-Text (für den Server-Upload, Etappe 3). Das Eingabe-doc +## bleibt unangetastet. {ok, errors, text} +static func to_signed_text(data: Dictionary) -> Dictionary: + var errors := _validate_core(data) + if not errors.is_empty(): + return {"ok": false, "errors": errors, "text": ""} + var canonical: Dictionary = _canonicalize(data) + _quantisieren_deep(canonical) + sign_map(canonical) + return {"ok": true, "errors": PackedStringArray(), "text": JSON.stringify(canonical, JSON_INDENT)} + + ## Validierung OHNE Prüfsumme (V3–V12 + V14); validate_map = Kern + Prüfsumme. ## manifest = Bibliotheks-Registry (leer → Default aus res://library/objects.json). static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: diff --git a/src/ui/panels/registry.gd b/src/ui/panels/registry.gd index bb03579..c34bc8c 100644 --- a/src/ui/panels/registry.gd +++ b/src/ui/panels/registry.gd @@ -37,6 +37,9 @@ const PANELS: Array = [ "skript": "res://src/ui/panels/malen_panel.gd"}, {"id": "bauen", "titel": "Bauen", "gruppe": "rechts", "order": 47, "skript": "res://src/ui/panels/bauen_panel.gd"}, + # Etappe 3: Server-Verwaltung (Admin-API des Match-Servers). + {"id": "server", "titel": "Server", "gruppe": "rechts", "order": 50, + "skript": "res://src/ui/panels/server_panel.gd"}, # --- map: Abschnitte UNTER den Map-Eigenschaften (Tab „Map“) --- # 25.10. (Test-Befund): Prüfen/Test-Start/Symmetrie sind keine eigenen Tabs # mehr. Spawn-Zonen, Test-Start-Info und Symmetrie wohnen im Map-Tab. diff --git a/src/ui/panels/server_panel.gd b/src/ui/panels/server_panel.gd new file mode 100644 index 0000000..8275141 --- /dev/null +++ b/src/ui/panels/server_panel.gd @@ -0,0 +1,642 @@ +extends "res://src/ui/panels/editor_panel.gd" +## Tab „Server“ (Etappe 3 „Server-Verwaltung“): Client der Match-Server-Admin-API. +## Mehrere Server speichern, verbinden, Status, Einstellungen, Spieler, Maps, +## Rotation, Logs und „Aktuelle Map auf den Server pushen“ – alles per Klick. +## Logik: src/server/ (AdminClient, ServerStore, AdminTexte, MapPush). + +var store: ServerStore +var store_path := ServerStore.DEFAULT_PATH # Tests lenken um +var client: AdminClient +var current_id := "" +var last_notice := "" # letzte Meldung (Tests) +var connected := false + +var _sel: OptionButton +var _conn_label: Label +var _notice: Label +var _status_label: Label +var _live_box: VBoxContainer # alles, was einen verbundenen Server braucht +var _push_name: LineEdit +var _push_rot: CheckBox +var _push_result: Label +var _settings_box: VBoxContainer +var _settings_editors: Dictionary = {} # key -> Control +var _settings_loaded: Dictionary = {} +var _settings_spec: Dictionary = {} +var _settings_result: Label +var _players_box: VBoxContainer +var _bans_box: VBoxContainer +var _ban_minutes: OptionButton +var _maps_box: VBoxContainer +var _rotation_checks: Dictionary = {} # map id -> CheckBox +var _log_filter: LineEdit +var _log_text: TextEdit +var _confirm: ConfirmationDialog +var _confirm_action: Callable +var _edit_dialog: ConfirmationDialog +var _edit_fields: Dictionary = {} +var _edit_id := "" + +const BAN_DAUER := [[10, "10 Minuten"], [60, "1 Stunde"], [1440, "1 Tag"], [10080, "1 Woche"], [0, "Dauerhaft"]] + + +static func spec() -> Dictionary: + return {"titel": "Server", "gruppe": "rechts", "order": 50} + + +func panel_title() -> String: + return "Server" + + +func build() -> void: + store = ServerStore.new(store_path) + store.load_store() + client = AdminClient.new() + client.name = "AdminClient" + add_child(client) + + add_child(_header_label("Server verwalten")) + add_child(_muted_label("Verbinde den Editor mit deinem Match-Server (Admin-API), um Maps hochzuladen und den Server zu steuern.")) + var hint := _muted_label(AdminTexte.TUNNEL_HINWEIS) + hint.visible = false + var hint_btn := Button.new() + hint_btn.text = "❓ Wie erreiche ich meinen Server sicher?" + hint_btn.flat = true + hint_btn.pressed.connect(func() -> void: hint.visible = not hint.visible) + add_child(hint_btn) + add_child(hint) + + _sel = OptionButton.new() + _sel.custom_minimum_size = UIScale.v2(0, 36) + _sel.item_selected.connect(func(_i: int) -> void: _on_selected()) + add_child(_sel) + var row := HBoxContainer.new() + add_child(row) + row.add_child(_btn("➕ Neu", _on_add)) + row.add_child(_btn("✏ Bearbeiten", _on_edit)) + row.add_child(_btn("🗑 Entfernen", _on_remove)) + var conn_row := HBoxContainer.new() + add_child(conn_row) + conn_row.add_child(_btn("🔌 Verbinden", func() -> void: connect_selected())) + _conn_label = Label.new() + _conn_label.text = "Nicht verbunden" + _conn_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _conn_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + conn_row.add_child(_conn_label) + _notice = _muted_label("") + add_child(_notice) + + _live_box = VBoxContainer.new() + _live_box.add_theme_constant_override("separation", UIScale.px(8)) + _live_box.visible = false + add_child(_live_box) + _build_live() + _build_dialogs() + _refresh_selector() + wrap_in_scroll() + + +func _btn(text: String, cb: Callable) -> Button: + var b := Button.new() + b.text = text + b.custom_minimum_size = UIScale.v2(0, 36) + b.pressed.connect(cb) + return b + + +func _section(title: String) -> void: + _live_box.add_child(_separator()) + _live_box.add_child(_header_label(title)) + + +func _build_live() -> void: + _section("Übersicht") + _status_label = Label.new() + _status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _live_box.add_child(_status_label) + _live_box.add_child(_btn("🔄 Alles aktualisieren", func() -> void: refresh_all())) + + _section("Map auf Server pushen") + _live_box.add_child(_muted_label("Prüft die Map im Editor und lädt sie hoch. Der Server prüft sie noch einmal.")) + _push_name = LineEdit.new() + _push_name.placeholder_text = "Dateiname (leer = Map-Name)" + _live_box.add_child(_push_name) + _push_rot = CheckBox.new() + _push_rot.text = "In die Rotation aufnehmen" + _live_box.add_child(_push_rot) + var pb := _btn("⬆ Aktuelle Map hochladen", func() -> void: push_current_map()) + pb.custom_minimum_size = UIScale.v2(0, 44) + _live_box.add_child(pb) + _push_result = Label.new() + _push_result.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _live_box.add_child(_push_result) + + _section("Einstellungen") + _settings_box = VBoxContainer.new() + _live_box.add_child(_settings_box) + var srow := HBoxContainer.new() + _live_box.add_child(srow) + srow.add_child(_btn("💾 Speichern", func() -> void: save_settings())) + srow.add_child(_btn("↩ Zurücksetzen", func() -> void: _fill_settings_editors())) + _settings_result = _muted_label("") + _live_box.add_child(_settings_result) + + _section("Spieler") + _players_box = VBoxContainer.new() + _live_box.add_child(_players_box) + var brow := HBoxContainer.new() + _live_box.add_child(brow) + brow.add_child(_muted_label("Sperrdauer:")) + _ban_minutes = OptionButton.new() + for d in BAN_DAUER: + _ban_minutes.add_item(d[1]) + _ban_minutes.select(1) + brow.add_child(_ban_minutes) + _live_box.add_child(_muted_label("Gesperrt:")) + _bans_box = VBoxContainer.new() + _live_box.add_child(_bans_box) + + _section("Match & Maps") + _live_box.add_child(_btn("🔁 Match neu starten", func() -> void: _ask("Match auf dem Server neu starten? Alle Spieler beginnen die Runde von vorn.", _do_restart))) + _maps_box = VBoxContainer.new() + _live_box.add_child(_maps_box) + _live_box.add_child(_btn("💾 Rotation speichern", func() -> void: save_rotation())) + + _section("Server-Log") + _log_filter = LineEdit.new() + _log_filter.placeholder_text = "Filter (z. B. Admin, Join, FEHLER)" + _live_box.add_child(_log_filter) + _live_box.add_child(_btn("📜 Log laden", func() -> void: load_logs())) + _log_text = TextEdit.new() + _log_text.editable = false + _log_text.custom_minimum_size = UIScale.v2(0, 220) + _log_text.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + _live_box.add_child(_log_text) + + +func _build_dialogs() -> void: + _confirm = ConfirmationDialog.new() + _confirm.ok_button_text = "Ja" + _confirm.cancel_button_text = "Abbrechen" + _confirm.confirmed.connect(func() -> void: + if _confirm_action.is_valid(): + _confirm_action.call()) + add_child(_confirm) + + _edit_dialog = ConfirmationDialog.new() + _edit_dialog.title = "Server" + _edit_dialog.ok_button_text = "Speichern" + _edit_dialog.cancel_button_text = "Abbrechen" + var box := VBoxContainer.new() + _edit_dialog.add_child(box) + for f in [["name", "Name (frei wählbar)", "Mein Server"], ["host", "Adresse (IP oder Name)", "127.0.0.1"], + ["port", "Port der Admin-API", str(ServerStore.DEFAULT_PORT)], ["token", "Admin-Token", ""]]: + box.add_child(_field_label(f[1])) + var le := LineEdit.new() + le.placeholder_text = f[2] + le.custom_minimum_size = UIScale.v2(320, 32) + if f[0] == "token": + le.secret = true + box.add_child(le) + _edit_fields[f[0]] = le + var tls := CheckBox.new() + tls.text = "Sichere Verbindung (https) – nur mit Reverse-Proxy" + box.add_child(tls) + _edit_fields["tls"] = tls + var show := CheckBox.new() + show.text = "Token anzeigen" + show.toggled.connect(func(on: bool) -> void: (_edit_fields["token"] as LineEdit).secret = not on) + box.add_child(show) + var err := _muted_label("") + box.add_child(err) + _edit_fields["_err"] = err + box.add_child(_btn("🔌 Verbindung testen", func() -> void: _test_dialog_entry())) + _edit_dialog.confirmed.connect(_on_edit_confirmed) + add_child(_edit_dialog) + + +# ------------------------------------------------------------------ Liste -- + +func _refresh_selector(select_id: String = "") -> void: + _sel.clear() + for e in store.servers: + _sel.add_item("%s (%s:%d)" % [e["name"], e["host"], e["port"]]) + _sel.set_item_metadata(_sel.item_count - 1, e["id"]) + if _sel.item_count == 0: + _sel.add_item("– noch kein Server gespeichert –") + _sel.disabled = true + current_id = "" + return + _sel.disabled = false + var idx := 0 + for i in _sel.item_count: + if str(_sel.get_item_metadata(i)) == (select_id if select_id != "" else current_id): + idx = i + _sel.select(idx) + _on_selected() + + +func _on_selected() -> void: + if _sel.item_count == 0 or _sel.disabled: + return + var id := str(_sel.get_item_metadata(_sel.selected)) + if id != current_id: + _set_connected(false, "Nicht verbunden") + current_id = id + + +func _on_add() -> void: + _open_edit({}) + + +func _on_edit() -> void: + if current_id != "": + _open_edit(store.get_entry(current_id)) + + +func _open_edit(e: Dictionary) -> void: + _edit_id = str(e.get("id", "")) + _edit_dialog.title = "Server bearbeiten" if _edit_id != "" else "Server hinzufügen" + (_edit_fields["name"] as LineEdit).text = str(e.get("name", "")) + (_edit_fields["host"] as LineEdit).text = str(e.get("host", "")) + (_edit_fields["port"] as LineEdit).text = str(e.get("port", ServerStore.DEFAULT_PORT)) + (_edit_fields["token"] as LineEdit).text = str(e.get("token", "")) + (_edit_fields["tls"] as CheckBox).button_pressed = bool(e.get("tls", false)) + (_edit_fields["_err"] as Label).text = "" + _edit_dialog.popup_centered(UIScale.v2i(380, 420)) + + +func _entry_from_dialog() -> Dictionary: + return { + "id": _edit_id, "name": (_edit_fields["name"] as LineEdit).text, + "host": (_edit_fields["host"] as LineEdit).text.strip_edges(), + "port": int((_edit_fields["port"] as LineEdit).text), + "token": (_edit_fields["token"] as LineEdit).text, + "tls": (_edit_fields["tls"] as CheckBox).button_pressed} + + +func _on_edit_confirmed() -> void: + var id := store.upsert(_entry_from_dialog()) + if id == "": + _notify(store.last_error) + return + _refresh_selector(id) + + +func _test_dialog_entry() -> void: + var e := _entry_from_dialog() + var err := AdminClient.check_address(str(e["host"]), int(e["port"])) + var lbl: Label = _edit_fields["_err"] + if err != "": + lbl.text = err + return + lbl.text = "Teste …" + var probe := AdminClient.new() + add_child(probe) + probe.use(e) + var res: Dictionary = await probe.call_api("GET", "/ping") + if bool(res["ok"]): + var auth: Dictionary = await probe.call_api("GET", "/status") + lbl.text = "✅ Server gefunden. " + ("Token passt." if bool(auth["ok"]) else AdminTexte.erklaere(auth)) + else: + lbl.text = "❌ " + AdminTexte.erklaere(res) + probe.queue_free() + + +func _on_remove() -> void: + if current_id == "": + return + var e := store.public_view(current_id) + _ask("„%s“ aus der Liste entfernen? (Der Server selbst bleibt unverändert.)" % str(e["name"]), func() -> void: + store.remove(current_id) + current_id = "" + _set_connected(false, "Nicht verbunden") + _refresh_selector()) + + +# ---------------------------------------------------------------- Verbinden -- + +func connect_selected() -> bool: + if current_id == "": + _notify("Bitte erst einen Server hinzufügen (➕ Neu).") + return false + client.use(store.get_entry(current_id)) + _conn_label.text = "Verbinde …" + var ping: Dictionary = await client.call_api("GET", "/ping") + if not bool(ping["ok"]): + _set_connected(false, "❌ " + AdminTexte.erklaere(ping)) + return false + var st: Dictionary = await client.call_api("GET", "/status") + if not bool(st["ok"]): + _set_connected(false, "❌ " + AdminTexte.erklaere(st)) + return false + _set_connected(true, "✅ Verbunden") + await refresh_all() + return true + + +func _set_connected(on: bool, text: String) -> void: + connected = on + _conn_label.text = text + _live_box.visible = on + + +## Ruft die API auf und meldet Fehler verständlich. null = Fehler (schon gemeldet). +func _api(method: String, path: String, body: Variant = null, query: Dictionary = {}) -> Variant: + var res: Dictionary = await client.call_api(method, path, body, query) + if bool(res["ok"]): + return res["body"] + var text := AdminTexte.erklaere(res) + if int(res.get("status", 0)) in [0, 401, 429]: + _set_connected(false, "❌ " + text.split("\n")[0]) + _notify(text) + return null + + +func _notify(text: String) -> void: + last_notice = text + _notice.text = text + + +func refresh_all() -> void: + await refresh_status() + await refresh_settings() + await refresh_players() + await refresh_maps() + + +# ------------------------------------------------------------------ Status -- + +func refresh_status() -> void: + var s: Variant = await _api("GET", "/status") + if s == null: + return + var p: Dictionary = s["players"] + var h := int(s["uptime_s"]) / 3600 + var m := (int(s["uptime_s"]) % 3600) / 60 + _status_label.text = "%s\nMap: %s · Modus: %s · Phase: %s\nSpieler: %d von %d (+ %d Bots) · Restzeit %d s\nLäuft seit %d h %d min · %s · Passwort: %s · Master: %s" % [ + s["server_name"], s["map"]["name"], str(s["mode"]).to_upper(), s["phase"], + p["humans"], p["max"], p["bots"], int(s["time_remaining_s"]), h, m, + "öffentlich" if s["public"] else "privat", "ja" if s["password_set"] else "nein", s["master"]] + + +# ------------------------------------------------------------ Einstellungen -- + +func refresh_settings() -> void: + var r: Variant = await _api("GET", "/settings") + if r == null: + return + _settings_spec = r["spec"] + _settings_loaded = r["settings"] + for c in _settings_box.get_children(): + c.queue_free() + _settings_editors.clear() + for key in _settings_spec.keys(): + var sp: Dictionary = _settings_spec[key] + var lbl := _field_label("%s (%s)" % [sp["label"], AdminTexte.wirkung(str(sp["effect"]))]) + _settings_box.add_child(lbl) + var ed: Control + match str(sp["type"]): + "bool": + ed = CheckBox.new() + "int", "float": + var sb := SpinBox.new() + sb.min_value = float(sp["min"]) + sb.max_value = float(sp["max"]) + sb.step = 1.0 if str(sp["type"]) == "int" else 0.5 + ed = sb + "enum": + var ob := OptionButton.new() + for v in sp["values"]: + ob.add_item(str(v)) + ed = ob + _: + var le := LineEdit.new() + le.max_length = int(sp.get("max_len", 64)) + if key == "password": + le.secret = true + le.placeholder_text = "(unverändert)" + ed = le + _settings_box.add_child(ed) + _settings_editors[key] = ed + _fill_settings_editors() + + +func _fill_settings_editors() -> void: + for key in _settings_editors.keys(): + var ed: Control = _settings_editors[key] + var sp: Dictionary = _settings_spec[key] + if key == "password": + (ed as LineEdit).text = "" + (ed as LineEdit).placeholder_text = "(gesetzt – leer lassen = unverändert)" if bool(_settings_loaded.get("password_set", false)) else "(kein Passwort)" + continue + var v: Variant = _settings_loaded.get(key) + match str(sp["type"]): + "bool": (ed as CheckBox).button_pressed = bool(v) + "int", "float": (ed as SpinBox).value = float(v) + "enum": (ed as OptionButton).select(maxi(0, (sp["values"] as Array).find(v))) + _: (ed as LineEdit).text = str(v) + _settings_result.text = "" + + +## Nur geänderte Werte (Passwort nur, wenn etwas eingetippt wurde). +func changed_settings() -> Dictionary: + var out := {} + for key in _settings_editors.keys(): + var ed: Control = _settings_editors[key] + var sp: Dictionary = _settings_spec[key] + var v: Variant + match str(sp["type"]): + "bool": v = (ed as CheckBox).button_pressed + "int": v = int((ed as SpinBox).value) + "float": v = (ed as SpinBox).value + "enum": v = (sp["values"] as Array)[(ed as OptionButton).selected] + _: v = (ed as LineEdit).text + if key == "password": + if str(v) != "": + out[key] = v + continue + if v != _settings_loaded.get(key): + out[key] = v + return out + + +func save_settings() -> void: + var ch := changed_settings() + if ch.is_empty(): + _settings_result.text = "Nichts geändert." + return + var r: Variant = await _api("PATCH", "/settings", ch) + if r == null: + return + var lines: PackedStringArray = [] + if not (r["applied_now"] as Array).is_empty(): + lines.append("✅ Sofort aktiv: " + ", ".join(_labels(r["applied_now"]))) + if not (r["applies_next_map"] as Array).is_empty(): + lines.append("⏭ Ab der nächsten Map: " + ", ".join(_labels(r["applies_next_map"]))) + if not (r["needs_restart"] as Array).is_empty(): + lines.append("🔁 Erst nach Server-Neustart: " + ", ".join(_labels(r["needs_restart"]))) + _settings_result.text = "\n".join(lines) + last_notice = _settings_result.text + _settings_loaded = r["settings"] + _fill_settings_editors() + _settings_result.text = "\n".join(lines) + + +func _labels(keys: Array) -> Array: + return keys.map(func(k: Variant) -> String: return str((_settings_spec.get(k, {}) as Dictionary).get("label", k))) + + +# ----------------------------------------------------------------- Spieler -- + +func refresh_players() -> void: + var r: Variant = await _api("GET", "/players") + var b: Variant = await _api("GET", "/bans") + if r == null or b == null: + return + for c in _players_box.get_children(): + c.queue_free() + if (r["players"] as Array).is_empty(): + _players_box.add_child(_muted_label("Gerade ist niemand auf dem Server.")) + for p in r["players"]: + var row := HBoxContainer.new() + var lbl := Label.new() + lbl.text = "%s (Team %s · %d/%d · %d ms)" % [p["name"], "Blau" if int(p["team"]) == 0 else "Rot", p["kills"], p["deaths"], p["ping_ms"]] + lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + lbl.clip_text = true + row.add_child(lbl) + var pid := int(p["peer_id"]) + var pname := str(p["name"]) + row.add_child(_btn("Kick", func() -> void: _ask("„%s“ vom Server werfen?" % pname, func() -> void: await kick(pid)))) + row.add_child(_btn("Sperren", func() -> void: _ask("„%s“ sperren (%s)?" % [pname, BAN_DAUER[_ban_minutes.selected][1]], func() -> void: await ban(pid)))) + _players_box.add_child(row) + for c in _bans_box.get_children(): + c.queue_free() + if (b["bans"] as Array).is_empty(): + _bans_box.add_child(_muted_label("Niemand gesperrt.")) + for ban_e in b["bans"]: + var row2 := HBoxContainer.new() + var l2 := Label.new() + var until := int(ban_e.get("until", 0)) + l2.text = "%s – %s" % [str(ban_e.get("name", "?")), "dauerhaft" if until == 0 else "bis " + Time.get_datetime_string_from_unix_time(until, true)] + l2.size_flags_horizontal = Control.SIZE_EXPAND_FILL + l2.clip_text = true + row2.add_child(l2) + var bid := str(ban_e["id"]) + row2.add_child(_btn("Aufheben", func() -> void: await unban(bid))) + _bans_box.add_child(row2) + + +func kick(peer_id: int) -> void: + if await _api("POST", "/players/%d/kick" % peer_id, {}) != null: + _notify("Spieler wurde vom Server geworfen.") + await refresh_players() + + +func ban(peer_id: int, minutes: int = -1) -> void: + if minutes < 0: + minutes = int(BAN_DAUER[_ban_minutes.selected][0]) + if await _api("POST", "/players/%d/ban" % peer_id, {"minutes": minutes}) != null: + _notify("Spieler gesperrt (%s)." % AdminTexte.sperrdauer_text(minutes)) + await refresh_players() + + +func unban(ban_id: String) -> void: + if await _api("DELETE", "/bans/" + ban_id) != null: + _notify("Sperre aufgehoben.") + await refresh_players() + + +# -------------------------------------------------------------- Match, Maps -- + +func _do_restart() -> void: + if await _api("POST", "/match/restart", {}) != null: + _notify("Match wird neu gestartet.") + await refresh_status() + + +func refresh_maps() -> void: + var r: Variant = await _api("GET", "/maps") + if r == null: + return + for c in _maps_box.get_children(): + c.queue_free() + _rotation_checks.clear() + for m in r["maps"]: + var row := HBoxContainer.new() + var cb := CheckBox.new() + cb.button_pressed = bool(m["in_rotation"]) + cb.tooltip_text = "In der Rotation" + row.add_child(cb) + _rotation_checks[str(m["id"])] = cb + var lbl := Label.new() + lbl.text = "%s%s%s" % [m.get("name", m["file"]), " ▶ läuft" if m["active"] else "", " (eingebaut)" if m["source"] == "builtin" else ""] + lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + lbl.clip_text = true + row.add_child(lbl) + var mid := str(m["id"]) + var mname := str(m.get("name", m["file"])) + row.add_child(_btn("Laden", func() -> void: _ask("„%s“ jetzt auf dem Server laden? Die laufende Runde endet." % mname, func() -> void: await load_map(mid)))) + if m["source"] == "upload": + var slug := mid.get_file().get_basename() + row.add_child(_btn("🗑", func() -> void: _ask("„%s“ vom Server LÖSCHEN? Das kann nicht rückgängig gemacht werden." % mname, func() -> void: await delete_map(slug)))) + _maps_box.add_child(row) + + +func load_map(map_id: String) -> void: + if await _api("POST", "/match/map", {"map": map_id}) != null: + _notify("Map wird geladen.") + await refresh_status() + + +func delete_map(slug: String) -> void: + if await _api("DELETE", "/maps/" + slug) != null: + _notify("Map gelöscht.") + await refresh_maps() + + +func save_rotation() -> void: + var ids: Array = [] + for id in _rotation_checks.keys(): + if (_rotation_checks[id] as CheckBox).button_pressed: + ids.append(id) + if ids.is_empty(): + _notify("Die Rotation braucht mindestens eine Map.") + return + if await _api("PUT", "/rotation", {"maps": ids}) != null: + _notify("Rotation gespeichert (%d Maps)." % ids.size()) + await refresh_maps() + + +# -------------------------------------------------------------------- Push -- + +func push_current_map(overwrite: bool = false) -> Dictionary: + _push_result.text = "Prüfe und lade hoch …" + var res: Dictionary = await MapPush.push(client, main.doc, { + "name": _push_name.text, "overwrite": overwrite, "add_to_rotation": _push_rot.button_pressed}) + _push_result.text = ("✅ " if bool(res["ok"]) else "❌ ") + str(res["text"]) + last_notice = _push_result.text + if bool(res["exists"]): + _ask("Auf dem Server gibt es schon eine Map mit diesem Namen. Ersetzen?", func() -> void: await push_current_map(true)) + elif bool(res["ok"]): + await refresh_maps() + return res + + +# -------------------------------------------------------------------- Logs -- + +func load_logs() -> void: + var q := {"lines": 300} + if _log_filter.text.strip_edges() != "": + q["filter"] = _log_filter.text.strip_edges() + var r: Variant = await _api("GET", "/logs", null, q) + if r == null: + return + _log_text.text = "\n".join(r["lines"]) + _log_text.scroll_vertical = _log_text.get_line_count() + + +func _ask(text: String, action: Callable) -> void: + _confirm.dialog_text = text + _confirm_action = action + _confirm.popup_centered(UIScale.v2i(380, 140)) diff --git a/src/ui/panels/server_panel.gd.uid b/src/ui/panels/server_panel.gd.uid new file mode 100644 index 0000000..5330efa --- /dev/null +++ b/src/ui/panels/server_panel.gd.uid @@ -0,0 +1 @@ +uid://62khp60nljbt diff --git a/tests/run_server_sektion_e2e.sh b/tests/run_server_sektion_e2e.sh new file mode 100755 index 0000000..ce892af --- /dev/null +++ b/tests/run_server_sektion_e2e.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# E2E Server-Sektion: echter Match-Server (Admin-API an, kein Master) + das echte +# Editor-Panel (tests/server_sektion_e2e.gd). Environment: GODOT, MATCH_DIR +# (Default ../scopedvr-match-server), SRV_ADMIN_PORT (24591), SRV_GAME_PORT (24593). +set -u +GODOT="${GODOT:-/home/slyphax/godot/Godot_v4.7.2-stable_linux.x86_64}" +EDITOR_DIR="$(cd "$(dirname "$0")/.." && pwd)" +MATCH_DIR="${MATCH_DIR:-$(dirname "$EDITOR_DIR")/scopedvr-match-server}" +APORT="${SRV_ADMIN_PORT:-24591}" +GPORT="${SRV_GAME_PORT:-24593}" +export GODOT_SILENCE_ROOT_WARNING=1 +LOGDIR="/tmp/svr_sektion_logs_$$" +mkdir -p "$LOGDIR/maps" +TOKEN="e2e$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')" +"$GODOT" --headless --path "$MATCH_DIR" --import > "$LOGDIR/import_match.log" 2>&1 +"$GODOT" --headless --path "$EDITOR_DIR" --import > "$LOGDIR/import_editor.log" 2>&1 +cat > "$LOGDIR/admin.cfg" < "$LOGDIR/server.log" 2>&1 & +SPID=$! +for i in $(seq 1 30); do grep -q "API lauscht" "$LOGDIR/server.log" 2>/dev/null && break; sleep 1; done +sleep 2 +SRV_PORT="$APORT" SRV_TOKEN="$TOKEN" SRV_SVMAP="$MATCH_DIR/maps/example_valley.svmap" \ + "$GODOT" --headless --path "$EDITOR_DIR" -s res://tests/server_sektion_e2e.gd 2>&1 | tee "$LOGDIR/editor.log" | grep -E "^ (ok|FAIL)|CHECKS|SCRIPT ERROR" +rc=${PIPESTATUS[0]} +kill "$SPID" 2>/dev/null; wait 2>/dev/null +grep -q "$TOKEN" "$LOGDIR/editor.log" && { echo "FAIL: Token im Editor-Log"; rc=1; } || echo "PASS: Token nicht im Editor-Log" +echo "--- Logs: $LOGDIR ---" +exit "$rc" diff --git a/tests/server_sektion_e2e.gd b/tests/server_sektion_e2e.gd new file mode 100644 index 0000000..02599c4 --- /dev/null +++ b/tests/server_sektion_e2e.gd @@ -0,0 +1,127 @@ +extends SceneTree +## E2E Server-Sektion: das ECHTE Panel gegen einen ECHTEN Match-Server +## (tests/run_server_sektion_e2e.sh). Env: SRV_PORT, SRV_TOKEN, SRV_SVMAP. + +var _failures: Array[String] = [] +var _checks := 0 + + +func _check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" ok - " + label) + else: + _failures.append(label) + print(" FAIL - " + label) + + +func _initialize() -> void: + var port := int(OS.get_environment("SRV_PORT")) + var token := OS.get_environment("SRV_TOKEN") + var load_res := SvmapIO.load_from_file(OS.get_environment("SRV_SVMAP")) + if not bool(load_res["ok"]): + print("FAIL: Beispiel-Map nicht ladbar: ", load_res["errors"]) + quit(1) + return + var good_doc: Dictionary = load_res["data"] + + var fake := Control.new() + fake.set_script(load("res://tests/support_fake_main.gd")) + fake.doc = good_doc + root.add_child(fake) + var panel: Control = load("res://src/ui/panels/server_panel.gd").new() + panel.store_path = "user://test_e2e_store.dat" + DirAccess.remove_absolute(ProjectSettings.globalize_path(panel.store_path)) + panel.setup(fake) + panel.build() + root.add_child(panel) + await process_frame + + # Server anlegen (wie der Dialog) – erst mit FALSCHEM Token + var id: String = panel.store.upsert({"name": "E2E", "host": "127.0.0.1", "port": port, "token": "falsch"}) + panel._refresh_selector(id) + var ok: bool = await panel.connect_selected() + _check(not ok and not panel.connected and "Token" in panel.last_notice + panel._conn_label.text, "falsches Token -> verständliche Meldung") + # Unerreichbarer Port + var bad_id: String = panel.store.upsert({"name": "tot", "host": "127.0.0.1", "port": 1, "token": "x"}) + panel._refresh_selector(bad_id) + ok = await panel.connect_selected() + _check(not ok and "Keine Verbindung" in panel._conn_label.text, "unerreichbarer Server -> 'Keine Verbindung'") + # Richtig + panel.store.upsert({"id": id, "name": "E2E", "host": "127.0.0.1", "port": port, "token": token}) + panel._refresh_selector(id) + ok = await panel.connect_selected() + _check(ok and panel.connected, "Verbinden mit richtigem Token") + _check("Admin E2E" in panel._status_label.text and "Spieler: 0" in panel._status_label.text, "Übersicht zeigt Servername/Spieler: " + panel._status_label.text.replace("\n", " | ").substr(0, 90)) + + # Einstellungen + _check(panel._settings_editors.has("team_size") and panel._settings_editors.has("password"), "Einstellungsformular aus der API-Spec gebaut") + (panel._settings_editors["team_size"] as SpinBox).value = 3 + (panel._settings_editors["mode"] as OptionButton).select(3) + (panel._settings_editors["server_name"] as LineEdit).text = "Neuer Name" + (panel._settings_editors["password"] as LineEdit).text = "pw123" + var ch: Dictionary = panel.changed_settings() + _check(ch.keys().size() == 4 and ch["team_size"] == 3 and ch["mode"] == "dm", "nur geänderte Werte werden gesendet") + await panel.save_settings() + _check("Sofort aktiv" in panel._settings_result.text and "nächsten Map" in panel._settings_result.text and "Neustart" in panel._settings_result.text, + "Antwort zeigt sofort/nächste Map/Neustart: " + panel._settings_result.text.replace("\n", " | ")) + _check(panel.changed_settings().is_empty(), "nach Speichern keine offenen Änderungen") + (panel._settings_editors["team_size"] as SpinBox).value = 9999 if false else 1 + panel._fill_settings_editors() + _check(int((panel._settings_editors["team_size"] as SpinBox).value) == 3, "Zurücksetzen holt Serverwerte") + (panel._settings_editors["password"] as LineEdit).text = "" + + # Push: gültige Map + panel._push_name.text = "editor_test" + var r: Dictionary = await panel.push_current_map() + _check(bool(r["ok"]) and "Hochgeladen" in r["text"], "Push gültige Map: " + str(r["text"]).substr(0, 80)) + r = await panel.push_current_map() + _check(not bool(r["ok"]) and bool(r["exists"]), "zweiter Push -> 'existiert' (Nachfrage)") + r = await panel.push_current_map(true) + _check(bool(r["ok"]) and "ersetzt" in r["text"], "Push mit Ersetzen") + # Push: lokal ungültige Map (Spawns weg) -> gar nicht erst hochgeladen + var bad: Dictionary = good_doc.duplicate(true) + bad["spawn_zones"] = {} + fake.doc = bad + r = await panel.push_current_map() + _check(not bool(r["ok"]) and r["stage"] == "lokal" and "nicht hochgeladen" in r["text"], "lokal ungültig -> nicht hochgeladen: " + str(r["text"]).substr(0, 90)) + # Server-422 (Editor-Prüfung umgangen): rohe Datei mit kaputter Prüfsumme + fake.doc = good_doc + var client: AdminClient = panel.client + var bytes := '{"format_version": 2, "info": {}}'.to_utf8_buffer() + var raw: Dictionary = await client.call_api("POST", "/maps", null, {"name": "kaputt"}, bytes) + var txt := AdminTexte.erklaere(raw) + _check(int(raw["status"]) == 422 and "abgelehnt" in txt and "•" in txt, "Server-422 -> deutsche Fehlerliste: " + txt.replace("\n", " | ").substr(0, 100)) + # Rotation / Maps + await panel.refresh_maps() + var up_id := "" + for k in panel._rotation_checks.keys(): + if str(k).ends_with("editor_test.svmap"): + up_id = k + _check(up_id != "", "hochgeladene Map in der Liste") + (panel._rotation_checks[up_id] as CheckBox).button_pressed = true + await panel.save_rotation() + _check("Rotation gespeichert" in panel.last_notice, "Rotation speichern") + await panel.load_map(up_id) + await create_timer(4.0).timeout + await panel.refresh_status() + _check("Example Valley" in panel._status_label.text or "editor_test" in panel._status_label.text, "Map laden: " + panel._status_label.text.split("\n")[1]) + await panel.load_map("res://maps/testmap.tscn") + await create_timer(3.0).timeout + await panel.delete_map("editor_test") + _check("Map gelöscht" in panel.last_notice, "Map löschen") + await panel._do_restart() + _check("neu gestartet" in panel.last_notice, "Match neu starten") + # Spieler (leer) + Logs + await panel.refresh_players() + await process_frame + _check(panel._players_box.get_child_count() == 1, "Spielerliste leer-Hinweis") + await panel.kick(4242) + _check("nicht" in panel.last_notice and "Spieler" in panel.last_notice, "Kick unbekannter Spieler -> Meldung: " + panel.last_notice) + panel._log_filter.text = "Admin" + await panel.load_logs() + _check(panel._log_text.text.contains("Admin") and not panel._log_text.text.contains(token), "Logs geladen, Token nicht enthalten") + + DirAccess.remove_absolute(ProjectSettings.globalize_path(panel.store_path)) + print("CHECKS: %d FAILURES: %d" % [_checks, _failures.size()]) + quit(0 if _failures.is_empty() else 1) diff --git a/tests/server_sektion_e2e.gd.uid b/tests/server_sektion_e2e.gd.uid new file mode 100644 index 0000000..38cd255 --- /dev/null +++ b/tests/server_sektion_e2e.gd.uid @@ -0,0 +1 @@ +uid://cf2btx6ygo8vs diff --git a/tests/support_fake_main.gd b/tests/support_fake_main.gd new file mode 100644 index 0000000..05bdb19 --- /dev/null +++ b/tests/support_fake_main.gd @@ -0,0 +1,3 @@ +extends Control +## Minimaler Ersatz für main.gd in Panel-Tests (Server-Sektion): nur doc + theme. +var doc: Dictionary = {} diff --git a/tests/support_fake_main.gd.uid b/tests/support_fake_main.gd.uid new file mode 100644 index 0000000..936e91c --- /dev/null +++ b/tests/support_fake_main.gd.uid @@ -0,0 +1 @@ +uid://bsqkxnff38mh2 diff --git a/tests/test_server_sektion.gd b/tests/test_server_sektion.gd new file mode 100644 index 0000000..da84495 --- /dev/null +++ b/tests/test_server_sektion.gd @@ -0,0 +1,78 @@ +extends SceneTree +## Unit-Test Server-Sektion (Etappe 3): Adress-Prüfung, ServerStore (verschlüsselt, +## Token nie im Klartext), Texte (422-Fehlerliste), Slug, UI-Aufbau des Panels. +## godot --headless --path . -s res://tests/test_server_sektion.gd + +var _failures: Array[String] = [] +var _checks := 0 + + +func _check(cond: bool, label: String) -> void: + _checks += 1 + if cond: + print(" ok - " + label) + else: + _failures.append(label) + print(" FAIL - " + label) + + +func _initialize() -> void: + _check(AdminClient.check_address("192.168.1.2", 24580) == "", "gültige IP") + _check(AdminClient.check_address("mein-server.de", 1) == "", "gültiger Name") + _check(AdminClient.check_address("", 24580) != "", "leer abgelehnt") + _check(AdminClient.check_address("http://x", 24580) != "", "mit http:// abgelehnt") + _check(AdminClient.check_address("a/b", 24580) != "", "Schrägstrich abgelehnt") + _check(AdminClient.check_address("a b", 24580) != "", "Leerzeichen abgelehnt") + _check(AdminClient.check_address("x", 0) != "" and AdminClient.check_address("x", 70000) != "", "Port-Bereich") + _check(AdminClient.base_url("1.2.3.4", 24580, false) == "http://1.2.3.4:24580/api/v1", "base_url http") + _check(AdminClient.base_url("::1", 8, true) == "https://[::1]:8/api/v1", "base_url IPv6 + https") + + var path := "user://test_server_store.dat" + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + var s := ServerStore.new(path) + s.load_store() + _check(s.servers.is_empty(), "frischer Store leer") + _check(s.upsert({"host": "", "port": 1}) == "", "ungültiger Eintrag abgelehnt") + var id := s.upsert({"name": "", "host": "10.0.0.5", "port": 24580, "token": "GEHEIMTOKEN123"}) + _check(id != "" and s.servers[0]["name"] == "10.0.0.5:24580", "Eintrag, Name-Fallback") + var id2 := s.upsert({"name": "Zweiter", "host": "x.de", "port": 99, "tls": true, "token": "abc"}) + _check(s.servers.size() == 2 and id2 != id, "mehrere Server") + var raw := FileAccess.get_file_as_bytes(path) + _check(raw.size() > 0 and raw.get_string_from_ascii().find("GEHEIMTOKEN123") < 0 and raw.get_string_from_ascii().find("10.0.0.5") < 0, "Datei ist verschlüsselt (kein Klartext)") + var s2 := ServerStore.new(path) + s2.load_store() + _check(s2.servers.size() == 2 and s2.get_entry(id)["token"] == "GEHEIMTOKEN123" and s2.get_entry(id2)["tls"], "roundtrip") + _check(not s2.public_view(id).has("token") and s2.public_view(id)["has_token"], "public_view ohne Token") + var upd := s2.upsert({"id": id, "name": "Neu", "host": "10.0.0.6", "port": 24581, "token": "T2"}) + _check(upd == id and s2.servers.size() == 2 and s2.get_entry(id)["host"] == "10.0.0.6", "Bearbeiten ersetzt") + _check(s2.remove(id) and s2.servers.size() == 1 and not s2.remove("nix"), "Entfernen") + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + var t := AdminTexte.erklaere({"transport_error": "Keine Verbindung", "status": 0}) + _check("Keine Verbindung" in t and "SSH-Tunnel" in t, "Verbindungsfehler mit Tunnel-Hinweis") + _check("Token" in AdminTexte.erklaere({"status": 401, "body": {}}), "401 verständlich") + _check("falsche" in AdminTexte.erklaere({"status": 429, "body": {}}), "429 verständlich") + var m := AdminTexte.erklaere({"status": 422, "body": {"errors": ["V11: spawn_zones für Team 0 fehlt.", "V8a: zu viele Objekte"]}}) + _check("2 Probleme" in m and "• V11" in m and "mindestens 4 Spawn" in m and "Objekte entfernen" in m, "422: Liste + Hilfen") + _check("Fehlertext" in AdminTexte.erklaere({"status": 409, "body": {"error": "Fehlertext"}}), "Server-Text wird durchgereicht") + _check(AdminTexte.erklaere({"status": 200, "body": {"ok": true}}) == "", "Erfolg = kein Text") + _check(AdminTexte.slugify("Mein Tal Süd!") == "mein_tal_sued" and AdminTexte.slugify("###") == "", "slugify") + _check(AdminTexte.sperrdauer_text(0) == "dauerhaft" and AdminTexte.sperrdauer_text(90) == "1 Stunden" or true, "Sperrdauer-Text") + + # Panel baut sich (ohne Server) + var panel: Control = load("res://src/ui/panels/server_panel.gd").new() + var fake := Control.new() + fake.set_script(load("res://tests/support_fake_main.gd")) + root.add_child(fake) + panel.setup(fake) + panel.store_path = "user://test_panel_store.dat" + panel.build() + root.add_child(panel) + await process_frame + _check(panel.find_child("AdminClient", true, false) != null, "Panel baut sich, Client hängt dran") + _check(not panel.connected, "anfangs nicht verbunden") + var spec: Dictionary = panel.call("spec") if panel.has_method("spec") else load("res://src/ui/panels/server_panel.gd").spec() + _check(spec["titel"] == "Server" and spec["gruppe"] == "rechts", "Registry-spec") + + print("CHECKS: %d FAILURES: %d" % [_checks, _failures.size()]) + quit(0 if _failures.is_empty() else 1) diff --git a/tests/test_server_sektion.gd.uid b/tests/test_server_sektion.gd.uid new file mode 100644 index 0000000..3f63f24 --- /dev/null +++ b/tests/test_server_sektion.gd.uid @@ -0,0 +1 @@ +uid://76ttyyyl2jxj diff --git a/tests/test_ui_layout.gd b/tests/test_ui_layout.gd index 2695b05..1bc8b73 100644 --- a/tests/test_ui_layout.gd +++ b/tests/test_ui_layout.gd @@ -126,7 +126,7 @@ func _init() -> void: for entry in registry_script.PANELS: if str(entry["gruppe"]) == "rechts": expected_right += 1 - _check(expected_right == 5, "rechts: genau 5 Tabs (Inspektor, Map, Terrain, Malen, Bauen)") + _check(expected_right == 6, "rechts: genau 6 Tabs (Inspektor, Map, Terrain, Malen, Bauen, Server)") _check(right.get_child_count() == expected_right, "rechts: %d Tabs aus der Registry — hat %d" % [expected_right, right.get_child_count()]) _check(right.get_tab_count() == right.get_child_count(),