M4: autoritative Host/Join-Sitzung, Protokolltests, Loopback und Demos

LuneSession hält den autoritativen Matchzustand; Clients senden nur
Absichten. Fremde Sitze, veraltete Befehle und Doppelkäufe sind geprüft,
Disconnect führt zur Bot-Übernahme statt zum Kernverlust. Exporte und
entpackbare Demos 0.16.0-m4 für Linux und Windows.
This commit is contained in:
Martin Stickelbroeck 2026-09-28 00:57:02 +02:00
parent faf0fbdef0
commit 214ed5a1cc
18 changed files with 891 additions and 21 deletions

View file

@ -38,6 +38,7 @@ Voraussetzung ist Godot 4.7.2. Blender wird nur zum Neuerzeugen von Assets gebra
```bash ```bash
godot --headless --path . res://tests/luneraria/test_lune_core.tscn # Regeln M1 (91, inkl. Spiegeltest) 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_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 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 --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 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 ## 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-<ver>.zip` und `…-Windows-x64-<ver>.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 ## Benutzereinstellungen

View file

@ -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. 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. 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/`. 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`.

View file

@ -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` | | 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 | | 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 | | 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 ## 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). 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) ## 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`. 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`.

View file

@ -8,7 +8,7 @@ Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `l
| M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** | | M1 · Spielbarer Drei-Lane-Kern | **abgeschlossen** |
| M2 · Mechanikdurchlauf | **abgeschlossen** | | M2 · Mechanikdurchlauf | **abgeschlossen** |
| M3 · Qualität und Balance | **abgeschlossen** (siehe unten) | | 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 ## 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. - Nativer Windows-11-Test: keine Windows-Hardware.
- Kompatibilitätsmodus (OpenGL) für Luneraria: erst mit M4-Exporten gezielt prüfen. - 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 ## 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.

View file

@ -0,0 +1,4 @@
{
"checks": 24,
"failures": []
}

View file

