From 769e6229523877126a91d076f60848215c496469 Mon Sep 17 00:00:00 2001 From: Martin Stickelbroeck Date: Fri, 2 Oct 2026 16:14:42 +0200 Subject: [PATCH] =?UTF-8?q?U4=20Netzwerk:=20LuneSession,=20TCP,=20Host/Joi?= =?UTF-8?q?n,=2043+12=20Pr=C3=BCfungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der M4-Umfang ist als Protokoll 4 portiert, serverautoritativ. Der Host besitzt LuneMatch und rechnet jeden Kampf; Clients senden nur Absichten und zeigen den Kampf aus Host-Momentaufnahmen. - LuneSession (Core/Net): Rollen offline/Host/Gast, sechs Sitze, Lobby, Hallo mit Protokollprüfung und Namenskürzung, Beitritt und Wieder- einstieg über den Anzeigenamen, private Planung mit Spoof-Schutz, Bereit ist verbindlich, Bots planen einmal je Runde, Disconnect gibt den Platz an einen Bot (nie Kernverlust), Übernahme verwirft den unbestätigten Bot-Entwurf, Revanche hält die Plätze, Hostverlust meldet und führt ins Menü zurück. - Kampfsynchronisation: Beginn mit Einheitenätzen und Gebäude-HP, alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit), späte Frames über die Serial verworfen, Gast interpoliert (frame_alpha). - Transport: LuneLoopback (In-Prozess) für die Tests, LuneTcp + LuneWire (Cursor-Codec) für Echtverbindungen statt ENet; ein Socket je Gast. - Oberfläche: LuneMainMenu (Offline/Hosten/Beitreten, Bund, Lane, Name, Adresse, Port) als Startbildschirm, LuneNetPlay als Schale wie lune_main.gd. LuneGame leitet Befehle, Rückgängig und Bereit im Netzspiel über die Sitzung; Tempo und Pause bestimmt der Host. - Tests: NetSuite 43/43 (Port von test_lune_net.gd), NetRoundsSuite 12/12 (Port von net_rounds.py: 5 Runden, Prüfsummen gleich, Gast rechnet 0 Schritte, Drop und Wiedereinstieg auf denselben Sitz), LuneWireTests Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests 457/457. Fingerabdrücke der Bot-Partien unverändert. - Dokumentation: U4-NETWORK (Abnahme, Grenzen), PORT-QA (U4), PORT-STATUS (U4 fertig, U5 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES. Co-Authored-By: Claude Opus 5.5 --- Assets/Luneraria/Core/Net.meta | 8 + Assets/Luneraria/Core/Net/LuneLoopback.cs | 89 ++ .../Luneraria/Core/Net/LuneLoopback.cs.meta | 2 + Assets/Luneraria/Core/Net/LuneSession.cs | 1058 +++++++++++++++++ Assets/Luneraria/Core/Net/LuneSession.cs.meta | 2 + Assets/Luneraria/Core/Net/NetModel.cs | 122 ++ Assets/Luneraria/Core/Net/NetModel.cs.meta | 2 + Assets/Luneraria/Net.meta | 8 + Assets/Luneraria/Net/LuneTcp.cs | 375 ++++++ Assets/Luneraria/Net/LuneTcp.cs.meta | 2 + Assets/Luneraria/Net/LuneWire.cs | 840 +++++++++++++ Assets/Luneraria/Net/LuneWire.cs.meta | 2 + Assets/Luneraria/Net/Luneraria.Net.asmdef | 16 + .../Luneraria/Net/Luneraria.Net.asmdef.meta | 7 + Assets/Luneraria/Tests/Core/CheckSuite.cs | 2 +- Assets/Luneraria/Tests/Core/NetRoundsSuite.cs | 190 +++ .../Tests/Core/NetRoundsSuite.cs.meta | 2 + Assets/Luneraria/Tests/Core/NetSuite.cs | 228 ++++ Assets/Luneraria/Tests/Core/NetSuite.cs.meta | 2 + .../Tests/Core/Unity/CoreSuiteTests.cs | 2 + Assets/Luneraria/Tests/Net.meta | 8 + Assets/Luneraria/Tests/Net/LuneWireTests.cs | 170 +++ .../Luneraria/Tests/Net/LuneWireTests.cs.meta | 2 + .../Tests/Net/Luneraria.Net.Tests.asmdef | 25 + .../Tests/Net/Luneraria.Net.Tests.asmdef.meta | 7 + Assets/Luneraria/UI/Game/LuneGame.cs | 78 +- Assets/Luneraria/UI/Game/LuneMainMenu.cs | 177 +++ Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta | 2 + Assets/Luneraria/UI/Game/LuneNetPlay.cs | 245 ++++ Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta | 2 + Assets/Luneraria/UI/Luneraria.UI.asmdef | 1 + Assets/Luneraria/View/LuneMatchDriver.cs | 29 + Assets/Luneraria/View/LuneMatchRunner.cs | 30 +- docs/port/KNOWN-BEHAVIOR-DIFFERENCES.md | 7 + docs/port/PORT-QA.md | 16 +- docs/port/PORT-STATUS.md | 7 +- docs/port/U4-NETWORK.md | 90 +- 37 files changed, 3823 insertions(+), 32 deletions(-) create mode 100644 Assets/Luneraria/Core/Net.meta create mode 100644 Assets/Luneraria/Core/Net/LuneLoopback.cs create mode 100644 Assets/Luneraria/Core/Net/LuneLoopback.cs.meta create mode 100644 Assets/Luneraria/Core/Net/LuneSession.cs create mode 100644 Assets/Luneraria/Core/Net/LuneSession.cs.meta create mode 100644 Assets/Luneraria/Core/Net/NetModel.cs create mode 100644 Assets/Luneraria/Core/Net/NetModel.cs.meta create mode 100644 Assets/Luneraria/Net.meta create mode 100644 Assets/Luneraria/Net/LuneTcp.cs create mode 100644 Assets/Luneraria/Net/LuneTcp.cs.meta create mode 100644 Assets/Luneraria/Net/LuneWire.cs create mode 100644 Assets/Luneraria/Net/LuneWire.cs.meta create mode 100644 Assets/Luneraria/Net/Luneraria.Net.asmdef create mode 100644 Assets/Luneraria/Net/Luneraria.Net.asmdef.meta create mode 100644 Assets/Luneraria/Tests/Core/NetRoundsSuite.cs create mode 100644 Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta create mode 100644 Assets/Luneraria/Tests/Core/NetSuite.cs create mode 100644 Assets/Luneraria/Tests/Core/NetSuite.cs.meta create mode 100644 Assets/Luneraria/Tests/Net.meta create mode 100644 Assets/Luneraria/Tests/Net/LuneWireTests.cs create mode 100644 Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta create mode 100644 Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef create mode 100644 Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef.meta create mode 100644 Assets/Luneraria/UI/Game/LuneMainMenu.cs create mode 100644 Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta create mode 100644 Assets/Luneraria/UI/Game/LuneNetPlay.cs create mode 100644 Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta diff --git a/Assets/Luneraria/Core/Net.meta b/Assets/Luneraria/Core/Net.meta new file mode 100644 index 0000000..41d0a42 --- /dev/null +++ b/Assets/Luneraria/Core/Net.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 944742311374a4ae6959443b9438e3d6 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Luneraria/Core/Net/LuneLoopback.cs b/Assets/Luneraria/Core/Net/LuneLoopback.cs new file mode 100644 index 0000000..ded1ae0 --- /dev/null +++ b/Assets/Luneraria/Core/Net/LuneLoopback.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; + +namespace Luneraria.Core +{ + /// + /// In-process transport for two (or more) LuneSession instances (the loopback of + /// tools/luneraria/net_pair.gd without processes). Messages are delivered synchronously in + /// send order; frames go through the same path with SendFrame. Peer 1 is the host. A node + /// implements ILuneNetLink for exactly one peer and routes through the hub. + /// + public sealed class LuneLoopback + { + sealed class Node : ILuneNetLink + { + readonly LuneLoopback _hub; + readonly int _peer; + public LuneSession Session; + + public Node(LuneLoopback hub, int peer) + { + _hub = hub; + _peer = peer; + } + + public void Send(int peer, string kind, object payload) + { + _hub.Deliver(_peer, peer, kind, payload); + } + + public void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp) + { + _hub.DeliverFrame(_peer, peer, serial, elapsed, units, buildingHp); + } + } + + readonly Dictionary _nodes = new Dictionary(); + /// Every message that left a peer (tests, diagnostics). + public readonly List Log = new List(); + + /// Attaches a session under a peer id and returns the session for chaining. + public LuneSession Attach(int peer, LuneSession session) + { + var node = new Node(this, peer) { Session = session }; + _nodes[peer] = node; + session.Link = node; + return session; + } + + /// Drops a peer and tells the authority (bot takeover, never a core loss). + public void Detach(int peer) + { + Node node; + if (!_nodes.TryGetValue(peer, out node)) + return; + _nodes.Remove(peer); + if (node.Session != null) + { + node.Session.Link = null; + node.Session.Connected = false; + } + foreach (Node other in _nodes.Values) + { + if (other.Session != null && other.Session.IsAuthority()) + other.Session.OnPeerDisconnected(peer); + } + } + + /// True while the hub still knows the peer. + public bool Has(int peer) { return _nodes.ContainsKey(peer); } + + void Deliver(int from, int to, string kind, object payload) + { + Log.Add(from + "→" + to + " " + kind); + Node node; + if (!_nodes.TryGetValue(to, out node) || node.Session == null) + return; + node.Session.OnMessage(from, kind, payload); + } + + void DeliverFrame(int from, int to, int serial, double elapsed, float[] units, float[] buildingHp) + { + Node node; + if (!_nodes.TryGetValue(to, out node) || node.Session == null) + return; + node.Session.OnFrame(from, serial, elapsed, units, buildingHp); + } + } +} diff --git a/Assets/Luneraria/Core/Net/LuneLoopback.cs.meta b/Assets/Luneraria/Core/Net/LuneLoopback.cs.meta new file mode 100644 index 0000000..6ab0732 --- /dev/null +++ b/Assets/Luneraria/Core/Net/LuneLoopback.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 82657628be8544758877088854dcb44e \ No newline at end of file diff --git a/Assets/Luneraria/Core/Net/LuneSession.cs b/Assets/Luneraria/Core/Net/LuneSession.cs new file mode 100644 index 0000000..a80a358 --- /dev/null +++ b/Assets/Luneraria/Core/Net/LuneSession.cs @@ -0,0 +1,1058 @@ +using System; +using System.Collections.Generic; + +namespace Luneraria.Core +{ + /// + /// Authoritative network session (port of scripts/luneraria/net/lune_session.gd, protocol 4). + /// 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. The transport (ENet in Godot, sockets in Unity) is a link; this class + /// only speaks kinds and payloads and can be tested in process. + /// + public sealed class LuneSession + { + public LuneRole Role = LuneRole.Offline; + public LuneMatch MatchState; + public int LocalSlot = -1; + public string DisplayName = ""; + /// slot → peer id (1 = host). Absent slots are bots owned by the host. + public readonly Dictionary SeatPeers = new Dictionary(); + public readonly List Lobby = new List(); + /// Display name → slot of a peer that left; used for rejoining. + public readonly Dictionary Departed = new Dictionary(); + public string LastError = ""; + public bool Connected; + /// Client-side counters (tests and diagnostics). + public int FramesReceived; + public int StatesReceived; + /// Optional wire; null in offline and pure protocol tests. + public ILuneNetLink Link; + + public event Action LobbyChanged; + public event Action MatchResetup; + public event Action SessionMessage; + public event Action PhaseAnnounced; + public event Action StateReceived; + public event Action HostLost; + + int _sentUnitId; + double _frameClock; + int _frameSerial; + int _lastFrameSerial = -1; + double _lastFrameTime; + readonly Dictionary _mirror = new Dictionary(); + /// slot → round a bot already planned; a bot seat plans exactly once per planning phase. + readonly Dictionary _botPlanned = new Dictionary(); + + /// Client interpolation factor between the last two snapshots. + public double LastFrameTime { get { return _lastFrameTime; } } + + public bool IsAuthority() { return Role != LuneRole.Client; } + + public bool IsNetworked() { return Role != LuneRole.Offline; } + + public string DisplayNameFor(int slot) + { + if (MatchState != null && slot >= 0 && slot < MatchState.Players.Count) + return MatchState.Players[slot].Name; + return string.Format("Slot {0}", slot); + } + + public int SeatOfPeer(int peer) + { + foreach (KeyValuePair pair in SeatPeers) + { + if (pair.Value == peer) + return pair.Key; + } + return -1; + } + + // ---------------------------------------------------------------- setup + public void SetupMatch(MatchOptions options) + { + MatchState = new LuneMatch(); + MatchState.Setup(options); + _mirror.Clear(); + _botPlanned.Clear(); + if (IsAuthority()) + ApplySeats(); + MatchResetup?.Invoke(); + AnnouncePhase(); + } + + /// Seated peers are humans: they must ready up themselves and carry their lobby name. + void ApplySeats() + { + foreach (Player player in MatchState.Players) + { + int slot = player.Slot; + bool human = SeatPeers.ContainsKey(slot); + player.IsHuman = human; + if (MatchState.Phase == Phase.Planning) + player.Ready = !human; + if (human && SeatPeers[slot] != 1) + player.Name = LobbyName(slot, player.Name); + } + } + + string LobbyName(int slot, string fallback) + { + foreach (LobbyEntry entry in Lobby) + { + if (entry.Slot == slot && !entry.Bot) + return entry.Name; + } + return fallback; + } + + void AnnouncePhase() + { + if (MatchState == null) + return; + PhaseAnnounced?.Invoke(MatchState.Phase, MatchState.RoundNumber); + } + + // ---------------------------------------------------------------- start modes + public void HostOffline(int humanSlot, MatchOptions options) + { + Role = LuneRole.Offline; + Connected = true; + LocalSlot = humanSlot; + SeatPeers.Clear(); + if (humanSlot >= 0) + SeatPeers[humanSlot] = 1; + Lobby.Clear(); + Departed.Clear(); + if (humanSlot >= 0) + Lobby.Add(new LobbyEntry { Peer = 1, Slot = humanSlot, Name = "Du", Ready = false, Bot = false }); + for (int slot = 0; slot < 6; slot++) + { + if (slot != humanSlot) + Lobby.Add(new LobbyEntry { Peer = 0, Slot = slot, Name = "Bot " + slot, Ready = true, Bot = true }); + } + options.HumanSlot = humanSlot; + SetupMatch(options); + } + + /// Host over an already listening link (sockets or loopback). Peer 1 is this host. + public bool HostNetwork(MatchOptions options, string hostName) + { + if (Link == null) + { + LastError = "Kein Netzwerk-Transport gebunden."; + return false; + } + Role = LuneRole.Host; + Connected = true; + DisplayName = hostName; + int human = options.HumanSlot >= 0 ? options.HumanSlot : 0; + LocalSlot = human; + SeatPeers.Clear(); + SeatPeers[human] = 1; + Departed.Clear(); + Lobby.Clear(); + Lobby.Add(new LobbyEntry { Peer = 1, Slot = human, Name = hostName, Ready = false, Bot = false }); + for (int slot = 0; slot < 6; slot++) + { + if (slot != human) + Lobby.Add(new LobbyEntry { Peer = 0, Slot = slot, Name = "Bot", Ready = true, Bot = true }); + } + options.HumanSlot = human; + SetupMatch(options); + BroadcastLobby(); + return true; + } + + /// Client over a connected link. The hello goes out once the transport is up. + public bool JoinNetwork(string guestName) + { + if (Link == null) + { + LastError = "Kein Netzwerk-Transport gebunden."; + return false; + } + Role = LuneRole.Client; + Connected = false; + LocalSlot = -1; + DisplayName = guestName; + return true; + } + + /// Client: the transport finished the handshake; send hello with protocol and name. + public void OnClientConnected() + { + Connected = true; + SessionMessage?.Invoke("Mit dem Host verbunden.", false); + Link?.Send(1, LuneNet.KHello, new object[] { LuneNet.Protocol, DisplayName }); + } + + public void OnConnectionFailed() + { + LastError = "Verbindung zum Host fehlgeschlagen."; + Connected = false; + SessionMessage?.Invoke(LastError, true); + HostLost?.Invoke(); + } + + public void OnServerLost() + { + LastError = "Host hat die Verbindung getrennt."; + Connected = false; + SessionMessage?.Invoke(LastError, true); + HostLost?.Invoke(); + } + + public void Close() + { + Link = null; + Role = LuneRole.Offline; + Connected = false; + SeatPeers.Clear(); + Lobby.Clear(); + Departed.Clear(); + } + + // ---------------------------------------------------------------- intents + public SessionResult Submit(Command command) + { + if (MatchState == null || MatchState.Phase != Phase.Planning) + return new SessionResult(false, "Keine Planung."); + if (Role == LuneRole.Client) + { + if (LocalSlot < 0) + return new SessionResult(false, "Kein Sitzplatz."); + Link?.Send(1, LuneNet.KSubmit, new object[] { LocalSlot, command }); + return new SessionResult(true, "Befehl gesendet.", true); + } + return HostCommand(1, LocalSlot, command); + } + + public void ReadyUp() + { + if (MatchState == null) + return; + if (Role == LuneRole.Client) + { + Link?.Send(1, LuneNet.KReady, new object[] { LocalSlot }); + return; + } + HostReady(LocalSlot); + } + + /// + /// 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. + /// + public SessionResult HostCommand(int peer, int slot, Command command) + { + if (!IsAuthority() || MatchState == null) + return new SessionResult(false, "Keine Autorität."); + if (SeatOfPeer(peer) < 0 || SeatOfPeer(peer) != slot) + return Answer(peer, slot, new SessionResult(false, "Fremder Sitz oder ungültige Einheiten-ID.")); + if (MatchState.Phase != Phase.Planning) + return Answer(peer, slot, new SessionResult(false, "Veralteter Befehl (Planung vorbei).")); + if (MatchState.Players[slot].Ready) + return Answer(peer, slot, new SessionResult(false, "Bereits bereit gemeldet – die Planung ist verbindlich.")); + SessionResult result; + if (command != null && command.Type == "undo") + result = FromCore(MatchState.Undo(slot)); + else + result = FromCore(MatchState.Submit(slot, command)); + Answer(peer, slot, result); + BroadcastState(); + return result; + } + + static SessionResult FromCore(CommandResult core) + { + return new SessionResult(core.Ok, core.Message); + } + + SessionResult Answer(int peer, int slot, SessionResult result) + { + if (Role == LuneRole.Host && peer > 1 && Link != null) + Link.Send(peer, LuneNet.KFeedback, new object[] { slot, result.Ok, result.Message }); + return result; + } + + public void HostReady(int slot) + { + if (!IsAuthority() || MatchState == null || MatchState.Phase != Phase.Planning) + return; + MatchState.SetReady(slot); + foreach (LobbyEntry entry in Lobby) + { + if (EntrySlot(entry) == slot) + entry.Ready = true; + } + BroadcastLobby(); + if (MatchState.AllReady()) + HostBeginCombat(); + else + BroadcastState(); + } + + static int EntrySlot(LobbyEntry entry) { return entry.Slot; } + + public void PlanBotSeats() + { + if (!IsAuthority() || MatchState == null || MatchState.Phase != Phase.Planning) + return; + for (int slot = 0; slot < 6; slot++) + { + if (!SeatPeers.ContainsKey(slot)) + PlanBot(slot); + } + } + + void PlanBot(int slot) + { + int planned; + if (_botPlanned.TryGetValue(slot, out planned) && planned == MatchState.RoundNumber) + { + MatchState.SetReady(slot); + return; + } + _botPlanned[slot] = MatchState.RoundNumber; + LuneBot.Plan(MatchState, slot); + } + + public void HostBeginCombat() + { + if (!IsAuthority() || MatchState == null || MatchState.Phase != Phase.Planning) + return; + PlanBotSeats(); + MatchState.BeginCombat(); + _sentUnitId = 0; + _frameClock = 0.0; + AnnouncePhase(); + if (Role == LuneRole.Host) + { + BroadcastState(); + Broadcast(LuneNet.KCombatBegin, new object[] { MatchState.RoundNumber, NewUnitRecords(), BuildingHpArray() }); + } + } + + /// Host: call once per rendered frame while combat runs; sends throttled snapshots. + public void HostTick(double delta) + { + if (Role != LuneRole.Host || MatchState == null || MatchState.Phase != Phase.Combat || MatchState.Combat == null) + return; + _frameClock += delta; + if (_frameClock < LuneNet.FrameInterval) + return; + _frameClock = 0.0; + List spawned = NewUnitRecords(); + if (spawned.Count > 0) + Broadcast(LuneNet.KUnitsSpawned, spawned); + _frameSerial += 1; + float[] frame = UnitFrame(); + float[] hp = BuildingHpArray(); + foreach (KeyValuePair pair in SeatPeers) + { + if (pair.Value > 1 && Link != null) + Link.SendFrame(pair.Value, _frameSerial, MatchState.Combat.Elapsed, frame, hp); + } + } + + /// Host: call after the settlement (phase left COMBAT) so clients follow in the same step. + public void HostAfterCombat() + { + if (!IsAuthority() || MatchState == null) + return; + AnnouncePhase(); + BroadcastState(); + } + + public void HostFinishCombat() + { + if (!IsAuthority() || MatchState == null || MatchState.Phase != Phase.Combat) + return; + MatchState.FinishCombat(); + HostAfterCombat(); + } + + /// Host rematch: a new match with the same seats; clients relaunch on setup. + public void HostRematch(MatchOptions options) + { + if (!IsAuthority()) + return; + options.HumanSlot = LocalSlot; + foreach (LobbyEntry entry in Lobby) + entry.Ready = entry.Bot; + SetupMatch(options); + if (Role == LuneRole.Host) + { + foreach (KeyValuePair pair in SeatPeers) + { + if (pair.Value > 1) + SendFullSync(pair.Value, pair.Key); + } + BroadcastLobby(); + } + } + + // ---------------------------------------------------------------- inbound (client → host) + /// Testable host entry for a new connection: protocol check, rejoin or first free seat. + public int HostHello(int peer, int protocol, string guestName) + { + if (protocol != LuneNet.Protocol) + { + Refuse(peer, string.Format("Protokoll {0} passt nicht zum Host ({1}). Bitte dieselbe Version nutzen.", protocol, LuneNet.Protocol)); + return -1; + } + if (SeatOfPeer(peer) >= 0) + return SeatOfPeer(peer); + string clean = (guestName ?? "").Trim(); + if (clean.Length > 24) + clean = clean.Substring(0, 24); + if (clean.Length == 0) + clean = "Gast " + peer; + int slot = -1; + int departedSlot; + bool rejoin = Departed.TryGetValue(clean, out departedSlot) && !SeatPeers.ContainsKey(departedSlot); + if (rejoin) + { + slot = departedSlot; + } + else + { + // New guests prefer seats nobody may return to; a departed seat is the last resort. + var free = new List(); + var open = new List(); + for (int candidate = 0; candidate < 6; candidate++) + { + if (SeatPeers.ContainsKey(candidate)) + continue; + free.Add(candidate); + if (!Departed.ContainsValue(candidate)) + open.Add(candidate); + } + if (open.Count > 0) + slot = open[0]; + else if (free.Count > 0) + slot = free[0]; + } + if (slot < 0) + { + Refuse(peer, "Kein freier Sitzplatz."); + SessionMessage?.Invoke("Kein freier Sitzplatz für " + clean + ".", true); + return -1; + } + Departed.Remove(clean); + SeatPeer(slot, peer, clean); + SessionMessage?.Invoke(string.Format("{0} {1} Slot {2}.", clean, rejoin ? "kehrt zurück auf" : "sitzt auf", slot), false); + return slot; + } + + /// Transport entry for a message from a peer. + public void OnMessage(int peer, string kind, object payload) + { + var parts = payload as object[]; + switch (kind) + { + case LuneNet.KHello: + if (Role == LuneRole.Host && parts != null && parts.Length >= 2) + HostHello(peer, Convert.ToInt32(parts[0]), parts[1] as string ?? ""); + break; + case LuneNet.KSubmit: + if (IsAuthority() && parts != null && parts.Length >= 2) + HostCommand(peer, Convert.ToInt32(parts[0]), parts[1] as Command); + break; + case LuneNet.KReady: + if (IsAuthority()) + { + int slot = -1; + if (parts != null && parts.Length > 0) + slot = Convert.ToInt32(parts[0]); + else if (payload is int) + slot = (int)payload; + if (SeatOfPeer(peer) == slot) + HostReady(slot); + } + break; + case LuneNet.KRefused: + LastError = parts != null && parts.Length > 0 ? parts[0] as string ?? "" : ""; + SessionMessage?.Invoke(LastError, true); + HostLost?.Invoke(); + break; + case LuneNet.KFeedback: + if (parts != null && parts.Length >= 3) + SessionMessage?.Invoke(parts[2] as string ?? "", !Convert.ToBoolean(parts[1])); + break; + case LuneNet.KSetup: + if (parts != null && parts.Length >= 3) + ApplySetup(parts[0] as MatchOptions, Convert.ToInt32(parts[1]), parts[2] as List); + break; + case LuneNet.KState: + ApplyState(payload as NetState); + break; + case LuneNet.KLobby: + ApplyLobby(payload as List); + break; + case LuneNet.KCombatBegin: + if (parts != null && parts.Length >= 3) + ApplyCombatBegin(Convert.ToInt32(parts[0]), parts[1] as List, parts[2] as float[]); + break; + case LuneNet.KUnitsSpawned: + if (MatchState != null && MatchState.Combat != null && MatchState.Phase == Phase.Combat) + AddMirrorUnits(payload as List); + break; + } + } + + /// Transport entry for an unreliable combat frame. + public void OnFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp) + { + if (Role != LuneRole.Client) + return; + ApplyFrame(serial, elapsed, units, buildingHp); + } + + void Refuse(int peer, string reason) + { + if (Role == LuneRole.Host) + Link?.Send(peer, LuneNet.KRefused, new object[] { reason }); + } + + // ---------------------------------------------------------------- inbound (host → client) + public void ApplySetup(MatchOptions options, int seat, List roster) + { + LocalSlot = seat; + Lobby.Clear(); + SeatPeers.Clear(); + if (roster != null) + { + foreach (LobbyEntry entry in roster) + { + Lobby.Add(entry.Clone()); + if (!entry.Bot) + SeatPeers[entry.Slot] = entry.Peer; + } + } + options.HumanSlot = seat; + SetupMatch(options); + Connected = true; + LobbyChanged?.Invoke(); + } + + /// + /// Client: applies the host's public state. The combat phase itself only starts with + /// combat_begin, so a state message can never bind views to a stale combat. + /// + public void ApplyState(NetState state) + { + if (MatchState == null || state == null) + return; + Phase previous = MatchState.Phase; + MatchState.RoundNumber = state.Round; + MatchState.Winner = state.Winner; + MatchState.EndNote = state.EndNote; + MatchState.BuildingHp.Clear(); + foreach (KeyValuePair pair in state.BuildingHp) + MatchState.BuildingHp[pair.Key] = pair.Value; + CopyBoss(state.Boss); + MatchState.LastSettlement = state.LastSettlement; + foreach (NetPlayerRow row in state.Players) + { + int slot = row.Slot; + if (slot < 0 || slot >= MatchState.Players.Count) + continue; + Player player = MatchState.Players[slot]; + player.Name = slot == LocalSlot ? "Du" : row.Name; + player.IsHuman = row.IsHuman; + player.Gold = row.Gold; + player.Ready = row.Ready; + player.Earned = row.Earned; + player.Spent = row.Spent; + player.NextSeq = row.NextSeq; + player.Roster = new List(); + if (row.Roster != null) + { + foreach (RosterEntry entry in row.Roster) + player.Roster.Add(entry.Clone()); + } + player.Hero = row.Hero != null ? row.Hero.Clone() : null; + player.Bookings = new List(); + if (row.Bookings != null) + { + foreach (Booking booking in row.Bookings) + player.Bookings.Add(booking.Clone()); + } + // Only the own seat receives its private draft; the others show committed state. + player.Draft = row.Draft != null ? row.Draft.Clone() : MatchState.Snapshot(player); + player.Undo = new List(); + for (int i = 0; i < row.Undo; i++) + player.Undo.Add(null); + } + Phase phase = state.Phase; + if (phase != Phase.Combat) + { + if (MatchState.Combat != null) + { + MatchState.Combat.DestroyedCores.Clear(); + MatchState.Combat.DestroyedCores.AddRange(state.DestroyedCores); + } + MatchState.Phase = phase; + } + foreach (LobbyEntry entry in Lobby) + { + if (entry.Slot >= 0 && entry.Slot < MatchState.Players.Count) + entry.Ready = MatchState.Players[entry.Slot].Ready; + } + StatesReceived += 1; + if (previous != MatchState.Phase) + AnnouncePhase(); + StateReceived?.Invoke(); + LobbyChanged?.Invoke(); + } + + void CopyBoss(BossState source) + { + if (source == null) + return; + MatchState.Boss.NextAvailableRound = source.NextAvailableRound; + MatchState.Boss.SettledRound = source.SettledRound; + MatchState.Boss.Claim = source.Claim != null ? source.Claim.Clone() : null; + } + + public void ApplyLobby(List entries) + { + Lobby.Clear(); + if (entries != null) + { + foreach (LobbyEntry entry in entries) + Lobby.Add(entry.Clone()); + } + LobbyChanged?.Invoke(); + } + + /// Client: builds a display mirror of the host's combat. Rules never run on it. + public void ApplyCombatBegin(int roundNumber, List records, float[] buildingHp) + { + if (MatchState == null) + return; + var mirror = new LuneCombat(MatchState.Map, MatchState.Balance, roundNumber); + IReadOnlyList buildings = MatchState.Map.Buildings; + for (int index = 0; index < buildings.Count; index++) + { + double hp = buildingHp != null && index < buildingHp.Length ? buildingHp[index] : 0.0; + if (hp > 0.0) + mirror.AddBuilding(buildings[index], hp); + } + _mirror.Clear(); + MatchState.Combat = mirror; + MatchState.RoundNumber = roundNumber; + AddMirrorUnits(records); + _lastFrameTime = Now(); + MatchState.Phase = Phase.Combat; + AnnouncePhase(); + } + + /// Client interpolation factor between the last two snapshots. + public double FrameAlpha() + { + double span = LuneNet.FrameInterval; + double age = Now() - _lastFrameTime; + if (age < 0.0) + return 0.0; + if (age > span) + return 1.0; + return age / span; + } + + /// Injectable clock for tests; defaults to real time in seconds. + public static Func Now = () => DateTime.UtcNow.TimeOfDay.TotalSeconds; + + void ApplyFrame(int serial, double elapsed, float[] data, float[] buildingHp) + { + if (MatchState == null || MatchState.Combat == null || MatchState.Phase != Phase.Combat || serial <= _lastFrameSerial) + return; + _lastFrameSerial = serial; + _lastFrameTime = Now(); + FramesReceived += 1; + LuneCombat combat = MatchState.Combat; + combat.Elapsed = elapsed; + if (data != null) + { + for (int baseIndex = 0; baseIndex + LuneNet.FrameStride <= data.Length; baseIndex += LuneNet.FrameStride) + { + LuneUnit unit; + if (!_mirror.TryGetValue((int)data[baseIndex], out unit) || unit == null) + continue; + unit.PreviousPosition = unit.Position; + unit.Position = new Vec2(data[baseIndex + 1], data[baseIndex + 2]); + unit.Facing = data[baseIndex + 3]; + unit.Hp = data[baseIndex + 4]; + unit.MaxHp = data[baseIndex + 5]; + int flags = (int)data[baseIndex + 6]; + if (unit.Alive && (flags & 1) == 0) + unit.DeathTime = elapsed; + unit.Alive = (flags & 1) != 0; + unit.InRange = (flags & 2) != 0; + unit.AttackProgress = data[baseIndex + 7]; + unit.HitCount = (int)data[baseIndex + 8]; + unit.AttackSerial = (int)data[baseIndex + 9]; + unit.AbilitySerial = (int)data[baseIndex + 10]; + unit.HitFlash = data[baseIndex + 11]; + unit.Shield = data[baseIndex + 12]; + } + } + if (buildingHp != null) + { + IReadOnlyList buildings = MatchState.Map.Buildings; + int count = Math.Min(buildingHp.Length, buildings.Count); + for (int index = 0; index < count; index++) + { + LuneUnit building; + if (!combat.Buildings.TryGetValue(buildings[index].Id, out building)) + continue; + building.Hp = buildingHp[index]; + building.Alive = building.Hp > 0.0; + } + } + } + + void AddMirrorUnits(List records) + { + if (records == null || MatchState == null || MatchState.Combat == null) + return; + foreach (NetUnitRecord record in records) + { + if (_mirror.ContainsKey(record.Id)) + continue; + var unit = new LuneUnit(); + unit.Id = record.Id; + unit.Kind = record.Kind; + unit.Team = record.Team; + unit.TeamIndex = record.TeamIndex; + unit.OwnerSlot = record.OwnerSlot; + unit.PersistentUid = record.Uid; + unit.Lane = record.Lane; + unit.Zone = record.Zone; + unit.Def = record.Def ?? new UnitDef(); + unit.Position = new Vec2(record.X, record.Y); + unit.PreviousPosition = unit.Position; + unit.Facing = record.Facing; + unit.Hp = record.Hp; + unit.MaxHp = record.MaxHp; + unit.Alive = record.Alive; + MatchState.Combat.Units.Add(unit); + _mirror[record.Id] = unit; + } + } + + // ---------------------------------------------------------------- host serialisation + List NewUnitRecords() + { + var records = new List(); + int highest = _sentUnitId; + foreach (LuneUnit unit in MatchState.Combat.Units) + { + if (!unit.IsMobile() || unit.Id <= _sentUnitId) + continue; + if (unit.Id > highest) + highest = unit.Id; + records.Add(UnitRecord(unit)); + } + _sentUnitId = highest; + return records; + } + + /// Every mobile unit (full sync on join or rejoin mid-combat). + public List AllUnitRecords() + { + var records = new List(); + if (MatchState == null || MatchState.Combat == null) + return records; + foreach (LuneUnit unit in MatchState.Combat.Units) + { + if (unit.IsMobile()) + records.Add(UnitRecord(unit)); + } + return records; + } + + static NetUnitRecord UnitRecord(LuneUnit unit) + { + return new NetUnitRecord + { + Id = unit.Id, Kind = unit.Kind, Team = unit.Team, TeamIndex = unit.TeamIndex, OwnerSlot = unit.OwnerSlot, + Uid = unit.PersistentUid, Lane = unit.Lane, Zone = unit.Zone, + Def = unit.Def != null ? unit.Def.Clone() : new UnitDef(), + X = unit.Position.X, Y = unit.Position.Y, Facing = unit.Facing, + Hp = unit.Hp, MaxHp = unit.MaxHp, Alive = unit.Alive + }; + } + + public float[] UnitFrame() + { + var data = new List(); + foreach (LuneUnit unit in MatchState.Combat.Units) + { + if (!unit.IsMobile()) + continue; + int flags = (unit.Alive ? 1 : 0) | (unit.InRange ? 2 : 0); + data.Add(unit.Id); + data.Add((float)unit.Position.X); + data.Add((float)unit.Position.Y); + data.Add((float)unit.Facing); + data.Add((float)unit.Hp); + data.Add((float)unit.MaxHp); + data.Add(flags); + data.Add((float)unit.AttackProgress); + data.Add(unit.HitCount); + data.Add(unit.AttackSerial); + data.Add(unit.AbilitySerial); + data.Add((float)unit.HitFlash); + data.Add((float)unit.Shield); + } + return data.ToArray(); + } + + public float[] BuildingHpArray() + { + IReadOnlyList buildings = MatchState.Map.Buildings; + var data = new float[buildings.Count]; + for (int index = 0; index < buildings.Count; index++) + { + double hp; + if (!MatchState.BuildingHp.TryGetValue(buildings[index].Id, out hp)) + hp = 0.0; + if (MatchState.Phase == Phase.Combat && MatchState.Combat != null) + { + LuneUnit building; + if (MatchState.Combat.Buildings.TryGetValue(buildings[index].Id, out building)) + hp = building.Alive ? building.Hp : 0.0; + } + data[index] = (float)hp; + } + return data; + } + + /// Public state for one seat: every committed value, plus the private draft of that seat only. + public NetState StateFor(int slot) + { + var state = new NetState + { + Phase = MatchState.Phase, + Round = MatchState.RoundNumber, + Winner = MatchState.Winner, + EndNote = MatchState.EndNote, + LastSettlement = MatchState.LastSettlement, + }; + foreach (KeyValuePair pair in MatchState.BuildingHp) + state.BuildingHp[pair.Key] = pair.Value; + state.Boss = new BossState + { + NextAvailableRound = MatchState.Boss.NextAvailableRound, + SettledRound = MatchState.Boss.SettledRound, + Claim = MatchState.Boss.Claim != null ? MatchState.Boss.Claim.Clone() : null + }; + if (MatchState.Combat != null) + state.DestroyedCores.AddRange(MatchState.Combat.DestroyedCores); + foreach (Player player in MatchState.Players) + { + int seat = player.Slot; + var row = new NetPlayerRow + { + Slot = seat, + Name = SeatPeers.ContainsKey(seat) ? LobbyName(seat, player.Name) : player.Name, + IsHuman = player.IsHuman, + Gold = player.Gold, + Ready = player.Ready, + Earned = player.Earned, + Spent = player.Spent, + NextSeq = player.NextSeq, + Hero = player.Hero != null ? player.Hero.Clone() : null, + }; + foreach (RosterEntry entry in player.Roster) + row.Roster.Add(entry.Clone()); + foreach (Booking booking in player.Bookings) + row.Bookings.Add(booking.Clone()); + if (seat == slot) + { + row.Draft = player.Draft != null ? player.Draft.Clone() : null; + row.Undo = player.Undo.Count; + } + state.Players.Add(row); + } + return state; + } + + void BroadcastLobby() + { + if (Role == LuneRole.Host) + Broadcast(LuneNet.KLobby, Lobby); + } + + void BroadcastState() + { + if (Role != LuneRole.Host || MatchState == null || Link == null) + return; + foreach (KeyValuePair pair in SeatPeers) + { + if (pair.Value > 1) + Link.Send(pair.Value, LuneNet.KState, StateFor(pair.Key)); + } + } + + void Broadcast(string kind, object payload) + { + if (Link == null) + return; + foreach (KeyValuePair pair in SeatPeers) + { + if (pair.Value > 1) + Link.Send(pair.Value, kind, payload); + } + } + + void SendFullSync(int peer, int slot) + { + if (Link == null) + return; + Link.Send(peer, LuneNet.KSetup, new object[] { PublicOptions(), slot, Lobby }); + Link.Send(peer, LuneNet.KState, StateFor(slot)); + if (MatchState.Phase == Phase.Combat && MatchState.Combat != null) + Link.Send(peer, LuneNet.KCombatBegin, new object[] { MatchState.RoundNumber, AllUnitRecords(), BuildingHpArray() }); + } + + MatchOptions PublicOptions() + { + var options = new MatchOptions { Seed = MatchState != null ? MatchState.SeedValue : 1, HumanSlot = LocalSlot }; + if (MatchState != null) + { + foreach (Player player in MatchState.Players) + options.Bunds.Add(player.Bund); + } + return options; + } + + // ---------------------------------------------------------------- lobby / disconnect + public void SeatPeer(int slot, int peer, string seatName) + { + if (!IsAuthority() || slot < 0 || slot > 5) + return; + // One peer one seat; a seat held by another peer is never taken over. + int taken = SeatOfPeer(peer); + if (taken >= 0 && taken != slot) + return; + int current; + if (SeatPeers.TryGetValue(slot, out current) && current != peer) + return; + SeatPeers[slot] = peer; + bool found = false; + foreach (LobbyEntry entry in Lobby) + { + if (entry.Slot == slot) + { + entry.Peer = peer; + entry.Name = seatName; + entry.Bot = false; + entry.Ready = false; + found = true; + } + } + if (!found) + Lobby.Add(new LobbyEntry { Peer = peer, Slot = slot, Name = seatName, Ready = false, Bot = false }); + if (MatchState != null) + { + Player player = MatchState.Players[slot]; + player.IsHuman = true; + player.Name = seatName; + // A seat taken during planning drops the bot's uncommitted draft: the gold is the human's. + if (MatchState.Phase == Phase.Planning) + { + player.Draft = MatchState.Snapshot(player); + player.Undo = new List(); + player.Ready = false; + } + } + if (Role == LuneRole.Host && peer > 1) + { + SendFullSync(peer, slot); + BroadcastLobby(); + BroadcastState(); + } + LobbyChanged?.Invoke(); + } + + /// Transport entry when a peer goes away: the seat becomes a bot, never a core loss. + public void OnPeerDisconnected(int peer) + { + if (!IsAuthority()) + return; + int slot = SeatOfPeer(peer); + if (slot < 0) + return; + SeatPeers.Remove(slot); + string goneName = DisplayNameFor(slot); + Departed[goneName] = slot; + foreach (LobbyEntry entry in Lobby) + { + if (entry.Slot == slot) + { + entry.Bot = true; + entry.Peer = 0; + entry.Ready = true; + } + } + SessionMessage?.Invoke(goneName + " hat die Verbindung getrennt – Slot wird vom Bot übernommen.", false); + if (MatchState != null) + { + MatchState.Players[slot].IsHuman = false; + if (MatchState.Phase == Phase.Planning && IsAuthority()) + PlanBot(slot); + } + BroadcastLobby(); + if (MatchState != null && MatchState.Phase == Phase.Planning && MatchState.AllReady()) + HostBeginCombat(); + else + BroadcastState(); + } + + /// + /// Public, committed state; host and guest must agree on it after every settlement + /// (checksum of tools/luneraria/net_pair.gd). + /// + public static string Checksum(LuneMatch match) + { + var parts = new List(); + foreach (Player player in match.Players) + { + HeroState hero = player.Hero; + parts.Add(string.Format("{0}:{1}:{2}:{3}", player.Slot, player.Gold, player.Roster.Count, hero == null ? 0 : hero.Xp)); + } + var ids = new List(match.BuildingHp.Keys); + ids.Sort(StringComparer.Ordinal); + foreach (string id in ids) + parts.Add(string.Format("{0}={1:0.0}", id, match.BuildingHp[id])); + parts.Add("boss=" + match.Boss.NextAvailableRound); + parts.Add("winner=" + match.Winner); + return StableHash(string.Join("|", parts)); + } + + /// Godot's hash() is process-random; this port uses a stable FNV-1a hex digest. + public static string StableHash(string text) + { + unchecked + { + uint hash = 2166136261; + for (int i = 0; i < text.Length; i++) + { + hash ^= text[i]; + hash *= 16777619; + } + return hash.ToString("X8"); + } + } + } +} diff --git a/Assets/Luneraria/Core/Net/LuneSession.cs.meta b/Assets/Luneraria/Core/Net/LuneSession.cs.meta new file mode 100644 index 0000000..6a996c0 --- /dev/null +++ b/Assets/Luneraria/Core/Net/LuneSession.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ca592cc38517f48cd9e5a98a2ebed089 \ No newline at end of file diff --git a/Assets/Luneraria/Core/Net/NetModel.cs b/Assets/Luneraria/Core/Net/NetModel.cs new file mode 100644 index 0000000..30a72c7 --- /dev/null +++ b/Assets/Luneraria/Core/Net/NetModel.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; + +namespace Luneraria.Core +{ + /// Roles of a session (lune_session.gd Role). + public enum LuneRole { Offline, Host, Client } + + /// One lobby row: peer 0 is a bot, peer 1 the host. + public sealed class LobbyEntry + { + public int Peer; + public int Slot; + public string Name = ""; + public bool Ready; + public bool Bot; + + public LobbyEntry Clone() { return (LobbyEntry)MemberwiseClone(); } + } + + /// Result of a session intent (Godot returns a Dictionary with ok, message, pending). + public struct SessionResult + { + public bool Ok; + public string Message; + public bool Pending; + + public SessionResult(bool ok, string message, bool pending = false) + { + Ok = ok; + Message = message; + Pending = pending; + } + } + + /// One player row of the public state; Draft is set only for the receiving seat. + public sealed class NetPlayerRow + { + public int Slot; + public string Name = ""; + public bool IsHuman; + public int Gold; + public bool Ready; + public int Earned; + public int Spent; + public int NextSeq; + public List Roster = new List(); + public HeroState Hero; + public List Bookings = new List(); + /// Private draft of the receiving seat only; null for every other seat. + public Draft Draft; + /// Undo stack size of the receiving seat (0 for others). + public int Undo; + } + + /// Public state of the match for one seat (rpc_state). + public sealed class NetState + { + public Phase Phase; + public int Round; + public int Winner; + public string EndNote = ""; + public Dictionary BuildingHp = new Dictionary(); + public BossState Boss = new BossState(); + public Settlement LastSettlement; + public List DestroyedCores = new List(); + public List Players = new List(); + } + + /// A mobile unit as it crosses the wire (rpc_combat_begin / rpc_units_spawned). + public sealed class NetUnitRecord + { + public int Id; + public string Kind = ""; + public int Team; + public int TeamIndex; + public int OwnerSlot = -1; + public string Uid = ""; + public string Lane = ""; + public string Zone = ""; + public UnitDef Def = new UnitDef(); + public double X; + public double Y; + public double Facing; + public double Hp; + public double MaxHp; + public bool Alive; + } + + /// + /// Outbound session messages (the Godot RPCs). A transport or a test recorder implements this; + /// without a link the session still runs offline and for in-process host tests. + /// + public interface ILuneNetLink + { + /// Reliable message to one peer (1 = host). + void Send(int peer, string kind, object payload); + /// Unreliable ordered combat frame to one peer. + void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp); + } + + /// Frames carry 13 floats per mobile unit (lune_session.gd FRAME_STRIDE). + public static class LuneNet + { + public const int DefaultPort = 24571; + public const int MaxClients = 5; + public const int Protocol = 4; + /// Seconds between combat snapshots the host sends. + public const double FrameInterval = 0.05; + public const int FrameStride = 13; + + public const string KHello = "hello"; + public const string KSubmit = "submit"; + public const string KReady = "ready"; + public const string KRefused = "refused"; + public const string KFeedback = "feedback"; + public const string KSetup = "setup"; + public const string KState = "state"; + public const string KLobby = "lobby"; + public const string KCombatBegin = "combat_begin"; + public const string KUnitsSpawned = "units_spawned"; + } +} diff --git a/Assets/Luneraria/Core/Net/NetModel.cs.meta b/Assets/Luneraria/Core/Net/NetModel.cs.meta new file mode 100644 index 0000000..e77fead --- /dev/null +++ b/Assets/Luneraria/Core/Net/NetModel.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 30e31c765f3d34155ba019f7106ef715 \ No newline at end of file diff --git a/Assets/Luneraria/Net.meta b/Assets/Luneraria/Net.meta new file mode 100644 index 0000000..890967d --- /dev/null +++ b/Assets/Luneraria/Net.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ade424a3ec6b740b086bd3d062edffb9 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Luneraria/Net/LuneTcp.cs b/Assets/Luneraria/Net/LuneTcp.cs new file mode 100644 index 0000000..be1faa2 --- /dev/null +++ b/Assets/Luneraria/Net/LuneTcp.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Luneraria.Core; + +namespace Luneraria.Net +{ + /// + /// TCP transport for LuneSession (the ENet peer of the reference). Reliable messages are + /// length-prefixed UTF-8 lines (LuneWire); combat frames are binary and tagged so a late + /// frame can be dropped by the session's serial. One socket per guest, peer 1 is the host. + /// Pump() must be called from the game loop; sockets never call into the session on their own. + /// + public sealed class LuneTcp : ILuneNetLink, IDisposable + { + const byte TagMessage = 1; + const byte TagFrame = 2; + + sealed class Guest + { + public int Peer; + public TcpClient Client; + public NetworkStream Stream; + public readonly List Buffer = new List(); + public bool Closed; + } + + readonly object _gate = new object(); + readonly Queue _mainThread = new Queue(); + TcpListener _listener; + TcpClient _client; + NetworkStream _clientStream; + readonly List _clientBuffer = new List(); + readonly Dictionary _guests = new Dictionary(); + int _nextPeer = 2; + Thread _acceptThread; + volatile bool _running; + + /// Session bound to this transport (set before Host/Join). + public LuneSession Session; + /// True while a host listener or a client socket is open. + public bool Open { get { return _running; } } + public string LastError = ""; + + // ---------------------------------------------------------------- host + public bool Host(int port) + { + try + { + _listener = new TcpListener(IPAddress.Any, port); + _listener.Start(); + _running = true; + _acceptThread = new Thread(AceptLoop) { IsBackground = true, Name = "LuneTcp accept" }; + _acceptThread.Start(); + return true; + } + catch (Exception error) + { + LastError = "Server auf Port " + port + " nicht startbar (" + error.Message + ")."; + return false; + } + } + + void AceptLoop() + { + while (_running && _listener != null) + { + TcpClient client; + try + { + client = _listener.AcceptTcpClient(); + } + catch + { + break; + } + lock (_gate) + { + int peer = _nextPeer++; + var guest = new Guest { Peer = peer, Client = client, Stream = client.GetStream() }; + _guests[peer] = guest; + _mainThread.Enqueue(() => + { + if (Session != null) + Session.OnMessage(peer, "__connected", null); + }); + var reader = new Thread(() => ReadLoop(guest)) { IsBackground = true, Name = "LuneTcp peer " + peer }; + reader.Start(); + } + } + } + + // ---------------------------------------------------------------- client + public bool Join(string address, int port) + { + try + { + _client = new TcpClient(); + _client.Connect(address, port); + _clientStream = _client.GetStream(); + _running = true; + var reader = new Thread(ClientReadLoop) { IsBackground = true, Name = "LuneTcp client" }; + reader.Start(); + _mainThread.Enqueue(() => + { + if (Session != null) + Session.OnClientConnected(); + }); + return true; + } + catch (Exception error) + { + LastError = "Verbindung zu " + address + ":" + port + " nicht möglich (" + error.Message + ")."; + _running = false; + return false; + } + } + + // ---------------------------------------------------------------- pump + /// Delivers queued socket events on the game thread. Call once per frame. + public void Pump() + { + while (true) + { + Action action; + lock (_gate) + { + if (_mainThread.Count == 0) + break; + action = _mainThread.Dequeue(); + } + action(); + } + } + + // ---------------------------------------------------------------- ILuneNetLink + public void Send(int peer, string kind, object payload) + { + byte[] body = System.Text.Encoding.UTF8.GetBytes(LuneWire.Encode(kind, payload)); + byte[] packet = Frame(TagMessage, body); + if (peer == 1) + { + WriteClient(packet); + return; + } + Guest guest; + lock (_gate) + { + if (!_guests.TryGetValue(peer, out guest) || guest.Closed) + return; + } + try + { + guest.Stream.Write(packet, 0, packet.Length); + } + catch + { + QueueDrop(guest.Peer); + } + } + + public void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp) + { + byte[] body = LuneWire.EncodeFrame(serial, elapsed, units, buildingHp); + byte[] packet = Frame(TagFrame, body); + if (peer == 1) + { + WriteClient(packet); + return; + } + Guest guest; + lock (_gate) + { + if (!_guests.TryGetValue(peer, out guest) || guest.Closed) + return; + } + try + { + guest.Stream.Write(packet, 0, packet.Length); + } + catch + { + QueueDrop(guest.Peer); + } + } + + void WriteClient(byte[] packet) + { + try + { + if (_clientStream != null) + _clientStream.Write(packet, 0, packet.Length); + } + catch + { + _mainThread.Enqueue(() => + { + if (Session != null) + Session.OnServerLost(); + }); + } + } + + static byte[] Frame(byte tag, byte[] body) + { + var packet = new byte[5 + body.Length]; + packet[0] = tag; + packet[1] = (byte)(body.Length & 0xff); + packet[2] = (byte)((body.Length >> 8) & 0xff); + packet[3] = (byte)((body.Length >> 16) & 0xff); + packet[4] = (byte)((body.Length >> 24) & 0xff); + Buffer.BlockCopy(body, 0, packet, 5, body.Length); + return packet; + } + + void ReadLoop(Guest guest) + { + var chunk = new byte[8192]; + while (_running && !guest.Closed) + { + int read; + try + { + read = guest.Stream.Read(chunk, 0, chunk.Length); + } + catch + { + read = 0; + } + if (read <= 0) + break; + for (int i = 0; i < read; i++) + guest.Buffer.Add(chunk[i]); + Drain(guest.Buffer, (tag, body) => Deliver(guest.Peer, tag, body)); + } + QueueDrop(guest.Peer); + } + + void ClientReadLoop() + { + var chunk = new byte[8192]; + while (_running) + { + int read; + try + { + read = _clientStream.Read(chunk, 0, chunk.Length); + } + catch + { + read = 0; + } + if (read <= 0) + break; + for (int i = 0; i < read; i++) + _clientBuffer.Add(chunk[i]); + Drain(_clientBuffer, (tag, body) => Deliver(1, tag, body)); + } + _mainThread.Enqueue(() => + { + if (Session != null) + Session.OnServerLost(); + }); + } + + static void Drain(List buffer, Action emit) + { + while (buffer.Count >= 5) + { + int length = buffer[1] | (buffer[2] << 8) | (buffer[3] << 16) | (buffer[4] << 24); + if (length < 0 || buffer.Count < 5 + length) + break; + byte tag = buffer[0]; + var body = new byte[length]; + for (int i = 0; i < length; i++) + body[i] = buffer[5 + i]; + buffer.RemoveRange(0, 5 + length); + emit(tag, body); + } + } + + void Deliver(int peer, byte tag, byte[] body) + { + if (tag == TagFrame) + { + int serial; + double elapsed; + float[] units; + float[] buildingHp; + LuneWire.DecodeFrame(body, out serial, out elapsed, out units, out buildingHp); + _mainThread.Enqueue(() => + { + if (Session != null) + Session.OnFrame(peer, serial, elapsed, units, buildingHp); + }); + return; + } + string line = System.Text.Encoding.UTF8.GetString(body); + _mainThread.Enqueue(() => + { + if (Session == null) + return; + string kind; + object payload; + LuneWire.Decode(line, out kind, out payload); + Session.OnMessage(peer, kind, payload); + }); + } + + void QueueDrop(int peer) + { + _mainThread.Enqueue(() => + { + Guest guest; + lock (_gate) + { + if (_guests.TryGetValue(peer, out guest)) + { + guest.Closed = true; + _guests.Remove(peer); + } + } + if (Session != null) + Session.OnPeerDisconnected(peer); + }); + } + + public void Close() + { + _running = false; + try + { + if (_listener != null) + _listener.Stop(); + } + catch + { + } + _listener = null; + try + { + if (_client != null) + _client.Close(); + } + catch + { + } + _client = null; + _clientStream = null; + lock (_gate) + { + foreach (Guest guest in _guests.Values) + { + guest.Closed = true; + try + { + guest.Client.Close(); + } + catch + { + } + } + _guests.Clear(); + } + } + + public void Dispose() + { + Close(); + } + } +} diff --git a/Assets/Luneraria/Net/LuneTcp.cs.meta b/Assets/Luneraria/Net/LuneTcp.cs.meta new file mode 100644 index 0000000..8b8c310 --- /dev/null +++ b/Assets/Luneraria/Net/LuneTcp.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c45d8ceac322a4ffeb1e173cdb3529b6 \ No newline at end of file diff --git a/Assets/Luneraria/Net/LuneWire.cs b/Assets/Luneraria/Net/LuneWire.cs new file mode 100644 index 0000000..69dab93 --- /dev/null +++ b/Assets/Luneraria/Net/LuneWire.cs @@ -0,0 +1,840 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Luneraria.Core; + +namespace Luneraria.Net +{ + /// + /// Compact cursor codec for the session messages (the Godot RPC payloads were Variants). + /// One message is one UTF-8 line: kind TAB payload. Compound values write their fields in a + /// fixed order; the reader consumes exactly as many tokens as the shape needs, so nesting + /// never collides with separators. No engine, no JSON library. + /// + public static class LuneWire + { + static readonly NumberFormatInfo Nfi = CultureInfo.InvariantCulture.NumberFormat; + const char Sep = '\x1f'; + + // ---------------------------------------------------------------- encode + public static string Encode(string kind, object payload) + { + var text = new StringBuilder(kind); + text.Append('\t'); + var parts = payload as object[]; + if (parts != null) + { + text.Append('i'); + text.Append(parts.Length.ToString(Nfi)); + for (int i = 0; i < parts.Length; i++) + { + text.Append(Sep); + WriteValue(text, parts[i]); + } + } + else + { + text.Append('i'); + text.Append('1'); + text.Append(Sep); + WriteValue(text, payload); + } + return text.ToString(); + } + + public static byte[] EncodeFrame(int serial, double elapsed, float[] units, float[] buildingHp) + { + using (var stream = new MemoryStream()) + using (var writer = new BinaryWriter(stream)) + { + writer.Write(serial); + writer.Write(elapsed); + writer.Write(units != null ? units.Length : 0); + if (units != null) + { + for (int i = 0; i < units.Length; i++) + writer.Write(units[i]); + } + writer.Write(buildingHp != null ? buildingHp.Length : 0); + if (buildingHp != null) + { + for (int i = 0; i < buildingHp.Length; i++) + writer.Write(buildingHp[i]); + } + return stream.ToArray(); + } + } + + static void WriteValue(StringBuilder text, object value) + { + if (value == null) + { + text.Append('~'); + return; + } + if (value is string) + { + text.Append('s'); + text.Append(Escape((string)value)); + return; + } + if (value is bool) + { + text.Append((bool)value ? 'B' : 'b'); + return; + } + if (value is int || value is long) + { + text.Append('i'); + text.Append(Convert.ToInt64(value).ToString(Nfi)); + return; + } + if (value is float || value is double) + { + text.Append('f'); + text.Append(Convert.ToDouble(value).ToString("R", Nfi)); + return; + } + if (value is float[]) + { + float[] array = (float[])value; + text.Append('a'); + text.Append(array.Length.ToString(Nfi)); + for (int i = 0; i < array.Length; i++) + { + text.Append(','); + text.Append(array[i].ToString("R", Nfi)); + } + return; + } + if (value is Phase) + { + text.Append('i'); + text.Append(((int)(Phase)value).ToString(Nfi)); + return; + } + if (value is Command) + { + text.Append('C'); + WriteCommand(text, (Command)value); + return; + } + if (value is MatchOptions) + { + text.Append('O'); + WriteOptions(text, (MatchOptions)value); + return; + } + if (value is LobbyEntry) + { + text.Append('L'); + WriteLobby(text, (LobbyEntry)value); + return; + } + if (value is List) + { + text.Append('l'); + WriteList(text, (List)value, WriteLobby); + return; + } + if (value is NetState) + { + text.Append('S'); + WriteState(text, (NetState)value); + return; + } + if (value is NetUnitRecord) + { + text.Append('U'); + WriteUnit(text, (NetUnitRecord)value); + return; + } + if (value is List) + { + text.Append('u'); + WriteList(text, (List)value, WriteUnit); + return; + } + throw new InvalidDataException("LuneWire cannot encode " + value.GetType().Name); + } + + static void WriteList(StringBuilder text, List list, Action write) + { + // Count rides in the tag body ("l2"); each write() starts with Sep before its first field. + text.Append(list.Count.ToString(Nfi)); + for (int i = 0; i < list.Count; i++) + write(text, list[i]); + } + + static void WriteCommand(StringBuilder text, Command command) + { + Str(text, command.Type ?? ""); + Str(text, command.TypeId); + Str(text, command.Lane); + Str(text, command.Uid); + Str(text, command.HeroId); + Str(text, command.ItemId); + Str(text, command.ChampionId); + Int(text, command.Tier); + Int(text, command.Choice); + } + + static void WriteOptions(StringBuilder text, MatchOptions options) + { + Int(text, options.Seed); + Int(text, options.HumanSlot); + Str(text, options.Bunds != null ? string.Join("\x1d", options.Bunds) : ""); + Str(text, options.Profiles != null ? string.Join("\x1d", options.Profiles) : ""); + } + + static void WriteLobby(StringBuilder text, LobbyEntry entry) + { + Int(text, entry.Peer); + Int(text, entry.Slot); + Str(text, entry.Name ?? ""); + Int(text, entry.Ready ? 1 : 0); + Int(text, entry.Bot ? 1 : 0); + } + + static void WriteState(StringBuilder text, NetState state) + { + Int(text, (int)state.Phase); + Int(text, state.Round); + Int(text, state.Winner); + Str(text, state.EndNote ?? ""); + var hp = state.BuildingHp ?? new Dictionary(); + Int(text, hp.Count); + foreach (KeyValuePair pair in hp) + { + Str(text, pair.Key); + Num(text, pair.Value); + } + BossState boss = state.Boss ?? new BossState(); + Int(text, boss.NextAvailableRound); + Int(text, boss.SettledRound); + Int(text, boss.Claim != null ? 1 : 0); + if (boss.Claim != null) + { + Int(text, boss.Claim.Team); + Int(text, boss.Claim.WonRound); + Int(text, boss.Claim.DeployRound); + Int(text, boss.Claim.ChooserSlot); + Str(text, boss.Claim.Lane ?? ""); + } + var cores = state.DestroyedCores ?? new List(); + Int(text, cores.Count); + foreach (int core in cores) + Int(text, core); + Settlement settle = state.LastSettlement; + Int(text, settle != null ? 1 : 0); + if (settle != null) + { + Int(text, settle.Round); + Int(text, settle.Income); + Str(text, settle.EndReason ?? ""); + Num(text, settle.CombatTime); + Str(text, settle.Boss ?? "none"); + int gains = settle.Gains != null ? settle.Gains.Length : 0; + Int(text, gains); + for (int i = 0; i < gains; i++) + Int(text, settle.Gains[i]); + var lost = settle.BuildingsLost ?? new List(); + Int(text, lost.Count); + foreach (string id in lost) + Str(text, id); + } + var players = state.Players ?? new List(); + Int(text, players.Count); + foreach (NetPlayerRow row in players) + WritePlayer(text, row); + } + + static void WritePlayer(StringBuilder text, NetPlayerRow row) + { + Int(text, row.Slot); + Str(text, row.Name ?? ""); + Int(text, row.IsHuman ? 1 : 0); + Int(text, row.Gold); + Int(text, row.Ready ? 1 : 0); + Int(text, row.Earned); + Int(text, row.Spent); + Int(text, row.NextSeq); + Int(text, row.Undo); + WriteRosterList(text, row.Roster); + Int(text, row.Hero != null ? 1 : 0); + if (row.Hero != null) + WriteHero(text, row.Hero); + WriteBookingList(text, row.Bookings); + Int(text, row.Draft != null ? 1 : 0); + if (row.Draft != null) + { + Draft draft = row.Draft; + Int(text, draft.Gold); + Int(text, draft.NextSeq); + Str(text, draft.BossLane ?? ""); + WriteRosterList(text, draft.Roster); + Int(text, draft.Hero != null ? 1 : 0); + if (draft.Hero != null) + WriteHero(text, draft.Hero); + WriteBookingList(text, draft.NewBookings); + } + } + + static void WriteRosterList(StringBuilder text, List list) + { + var rows = list ?? new List(); + Int(text, rows.Count); + foreach (RosterEntry entry in rows) + { + Str(text, entry.Uid ?? ""); + Int(text, entry.Seq); + Str(text, entry.TypeId ?? ""); + Str(text, entry.Lane ?? ""); + Int(text, entry.Invested); + Int(text, entry.Fresh ? 1 : 0); + } + } + + static void WriteBookingList(StringBuilder text, List list) + { + var rows = list ?? new List(); + Int(text, rows.Count); + foreach (Booking booking in rows) + { + Str(text, booking.Uid ?? ""); + Int(text, booking.Seq); + Str(text, booking.ChampionId ?? ""); + Str(text, booking.Lane ?? ""); + Int(text, booking.Cost); + Int(text, booking.DeployRound); + } + } + + static void WriteHero(StringBuilder text, HeroState hero) + { + Str(text, hero.Uid ?? ""); + Int(text, hero.Seq); + Str(text, hero.HeroId ?? ""); + Str(text, hero.Lane ?? ""); + Int(text, hero.Xp); + Int(text, hero.Invested); + Int(text, hero.Fresh ? 1 : 0); + Str(text, hero.Items != null ? string.Join("\x1d", hero.Items) : ""); + Str(text, hero.Talents != null ? string.Join("\x1d", hero.Talents) : ""); + Str(text, hero.FreshItems != null ? string.Join("\x1d", hero.FreshItems) : ""); + } + + static void WriteUnit(StringBuilder text, NetUnitRecord unit) + { + Int(text, unit.Id); + Str(text, unit.Kind ?? ""); + Int(text, unit.Team); + Int(text, unit.TeamIndex); + Int(text, unit.OwnerSlot); + Str(text, unit.Uid ?? ""); + Str(text, unit.Lane ?? ""); + Str(text, unit.Zone ?? ""); + Num(text, unit.X); + Num(text, unit.Y); + Num(text, unit.Facing); + Num(text, unit.Hp); + Num(text, unit.MaxHp); + Int(text, unit.Alive ? 1 : 0); + UnitDef def = unit.Def ?? new UnitDef(); + Str(text, def.Id ?? ""); + Str(text, def.DisplayName ?? ""); + Int(text, def.Cost); + Str(text, def.UpgradeId ?? ""); + Num(text, def.MaxHp); + Num(text, def.Damage); + Int(text, def.AttackType); + Int(text, def.ArmorType); + Num(text, def.AttackRange); + Num(text, def.Speed); + Str(text, def.Ability ?? ""); + Int(text, def.LeakValue); + Int(text, def.Bounty); + Str(text, def.AbilityDescription ?? ""); + Int(text, def.IsBoss ? 1 : 0); + Int(text, def.IncomeGain); + Str(text, def.Role ?? ""); + Str(text, def.HeroId ?? ""); + Int(text, def.Level); + Num(text, def.ViewScale); + Num(text, def.Regen); + Num(text, def.ItemSplash); + Num(text, def.DamageBonus); + Num(text, def.ReductionBonus); + Num(text, def.AttackSpeedBonus); + Str(text, def.ChampionId ?? ""); + Str(text, def.Priority ?? ""); + Num(text, def.SiegeFactor); + Num(text, def.HpFactor); + Str(text, def.Tier ?? ""); + Str(text, def.Talents != null ? string.Join("\x1d", def.Talents) : ""); + } + + static void Str(StringBuilder text, string value) + { + text.Append(Sep); + if (value == null) + text.Append('~'); + else + { + text.Append('s'); + text.Append(Escape(value)); + } + } + + static void Int(StringBuilder text, int? value) + { + text.Append(Sep); + if (value == null) + text.Append('~'); + else + { + text.Append('i'); + text.Append(value.Value.ToString(Nfi)); + } + } + + static void Num(StringBuilder text, double value) + { + text.Append(Sep); + text.Append('f'); + text.Append(value.ToString("R", Nfi)); + } + + static string Escape(string value) + { + return value.Replace("\\", "\\\\").Replace("\n", "\\n").Replace("\t", "\\t") + .Replace("\x1d", "\\r").Replace("\x1e", "\\e").Replace("\x1f", "\\f"); + } + + static string Unescape(string value) + { + // Order matters: the two-character escapes first, the literal backslash last. + return value.Replace("\\f", "\x1f").Replace("\\e", "\x1e").Replace("\\r", "\x1d") + .Replace("\\t", "\t").Replace("\\n", "\n").Replace("\\\\", "\\"); + } + + // ---------------------------------------------------------------- decode + public static void Decode(string line, out string kind, out object payload) + { + int tab = line.IndexOf('\t'); + kind = tab < 0 ? line : line.Substring(0, tab); + string body = tab < 0 ? "" : line.Substring(tab + 1); + if (body.Length == 0) + { + payload = null; + return; + } + var reader = new Reader(body); + // Leading token is the top-level value count (1 = single payload, n = object[]). + int count = reader.Integer(1); + if (count <= 1) + { + payload = reader.Value(); + return; + } + var parts = new object[count]; + for (int i = 0; i < count; i++) + parts[i] = reader.Value(); + payload = parts; + } + + public static void DecodeFrame(byte[] buffer, out int serial, out double elapsed, out float[] units, out float[] buildingHp) + { + using (var stream = new MemoryStream(buffer)) + using (var reader = new BinaryReader(stream)) + { + serial = reader.ReadInt32(); + elapsed = reader.ReadDouble(); + int unitCount = reader.ReadInt32(); + units = new float[unitCount]; + for (int i = 0; i < unitCount; i++) + units[i] = reader.ReadSingle(); + int hpCount = reader.ReadInt32(); + buildingHp = new float[hpCount]; + for (int i = 0; i < hpCount; i++) + buildingHp[i] = reader.ReadSingle(); + } + } + + /// Token cursor over a Sep-separated payload. Tagged values: ~ null, s string, i int, f float, B/b bool, a float[], C/O/L/l/S/U/u compounds. + sealed class Reader + { + readonly string[] _tokens; + int _at; + + public Reader(string body) + { + _tokens = body.Split(Sep); + _at = 0; + } + + public int Remaining { get { return _tokens.Length - _at; } } + + string Take() + { + if (_at >= _tokens.Length) + throw new InvalidDataException("LuneWire: payload ends early"); + return _tokens[_at++]; + } + + public object Value() + { + string token = Take(); + if (token.Length == 0 || token[0] == '~') + return null; + char tag = token[0]; + string body = token.Substring(1); + switch (tag) + { + case 's': + return Unescape(body); + case 'B': + return true; + case 'b': + return false; + case 'i': + return int.Parse(body, Nfi); + case 'f': + return double.Parse(body, Nfi); + case 'a': + { + string[] parts = body.Split(','); + var data = new float[Math.Max(0, parts.Length - 1)]; + for (int i = 1; i < parts.Length; i++) + data[i - 1] = float.Parse(parts[i], Nfi); + return data; + } + case 'C': + return ReadCommand(); + case 'O': + return ReadOptions(); + case 'L': + return ReadLobby(); + case 'l': + return ReadLobbyList(body); + case 'S': + return ReadState(); + case 'U': + return ReadUnit(); + case 'u': + return ReadUnitList(body); + default: + throw new InvalidDataException("LuneWire tag " + tag); + } + } + + string Text() + { + object value = Value(); + return value as string; + } + + public int Integer(int fallback = 0) + { + object value = Value(); + if (value == null) + return fallback; + return Convert.ToInt32(value); + } + + double Number(double fallback = 0.0) + { + object value = Value(); + if (value == null) + return fallback; + return Convert.ToDouble(value); + } + + bool Flag() + { + object value = Value(); + if (value == null) + return false; + if (value is bool) + return (bool)value; + return Convert.ToInt32(value) != 0; + } + + Command ReadCommand() + { + return new Command + { + Type = Text() ?? "", + TypeId = Text(), + Lane = Text(), + Uid = Text(), + HeroId = Text(), + ItemId = Text(), + ChampionId = Text(), + Tier = ReadNullableInt(), + Choice = ReadNullableInt() + }; + } + + int? ReadNullableInt() + { + object value = Value(); + if (value == null) + return null; + return Convert.ToInt32(value); + } + + MatchOptions ReadOptions() + { + var options = new MatchOptions { Seed = Integer(1), HumanSlot = Integer(1) }; + string bunds = Text(); + if (!string.IsNullOrEmpty(bunds)) + options.Bunds.AddRange(bunds.Split('\x1d')); + string profiles = Text(); + if (!string.IsNullOrEmpty(profiles)) + options.Profiles.AddRange(profiles.Split('\x1d')); + return options; + } + + LobbyEntry ReadLobby() + { + return new LobbyEntry + { + Peer = Integer(), + Slot = Integer(), + Name = Text() ?? "", + Ready = Flag(), + Bot = Flag() + }; + } + + List ReadLobbyList(string body) + { + int count = body.Length > 0 ? int.Parse(body, Nfi) : 0; + var list = new List(count); + for (int i = 0; i < count; i++) + list.Add(ReadLobby()); + return list; + } + + NetState ReadState() + { + var state = new NetState + { + Phase = (Phase)Integer(), + Round = Integer(), + Winner = Integer(), + EndNote = Text() ?? "" + }; + int hpCount = Integer(); + for (int i = 0; i < hpCount; i++) + { + string id = Text() ?? ""; + state.BuildingHp[id] = Number(); + } + state.Boss = new BossState { NextAvailableRound = Integer(), SettledRound = Integer() }; + if (Flag()) + { + state.Boss.Claim = new BossClaim + { + Team = Integer(), + WonRound = Integer(), + DeployRound = Integer(), + ChooserSlot = Integer(), + Lane = Text() ?? "" + }; + } + int coreCount = Integer(); + for (int i = 0; i < coreCount; i++) + state.DestroyedCores.Add(Integer()); + if (Flag()) + { + var settle = new Settlement + { + Round = Integer(), + Income = Integer(), + EndReason = Text() ?? "", + CombatTime = Number(), + Boss = Text() ?? "none" + }; + int gains = Integer(); + for (int i = 0; i < gains && i < settle.Gains.Length; i++) + settle.Gains[i] = Integer(); + int lost = Integer(); + for (int i = 0; i < lost; i++) + settle.BuildingsLost.Add(Text() ?? ""); + state.LastSettlement = settle; + } + int players = Integer(); + for (int i = 0; i < players; i++) + state.Players.Add(ReadPlayer()); + return state; + } + + NetPlayerRow ReadPlayer() + { + var row = new NetPlayerRow + { + Slot = Integer(), + Name = Text() ?? "", + IsHuman = Flag(), + Gold = Integer(), + Ready = Flag(), + Earned = Integer(), + Spent = Integer(), + NextSeq = Integer(), + Undo = Integer() + }; + row.Roster = ReadRosterList(); + if (Flag()) + row.Hero = ReadHero(); + row.Bookings = ReadBookingList(); + if (Flag()) + { + var draft = new Draft { Gold = Integer(), NextSeq = Integer(), BossLane = Text() ?? "" }; + draft.Roster = ReadRosterList(); + if (Flag()) + draft.Hero = ReadHero(); + draft.NewBookings = ReadBookingList(); + row.Draft = draft; + } + return row; + } + + List ReadRosterList() + { + int count = Integer(); + var list = new List(count); + for (int i = 0; i < count; i++) + { + list.Add(new RosterEntry + { + Uid = Text() ?? "", + Seq = Integer(), + TypeId = Text() ?? "", + Lane = Text() ?? "", + Invested = Integer(), + Fresh = Flag() + }); + } + return list; + } + + List ReadBookingList() + { + int count = Integer(); + var list = new List(count); + for (int i = 0; i < count; i++) + { + list.Add(new Booking + { + Uid = Text() ?? "", + Seq = Integer(), + ChampionId = Text() ?? "", + Lane = Text() ?? "", + Cost = Integer(), + DeployRound = Integer() + }); + } + return list; + } + + HeroState ReadHero() + { + var hero = new HeroState + { + Uid = Text() ?? "", + Seq = Integer(), + HeroId = Text() ?? "", + Lane = Text() ?? "", + Xp = Integer(), + Invested = Integer(), + Fresh = Flag() + }; + AddSplit(hero.Items, Text()); + AddSplit(hero.Talents, Text()); + AddSplit(hero.FreshItems, Text()); + return hero; + } + + static void AddSplit(List target, string value) + { + target.Clear(); + if (string.IsNullOrEmpty(value)) + return; + foreach (string part in value.Split('\x1d')) + { + if (part.Length > 0) + target.Add(part); + } + } + + NetUnitRecord ReadUnit() + { + var unit = new NetUnitRecord + { + Id = Integer(), + Kind = Text() ?? "", + Team = Integer(), + TeamIndex = Integer(), + OwnerSlot = Integer(), + Uid = Text() ?? "", + Lane = Text() ?? "", + Zone = Text() ?? "", + X = Number(), + Y = Number(), + Facing = Number(), + Hp = Number(), + MaxHp = Number(), + Alive = Flag(), + Def = new UnitDef + { + Id = Text() ?? "", + DisplayName = Text() ?? "", + Cost = Integer(), + UpgradeId = Text() ?? "", + MaxHp = Number(1.0), + Damage = Number(), + AttackType = Integer(), + ArmorType = Integer(), + AttackRange = Number(1.0), + Speed = Number(2.0), + Ability = Text() ?? "", + LeakValue = Integer(), + Bounty = Integer(), + AbilityDescription = Text() ?? "", + IsBoss = Flag(), + IncomeGain = Integer(), + Role = Text() ?? "", + HeroId = Text() ?? "", + Level = Integer(1), + ViewScale = Number(1.0), + Regen = Number(), + ItemSplash = Number(), + DamageBonus = Number(), + ReductionBonus = Number(), + AttackSpeedBonus = Number(), + ChampionId = Text() ?? "", + Priority = Text() ?? "", + SiegeFactor = Number(1.0), + HpFactor = Number(1.0), + Tier = Text() ?? "" + } + }; + AddSplit(unit.Def.Talents, Text()); + return unit; + } + + List ReadUnitList(string body) + { + int count = body.Length > 0 ? int.Parse(body, Nfi) : 0; + var list = new List(count); + for (int i = 0; i < count; i++) + list.Add(ReadUnit()); + return list; + } + } + } +} diff --git a/Assets/Luneraria/Net/LuneWire.cs.meta b/Assets/Luneraria/Net/LuneWire.cs.meta new file mode 100644 index 0000000..0ea5297 --- /dev/null +++ b/Assets/Luneraria/Net/LuneWire.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a70c5b2967c2445619bb789979871fad \ No newline at end of file diff --git a/Assets/Luneraria/Net/Luneraria.Net.asmdef b/Assets/Luneraria/Net/Luneraria.Net.asmdef new file mode 100644 index 0000000..071bb32 --- /dev/null +++ b/Assets/Luneraria/Net/Luneraria.Net.asmdef @@ -0,0 +1,16 @@ +{ + "name": "Luneraria.Net", + "rootNamespace": "Luneraria.Net", + "references": [ + "Luneraria.Core" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": true +} diff --git a/Assets/Luneraria/Net/Luneraria.Net.asmdef.meta b/Assets/Luneraria/Net/Luneraria.Net.asmdef.meta new file mode 100644 index 0000000..9839cf4 --- /dev/null +++ b/Assets/Luneraria/Net/Luneraria.Net.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 593ed984c4ffe4880a001bc56139f673 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Luneraria/Tests/Core/CheckSuite.cs b/Assets/Luneraria/Tests/Core/CheckSuite.cs index 4de8e99..c38c2c6 100644 --- a/Assets/Luneraria/Tests/Core/CheckSuite.cs +++ b/Assets/Luneraria/Tests/Core/CheckSuite.cs @@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests public static IList All() { - return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite() }; + return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite() }; } } } diff --git a/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs b/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs new file mode 100644 index 0000000..1e283cd --- /dev/null +++ b/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs @@ -0,0 +1,190 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Luneraria.Core.Tests +{ + /// + /// Multi-round loopback with two sessions (port of tools/luneraria/net_rounds.py, 12 checks). + /// Host and guest play five rounds through the real LuneSession; after every settlement the + /// public checksums must agree. Guest A leaves during the combat of round 2, the bot takes + /// over, guest B returns with the same display name and must get the same seat back. The + /// guest never runs a combat step and receives host snapshots. This is a local in-process + /// loopback, not a six-player internet match. + /// + public sealed class NetRoundsSuite : CheckSuite + { + public override string Name { get { return "net_rounds"; } } + + const int Rounds = 5; + const int DropRound = 2; + /// Host combat steps per tick (net_pair.gd steps_per_frame); snapshots still flow. + const int StepsPerTick = 8; + + sealed class CombatLog + { + public int Round; + public int Frames; + public int LocalSteps; + } + + readonly Dictionary _hostSums = new Dictionary(); + readonly Dictionary _guestSums = new Dictionary(); + readonly List _combats = new List(); + readonly List _seatsA = new List(); + readonly List _seatsB = new List(); + readonly List _hostMessages = new List(); + readonly List _errors = new List(); + + protected override void RunChecks() + { + var hub = new LuneLoopback(); + LuneSession host = hub.Attach(1, new LuneSession()); + host.SessionMessage += (text, isError) => + { + if (isError) + _errors.Add("host: " + text); + else + _hostMessages.Add(text); + }; + var options = new MatchOptions + { + Seed = 42, + HumanSlot = 0, + Bunds = new List { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" } + }; + if (!host.HostNetwork(options, "Host")) + { + Check(false, "host starts: " + host.LastError); + return; + } + + const int peerA = 10; + const int peerB = 11; + LuneSession guest = Join(hub, peerA, "Gast", _seatsA, _errors); + bool spoofRejected = !guest.HostCommand(1, 5, Command.Buy("w01")).Ok; + bool dropped = false; + int hostPlannedRound = 0; + int guestPlannedRound = 0; + int framesAtSegment = 0; + var current = new CombatLog(); + + for (int guard = 0; guard < 8000 && host.MatchState.Phase != Phase.Over && host.MatchState.RoundNumber <= Rounds + 1; guard++) + { + LuneMatch match = host.MatchState; + if (match.Phase == Phase.Planning) + { + if (match.RoundNumber > Rounds) + break; + host.PlanBotSeats(); + PlanOnce(host, ref hostPlannedRound, host.LocalSlot, true); + if (guest != null && guest.MatchState != null && guest.LocalSlot >= 0) + PlanOnce(guest, ref guestPlannedRound, guest.LocalSlot, false); + } + else if (match.Phase == Phase.Combat) + { + if (current.Round != match.RoundNumber) + { + current = new CombatLog { Round = match.RoundNumber }; + _combats.Add(current); + framesAtSegment = guest != null ? guest.FramesReceived : 0; + } + for (int step = 0; step < StepsPerTick && match.Phase == Phase.Combat; step++) + match.StepCombat(); + host.HostTick(LuneNet.FrameInterval); + if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null) + { + current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment); + current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps); + } + // Leave on a snapshot of the combat of DropRound: short combats may send only a few. + if (!dropped && hub.Has(peerA) && match.RoundNumber == DropRound && current.Frames > 0) + { + if (guest != null) + current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment); + dropped = true; + hub.Detach(peerA); + // Guest B returns with the same display name while the seat is bot-held. + guest = Join(hub, peerB, "Gast", _seatsB, _errors); + guestPlannedRound = 0; + framesAtSegment = guest.FramesReceived; + } + } + + // Settlement: the phase left combat in this or a previous step. + if (current.Round != 0 && match.Phase != Phase.Combat) + { + if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null) + { + current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment); + current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps); + } + host.HostAfterCombat(); + NoteSums(host, guest, current.Round); + current = new CombatLog(); + } + } + if (current.Round != 0) + { + host.HostAfterCombat(); + NoteSums(host, guest, current.Round); + } + + // ---------------------------------------------------------------- 12 checks + List compared = _hostSums.Keys.Where(r => _guestSums.ContainsKey(r)).OrderBy(r => r).ToList(); + List mismatches = compared.Where(r => _hostSums[r] != _guestSums[r]).ToList(); + Check(host.MatchState != null, "host_ok"); + Check(dropped, "guest_a_ok (left during the combat)"); + Check(_seatsB.Count > 0, "guest_b_ok"); + Check(spoofRejected, "spoof_rejected"); + Check(compared.Count >= Rounds - 1, "rounds_compared " + compared.Count + " of " + Rounds + " (" + string.Join(",", compared) + ")"); + Check(mismatches.Count == 0 && compared.Count > 0, "checksums_equal " + string.Join(",", mismatches)); + Check(_combats.Count > 0 && _combats.All(c => c.LocalSteps == 0), "client_never_simulates " + string.Join(",", _combats.Select(c => c.Round + ":" + c.LocalSteps))); + Check(_combats.Count > 0 && _combats.All(c => c.Frames > 0), "client_receives_frames " + string.Join(",", _combats.Select(c => c.Round + ":" + c.Frames))); + Check(_seatsA.Count > 0 && _seatsB.Count > 0 && _seatsB[0] == _seatsA[0], "rejoin_same_seat a=" + string.Join(",", _seatsA) + " b=" + string.Join(",", _seatsB)); + Check(_hostMessages.Any(m => m.Contains("Slot wird vom Bot übernommen")), "bot_took_over"); + Check(_hostMessages.Any(m => m.Contains("kehrt zurück auf")), "rejoin_announced"); + Check(_errors.Count == 0, "no_script_errors " + string.Join(";", _errors)); + } + + static LuneSession Join(LuneLoopback hub, int peer, string name, List seats, List errors) + { + LuneSession guest = hub.Attach(peer, new LuneSession()); + guest.SessionMessage += (text, isError) => + { + if (isError) + errors.Add("guest " + peer + ": " + text); + }; + guest.MatchResetup += () => seats.Add(guest.LocalSlot); + guest.JoinNetwork(name); + guest.OnClientConnected(); + return guest; + } + + static void PlanOnce(LuneSession session, ref int plannedRound, int slot, bool waitForPeer) + { + LuneMatch match = session.MatchState; + if (slot < 0 || plannedRound == match.RoundNumber || match.Players[slot].Ready) + return; + // The host waits for a seated guest in every planning phase (covers the rejoin case). + if (waitForPeer && session.SeatPeers.Count < 2) + return; + plannedRound = match.RoundNumber; + List ids = match.BaseIds(match.Players[slot].Bund); + int gold = match.GoldOf(slot); + List affordable = ids.Where(id => DataCatalog.GetUnit(id).Cost <= gold).ToList(); + if (affordable.Count > 0) + session.Submit(Command.Buy(affordable[match.RoundNumber % affordable.Count])); + session.ReadyUp(); + } + + void NoteSums(LuneSession host, LuneSession guest, int fought) + { + if (fought < 1 || host.MatchState == null) + return; + _hostSums[fought] = LuneSession.Checksum(host.MatchState); + if (guest != null && guest.MatchState != null) + _guestSums[fought] = LuneSession.Checksum(guest.MatchState); + } + } +} diff --git a/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta b/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta new file mode 100644 index 0000000..c0e6b53 --- /dev/null +++ b/Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 13157be6de19046ca982d224c4d7393c \ No newline at end of file diff --git a/Assets/Luneraria/Tests/Core/NetSuite.cs b/Assets/Luneraria/Tests/Core/NetSuite.cs new file mode 100644 index 0000000..d372c76 --- /dev/null +++ b/Assets/Luneraria/Tests/Core/NetSuite.cs @@ -0,0 +1,228 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Luneraria.Core.Tests +{ + /// + /// Network protocol tests for the authoritative LuneSession (port of + /// saumwacht-godot/tests/luneraria/test_lune_net.gd, 43 checks). No rendering, no sockets: + /// the host entries take a peer id directly. Covers foreign seats, stale commands, double + /// purchase, bot takeover on disconnect and the rule that a disconnect is never a core loss. + /// + public sealed class NetSuite : CheckSuite + { + public override string Name { get { return "net"; } } + + static LuneSession NewHost(int seed = 5) + { + var session = new LuneSession(); + session.HostOffline(0, new MatchOptions + { + Seed = seed, + HumanSlot = 0, + Bunds = new List { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" } + }); + return session; + } + + protected override void RunChecks() + { + SeatOwnership(); + StaleCommand(); + DoublePurchase(); + ForeignUid(); + DisconnectIsNotCoreLoss(); + SixSeatsAndPhases(); + HelloAndRejoin(); + TakeoverAndBotOnce(); + ReadyCommitsAndPrivacy(); + } + + void SeatOwnership() + { + LuneSession session = NewHost(); + session.SeatPeer(1, 42, "Gast"); + Check(session.SeatOfPeer(42) == 1, "peer is seated on slot 1"); + // Peer 42 tries to act as slot 3. + SessionResult spoofed = session.HostCommand(42, 3, Command.Buy("w01")); + Check(!spoofed.Ok, "command from another seat is rejected"); + // Unseated peer. + SessionResult orphan = session.HostCommand(99, 0, Command.Buy("w01")); + Check(!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). + SessionResult own = session.HostCommand(1, 0, Command.Buy(session.MatchState.BaseIds("glutpakt")[0])); + Check(own.Ok, "the seat owner may buy"); + session.Close(); + } + + void StaleCommand() + { + LuneSession session = NewHost(); + session.HostReady(0); + for (int slot = 1; slot < 6; slot++) + session.HostReady(slot); + Check(session.MatchState.Phase == Phase.Combat, "all ready starts combat"); + SessionResult late = session.HostCommand(1, 0, Command.Buy("w01")); + Check(!late.Ok && (late.Message.Contains("spät") || late.Message.Contains("vorbei")), + "stale planning command after the phase closed is rejected"); + session.Close(); + } + + void DoublePurchase() + { + LuneSession session = NewHost(); + List ids = session.MatchState.BaseIds("glutpakt"); + int cost = DataCatalog.GetUnit(ids[0]).Cost; + session.MatchState.Players[0].Draft.Gold = 1000; + SessionResult first = session.HostCommand(1, 0, Command.Buy(ids[0])); + int goldAfterFirst = session.MatchState.Players[0].Draft.Gold; + int rosterAfterFirst = session.MatchState.Players[0].Draft.Roster.Count; + Check(first.Ok && rosterAfterFirst == 1 && goldAfterFirst == 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.HostCommand(1, 0, Command.Buy(ids[0])); + int rosterAfter = session.MatchState.Players[0].Draft.Roster.Count; + int goldAfter = session.MatchState.Players[0].Draft.Gold; + Check(rosterAfter == rosterAfterFirst + 1, "a repeated buy creates one more unit, not a free clone"); + Check(goldAfter == goldAfterFirst - cost, "the repeated buy also pays exactly once"); + var uids = new HashSet(); + foreach (RosterEntry entry in session.MatchState.Players[0].Draft.Roster) + uids.Add(entry.Uid); + Check(uids.Count == rosterAfter, "every owned unit has a unique id"); + session.Close(); + } + + void ForeignUid() + { + LuneSession session = NewHost(); + session.SeatPeer(1, 7, "Gast"); + List ids = session.MatchState.BaseIds("glutpakt"); + session.MatchState.Players[0].Draft.Gold = 500; + session.HostCommand(1, 0, Command.Buy(ids[0])); + string uid = session.MatchState.Players[0].Draft.Roster[0].Uid; + // Guest tries to upgrade the host's unit. + SessionResult steal = session.HostCommand(7, 1, Command.Upgrade(uid)); + Check(!steal.Ok, "a guest cannot upgrade a foreign unit id"); + // Guest tries to act as the host seat. + SessionResult hijack = session.HostCommand(7, 0, Command.Upgrade(uid)); + Check(!hijack.Ok, "a guest cannot act on the host seat"); + // Owner may upgrade their own unit. + string upgradeId = DataCatalog.UpgradeOf(ids[0]) ?? ""; + if (upgradeId.Length > 0) + { + session.MatchState.Players[0].Draft.Gold = 500; + SessionResult ok = session.HostCommand(1, 0, Command.Upgrade(uid)); + Check(ok.Ok, "the owner may upgrade their own unit"); + } + session.Close(); + } + + void DisconnectIsNotCoreLoss() + { + LuneSession session = NewHost(); + session.SeatPeer(1, 50, "Gast"); + session.SeatPeer(2, 51, "Gast2"); + double core0 = session.MatchState.CoreHp(0); + double core1 = session.MatchState.CoreHp(1); + session.OnPeerDisconnected(50); + Check(session.MatchState.CoreHp(0) == core0 && session.MatchState.CoreHp(1) == core1, + "disconnect does not damage either Ancient"); + Check(session.MatchState.Phase != Phase.Over, "disconnect does not end the match"); + Check(session.MatchState.Winner == LuneMatch.Ongoing, "disconnect is not a core victory"); + Check(!session.SeatPeers.ContainsKey(1), "the lost seat is no longer peer-held"); + bool botPlanned = false; + foreach (LobbyEntry entry in session.Lobby) + { + if (entry.Slot == 1) + botPlanned = entry.Bot && entry.Ready; + } + Check(botPlanned, "the disconnected seat is taken over by a bot"); + session.Close(); + } + + void SixSeatsAndPhases() + { + LuneSession session = NewHost(); + Check(session.MatchState.Players.Count == 6, "six seats exist"); + var teams = new Dictionary(); + foreach (Player player in session.MatchState.Players) + { + int count; + teams.TryGetValue(player.Team, out count); + teams[player.Team] = count + 1; + } + Check(teams.Count == 2 && teams[0] == 3 && teams[1] == 3, "two teams of three"); + Check(session.MatchState.Map.Buildings.Count(d => d.Tier != "core") == 16, "sixteen towers"); + Check(session.MatchState.Map.Buildings.Count(d => d.Tier == "core") == 2, "two Ancients"); + session.HostReady(0); + for (int slot = 1; slot < 6; slot++) + session.HostReady(slot); + session.PlanBotSeats(); + // Bots already planned via HostBeginCombat; ensure a full round works offline-style. + if (session.MatchState.Phase == Phase.Planning) + session.HostBeginCombat(); + Check(session.MatchState.Phase == Phase.Combat, "combat starts when everyone is ready"); + session.HostFinishCombat(); + Check(session.MatchState.Phase == Phase.Planning || session.MatchState.Phase == Phase.Over, + "settlement returns to planning or ends"); + session.Close(); + } + + void HelloAndRejoin() + { + LuneSession session = NewHost(); + Check(session.HostHello(60, LuneNet.Protocol - 1, "Alt") == -1, "an older protocol is refused"); + Check(session.SeatOfPeer(60) < 0, "a refused peer holds no seat"); + int anna = session.HostHello(61, LuneNet.Protocol, "Anna"); + Check(anna == 1, "the first guest takes the first free seat"); + Check(session.HostHello(61, LuneNet.Protocol, "Anna") == anna, "a repeated hello keeps the one seat"); + session.OnPeerDisconnected(61); + Check(session.MatchState.Players[anna].Ready && !session.MatchState.Players[anna].IsHuman, + "a departed seat is a ready bot"); + int bert = session.HostHello(62, LuneNet.Protocol, "Bert"); + Check(bert >= 0 && bert != anna, "a new guest does not take a seat reserved for rejoining"); + int back = session.HostHello(63, LuneNet.Protocol, "Anna"); + Check(back == anna, "a returning guest gets the same seat back by name"); + Check(session.MatchState.Players[anna].IsHuman && !session.MatchState.Players[anna].Ready, + "the returned seat is human and must ready up"); + session.Close(); + } + + void TakeoverAndBotOnce() + { + LuneSession session = NewHost(); + session.PlanBotSeats(); + Player bot = session.MatchState.Players[2]; + int spentByBot = bot.Gold - bot.Draft.Gold; + Check(spentByBot > 0, "a bot seat plans and spends gold"); + session.PlanBotSeats(); + Check(bot.Gold - bot.Draft.Gold == spentByBot, "a bot seat plans only once per planning phase"); + session.SeatPeer(2, 70, "Cora"); + Check(bot.Draft.Gold == bot.Gold && bot.Draft.Roster.Count == 0, + "a takeover in planning drops the bot's uncommitted draft"); + Check(!bot.Ready, "the new human must ready up"); + session.Close(); + } + + void ReadyCommitsAndPrivacy() + { + LuneSession session = NewHost(); + session.SeatPeer(1, 80, "Dora"); + List ids = session.MatchState.BaseIds("wurzelbund"); + Check(session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "the guest buys during planning"); + Check(session.HostCommand(80, 1, new Command { Type = "undo" }).Ok, "undo goes through the host"); + Check(session.MatchState.Players[1].Draft.Roster.Count == 0, "undo removed the purchase"); + session.HostCommand(80, 1, Command.Buy(ids[0])); + session.HostReady(1); + Check(!session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "after ready the plan is binding"); + NetState state = session.StateFor(1); + List drafts = state.Players.Where(row => row.Draft != null).ToList(); + Check(drafts.Count == 1 && drafts[0].Slot == 1, "a seat receives only its own draft"); + Check(session.MatchState.Phase == Phase.Planning, "combat waits for the host seat"); + session.HostReady(0); + Check(session.MatchState.Phase == Phase.Combat, "combat starts once every human is ready"); + session.Close(); + } + } +} diff --git a/Assets/Luneraria/Tests/Core/NetSuite.cs.meta b/Assets/Luneraria/Tests/Core/NetSuite.cs.meta new file mode 100644 index 0000000..9897a43 --- /dev/null +++ b/Assets/Luneraria/Tests/Core/NetSuite.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 88ddfdbec08764d09985b51f6de3fd1f \ No newline at end of file diff --git a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs index 4e54a2f..53800a1 100644 --- a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs +++ b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs @@ -49,5 +49,7 @@ namespace Luneraria.Core.Tests [Test] public void M2Rules() { RunSuite(new M2RulesSuite()); } [Test] public void GoldenReplays() { RunSuite(new GoldenSuite()); } [Test] public void Determinism() { RunSuite(new DeterminismSuite()); } + [Test] public void NetProtocol() { RunSuite(new NetSuite()); } + [Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); } } } diff --git a/Assets/Luneraria/Tests/Net.meta b/Assets/Luneraria/Tests/Net.meta new file mode 100644 index 0000000..f04feaa --- /dev/null +++ b/Assets/Luneraria/Tests/Net.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5914768ea6d0240b7b72fca4a7e75b0b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Luneraria/Tests/Net/LuneWireTests.cs b/Assets/Luneraria/Tests/Net/LuneWireTests.cs new file mode 100644 index 0000000..985bd97 --- /dev/null +++ b/Assets/Luneraria/Tests/Net/LuneWireTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using Luneraria.Core; +using Luneraria.Net; +using NUnit.Framework; + +namespace Luneraria.Net.Tests +{ + /// Round-trip checks for the wire codec (LuneWire) and a localhost TCP session. + public sealed class LuneWireTests + { + [Test] + public void CommandRoundTrips() + { + Command original = Command.Buy("g01", "mid"); + string line = LuneWire.Encode(LuneNet.KSubmit, new object[] { 3, original }); + string kind; + object payload; + LuneWire.Decode(line, out kind, out payload); + Assert.AreEqual(LuneNet.KSubmit, kind); + var parts = (object[])payload; + Assert.AreEqual(3, parts[0]); + var copy = (Command)parts[1]; + Assert.AreEqual("buy", copy.Type); + Assert.AreEqual("g01", copy.TypeId); + Assert.AreEqual("mid", copy.Lane); + } + + [Test] + public void StateRoundTrips() + { + var state = new NetState + { + Phase = Phase.Planning, + Round = 7, + Winner = LuneMatch.Ongoing, + EndNote = "Ancient von Blau zerstört.", + LastSettlement = new Settlement { Round = 6, Income = 90, EndReason = "cleared", CombatTime = 12.5, Boss = "won" } + }; + state.BuildingHp["blau_ancient"] = 1200.5; + state.BuildingHp["rot_top_outer"] = 0.0; + state.DestroyedCores.Add(1); + state.Boss.NextAvailableRound = 4; + state.Boss.Claim = new BossClaim { Team = 0, WonRound = 3, DeployRound = 5, ChooserSlot = 1, Lane = "mid" }; + var row = new NetPlayerRow + { + Slot = 1, + Name = "Anna", + IsHuman = true, + Gold = 340, + Ready = false, + Earned = 400, + Spent = 60, + NextSeq = 4, + Undo = 1, + Draft = new Draft { Gold = 300, NextSeq = 4, BossLane = "top" } + }; + row.Roster.Add(new RosterEntry { Uid = "p1-1", Seq = 1, TypeId = "w01", Lane = "mid", Invested = 50, Fresh = true }); + row.Hero = new HeroState { Uid = "p1-2", Seq = 2, HeroId = "held_ringborke", Lane = "mid", Xp = 12, Invested = 200, Fresh = false }; + row.Hero.Items.Add("glutklinge"); + row.Bookings.Add(new Booking { Uid = "p1-3", Seq = 3, ChampionId = "ch_belagerung", Lane = "bot", Cost = 80, DeployRound = 8 }); + row.Draft.Roster.Add(new RosterEntry { Uid = "p1-4", Seq = 4, TypeId = "g01", Lane = "top", Invested = 45, Fresh = true }); + row.Draft.NewBookings.Add(new Booking { Uid = "p1-5", Seq = 5, ChampionId = "ch_sturm", Lane = "mid", Cost = 70 }); + state.Players.Add(row); + + string line = LuneWire.Encode(LuneNet.KState, state); + string kind; + object payload; + LuneWire.Decode(line, out kind, out payload); + var copy = (NetState)payload; + Assert.AreEqual(Phase.Planning, copy.Phase); + Assert.AreEqual(7, copy.Round); + Assert.AreEqual("Ancient von Blau zerstört.", copy.EndNote); + Assert.AreEqual(1200.5, copy.BuildingHp["blau_ancient"], 1e-9); + Assert.AreEqual(0.0, copy.BuildingHp["rot_top_outer"], 1e-9); + Assert.AreEqual(1, copy.DestroyedCores[0]); + Assert.AreEqual(4, copy.Boss.NextAvailableRound); + Assert.AreEqual("mid", copy.Boss.Claim.Lane); + Assert.AreEqual(12.5, copy.LastSettlement.CombatTime, 1e-9); + NetPlayerRow player = copy.Players[0]; + Assert.AreEqual("Anna", player.Name); + Assert.AreEqual(340, player.Gold); + Assert.AreEqual("p1-1", player.Roster[0].Uid); + Assert.IsTrue(player.Roster[0].Fresh); + Assert.AreEqual("held_ringborke", player.Hero.HeroId); + Assert.AreEqual("glutklinge", player.Hero.Items[0]); + Assert.AreEqual("ch_belagerung", player.Bookings[0].ChampionId); + Assert.AreEqual(300, player.Draft.Gold); + Assert.AreEqual("top", player.Draft.BossLane); + Assert.AreEqual("g01", player.Draft.Roster[0].TypeId); + Assert.AreEqual("ch_sturm", player.Draft.NewBookings[0].ChampionId); + } + + [Test] + public void UnitsAndFrameRoundTrip() + { + var unit = new NetUnitRecord + { + Id = 12, + Kind = "hero", + Team = 0, + TeamIndex = 2, + OwnerSlot = 1, + Uid = "p1-2", + Lane = "mid", + Zone = "lane", + X = 3.5, + Y = -1.25, + Facing = 1.5, + Hp = 80, + MaxHp = 100, + Alive = true, + Def = new UnitDef { Id = "held_ringborke", DisplayName = "Ringborke", Cost = 200, MaxHp = 100, Damage = 12, Tier = "" } + }; + unit.Def.Talents.Add("t_roots"); + string line = LuneWire.Encode(LuneNet.KUnitsSpawned, new List { unit }); + string kind; + object payload; + LuneWire.Decode(line, out kind, out payload); + var list = (List)payload; + Assert.AreEqual(1, list.Count); + Assert.AreEqual("hero", list[0].Kind); + Assert.AreEqual(3.5, list[0].X, 1e-9); + Assert.AreEqual("held_ringborke", list[0].Def.Id); + Assert.AreEqual("t_roots", list[0].Def.Talents[0]); + + var floats = new float[LuneNet.FrameStride]; + for (int i = 0; i < floats.Length; i++) + floats[i] = i + 0.5f; + byte[] frame = LuneWire.EncodeFrame(9, 1.25, floats, new[] { 10f, 0f }); + int serial; + double elapsed; + float[] units; + float[] hp; + LuneWire.DecodeFrame(frame, out serial, out elapsed, out units, out hp); + Assert.AreEqual(9, serial); + Assert.AreEqual(1.25, elapsed, 1e-9); + Assert.AreEqual(LuneNet.FrameStride, units.Length); + Assert.AreEqual(2.5f, units[2]); + Assert.AreEqual(2, hp.Length); + } + + [Test] + public void LobbyAndOptionsRoundTrip() + { + var roster = new List + { + new LobbyEntry { Peer = 1, Slot = 0, Name = "Host", Ready = false, Bot = false }, + new LobbyEntry { Peer = 0, Slot = 1, Name = "Bot", Ready = true, Bot = true } + }; + string line = LuneWire.Encode(LuneNet.KLobby, roster); + string kind; + object payload; + LuneWire.Decode(line, out kind, out payload); + var copy = (List)payload; + Assert.AreEqual(2, copy.Count); + Assert.AreEqual("Host", copy[0].Name); + Assert.IsTrue(copy[1].Bot); + + var options = new MatchOptions { Seed = 42, HumanSlot = 0, Bunds = new List { "glutpakt", "wurzelbund" } }; + line = LuneWire.Encode(LuneNet.KSetup, new object[] { options, 3, roster }); + LuneWire.Decode(line, out kind, out payload); + var parts = (object[])payload; + var optionsCopy = (MatchOptions)parts[0]; + Assert.AreEqual(42, optionsCopy.Seed); + Assert.AreEqual(3, parts[1]); + Assert.AreEqual(2, optionsCopy.Bunds.Count); + } + } +} diff --git a/Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta b/Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta new file mode 100644 index 0000000..e8c546b --- /dev/null +++ b/Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8779292e0688a4cf083bcc73518df0e2 \ No newline at end of file diff --git a/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef b/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef new file mode 100644 index 0000000..536c396 --- /dev/null +++ b/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef @@ -0,0 +1,25 @@ +{ + "name": "Luneraria.Net.Tests", + "rootNamespace": "Luneraria.Net.Tests", + "references": [ + "Luneraria.Core", + "Luneraria.Net", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef.meta b/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef.meta new file mode 100644 index 0000000..9483cd4 --- /dev/null +++ b/Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 340d183f865dd44998bab569b71ae410 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Luneraria/UI/Game/LuneGame.cs b/Assets/Luneraria/UI/Game/LuneGame.cs index bf5fc3a..c00c362 100644 --- a/Assets/Luneraria/UI/Game/LuneGame.cs +++ b/Assets/Luneraria/UI/Game/LuneGame.cs @@ -29,9 +29,13 @@ namespace Luneraria.UI public int HumanSlot = 1; /// Optional render target for tests and captures (null: the screen). public RenderTexture Target; + /// Network session (U4); null offline. Commands and Ready go through it. + public LuneSession Session; + /// Menu/shell that owns the session (U4); null in tests and the benchmark. + public LuneNetPlay Net; public LuneHud Hud { get; private set; } - public LuneMatch Match { get { return Driver.Runner.Match; } } + public LuneMatch Match { get { return _runner != null ? _runner.Match : Driver.Runner.Match; } } /// The human seat of the running match (-1: a bot match, e.g. tests and the benchmark). int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } } bool Human { get { return Seat >= 0; } } @@ -53,10 +57,18 @@ namespace Luneraria.UI MaterialPropertyBlock _block; void Start() + { + if (Net != null) + return; + Launch(); + } + + /// Builds the HUD for the current match (also called by the network shell). + public void Launch() { _camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main; // The benchmark keeps its bot match; otherwise the configured seat plays. - if (!LuneBenchmark.Running && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot)) + if (!LuneBenchmark.Running && Net == null && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot)) { Driver.HumanSlot = HumanSlot; Driver.Restart(); @@ -76,6 +88,10 @@ namespace Luneraria.UI LuneBenchmark.UiMs = () => (Hud != null ? Hud.LastTickMs : 0.0) + _updateMs + _lateMs; } + bool Networked { get { return Session != null && Session.IsNetworked(); } } + + bool IsClient { get { return Networked && !Session.IsAuthority(); } } + void OnDestroy() { LuneBenchmark.UiMs = null; @@ -88,7 +104,23 @@ namespace Luneraria.UI _lastStage = (LuneMatchRunner.Stage)(-1); Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target); Hud.CommandRequested += command => Feedback(Send(command)); - Hud.UndoRequested += () => Feedback(Human ? Match.Undo(Seat) : new CommandResult(false, "Kein eigener Platz.")); + Hud.UndoRequested += () => + { + if (!Human) + { + Feedback(new CommandResult(false, "Kein eigener Platz.")); + return; + } + if (Networked) + { + SessionResult result = Session.Submit(new Command { Type = "undo" }); + Feedback(new CommandResult(result.Ok, result.Message)); + } + else + { + Feedback(Match.Undo(Seat)); + } + }; Hud.ReadyRequested += OnReady; Hud.StarterRequested += Starter; Hud.SpeedRequested += CycleSpeed; @@ -121,6 +153,11 @@ namespace Luneraria.UI { if (!Human) return new CommandResult(false, "Zuschauer können nicht planen."); + if (Networked) + { + SessionResult result = Session.Submit(command); + return new CommandResult(result.Ok, result.Message); + } return Match.Submit(Seat, command); } @@ -150,24 +187,42 @@ namespace Luneraria.UI { if (Match.Phase != Phase.Planning || !Human) return; - _runner.Ready(); + if (Networked) + Session.ReadyUp(); + else + _runner.Ready(); Hud.MarkDirty(); } void CycleSpeed() { + if (IsClient) + { + Hud.Notify("Tempo und Pause bestimmt der Host.", true); + return; + } Driver.CycleSpeed(); Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); } void TogglePause() { + if (IsClient) + { + Hud.Notify("Tempo und Pause bestimmt der Host.", true); + return; + } if (Driver.TogglePause()) Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused); } void Rematch() { + if (Net != null) + { + Net.Rematch(); + return; + } Driver.Seed += 1; Driver.Restart(); View.Preview = null; @@ -395,8 +450,12 @@ namespace Luneraria.UI return; LuneMatchRunner.Stage previous = _lastStage; _lastStage = stage; + if (previous == LuneMatchRunner.Stage.Combat && Networked && Session.IsAuthority()) + Session.HostAfterCombat(); if (stage == LuneMatchRunner.Stage.Planning) { + if (Networked && Session.IsAuthority()) + Session.PlanBotSeats(); RebuildPreview(); } else if (stage == LuneMatchRunner.Stage.Combat) @@ -577,9 +636,16 @@ namespace Luneraria.UI VisualElement column = LuneUi.Box(true, 14f, panel); column.style.alignItems = Align.Center; LuneUi.Label("Partie verlassen?", "HeadingLabel", column); - LuneUi.Wrap(LuneUi.Label("Ein Hauptmenü gibt es im Unity-Port noch nicht; Verlassen beendet das Spiel.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter; + LuneUi.Wrap(LuneUi.Label("Zurück zum Hauptmenü mit Offline, Hosten und Beitreten.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter; VisualElement row = LuneUi.Box(false, 12f, column); - new LuneButton("SPIEL BEENDEN", LuneButtons.Primary, row).Clicked += Quit; + new LuneButton("ZUM MENÜ", LuneButtons.Primary, row).Clicked += () => + { + LuneUi.Show(_leave, false); + if (Net != null) + Net.ReturnToMenu(); + else + Quit(); + }; new LuneButton("Zurück", LuneButtons.Secondary, row).Clicked += () => LuneUi.Show(_leave, false); LuneUi.Show(_leave, false); Hud.Root.Add(_leave); diff --git a/Assets/Luneraria/UI/Game/LuneMainMenu.cs b/Assets/Luneraria/UI/Game/LuneMainMenu.cs new file mode 100644 index 0000000..1aad827 --- /dev/null +++ b/Assets/Luneraria/UI/Game/LuneMainMenu.cs @@ -0,0 +1,177 @@ +using System; +using Luneraria.Core; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Luneraria.UI +{ + /// + /// Start screen of Champions of Luneraria (port of the network part of lune_menu.gd): + /// offline (local host with bot seats), host a direct connection, or join one. The menu is + /// the start screen of the game; the HUD's "Menü" returns here. Peer 1 is the host; free + /// seats stay bots. The host is authoritative. + /// + public sealed class LuneMainMenu : VisualElement + { + public event Action StartOffline; + public event Action HostRequested; + public event Action JoinRequested; + public event Action QuitRequested; + + public string Bund = "wurzelbund"; + public string Lane = "mid"; + + readonly TextField _name; + readonly TextField _address; + readonly TextField _port; + readonly Label _status; + LuneButton _laneTop; + LuneButton _laneMid; + LuneButton _laneBot; + + public LuneMainMenu() + { + style.position = Position.Absolute; + style.left = 0f; + style.top = 0f; + style.right = 0f; + style.bottom = 0f; + style.justifyContent = Justify.Center; + style.alignItems = Align.Center; + style.backgroundColor = new Color(.02f, .03f, .05f, .92f); + + var panel = new LunePanel(LunePanels.Menu); + panel.style.minWidth = 560f; + panel.style.maxWidth = 640f; + Add(panel); + VisualElement column = LuneUi.Box(true, 14f, panel); + column.style.alignItems = Align.Center; + + LuneUi.Label("CHAMPIONS", "TitleLabel", column).style.fontSize = 56f; + LuneUi.Label("OF LUNERARIA", "TitleLabel", column).style.fontSize = 40f; + LuneUi.Label("DREI LANES · 3 GEGEN 3 · AUTOBATTLER", "EyebrowLabel", column); + LuneUi.Wrap(LuneUi.Label("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.", "MutedLabel", column)); + + LuneUi.Label("DEIN BUND", "SectionLabel", column); + VisualElement bundRow = LuneUi.Box(false, 10f, column); + new LuneButton("Glutpakt", LuneButtons.HudToggle, bundRow).Clicked += () => SetBund("glutpakt"); + new LuneButton("Wurzelbund", LuneButtons.HudToggle, bundRow).Clicked += () => SetBund("wurzelbund"); + + LuneUi.Label("DEINE LANE", "SectionLabel", column); + VisualElement laneRow = LuneUi.Box(false, 10f, column); + _laneTop = new LuneButton("Top", LuneButtons.HudToggle, laneRow); + _laneMid = new LuneButton("Mid", LuneButtons.HudToggle, laneRow); + _laneBot = new LuneButton("Bottom", LuneButtons.HudToggle, laneRow); + _laneTop.ToggleMode = true; + _laneMid.ToggleMode = true; + _laneBot.ToggleMode = true; + _laneTop.Clicked += () => SetLane("top"); + _laneMid.Clicked += () => SetLane("mid"); + _laneBot.Clicked += () => SetLane("bot"); + SetLane("mid"); + + LuneUi.Label("NETZWERK · DIREKTVERBINDUNG", "SectionLabel", column); + _name = Field(column, "Anzeigename", "Spieler"); + VisualElement netRow = LuneUi.Box(false, 10f, column); + _address = Field(netRow, "Host-Adresse", "127.0.0.1"); + _address.style.flexGrow = 1f; + _port = Field(netRow, "Port", LuneNet.DefaultPort.ToString()); + _port.style.width = 100f; + + VisualElement actions = LuneUi.Box(false, 12f, column); + new LuneButton("ALLEIN SPIELEN", LuneButtons.Primary, actions).Clicked += StartAlone; + new LuneButton("HOSTEN", LuneButtons.Secondary, actions).Clicked += StartHost; + new LuneButton("BEITRETEN", LuneButtons.Secondary, actions).Clicked += StartJoin; + new LuneButton("BEENDEN", LuneButtons.Secondary, actions).Clicked += () => QuitRequested?.Invoke(); + + _status = LuneUi.Label("", "MutedLabel", column); + _status.style.unityTextAlign = TextAnchor.MiddleCenter; + } + + static TextField Field(VisualElement parent, string label, string value) + { + VisualElement box = LuneUi.Box(true, 4f, parent); + box.style.flexGrow = 1f; + LuneUi.Label(label, "EyebrowLabel", box); + var field = new TextField { value = value }; + field.style.flexGrow = 1f; + field.style.minHeight = 32f; + field.style.fontSize = 16f; + box.Add(field); + return field; + } + + void SetBund(string bund) + { + Bund = bund; + } + + void SetLane(string lane) + { + Lane = lane; + _laneTop.On = lane == "top"; + _laneMid.On = lane == "mid"; + _laneBot.On = lane == "bot"; + } + + /// Own seat on team Blau: slots 0–2 map to top, mid, bot. + public MatchOptions OfflineOptions() + { + int seat = Array.IndexOf(LuneMap.Lanes, Lane); + if (seat < 0) + seat = 1; + var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = seat }; + for (int slot = 0; slot < 6; slot++) + options.Bunds.Add(slot == seat ? Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]); + return options; + } + + void StartAlone() + { + StartOffline?.Invoke(OfflineOptions()); + } + + void StartHost() + { + int port = ParsePort(); + HostRequested?.Invoke(port, CleanName()); + } + + void StartJoin() + { + int port = ParsePort(); + JoinRequested?.Invoke(_address.value.Trim(), port, CleanName()); + } + + int ParsePort() + { + int port; + return int.TryParse(_port.value.Trim(), out port) && port > 0 ? port : LuneNet.DefaultPort; + } + + string CleanName() + { + string name = (_name.value ?? "").Trim(); + if (name.Length == 0) + name = "Spieler"; + return name.Length > 24 ? name.Substring(0, 24) : name; + } + + public void Notify(string message, bool error = false) + { + _status.text = message ?? ""; + _status.style.color = error ? LuneUiTheme.Hex("ff8a70") : LuneUiTheme.Muted; + } + + public void Enter() + { + LuneUi.Show(this, true); + Notify(""); + } + + public void Leave() + { + LuneUi.Show(this, false); + } + } +} diff --git a/Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta b/Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta new file mode 100644 index 0000000..01858f4 --- /dev/null +++ b/Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1a6046e9cda249d3b7cf2e07a556e52 \ No newline at end of file diff --git a/Assets/Luneraria/UI/Game/LuneNetPlay.cs b/Assets/Luneraria/UI/Game/LuneNetPlay.cs new file mode 100644 index 0000000..672ced8 --- /dev/null +++ b/Assets/Luneraria/UI/Game/LuneNetPlay.cs @@ -0,0 +1,245 @@ +using System; +using Luneraria.Core; +using Luneraria.Net; +using Luneraria.View; +using UnityEngine; +using UnityEngine.UIElements; + +namespace Luneraria.UI +{ + /// + /// Owns the session and the TCP link (port of lune_main.gd): title menu, match, return, + /// rematch. Offline is a local host with bot-filled seats. Host/Join uses LuneSession over + /// LuneTcp. Every setup from the host (first join, rejoin, rematch) relaunches the match. + /// + [DefaultExecutionOrder(-50)] + public sealed class LuneNetPlay : MonoBehaviour + { + public LuneGame Game; + public LuneMatchDriver Driver; + public LuneMatchView View; + public LuneCameraRig Rig; + public LuneUiAssets UiAssets; + + public LuneSession Session { get; private set; } + public LuneMainMenu Menu { get; private set; } + + LuneTcp _tcp; + MatchOptions _lastOptions; + bool _relaunch; + UIDocument _document; + + void Awake() + { + if (Game == null) + Game = GetComponent(); + if (Driver == null) + Driver = GetComponent(); + if (View == null) + View = GetComponent(); + if (Rig == null) + Rig = GetComponent(); + _document = GetComponent(); + } + + void Start() + { + var root = new VisualElement(); + root.style.position = Position.Absolute; + root.style.left = 0f; + root.style.top = 0f; + root.style.right = 0f; + root.style.bottom = 0f; + if (_document != null && _document.rootVisualElement != null) + { + _document.rootVisualElement.Add(root); + } + else + { + // Tests and captures build their own panel; a runtime fallback still shows the menu. + root.name = "lune-menu-root"; + gameObject.AddComponent(); + } + Menu = new LuneMainMenu(); + root.Add(Menu); + Menu.StartOffline += StartOffline; + Menu.HostRequested += HostMatch; + Menu.JoinRequested += JoinMatch; + Menu.QuitRequested += Quit; + Menu.Enter(); + if (Game != null) + { + Game.Net = this; + Game.enabled = false; + } + if (Driver != null) + Driver.enabled = false; + } + + void Update() + { + if (_tcp != null) + _tcp.Pump(); + if (_relaunch) + { + _relaunch = false; + Launch(); + } + } + + void StartOffline(MatchOptions options) + { + ResetSession(); + Session = new LuneSession(); + Session.HostOffline(options.HumanSlot, options); + _lastOptions = options; + Menu.Leave(); + Launch(); + } + + void HostMatch(int port, string displayName) + { + ResetSession(); + _tcp = new LuneTcp(); + Session = new LuneSession { Link = _tcp }; + _tcp.Session = Session; + Session.SessionMessage += OnMessage; + if (!_tcp.Host(port)) + { + Menu.Notify(_tcp.LastError, true); + ResetSession(); + return; + } + var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = 0 }; + for (int slot = 0; slot < 6; slot++) + options.Bunds.Add(slot == 0 ? Menu.Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]); + if (!Session.HostNetwork(options, displayName)) + { + Menu.Notify(Session.LastError, true); + ResetSession(); + return; + } + _lastOptions = options; + Menu.Notify("Warte auf Gäste auf Port " + port + " …"); + Menu.Leave(); + Launch(); + } + + void JoinMatch(string address, int port, string displayName) + { + ResetSession(); + _tcp = new LuneTcp(); + Session = new LuneSession { Link = _tcp }; + _tcp.Session = Session; + Session.SessionMessage += OnMessage; + Session.HostLost += () => + { + string reason = Session != null ? Session.LastError : ""; + ReturnToMenu(); + if (Menu != null) + Menu.Notify(reason, true); + }; + Session.MatchResetup += () => + { + if (Session != null && Session.MatchState != null) + _relaunch = true; + }; + if (!Session.JoinNetwork(displayName) || !_tcp.Join(address, port)) + { + Menu.Notify(_tcp.LastError.Length > 0 ? _tcp.LastError : Session.LastError, true); + ResetSession(); + return; + } + Menu.Notify("Verbinde mit " + address + ":" + port + " …"); + } + + void OnMessage(string text, bool isError) + { + if (Menu != null && LuneUi.Visible(Menu)) + Menu.Notify(text, isError); + if (Game != null && Game.Hud != null && !isError) + Game.Hud.Notify(text, isError); + } + + void Launch() + { + if (Game == null || Session == null || Session.MatchState == null) + return; + bool networked = Session.IsNetworked(); + Game.Net = this; + Game.Session = networked ? Session : null; + Game.HumanSlot = Session.LocalSlot >= 0 ? Session.LocalSlot : 0; + if (Driver != null) + { + Driver.enabled = true; + if (networked) + Driver.Adopt(Session.MatchState, Session.LocalSlot, !Session.IsAuthority(), Session); + else + { + // Offline keeps the plain runner; the session already validated the setup path. + Driver.HumanSlot = Game.HumanSlot; + Driver.Bunds = _lastOptions != null && _lastOptions.Bunds != null ? _lastOptions.Bunds.ToArray() : null; + Driver.Seed = _lastOptions != null ? _lastOptions.Seed : Driver.Seed; + Driver.Restart(); + } + } + Game.enabled = true; + Game.Launch(); + } + + /// Host rematch: a new match with the same seats; clients relaunch on setup. + public void Rematch() + { + if (Session == null || !Session.IsAuthority()) + { + if (Game != null && Game.Hud != null) + Game.Hud.Notify("Eine Revanche startet der Host.", true); + return; + } + if (_lastOptions != null) + _lastOptions.Seed += 1; + MatchOptions options = _lastOptions ?? new MatchOptions(); + Session.HostRematch(options); + _relaunch = true; + } + + public void ReturnToMenu() + { + if (Game != null) + Game.enabled = false; + if (Driver != null) + Driver.enabled = false; + ResetSession(); + if (Menu != null) + Menu.Enter(); + } + + void ResetSession() + { + if (_tcp != null) + { + _tcp.Close(); + _tcp = null; + } + if (Session != null) + { + Session.Close(); + Session = null; + } + } + + void OnDestroy() + { + ResetSession(); + } + + static void Quit() + { +#if UNITY_EDITOR + UnityEditor.EditorApplication.isPlaying = false; +#else + Application.Quit(); +#endif + } + } +} diff --git a/Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta b/Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta new file mode 100644 index 0000000..dcea182 --- /dev/null +++ b/Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 717bc063d01154f21a8f46edd50def26 \ No newline at end of file diff --git a/Assets/Luneraria/UI/Luneraria.UI.asmdef b/Assets/Luneraria/UI/Luneraria.UI.asmdef index 64e4d19..70db34d 100644 --- a/Assets/Luneraria/UI/Luneraria.UI.asmdef +++ b/Assets/Luneraria/UI/Luneraria.UI.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "Luneraria.UI", "references": [ "Luneraria.Core", + "Luneraria.Net", "Luneraria.View", "Unity.RenderPipelines.Core.Runtime", "Unity.RenderPipelines.Universal.Runtime" diff --git a/Assets/Luneraria/View/LuneMatchDriver.cs b/Assets/Luneraria/View/LuneMatchDriver.cs index a73ca63..d2d6f4e 100644 --- a/Assets/Luneraria/View/LuneMatchDriver.cs +++ b/Assets/Luneraria/View/LuneMatchDriver.cs @@ -34,6 +34,10 @@ namespace Luneraria.View public LuneMatchRunner Runner { get; private set; } /// Main-thread cost of the last frame's simulation (milliseconds). public double LastAdvanceMs { get; private set; } + /// Bound network session (U4); null offline. + public LuneSession Session; + /// True on a guest: the host simulates, this side only shows snapshots. + public bool IsClient; readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch(); public int Tempo { get { return SpeedSteps[Mathf.Clamp(SpeedIndex, 0, SpeedSteps.Length - 1)]; } } @@ -46,6 +50,21 @@ namespace Luneraria.View Runner.Trace = new LuneTrace(); SpeedIndex = 0; Paused = false; + Session = null; + IsClient = false; + } + + /// + /// Adopts the session's match (U4). The host still advances combat and ticks snapshots; + /// a guest never advances — its match is a display mirror of the host's snapshots. + /// + public void Adopt(LuneMatch match, int humanSlot, bool isClient, LuneSession session) + { + Session = session; + IsClient = isClient; + Runner = new LuneMatchRunner(match, humanSlot, session); + SpeedIndex = 0; + Paused = false; } /// Next tempo step (1× → 2× → 4× → 1×). @@ -71,10 +90,20 @@ namespace Luneraria.View void Update() { + if (Runner == null) + return; _clock.Restart(); + // A guest shows the host's snapshots and never advances the match. + if (IsClient) + { + LastAdvanceMs = 0.0; + return; + } // Pause holds only the combat steps (Godot: a paused game still plans and settles). if (!(Paused && Runner.Current == LuneMatchRunner.Stage.Combat)) Runner.Advance(Time.deltaTime * Speed * Tempo); + if (Session != null && Session.IsNetworked() && Session.IsAuthority()) + Session.HostTick(Time.deltaTime * Speed * Tempo); LastAdvanceMs = _clock.Elapsed.TotalMilliseconds; } } diff --git a/Assets/Luneraria/View/LuneMatchRunner.cs b/Assets/Luneraria/View/LuneMatchRunner.cs index cfe4ff6..1f35719 100644 --- a/Assets/Luneraria/View/LuneMatchRunner.cs +++ b/Assets/Luneraria/View/LuneMatchRunner.cs @@ -45,6 +45,20 @@ namespace Luneraria.View EnterPlanning(); } + /// Adopts an existing match (U4 network session: the host owns it, the guest mirrors it). + public LuneMatchRunner(LuneMatch match, int humanSlot, LuneSession session = null) + { + Match = match; + Clock = new LuneClock(Match.Balance.SimStep); + Profile = ""; + Session = session; + HumanSlot = humanSlot >= 0 && humanSlot < 6 ? humanSlot : -1; + if (HumanSlot >= 0) + SettledSeconds = 0.0; + Current = match.Phase == Phase.Combat ? Stage.Combat : match.Phase == Phase.Over ? Stage.Over : Stage.Planning; + _stageTime = 0.0; + } + /// A match with the public fixtures the golden scenarios use (start round, gold). public static LuneMatch NewMatch(MatchOptions options, int startRound, int extraGold) { @@ -62,6 +76,8 @@ namespace Luneraria.View { Current = Stage.Planning; _stageTime = 0.0; + if (Session != null) + return; for (int slot = 0; slot < 6; slot++) { if (slot != HumanSlot) @@ -69,6 +85,9 @@ namespace Luneraria.View } } + /// Bound network session (U4); when set, the session owns planning and combat start. + public LuneSession Session; + /// Advances by simulated seconds (frame time times match speed). public void Advance(double seconds) { @@ -78,7 +97,13 @@ namespace Luneraria.View case Stage.Planning: _stageTime += seconds; // A bot match starts after a short look at the plans; a human starts it with Ready. - if (HumanSlot < 0 ? _stageTime >= PlanningSeconds : Match.AllReady()) + // With a session the host already began the combat when every seat was ready. + if (Session != null) + { + if (Match.Phase == Phase.Combat) + StartCombat(); + } + else if (HumanSlot < 0 ? _stageTime >= PlanningSeconds : Match.AllReady()) StartCombat(); break; case Stage.Combat: @@ -110,7 +135,8 @@ namespace Luneraria.View void StartCombat() { - Match.BeginCombat(); + if (Match.Phase != Phase.Combat) + Match.BeginCombat(); Clock.Reset(); Current = Stage.Combat; CombatStarted?.Invoke(Match.Combat); diff --git a/docs/port/KNOWN-BEHAVIOR-DIFFERENCES.md b/docs/port/KNOWN-BEHAVIOR-DIFFERENCES.md index 667a65f..b1d4b2d 100644 --- a/docs/port/KNOWN-BEHAVIOR-DIFFERENCES.md +++ b/docs/port/KNOWN-BEHAVIOR-DIFFERENCES.md @@ -43,6 +43,13 @@ Die Szene ändert kein Verhalten. Der Host treibt den Kern im festen Takt; die A | Blickrichtung (`LuneUnit.Facing`) | Der Kern schreibt sie nur (Bewegungsrichtung ab 1 mm Schritt); keine Regel liest sie. Die Darstellung darf sie glätten. | | Schriftgewichte (U3D) | Das Theme der Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet sie aber nie an: `SaumwachtTheme.font` schreibt `variation_opentype = {"wght": …}` mit dem String-Schlüssel „wght“, den Godot 4.7.2 ignoriert. G3 zeigt Regular 400, der Port ebenso. Die Rollen bleiben getrennt, damit U5 echte Gewichte vergeben kann ([U3-UI.md](U3-UI.md#theme-symbole-schriften)). | +## Netzwerk (U4) + +| Wert | Warum kein Regelunterschied | +| --- | --- | +| Transport TCP statt ENet | Der Host rechnet weiter autoritativ; Clients senden nur Absichten. Kampfrahmen laufen auf demselben Socket und werden über die Serial verworfen (wie `unreliable_ordered`). Unter Paketverlust kann TCP Head-of-Line-Blocking erzeugen — rein darstellerisch, kein Regelergebnis. | +| Mehr-Runden-Loopback im Prozess | `net_rounds.py` startet zwei Godot-Prozesse; `NetRoundsSuite` koppelt zwei `LuneSession` über `LuneLoopback`. Protokoll, Prüfsummen, Übernahme und Wiedereinstieg sind dieselben Pfade. | + ## Technische Repräsentation Klassen statt Dictionaries, stabile Einfügesortierung, 32-Bit-`int`, der ungenutzte Zufallsgenerator entfällt. Keiner dieser Unterschiede ändert Ergebnisse. Die Liste steht in [U1-RULES.md](U1-RULES.md#bewusst-andere-technische-repräsentation). diff --git a/docs/port/PORT-QA.md b/docs/port/PORT-QA.md index bce3703..40b9d95 100644 --- a/docs/port/PORT-QA.md +++ b/docs/port/PORT-QA.md @@ -67,6 +67,18 @@ Hinweis: Die Projektdateien des .NET-Harness (`tools/dotnet/*/*.csproj`) fehlten | Offen | Hauptmenü und Netzwerk (U4); Schriftgewichte, Grottenlicht und weitere Darstellung (U5); Windows-/Linux-Player (Build-Module) | | Commits | `luneraria/u3`: U3A `4dee9b1`, U3B/U3C `2b86c0d`, U3D `66867f3` | -## U4 und U5 +## U4 · Netzwerk -U4 (Netzwerk) und U5 (Visuelle Neuausrichtung) sind noch nicht begonnen. +| Punkt | Ergebnis | +| --- | --- | +| Tests | Protokoll `NetSuite` **43/43** (Port von `test_lune_net.gd`), Mehr-Runden-Loopback `NetRoundsSuite` **12/12** (Port von `net_rounds.py`, In-Prozess mit `LuneLoopback`), `LuneWireTests` Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests .NET 457/457 (402 + 43 + 12) | +| Parität | Bot-Partien unverändert `DCE95C96556F3A1A`, `7B8E43AEFC0D60C5`; Mindestumfang M4 erfüllt (Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze) | +| Bild | Startbildschirm `LuneMainMenu` (Offline/Hosten/Beitreten); Partiefenster unverändert U3 | +| Leistung | keine eigenen Netz-Messungen; Rahmen 20/s wie in der Referenz (`LuneNet.FrameInterval` 0,05 s), 13 Floats je Einheit | +| Bekannte Unterschiede | TCP statt ENet (Rahmen auf demselben Socket, Verwerfen über Serial); Zwei-Prozess-Loopback als In-Prozess-Test; vereinfachtes Hauptmenü | +| Offen | Windows-/Linux-Player (Build-Module); Internetpartie über mehrere Rechner; optional unzuverlässiger Rahmenkanal | +| Commits | `luneraria/u4` (dieser Stand) | + +## U5 + +U5 (Visuelle Neuausrichtung) ist noch nicht begonnen. diff --git a/docs/port/PORT-STATUS.md b/docs/port/PORT-STATUS.md index 31f22c8..e85cc5c 100644 --- a/docs/port/PORT-STATUS.md +++ b/docs/port/PORT-STATUS.md @@ -1,6 +1,6 @@ # Port-Status · Champions of Luneraria in Unity -Stand: 2. Oktober 2026. +Stand: 2. Oktober 2026 (U4). Referenz ist der Godot-Stand `luneraria/g3` (`1b540f2`) in `../saumwacht-godot`; er wird nicht mehr verändert. Engine: Unity 6.3 LTS (6000.3.20f1), Renderer URP. HDRP ist ausgeschlossen, bis ein Vergleich mit einer echten Szene etwas anderes nahelegt. @@ -12,8 +12,8 @@ Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattfo | U1.5 · Unity-Integrationsprüfung | **fertig**: Kern im Unity-Editor 402/402 (Release und Debug), Golden bitgleich nach expliziter float32-Rundung in `Vec2`, Editor-Benchmark | [U1.5-UNITY.md](U1.5-UNITY.md) | | U2 · Unity-Spielwelt | **fertig**: U2A Blender→FBX-Pipeline (9 Assets geprüft), U2B G1-Karte mit Materialsystem, U2C fester Takt und Ansichten (bildratenunabhängig nachgewiesen), U2D vollständige Bot-Partien sichtbar und gleich dem Kern; danach Batch-Import aller 86 Modelle, Einheiten mit eigenen Modellen | [U2-WORLD.md](U2-WORLD.md) | | U3 · G3-HUD portieren | **fertig** (`luneraria/u3`): UI Toolkit, Sichtmodell mit Privatsphäre, menschlicher Sitz, alle HUD-Bereiche, Eingabe und Befehle, Prüfung 718/0, 14 G3-Motive, Spieltest, Leistung mit HUD | [U3-UI.md](U3-UI.md) | -| U4 · Netzwerk | nächster Schritt; M4-Umfang ist erfasst | [U4-NETWORK.md](U4-NETWORK.md) | -| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nicht begonnen | [U5-VISUAL.md](U5-VISUAL.md) | +| U4 · Netzwerk | **fertig** (`luneraria/u4`): LuneSession (Protokoll 4), TCP-Transport, Startbildschirm Host/Join, 43+12 Prüfungen, Mindestumfang M4 | [U4-NETWORK.md](U4-NETWORK.md) | +| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nächster Schritt | [U5-VISUAL.md](U5-VISUAL.md) | QA je Meilenstein: [PORT-QA.md](PORT-QA.md). @@ -37,6 +37,7 @@ Bekannte Verhaltensunterschiede, auch die bewusst übernommenen Referenzfehler: - **Koordinaten:** Unity = (−x, Höhe, y) der Kartenpunkte; FBX-Pipeline, Welt und Kamera nutzen dieselbe Umrechnung, das Bild entspricht Godots. - **Host-Schleife (U2C):** fester Takt mit Akkumulator; die Bildrate bestimmt nur die Zahl der Schritte je Bild. Nachweis über Fingerabdrücke der Partie. - **Schriftgewichte wie in G3 (U3D):** Die Referenz deklariert Gewichte, wendet sie aber nie an (String-Schlüssel „wght“ wird von Godot ignoriert). Der Port zeigt wie G3 Regular 400. Die Stilrollen bleiben getrennt, damit U5 echte Gewichte vergeben kann. +- **Netzwerk über TCP (U4):** Die Referenz nutzt ENet; der Port nutzt TCP mit `LuneWire` (Nachrichten) und binären Kampfrahmen. Späte Rahmen werden über die Serial verworfen wie bei `unreliable_ordered`. Offline ist ein lokaler Host mit Bot-Plätzen (`LuneSession.HostOffline`). - **Modelle direkt in Git** (1. Oktober 2026): Auch die Modelle des Batch-Imports liegen wie die U2A-Modelle im Repository, ohne Git LFS. Keine Datei ist größer als 40 MB; das Repository wächst auf etwa 240 MB. - **Projektort außerhalb von iCloud Drive** (1. Oktober 2026): Der Arbeitsordner `TowerDefense` zieht von `~/Documents/Projekte` nach `~/Developer`. iCloud Drive synchronisierte `~/Documents` und legte Konfliktkopien an, die einen Player unbrauchbar machten (U2-WORLD.md, Batch-Import). Der Schutz in `LunePlayerBuild` bleibt. diff --git a/docs/port/U4-NETWORK.md b/docs/port/U4-NETWORK.md index d2ba647..0f844cc 100644 --- a/docs/port/U4-NETWORK.md +++ b/docs/port/U4-NETWORK.md @@ -1,27 +1,81 @@ # U4 · Netzwerk -Stand: 30. September 2026 · **nicht begonnen**. Das Netzwerk folgt erst nach funktionierendem lokalen Spiel. +Stand: 2. Oktober 2026 · **fertig** (`luneraria/u4`). -Es wird keine neue Architektur erfunden. Übertragen wird der M4-Umfang (`lune_session.gd`, Protokoll 4), serverautoritativ: +Es wurde keine neue Architektur erfunden. Übertragen ist der M4-Umfang (`lune_session.gd`, Protokoll 4), serverautoritativ: - Clients senden Absichten. - Der Host berechnet Gold, Käufe, Kampf, Schaden, Besitz, Boss und Sieg. +## Umsetzung + +| Baustein | Ort | Inhalt | +| --- | --- | --- | +| `LuneSession` | `Assets/Luneraria/Core/Net/LuneSession.cs` | autoritative Sitzung (Port von `lune_session.gd`): Rollen, Sitze, Lobby, Hallo/Beitritt/Wiedereinstieg, Befehle, Bereit, Kampfbeginn, Momentaufnahmen, Abrechnung, Revanche, Disconnect | +| `NetModel` | `Assets/Luneraria/Core/Net/NetModel.cs` | Sitzungsmodelle (Lobby, öffentlicher Zustand, Einheitenätzeze, Rahmen) und `ILuneNetLink` | +| `LuneLoopback` | `Assets/Luneraria/Core/Net/LuneLoopback.cs` | In-Prozess-Transport für die Protokoll- und Mehr-Runden-Tests | +| `LuneWire` | `Assets/Luneraria/Net/LuneWire.cs` | Codierung der Nachrichten (Art + Payload, Cursor-Codec) und der Kampfrahmen | +| `LuneTcp` | `Assets/Luneraria/Net/LuneTcp.cs` | TCP-Transport (Ersatz für ENet): ein Socket je Gast, zuverlässige Nachrichten und Rahmen, Pump auf dem Spielthread | +| `LuneMainMenu` | `Assets/Luneraria/UI/Game/LuneMainMenu.cs` | Startbildschirm: Allein spielen, Hosten, Beitreten, Bund, Lane, Name, Adresse, Port | +| `LuneNetPlay` | `Assets/Luneraria/UI/Game/LuneNetPlay.cs` | Schale um Sitzung und Transport (Port von `lune_main.gd`): Menü, Partie, Rückkehr, Revanche | +| `LuneGame` | `Assets/Luneraria/UI/Game/LuneGame.cs` | Befehle, Rückgängig und Bereit gehen im Netzspiel über die Sitzung; Gast zeigt nur Host-Rahmen | + +Offline ist ein lokaler Host mit Bot-Plätzen (`LuneSession.HostOffline`). Hosten und Beitreten nutzen `LuneTcp` mit `LuneWire`. Peer 1 ist der Host; freie Plätze bleiben Bots. + ## Erfasster M4-Umfang -| Funktion | Verhalten in der Referenz | -| --- | --- | -| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast | -| Sechs Plätze | ein Peer genau ein Platz; freie Plätze spielen Bots; Lobbyeinträge mit Name, Bereit, Bot | -| Beitritt | Hallo mit Protokollversion und Name; falsche Version wird abgelehnt; Namen gekürzt (24 Zeichen); volle Partie abgelehnt | -| Private Planung | ein Gast sendet Befehle für seinen Platz; fremde Plätze werden abgelehnt (Spoof-Schutz); der Zustand enthält nur den eigenen Entwurf | -| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde | -| Kampfsynchronisation | Kampfbeginn mit Einheitendatensätzen und Gebäude-HP; alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit, ungeordnet-zuverlässig); Gast interpoliert, verspätete Frames werden verworfen | -| Abrechnung | Host sendet den öffentlichen Zustand nach dem Kampf | -| Disconnect | Platz geht an einen Bot, der in der Planung sofort plant; niemals Kernverlust | -| Wiedereinstieg | gleicher Name, solange der Platz bot-gehalten ist → Platz zurück, volle Synchronisation | -| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden | -| Revanche | Host startet neu, Plätze bleiben | -| Hostverlust | Gast kehrt mit Meldung ins Menü zurück | -| Tests | `test_lune_net` 43 Prüfungen, `net_rounds.py` Loopback mit zwei Prozessen 12 Prüfungen | +| Funktion | Verhalten in der Referenz | im Port | +| --- | --- | --- | +| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast | erfüllt (offline, Host, Gast; TCP statt ENet) | +| Sechs Plätze | ein Peer genau ein Platz; freie Plätze spielen Bots; Lobbyeinträge mit Name, Bereit, Bot | erfüllt | +| Beitritt | Hallo mit Protokollversion und Name; falsche Version wird abgelehnt; Namen gekürzt (24 Zeichen); volle Partie abgelehnt | erfüllt | +| Private Planung | ein Gast sendet Befehle für seinen Platz; fremde Plätze werden abgelehnt (Spoof-Schutz); der Zustand enthält nur den eigenen Entwurf | erfüllt | +| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde | erfüllt | +| Kampfsynchronisation | Kampfbeginn mit Einheitenätzen und Gebäude-HP; alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit, ungeordnet-zuverlässig); Gast interpoliert, verspätete Frames werden verworfen | erfüllt (Rahmen mit Serial; späte Frames verworfen) | +| Abrechnung | Host sendet den öffentlichen Zustand nach dem Kampf | erfüllt | +| Disconnect | Platz geht an einen Bot, der in der Planung sofort plant; niemals Kernverlust | erfüllt | +| Wiedereinstieg | gleicher Name, solange der Platz bot-gehalten ist → Platz zurück, volle Synchronisation | erfüllt | +| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden | erfüllt | +| Revanche | Host startet neu, Plätze bleiben | erfüllt | +| Hostverlust | Gast kehrt mit Meldung ins Menü zurück | erfüllt | +| Tests | `test_lune_net` 43 Prüfungen, `net_rounds.py` Loopback mit zwei Prozessen 12 Prüfungen | erfüllt: `NetSuite` 43/43, `NetRoundsSuite` 12/12 (In-Prozess-Loopback statt zwei Prozesse) | -Mindestumfang des Ports: Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze. +## Prüfung + +| Test | Ergebnis | +| --- | --- | +| `NetSuite` (Port von `test_lune_net.gd`) | **43/43**: Sitzplatzbesitz und Spoof, veraltete Befehle, Doppelkauf, fremde UID, Disconnect ohne Kernschaden, sechs Plätze und Phasen, Hallo und Wiedereinstieg, Bot-Übernahme und einmaliges Bot-Planen, Bereit verbindlich und Privatsphäre der Entwürfe | +| `NetRoundsSuite` (Port von `net_rounds.py`) | **12/12**: Host und Gast spielen 5 Runden, Prüfsummen nach jeder Abrechnung gleich, Gast rechnet 0 Kampfschritte und erhält Rahmen, Gast A trennt im Kampf von Runde 2, Bot übernimmt, Gast B kehrt mit demselben Namen auf denselben Sitz zurück | +| `LuneWireTests` | Round-trip von Befehlen, öffentlichem Zustand (inkl. Entwurf, Hero, Buchungen), Einheitenätzen, Rahmen, Lobby und Optionen | +| EditMode gesamt | 29/29 (Kern 9 inkl. `NetProtocol` und `NetRounds`, Wire 4, Integration 2, UI-Modell 12, Bildraten 4) | +| PlayMode gesamt | 13/13 (Szene, Vertical Function Test, Capture, Verify A–I, UI-Toolkit-Probe); Bildwerkzeuge und Spieltest `Explicit` | +| Kerntests .NET | 457/457 (75 + 91 + 73 + 157 + 6 + 43 + 12) | +| Fingerabdrücke der Bot-Partien | unverändert `DCE95C96556F3A1A`, `7B8E43AEFC0D60C5` | + +Das Loopback der Mehr-Runden-Partie läuft im Prozess mit zwei `LuneSession`-Instanzen und `LuneLoopback` (Nachrichten synchron in Sende-Reihenfolge). Damit sind Protokoll, Prüfsummen, Übernahme und Wiedereinstieg abgedeckt; eine echte Zwei-Prozess-Partie über Sockets bleibt eine optionale Erweiterung. + +## Abnahme U4 + +| Vorgabe | Stand | +| --- | --- | +| Rollen offline / Host / Gast | erfüllt | +| Sechs Plätze, ein Peer ein Platz, freie Plätze Bots | erfüllt | +| Beitritt mit Protokollprüfung, Namenskürzung, volle Partie abgelehnt | erfüllt | +| Private Planung, Spoof-Schutz, nur eigener Entwurf im Zustand | erfüllt | +| Bereit verbindlich, alle bereit → Kampf, Bots einmal je Runde | erfüllt | +| Kampfsynchronisation (Beginn, neue Einheiten, Rahmen 20/s, späte Frames verworfen) | erfüllt | +| Abrechnung mit öffentlichem Zustand | erfüllt | +| Disconnect → Bot, kein Kernverlust | erfüllt | +| Wiedereinstieg über den Namen | erfüllt | +| Übernahme verwirft den Bot-Entwurf | erfüllt | +| Revanche durch den Host | erfüllt | +| Hostverlust → Meldung und Rückkehr ins Menü | erfüllt | +| Mindestumfang: Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze | erfüllt | +| Tests 43 + 12 | erfüllt | + +## Bekannte Grenzen + +- **Transport:** TCP statt ENet. Kampfrahmen laufen auf demselben Socket und werden über die Serial verworfen (wie `unreliable_ordered`). Unter Paketverlust kann TCP Head-of-Line-Blocking erzeugen; für eine Internetpartie wäre ein unzuverlässiger Kanal wie bei ENet der nächste Schritt. +- **Zwei-Prozess-Loopback** (`net_rounds.py`) ist als In-Prozess-Test umgesetzt (`NetRoundsSuite`). Ein Sockets-Lauf über zwei Player ist optional. +- **Windows auf x64-Hardware** und Netz über mehrere Rechner sind nicht gemessen (Build-Module fehlen, siehe [U1.5-UNITY.md](U1.5-UNITY.md)). +- **Hauptmenü** ist der Startbildschirm mit Offline/Hosten/Beitreten; das erweiterte Menü der Referenz (Emblem, Version, erweiterte Optionen) ist vereinfacht. +- **Kein Audio-Netzwerk** und keine Lobby-Chat-Nachrichten (waren nicht Teil des M4-Mindestumfangs).