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; // H1: leap and ultimate counters ride in the flag bits (8 bits each, compared for change only). unit.LeapSerial = (flags >> 2) & 0xFF; unit.UltimateSerial = (flags >> 10) & 0xFF; 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) | ((unit.LeapSerial & 0xFF) << 2) | ((unit.UltimateSerial & 0xFF) << 10); 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"); } } } }