@ -1,14 +1,14 @@
[preset.0] [preset.0]
name="Linux" name="Luneraria-Linux"
platform="Linux" platform="Linux"
runnable=true runnable=true
advanced_options=false advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" 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/*" 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_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
encrypt_pck=false encrypt_pck=false
@ -28,22 +28,22 @@ texture_format/etc=false
codesign/enable=false codesign/enable=false
application/modify_resources=false application/modify_resources=false
application/company_name="" application/company_name=""
application/product_name="Saumwacht – Die zwei Bünde" application/product_name="Champions of Luneraria"
application/file_description="Saumwacht – Die zwei Bünde Demo" application/file_description="Champions of Luneraria Demo"
application/file_version="0.15.0.0" application/file_version="0.15.0.0"
application/product_version="0.15.0.0" application/product_version="0.15.0.0"
[preset.1] [preset.1]
name="Windows" name="Luneraria-Windows"
platform="Windows Desktop" platform="Windows Desktop"
runnable=true runnable=true
advanced_options=false advanced_options=false
dedicated_server=false dedicated_server=false
custom_features="" custom_features=""
export_filter="all_resources" 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/*" 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_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
encrypt_pck=false encrypt_pck=false
@ -63,7 +63,7 @@ texture_format/etc=false
codesign/enable=false codesign/enable=false
application/modify_resources=false application/modify_resources=false
application/company_name="" application/company_name=""
application/product_name="Saumwacht – Die zwei Bünde" application/product_name="Champions of Luneraria"
application/file_description="Saumwacht – Die zwei Bünde Demo" application/file_description="Champions of Luneraria Demo"
application/file_version="0.15.0.0" application/file_version="0.15.0.0"
application/product_version="0.15.0.0" application/product_version="0.15.0.0"

View file

@ -12,6 +12,7 @@ const Hud = preload("res://scripts/luneraria/ui/lune_hud.gd")
const SPEEDS: Array[int] = [1, 2, 4] const SPEEDS: Array[int] = [1, 2, 4]
var match_state: LuneMatch var match_state: LuneMatch
var session: Node
var world: Node3D var world: Node3D
var camera: Camera3D var camera: Camera3D
var hud: Control var hud: Control
@ -31,8 +32,11 @@ func start(setup_options: Dictionary) -> void:
options = setup_options options = setup_options
name = "LuneGame" name = "LuneGame"
human_slot = int(options.get("human_slot", 1)) human_slot = int(options.get("human_slot", 1))
match_state = LuneMatch.new() if session != null and is_instance_valid(session) and session.match_state != null:
match_state.setup(options) match_state = session.match_state
else:
match_state = LuneMatch.new()
match_state.setup(options)
world = World.new() world = World.new()
add_child(world) add_child(world)
world.build(match_state.map) world.build(match_state.map)
@ -78,7 +82,10 @@ func _feedback(result: Dictionary) -> void:
_rebuild_preview() _rebuild_preview()
func _on_command(command: Dictionary) -> void: 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: func _starter() -> void:
var player: Dictionary = match_state.players[human_slot] var player: Dictionary = match_state.players[human_slot]

View file

@ -1,15 +1,17 @@
extends Node extends Node
## Entry scene of Champions of Luneraria: title menu, match, return, rematch. ## Entry scene of Champions of Luneraria: title menu, match, return, rematch.
## Legacy QA switches (--smoke-test, --faction-test, --expedition-test) open the archived ## Offline is a local host with bot-filled seats. Host/Join uses LuneSession (ENet).
## Saumwacht scene unchanged, so its technical regressions keep running. ## Legacy QA switches open the archived Saumwacht scene unchanged.
const Game = preload("res://scripts/luneraria/lune_game.gd") const Game = preload("res://scripts/luneraria/lune_game.gd")
const Menu = preload("res://scripts/luneraria/ui/lune_menu.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"] const LEGACY_SWITCHES: Array[String] = ["--smoke-test", "--faction-test", "--expedition-test", "--ringborke", "--legacy"]
var menu: MainMenu var menu: MainMenu
var game: Node3D var game: Node3D
var settings: Control var settings: Control
var session: Node
var _layer: CanvasLayer var _layer: CanvasLayer
var _last_options: Dictionary = {} 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.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.quit_requested.connect(func() -> void: Soundscape.quit_game())
menu.settings_requested.connect(toggle_settings) 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() settings = LuneSettings.new()
_layer.add_child(settings) _layer.add_child(settings)
settings.visible = false 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} _last_options = {"seed": int(options.get("seed", 1)), "human_slot": human, "bunds": bunds}
_launch(_last_options) _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: func _launch(options: Dictionary) -> void:
# The rematch button lives inside the old match; detach it now, free it after the signal. # The rematch button lives inside the old match; detach it now, free it after the signal.
if is_instance_valid(game): if is_instance_valid(game):
@ -54,6 +94,8 @@ func _launch(options: Dictionary) -> void:
game = Game.new() game = Game.new()
add_child(game) add_child(game)
game.input_blocked = func() -> bool: return menu.visible or settings.visible 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.start(options)
game.exit_requested.connect(return_to_menu) game.exit_requested.connect(return_to_menu)
game.rematch_requested.connect(func() -> void: game.rematch_requested.connect(func() -> void:
@ -67,6 +109,7 @@ func return_to_menu() -> void:
remove_child(game) remove_child(game)
game.queue_free() game.queue_free()
game = null game = null
_reset_session()
menu._locked = false menu._locked = false
menu.pending_screen = MainMenu.Screen.TITLE menu.pending_screen = MainMenu.Screen.TITLE
menu.enter() menu.enter()

View file

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

View file

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

View file

@ -2,9 +2,15 @@ extends MainMenu
## The Phase 20A menu shell retitled for Champions of Luneraria. Match options here are ## 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. ## 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 bund: String = "wurzelbund"
var lane: String = "mid" var lane: String = "mid"
var lane_choice: ChoiceGroup var lane_choice: ChoiceGroup
var _net_name: LineEdit
var _net_address: LineEdit
var _net_port: LineEdit
func _ready() -> void: func _ready() -> void:
super._ready() super._ready()
@ -19,7 +25,7 @@ func _ready() -> void:
brand_names.add_child(tagline) brand_names.add_child(tagline)
lore_label.text = "Führe deine Armee über drei Lanes. Die Partie endet erst, wenn ein Ancient fällt." 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") 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" _title_caption.text = "Glutpakt und Wurzelbund an den Ufern der Grünen Kluft"
_retarget_setup() _retarget_setup()
@ -51,9 +57,41 @@ func _retarget_setup() -> void:
var rules := Label.new() var rules := Label.new()
rules.theme_type_variation = "MutedLabel" rules.theme_type_variation = "MutedLabel"
rules.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART 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.add_child(rules)
body.move_child(rules, at + 2) 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: func sync() -> void:
super.sync() super.sync()

View file

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

View file

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

View file

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

View file

@ -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-<ver>.zip
../dist/luneraria/ChampionsOfLuneraria-Windows-x64-<ver>.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())

View file

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

View file

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

View file

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