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