Betäubungs-Ring über dem Kopf (Gäste über Bit 18 der Rahmen-Flags), Nebel-Gitter LuneFogOverlay mit Shader Luneraria/FogOverlay aus LuneFog.Vision (10 Hz, weiche Kante), laufende Glyphe je Team als vier Zusatzwerte nach den Gebäude-Lebenspunkten. Reine Darstellung. Kern 627/627 (s4 8), EditMode 35/35, PlayMode 20/20, Verify 718/0, Selbsttest 30/30, Golden 0, Fingerabdruck unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1098 lines
45 KiB
C#
1098 lines
45 KiB
C#
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.IncomeBonus = row.IncomeBonus;
|
||
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;
|
||
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();
|
||
if (source.CampBuff != null && source.CampBuff.Length == 6)
|
||
MatchState.Boss.CampBuff = (int[])source.CampBuff.Clone();
|
||
if (source.Wards != null && source.Wards.Length == 6)
|
||
MatchState.Boss.Wards = (int[])source.Wards.Clone();
|
||
}
|
||
|
||
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;
|
||
// 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.NetStunned = (flags & (1 << 18)) != 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;
|
||
}
|
||
for (int team = 0; team < 2 && buildingHp.Length >= buildings.Count + 4; team++)
|
||
{
|
||
int lane = (int)buildingHp[buildings.Count + team * 2] - 1;
|
||
float left = buildingHp[buildings.Count + team * 2 + 1];
|
||
combat.GlyphLane[team] = lane >= 0 && lane < 3 ? LuneMap.Lanes[lane] : "";
|
||
combat.GlyphUntil[team] = lane >= 0 ? combat.Elapsed + left : -1.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) | ((unit.LeapSerial & 0xFF) << 2) | ((unit.UltimateSerial & 0xFF) << 10) | (unit.Stunned() ? 1 << 18 : 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;
|
||
// S4: four more values after the buildings carry the running glyph of each team (lane
|
||
// index + 1, seconds left) so guests see the glyph ring; older guests ignore them.
|
||
var data = new float[buildings.Count + 4];
|
||
LuneCombat glyphs = MatchState.Phase == Phase.Combat ? MatchState.Combat : null;
|
||
for (int team = 0; team < 2 && glyphs != null; team++)
|
||
{
|
||
double left = glyphs.GlyphUntil[team] - glyphs.Elapsed;
|
||
if (glyphs.GlyphLane[team].Length > 0 && left > 0.0)
|
||
{
|
||
data[buildings.Count + team * 2] = LuneMap.LaneIndex(glyphs.GlyphLane[team]) + 1;
|
||
data[buildings.Count + team * 2 + 1] = (float)left;
|
||
}
|
||
}
|
||
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,
|
||
Blessing = (int[])MatchState.Boss.Blessing.Clone(),
|
||
GlyphReady = (int[])MatchState.Boss.GlyphReady.Clone(),
|
||
CoreLevel = (int[])MatchState.Boss.CoreLevel.Clone(),
|
||
CampBuff = (int[])MatchState.Boss.CampBuff.Clone(),
|
||
Wards = (int[])MatchState.Boss.Wards.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<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, 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<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");
|
||
}
|
||
}
|
||
}
|
||
}
|