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.IncomeBonus = row.IncomeBonus;
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;
if (source.Blessing != null && source.Blessing.Length == 2)
MatchState.Boss.Blessing = (int[])source.Blessing.Clone();
if (source.GlyphReady != null && source.GlyphReady.Length == 2)
MatchState.Boss.GlyphReady = (int[])source.GlyphReady.Clone();
if (source.CoreLevel != null && source.CoreLevel.Length == 2)
MatchState.Boss.CoreLevel = (int[])source.CoreLevel.Clone();
}
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,
Blessing = (int[])MatchState.Boss.Blessing.Clone(),
GlyphReady = (int[])MatchState.Boss.GlyphReady.Clone(),
CoreLevel = (int[])MatchState.Boss.CoreLevel.Clone()
};
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,
IncomeBonus = player.IncomeBonus,
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, Extended = MatchState != null && MatchState.Extended };
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");
}
}
}
}