champions-of-luneraria/Assets/Luneraria/Core/Net/LuneSession.cs
Martin Stickelbroeck 769e622952 U4 Netzwerk: LuneSession, TCP, Host/Join, 43+12 Prüfungen
Der M4-Umfang ist als Protokoll 4 portiert, serverautoritativ. Der Host
besitzt LuneMatch und rechnet jeden Kampf; Clients senden nur Absichten
und zeigen den Kampf aus Host-Momentaufnahmen.

- LuneSession (Core/Net): Rollen offline/Host/Gast, sechs Sitze, Lobby,
  Hallo mit Protokollprüfung und Namenskürzung, Beitritt und Wieder-
  einstieg über den Anzeigenamen, private Planung mit Spoof-Schutz,
  Bereit ist verbindlich, Bots planen einmal je Runde, Disconnect gibt
  den Platz an einen Bot (nie Kernverlust), Übernahme verwirft den
  unbestätigten Bot-Entwurf, Revanche hält die Plätze, Hostverlust
  meldet und führt ins Menü zurück.
- Kampfsynchronisation: Beginn mit Einheitenätzen und Gebäude-HP, alle
  0,05 s neue Einheiten und ein Frame (13 Werte je Einheit), späte
  Frames über die Serial verworfen, Gast interpoliert (frame_alpha).
- Transport: LuneLoopback (In-Prozess) für die Tests, LuneTcp + LuneWire
  (Cursor-Codec) für Echtverbindungen statt ENet; ein Socket je Gast.
- Oberfläche: LuneMainMenu (Offline/Hosten/Beitreten, Bund, Lane, Name,
  Adresse, Port) als Startbildschirm, LuneNetPlay als Schale wie
  lune_main.gd. LuneGame leitet Befehle, Rückgängig und Bereit im
  Netzspiel über die Sitzung; Tempo und Pause bestimmt der Host.
- Tests: NetSuite 43/43 (Port von test_lune_net.gd), NetRoundsSuite
  12/12 (Port von net_rounds.py: 5 Runden, Prüfsummen gleich, Gast
  rechnet 0 Schritte, Drop und Wiedereinstieg auf denselben Sitz),
  LuneWireTests Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests
  457/457. Fingerabdrücke der Bot-Partien unverändert.
- Dokumentation: U4-NETWORK (Abnahme, Grenzen), PORT-QA (U4), PORT-STATUS
  (U4 fertig, U5 nächster Schritt), KNOWN-BEHAVIOR-DIFFERENCES.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:14:42 +02:00

