diff --git a/docs/luneraria/ANLEITUNG.md b/docs/luneraria/ANLEITUNG.md index 11aceef..c0692fc 100644 --- a/docs/luneraria/ANLEITUNG.md +++ b/docs/luneraria/ANLEITUNG.md @@ -38,7 +38,8 @@ 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 --headless --path . res://tests/luneraria/test_lune_net.tscn # Netzwerkprotokoll (43) +python3 tools/luneraria/net_rounds.py # Host + 2 Gäste, 5 Runden, Wiedereinstieg 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 @@ -78,11 +79,12 @@ 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. +**Netzwerk (experimentell, pausiert):** Im Setup *Hosten* oder *Beitreten* mit Anzeigename, Adresse und Port (Standard 24571). Der Host ist autoritativ und rechnet den Kampf; Gäste sehen ihn über Momentaufnahmen. Freie Plätze sind Bots, bis jemand beitritt. Tempo, Pause und Revanche bestimmt der Host. „Bereit“ ist verbindlich. Ein Disconnect wird von einem Bot übernommen und ist kein Kernverlust; wer sich mit demselben Anzeigenamen wieder verbindet, bekommt seinen Platz zurück. Getestet sind Protokollchecks und ein lokaler Loopback über fünf Runden mit Wiedereinstieg – keine verifizierte Internetpartie auf sechs Rechnern, kein OpenGL- und kein Windows-11-Test. 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 +godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=host --rounds=4 & +godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client --name=Gast --rounds=4 +# mit Fenster und Gast-Aufnahmen: ohne --headless, Host mit --steps=0, Gast mit --shots= ``` ## Benutzereinstellungen diff --git a/docs/luneraria/ARCHITEKTUR.md b/docs/luneraria/ARCHITEKTUR.md index d0c3f75..9117b26 100644 --- a/docs/luneraria/ARCHITEKTUR.md +++ b/docs/luneraria/ARCHITEKTUR.md @@ -25,4 +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`. +9. **M4-Nachtrag:** `scripts/luneraria/net/lune_session.gd` (Node) kapselt ENet Host/Join. Der Host besitzt `LuneMatch`, rechnet jeden Kampf und sendet je Sitz den öffentlichen Zustand plus nur dessen eigenen Entwurf (`rpc_state`), zum Kampfbeginn die Einheitenliste (`rpc_combat_begin`), danach Momentaufnahmen (`rpc_frame`, 20/s, `PackedFloat32Array`). Der Client baut daraus ein reines Anzeige-Abbild von `LuneCombat`, auf dem nie eine Regel läuft. Clients senden nur Befehle (`rpc_hello` mit Protokollversion, `rpc_submit`, `rpc_ready`). Peer-ID → Sitz, ein Peer ein Sitz. Disconnect: Bot-Übernahme, kein Kernverlust, keine Hostmigration; Wiedereinstieg über den Anzeigenamen. `LuneGame` spricht offline direkt mit `LuneMatch`, im Netzspiel ausschließlich mit der Sitzung. Protokolltests `tests/luneraria/test_lune_net.gd`, Mehr-Runden-Loopback `tools/luneraria/net_pair.tscn` mit Treiber `tools/luneraria/net_rounds.py`. 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 6a7bafd..81a3724 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 | ✅ Protokoll + Loopback (1 Runde) | `test_lune_net` 24/24 · `net_pair` Host/Client 0/0 | +| 12 | Netzwerk: doppelte, veraltete und fremde Befehle; synchroner Phasenwechsel, Disconnect | ✅ Protokoll + Mehr-Runden-Loopback (5 Runden, Wiedereinstieg) | `test_lune_net` 43/43 · `net_rounds.py` 12/12 | ## M3 · Qualität und Balance @@ -62,12 +62,17 @@ Weitere M2-Belege: `test_lune_m2` 73/73, darunter der Nachweis, dass die alte Ar | 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` | +| Zwei-Prozess-Loopback (lokal, nicht Internet) | ✅ 5 Runden, echtes `LuneGame` auf beiden Seiten | `tools/luneraria/net_rounds.py` · `logs/m4/net-rounds.json` | +| Gleicher öffentlicher Zustand nach jeder Abrechnung | ✅ 5/5 Prüfsummen gleich | `net-rounds.json` | +| Client rechnet keinen Kampf, zeigt Host-Momentaufnahmen | ✅ 0 lokale Schritte · 👁 zwei Fenster | `net-rounds.json`, `screenshots/m4/` | +| Nur eigener Planungsentwurf beim Client | ✅ | `test_lune_net` ready_commits_and_privacy | | 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 | +| Mehr-Runden-Phasensync im Zwei-Fenster-Spiel | ✅ headless 5 Runden · 👁 zwei Fenster, 2 Runden Echtzeit | `net-rounds.json`, `screenshots/m4/` | +| Wiedereinstieg als Mensch (neben Bot-Übernahme) | ✅ derselbe Sitz über den Anzeigenamen, auch im Kampf | `test_lune_net` hello_and_rejoin · `net-rounds.json` | +| Kompatibilitätsmodus (OpenGL) | ⏳ offen, M4 pausiert | — | +| Sechs Menschen auf mehreren Rechnern / Internet | ⏳ nicht geprüft | nur lokaler Loopback | ## Legacy-Regressionen (Saumwacht) @@ -78,5 +83,5 @@ Unverändert über die Umleitung der alten QA-Schalter: 209 · 33 · 463 · 206 | Plattform | Stand | | --- | --- | | Fedora 44, Forward+ | ✅ Quellprojekt (Tests, Aufnahmen, Leistung, Balance, Stress) | -| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ M4 (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht) | +| Fedora 44, Kompatibilitätsmodus (OpenGL) | ⏳ offen (für Saumwacht 0.15.0 geprüft, für Luneraria noch nicht; M4 pausiert) | | Windows 11 | ⏳ nicht geprüft (keine Hardware). Ein exportiertes Paket oder ein Wine-Lauf wäre kein nativer Test. | diff --git a/docs/luneraria/STATUS.md b/docs/luneraria/STATUS.md index 8b5e769..8dfc1ba 100644 --- a/docs/luneraria/STATUS.md +++ b/docs/luneraria/STATUS.md @@ -1,6 +1,6 @@ # STATUS · Champions of Luneraria -Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3` · Version im Projekt 0.15.0 (Saumwacht-Demo), Luneraria-Paket erst mit M4. +Stand: 28. September 2026 · Branches `luneraria/m1` (M0/M1), `luneraria/m2`, `luneraria/m3`, `luneraria/m4` · Luneraria-Demo 0.16.0-m4. | Meilenstein | Zustand | | --- | --- | @@ -8,7 +8,8 @@ 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 | **teilweise** (Loopback + Exporte; Windows-Nativtest blockiert) | +| M4 · Netzwerk und Pakete | **pausiert** (Host/Join, Mehr-Runden-Loopback, Wiedereinstieg; offen: OpenGL, Windows 11) | +| G1–G6 · Karte, Grafik, HUD | **als Nächstes** (Auftrag vom 28. September 2026) | ## M1 · abgeschlossen @@ -92,22 +93,37 @@ 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 +## M4 · pausiert + +Auf Entscheidung des Auftraggebers ruht M4 seit dem 28. September 2026 zugunsten der Grafik- und Kartenphasen G1–G6. Während dieser Phasen gibt es keine Netzwerk-Featurearbeit; die Netzwerktests laufen nur als Regression mit. **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. +- `LuneSession` (autoritativ): Der Host besitzt `LuneMatch` und rechnet jeden Kampf. Clients senden nur Absichten (Kauf, Aufwertung, Zuweisung, Rückgängig, Bereit) und zeigen den Kampf aus Host-Momentaufnahmen (20/s, interpoliert). Ein Client rechnet keinen einzigen Kampfschritt. +- Das echte Spiel (`LuneGame`) läuft vollständig über die Sitzung: Bereitschaft, Startaufstellung und Rückgängig gehen an den Host; Bots planen nur freie Sitze und höchstens einmal pro Runde; Tempo und Pause bestimmt der Host. +- „Bereit“ ist verbindlich: Danach nimmt der Host für diesen Sitz keine Planungsbefehle mehr an. +- Privatsphäre: Jeder Sitz erhält nur den eigenen Planungsentwurf, von allen anderen den übernommenen Stand. +- Verbindungsaufbau mit Protokollprüfung (`PROTOCOL` 4). Sitzplätze an Peer-IDs, ein Peer ein Sitz. +- Disconnect → Bot-Übernahme, nie Kernverlust, keine Hostmigration. **Wiedereinstieg:** Wer sich mit demselben Anzeigenamen erneut verbindet, erhält seinen alten Sitz zurück, auch mitten im Kampf (vollständiger Abgleich inkl. laufender Einheiten). Neue Gäste nehmen reservierte Sitze nur als letzte Möglichkeit. +- Übernahme eines Bot-Sitzes in der Planung: Der noch nicht übernommene Bot-Entwurf verfällt, das Gold gehört dem Menschen. +- Revanche startet der Host; Gäste starten automatisch neu. Verbindungsverlust führt den Gast mit Meldung ins Menü. - 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/`. +- Exporte `Luneraria-Linux` und `Luneraria-Windows`, entpackbare Demos 0.16.0-m4 unter `../dist/luneraria/` (Stand vor dem Sitzungsumbau; nicht neu gebaut). -**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). +**Automatisiert geprüft** +- Protokoll `test_lune_net` 43/43: fremder Sitz, fremde UID, veralteter Befehl, Doppelkauf, Disconnect ohne Kernschaden, Protokollversion, Wiedereinstieg auf denselben Sitz, Reservierung gegen neue Gäste, Übernahme mit verfallendem Bot-Entwurf, Bot plant einmal pro Runde, Bereit ist verbindlich, nur eigener Entwurf im Zustand. +- Mehr-Runden-Loopback mit drei Prozessen (`python3 tools/luneraria/net_rounds.py`) 12/12: Host und Gast spielen fünf Runden mit dem echten `LuneGame`; nach jeder Abrechnung ist die Prüfsumme des öffentlichen Zustands gleich; der Gast rechnet 0 Kampfschritte und erhält Momentaufnahmen; Gast A trennt im Kampf der Runde 2, der Bot übernimmt, Gast B kehrt mit demselben Namen auf Slot 1 zurück (`logs/m4/net-rounds.json`). +- Regressionen nach dem Umbau: Regeln 91/91, M2 73/73, echte Anwendung M1 38/38 und M2 14/14 (Partie endete vor einer Bossentsendung). + +**Visuell geprüft:** Host und Gast in zwei Fenstern mit Grafik, Kampf in Echtzeit. Gastsicht in Planung und Kampf von Runde 1 und 2 (`screenshots/m4/`). Runde 1: 872, Runde 2: 730 empfangene Momentaufnahmen, Prüfsummen gleich. + +**Offen (eingefroren)** - Kompatibilitätsmodus (OpenGL) für Luneraria. - Nativer Windows-11-Test (blockiert: keine Hardware). +- Neubau der Demo-Pakete mit der umgebauten Sitzung. +- Partie mit sechs Menschen auf mehreren Rechnern bzw. über das Internet (bisher nur lokaler Loopback). +- Wiedereinstieg ist nur an den Anzeigenamen gebunden (keine Konten); Übertragungsrate bei vielen Gästen über das Internet ist nicht gemessen. - Öffentliches Matchmaking, Accounts, Ranglisten: außerhalb des Umfangs. ## Nächster Schritt -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. +Phasen G1–G6 (Karte, Ancients/Türme/Grotte, HUD, Umgebung, Licht/Material, Einheitendarstellung) nach dem Grafikauftrag vom 28. September 2026. M4 ruht; beim Wiederaufnehmen zuerst OpenGL-Sichtprüfung, Windows-11-Protokoll auf fremder Hardware und Neubau der Demos. diff --git a/docs/luneraria/logs/m1-acceptance.json b/docs/luneraria/logs/m1-acceptance.json index a5fdde6..b4eac04 100644 --- a/docs/luneraria/logs/m1-acceptance.json +++ b/docs/luneraria/logs/m1-acceptance.json @@ -2,9 +2,9 @@ "checks": 38, "failures": [], "object_counts": [ - 3363.0, - 3363.0, - 3363.0, - 3363.0 + 3408.0, + 3404.0, + 3404.0, + 3406.0 ] } \ No newline at end of file diff --git a/docs/luneraria/logs/m4/net-rounds-gast-a.log b/docs/luneraria/logs/m4/net-rounds-gast-a.log new file mode 100644 index 0000000..0d2096e --- /dev/null +++ b/docs/luneraria/logs/m4/net-rounds-gast-a.log @@ -0,0 +1,13 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +NET_JOIN OK +NET_MSG OK Mit dem Host verbunden. +NET_SETUP seat=1 round=1 phase=0 +NET_SPOOF REJECTED +NET_PLAN role=client round=1 seat=1 +NET_MSG OK Korkrücken gekauft. +NET_ROUND role=client round=1 phase=0 sum=1392179697 +NET_CLIENT_COMBAT round=1 frames=32 local_steps=0 +NET_PLAN role=client round=2 seat=1 +NET_MSG OK Samenschleuder gekauft. +NET_CLIENT_DROP round=2 seat=1 diff --git a/docs/luneraria/logs/m4/net-rounds-gast-b.log b/docs/luneraria/logs/m4/net-rounds-gast-b.log new file mode 100644 index 0000000..46d19de --- /dev/null +++ b/docs/luneraria/logs/m4/net-rounds-gast-b.log @@ -0,0 +1,21 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +NET_JOIN OK +NET_MSG OK Mit dem Host verbunden. +NET_SETUP seat=1 round=1 phase=0 +NET_SPOOF REJECTED +NET_ROUND role=client round=2 phase=0 sum=612150297 +NET_CLIENT_COMBAT round=2 frames=8 local_steps=0 +NET_PLAN role=client round=3 seat=1 +NET_MSG OK Sporenkelch gekauft. +NET_ROUND role=client round=3 phase=0 sum=87981900 +NET_CLIENT_COMBAT round=3 frames=3 local_steps=0 +NET_PLAN role=client round=4 seat=1 +NET_MSG OK Tauhüter gekauft. +NET_ROUND role=client round=4 phase=0 sum=2202250974 +NET_CLIENT_COMBAT round=4 frames=12 local_steps=0 +NET_PLAN role=client round=5 seat=1 +NET_MSG OK Dornhopser gekauft. +NET_ROUND role=client round=5 phase=0 sum=1883482295 +NET_CLIENT_COMBAT round=5 frames=49 local_steps=0 +NET_CLIENT_DONE round=6 seat=1 diff --git a/docs/luneraria/logs/m4/net-rounds-host.log b/docs/luneraria/logs/m4/net-rounds-host.log new file mode 100644 index 0000000..f2fc1c7 --- /dev/null +++ b/docs/luneraria/logs/m4/net-rounds-host.log @@ -0,0 +1,23 @@ +Godot Engine v4.7.2.stable.fedora.ed1daf0bf - https://godotengine.org + +NET_HOST OK +NET_MSG OK Gast sitzt auf Slot 1. +NET_PLAN role=host round=1 seat=0 +WARNING: Sending 1718 bytes unreliably which is above the MTU (1392), this will result in higher packet loss + at: put_packet (modules/enet/enet_multiplayer_peer.cpp:365) + GDScript backtrace (most recent call first): + [0] host_tick (res://scripts/luneraria/net/lune_session.gd:291) + [1] _process (res://scripts/luneraria/lune_game.gd:207) +NET_ROUND role=host round=1 phase=0 sum=1392179697 +NET_PLAN role=host round=2 seat=0 +NET_MSG OK Gast hat die Verbindung getrennt – Slot wird vom Bot übernommen. +NET_MSG OK Gast kehrt zurück auf Slot 1. +NET_ROUND role=host round=2 phase=0 sum=612150297 +NET_PLAN role=host round=3 seat=0 +NET_ROUND role=host round=3 phase=0 sum=87981900 +NET_PLAN role=host round=4 seat=0 +NET_ROUND role=host round=4 phase=0 sum=2202250974 +NET_PLAN role=host round=5 seat=0 +NET_ROUND role=host round=5 phase=0 sum=1883482295 +NET_HOST_DONE round=6 seat=0 +NET_MSG OK Gast hat die Verbindung getrennt – Slot wird vom Bot übernommen. diff --git a/docs/luneraria/logs/m4/net-rounds.json b/docs/luneraria/logs/m4/net-rounds.json new file mode 100644 index 0000000..c34aa12 --- /dev/null +++ b/docs/luneraria/logs/m4/net-rounds.json @@ -0,0 +1,75 @@ +{ + "rounds": 5, + "drop_round": 2, + "compared_rounds": [ + 1, + 2, + 3, + 4, + 5 + ], + "mismatches": [], + "host_sums": { + "1": "1392179697", + "2": "612150297", + "3": "87981900", + "4": "2202250974", + "5": "1883482295" + }, + "guest_sums": { + "1": "1392179697", + "2": "612150297", + "3": "87981900", + "4": "2202250974", + "5": "1883482295" + }, + "client_combats": [ + { + "round": 1, + "frames": 32, + "local_steps": 0 + }, + { + "round": 2, + "frames": 8, + "local_steps": 0 + }, + { + "round": 3, + "frames": 3, + "local_steps": 0 + }, + { + "round": 4, + "frames": 12, + "local_steps": 0 + }, + { + "round": 5, + "frames": 49, + "local_steps": 0 + } + ], + "seats": { + "gast_a": [ + 1 + ], + "gast_b": [ + 1 + ] + }, + "checks": { + "host_ok": true, + "guest_a_ok": true, + "guest_b_ok": true, + "spoof_rejected": true, + "rounds_compared": true, + "checksums_equal": true, + "client_never_simulates": true, + "client_receives_frames": true, + "rejoin_same_seat": true, + "bot_took_over": true, + "rejoin_announced": true, + "no_script_errors": true + } +} \ No newline at end of file diff --git a/docs/luneraria/logs/net-tests.json b/docs/luneraria/logs/net-tests.json index 3b6ee71..d78e114 100644 --- a/docs/luneraria/logs/net-tests.json +++ b/docs/luneraria/logs/net-tests.json @@ -1,4 +1,4 @@ { - "checks": 24, + "checks": 43, "failures": [] } \ No newline at end of file diff --git a/docs/luneraria/screenshots/m4/net-guest-combat-r1.png b/docs/luneraria/screenshots/m4/net-guest-combat-r1.png new file mode 100644 index 0000000..e12a6db Binary files /dev/null and b/docs/luneraria/screenshots/m4/net-guest-combat-r1.png differ diff --git a/docs/luneraria/screenshots/m4/net-guest-combat-r2.png b/docs/luneraria/screenshots/m4/net-guest-combat-r2.png new file mode 100644 index 0000000..ca1cdf7 Binary files /dev/null and b/docs/luneraria/screenshots/m4/net-guest-combat-r2.png differ diff --git a/docs/luneraria/screenshots/m4/net-guest-planning-r1.png b/docs/luneraria/screenshots/m4/net-guest-planning-r1.png new file mode 100644 index 0000000..4afba93 Binary files /dev/null and b/docs/luneraria/screenshots/m4/net-guest-planning-r1.png differ diff --git a/docs/luneraria/screenshots/m4/net-guest-planning-r2.png b/docs/luneraria/screenshots/m4/net-guest-planning-r2.png new file mode 100644 index 0000000..1fadc82 Binary files /dev/null and b/docs/luneraria/screenshots/m4/net-guest-planning-r2.png differ diff --git a/scripts/luneraria/lune_game.gd b/scripts/luneraria/lune_game.gd index e8b6d49..283e138 100644 --- a/scripts/luneraria/lune_game.gd +++ b/scripts/luneraria/lune_game.gd @@ -1,6 +1,8 @@ extends Node3D ## One Champions of Luneraria match on screen: authoritative LuneMatch, the 3D world, ## unit views and the HUD. Offline, planning waits for the human; pause and speed are local. +## With a LuneSession every command, ready signal and undo goes through the session. The +## host simulates and streams snapshots; a client only displays the host's combat. signal exit_requested signal rematch_requested @@ -27,6 +29,7 @@ var _last_phase: int = -1 var _preview: LuneCombat var _time: float = 0.0 var _lane_cycle: int = 0 +var _bound_combat: LuneCombat func start(setup_options: Dictionary) -> void: options = setup_options @@ -53,7 +56,7 @@ func start(setup_options: Dictionary) -> void: hud.human_slot = maxi(0, human_slot) layer.add_child(hud) hud.command_requested.connect(_on_command) - hud.undo_requested.connect(func() -> void: _feedback(match_state.undo(human_slot))) + hud.undo_requested.connect(func() -> void: _feedback(_send({"type": "undo"}) if _net() else match_state.undo(human_slot))) hud.ready_requested.connect(_on_ready) hud.starter_requested.connect(_starter) hud.speed_requested.connect(func(_v: int) -> void: _cycle_speed()) @@ -62,11 +65,25 @@ func start(setup_options: Dictionary) -> void: hud.map_focus_requested.connect(func(point: Vector3) -> void: camera.look_at_point(point)) hud.menu_requested.connect(func() -> void: exit_requested.emit()) hud.rematch_requested.connect(func() -> void: rematch_requested.emit()) + if _net(): + session.session_message.connect(func(text: String, is_error: bool) -> void: hud.notify(text, is_error)) + session.state_received.connect(_rebuild_preview) if human_slot >= 0: focus_lane(match_state.players[human_slot].lane, true) else: camera.overview(true) - _enter_planning() + if match_state.phase == LuneMatch.Phase.PLANNING: + _enter_planning() + +## True when a network session (host or client) owns the match. +func _net() -> bool: + return session != null and is_instance_valid(session) and session.is_networked() + +func _is_client() -> bool: + return _net() and not session.is_authority() + +func _send(command: Dictionary) -> Dictionary: + return session.submit(command) if _net() else match_state.submit(human_slot, command) func focus_lane(lane: String, instant: bool = false) -> void: var team: int = int(match_state.players[maxi(0, human_slot)].team) @@ -82,16 +99,13 @@ func _feedback(result: Dictionary) -> void: _rebuild_preview() func _on_command(command: Dictionary) -> void: - if session != null and is_instance_valid(session): - _feedback(session.submit(command)) - else: - _feedback(match_state.submit(human_slot, command)) + _feedback(_send(command)) func _starter() -> void: var player: Dictionary = match_state.players[human_slot] var ids: Array[String] = match_state.base_ids(player.bund) for index: int in [1, 0, 2]: - var result: Dictionary = match_state.submit(human_slot, {"type": "buy", "type_id": ids[index]}) + var result: Dictionary = _send({"type": "buy", "type_id": ids[index]}) if not result.ok: _feedback(result) return @@ -100,23 +114,30 @@ func _starter() -> void: func _on_ready() -> void: if match_state.phase != LuneMatch.Phase.PLANNING: return + if _net(): + session.ready_up() + return if human_slot >= 0: match_state.set_ready(human_slot) if match_state.all_ready(): - _start_combat() + match_state.begin_combat() func _enter_planning() -> void: - for slot: int in range(6): - if slot != human_slot: - LuneBot.plan(match_state, slot) + if _net(): + session.plan_bot_seats() + else: + for slot: int in range(6): + if slot != human_slot: + LuneBot.plan(match_state, slot) _rebuild_preview() - if human_slot < 0: - _start_combat() + if human_slot < 0 and not _net(): + match_state.begin_combat() -func _start_combat() -> void: +## Combat views bind to the combat object of this phase (on a client: the host's mirror). +func _enter_combat() -> void: _clear_views() _preview = null - match_state.begin_combat() + _bound_combat = match_state.combat _accumulator = 0.0 Soundscape.play("wave") @@ -159,10 +180,16 @@ func _add_view(unit: LuneUnit) -> void: _views[unit.id] = view func _cycle_speed() -> void: + if _is_client(): + hud.notify("Tempo und Pause bestimmt der Host.", true) + return speed_index = (speed_index + 1) % SPEEDS.size() hud.set_speed_label(SPEEDS[speed_index], paused) func _toggle_pause() -> void: + if _is_client(): + hud.notify("Tempo und Pause bestimmt der Host.", true) + return if match_state.phase == LuneMatch.Phase.COMBAT: paused = not paused hud.set_speed_label(SPEEDS[speed_index], paused) @@ -170,21 +197,24 @@ func _toggle_pause() -> void: func _process(delta: float) -> void: _time += delta var step: float = match_state.balance.sim_step - if match_state.phase == LuneMatch.Phase.COMBAT and not paused: + _track_phase() + if match_state.phase == LuneMatch.Phase.COMBAT and not paused and not _is_client(): _accumulator += minf(delta, .1) * SPEEDS[speed_index] while _accumulator >= step and match_state.phase == LuneMatch.Phase.COMBAT: match_state.step_combat() _accumulator -= step + if _net(): + session.host_tick(delta) + _track_phase() if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + if match_state.combat != _bound_combat: + _enter_combat() for unit: LuneUnit in match_state.combat.units: if unit.is_mobile() and not _views.has(unit.id): _add_view(unit) - if match_state.phase != _last_phase: - var previous: int = _last_phase - _last_phase = match_state.phase - if previous == LuneMatch.Phase.COMBAT: - _after_combat() - var alpha: float = _accumulator / step if match_state.phase == LuneMatch.Phase.COMBAT else 1.0 + var alpha: float = 1.0 + if match_state.phase == LuneMatch.Phase.COMBAT: + alpha = session.frame_alpha() if _is_client() else _accumulator / step var sim_delta: float = 0.0 if paused else delta * SPEEDS[speed_index] for view: Node3D in _views.values(): view.sync(alpha, sim_delta if match_state.phase == LuneMatch.Phase.COMBAT else delta) @@ -201,6 +231,19 @@ func _process(delta: float) -> void: hud.minimap.camera_focus = camera.focus hud.minimap.camera_span = camera.zoom +## Reacts to phase changes, whether they come from the local simulation or the host. +func _track_phase() -> void: + if match_state.phase == _last_phase: + return + var previous: int = _last_phase + _last_phase = match_state.phase + if match_state.phase == LuneMatch.Phase.COMBAT: + _enter_combat() + elif previous == LuneMatch.Phase.COMBAT: + if _net() and not _is_client(): + session.host_after_combat() + _after_combat() + func _after_combat() -> void: if match_state.phase == LuneMatch.Phase.OVER: paused = false diff --git a/scripts/luneraria/lune_main.gd b/scripts/luneraria/lune_main.gd index 5344367..9b6bcbf 100644 --- a/scripts/luneraria/lune_main.gd +++ b/scripts/luneraria/lune_main.gd @@ -65,6 +65,10 @@ func _host_match(port: int, display_name: String) -> void: if err == OK: _last_options = options _launch(_last_options) + else: + push_warning(session.last_error) + menu.notify_error(session.last_error) + _reset_session() func _join_match(address: String, port: int, display_name: String) -> void: _reset_session() @@ -73,12 +77,19 @@ func _join_match(address: String, port: int, display_name: String) -> void: add_child(session) session.session_message.connect(func(text: String, is_error: bool) -> void: print(("FEHLER: " if is_error else "") + text)) + session.host_lost.connect(func() -> void: + var reason: String = session.last_error if session != null else "" + return_to_menu() + menu.notify_error(reason)) if session.join_enet(address, port, display_name) != OK: + menu.notify_error(session.last_error) + _reset_session() return + # Every setup from the host (first join, rejoin, rematch) relaunches the match view. session.match_resetup.connect(func() -> void: - if game == null and session != null and session.match_state != null: + if 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) + _launch(_last_options)) func _reset_session() -> void: if session != null and is_instance_valid(session): @@ -98,12 +109,19 @@ func _launch(options: Dictionary) -> void: game.session = session game.start(options) game.exit_requested.connect(return_to_menu) - game.rematch_requested.connect(func() -> void: - _last_options.seed = int(_last_options.seed) + 1 - _launch(_last_options)) + game.rematch_requested.connect(_rematch) game.hud.settings_requested.connect(toggle_settings) menu.leave() +func _rematch() -> void: + _last_options.seed = int(_last_options.seed) + 1 + if session != null and is_instance_valid(session) and session.is_networked(): + if not session.is_authority(): + game.hud.notify("Eine Revanche startet der Host.", true) + return + session.host_rematch(_last_options.duplicate(true)) + _launch(_last_options) + func return_to_menu() -> void: if is_instance_valid(game): remove_child(game) @@ -116,7 +134,9 @@ func return_to_menu() -> void: func toggle_settings() -> void: settings.visible = not settings.visible - if is_instance_valid(game): + # Only an offline match pauses behind the settings; a network match keeps running. + var networked: bool = session != null and is_instance_valid(session) and session.is_networked() + if is_instance_valid(game) and not networked: game.paused = settings.visible or game.paused if settings.visible: settings.sync() diff --git a/scripts/luneraria/net/lune_session.gd b/scripts/luneraria/net/lune_session.gd index ba4274d..95de49d 100644 --- a/scripts/luneraria/net/lune_session.gd +++ b/scripts/luneraria/net/lune_session.gd @@ -2,36 +2,61 @@ 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 host owns LuneMatch and every simulation result. Clients send intents only and +## show the host's combat through snapshots; they never simulate a combat themselves. ## 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. +## Rejoin: a client that says hello with the name of a departed seat gets that seat back +## while it is still bot-held (no accounts, so the display name is the only key). ## 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 +const PROTOCOL: int = 4 +## Seconds between combat snapshots the host sends (clients interpolate in between). +const FRAME_INTERVAL: float = 0.05 +## id, x, y, facing, hp, max_hp, flags, attack_progress, hit_count, attack_serial, ability_serial, hit_flash, shield +const FRAME_STRIDE: int = 13 enum Role { OFFLINE, HOST, CLIENT } var role: int = Role.OFFLINE var match_state: LuneMatch var local_slot: int = -1 +var display_name: String = "" ## 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] = [] +## display name -> slot of a peer that left; used for rejoining. +var departed: Dictionary = {} var last_error: String = "" var connected: bool = false +## Client-side counters (tests and diagnostics). +var frames_received: int = 0 +var states_received: int = 0 +var _sent_unit_id: int = 0 +var _frame_clock: float = 0.0 +var _frame_serial: int = 0 +var _last_frame_serial: int = -1 +var _last_frame_msec: int = 0 +var _mirror: Dictionary = {} +## slot -> round a bot already planned; a bot seat plans exactly once per planning phase. +var _bot_planned: Dictionary = {} 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) +signal state_received +signal host_lost func is_authority() -> bool: return role != Role.CLIENT +func is_networked() -> bool: + return role != Role.OFFLINE + 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) @@ -47,9 +72,30 @@ func seat_of_peer(peer: int) -> int: func setup_match(options: Dictionary) -> void: match_state = LuneMatch.new() match_state.setup(options) + _mirror.clear() + _bot_planned.clear() + if is_authority(): + _apply_seats() match_resetup.emit() _announce_phase() +## Seated peers are humans: they must ready up themselves and carry their lobby name. +func _apply_seats() -> void: + for player: Dictionary in match_state.players: + var slot: int = int(player.slot) + var human: bool = seat_peers.has(slot) + player.is_human = human + if match_state.phase == LuneMatch.Phase.PLANNING: + player.ready = not human + if human and int(seat_peers[slot]) != 1: + player.name = _lobby_name(slot, str(player.name)) + +func _lobby_name(slot: int, fallback: String) -> String: + for entry: Dictionary in lobby: + if int(entry.slot) == slot and not bool(entry.bot): + return str(entry.name) + return fallback + func _announce_phase() -> void: if match_state == null: return @@ -70,7 +116,7 @@ func host_offline(human_slot: int, options: Dictionary) -> void: options["human_slot"] = human_slot setup_match(options) -func host_enet(port: int, options: Dictionary, display_name: String = "Host") -> Error: +func host_enet(port: int, options: Dictionary, host_name: String = "Host") -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_server(port, MAX_CLIENTS) if err != OK: @@ -79,16 +125,21 @@ func host_enet(port: int, options: Dictionary, display_name: String = "Host") -> _bind_peer(peer) role = Role.HOST connected = true + display_name = host_name 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}] + departed.clear() + lobby = [{"peer": 1, "slot": human, "name": host_name, "ready": false, "bot": false}] + for slot: int in range(6): + if slot != human: + lobby.append({"peer": 0, "slot": slot, "name": "Bot", "ready": true, "bot": true}) options["human_slot"] = human setup_match(options) lobby_changed.emit() return OK -func join_enet(address: String, port: int, display_name: String = "Gast") -> Error: +func join_enet(address: String, port: int, guest_name: String = "Gast") -> Error: var peer := ENetMultiplayerPeer.new() var err := peer.create_client(address, port) if err != OK: @@ -98,6 +149,7 @@ func join_enet(address: String, port: int, display_name: String = "Gast") -> Err role = Role.CLIENT connected = false local_slot = -1 + display_name = guest_name 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): @@ -109,16 +161,19 @@ func join_enet(address: String, port: int, display_name: String = "Gast") -> Err func _on_connected() -> void: connected = true session_message.emit("Mit dem Host verbunden.", false) + rpc_id(1, "rpc_hello", PROTOCOL, display_name) func _on_connection_failed() -> void: last_error = "Verbindung zum Host fehlgeschlagen." connected = false session_message.emit(last_error, true) + host_lost.emit() func _on_server_lost() -> void: last_error = "Host hat die Verbindung getrennt." connected = false session_message.emit(last_error, true) + host_lost.emit() func _bind_peer(peer: MultiplayerPeer) -> void: if multiplayer.multiplayer_peer != null: @@ -126,8 +181,6 @@ func _bind_peer(peer: MultiplayerPeer) -> void: 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: @@ -137,6 +190,7 @@ func close() -> void: connected = false seat_peers.clear() lobby.clear() + departed.clear() # ---------------------------------------------------------------- intents func submit(command: Dictionary) -> Dictionary: @@ -146,8 +200,8 @@ func submit(command: Dictionary) -> Dictionary: 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) + return {"ok": true, "message": "Befehl gesendet.", "pending": true} + return host_command(1, local_slot, command) func ready_up() -> void: if match_state == null: @@ -159,24 +213,28 @@ func ready_up() -> void: ## 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. +## A seat that reported ready has committed its plan; later commands are refused. 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 + return _answer(peer, slot, {"ok": false, "message": "Fremder Sitz oder ungültige Einheiten-ID."}) 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 _answer(peer, slot, {"ok": false, "message": "Veralteter Befehl (Planung vorbei)."}) + if bool(match_state.players[slot].ready): + return _answer(peer, slot, {"ok": false, "message": "Bereits bereit gemeldet – die Planung ist verbindlich."}) + var result: Dictionary + if str(command.get("type", "")) == "undo": + result = match_state.undo(slot) + else: + result = match_state.submit(slot, command) + _answer(peer, slot, result) + _broadcast_state() + return result + +func _answer(peer: int, slot: int, result: Dictionary) -> Dictionary: + if role == Role.HOST and peer > 1: + rpc_id(peer, "rpc_feedback", slot, result) return result func host_ready(slot: int) -> void: @@ -189,33 +247,116 @@ func host_ready(slot: int) -> void: _broadcast_lobby() if match_state.all_ready(): host_begin_combat() + else: + _broadcast_state() func plan_bot_seats() -> void: - if not is_authority() or match_state == null: + if not is_authority() or match_state == null or match_state.phase != LuneMatch.Phase.PLANNING: return for slot: int in range(6): if not seat_peers.has(slot): - LuneBot.plan(match_state, slot) + _plan_bot(slot) + +func _plan_bot(slot: int) -> void: + if int(_bot_planned.get(slot, -1)) == match_state.round_number: + match_state.set_ready(slot) + return + _bot_planned[slot] = match_state.round_number + 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() + _sent_unit_id = 0 + _frame_clock = 0.0 _announce_phase() if role == Role.HOST: - rpc("rpc_phase", match_state.phase, match_state.round_number) + _broadcast_state() + rpc("rpc_combat_begin", match_state.round_number, _new_unit_records(), _building_hp_array()) + +## Host: call once per rendered frame while combat runs; sends throttled snapshots. +func host_tick(delta: float) -> void: + if role != Role.HOST or match_state == null or match_state.phase != LuneMatch.Phase.COMBAT: + return + _frame_clock += delta + if _frame_clock < FRAME_INTERVAL: + return + _frame_clock = 0.0 + var spawned: Array = _new_unit_records() + if not spawned.is_empty(): + rpc("rpc_units_spawned", spawned) + _frame_serial += 1 + rpc("rpc_frame", _frame_serial, match_state.combat.elapsed, _unit_frame(), _building_hp_array()) + +## Host: call after the settlement (phase left COMBAT) so clients follow in the same step. +func host_after_combat() -> void: + if not is_authority() or match_state == null: + return + _announce_phase() + _broadcast_state() 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() + host_after_combat() + +## Host rematch: a new match with the same seats; clients relaunch on rpc_setup. +func host_rematch(options: Dictionary) -> void: + if not is_authority(): + return + options["human_slot"] = local_slot + for entry: Dictionary in lobby: + entry.ready = bool(entry.bot) + setup_match(options) + if role == Role.HOST: + for slot: int in seat_peers: + var peer: int = int(seat_peers[slot]) + if peer > 1: + _send_full_sync(peer, slot) + _broadcast_lobby() + +# ---------------------------------------------------------------- rpc (client -> host) +@rpc("any_peer", "call_remote", "reliable") +func rpc_hello(protocol: int, guest_name: String) -> void: + if role != Role.HOST: + return + host_hello(multiplayer.get_remote_sender_id(), protocol, guest_name) + +## Testable host entry for a new connection: protocol check, rejoin or first free seat. +func host_hello(peer: int, protocol: int, guest_name: String) -> int: + if protocol != PROTOCOL: + _refuse(peer, "Protokoll %d passt nicht zum Host (%d). Bitte dieselbe Version nutzen." % [protocol, PROTOCOL]) + return -1 + if seat_of_peer(peer) >= 0: + return seat_of_peer(peer) + var clean: String = guest_name.strip_edges().left(24) + if clean.is_empty(): + clean = "Gast %d" % peer + var slot: int = -1 + var rejoin: bool = departed.has(clean) and not seat_peers.has(int(departed[clean])) + if rejoin: + slot = int(departed[clean]) + else: + # New guests prefer seats nobody may return to; a departed seat is the last resort. + var free: Array = range(6).filter(func(c: int) -> bool: return not seat_peers.has(c)) + var open: Array = free.filter(func(c: int) -> bool: return not departed.values().has(c)) + slot = open[0] if not open.is_empty() else (free[0] if not free.is_empty() else -1) + if slot < 0: + _refuse(peer, "Kein freier Sitzplatz.") + session_message.emit("Kein freier Sitzplatz für %s." % clean, true) + return -1 + departed.erase(clean) + seat_peer(slot, peer, clean) + session_message.emit("%s %s Slot %d." % [clean, "kehrt zurück auf" if rejoin else "sitzt auf", slot], false) + return slot + +func _refuse(peer: int, reason: String) -> void: + if role == Role.HOST: + rpc_id(peer, "rpc_refused", reason) -# ---------------------------------------------------------------- rpc @rpc("any_peer", "call_remote", "reliable") func rpc_submit(slot: int, command: Dictionary) -> void: if not is_authority(): @@ -231,18 +372,17 @@ func rpc_ready(slot: int) -> void: return host_ready(slot) +# ---------------------------------------------------------------- rpc (host -> client) +@rpc("authority", "call_remote", "reliable") +func rpc_refused(reason: String) -> void: + last_error = reason + session_message.emit(reason, true) + host_lost.emit() + @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 @@ -250,34 +390,57 @@ func rpc_setup(options: Dictionary, seat: int, roster: Array) -> void: seat_peers.clear() for entry: Dictionary in roster: lobby.append(entry.duplicate()) - seat_peers[int(entry.slot)] = int(entry.peer) + if not bool(entry.get("bot", false)): + seat_peers[int(entry.slot)] = int(entry.peer) + options["human_slot"] = seat setup_match(options) connected = true lobby_changed.emit() +## Client: applies the host's public state. The combat phase itself only starts with +## rpc_combat_begin, so a state message can never bind views to a stale combat. @rpc("authority", "call_remote", "reliable") -func rpc_state(drafts: Array, building_hp: Dictionary, boss: Dictionary, phase: int, round_number: int) -> void: +func rpc_state(state: Dictionary) -> 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 previous_phase: int = match_state.phase + match_state.round_number = int(state.round) + match_state.winner = int(state.winner) + match_state.end_note = str(state.end_note) + match_state.building_hp = (state.building_hp as Dictionary).duplicate() + match_state.boss = (state.boss as Dictionary).duplicate(true) + match_state.last_settlement = (state.last_settlement as Dictionary).duplicate(true) + for row: Dictionary in state.players: 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.name = "Du" if slot == local_slot else str(row.name) + player.is_human = bool(row.is_human) 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.earned = int(row.earned) + player.spent = int(row.spent) + player.next_seq = int(row.next_seq) + player.roster = (row.roster as Array).duplicate(true) + player.hero = (row.hero as Dictionary).duplicate(true) player.bookings = (row.bookings as Array).duplicate(true) + # Only the own seat receives its private draft; the others show committed state. + player.draft = (row.draft as Dictionary).duplicate(true) if row.has("draft") else match_state._snapshot(player) + var undo: Array = [] + undo.resize(int(row.get("undo", 0))) + player.undo = undo + var phase: int = int(state.phase) + if phase != LuneMatch.Phase.COMBAT: + if match_state.combat != null: + match_state.combat.destroyed_cores.assign(state.destroyed_cores) + match_state.phase = phase for entry: Dictionary in lobby: - for row: Dictionary in drafts: - if int(row.slot) == int(entry.slot): - entry.ready = bool(row.ready) + entry.ready = bool(match_state.players[int(entry.slot)].ready) + states_received += 1 + if previous_phase != match_state.phase: + _announce_phase() + state_received.emit() lobby_changed.emit() @rpc("authority", "call_remote", "reliable") @@ -287,11 +450,171 @@ func rpc_lobby(entries: Array) -> void: lobby.append(entry.duplicate()) lobby_changed.emit() -@rpc("authority", "call_remote", "unreliable_ordered") -func rpc_units(payload: Array) -> void: - combat_snapshot.emit(payload) +## Client: builds a display mirror of the host's combat. Rules never run on it. +@rpc("authority", "call_remote", "reliable") +func rpc_combat_begin(round_number: int, records: Array, building_hp: PackedFloat32Array) -> void: + if match_state == null: + return + var mirror := LuneCombat.new(match_state.map, match_state.balance, round_number) + for index: int in range(match_state.map.buildings.size()): + var hp: float = building_hp[index] if index < building_hp.size() else 0.0 + if hp > 0.0: + mirror.add_building(match_state.map.buildings[index], hp) + _mirror.clear() + match_state.combat = mirror + match_state.round_number = round_number + _add_mirror_units(records) + _last_frame_msec = Time.get_ticks_msec() + match_state.phase = LuneMatch.Phase.COMBAT + _announce_phase() + +@rpc("authority", "call_remote", "reliable") +func rpc_units_spawned(records: Array) -> void: + if match_state != null and match_state.combat != null and match_state.phase == LuneMatch.Phase.COMBAT: + _add_mirror_units(records) + +## Frame serials rise across rounds, so a late frame of the previous combat is ignored. +@rpc("authority", "call_remote", "unreliable_ordered") +func rpc_frame(serial: int, elapsed: float, data: PackedFloat32Array, building_hp: PackedFloat32Array) -> void: + if match_state == null or match_state.combat == null or match_state.phase != LuneMatch.Phase.COMBAT or serial <= _last_frame_serial: + return + _last_frame_serial = serial + _last_frame_msec = Time.get_ticks_msec() + frames_received += 1 + var combat: LuneCombat = match_state.combat + combat.elapsed = elapsed + for base: int in range(0, data.size() - FRAME_STRIDE + 1, FRAME_STRIDE): + var unit: LuneUnit = _mirror.get(int(data[base])) + if unit == null: + continue + unit.previous_position = unit.position + unit.position = Vector2(data[base + 1], data[base + 2]) + unit.facing = data[base + 3] + unit.hp = data[base + 4] + unit.max_hp = data[base + 5] + var flags: int = int(data[base + 6]) + if unit.alive and flags & 1 == 0: + unit.death_time = elapsed + unit.alive = flags & 1 != 0 + unit.in_range = flags & 2 != 0 + unit.attack_progress = data[base + 7] + unit.hit_count = int(data[base + 8]) + unit.attack_serial = int(data[base + 9]) + unit.ability_serial = int(data[base + 10]) + unit.hit_flash = data[base + 11] + unit.shield = data[base + 12] + for index: int in range(mini(building_hp.size(), match_state.map.buildings.size())): + var id: String = str(match_state.map.buildings[index].id) + if combat.buildings.has(id): + var building: LuneUnit = combat.buildings[id] + building.hp = building_hp[index] + building.alive = building.hp > 0.0 + +## Client interpolation factor between the last two snapshots. +func frame_alpha() -> float: + return clampf(float(Time.get_ticks_msec() - _last_frame_msec) / (FRAME_INTERVAL * 1000.0), 0.0, 1.0) + +func _add_mirror_units(records: Array) -> void: + for record: Dictionary in records: + var id: int = int(record.id) + if _mirror.has(id): + continue + var unit := LuneUnit.new() + unit.id = id + unit.kind = str(record.kind) + unit.team = int(record.team) + unit.team_index = int(record.team_index) + unit.owner_slot = int(record.owner_slot) + unit.persistent_uid = str(record.uid) + unit.lane = str(record.lane) + unit.zone = str(record.zone) + unit.def = record.def + unit.position = Vector2(float(record.x), float(record.y)) + unit.previous_position = unit.position + unit.facing = float(record.facing) + unit.hp = float(record.hp) + unit.max_hp = float(record.max_hp) + unit.alive = bool(record.alive) + match_state.combat.units.append(unit) + _mirror[id] = unit + +# ---------------------------------------------------------------- host serialisation +func _new_unit_records() -> Array: + var records: Array = [] + var highest: int = _sent_unit_id + for unit: LuneUnit in match_state.combat.units: + if not unit.is_mobile() or unit.id <= _sent_unit_id: + continue + highest = maxi(highest, unit.id) + records.append(_unit_record(unit)) + _sent_unit_id = highest + return records + +func _all_unit_records() -> Array: + var records: Array = [] + for unit: LuneUnit in match_state.combat.units: + if unit.is_mobile(): + records.append(_unit_record(unit)) + return records + +func _unit_record(unit: LuneUnit) -> Dictionary: + return {"id": unit.id, "kind": unit.kind, "team": unit.team, "team_index": unit.team_index, "owner_slot": unit.owner_slot, + "uid": unit.persistent_uid, "lane": unit.lane, "zone": unit.zone, "def": _plain(unit.def), + "x": unit.position.x, "y": unit.position.y, "facing": unit.facing, "hp": unit.hp, "max_hp": unit.max_hp, "alive": unit.alive} + +## Keeps only plain data so a definition can cross the wire (objects are never sent). +static func _plain(value: Variant) -> Variant: + match typeof(value): + TYPE_DICTIONARY: + var result: Dictionary = {} + for key: Variant in value: + if typeof(value[key]) != TYPE_OBJECT: + result[key] = _plain(value[key]) + return result + TYPE_ARRAY: + var items: Array = [] + for item: Variant in value: + if typeof(item) != TYPE_OBJECT: + items.append(_plain(item)) + return items + return value + +func _unit_frame() -> PackedFloat32Array: + var data := PackedFloat32Array() + for unit: LuneUnit in match_state.combat.units: + if not unit.is_mobile(): + continue + data.append_array([unit.id, unit.position.x, unit.position.y, unit.facing, unit.hp, unit.max_hp, + (1 if unit.alive else 0) | (2 if unit.in_range else 0), unit.attack_progress, unit.hit_count, + unit.attack_serial, unit.ability_serial, unit.hit_flash, unit.shield]) + return data + +func _building_hp_array() -> PackedFloat32Array: + var data := PackedFloat32Array() + for definition: Dictionary in match_state.map.buildings: + var hp: float = float(match_state.building_hp.get(definition.id, 0.0)) + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null and match_state.combat.buildings.has(definition.id): + var building: LuneUnit = match_state.combat.buildings[definition.id] + hp = building.hp if building.alive else 0.0 + data.append(hp) + return data + +## Public state for one seat: every committed value, plus the private draft of that seat only. +func state_for(slot: int) -> Dictionary: + var rows: Array = [] + for player: Dictionary in match_state.players: + var seat: int = int(player.slot) + var row := {"slot": seat, "name": _lobby_name(seat, str(player.name)) if seat_peers.has(seat) else str(player.name), + "is_human": player.is_human, "gold": player.gold, "ready": player.ready, "earned": player.earned, "spent": player.spent, + "next_seq": player.next_seq, "roster": player.roster, "hero": player.hero, "bookings": player.bookings} + if seat == slot: + row.draft = (player.draft as Dictionary).duplicate(true) + row.undo = (player.undo as Array).size() + rows.append(row) + return {"phase": match_state.phase, "round": match_state.round_number, "winner": match_state.winner, "end_note": match_state.end_note, + "building_hp": match_state.building_hp, "boss": match_state.boss, "last_settlement": _plain(match_state.last_settlement), + "destroyed_cores": (match_state.combat.destroyed_cores if match_state.combat != null else []) as Array, "players": rows} -# ---------------------------------------------------------------- host broadcast func _broadcast_lobby() -> void: if role == Role.HOST: rpc("rpc_lobby", lobby) @@ -299,91 +622,78 @@ func _broadcast_lobby() -> void: 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) + for slot: int in seat_peers: + var peer: int = int(seat_peers[slot]) + if peer > 1: + rpc_id(peer, "rpc_state", state_for(slot)) -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) +func _send_full_sync(peer: int, slot: int) -> void: + rpc_id(peer, "rpc_setup", _public_options(), slot, lobby) + rpc_id(peer, "rpc_state", state_for(slot)) + if match_state.phase == LuneMatch.Phase.COMBAT and match_state.combat != null: + rpc_id(peer, "rpc_combat_begin", match_state.round_number, _all_unit_records(), _building_hp_array()) # ---------------------------------------------------------------- lobby / disconnect -func seat_peer(slot: int, peer: int, display_name: String) -> void: +func seat_peer(slot: int, peer: int, seat_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 + # One peer one seat; a seat held by another peer is never taken over. 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: + if seat_peers.has(slot) and int(seat_peers[slot]) != peer: 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.name = seat_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}) + lobby.append({"peer": peer, "slot": slot, "name": seat_name, "ready": false, "bot": false}) + if match_state != null: + var player: Dictionary = match_state.players[slot] + player.is_human = true + player.name = seat_name + # A seat taken during planning drops the bot's uncommitted draft: the gold is the human's. + if match_state.phase == LuneMatch.Phase.PLANNING: + player.draft = match_state._snapshot(player) + player.undo = [] + player.ready = false if role == Role.HOST and peer > 1: - rpc_id(peer, "rpc_setup", _public_options(), slot, lobby) - rpc("rpc_lobby", lobby) + _send_full_sync(peer, slot) + _broadcast_lobby() + _broadcast_state() lobby_changed.emit() func _public_options() -> Dictionary: - return {"seed": match_state.seed_value if match_state != null else 1, "human_slot": local_slot, + return {"seed": match_state.seed_value if match_state != null else 1, "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: + if not is_authority(): + return var slot: int = seat_of_peer(peer) if slot < 0: return seat_peers.erase(slot) + var gone_name: String = display_name_for(slot) + departed[gone_name] = 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) + session_message.emit("%s hat die Verbindung getrennt – Slot wird vom Bot übernommen." % gone_name, false) + if match_state != null: + match_state.players[slot].is_human = false + if match_state.phase == LuneMatch.Phase.PLANNING and is_authority(): + _plan_bot(slot) _broadcast_lobby() - _broadcast_state() if match_state != null and match_state.phase == LuneMatch.Phase.PLANNING and match_state.all_ready(): host_begin_combat() + else: + _broadcast_state() diff --git a/scripts/luneraria/ui/lune_menu.gd b/scripts/luneraria/ui/lune_menu.gd index 4ac5ff3..e79b937 100644 --- a/scripts/luneraria/ui/lune_menu.gd +++ b/scripts/luneraria/ui/lune_menu.gd @@ -11,6 +11,7 @@ var lane_choice: ChoiceGroup var _net_name: LineEdit var _net_address: LineEdit var _net_port: LineEdit +var _net_status: Label func _ready() -> void: super._ready() @@ -92,6 +93,18 @@ func _retarget_setup() -> void: buttons.add_child(host_btn) buttons.add_child(join_btn) body.add_child(buttons) + _net_status = Label.new() + _net_status.theme_type_variation = "MutedLabel" + _net_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _net_status.text = "Wer die Verbindung verliert, wird vom Bot vertreten und kann mit demselben Anzeigenamen auf seinen Platz zurückkehren." + body.add_child(_net_status) + +## Shows why a network start or a running network match ended. +func notify_error(text: String) -> void: + if text.is_empty() or _net_status == null: + return + _net_status.text = "Netzwerk: " + text + _net_status.add_theme_color_override("font_color", Color("ff8a70")) func sync() -> void: super.sync() diff --git a/tests/luneraria/test_lune_net.gd b/tests/luneraria/test_lune_net.gd index 0462274..0c64807 100644 --- a/tests/luneraria/test_lune_net.gd +++ b/tests/luneraria/test_lune_net.gd @@ -30,6 +30,9 @@ func run() -> void: _test_foreign_uid() _test_disconnect_is_not_core_loss() _test_six_seats_and_phases() + _test_hello_and_rejoin() + _test_takeover_and_bot_once() + _test_ready_commits_and_privacy() 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")) @@ -144,3 +147,50 @@ func _test_six_seats_and_phases() -> void: 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() + +func _test_hello_and_rejoin() -> void: + var session: LuneSession = new_host() + check(session.host_hello(60, LuneSession.PROTOCOL - 1, "Alt") == -1, "an older protocol is refused") + check(session.seat_of_peer(60) < 0, "a refused peer holds no seat") + var anna: int = session.host_hello(61, LuneSession.PROTOCOL, "Anna") + check(anna == 1, "the first guest takes the first free seat") + check(session.host_hello(61, LuneSession.PROTOCOL, "Anna") == anna, "a repeated hello keeps the one seat") + session._on_peer_disconnected(61) + check(bool(session.match_state.players[anna].ready) and not bool(session.match_state.players[anna].is_human), "a departed seat is a ready bot") + var bert: int = session.host_hello(62, LuneSession.PROTOCOL, "Bert") + check(bert >= 0 and bert != anna, "a new guest does not take a seat reserved for rejoining") + var back: int = session.host_hello(63, LuneSession.PROTOCOL, "Anna") + check(back == anna, "a returning guest gets the same seat back by name") + check(bool(session.match_state.players[anna].is_human) and not bool(session.match_state.players[anna].ready), "the returned seat is human and must ready up") + session.close() + +func _test_takeover_and_bot_once() -> void: + var session: LuneSession = new_host() + session.plan_bot_seats() + var bot: Dictionary = session.match_state.players[2] + var spent_by_bot: int = int(bot.gold) - int(bot.draft.gold) + check(spent_by_bot > 0, "a bot seat plans and spends gold") + session.plan_bot_seats() + check(int(bot.gold) - int(bot.draft.gold) == spent_by_bot, "a bot seat plans only once per planning phase") + session.seat_peer(2, 70, "Cora") + check(int(bot.draft.gold) == int(bot.gold) and (bot.draft.roster as Array).is_empty(), "a takeover in planning drops the bot's uncommitted draft") + check(not bool(bot.ready), "the new human must ready up") + session.close() + +func _test_ready_commits_and_privacy() -> void: + var session: LuneSession = new_host() + session.seat_peer(1, 80, "Dora") + var ids: Array[String] = session.match_state.base_ids("wurzelbund") + check(session.host_command(80, 1, {"type": "buy", "type_id": ids[0]}).ok, "the guest buys during planning") + check(session.host_command(80, 1, {"type": "undo"}).ok, "undo goes through the host") + check((session.match_state.players[1].draft.roster as Array).is_empty(), "undo removed the purchase") + session.host_command(80, 1, {"type": "buy", "type_id": ids[0]}) + session.host_ready(1) + check(not session.host_command(80, 1, {"type": "buy", "type_id": ids[0]}).ok, "after ready the plan is binding") + var state: Dictionary = session.state_for(1) + var drafts: Array = (state.players as Array).filter(func(row: Dictionary) -> bool: return row.has("draft")) + check(drafts.size() == 1 and int(drafts[0].slot) == 1, "a seat receives only its own draft") + check(session.match_state.phase == LuneMatch.Phase.PLANNING, "combat waits for the host seat") + session.host_ready(0) + check(session.match_state.phase == LuneMatch.Phase.COMBAT, "combat starts once every human is ready") + session.close() diff --git a/tools/luneraria/net_pair.gd b/tools/luneraria/net_pair.gd index 4831a87..c989b3e 100644 --- a/tools/luneraria/net_pair.gd +++ b/tools/luneraria/net_pair.gd @@ -1,28 +1,51 @@ 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. +## Two-process loopback test with the real match view (LuneGame) on both sides. +## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=host --rounds=4 +## godot --headless --path . res://tools/luneraria/net_pair.tscn -- --role=client --name=Gast +## Options: --port=, --address=, --rounds=, --drop-round=N (client leaves during combat N), +## --shots=DIR (with a window: the guest saves planning and combat views of round 1 and 2). +## tools/luneraria/net_rounds.py starts the processes and compares their round checksums. +## This is a local loopback test, not a six-player internet match. const Session = preload("res://scripts/luneraria/net/lune_session.gd") +const Game = preload("res://scripts/luneraria/lune_game.gd") +## Extra host steps per frame at 60 FPS: a round takes seconds, snapshots still flow. +## --steps=0 keeps the real-time combat clock (visual checks). +var steps_per_frame: int = 8 + var role: String = "host" var port: int = 24571 var address: String = "127.0.0.1" +var guest_name: String = "Gast" +var rounds: int = 4 +var drop_round: int = -1 var session: Node -var steps: int = 0 +var game: Node3D +var started_msec: int = 0 +var planned_round: int = 0 +var frames_at_combat_start: int = 0 +var shots_dir: String = "" +var _shots_taken: Dictionary = {} 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) + var value: String = arg.get_slice("=", 1) + match arg.get_slice("=", 0): + "--role": role = value + "--port": port = int(value) + "--address": address = value + "--name": guest_name = value + "--rounds": rounds = int(value) + "--drop-round": drop_round = int(value) + "--shots": shots_dir = value + "--steps": steps_per_frame = int(value) + started_msec = Time.get_ticks_msec() + Engine.max_fps = 60 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)) + session.phase_announced.connect(_on_phase) call_deferred("start") func start() -> void: @@ -34,39 +57,131 @@ func start() -> void: if err != OK: get_tree().quit(1) return - session.lobby_changed.connect(func() -> void: - print("NET_LOBBY ", session.lobby)) + _launch() else: - var err: int = session.join_enet(address, port, "Gast") + session.host_lost.connect(func() -> void: + print("NET_HOST_LOST ", session.last_error) + get_tree().quit(3)) + session.match_resetup.connect(func() -> void: + print("NET_SETUP seat=", session.local_slot, " round=", session.match_state.round_number, " phase=", session.match_state.phase) + _launch()) + var err: int = session.join_enet(address, port, guest_name) 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 _launch() -> void: + if is_instance_valid(game): + game.free() + game = Game.new() + add_child(game) + game.session = session + game.start({"seed": session.match_state.seed_value, "human_slot": session.local_slot}) + if role == "client": + # Guard: a guest must never act for another seat, even through the host API. + var spoof: Dictionary = session.host_command(1, 5, {"type": "buy", "type_id": "w01"}) + print("NET_SPOOF ", "REJECTED" if not spoof.ok else "ACCEPTED-BAD") + +## Public, committed state; host and guest must agree on it after every settlement. +static func checksum(match_state: LuneMatch) -> String: + var parts: PackedStringArray = [] + for player: Dictionary in match_state.players: + var hero: Dictionary = player.hero + parts.append("%d:%d:%d:%d" % [int(player.slot), int(player.gold), (player.roster as Array).size(), 0 if hero.is_empty() else int(hero.xp)]) + var ids: Array = match_state.building_hp.keys() + ids.sort() + for id: String in ids: + parts.append("%s=%.1f" % [id, float(match_state.building_hp[id])]) + parts.append("boss=%d" % int(match_state.boss.get("next_available_round", 0))) + parts.append("winner=%d" % match_state.winner) + return str(hash("|".join(parts))) + +func _on_phase(phase: int, round_number: int) -> void: + if phase == LuneMatch.Phase.COMBAT: + frames_at_combat_start = session.frames_received + return + if round_number <= 1 and phase != LuneMatch.Phase.OVER: + return + # The settlement of the previous combat is now visible on this side. + var fought: int = round_number - 1 if phase == LuneMatch.Phase.PLANNING else round_number + print("NET_ROUND role=%s round=%d phase=%d sum=%s" % [role, fought, phase, checksum(session.match_state)]) + if role == "client" and session.match_state.combat != null: + print("NET_CLIENT_COMBAT round=%d frames=%d local_steps=%d" % [fought, + session.frames_received - frames_at_combat_start, session.match_state.combat.steps]) func _process(_delta: float) -> void: - steps += 1 - if steps > 1200: + if Time.get_ticks_msec() - started_msec > 300000: 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) + if not is_instance_valid(game) or game.match_state == null: + return + var state: LuneMatch = game.match_state + if state.phase == LuneMatch.Phase.OVER: + _quit_soon("over round=%d winner=%d" % [state.round_number, state.winner]) + return + if state.phase == LuneMatch.Phase.PLANNING: + if state.round_number > rounds: + _quit_soon("round=%d seat=%d" % [state.round_number, session.local_slot]) + return + if role == "client" and not shots_dir.is_empty() and not _shot_once("planning-r%d" % state.round_number, 20): + return + _plan(state) + elif state.phase == LuneMatch.Phase.COMBAT: + if role == "client" and not shots_dir.is_empty() and session.frames_received > frames_at_combat_start + 60: + if _shot_once("combat-r%d" % state.round_number, 0) and state.round_number == 1: + game.focus_lane(state.players[session.local_slot].lane, true) + if role == "host": + # Faster combat clock on the host only; the rules stay unchanged. + for i: int in range(steps_per_frame): + if state.phase != LuneMatch.Phase.COMBAT: + break + state.step_combat() + elif drop_round == state.round_number and session.frames_received > frames_at_combat_start + 5: + print("NET_CLIENT_DROP round=%d seat=%d" % [state.round_number, session.local_slot]) + session.close() + get_tree().quit(0) + +var _quitting: bool = false +var _shot_wait: Dictionary = {} + +## Saves one guest screenshot per label after a few frames of settling; true once taken. +func _shot_once(label: String, settle_frames: int) -> bool: + if _shots_taken.has(label) or int(label.right(1)) > 2: + return true + _shot_wait[label] = int(_shot_wait.get(label, 0)) + 1 + if int(_shot_wait[label]) < settle_frames: + return false + var image: Image = get_viewport().get_texture().get_image() + DirAccess.make_dir_recursive_absolute(shots_dir) + image.save_png(shots_dir.path_join("net-guest-%s.png" % label)) + _shots_taken[label] = true + print("NET_SHOT ", label) + return true + +## The host lingers briefly so the last state reaches the guest before the socket closes. +func _quit_soon(note: String) -> void: + if _quitting: + return + _quitting = true + print("NET_%s_DONE %s" % [role.to_upper(), note]) + await get_tree().create_timer(1.0 if role == "host" else 0.1).timeout + session.close() + get_tree().quit(0) + +## Buys one unit and confirms once per planning phase, through the real game controls. +## The host waits for a seated guest in every planning phase (covers the rejoin case). +func _plan(state: LuneMatch) -> void: + var slot: int = session.local_slot + if slot < 0 or planned_round == state.round_number or bool(state.players[slot].ready): + return + if role == "host" and session.seat_peers.size() < 2: + return + planned_round = state.round_number + var ids: Array[String] = state.base_ids(state.players[slot].bund) + var gold: int = state.gold_of(slot) + var affordable: Array = ids.filter(func(id: String) -> bool: return int(DataCatalog.get_unit(id).cost) <= gold) + if not affordable.is_empty(): + game._on_command({"type": "buy", "type_id": affordable[state.round_number % affordable.size()]}) + game._on_ready() + print("NET_PLAN role=%s round=%d seat=%d" % [role, state.round_number, slot]) diff --git a/tools/luneraria/net_rounds.py b/tools/luneraria/net_rounds.py new file mode 100644 index 0000000..1c37103 --- /dev/null +++ b/tools/luneraria/net_rounds.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Mehr-Runden-Loopback mit zwei bzw. drei Godot-Prozessen (Host, Gast, wiederkehrender Gast). + +Ablauf: Der Host spielt --rounds Runden. Gast A spielt mit und trennt die Verbindung im +Kampf der Runde --drop-round. Danach startet Gast B mit demselben Anzeigenamen und muss +denselben Sitz zurückerhalten. Nach jeder Abrechnung vergleicht das Skript die +Prüfsumme des öffentlichen Zustands von Host und Gast. + +Das ist ein lokaler Test über 127.0.0.1, keine Internetpartie auf sechs Rechnern. +Aufruf: python3 tools/luneraria/net_rounds.py [--rounds 5] [--drop-round 2] [--godot godot] +""" +import argparse +import json +import re +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +LOG_DIR = ROOT / "docs" / "luneraria" / "logs" / "m4" +ROUND_RE = re.compile(r"NET_ROUND role=(\w+) round=(\d+) phase=(\d+) sum=(\S+)") +COMBAT_RE = re.compile(r"NET_CLIENT_COMBAT round=(\d+) frames=(\d+) local_steps=(-?\d+)") +SETUP_RE = re.compile(r"NET_SETUP seat=(\d+)") + + +def launch(godot, port, *extra): + cmd = [godot, "--headless", "--path", str(ROOT), "res://tools/luneraria/net_pair.tscn", "--", + f"--port={port}", *extra] + return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--godot", default="godot") + parser.add_argument("--rounds", type=int, default=5) + parser.add_argument("--drop-round", type=int, default=2) + parser.add_argument("--port", type=int, default=24581) + args = parser.parse_args() + LOG_DIR.mkdir(parents=True, exist_ok=True) + + host = launch(args.godot, args.port, "--role=host", f"--rounds={args.rounds}") + time.sleep(2.0) + guest_a = launch(args.godot, args.port, "--role=client", "--name=Gast", f"--rounds={args.rounds}", + f"--drop-round={args.drop_round}") + out_a, _ = guest_a.communicate(timeout=300) + guest_b = launch(args.godot, args.port, "--role=client", "--name=Gast", f"--rounds={args.rounds}") + out_b, _ = guest_b.communicate(timeout=300) + out_host, _ = host.communicate(timeout=300) + + for name, text in (("host", out_host), ("gast-a", out_a), ("gast-b", out_b)): + (LOG_DIR / f"net-rounds-{name}.log").write_text(text) + + def sums(text, role): + return {int(m.group(2)): m.group(4) for m in ROUND_RE.finditer(text) if m.group(1) == role} + + host_sums = sums(out_host, "host") + guest_sums = {**sums(out_a, "client"), **sums(out_b, "client")} + compared = sorted(set(host_sums) & set(guest_sums)) + mismatches = [r for r in compared if host_sums[r] != guest_sums[r]] + combats = [{"round": int(m.group(1)), "frames": int(m.group(2)), "local_steps": int(m.group(3))} + for text in (out_a, out_b) for m in COMBAT_RE.finditer(text)] + seats_a = [int(s) for s in SETUP_RE.findall(out_a)] + seats_b = [int(s) for s in SETUP_RE.findall(out_b)] + + checks = { + "host_ok": host.returncode == 0, + "guest_a_ok": guest_a.returncode == 0 and "NET_CLIENT_DROP" in out_a, + "guest_b_ok": guest_b.returncode == 0 and "NET_CLIENT_DONE" in out_b, + "spoof_rejected": "ACCEPTED-BAD" not in out_a + out_b and "NET_SPOOF REJECTED" in out_a, + "rounds_compared": len(compared) >= args.rounds - 1, + "checksums_equal": not mismatches and bool(compared), + "client_never_simulates": bool(combats) and all(c["local_steps"] == 0 for c in combats), + "client_receives_frames": bool(combats) and all(c["frames"] > 0 for c in combats), + "rejoin_same_seat": bool(seats_a) and bool(seats_b) and seats_b[0] == seats_a[0], + "bot_took_over": "Slot wird vom Bot übernommen" in out_host, + "rejoin_announced": "kehrt zurück auf" in out_host, + "no_script_errors": all("SCRIPT ERROR" not in t for t in (out_host, out_a, out_b)), + } + report = {"rounds": args.rounds, "drop_round": args.drop_round, "compared_rounds": compared, + "mismatches": mismatches, "host_sums": host_sums, "guest_sums": guest_sums, + "client_combats": combats, "seats": {"gast_a": seats_a, "gast_b": seats_b}, "checks": checks} + (LOG_DIR / "net-rounds.json").write_text(json.dumps(report, indent=2, ensure_ascii=False)) + failed = [k for k, v in checks.items() if not v] + print(json.dumps(checks, indent=2)) + print("NET_ROUNDS_%s: %d/%d" % ("PASS" if not failed else "FAIL", len(checks) - len(failed), len(checks))) + return 0 if not failed else 1 + + +if __name__ == "__main__": + sys.exit(main())