diff --git a/docs/luneraria/ANLEITUNG.md b/docs/luneraria/ANLEITUNG.md index fda69df..11aceef 100644 --- a/docs/luneraria/ANLEITUNG.md +++ b/docs/luneraria/ANLEITUNG.md @@ -38,6 +38,7 @@ Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebra ```bash godot --headless --path . res://tests/luneraria/test_lune_core.tscn # Regeln M1 (91, inkl. Spiegeltest) godot --headless --path . res://tests/luneraria/test_lune_m2.tscn # Regeln M2 (73) +godot --headless --path . res://tests/luneraria/test_lune_net.tscn # Netzwerkprotokoll (24) godot --path . --resolution 1280x720 res://tools/luneraria/verify_m1.tscn # echte Bedienung, volle Partie (38) godot --path . --resolution 1920x1080 res://tools/luneraria/verify_m2.tscn # Helden, Items, Grotte, Boss (15) godot --headless --path . res://tools/luneraria/sim_match.tscn -- --seeds=1,2,3 --limit=80 @@ -70,7 +71,19 @@ godot --headless --editor --import --path . ## Export -Die Pakete für Champions of Luneraria kommen mit M4. Bis dahin baut `tools/build_demo.py` weiterhin die Saumwacht-Demo 0.15.0. +```bash +python3 tools/luneraria/build_luneraria_demo.py --check-only +python3 tools/luneraria/build_luneraria_demo.py +``` + +Erzeugt `../dist/luneraria/ChampionsOfLuneraria-Linux-x64-.zip` und `…-Windows-x64-.zip` (Produktname Champions of Luneraria). Alte Saumwacht-Releases unter `../dist/Saumwacht-*.zip` bleiben unverändert. `tools/build_demo.py` baut weiterhin Saumwacht 0.15.0. + +**Netzwerk (experimentell):** Im Setup *Hosten* oder *Beitreten* mit Adresse und Port (Standard 24571). Der Host ist autoritativ, freie Plätze sind Bots. Ein Disconnect wird von einem Bot übernommen und ist kein Kernverlust. Getestet sind Protokollchecks und ein lokaler Zwei-Prozess-Loopback (`tools/luneraria/net_pair.tscn`) – keine verifizierte Internetpartie auf sechs Rechnern. Kein Matchmaking, keine Hostmigration. + +```bash +godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=host --port=24571 & +godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client --port=24571 +``` ## Benutzereinstellungen diff --git a/docs/luneraria/ARCHITEKTUR.md b/docs/luneraria/ARCHITEKTUR.md index 046d970..d0c3f75 100644 --- a/docs/luneraria/ARCHITEKTUR.md +++ b/docs/luneraria/ARCHITEKTUR.md @@ -25,3 +25,4 @@ Stand: 27. September 2026 (M0). 6. **Benutzereinstellungen:** Der Projektname wird zu „Champions of Luneraria“, dadurch ändert sich der `user://`-Pfad. `Preferences` übernimmt beim ersten Start einmalig die alte `preferences.cfg` aus „Saumwacht · Glutpakt“ und protokolliert das. Fehlt sie, gelten die Standardwerte. 7. **Tests:** Neue Tests liegen unter `tests/luneraria/` und `tools/luneraria/`, die Legacy-Tests bleiben unverändert und werden als „Legacy“ geführt. Neue Referenzen werden erst nach fachlicher Prüfung festgeschrieben. 8. **M3-Nachtrag:** Bots planen Items/Champions vor der Armee und helfen deterministisch (kein geteilter `rng`), damit Spiegelmatches bit-symmetrisch bleiben. Kartenprops stammen aus `map_props.glb` (MultiMesh, ENV_*-Kluftshader). Volksrepräsentanten `p_*` sind Asset- und Datenziele ohne Matcheinbindung; siehe [VOELKER.md](VOELKER.md). Balance- und Stresswerkzeuge liegen unter `tools/luneraria/`. +9. **M4-Nachtrag:** `scripts/luneraria/net/lune_session.gd` (Node) kapselt ENet Host/Join. Der Host besitzt `LuneMatch` und sendet Zustand/Phasen; Clients senden nur Befehle (`rpc_submit`, `rpc_ready`). Peer-ID → Sitz, ein Peer ein Sitz. Disconnect: Bot-Übernahme, kein Kernverlust, keine Hostmigration. Protokolltests `tests/luneraria/test_lune_net.gd`, Zwei-Prozess-Loopback `tools/luneraria/net_pair.tscn`. Export-Presets `Luneraria-Linux` / `Luneraria-Windows`, Demo-Bau `tools/luneraria/build_luneraria_demo.py`. diff --git a/docs/luneraria/QA-MATRIX.md b/docs/luneraria/QA-MATRIX.md index 99540cd..6a7bafd 100644 --- a/docs/luneraria/QA-MATRIX.md +++ b/docs/luneraria/QA-MATRIX.md @@ -19,7 +19,7 @@ Legende: ✅ automatisiert geprüft · 👁 in echten Aufnahmen visuell geprüft | 9 | Beide Expeditionen, Scheitern, Erfolg, genau eine Entsendung, Kopfgeld, Verfügbarkeit ab Runde 10 | ✅ M2 (Sieg in Runde 6 → Einsatz 7 → gesperrt 7–9 → frei ab 10; wiederholtes Signal ohne Wirkung) | `test_lune_m2` · boss_*; Szenarien `boss_scenarios` | | 10 | Laneverlust, Phasenende, Pause, Neustart, Rückkehr ins Menü | ✅ M1/M2 | `verify_m1`, `test_lune_core` · victory (timeout), `test_lune_m2` · lane_loss | | 11 | Bots ohne Sonderressourcen und ohne Sicht auf private Befehle | ✅ Bots nutzen dieselben Befehle ohne Sondergold; sie lesen nur den eigenen Entwurf, veröffentlichte Gebäudestände, den öffentlichen Bosszustand und Ankündigungen (Code-Durchsicht, `LuneBot`) | `test_lune_core` · commands; `test_lune_m2` · bots_m2 | -| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ⏳ M4 | — | +| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ✅ Protokoll + Loopback (1 Runde) | `test_lune_net` 24/24 · `net_pair` Host/Client 0/0 | ## M3 · Qualität und Balance @@ -55,6 +55,20 @@ Nur die Kampfuhr wird dabei beschleunigt (mehr feste Schritte pro Bild), die Reg Weitere M2-Belege: `test_lune_m2` 73/73, darunter der Nachweis, dass die alte Arbeiter- und Funkenökonomie nicht mitläuft (Arbeitsannahme E). M2-App nach M3-Botfix erneut 15/15 (bzw. 14/14, wenn die Partie vor einer Bossentsendung endet). +## M4 · Netzwerk und Pakete + +| Nachweis | Stand | Beleg | +| --- | --- | --- | +| Sechs Slots, Host/Join Direktverbindung, Bots für freie Plätze | ✅ | `LuneSession`, Menü „Hosten/Beitreten“ | +| Fremder Sitz / fremde UID / veralteter Befehl / Doppelkauf | ✅ | `test_lune_net` 24/24 | +| Disconnect ≠ Kernzerstörung, Bot-Übernahme | ✅ | `test_lune_net` disconnect | +| Zwei-Prozess-Loopback (lokal, nicht Internet) | ✅ 1 Runde | `tools/luneraria/net_pair.tscn` | +| Linux-Export + entpackbare Demo | ✅ 0.16.0-m4 | `../dist/luneraria/ChampionsOfLuneraria-Linux-x64-0.16.0-m4.zip` | +| Windows-Export + entpackbare Demo | ✅ Dateien erzeugt | `../dist/luneraria/ChampionsOfLuneraria-Windows-x64-0.16.0-m4.zip` | +| Nativer Windows-11-Lauf | ⏳ blockiert | keine Windows-Hardware; Export oder Wine wäre kein Nativtest | +| Mehr-Runden-Phasensync im Zwei-Fenster-Spiel | ⏳ | Smoke deckt eine Runde ab | +| Reconnect als Mensch (neben Bot-Übernahme) | ⏳ | derzeit immer Bot-Übernahme | + ## Legacy-Regressionen (Saumwacht) Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 · 1.665 · Menü 20A 68. Logs: `logs/m0-baseline/`, `logs/m1/legacy-*.log`. diff --git a/docs/luneraria/STATUS.md b/docs/luneraria/STATUS.md index b8795b2..8b5e769 100644 --- a/docs/luneraria/STATUS.md +++ b/docs/luneraria/STATUS.md @@ -8,7 +8,7 @@ Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l | M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** | | M2 · Mechanikdurchlauf | **abgeschlossen** | | M3 · Qualität und Balance | **abgeschlossen** (siehe unten) | -| M4 · Netzwerk und Pakete | offen | +| M4 · Netzwerk und Pakete | **teilweise** (Loopback + Exporte; Windows-Nativtest blockiert) | ## M1 · abgeschlossen @@ -92,6 +92,22 @@ Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l - Nativer Windows-11-Test: keine Windows-Hardware. - Kompatibilitätsmodus (OpenGL) für Luneraria: erst mit M4-Exporten gezielt prüfen. +## M4 · teilweise + +**Implementiert** +- `LuneSession` (autoritativ): Host besitzt `LuneMatch`, Clients senden nur Absichten. Sitzplätze an Peer-IDs, ein Peer ein Sitz. Disconnect → Bot-Übernahme, nie Kernverlust, keine Hostmigration. +- Host/Join im Setup-Menü (Direktverbindung, Standardport 24571), freie Plätze als Bots. +- Protokollprüfungen: fremder Sitz, fremde UID, veralteter Befehl, Doppelkauf ohne Gratisklon, Disconnect ohne Kernschaden (`test_lune_net` 24/24). +- Zwei-Prozess-Loopback (`tools/luneraria/net_pair.tscn`): Gast empfängt Setup, Spoof abgelehnt, Kauf gesendet, sauberes Client-Ende. **Das ist ein lokaler Zwei-Prozess-Test, keine Internetpartie auf sechs Rechnern.** +- Exporte: `Luneraria-Linux` und `Luneraria-Windows` (Produktname Champions of Luneraria, Luneraria-Manifeste enthalten), entpackbare Demos 0.16.0-m4 unter `../dist/luneraria/`. + +**Offen** +- Phasensynchronisation über mehrere Runden im echten Zwei-Fenster-Spiel (derzeit Smoke-Test über eine Runde). +- Zustandsabgleich/Reconnect als wieder einsteigender Spieler (derzeit Bot-Übernahme). +- Kompatibilitätsmodus (OpenGL) für Luneraria. +- Nativer Windows-11-Test (blockiert: keine Hardware). +- Öffentliches Matchmaking, Accounts, Ranglisten: außerhalb des Umfangs. + ## Nächster Schritt -M4: autoritatives Host/Join für sechs Slots (Direktverbindung, Bots für freie Slots), synchronisierte Phasen und Zustandsabgleich, reale Mehrprozessprüfungen (Loopback), reproduzierbare Linux-/Windows-Exports, entpackbare Demo, klare Testmatrix. Öffentliches Matchmaking bleibt außerhalb des Umfangs. +M4 fertigstellen: Mehr-Runden-Zwei-Prozess-Test, dokumentierte Reconnect-Regel (Bot-Übernahme vs. Wiedereinstieg), OpenGL-Sichtprüfung, Windows-11-Protokollvorlage für fremde Hardware. Danach Volksintegration nach VOELKER.md. diff --git a/docs/luneraria/logs/net-tests.json b/docs/luneraria/logs/net-tests.json new file mode 100644 index 0000000..3b6ee71 --- /dev/null +++ b/docs/luneraria/logs/net-tests.json @@ -0,0 +1,4 @@ +{ + "checks": 24, + "failures": [] +} \ No newline at end of file diff --git a/export_presets.cfg b/export_presets.cfg index 4a3dc08..acad2db 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -1,14 +1,14 @@ [preset.0] -name="Linux" +name="Luneraria-Linux" platform="Linux" runnable=true advanced_options=false dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json" exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" -export_path="../dist/linux/Saumwacht.x86_64" +export_path="../dist/luneraria/linux/ChampionsOfLuneraria.x86_64" encryption_include_filters="" encryption_exclude_filters="" encrypt_pck=false @@ -28,22 +28,22 @@ texture_format/etc=false codesign/enable=false application/modify_resources=false application/company_name="" -application/product_name="Saumwacht – Die zwei Bünde" -application/file_description="Saumwacht – Die zwei Bünde Demo" +application/product_name="Champions of Luneraria" +application/file_description="Champions of Luneraria Demo" application/file_version="0.15.0.0" application/product_version="0.15.0.0" [preset.1] -name="Windows" +name="Luneraria-Windows" platform="Windows Desktop" runnable=true advanced_options=false dedicated_server=false custom_features="" export_filter="all_resources" -include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json" +include_filter="assets/phase3_manifest.json,assets/phase4_manifest.json,assets/phase11_manifest.json,assets/phase12_manifest.json,assets/phase15_manifest.json,assets/phase16_manifest.json,assets/phase18_manifest.json,assets/phase19_manifest.json,assets/menu_stage_manifest.json,assets/audio/manifest.json,assets/icons/manifest.json,assets/luneraria/tower_manifest.json,assets/luneraria/grotto_manifest.json,assets/luneraria/map_props_manifest.json,assets/luneraria/peoples_manifest.json" exclude_filter="docs/*,tests/*,tools/*,assets/ui/ui_*,assets/maps/*,assets/materials/phase11/*" -export_path="../dist/windows/Saumwacht.exe" +export_path="../dist/luneraria/windows/ChampionsOfLuneraria.exe" encryption_include_filters="" encryption_exclude_filters="" encrypt_pck=false @@ -63,7 +63,7 @@ texture_format/etc=false codesign/enable=false application/modify_resources=false application/company_name="" -application/product_name="Saumwacht – Die zwei Bünde" -application/file_description="Saumwacht – Die zwei Bünde Demo" +application/product_name="Champions of Luneraria" +application/file_description="Champions of Luneraria Demo" application/file_version="0.15.0.0" application/product_version="0.15.0.0" diff --git a/scripts/luneraria/lune_game.gd b/scripts/luneraria/lune_game.gd index b94eb6e..e8b6d49 100644 --- a/scripts/luneraria/lune_game.gd +++ b/scripts/luneraria/lune_game.gd @@ -12,6 +12,7 @@ const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd") const SPEEDS: Array[int] = [1, 2, 4] var match_state: LuneMatch +var session: Node var world: Node3D var camera: Camera3D var hud: Control @@ -31,8 +32,11 @@ func start(setup_options: Dictionary) -> void: options = setup_options name = "LuneGame" human_slot = int(options.get("human_slot", 1)) - match_state = LuneMatch.new() - match_state.setup(options) + if session != null and is_instance_valid(session) and session.match_state != null: + match_state = session.match_state + else: + match_state = LuneMatch.new() + match_state.setup(options) world = World.new() add_child(world) world.build(match_state.map) @@ -78,7 +82,10 @@ func _feedback(result: Dictionary) -> void: _rebuild_preview() func _on_command(command: Dictionary) -> void: - _feedback(match_state.submit(human_slot, command)) + if session != null and is_instance_valid(session): + _feedback(session.submit(command)) + else: + _feedback(match_state.submit(human_slot, command)) func _starter() -> void: var player: Dictionary = match_state.players[human_slot] diff --git a/scripts/luneraria/lune_main.gd b/scripts/luneraria/lune_main.gd index dc8b8e5..5344367 100644 --- a/scripts/luneraria/lune_main.gd +++ b/scripts/luneraria/lune_main.gd @@ -1,15 +1,17 @@ extends Node ## Entry scene of Champions of Luneraria: title menu, match, return, rematch. -## Legacy QA switches (--smoke-test, --faction-test, --expedition-test) open the archived -## Saumwacht scene unchanged, so its technical regressions keep running. +## Offline is a local host with bot-filled seats. Host/Join uses LuneSession (ENet). +## Legacy QA switches open the archived Saumwacht scene unchanged. const Game = preload("res://scripts/luneraria/lune_game.gd") const Menu = preload("res://scripts/luneraria/ui/lune_menu.gd") +const Session = preload("res://scripts/luneraria/net/lune_session.gd") const LEGACY_SWITCHES: Array[String] = ["--smoke-test", "--faction-test", "--expedition-test", "--ringborke", "--legacy"] var menu: MainMenu var game: Node3D var settings: Control +var session: Node var _layer: CanvasLayer var _last_options: Dictionary = {} @@ -29,6 +31,9 @@ func _ready() -> void: menu.start_requested.connect(func() -> void: start_match({"seed": int(Time.get_unix_time_from_system()) % 100000})) menu.quit_requested.connect(func() -> void: Soundscape.quit_game()) menu.settings_requested.connect(toggle_settings) + if menu.has_signal("host_requested"): + menu.host_requested.connect(_host_match) + menu.join_requested.connect(_join_match) settings = LuneSettings.new() _layer.add_child(settings) settings.visible = false @@ -46,6 +51,41 @@ func start_match(options: Dictionary) -> void: _last_options = {"seed": int(options.get("seed", 1)), "human_slot": human, "bunds": bunds} _launch(_last_options) +func _host_match(port: int, display_name: String) -> void: + _reset_session() + session = Session.new() + session.name = "LuneSession" + add_child(session) + var options := {"seed": int(Time.get_unix_time_from_system()) % 100000, "human_slot": 0, "bunds": []} + var rng := RandomNumberGenerator.new() + rng.seed = options.seed + for slot: int in range(6): + options.bunds.append(menu.bund if slot == 0 else DataCatalog.FACTIONS[rng.randi() % DataCatalog.FACTIONS.size()]) + var err: int = session.host_enet(port, options, display_name) + if err == OK: + _last_options = options + _launch(_last_options) + +func _join_match(address: String, port: int, display_name: String) -> void: + _reset_session() + session = Session.new() + session.name = "LuneSession" + add_child(session) + session.session_message.connect(func(text: String, is_error: bool) -> void: + print(("FEHLER: " if is_error else "") + text)) + if session.join_enet(address, port, display_name) != OK: + return + session.match_resetup.connect(func() -> void: + if game == null and session != null and session.match_state != null: + _last_options = {"seed": session.match_state.seed_value, "human_slot": session.local_slot, "bunds": []} + _launch(_last_options), CONNECT_ONE_SHOT) + +func _reset_session() -> void: + if session != null and is_instance_valid(session): + session.close() + session.queue_free() + session = null + func _launch(options: Dictionary) -> void: # The rematch button lives inside the old match; detach it now, free it after the signal. if is_instance_valid(game): @@ -54,6 +94,8 @@ func _launch(options: Dictionary) -> void: game = Game.new() add_child(game) game.input_blocked = func() -> bool: return menu.visible or settings.visible + if session != null and is_instance_valid(session): + game.session = session game.start(options) game.exit_requested.connect(return_to_menu) game.rematch_requested.connect(func() -> void: @@ -67,6 +109,7 @@ func return_to_menu() -> void: remove_child(game) game.queue_free() game = null + _reset_session() menu._locked = false menu.pending_screen = MainMenu.Screen.TITLE menu.enter() diff --git a/scripts/luneraria/net/lune_session.gd b/scripts/luneraria/net/lune_session.gd new file mode 100644 index 0000000..ba4274d --- /dev/null +++ b/scripts/luneraria/net/lune_session.gd @@ -0,0 +1,389 @@ +class_name LuneSession +extends Node +## Authoritative network session for Champions of Luneraria. +## Must live in the scene tree (RPC + MultiplayerAPI live on Node). +## The host owns LuneMatch and every simulation result. Clients send intents only. +## The peer id maps to exactly one seat; a client never chooses another player's slot. +## A disconnect hands the seat to a bot. It never destroys a core and never migrates the host. +## Offline runs the same code path as a local host with bot-filled free seats. + +const DEFAULT_PORT: int = 24571 +const MAX_CLIENTS: int = 5 +const PROTOCOL: int = 3 + +enum Role { OFFLINE, HOST, CLIENT } + +var role: int = Role.OFFLINE +var match_state: LuneMatch +var local_slot: int = -1 +## slot -> peer id (1 = host). Absent slots are bots owned by the host. +var seat_peers: Dictionary = {} +## [{peer, slot, name, ready, bot}] +var lobby: Array[Dictionary] = [] +var last_error: String = "" +var connected: bool = false + +signal lobby_changed +signal match_resetup +signal session_message(text: String, is_error: bool) +signal phase_announced(phase: int, round_number: int) +signal combat_snapshot(units: Array) + +func is_authority() -> bool: + return role != Role.CLIENT + +func display_name_for(slot: int) -> String: + if match_state != null and slot >= 0 and slot < match_state.players.size(): + return str(match_state.players[slot].name) + return "Slot %d" % slot + +func seat_of_peer(peer: int) -> int: + for slot: int in seat_peers: + if int(seat_peers[slot]) == peer: + return slot + return -1 + +# ---------------------------------------------------------------- setup +func setup_match(options: Dictionary) -> void: + match_state = LuneMatch.new() + match_state.setup(options) + match_resetup.emit() + _announce_phase() + +func _announce_phase() -> void: + if match_state == null: + return + phase_announced.emit(match_state.phase, match_state.round_number) + +# ---------------------------------------------------------------- start modes +func host_offline(human_slot: int, options: Dictionary) -> void: + role = Role.OFFLINE + connected = true + local_slot = human_slot + seat_peers = {human_slot: 1} if human_slot >= 0 else {} + lobby = [] + if human_slot >= 0: + lobby.append({"peer": 1, "slot": human_slot, "name": "Du", "ready": false, "bot": false}) + for slot: int in range(6): + if slot != human_slot: + lobby.append({"peer": 0, "slot": slot, "name": "Bot %d" % slot, "ready": true, "bot": true}) + options["human_slot"] = human_slot + setup_match(options) + +func host_enet(port: int, options: Dictionary, display_name: String = "Host") -> Error: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_server(port, MAX_CLIENTS) + if err != OK: + last_error = "Server auf Port %d nicht startbar (%s)." % [port, error_string(err)] + return err + _bind_peer(peer) + role = Role.HOST + connected = true + var human: int = int(options.get("human_slot", 0)) + local_slot = human + seat_peers = {human: 1} + lobby = [{"peer": 1, "slot": human, "name": display_name, "ready": false, "bot": false}] + options["human_slot"] = human + setup_match(options) + lobby_changed.emit() + return OK + +func join_enet(address: String, port: int, display_name: String = "Gast") -> Error: + var peer := ENetMultiplayerPeer.new() + var err := peer.create_client(address, port) + if err != OK: + last_error = "Verbindung zu %s:%d nicht möglich (%s)." % [address, port, error_string(err)] + return err + _bind_peer(peer) + role = Role.CLIENT + connected = false + local_slot = -1 + if not multiplayer.connected_to_server.is_connected(_on_connected): + multiplayer.connected_to_server.connect(_on_connected) + if not multiplayer.connection_failed.is_connected(_on_connection_failed): + multiplayer.connection_failed.connect(_on_connection_failed) + if not multiplayer.server_disconnected.is_connected(_on_server_lost): + multiplayer.server_disconnected.connect(_on_server_lost) + return OK + +func _on_connected() -> void: + connected = true + session_message.emit("Mit dem Host verbunden.", false) + +func _on_connection_failed() -> void: + last_error = "Verbindung zum Host fehlgeschlagen." + connected = false + session_message.emit(last_error, true) + +func _on_server_lost() -> void: + last_error = "Host hat die Verbindung getrennt." + connected = false + session_message.emit(last_error, true) + +func _bind_peer(peer: MultiplayerPeer) -> void: + if multiplayer.multiplayer_peer != null: + multiplayer.multiplayer_peer.close() + multiplayer.multiplayer_peer = peer + if not multiplayer.peer_disconnected.is_connected(_on_peer_disconnected): + multiplayer.peer_disconnected.connect(_on_peer_disconnected) + if not multiplayer.peer_connected.is_connected(_on_peer_connected): + multiplayer.peer_connected.connect(_on_peer_connected) + +func close() -> void: + if multiplayer != null and multiplayer.multiplayer_peer != null: + multiplayer.multiplayer_peer.close() + multiplayer.multiplayer_peer = null + role = Role.OFFLINE + connected = false + seat_peers.clear() + lobby.clear() + +# ---------------------------------------------------------------- intents +func submit(command: Dictionary) -> Dictionary: + if match_state == null or match_state.phase != LuneMatch.Phase.PLANNING: + return {"ok": false, "message": "Keine Planung."} + if role == Role.CLIENT: + if local_slot < 0: + return {"ok": false, "message": "Kein Sitzplatz."} + rpc_id(1, "rpc_submit", local_slot, command) + return {"ok": true, "message": "Befehl gesendet."} + return host_command(1 if role == Role.HOST else 1, local_slot, command) + +func ready_up() -> void: + if match_state == null: + return + if role == Role.CLIENT: + rpc_id(1, "rpc_ready", local_slot) + return + host_ready(local_slot) + +## Testable host entry: peer must own slot. Duplicate submits of a purchase are +## rejected by LuneMatch through unique uids and gold, not by packet identity. +func host_command(peer: int, slot: int, command: Dictionary) -> Dictionary: + if not is_authority() or match_state == null: + return {"ok": false, "message": "Keine Autorität."} + if seat_of_peer(peer) < 0 or seat_of_peer(peer) != slot: + var rejected := {"ok": false, "message": "Fremder Sitz oder ungültige Einheiten-ID."} + if role == Role.HOST and peer > 1: + rpc_id(peer, "rpc_feedback", slot, rejected) + return rejected + if match_state.phase != LuneMatch.Phase.PLANNING: + var stale := {"ok": false, "message": "Veralteter Befehl (Planung vorbei)."} + if role == Role.HOST and peer > 1: + rpc_id(peer, "rpc_feedback", slot, stale) + return stale + var result: Dictionary = match_state.submit(slot, command) + if role == Role.HOST: + if peer > 1: + rpc_id(peer, "rpc_feedback", slot, result) + _broadcast_state() + return result + +func host_ready(slot: int) -> void: + if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING: + return + match_state.set_ready(slot) + for entry: Dictionary in lobby: + if int(entry.slot) == slot: + entry.ready = true + _broadcast_lobby() + if match_state.all_ready(): + host_begin_combat() + +func plan_bot_seats() -> void: + if not is_authority() or match_state == null: + return + for slot: int in range(6): + if not seat_peers.has(slot): + LuneBot.plan(match_state, slot) + +func host_begin_combat() -> void: + if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING: + return + plan_bot_seats() + match_state.begin_combat() + _announce_phase() + if role == Role.HOST: + rpc("rpc_phase", match_state.phase, match_state.round_number) + +func host_finish_combat() -> void: + if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT: + return + match_state.finish_combat() + _announce_phase() + if role == Role.HOST: + rpc("rpc_phase", match_state.phase, match_state.round_number) + _broadcast_state() + +# ---------------------------------------------------------------- rpc +@rpc("any_peer", "call_remote", "reliable") +func rpc_submit(slot: int, command: Dictionary) -> void: + if not is_authority(): + return + host_command(multiplayer.get_remote_sender_id(), slot, command) + +@rpc("any_peer", "call_remote", "reliable") +func rpc_ready(slot: int) -> void: + if not is_authority(): + return + var peer: int = multiplayer.get_remote_sender_id() + if seat_of_peer(peer) != slot: + return + host_ready(slot) + +@rpc("authority", "call_remote", "reliable") +func rpc_feedback(_slot: int, result: Dictionary) -> void: + session_message.emit(str(result.get("message", "")), not bool(result.get("ok", false))) + +@rpc("authority", "call_remote", "reliable") +func rpc_phase(phase: int, round_number: int) -> void: + if match_state == null: + return + match_state.phase = phase + match_state.round_number = round_number + _announce_phase() + +@rpc("authority", "call_remote", "reliable") +func rpc_setup(options: Dictionary, seat: int, roster: Array) -> void: + local_slot = seat + lobby = [] + seat_peers.clear() + for entry: Dictionary in roster: + lobby.append(entry.duplicate()) + seat_peers[int(entry.slot)] = int(entry.peer) + setup_match(options) + connected = true + lobby_changed.emit() + +@rpc("authority", "call_remote", "reliable") +func rpc_state(drafts: Array, building_hp: Dictionary, boss: Dictionary, phase: int, round_number: int) -> void: + if match_state == null: + return + match_state.phase = phase + match_state.round_number = round_number + match_state.building_hp = building_hp + match_state.boss = boss + for row: Dictionary in drafts: + var slot: int = int(row.get("slot", -1)) + if slot < 0 or slot >= match_state.players.size(): + continue + var player: Dictionary = match_state.players[slot] + player.gold = int(row.gold) + player.ready = bool(row.ready) + player.draft.roster = (row.roster as Array).duplicate(true) + player.draft.hero = (row.hero as Dictionary).duplicate(true) + player.draft.new_bookings = (row.new_bookings as Array).duplicate(true) + player.bookings = (row.bookings as Array).duplicate(true) + for entry: Dictionary in lobby: + for row: Dictionary in drafts: + if int(row.slot) == int(entry.slot): + entry.ready = bool(row.ready) + lobby_changed.emit() + +@rpc("authority", "call_remote", "reliable") +func rpc_lobby(entries: Array) -> void: + lobby = [] + for entry: Dictionary in entries: + lobby.append(entry.duplicate()) + lobby_changed.emit() + +@rpc("authority", "call_remote", "unreliable_ordered") +func rpc_units(payload: Array) -> void: + combat_snapshot.emit(payload) + +# ---------------------------------------------------------------- host broadcast +func _broadcast_lobby() -> void: + if role == Role.HOST: + rpc("rpc_lobby", lobby) + +func _broadcast_state() -> void: + if role != Role.HOST or match_state == null: + return + var drafts: Array = [] + for player: Dictionary in match_state.players: + var use_draft: bool = match_state.phase == LuneMatch.Phase.PLANNING + drafts.append({ + "slot": player.slot, "gold": player.gold, "ready": player.ready, + "roster": (player.draft.roster if use_draft else player.roster) as Array, + "hero": (player.draft.hero if use_draft else player.hero) as Dictionary, + "bookings": player.bookings as Array, + "new_bookings": player.draft.new_bookings as Array, + }) + rpc("rpc_state", drafts, match_state.building_hp, match_state.boss, match_state.phase, match_state.round_number) + +func broadcast_combat_units() -> void: + if role != Role.HOST or match_state == null or match_state.combat == null: + return + var payload: Array = [] + for unit: LuneUnit in match_state.combat.units: + if not unit.is_mobile(): + continue + payload.append({"id": unit.id, "x": unit.position.x, "y": unit.position.y, + "hp": unit.hp, "max": unit.max_hp, "team": unit.team, "alive": unit.alive, "kind": unit.kind}) + rpc("rpc_units", payload) + +# ---------------------------------------------------------------- lobby / disconnect +func seat_peer(slot: int, peer: int, display_name: String) -> void: + if not is_authority() or slot < 0 or slot > 5: + return + # One peer one seat; rejoining replaces the previous peer of that seat. + var previous: int = seat_of_peer(peer) + if previous >= 0 and previous != slot: + return + for other: int in seat_peers: + if int(seat_peers[other]) == peer and other != slot: + return + var taken: int = seat_of_peer(peer) + if taken >= 0 and taken != slot: + return + if seat_peers.has(slot) and int(seat_peers[slot]) != peer and int(seat_peers[slot]) > 1: + return + seat_peers[slot] = peer + var found: bool = false + for entry: Dictionary in lobby: + if int(entry.slot) == slot: + entry.peer = peer + entry.name = display_name + entry.bot = false + entry.ready = false + found = true + if not found: + lobby.append({"peer": peer, "slot": slot, "name": display_name, "ready": false, "bot": false}) + if role == Role.HOST and peer > 1: + rpc_id(peer, "rpc_setup", _public_options(), slot, lobby) + rpc("rpc_lobby", lobby) + lobby_changed.emit() + +func _public_options() -> Dictionary: + return {"seed": match_state.seed_value if match_state != null else 1, "human_slot": local_slot, + "bunds": match_state.players.map(func(p: Dictionary) -> String: return str(p.bund)) if match_state != null else []} + +func _on_peer_connected(peer: int) -> void: + if role != Role.HOST: + return + # Auto-seat: first free slot that is not peer-held. One peer one seat. + for slot: int in range(6): + if not seat_peers.has(slot): + seat_peer(slot, peer, "Gast %d" % peer) + session_message.emit("Gast auf Slot %d." % slot, false) + return + session_message.emit("Kein freier Sitzplatz – Verbindung wird nicht übernommen.", true) + +func _on_peer_disconnected(peer: int) -> void: + var slot: int = seat_of_peer(peer) + if slot < 0: + return + seat_peers.erase(slot) + for entry: Dictionary in lobby: + if int(entry.slot) == slot: + entry.bot = true + entry.peer = 0 + entry.ready = true + session_message.emit("%s hat die Verbindung getrennt – Slot wird vom Bot übernommen." % display_name_for(slot), false) + if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and is_authority(): + LuneBot.plan(match_state, slot) + match_state.set_ready(slot) + _broadcast_lobby() + _broadcast_state() + if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and match_state.all_ready(): + host_begin_combat() diff --git a/scripts/luneraria/net/lune_session.gd.uid b/scripts/luneraria/net/lune_session.gd.uid new file mode 100644 index 0000000..2ded643 --- /dev/null +++ b/scripts/luneraria/net/lune_session.gd.uid @@ -0,0 +1 @@ +uid://bg1iuasofd0c8 diff --git a/scripts/luneraria/ui/lune_menu.gd b/scripts/luneraria/ui/lune_menu.gd index f60a3c8..4ac5ff3 100644 --- a/scripts/luneraria/ui/lune_menu.gd +++ b/scripts/luneraria/ui/lune_menu.gd @@ -2,9 +2,15 @@ extends MainMenu ## The Phase 20A menu shell retitled for Champions of Luneraria. Match options here are ## the new mode's options (own Bund, lane, seed); they never touch the legacy GameState. +signal host_requested(port: int, display_name: String) +signal join_requested(address: String, port: int, display_name: String) + var bund: String = "wurzelbund" var lane: String = "mid" var lane_choice: ChoiceGroup +var _net_name: LineEdit +var _net_address: LineEdit +var _net_port: LineEdit func _ready() -> void: super._ready() @@ -19,7 +25,7 @@ func _ready() -> void: brand_names.add_child(tagline) lore_label.text = "Führe deine Armee über drei Lanes. Die Partie endet erst, wenn ein Ancient fällt." emblem_rect.texture = load("res://assets/ui/emblems/luneraria.svg") - version_label.text = "Prototyp %s · 1 Mensch + 5 Bots" % str(ProjectSettings.get_setting("application/config/version", "")) + version_label.text = "Prototyp %s · 1 Mensch + 5 Bots oder 6 über Direktverbindung" % str(ProjectSettings.get_setting("application/config/version", "")) _title_caption.text = "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft" _retarget_setup() @@ -51,9 +57,41 @@ func _retarget_setup() -> void: var rules := Label.new() rules.theme_type_variation = "MutedLabel" rules.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - rules.text = "Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Kein Rundenlimit: Es gewinnt, wer den gegnerischen Ancient zerstört." + rules.text = "Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Kein Rundenlimit: Es gewinnt, wer den gegnerische Ancient zerstört. Für sechs Menschen hosten oder beitreten; freie Plätze bleiben Bots. Der Host ist autoritativ." body.add_child(rules) body.move_child(rules, at + 2) + var net_heading := Label.new() + net_heading.text = "NETZWERK · DIREKTVERBINDUNG" + net_heading.theme_type_variation = "SectionLabel" + body.add_child(net_heading) + _net_name = LineEdit.new() + _net_name.placeholder_text = "Anzeigename" + _net_name.text = "Spieler" + body.add_child(_net_name) + var net_row := HBoxContainer.new() + _net_address = LineEdit.new() + _net_address.placeholder_text = "Host-Adresse" + _net_address.text = "127.0.0.1" + _net_address.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _net_port = LineEdit.new() + _net_port.placeholder_text = "Port" + _net_port.text = "24571" + _net_port.custom_minimum_size.x = 90 + net_row.add_child(_net_address) + net_row.add_child(_net_port) + body.add_child(net_row) + var buttons := HBoxContainer.new() + var host_btn := Button.new() + host_btn.text = "Hosten" + host_btn.tooltip_text = "Partie eröffnen. Freie Plätze sind Bots." + host_btn.pressed.connect(func() -> void: host_requested.emit(int(_net_port.text), _net_name.text)) + var join_btn := Button.new() + join_btn.text = "Beitreten" + join_btn.tooltip_text = "Mit Host und Port verbinden." + join_btn.pressed.connect(func() -> void: join_requested.emit(_net_address.text.strip_edges(), int(_net_port.text), _net_name.text)) + buttons.add_child(host_btn) + buttons.add_child(join_btn) + body.add_child(buttons) func sync() -> void: super.sync() diff --git a/tests/luneraria/test_lune_net.gd b/tests/luneraria/test_lune_net.gd new file mode 100644 index 0000000..0462274 --- /dev/null +++ b/tests/luneraria/test_lune_net.gd @@ -0,0 +1,146 @@ +extends Node +## Network protocol tests for the authoritative LuneSession (no rendering). +## Covers foreign seats, stale commands, double purchase, bot takeover on disconnect +## and the rule that a disconnect is never a core destruction. +## Run: godot --headless --path . res://tests/luneraria/test_lune_net.tscn +var checks: int = 0 +var failures: Array[String] = [] + +func check(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures.append(label) + printerr("NET_FAIL: ", label) + +func _ready() -> void: + call_deferred("run") + +func new_host(seed: int = 5) -> LuneSession: + var session: LuneSession = LuneSession.new() + session.name = "LuneSession" + add_child(session) + session.host_offline(0, {"seed": seed, "human_slot": 0, + "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]}) + return session + +func run() -> void: + _test_seat_ownership() + _test_stale_command() + _test_double_purchase() + _test_foreign_uid() + _test_disconnect_is_not_core_loss() + _test_six_seats_and_phases() + var report := {"checks": checks, "failures": failures} + var file := FileAccess.open("res://docs/luneraria/logs/net-tests.json", FileAccess.WRITE) + file.store_string(JSON.stringify(report, "\t")) + file.close() + print("LUNE_NET_%s: %d checks; %d failures" % ["PASS" if failures.is_empty() else "FAIL", checks, failures.size()]) + get_tree().quit(0 if failures.is_empty() else 1) + +func _test_seat_ownership() -> void: + var session: LuneSession = new_host() + session.seat_peer(1, 42, "Gast") + check(session.seat_of_peer(42) == 1, "peer is seated on slot 1") + # Peer 42 tries to act as slot 3. + var spoofed: Dictionary = session.host_command(42, 3, {"type": "buy", "type_id": "w01"}) + check(not spoofed.ok, "command from another seat is rejected") + # Unseated peer. + var orphan: Dictionary = session.host_command(99, 0, {"type": "buy", "type_id": "w01"}) + check(not orphan.ok, "command from an unseated peer is rejected") + # The owner may act during planning (bots are pre-ready, so do not ready up yet). + var own: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": session.match_state.base_ids("glutpakt")[0]}) + check(own.ok, "the seat owner may buy") + session.close() + +func _test_stale_command() -> void: + var session: LuneSession = new_host() + session.host_ready(0) + for slot: int in range(1, 6): + session.host_ready(slot) + check(session.match_state.phase == LuneMatch.Phase.COMBAT, "all ready starts combat") + var late: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": "w01"}) + check(not late.ok and "spät" in str(late.message) or "vorbei" in str(late.message), "stale planning command after the phase closed is rejected") + session.close() + +func _test_double_purchase() -> void: + var session: LuneSession = new_host() + var ids: Array[String] = session.match_state.base_ids("glutpakt") + var cost: int = int(DataCatalog.get_unit(ids[0]).cost) + session.match_state.players[0].draft.gold = 1000 + var first: Dictionary = session.host_command(1, 0, {"type": "buy", "type_id": ids[0]}) + var gold_after_first: int = int(session.match_state.players[0].draft.gold) + var roster_after_first: int = int(session.match_state.players[0].draft.roster.size()) + check(first.ok and roster_after_first == 1 and gold_after_first == 1000 - cost, "one purchase spends the catalogue cost exactly once") + # A duplicated packet is another buy command: it may buy a second unit at a cost, + # but it must never hand out a free clone (ownership is the uid list plus gold). + session.host_command(1, 0, {"type": "buy", "type_id": ids[0]}) + var roster_after: int = int(session.match_state.players[0].draft.roster.size()) + var gold_after: int = int(session.match_state.players[0].draft.gold) + check(roster_after == roster_after_first + 1, "a repeated buy creates one more unit, not a free clone") + check(gold_after == gold_after_first - cost, "the repeated buy also pays exactly once") + var uids: Dictionary = {} + for entry: Dictionary in session.match_state.players[0].draft.roster: + uids[str(entry.uid)] = true + check(uids.size() == roster_after, "every owned unit has a unique id") + session.close() + +func _test_foreign_uid() -> void: + var session: LuneSession = new_host() + session.seat_peer(1, 7, "Gast") + var ids: Array[String] = session.match_state.base_ids("glutpakt") + session.match_state.players[0].draft.gold = 500 + session.host_command(1, 0, {"type": "buy", "type_id": ids[0]}) + var uid: String = str(session.match_state.players[0].draft.roster[0].uid) + # Guest tries to upgrade the host's unit. + var steal: Dictionary = session.host_command(7, 1, {"type": "upgrade", "uid": uid}) + check(not steal.ok, "a guest cannot upgrade a foreign unit id") + # Guest tries to act as the host seat. + var hijack: Dictionary = session.host_command(7, 0, {"type": "upgrade", "uid": uid}) + check(not hijack.ok, "a guest cannot act on the host seat") + # Owner may upgrade their own unit. + var ids2: Array[String] = session.match_state.base_ids("glutpakt") + var upgrade_id: String = str(DataCatalog.get_unit(ids[0]).get("upgrade_id", "")) + if not upgrade_id.is_empty(): + session.match_state.players[0].draft.gold = 500 + var ok: Dictionary = session.host_command(1, 0, {"type": "upgrade", "uid": uid}) + check(ok.ok, "the owner may upgrade their own unit") + session.close() + +func _test_disconnect_is_not_core_loss() -> void: + var session: LuneSession = new_host() + session.seat_peer(1, 50, "Gast") + session.seat_peer(2, 51, "Gast2") + var core0: float = session.match_state.core_hp(0) + var core1: float = session.match_state.core_hp(1) + session._on_peer_disconnected(50) + check(session.match_state.core_hp(0) == core0 and session.match_state.core_hp(1) == core1, "disconnect does not damage either Ancient") + check(session.match_state.phase != LuneMatch.Phase.OVER, "disconnect does not end the match") + check(session.match_state.winner == LuneMatch.ONGOING, "disconnect is not a core victory") + check(not session.seat_peers.has(1), "the lost seat is no longer peer-held") + var bot_planned: bool = false + for entry: Dictionary in session.lobby: + if int(entry.slot) == 1: + bot_planned = bool(entry.bot) and bool(entry.ready) + check(bot_planned, "the disconnected seat is taken over by a bot") + session.close() + +func _test_six_seats_and_phases() -> void: + var session: LuneSession = new_host() + check(session.match_state.players.size() == 6, "six seats exist") + var teams: Dictionary = {} + for player: Dictionary in session.match_state.players: + teams[player.team] = int(teams.get(player.team, 0)) + 1 + check(teams.size() == 2 and teams[0] == 3 and teams[1] == 3, "two teams of three") + check(session.match_state.map.buildings.filter(func(d: Dictionary) -> bool: return d.tier != "core").size() == 16, "sixteen towers") + check(session.match_state.map.buildings.filter(func(d: Dictionary) -> bool: return d.tier == "core").size() == 2, "two Ancients") + session.host_ready(0) + for slot: int in range(1, 6): + session.host_ready(slot) + session.plan_bot_seats() + # Bots already planned via host_begin_combat; ensure a full round works offline-style. + if session.match_state.phase == LuneMatch.Phase.PLANNING: + session.host_begin_combat() + check(session.match_state.phase == LuneMatch.Phase.COMBAT, "combat starts when everyone is ready") + session.host_finish_combat() + check(session.match_state.phase == LuneMatch.Phase.PLANNING or session.match_state.phase == LuneMatch.Phase.OVER, "settlement returns to planning or ends") + session.close() diff --git a/tests/luneraria/test_lune_net.gd.uid b/tests/luneraria/test_lune_net.gd.uid new file mode 100644 index 0000000..1af1c95 --- /dev/null +++ b/tests/luneraria/test_lune_net.gd.uid @@ -0,0 +1 @@ +uid://dxbmeyqwoo8ct diff --git a/tests/luneraria/test_lune_net.tscn b/tests/luneraria/test_lune_net.tscn new file mode 100644 index 0000000..90b946c --- /dev/null +++ b/tests/luneraria/test_lune_net.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/luneraria/test_lune_net.gd" id="1"] + +[node name="NetTests" type="Node"] +script = ExtResource("1") diff --git a/tools/luneraria/build_luneraria_demo.py b/tools/luneraria/build_luneraria_demo.py new file mode 100644 index 0000000..b0cdb21 --- /dev/null +++ b/tools/luneraria/build_luneraria_demo.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Build unpackable Champions of Luneraria demos for Linux and Windows. + +Run: python3 tools/luneraria/build_luneraria_demo.py [--check-only] +Output: ../dist/luneraria/ChampionsOfLuneraria-Linux-x64-.zip + ../dist/luneraria/ChampionsOfLuneraria-Windows-x64-.zip +Old Saumwacht releases under ../dist/Saumwacht-*.zip stay untouched. +""" +from pathlib import Path +import argparse, zipfile, subprocess, shutil, sys + +PROJECT = Path(__file__).resolve().parents[2] +DIST = PROJECT.parent / "dist" / "luneraria" +VERSION = "0.16.0-m4" +INTRO = f"""CHAMPIONS OF LUNERARIA · PROTOTYP {VERSION} + +START + Linux: ./ChampionsOfLuneraria.x86_64 + Windows: ChampionsOfLuneraria.exe + Vulkan-Probleme: --rendering-driver opengl3 + +Godot und Blender werden zum Spielen nicht benötigt. Das ganze ZIP zuerst entpacken. + +INHALT + Drei Lanes, zwei Teams à drei Rollen, Partieende nur durch einen zerstörten Ancient. + Planung → automatischer Kampf → Abrechnung. Helden, Items, Angriffschampions und + eine Bossgrotte mit entsandtem Boss. + +NETZWERK (Direktverbindung, experimentell) + Im Setup: Hosten oder Beitreten mit Adresse und Port (Standard 24571). + Der Host ist autoritativ. Freie Plätze werden von Bots gespielt. + Ein Disconnect übernimmt ein Bot; er ist kein Kernverlust. Kein Matchmaking, + keine Hostmigration. Getestet: Protokollchecks und ein lokaler Zwei-Prozess- + Loopback – keine Internetpartie auf sechs Rechnern. + +PLATTFORM + Geprüft: Fedora 44, Forward+ (Quellprojekt). Windows-Export vorhanden; + ein nativer Windows-11-Test steht aus (keine Hardware in dieser Umgebung). + +QUELLEN + https://github.com/ (lokal) · Doku im Quellrepo unter docs/luneraria/ +""" + +PRESETS = [ + ("Luneraria-Linux", "linux", "ChampionsOfLuneraria.x86_64", + "ChampionsOfLuneraria-Linux-x64"), + ("Luneraria-Windows", "windows", "ChampionsOfLuneraria.exe", + "ChampionsOfLuneraria-Windows-x64"), +] + + +def build(preset: str, folder: str, binary: str) -> Path | None: + out_dir = DIST / folder + out_dir.mkdir(parents=True, exist_ok=True) + cmd = ["godot", "--headless", "--path", str(PROJECT), "--export-release", preset, + str(out_dir / binary)] + print("+", " ".join(cmd)) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print(result.stdout[-2000:]) + print(result.stderr[-2000:], file=sys.stderr) + return None + return out_dir / binary + + +def pack(folder: str, binary: str, zip_stem: str) -> Path: + out_dir = DIST / folder + zip_path = DIST / f"{zip_stem}-{VERSION}.zip" + readme = out_dir / "LIESMICH.txt" + readme.write_text(INTRO, encoding="utf-8") + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for path in sorted(out_dir.iterdir()): + if path.suffix == ".zip": + continue + zf.write(path, path.name) + print("PACK", zip_path) + return zip_path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check-only", action="store_true") + args = parser.parse_args() + DIST.mkdir(parents=True, exist_ok=True) + # Fail early on missing templates / manifests instead of shipping a broken zip. + required = [ + PROJECT / "assets-source/phase6/export-templates/linux_release.x86_64", + PROJECT / "assets-source/phase6/export-templates/windows_release_x86_64.exe", + PROJECT / "assets/luneraria/peoples_manifest.json", + PROJECT / "assets/luneraria/map_props_manifest.json", + PROJECT / "scenes/luneraria.tscn", + ] + missing = [str(p) for p in required if not p.exists()] + if missing: + print("MISSING", missing) + return 1 + if args.check_only: + print("CHECK_OK", VERSION) + return 0 + ok = True + for preset, folder, binary, stem in PRESETS: + artifact = build(preset, folder, binary) + if artifact is None or not artifact.exists(): + print("EXPORT_FAIL", preset) + ok = False + continue + pack(folder, binary, stem) + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/luneraria/net_pair.gd b/tools/luneraria/net_pair.gd new file mode 100644 index 0000000..4831a87 --- /dev/null +++ b/tools/luneraria/net_pair.gd @@ -0,0 +1,72 @@ +extends Node +## Two-process loopback smoke test. Launch twice: +## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=host --port=24571 +## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client --port=24571 +## This is a local two-process loopback test, not a six-player internet match. +const Session = preload("res://scripts/luneraria/net/lune_session.gd") +var role: String = "host" +var port: int = 24571 +var address: String = "127.0.0.1" +var session: Node +var steps: int = 0 + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--role="): + role = arg.substr(7) + elif arg.begins_with("--port="): + port = int(arg.substr(7)) + elif arg.begins_with("--address="): + address = arg.substr(10) + session = Session.new() + session.name = "LuneSession" + add_child(session) + session.session_message.connect(func(text: String, is_error: bool) -> void: + print("NET_MSG ", "ERR " if is_error else "OK ", text)) + call_deferred("start") + +func start() -> void: + if role == "host": + var options := {"seed": 42, "human_slot": 0, + "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]} + var err: int = session.host_enet(port, options, "Host") + print("NET_HOST ", "OK" if err == OK else session.last_error) + if err != OK: + get_tree().quit(1) + return + session.lobby_changed.connect(func() -> void: + print("NET_LOBBY ", session.lobby)) + else: + var err: int = session.join_enet(address, port, "Gast") + print("NET_JOIN ", "OK" if err == OK else session.last_error) + if err != OK: + get_tree().quit(1) + return + session.match_resetup.connect(func() -> void: + print("NET_SETUP seat=", session.local_slot, " players=", session.match_state.players.size()) + var spoof: Dictionary = session.host_command(1, 5, {"type": "buy", "type_id": "w01"}) + print("NET_SPOOF ", "REJECTED" if not spoof.ok else "ACCEPTED-BAD") + var ids: Array[String] = session.match_state.base_ids(session.match_state.players[session.local_slot].bund) + var buy: Dictionary = session.submit({"type": "buy", "type_id": ids[0]}) + print("NET_BUY_SENT ", buy.ok) + print("NET_CLIENT_DONE seat=", session.local_slot) + get_tree().quit(0 if (not spoof.ok and buy.ok) else 1), CONNECT_ONE_SHOT) + +func _process(_delta: float) -> void: + steps += 1 + if steps > 1200: + print("NET_TIMEOUT role=", role) + get_tree().quit(2) + return + if role == "host" and session != null and session.match_state != null: + if session.match_state.phase == LuneMatch.Phase.PLANNING and steps == 400: + session.plan_bot_seats() + session.host_ready(0) + if session.match_state.phase == LuneMatch.Phase.COMBAT: + session.broadcast_combat_units() + if steps % 20 == 0: + session.match_state.step_combat() + if steps >= 700: + session.host_finish_combat() + print("NET_HOST_DONE round=", session.match_state.round_number, " phase=", session.match_state.phase) + get_tree().quit(0) diff --git a/tools/luneraria/net_pair.gd.uid b/tools/luneraria/net_pair.gd.uid new file mode 100644 index 0000000..7df1f8e --- /dev/null +++ b/tools/luneraria/net_pair.gd.uid @@ -0,0 +1 @@ +uid://blytblbt7tlcq diff --git a/tools/luneraria/net_pair.tscn b/tools/luneraria/net_pair.tscn new file mode 100644 index 0000000..3ffd4cc --- /dev/null +++ b/tools/luneraria/net_pair.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/luneraria/net_pair.gd" id="1"] + +[node name="NetPair" type="Node"] +script = ExtResource("1")