1058 lines
42 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// 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.
/// </summary>
public sealed class LuneSession
{
public LuneRole Role = LuneRole.Offline;
public LuneMatch MatchState;
public int LocalSlot = -1;
public string DisplayName = "";
/// <summary>slot → peer id (1 = host). Absent slots are bots owned by the host.</summary>
public readonly Dictionary<int, int> SeatPeers = new Dictionary<int, int>();
public readonly List<LobbyEntry> Lobby = new List<LobbyEntry>();
/// <summary>Display name → slot of a peer that left; used for rejoining.</summary>
public readonly Dictionary<string, int> Departed = new Dictionary<string, int>();
public string LastError = "";
public bool Connected;
/// <summary>Client-side counters (tests and diagnostics).</summary>
public int FramesReceived;
public int StatesReceived;
/// <summary>Optional wire; null in offline and pure protocol tests.</summary>
public ILuneNetLink Link;
public event Action LobbyChanged;
public event Action MatchResetup;
public event Action<string, bool> SessionMessage;
public event Action<Phase, int> PhaseAnnounced;
public event Action StateReceived;
public event Action HostLost;
int _sentUnitId;
double _frameClock;
int _frameSerial;
int _lastFrameSerial = -1;
double _lastFrameTime;
readonly Dictionary<int, LuneUnit> _mirror = new Dictionary<int, LuneUnit>();
/// <summary>slot → round a bot already planned; a bot seat plans exactly once per planning phase.</summary>
readonly Dictionary<int, int> _botPlanned = new Dictionary<int, int>();
/// <summary>Client interpolation factor between the last two snapshots.</summary>
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<int, int> 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();
}
/// <summary>Seated peers are humans: they must ready up themselves and carry their lobby name.</summary>
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);
}
/// <summary>Host over an already listening link (sockets or loopback). Peer 1 is this host.</summary>
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;
}
/// <summary>Client over a connected link. The hello goes out once the transport is up.</summary>
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;
}
/// <summary>Client: the transport finished the handshake; send hello with protocol and name.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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() });
}
}
/// <summary>Host: call once per rendered frame while combat runs; sends throttled snapshots.</summary>
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<NetUnitRecord> spawned = NewUnitRecords();
if (spawned.Count > 0)
Broadcast(LuneNet.KUnitsSpawned, spawned);
_frameSerial += 1;
float[] frame = UnitFrame();
float[] hp = BuildingHpArray();
foreach (KeyValuePair<int, int> pair in SeatPeers)
{
if (pair.Value > 1 && Link != null)
Link.SendFrame(pair.Value, _frameSerial, MatchState.Combat.Elapsed, frame, hp);
}
}
/// <summary>Host: call after the settlement (phase left COMBAT) so clients follow in the same step.</summary>
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();
}
/// <summary>Host rematch: a new match with the same seats; clients relaunch on setup.</summary>
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<int, int> pair in SeatPeers)
{
if (pair.Value > 1)
SendFullSync(pair.Value, pair.Key);
}
BroadcastLobby();
}
}
// ---------------------------------------------------------------- inbound (client → host)
/// <summary>Testable host entry for a new connection: protocol check, rejoin or first free seat.</summary>
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<int>();
var open = new List<int>();
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;
}
/// <summary>Transport entry for a message from a peer.</summary>
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<LobbyEntry>);
break;
case LuneNet.KState:
ApplyState(payload as NetState);
break;
case LuneNet.KLobby:
ApplyLobby(payload as List<LobbyEntry>);
break;
case LuneNet.KCombatBegin:
if (parts != null && parts.Length >= 3)
ApplyCombatBegin(Convert.ToInt32(parts[0]), parts[1] as List<NetUnitRecord>, parts[2] as float[]);
break;
case LuneNet.KUnitsSpawned:
if (MatchState != null && MatchState.Combat != null && MatchState.Phase == Phase.Combat)
AddMirrorUnits(payload as List<NetUnitRecord>);
break;
}
}
/// <summary>Transport entry for an unreliable combat frame.</summary>
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<LobbyEntry> 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();
}
/// <summary>
/// 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.
/// </summary>
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<string, double> 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<RosterEntry>();
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<Booking>();
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<Draft>();
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<LobbyEntry> entries)
{
Lobby.Clear();
if (entries != null)
{
foreach (LobbyEntry entry in entries)
Lobby.Add(entry.Clone());
}
LobbyChanged?.Invoke();
}
/// <summary>Client: builds a display mirror of the host's combat. Rules never run on it.</summary>
public void ApplyCombatBegin(int roundNumber, List<NetUnitRecord> records, float[] buildingHp)
{
if (MatchState == null)
return;
var mirror = new LuneCombat(MatchState.Map, MatchState.Balance, roundNumber);
IReadOnlyList<BuildingDef> 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();
}
/// <summary>Client interpolation factor between the last two snapshots.</summary>
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;
}
/// <summary>Injectable clock for tests; defaults to real time in seconds.</summary>
public static Func<double> 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<BuildingDef> 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<NetUnitRecord> 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<NetUnitRecord> NewUnitRecords()
{
var records = new List<NetUnitRecord>();
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;
}
/// <summary>Every mobile unit (full sync on join or rejoin mid-combat).</summary>
public List<NetUnitRecord> AllUnitRecords()
{
var records = new List<NetUnitRecord>();
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<float>();
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<BuildingDef> 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;
}
/// <summary>Public state for one seat: every committed value, plus the private draft of that seat only.</summary>
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<string, double> 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<int, int> 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<int, int> 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<Draft>();
player.Ready = false;
}
}
if (Role == LuneRole.Host && peer > 1)
{
SendFullSync(peer, slot);
BroadcastLobby();
BroadcastState();
}
LobbyChanged?.Invoke();
}
/// <summary>Transport entry when a peer goes away: the seat becomes a bot, never a core loss.</summary>
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();
}
/// <summary>
/// Public, committed state; host and guest must agree on it after every settlement
/// (checksum of tools/luneraria/net_pair.gd).
/// </summary>
public static string Checksum(LuneMatch match)
{
var parts = new List<string>();
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<string>(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));
}
/// <summary>Godot's hash() is process-random; this port uses a stable FNV-1a hex digest.</summary>
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");
}
}
}
}