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>
This commit is contained in:
Martin Stickelbroeck 2026-10-02 16:14:42 +02:00
parent e2aadcbc74
commit 769e622952
37 changed files with 3823 additions and 32 deletions

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 944742311374a4ae6959443b9438e3d6
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,89 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// In-process transport for two (or more) LuneSession instances (the loopback of
/// tools/luneraria/net_pair.gd without processes). Messages are delivered synchronously in
/// send order; frames go through the same path with SendFrame. Peer 1 is the host. A node
/// implements ILuneNetLink for exactly one peer and routes through the hub.
/// </summary>
public sealed class LuneLoopback
{
sealed class Node : ILuneNetLink
{
readonly LuneLoopback _hub;
readonly int _peer;
public LuneSession Session;
public Node(LuneLoopback hub, int peer)
{
_hub = hub;
_peer = peer;
}
public void Send(int peer, string kind, object payload)
{
_hub.Deliver(_peer, peer, kind, payload);
}
public void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp)
{
_hub.DeliverFrame(_peer, peer, serial, elapsed, units, buildingHp);
}
}
readonly Dictionary<int, Node> _nodes = new Dictionary<int, Node>();
/// <summary>Every message that left a peer (tests, diagnostics).</summary>
public readonly List<string> Log = new List<string>();
/// <summary>Attaches a session under a peer id and returns the session for chaining.</summary>
public LuneSession Attach(int peer, LuneSession session)
{
var node = new Node(this, peer) { Session = session };
_nodes[peer] = node;
session.Link = node;
return session;
}
/// <summary>Drops a peer and tells the authority (bot takeover, never a core loss).</summary>
public void Detach(int peer)
{
Node node;
if (!_nodes.TryGetValue(peer, out node))
return;
_nodes.Remove(peer);
if (node.Session != null)
{
node.Session.Link = null;
node.Session.Connected = false;
}
foreach (Node other in _nodes.Values)
{
if (other.Session != null && other.Session.IsAuthority())
other.Session.OnPeerDisconnected(peer);
}
}
/// <summary>True while the hub still knows the peer.</summary>
public bool Has(int peer) { return _nodes.ContainsKey(peer); }
void Deliver(int from, int to, string kind, object payload)
{
Log.Add(from + "→" + to + " " + kind);
Node node;
if (!_nodes.TryGetValue(to, out node) || node.Session == null)
return;
node.Session.OnMessage(from, kind, payload);
}
void DeliverFrame(int from, int to, int serial, double elapsed, float[] units, float[] buildingHp)
{
Node node;
if (!_nodes.TryGetValue(to, out node) || node.Session == null)
return;
node.Session.OnFrame(from, serial, elapsed, units, buildingHp);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 82657628be8544758877088854dcb44e

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ca592cc38517f48cd9e5a98a2ebed089

View file

@ -0,0 +1,122 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Roles of a session (lune_session.gd Role).</summary>
public enum LuneRole { Offline, Host, Client }
/// <summary>One lobby row: peer 0 is a bot, peer 1 the host.</summary>
public sealed class LobbyEntry
{
public int Peer;
public int Slot;
public string Name = "";
public bool Ready;
public bool Bot;
public LobbyEntry Clone() { return (LobbyEntry)MemberwiseClone(); }
}
/// <summary>Result of a session intent (Godot returns a Dictionary with ok, message, pending).</summary>
public struct SessionResult
{
public bool Ok;
public string Message;
public bool Pending;
public SessionResult(bool ok, string message, bool pending = false)
{
Ok = ok;
Message = message;
Pending = pending;
}
}
/// <summary>One player row of the public state; Draft is set only for the receiving seat.</summary>
public sealed class NetPlayerRow
{
public int Slot;
public string Name = "";
public bool IsHuman;
public int Gold;
public bool Ready;
public int Earned;
public int Spent;
public int NextSeq;
public List<RosterEntry> Roster = new List<RosterEntry>();
public HeroState Hero;
public List<Booking> Bookings = new List<Booking>();
/// <summary>Private draft of the receiving seat only; null for every other seat.</summary>
public Draft Draft;
/// <summary>Undo stack size of the receiving seat (0 for others).</summary>
public int Undo;
}
/// <summary>Public state of the match for one seat (rpc_state).</summary>
public sealed class NetState
{
public Phase Phase;
public int Round;
public int Winner;
public string EndNote = "";
public Dictionary<string, double> BuildingHp = new Dictionary<string, double>();
public BossState Boss = new BossState();
public Settlement LastSettlement;
public List<int> DestroyedCores = new List<int>();
public List<NetPlayerRow> Players = new List<NetPlayerRow>();
}
/// <summary>A mobile unit as it crosses the wire (rpc_combat_begin / rpc_units_spawned).</summary>
public sealed class NetUnitRecord
{
public int Id;
public string Kind = "";
public int Team;
public int TeamIndex;
public int OwnerSlot = -1;
public string Uid = "";
public string Lane = "";
public string Zone = "";
public UnitDef Def = new UnitDef();
public double X;
public double Y;
public double Facing;
public double Hp;
public double MaxHp;
public bool Alive;
}
/// <summary>
/// Outbound session messages (the Godot RPCs). A transport or a test recorder implements this;
/// without a link the session still runs offline and for in-process host tests.
/// </summary>
public interface ILuneNetLink
{
/// <summary>Reliable message to one peer (1 = host).</summary>
void Send(int peer, string kind, object payload);
/// <summary>Unreliable ordered combat frame to one peer.</summary>
void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp);
}
/// <summary>Frames carry 13 floats per mobile unit (lune_session.gd FRAME_STRIDE).</summary>
public static class LuneNet
{
public const int DefaultPort = 24571;
public const int MaxClients = 5;
public const int Protocol = 4;
/// <summary>Seconds between combat snapshots the host sends.</summary>
public const double FrameInterval = 0.05;
public const int FrameStride = 13;
public const string KHello = "hello";
public const string KSubmit = "submit";
public const string KReady = "ready";
public const string KRefused = "refused";
public const string KFeedback = "feedback";
public const string KSetup = "setup";
public const string KState = "state";
public const string KLobby = "lobby";
public const string KCombatBegin = "combat_begin";
public const string KUnitsSpawned = "units_spawned";
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 30e31c765f3d34155ba019f7106ef715

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ade424a3ec6b740b086bd3d062edffb9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,375 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Luneraria.Core;
namespace Luneraria.Net
{
/// <summary>
/// TCP transport for LuneSession (the ENet peer of the reference). Reliable messages are
/// length-prefixed UTF-8 lines (LuneWire); combat frames are binary and tagged so a late
/// frame can be dropped by the session's serial. One socket per guest, peer 1 is the host.
/// Pump() must be called from the game loop; sockets never call into the session on their own.
/// </summary>
public sealed class LuneTcp : ILuneNetLink, IDisposable
{
const byte TagMessage = 1;
const byte TagFrame = 2;
sealed class Guest
{
public int Peer;
public TcpClient Client;
public NetworkStream Stream;
public readonly List<byte> Buffer = new List<byte>();
public bool Closed;
}
readonly object _gate = new object();
readonly Queue<Action> _mainThread = new Queue<Action>();
TcpListener _listener;
TcpClient _client;
NetworkStream _clientStream;
readonly List<byte> _clientBuffer = new List<byte>();
readonly Dictionary<int, Guest> _guests = new Dictionary<int, Guest>();
int _nextPeer = 2;
Thread _acceptThread;
volatile bool _running;
/// <summary>Session bound to this transport (set before Host/Join).</summary>
public LuneSession Session;
/// <summary>True while a host listener or a client socket is open.</summary>
public bool Open { get { return _running; } }
public string LastError = "";
// ---------------------------------------------------------------- host
public bool Host(int port)
{
try
{
_listener = new TcpListener(IPAddress.Any, port);
_listener.Start();
_running = true;
_acceptThread = new Thread(AceptLoop) { IsBackground = true, Name = "LuneTcp accept" };
_acceptThread.Start();
return true;
}
catch (Exception error)
{
LastError = "Server auf Port " + port + " nicht startbar (" + error.Message + ").";
return false;
}
}
void AceptLoop()
{
while (_running && _listener != null)
{
TcpClient client;
try
{
client = _listener.AcceptTcpClient();
}
catch
{
break;
}
lock (_gate)
{
int peer = _nextPeer++;
var guest = new Guest { Peer = peer, Client = client, Stream = client.GetStream() };
_guests[peer] = guest;
_mainThread.Enqueue(() =>
{
if (Session != null)
Session.OnMessage(peer, "__connected", null);
});
var reader = new Thread(() => ReadLoop(guest)) { IsBackground = true, Name = "LuneTcp peer " + peer };
reader.Start();
}
}
}
// ---------------------------------------------------------------- client
public bool Join(string address, int port)
{
try
{
_client = new TcpClient();
_client.Connect(address, port);
_clientStream = _client.GetStream();
_running = true;
var reader = new Thread(ClientReadLoop) { IsBackground = true, Name = "LuneTcp client" };
reader.Start();
_mainThread.Enqueue(() =>
{
if (Session != null)
Session.OnClientConnected();
});
return true;
}
catch (Exception error)
{
LastError = "Verbindung zu " + address + ":" + port + " nicht möglich (" + error.Message + ").";
_running = false;
return false;
}
}
// ---------------------------------------------------------------- pump
/// <summary>Delivers queued socket events on the game thread. Call once per frame.</summary>
public void Pump()
{
while (true)
{
Action action;
lock (_gate)
{
if (_mainThread.Count == 0)
break;
action = _mainThread.Dequeue();
}
action();
}
}
// ---------------------------------------------------------------- ILuneNetLink
public void Send(int peer, string kind, object payload)
{
byte[] body = System.Text.Encoding.UTF8.GetBytes(LuneWire.Encode(kind, payload));
byte[] packet = Frame(TagMessage, body);
if (peer == 1)
{
WriteClient(packet);
return;
}
Guest guest;
lock (_gate)
{
if (!_guests.TryGetValue(peer, out guest) || guest.Closed)
return;
}
try
{
guest.Stream.Write(packet, 0, packet.Length);
}
catch
{
QueueDrop(guest.Peer);
}
}
public void SendFrame(int peer, int serial, double elapsed, float[] units, float[] buildingHp)
{
byte[] body = LuneWire.EncodeFrame(serial, elapsed, units, buildingHp);
byte[] packet = Frame(TagFrame, body);
if (peer == 1)
{
WriteClient(packet);
return;
}
Guest guest;
lock (_gate)
{
if (!_guests.TryGetValue(peer, out guest) || guest.Closed)
return;
}
try
{
guest.Stream.Write(packet, 0, packet.Length);
}
catch
{
QueueDrop(guest.Peer);
}
}
void WriteClient(byte[] packet)
{
try
{
if (_clientStream != null)
_clientStream.Write(packet, 0, packet.Length);
}
catch
{
_mainThread.Enqueue(() =>
{
if (Session != null)
Session.OnServerLost();
});
}
}
static byte[] Frame(byte tag, byte[] body)
{
var packet = new byte[5 + body.Length];
packet[0] = tag;
packet[1] = (byte)(body.Length & 0xff);
packet[2] = (byte)((body.Length >> 8) & 0xff);
packet[3] = (byte)((body.Length >> 16) & 0xff);
packet[4] = (byte)((body.Length >> 24) & 0xff);
Buffer.BlockCopy(body, 0, packet, 5, body.Length);
return packet;
}
void ReadLoop(Guest guest)
{
var chunk = new byte[8192];
while (_running && !guest.Closed)
{
int read;
try
{
read = guest.Stream.Read(chunk, 0, chunk.Length);
}
catch
{
read = 0;
}
if (read <= 0)
break;
for (int i = 0; i < read; i++)
guest.Buffer.Add(chunk[i]);
Drain(guest.Buffer, (tag, body) => Deliver(guest.Peer, tag, body));
}
QueueDrop(guest.Peer);
}
void ClientReadLoop()
{
var chunk = new byte[8192];
while (_running)
{
int read;
try
{
read = _clientStream.Read(chunk, 0, chunk.Length);
}
catch
{
read = 0;
}
if (read <= 0)
break;
for (int i = 0; i < read; i++)
_clientBuffer.Add(chunk[i]);
Drain(_clientBuffer, (tag, body) => Deliver(1, tag, body));
}
_mainThread.Enqueue(() =>
{
if (Session != null)
Session.OnServerLost();
});
}
static void Drain(List<byte> buffer, Action<byte, byte[]> emit)
{
while (buffer.Count >= 5)
{
int length = buffer[1] | (buffer[2] << 8) | (buffer[3] << 16) | (buffer[4] << 24);
if (length < 0 || buffer.Count < 5 + length)
break;
byte tag = buffer[0];
var body = new byte[length];
for (int i = 0; i < length; i++)
body[i] = buffer[5 + i];
buffer.RemoveRange(0, 5 + length);
emit(tag, body);
}
}
void Deliver(int peer, byte tag, byte[] body)
{
if (tag == TagFrame)
{
int serial;
double elapsed;
float[] units;
float[] buildingHp;
LuneWire.DecodeFrame(body, out serial, out elapsed, out units, out buildingHp);
_mainThread.Enqueue(() =>
{
if (Session != null)
Session.OnFrame(peer, serial, elapsed, units, buildingHp);
});
return;
}
string line = System.Text.Encoding.UTF8.GetString(body);
_mainThread.Enqueue(() =>
{
if (Session == null)
return;
string kind;
object payload;
LuneWire.Decode(line, out kind, out payload);
Session.OnMessage(peer, kind, payload);
});
}
void QueueDrop(int peer)
{
_mainThread.Enqueue(() =>
{
Guest guest;
lock (_gate)
{
if (_guests.TryGetValue(peer, out guest))
{
guest.Closed = true;
_guests.Remove(peer);
}
}
if (Session != null)
Session.OnPeerDisconnected(peer);
});
}
public void Close()
{
_running = false;
try
{
if (_listener != null)
_listener.Stop();
}
catch
{
}
_listener = null;
try
{
if (_client != null)
_client.Close();
}
catch
{
}
_client = null;
_clientStream = null;
lock (_gate)
{
foreach (Guest guest in _guests.Values)
{
guest.Closed = true;
try
{
guest.Client.Close();
}
catch
{
}
}
_guests.Clear();
}
}
public void Dispose()
{
Close();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c45d8ceac322a4ffeb1e173cdb3529b6

View file

@ -0,0 +1,840 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Luneraria.Core;
namespace Luneraria.Net
{
/// <summary>
/// Compact cursor codec for the session messages (the Godot RPC payloads were Variants).
/// One message is one UTF-8 line: kind TAB payload. Compound values write their fields in a
/// fixed order; the reader consumes exactly as many tokens as the shape needs, so nesting
/// never collides with separators. No engine, no JSON library.
/// </summary>
public static class LuneWire
{
static readonly NumberFormatInfo Nfi = CultureInfo.InvariantCulture.NumberFormat;
const char Sep = '\x1f';
// ---------------------------------------------------------------- encode
public static string Encode(string kind, object payload)
{
var text = new StringBuilder(kind);
text.Append('\t');
var parts = payload as object[];
if (parts != null)
{
text.Append('i');
text.Append(parts.Length.ToString(Nfi));
for (int i = 0; i < parts.Length; i++)
{
text.Append(Sep);
WriteValue(text, parts[i]);
}
}
else
{
text.Append('i');
text.Append('1');
text.Append(Sep);
WriteValue(text, payload);
}
return text.ToString();
}
public static byte[] EncodeFrame(int serial, double elapsed, float[] units, float[] buildingHp)
{
using (var stream = new MemoryStream())
using (var writer = new BinaryWriter(stream))
{
writer.Write(serial);
writer.Write(elapsed);
writer.Write(units != null ? units.Length : 0);
if (units != null)
{
for (int i = 0; i < units.Length; i++)
writer.Write(units[i]);
}
writer.Write(buildingHp != null ? buildingHp.Length : 0);
if (buildingHp != null)
{
for (int i = 0; i < buildingHp.Length; i++)
writer.Write(buildingHp[i]);
}
return stream.ToArray();
}
}
static void WriteValue(StringBuilder text, object value)
{
if (value == null)
{
text.Append('~');
return;
}
if (value is string)
{
text.Append('s');
text.Append(Escape((string)value));
return;
}
if (value is bool)
{
text.Append((bool)value ? 'B' : 'b');
return;
}
if (value is int || value is long)
{
text.Append('i');
text.Append(Convert.ToInt64(value).ToString(Nfi));
return;
}
if (value is float || value is double)
{
text.Append('f');
text.Append(Convert.ToDouble(value).ToString("R", Nfi));
return;
}
if (value is float[])
{
float[] array = (float[])value;
text.Append('a');
text.Append(array.Length.ToString(Nfi));
for (int i = 0; i < array.Length; i++)
{
text.Append(',');
text.Append(array[i].ToString("R", Nfi));
}
return;
}
if (value is Phase)
{
text.Append('i');
text.Append(((int)(Phase)value).ToString(Nfi));
return;
}
if (value is Command)
{
text.Append('C');
WriteCommand(text, (Command)value);
return;
}
if (value is MatchOptions)
{
text.Append('O');
WriteOptions(text, (MatchOptions)value);
return;
}
if (value is LobbyEntry)
{
text.Append('L');
WriteLobby(text, (LobbyEntry)value);
return;
}
if (value is List<LobbyEntry>)
{
text.Append('l');
WriteList(text, (List<LobbyEntry>)value, WriteLobby);
return;
}
if (value is NetState)
{
text.Append('S');
WriteState(text, (NetState)value);
return;
}
if (value is NetUnitRecord)
{
text.Append('U');
WriteUnit(text, (NetUnitRecord)value);
return;
}
if (value is List<NetUnitRecord>)
{
text.Append('u');
WriteList(text, (List<NetUnitRecord>)value, WriteUnit);
return;
}
throw new InvalidDataException("LuneWire cannot encode " + value.GetType().Name);
}
static void WriteList<T>(StringBuilder text, List<T> list, Action<StringBuilder, T> write)
{
// Count rides in the tag body ("l2"); each write() starts with Sep before its first field.
text.Append(list.Count.ToString(Nfi));
for (int i = 0; i < list.Count; i++)
write(text, list[i]);
}
static void WriteCommand(StringBuilder text, Command command)
{
Str(text, command.Type ?? "");
Str(text, command.TypeId);
Str(text, command.Lane);
Str(text, command.Uid);
Str(text, command.HeroId);
Str(text, command.ItemId);
Str(text, command.ChampionId);
Int(text, command.Tier);
Int(text, command.Choice);
}
static void WriteOptions(StringBuilder text, MatchOptions options)
{
Int(text, options.Seed);
Int(text, options.HumanSlot);
Str(text, options.Bunds != null ? string.Join("\x1d", options.Bunds) : "");
Str(text, options.Profiles != null ? string.Join("\x1d", options.Profiles) : "");
}
static void WriteLobby(StringBuilder text, LobbyEntry entry)
{
Int(text, entry.Peer);
Int(text, entry.Slot);
Str(text, entry.Name ?? "");
Int(text, entry.Ready ? 1 : 0);
Int(text, entry.Bot ? 1 : 0);
}
static void WriteState(StringBuilder text, NetState state)
{
Int(text, (int)state.Phase);
Int(text, state.Round);
Int(text, state.Winner);
Str(text, state.EndNote ?? "");
var hp = state.BuildingHp ?? new Dictionary<string, double>();
Int(text, hp.Count);
foreach (KeyValuePair<string, double> pair in hp)
{
Str(text, pair.Key);
Num(text, pair.Value);
}
BossState boss = state.Boss ?? new BossState();
Int(text, boss.NextAvailableRound);
Int(text, boss.SettledRound);
Int(text, boss.Claim != null ? 1 : 0);
if (boss.Claim != null)
{
Int(text, boss.Claim.Team);
Int(text, boss.Claim.WonRound);
Int(text, boss.Claim.DeployRound);
Int(text, boss.Claim.ChooserSlot);
Str(text, boss.Claim.Lane ?? "");
}
var cores = state.DestroyedCores ?? new List<int>();
Int(text, cores.Count);
foreach (int core in cores)
Int(text, core);
Settlement settle = state.LastSettlement;
Int(text, settle != null ? 1 : 0);
if (settle != null)
{
Int(text, settle.Round);
Int(text, settle.Income);
Str(text, settle.EndReason ?? "");
Num(text, settle.CombatTime);
Str(text, settle.Boss ?? "none");
int gains = settle.Gains != null ? settle.Gains.Length : 0;
Int(text, gains);
for (int i = 0; i < gains; i++)
Int(text, settle.Gains[i]);
var lost = settle.BuildingsLost ?? new List<string>();
Int(text, lost.Count);
foreach (string id in lost)
Str(text, id);
}
var players = state.Players ?? new List<NetPlayerRow>();
Int(text, players.Count);
foreach (NetPlayerRow row in players)
WritePlayer(text, row);
}
static void WritePlayer(StringBuilder text, NetPlayerRow row)
{
Int(text, row.Slot);
Str(text, row.Name ?? "");
Int(text, row.IsHuman ? 1 : 0);
Int(text, row.Gold);
Int(text, row.Ready ? 1 : 0);
Int(text, row.Earned);
Int(text, row.Spent);
Int(text, row.NextSeq);
Int(text, row.Undo);
WriteRosterList(text, row.Roster);
Int(text, row.Hero != null ? 1 : 0);
if (row.Hero != null)
WriteHero(text, row.Hero);
WriteBookingList(text, row.Bookings);
Int(text, row.Draft != null ? 1 : 0);
if (row.Draft != null)
{
Draft draft = row.Draft;
Int(text, draft.Gold);
Int(text, draft.NextSeq);
Str(text, draft.BossLane ?? "");
WriteRosterList(text, draft.Roster);
Int(text, draft.Hero != null ? 1 : 0);
if (draft.Hero != null)
WriteHero(text, draft.Hero);
WriteBookingList(text, draft.NewBookings);
}
}
static void WriteRosterList(StringBuilder text, List<RosterEntry> list)
{
var rows = list ?? new List<RosterEntry>();
Int(text, rows.Count);
foreach (RosterEntry entry in rows)
{
Str(text, entry.Uid ?? "");
Int(text, entry.Seq);
Str(text, entry.TypeId ?? "");
Str(text, entry.Lane ?? "");
Int(text, entry.Invested);
Int(text, entry.Fresh ? 1 : 0);
}
}
static void WriteBookingList(StringBuilder text, List<Booking> list)
{
var rows = list ?? new List<Booking>();
Int(text, rows.Count);
foreach (Booking booking in rows)
{
Str(text, booking.Uid ?? "");
Int(text, booking.Seq);
Str(text, booking.ChampionId ?? "");
Str(text, booking.Lane ?? "");
Int(text, booking.Cost);
Int(text, booking.DeployRound);
}
}
static void WriteHero(StringBuilder text, HeroState hero)
{
Str(text, hero.Uid ?? "");
Int(text, hero.Seq);
Str(text, hero.HeroId ?? "");
Str(text, hero.Lane ?? "");
Int(text, hero.Xp);
Int(text, hero.Invested);
Int(text, hero.Fresh ? 1 : 0);
Str(text, hero.Items != null ? string.Join("\x1d", hero.Items) : "");
Str(text, hero.Talents != null ? string.Join("\x1d", hero.Talents) : "");
Str(text, hero.FreshItems != null ? string.Join("\x1d", hero.FreshItems) : "");
}
static void WriteUnit(StringBuilder text, NetUnitRecord unit)
{
Int(text, unit.Id);
Str(text, unit.Kind ?? "");
Int(text, unit.Team);
Int(text, unit.TeamIndex);
Int(text, unit.OwnerSlot);
Str(text, unit.Uid ?? "");
Str(text, unit.Lane ?? "");
Str(text, unit.Zone ?? "");
Num(text, unit.X);
Num(text, unit.Y);
Num(text, unit.Facing);
Num(text, unit.Hp);
Num(text, unit.MaxHp);
Int(text, unit.Alive ? 1 : 0);
UnitDef def = unit.Def ?? new UnitDef();
Str(text, def.Id ?? "");
Str(text, def.DisplayName ?? "");
Int(text, def.Cost);
Str(text, def.UpgradeId ?? "");
Num(text, def.MaxHp);
Num(text, def.Damage);
Int(text, def.AttackType);
Int(text, def.ArmorType);
Num(text, def.AttackRange);
Num(text, def.Speed);
Str(text, def.Ability ?? "");
Int(text, def.LeakValue);
Int(text, def.Bounty);
Str(text, def.AbilityDescription ?? "");
Int(text, def.IsBoss ? 1 : 0);
Int(text, def.IncomeGain);
Str(text, def.Role ?? "");
Str(text, def.HeroId ?? "");
Int(text, def.Level);
Num(text, def.ViewScale);
Num(text, def.Regen);
Num(text, def.ItemSplash);
Num(text, def.DamageBonus);
Num(text, def.ReductionBonus);
Num(text, def.AttackSpeedBonus);
Str(text, def.ChampionId ?? "");
Str(text, def.Priority ?? "");
Num(text, def.SiegeFactor);
Num(text, def.HpFactor);
Str(text, def.Tier ?? "");
Str(text, def.Talents != null ? string.Join("\x1d", def.Talents) : "");
}
static void Str(StringBuilder text, string value)
{
text.Append(Sep);
if (value == null)
text.Append('~');
else
{
text.Append('s');
text.Append(Escape(value));
}
}
static void Int(StringBuilder text, int? value)
{
text.Append(Sep);
if (value == null)
text.Append('~');
else
{
text.Append('i');
text.Append(value.Value.ToString(Nfi));
}
}
static void Num(StringBuilder text, double value)
{
text.Append(Sep);
text.Append('f');
text.Append(value.ToString("R", Nfi));
}
static string Escape(string value)
{
return value.Replace("\\", "\\\\").Replace("\n", "\\n").Replace("\t", "\\t")
.Replace("\x1d", "\\r").Replace("\x1e", "\\e").Replace("\x1f", "\\f");
}
static string Unescape(string value)
{
// Order matters: the two-character escapes first, the literal backslash last.
return value.Replace("\\f", "\x1f").Replace("\\e", "\x1e").Replace("\\r", "\x1d")
.Replace("\\t", "\t").Replace("\\n", "\n").Replace("\\\\", "\\");
}
// ---------------------------------------------------------------- decode
public static void Decode(string line, out string kind, out object payload)
{
int tab = line.IndexOf('\t');
kind = tab < 0 ? line : line.Substring(0, tab);
string body = tab < 0 ? "" : line.Substring(tab + 1);
if (body.Length == 0)
{
payload = null;
return;
}
var reader = new Reader(body);
// Leading token is the top-level value count (1 = single payload, n = object[]).
int count = reader.Integer(1);
if (count <= 1)
{
payload = reader.Value();
return;
}
var parts = new object[count];
for (int i = 0; i < count; i++)
parts[i] = reader.Value();
payload = parts;
}
public static void DecodeFrame(byte[] buffer, out int serial, out double elapsed, out float[] units, out float[] buildingHp)
{
using (var stream = new MemoryStream(buffer))
using (var reader = new BinaryReader(stream))
{
serial = reader.ReadInt32();
elapsed = reader.ReadDouble();
int unitCount = reader.ReadInt32();
units = new float[unitCount];
for (int i = 0; i < unitCount; i++)
units[i] = reader.ReadSingle();
int hpCount = reader.ReadInt32();
buildingHp = new float[hpCount];
for (int i = 0; i < hpCount; i++)
buildingHp[i] = reader.ReadSingle();
}
}
/// <summary>Token cursor over a Sep-separated payload. Tagged values: ~ null, s string, i int, f float, B/b bool, a float[], C/O/L/l/S/U/u compounds.</summary>
sealed class Reader
{
readonly string[] _tokens;
int _at;
public Reader(string body)
{
_tokens = body.Split(Sep);
_at = 0;
}
public int Remaining { get { return _tokens.Length - _at; } }
string Take()
{
if (_at >= _tokens.Length)
throw new InvalidDataException("LuneWire: payload ends early");
return _tokens[_at++];
}
public object Value()
{
string token = Take();
if (token.Length == 0 || token[0] == '~')
return null;
char tag = token[0];
string body = token.Substring(1);
switch (tag)
{
case 's':
return Unescape(body);
case 'B':
return true;
case 'b':
return false;
case 'i':
return int.Parse(body, Nfi);
case 'f':
return double.Parse(body, Nfi);
case 'a':
{
string[] parts = body.Split(',');
var data = new float[Math.Max(0, parts.Length - 1)];
for (int i = 1; i < parts.Length; i++)
data[i - 1] = float.Parse(parts[i], Nfi);
return data;
}
case 'C':
return ReadCommand();
case 'O':
return ReadOptions();
case 'L':
return ReadLobby();
case 'l':
return ReadLobbyList(body);
case 'S':
return ReadState();
case 'U':
return ReadUnit();
case 'u':
return ReadUnitList(body);
default:
throw new InvalidDataException("LuneWire tag " + tag);
}
}
string Text()
{
object value = Value();
return value as string;
}
public int Integer(int fallback = 0)
{
object value = Value();
if (value == null)
return fallback;
return Convert.ToInt32(value);
}
double Number(double fallback = 0.0)
{
object value = Value();
if (value == null)
return fallback;
return Convert.ToDouble(value);
}
bool Flag()
{
object value = Value();
if (value == null)
return false;
if (value is bool)
return (bool)value;
return Convert.ToInt32(value) != 0;
}
Command ReadCommand()
{
return new Command
{
Type = Text() ?? "",
TypeId = Text(),
Lane = Text(),
Uid = Text(),
HeroId = Text(),
ItemId = Text(),
ChampionId = Text(),
Tier = ReadNullableInt(),
Choice = ReadNullableInt()
};
}
int? ReadNullableInt()
{
object value = Value();
if (value == null)
return null;
return Convert.ToInt32(value);
}
MatchOptions ReadOptions()
{
var options = new MatchOptions { Seed = Integer(1), HumanSlot = Integer(1) };
string bunds = Text();
if (!string.IsNullOrEmpty(bunds))
options.Bunds.AddRange(bunds.Split('\x1d'));
string profiles = Text();
if (!string.IsNullOrEmpty(profiles))
options.Profiles.AddRange(profiles.Split('\x1d'));
return options;
}
LobbyEntry ReadLobby()
{
return new LobbyEntry
{
Peer = Integer(),
Slot = Integer(),
Name = Text() ?? "",
Ready = Flag(),
Bot = Flag()
};
}
List<LobbyEntry> ReadLobbyList(string body)
{
int count = body.Length > 0 ? int.Parse(body, Nfi) : 0;
var list = new List<LobbyEntry>(count);
for (int i = 0; i < count; i++)
list.Add(ReadLobby());
return list;
}
NetState ReadState()
{
var state = new NetState
{
Phase = (Phase)Integer(),
Round = Integer(),
Winner = Integer(),
EndNote = Text() ?? ""
};
int hpCount = Integer();
for (int i = 0; i < hpCount; i++)
{
string id = Text() ?? "";
state.BuildingHp[id] = Number();
}
state.Boss = new BossState { NextAvailableRound = Integer(), SettledRound = Integer() };
if (Flag())
{
state.Boss.Claim = new BossClaim
{
Team = Integer(),
WonRound = Integer(),
DeployRound = Integer(),
ChooserSlot = Integer(),
Lane = Text() ?? ""
};
}
int coreCount = Integer();
for (int i = 0; i < coreCount; i++)
state.DestroyedCores.Add(Integer());
if (Flag())
{
var settle = new Settlement
{
Round = Integer(),
Income = Integer(),
EndReason = Text() ?? "",
CombatTime = Number(),
Boss = Text() ?? "none"
};
int gains = Integer();
for (int i = 0; i < gains && i < settle.Gains.Length; i++)
settle.Gains[i] = Integer();
int lost = Integer();
for (int i = 0; i < lost; i++)
settle.BuildingsLost.Add(Text() ?? "");
state.LastSettlement = settle;
}
int players = Integer();
for (int i = 0; i < players; i++)
state.Players.Add(ReadPlayer());
return state;
}
NetPlayerRow ReadPlayer()
{
var row = new NetPlayerRow
{
Slot = Integer(),
Name = Text() ?? "",
IsHuman = Flag(),
Gold = Integer(),
Ready = Flag(),
Earned = Integer(),
Spent = Integer(),
NextSeq = Integer(),
Undo = Integer()
};
row.Roster = ReadRosterList();
if (Flag())
row.Hero = ReadHero();
row.Bookings = ReadBookingList();
if (Flag())
{
var draft = new Draft { Gold = Integer(), NextSeq = Integer(), BossLane = Text() ?? "" };
draft.Roster = ReadRosterList();
if (Flag())
draft.Hero = ReadHero();
draft.NewBookings = ReadBookingList();
row.Draft = draft;
}
return row;
}
List<RosterEntry> ReadRosterList()
{
int count = Integer();
var list = new List<RosterEntry>(count);
for (int i = 0; i < count; i++)
{
list.Add(new RosterEntry
{
Uid = Text() ?? "",
Seq = Integer(),
TypeId = Text() ?? "",
Lane = Text() ?? "",
Invested = Integer(),
Fresh = Flag()
});
}
return list;
}
List<Booking> ReadBookingList()
{
int count = Integer();
var list = new List<Booking>(count);
for (int i = 0; i < count; i++)
{
list.Add(new Booking
{
Uid = Text() ?? "",
Seq = Integer(),
ChampionId = Text() ?? "",
Lane = Text() ?? "",
Cost = Integer(),
DeployRound = Integer()
});
}
return list;
}
HeroState ReadHero()
{
var hero = new HeroState
{
Uid = Text() ?? "",
Seq = Integer(),
HeroId = Text() ?? "",
Lane = Text() ?? "",
Xp = Integer(),
Invested = Integer(),
Fresh = Flag()
};
AddSplit(hero.Items, Text());
AddSplit(hero.Talents, Text());
AddSplit(hero.FreshItems, Text());
return hero;
}
static void AddSplit(List<string> target, string value)
{
target.Clear();
if (string.IsNullOrEmpty(value))
return;
foreach (string part in value.Split('\x1d'))
{
if (part.Length > 0)
target.Add(part);
}
}
NetUnitRecord ReadUnit()
{
var unit = new NetUnitRecord
{
Id = Integer(),
Kind = Text() ?? "",
Team = Integer(),
TeamIndex = Integer(),
OwnerSlot = Integer(),
Uid = Text() ?? "",
Lane = Text() ?? "",
Zone = Text() ?? "",
X = Number(),
Y = Number(),
Facing = Number(),
Hp = Number(),
MaxHp = Number(),
Alive = Flag(),
Def = new UnitDef
{
Id = Text() ?? "",
DisplayName = Text() ?? "",
Cost = Integer(),
UpgradeId = Text() ?? "",
MaxHp = Number(1.0),
Damage = Number(),
AttackType = Integer(),
ArmorType = Integer(),
AttackRange = Number(1.0),
Speed = Number(2.0),
Ability = Text() ?? "",
LeakValue = Integer(),
Bounty = Integer(),
AbilityDescription = Text() ?? "",
IsBoss = Flag(),
IncomeGain = Integer(),
Role = Text() ?? "",
HeroId = Text() ?? "",
Level = Integer(1),
ViewScale = Number(1.0),
Regen = Number(),
ItemSplash = Number(),
DamageBonus = Number(),
ReductionBonus = Number(),
AttackSpeedBonus = Number(),
ChampionId = Text() ?? "",
Priority = Text() ?? "",
SiegeFactor = Number(1.0),
HpFactor = Number(1.0),
Tier = Text() ?? ""
}
};
AddSplit(unit.Def.Talents, Text());
return unit;
}
List<NetUnitRecord> ReadUnitList(string body)
{
int count = body.Length > 0 ? int.Parse(body, Nfi) : 0;
var list = new List<NetUnitRecord>(count);
for (int i = 0; i < count; i++)
list.Add(ReadUnit());
return list;
}
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a70c5b2967c2445619bb789979871fad

View file

@ -0,0 +1,16 @@
{
"name": "Luneraria.Net",
"rootNamespace": "Luneraria.Net",
"references": [
"Luneraria.Core"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 593ed984c4ffe4880a001bc56139f673
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests
public static IList<CheckSuite> All()
{
return new List<CheckSuite> { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite() };
return new List<CheckSuite> { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite() };
}
}
}

View file

@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Multi-round loopback with two sessions (port of tools/luneraria/net_rounds.py, 12 checks).
/// Host and guest play five rounds through the real LuneSession; after every settlement the
/// public checksums must agree. Guest A leaves during the combat of round 2, the bot takes
/// over, guest B returns with the same display name and must get the same seat back. The
/// guest never runs a combat step and receives host snapshots. This is a local in-process
/// loopback, not a six-player internet match.
/// </summary>
public sealed class NetRoundsSuite : CheckSuite
{
public override string Name { get { return "net_rounds"; } }
const int Rounds = 5;
const int DropRound = 2;
/// <summary>Host combat steps per tick (net_pair.gd steps_per_frame); snapshots still flow.</summary>
const int StepsPerTick = 8;
sealed class CombatLog
{
public int Round;
public int Frames;
public int LocalSteps;
}
readonly Dictionary<int, string> _hostSums = new Dictionary<int, string>();
readonly Dictionary<int, string> _guestSums = new Dictionary<int, string>();
readonly List<CombatLog> _combats = new List<CombatLog>();
readonly List<int> _seatsA = new List<int>();
readonly List<int> _seatsB = new List<int>();
readonly List<string> _hostMessages = new List<string>();
readonly List<string> _errors = new List<string>();
protected override void RunChecks()
{
var hub = new LuneLoopback();
LuneSession host = hub.Attach(1, new LuneSession());
host.SessionMessage += (text, isError) =>
{
if (isError)
_errors.Add("host: " + text);
else
_hostMessages.Add(text);
};
var options = new MatchOptions
{
Seed = 42,
HumanSlot = 0,
Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" }
};
if (!host.HostNetwork(options, "Host"))
{
Check(false, "host starts: " + host.LastError);
return;
}
const int peerA = 10;
const int peerB = 11;
LuneSession guest = Join(hub, peerA, "Gast", _seatsA, _errors);
bool spoofRejected = !guest.HostCommand(1, 5, Command.Buy("w01")).Ok;
bool dropped = false;
int hostPlannedRound = 0;
int guestPlannedRound = 0;
int framesAtSegment = 0;
var current = new CombatLog();
for (int guard = 0; guard < 8000 && host.MatchState.Phase != Phase.Over && host.MatchState.RoundNumber <= Rounds + 1; guard++)
{
LuneMatch match = host.MatchState;
if (match.Phase == Phase.Planning)
{
if (match.RoundNumber > Rounds)
break;
host.PlanBotSeats();
PlanOnce(host, ref hostPlannedRound, host.LocalSlot, true);
if (guest != null && guest.MatchState != null && guest.LocalSlot >= 0)
PlanOnce(guest, ref guestPlannedRound, guest.LocalSlot, false);
}
else if (match.Phase == Phase.Combat)
{
if (current.Round != match.RoundNumber)
{
current = new CombatLog { Round = match.RoundNumber };
_combats.Add(current);
framesAtSegment = guest != null ? guest.FramesReceived : 0;
}
for (int step = 0; step < StepsPerTick && match.Phase == Phase.Combat; step++)
match.StepCombat();
host.HostTick(LuneNet.FrameInterval);
if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null)
{
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps);
}
// Leave on a snapshot of the combat of DropRound: short combats may send only a few.
if (!dropped && hub.Has(peerA) && match.RoundNumber == DropRound && current.Frames > 0)
{
if (guest != null)
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
dropped = true;
hub.Detach(peerA);
// Guest B returns with the same display name while the seat is bot-held.
guest = Join(hub, peerB, "Gast", _seatsB, _errors);
guestPlannedRound = 0;
framesAtSegment = guest.FramesReceived;
}
}
// Settlement: the phase left combat in this or a previous step.
if (current.Round != 0 && match.Phase != Phase.Combat)
{
if (guest != null && guest.MatchState != null && guest.MatchState.Combat != null)
{
current.Frames = Math.Max(current.Frames, guest.FramesReceived - framesAtSegment);
current.LocalSteps = Math.Max(current.LocalSteps, guest.MatchState.Combat.Steps);
}
host.HostAfterCombat();
NoteSums(host, guest, current.Round);
current = new CombatLog();
}
}
if (current.Round != 0)
{
host.HostAfterCombat();
NoteSums(host, guest, current.Round);
}
// ---------------------------------------------------------------- 12 checks
List<int> compared = _hostSums.Keys.Where(r => _guestSums.ContainsKey(r)).OrderBy(r => r).ToList();
List<int> mismatches = compared.Where(r => _hostSums[r] != _guestSums[r]).ToList();
Check(host.MatchState != null, "host_ok");
Check(dropped, "guest_a_ok (left during the combat)");
Check(_seatsB.Count > 0, "guest_b_ok");
Check(spoofRejected, "spoof_rejected");
Check(compared.Count >= Rounds - 1, "rounds_compared " + compared.Count + " of " + Rounds + " (" + string.Join(",", compared) + ")");
Check(mismatches.Count == 0 && compared.Count > 0, "checksums_equal " + string.Join(",", mismatches));
Check(_combats.Count > 0 && _combats.All(c => c.LocalSteps == 0), "client_never_simulates " + string.Join(",", _combats.Select(c => c.Round + ":" + c.LocalSteps)));
Check(_combats.Count > 0 && _combats.All(c => c.Frames > 0), "client_receives_frames " + string.Join(",", _combats.Select(c => c.Round + ":" + c.Frames)));
Check(_seatsA.Count > 0 && _seatsB.Count > 0 && _seatsB[0] == _seatsA[0], "rejoin_same_seat a=" + string.Join(",", _seatsA) + " b=" + string.Join(",", _seatsB));
Check(_hostMessages.Any(m => m.Contains("Slot wird vom Bot übernommen")), "bot_took_over");
Check(_hostMessages.Any(m => m.Contains("kehrt zurück auf")), "rejoin_announced");
Check(_errors.Count == 0, "no_script_errors " + string.Join(";", _errors));
}
static LuneSession Join(LuneLoopback hub, int peer, string name, List<int> seats, List<string> errors)
{
LuneSession guest = hub.Attach(peer, new LuneSession());
guest.SessionMessage += (text, isError) =>
{
if (isError)
errors.Add("guest " + peer + ": " + text);
};
guest.MatchResetup += () => seats.Add(guest.LocalSlot);
guest.JoinNetwork(name);
guest.OnClientConnected();
return guest;
}
static void PlanOnce(LuneSession session, ref int plannedRound, int slot, bool waitForPeer)
{
LuneMatch match = session.MatchState;
if (slot < 0 || plannedRound == match.RoundNumber || match.Players[slot].Ready)
return;
// The host waits for a seated guest in every planning phase (covers the rejoin case).
if (waitForPeer && session.SeatPeers.Count < 2)
return;
plannedRound = match.RoundNumber;
List<string> ids = match.BaseIds(match.Players[slot].Bund);
int gold = match.GoldOf(slot);
List<string> affordable = ids.Where(id => DataCatalog.GetUnit(id).Cost <= gold).ToList();
if (affordable.Count > 0)
session.Submit(Command.Buy(affordable[match.RoundNumber % affordable.Count]));
session.ReadyUp();
}
void NoteSums(LuneSession host, LuneSession guest, int fought)
{
if (fought < 1 || host.MatchState == null)
return;
_hostSums[fought] = LuneSession.Checksum(host.MatchState);
if (guest != null && guest.MatchState != null)
_guestSums[fought] = LuneSession.Checksum(guest.MatchState);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 13157be6de19046ca982d224c4d7393c

View file

@ -0,0 +1,228 @@
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Network protocol tests for the authoritative LuneSession (port of
/// saumwacht-godot/tests/luneraria/test_lune_net.gd, 43 checks). No rendering, no sockets:
/// the host entries take a peer id directly. Covers foreign seats, stale commands, double
/// purchase, bot takeover on disconnect and the rule that a disconnect is never a core loss.
/// </summary>
public sealed class NetSuite : CheckSuite
{
public override string Name { get { return "net"; } }
static LuneSession NewHost(int seed = 5)
{
var session = new LuneSession();
session.HostOffline(0, new MatchOptions
{
Seed = seed,
HumanSlot = 0,
Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" }
});
return session;
}
protected override void RunChecks()
{
SeatOwnership();
StaleCommand();
DoublePurchase();
ForeignUid();
DisconnectIsNotCoreLoss();
SixSeatsAndPhases();
HelloAndRejoin();
TakeoverAndBotOnce();
ReadyCommitsAndPrivacy();
}
void SeatOwnership()
{
LuneSession session = NewHost();
session.SeatPeer(1, 42, "Gast");
Check(session.SeatOfPeer(42) == 1, "peer is seated on slot 1");
// Peer 42 tries to act as slot 3.
SessionResult spoofed = session.HostCommand(42, 3, Command.Buy("w01"));
Check(!spoofed.Ok, "command from another seat is rejected");
// Unseated peer.
SessionResult orphan = session.HostCommand(99, 0, Command.Buy("w01"));
Check(!orphan.Ok, "command from an unseated peer is rejected");
// The owner may act during planning (bots are pre-ready, so do not ready up yet).
SessionResult own = session.HostCommand(1, 0, Command.Buy(session.MatchState.BaseIds("glutpakt")[0]));
Check(own.Ok, "the seat owner may buy");
session.Close();
}
void StaleCommand()
{
LuneSession session = NewHost();
session.HostReady(0);
for (int slot = 1; slot < 6; slot++)
session.HostReady(slot);
Check(session.MatchState.Phase == Phase.Combat, "all ready starts combat");
SessionResult late = session.HostCommand(1, 0, Command.Buy("w01"));
Check(!late.Ok && (late.Message.Contains("spät") || late.Message.Contains("vorbei")),
"stale planning command after the phase closed is rejected");
session.Close();
}
void DoublePurchase()
{
LuneSession session = NewHost();
List<string> ids = session.MatchState.BaseIds("glutpakt");
int cost = DataCatalog.GetUnit(ids[0]).Cost;
session.MatchState.Players[0].Draft.Gold = 1000;
SessionResult first = session.HostCommand(1, 0, Command.Buy(ids[0]));
int goldAfterFirst = session.MatchState.Players[0].Draft.Gold;
int rosterAfterFirst = session.MatchState.Players[0].Draft.Roster.Count;
Check(first.Ok && rosterAfterFirst == 1 && goldAfterFirst == 1000 - cost,
"one purchase spends the catalogue cost exactly once");
// A duplicated packet is another buy command: it may buy a second unit at a cost,
// but it must never hand out a free clone (ownership is the uid list plus gold).
session.HostCommand(1, 0, Command.Buy(ids[0]));
int rosterAfter = session.MatchState.Players[0].Draft.Roster.Count;
int goldAfter = session.MatchState.Players[0].Draft.Gold;
Check(rosterAfter == rosterAfterFirst + 1, "a repeated buy creates one more unit, not a free clone");
Check(goldAfter == goldAfterFirst - cost, "the repeated buy also pays exactly once");
var uids = new HashSet<string>();
foreach (RosterEntry entry in session.MatchState.Players[0].Draft.Roster)
uids.Add(entry.Uid);
Check(uids.Count == rosterAfter, "every owned unit has a unique id");
session.Close();
}
void ForeignUid()
{
LuneSession session = NewHost();
session.SeatPeer(1, 7, "Gast");
List<string> ids = session.MatchState.BaseIds("glutpakt");
session.MatchState.Players[0].Draft.Gold = 500;
session.HostCommand(1, 0, Command.Buy(ids[0]));
string uid = session.MatchState.Players[0].Draft.Roster[0].Uid;
// Guest tries to upgrade the host's unit.
SessionResult steal = session.HostCommand(7, 1, Command.Upgrade(uid));
Check(!steal.Ok, "a guest cannot upgrade a foreign unit id");
// Guest tries to act as the host seat.
SessionResult hijack = session.HostCommand(7, 0, Command.Upgrade(uid));
Check(!hijack.Ok, "a guest cannot act on the host seat");
// Owner may upgrade their own unit.
string upgradeId = DataCatalog.UpgradeOf(ids[0]) ?? "";
if (upgradeId.Length > 0)
{
session.MatchState.Players[0].Draft.Gold = 500;
SessionResult ok = session.HostCommand(1, 0, Command.Upgrade(uid));
Check(ok.Ok, "the owner may upgrade their own unit");
}
session.Close();
}
void DisconnectIsNotCoreLoss()
{
LuneSession session = NewHost();
session.SeatPeer(1, 50, "Gast");
session.SeatPeer(2, 51, "Gast2");
double core0 = session.MatchState.CoreHp(0);
double core1 = session.MatchState.CoreHp(1);
session.OnPeerDisconnected(50);
Check(session.MatchState.CoreHp(0) == core0 && session.MatchState.CoreHp(1) == core1,
"disconnect does not damage either Ancient");
Check(session.MatchState.Phase != Phase.Over, "disconnect does not end the match");
Check(session.MatchState.Winner == LuneMatch.Ongoing, "disconnect is not a core victory");
Check(!session.SeatPeers.ContainsKey(1), "the lost seat is no longer peer-held");
bool botPlanned = false;
foreach (LobbyEntry entry in session.Lobby)
{
if (entry.Slot == 1)
botPlanned = entry.Bot && entry.Ready;
}
Check(botPlanned, "the disconnected seat is taken over by a bot");
session.Close();
}
void SixSeatsAndPhases()
{
LuneSession session = NewHost();
Check(session.MatchState.Players.Count == 6, "six seats exist");
var teams = new Dictionary<int, int>();
foreach (Player player in session.MatchState.Players)
{
int count;
teams.TryGetValue(player.Team, out count);
teams[player.Team] = count + 1;
}
Check(teams.Count == 2 && teams[0] == 3 && teams[1] == 3, "two teams of three");
Check(session.MatchState.Map.Buildings.Count(d => d.Tier != "core") == 16, "sixteen towers");
Check(session.MatchState.Map.Buildings.Count(d => d.Tier == "core") == 2, "two Ancients");
session.HostReady(0);
for (int slot = 1; slot < 6; slot++)
session.HostReady(slot);
session.PlanBotSeats();
// Bots already planned via HostBeginCombat; ensure a full round works offline-style.
if (session.MatchState.Phase == Phase.Planning)
session.HostBeginCombat();
Check(session.MatchState.Phase == Phase.Combat, "combat starts when everyone is ready");
session.HostFinishCombat();
Check(session.MatchState.Phase == Phase.Planning || session.MatchState.Phase == Phase.Over,
"settlement returns to planning or ends");
session.Close();
}
void HelloAndRejoin()
{
LuneSession session = NewHost();
Check(session.HostHello(60, LuneNet.Protocol - 1, "Alt") == -1, "an older protocol is refused");
Check(session.SeatOfPeer(60) < 0, "a refused peer holds no seat");
int anna = session.HostHello(61, LuneNet.Protocol, "Anna");
Check(anna == 1, "the first guest takes the first free seat");
Check(session.HostHello(61, LuneNet.Protocol, "Anna") == anna, "a repeated hello keeps the one seat");
session.OnPeerDisconnected(61);
Check(session.MatchState.Players[anna].Ready && !session.MatchState.Players[anna].IsHuman,
"a departed seat is a ready bot");
int bert = session.HostHello(62, LuneNet.Protocol, "Bert");
Check(bert >= 0 && bert != anna, "a new guest does not take a seat reserved for rejoining");
int back = session.HostHello(63, LuneNet.Protocol, "Anna");
Check(back == anna, "a returning guest gets the same seat back by name");
Check(session.MatchState.Players[anna].IsHuman && !session.MatchState.Players[anna].Ready,
"the returned seat is human and must ready up");
session.Close();
}
void TakeoverAndBotOnce()
{
LuneSession session = NewHost();
session.PlanBotSeats();
Player bot = session.MatchState.Players[2];
int spentByBot = bot.Gold - bot.Draft.Gold;
Check(spentByBot > 0, "a bot seat plans and spends gold");
session.PlanBotSeats();
Check(bot.Gold - bot.Draft.Gold == spentByBot, "a bot seat plans only once per planning phase");
session.SeatPeer(2, 70, "Cora");
Check(bot.Draft.Gold == bot.Gold && bot.Draft.Roster.Count == 0,
"a takeover in planning drops the bot's uncommitted draft");
Check(!bot.Ready, "the new human must ready up");
session.Close();
}
void ReadyCommitsAndPrivacy()
{
LuneSession session = NewHost();
session.SeatPeer(1, 80, "Dora");
List<string> ids = session.MatchState.BaseIds("wurzelbund");
Check(session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "the guest buys during planning");
Check(session.HostCommand(80, 1, new Command { Type = "undo" }).Ok, "undo goes through the host");
Check(session.MatchState.Players[1].Draft.Roster.Count == 0, "undo removed the purchase");
session.HostCommand(80, 1, Command.Buy(ids[0]));
session.HostReady(1);
Check(!session.HostCommand(80, 1, Command.Buy(ids[0])).Ok, "after ready the plan is binding");
NetState state = session.StateFor(1);
List<NetPlayerRow> drafts = state.Players.Where(row => row.Draft != null).ToList();
Check(drafts.Count == 1 && drafts[0].Slot == 1, "a seat receives only its own draft");
Check(session.MatchState.Phase == Phase.Planning, "combat waits for the host seat");
session.HostReady(0);
Check(session.MatchState.Phase == Phase.Combat, "combat starts once every human is ready");
session.Close();
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 88ddfdbec08764d09985b51f6de3fd1f

View file

@ -49,5 +49,7 @@ namespace Luneraria.Core.Tests
[Test] public void M2Rules() { RunSuite(new M2RulesSuite()); }
[Test] public void GoldenReplays() { RunSuite(new GoldenSuite()); }
[Test] public void Determinism() { RunSuite(new DeterminismSuite()); }
[Test] public void NetProtocol() { RunSuite(new NetSuite()); }
[Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); }
}
}

View file

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5914768ea6d0240b7b72fca4a7e75b0b
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,170 @@
using System;
using System.Collections.Generic;
using Luneraria.Core;
using Luneraria.Net;
using NUnit.Framework;
namespace Luneraria.Net.Tests
{
/// <summary>Round-trip checks for the wire codec (LuneWire) and a localhost TCP session.</summary>
public sealed class LuneWireTests
{
[Test]
public void CommandRoundTrips()
{
Command original = Command.Buy("g01", "mid");
string line = LuneWire.Encode(LuneNet.KSubmit, new object[] { 3, original });
string kind;
object payload;
LuneWire.Decode(line, out kind, out payload);
Assert.AreEqual(LuneNet.KSubmit, kind);
var parts = (object[])payload;
Assert.AreEqual(3, parts[0]);
var copy = (Command)parts[1];
Assert.AreEqual("buy", copy.Type);
Assert.AreEqual("g01", copy.TypeId);
Assert.AreEqual("mid", copy.Lane);
}
[Test]
public void StateRoundTrips()
{
var state = new NetState
{
Phase = Phase.Planning,
Round = 7,
Winner = LuneMatch.Ongoing,
EndNote = "Ancient von Blau zerstört.",
LastSettlement = new Settlement { Round = 6, Income = 90, EndReason = "cleared", CombatTime = 12.5, Boss = "won" }
};
state.BuildingHp["blau_ancient"] = 1200.5;
state.BuildingHp["rot_top_outer"] = 0.0;
state.DestroyedCores.Add(1);
state.Boss.NextAvailableRound = 4;
state.Boss.Claim = new BossClaim { Team = 0, WonRound = 3, DeployRound = 5, ChooserSlot = 1, Lane = "mid" };
var row = new NetPlayerRow
{
Slot = 1,
Name = "Anna",
IsHuman = true,
Gold = 340,
Ready = false,
Earned = 400,
Spent = 60,
NextSeq = 4,
Undo = 1,
Draft = new Draft { Gold = 300, NextSeq = 4, BossLane = "top" }
};
row.Roster.Add(new RosterEntry { Uid = "p1-1", Seq = 1, TypeId = "w01", Lane = "mid", Invested = 50, Fresh = true });
row.Hero = new HeroState { Uid = "p1-2", Seq = 2, HeroId = "held_ringborke", Lane = "mid", Xp = 12, Invested = 200, Fresh = false };
row.Hero.Items.Add("glutklinge");
row.Bookings.Add(new Booking { Uid = "p1-3", Seq = 3, ChampionId = "ch_belagerung", Lane = "bot", Cost = 80, DeployRound = 8 });
row.Draft.Roster.Add(new RosterEntry { Uid = "p1-4", Seq = 4, TypeId = "g01", Lane = "top", Invested = 45, Fresh = true });
row.Draft.NewBookings.Add(new Booking { Uid = "p1-5", Seq = 5, ChampionId = "ch_sturm", Lane = "mid", Cost = 70 });
state.Players.Add(row);
string line = LuneWire.Encode(LuneNet.KState, state);
string kind;
object payload;
LuneWire.Decode(line, out kind, out payload);
var copy = (NetState)payload;
Assert.AreEqual(Phase.Planning, copy.Phase);
Assert.AreEqual(7, copy.Round);
Assert.AreEqual("Ancient von Blau zerstört.", copy.EndNote);
Assert.AreEqual(1200.5, copy.BuildingHp["blau_ancient"], 1e-9);
Assert.AreEqual(0.0, copy.BuildingHp["rot_top_outer"], 1e-9);
Assert.AreEqual(1, copy.DestroyedCores[0]);
Assert.AreEqual(4, copy.Boss.NextAvailableRound);
Assert.AreEqual("mid", copy.Boss.Claim.Lane);
Assert.AreEqual(12.5, copy.LastSettlement.CombatTime, 1e-9);
NetPlayerRow player = copy.Players[0];
Assert.AreEqual("Anna", player.Name);
Assert.AreEqual(340, player.Gold);
Assert.AreEqual("p1-1", player.Roster[0].Uid);
Assert.IsTrue(player.Roster[0].Fresh);
Assert.AreEqual("held_ringborke", player.Hero.HeroId);
Assert.AreEqual("glutklinge", player.Hero.Items[0]);
Assert.AreEqual("ch_belagerung", player.Bookings[0].ChampionId);
Assert.AreEqual(300, player.Draft.Gold);
Assert.AreEqual("top", player.Draft.BossLane);
Assert.AreEqual("g01", player.Draft.Roster[0].TypeId);
Assert.AreEqual("ch_sturm", player.Draft.NewBookings[0].ChampionId);
}
[Test]
public void UnitsAndFrameRoundTrip()
{
var unit = new NetUnitRecord
{
Id = 12,
Kind = "hero",
Team = 0,
TeamIndex = 2,
OwnerSlot = 1,
Uid = "p1-2",
Lane = "mid",
Zone = "lane",
X = 3.5,
Y = -1.25,
Facing = 1.5,
Hp = 80,
MaxHp = 100,
Alive = true,
Def = new UnitDef { Id = "held_ringborke", DisplayName = "Ringborke", Cost = 200, MaxHp = 100, Damage = 12, Tier = "" }
};
unit.Def.Talents.Add("t_roots");
string line = LuneWire.Encode(LuneNet.KUnitsSpawned, new List<NetUnitRecord> { unit });
string kind;
object payload;
LuneWire.Decode(line, out kind, out payload);
var list = (List<NetUnitRecord>)payload;
Assert.AreEqual(1, list.Count);
Assert.AreEqual("hero", list[0].Kind);
Assert.AreEqual(3.5, list[0].X, 1e-9);
Assert.AreEqual("held_ringborke", list[0].Def.Id);
Assert.AreEqual("t_roots", list[0].Def.Talents[0]);
var floats = new float[LuneNet.FrameStride];
for (int i = 0; i < floats.Length; i++)
floats[i] = i + 0.5f;
byte[] frame = LuneWire.EncodeFrame(9, 1.25, floats, new[] { 10f, 0f });
int serial;
double elapsed;
float[] units;
float[] hp;
LuneWire.DecodeFrame(frame, out serial, out elapsed, out units, out hp);
Assert.AreEqual(9, serial);
Assert.AreEqual(1.25, elapsed, 1e-9);
Assert.AreEqual(LuneNet.FrameStride, units.Length);
Assert.AreEqual(2.5f, units[2]);
Assert.AreEqual(2, hp.Length);
}
[Test]
public void LobbyAndOptionsRoundTrip()
{
var roster = new List<LobbyEntry>
{
new LobbyEntry { Peer = 1, Slot = 0, Name = "Host", Ready = false, Bot = false },
new LobbyEntry { Peer = 0, Slot = 1, Name = "Bot", Ready = true, Bot = true }
};
string line = LuneWire.Encode(LuneNet.KLobby, roster);
string kind;
object payload;
LuneWire.Decode(line, out kind, out payload);
var copy = (List<LobbyEntry>)payload;
Assert.AreEqual(2, copy.Count);
Assert.AreEqual("Host", copy[0].Name);
Assert.IsTrue(copy[1].Bot);
var options = new MatchOptions { Seed = 42, HumanSlot = 0, Bunds = new List<string> { "glutpakt", "wurzelbund" } };
line = LuneWire.Encode(LuneNet.KSetup, new object[] { options, 3, roster });
LuneWire.Decode(line, out kind, out payload);
var parts = (object[])payload;
var optionsCopy = (MatchOptions)parts[0];
Assert.AreEqual(42, optionsCopy.Seed);
Assert.AreEqual(3, parts[1]);
Assert.AreEqual(2, optionsCopy.Bunds.Count);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8779292e0688a4cf083bcc73518df0e2

View file

@ -0,0 +1,25 @@
{
"name": "Luneraria.Net.Tests",
"rootNamespace": "Luneraria.Net.Tests",
"references": [
"Luneraria.Core",
"Luneraria.Net",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 340d183f865dd44998bab569b71ae410
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -29,9 +29,13 @@ namespace Luneraria.UI
public int HumanSlot = 1;
/// <summary>Optional render target for tests and captures (null: the screen).</summary>
public RenderTexture Target;
/// <summary>Network session (U4); null offline. Commands and Ready go through it.</summary>
public LuneSession Session;
/// <summary>Menu/shell that owns the session (U4); null in tests and the benchmark.</summary>
public LuneNetPlay Net;
public LuneHud Hud { get; private set; }
public LuneMatch Match { get { return Driver.Runner.Match; } }
public LuneMatch Match { get { return _runner != null ? _runner.Match : Driver.Runner.Match; } }
/// <summary>The human seat of the running match (-1: a bot match, e.g. tests and the benchmark).</summary>
int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } }
bool Human { get { return Seat >= 0; } }
@ -53,10 +57,18 @@ namespace Luneraria.UI
MaterialPropertyBlock _block;
void Start()
{
if (Net != null)
return;
Launch();
}
/// <summary>Builds the HUD for the current match (also called by the network shell).</summary>
public void Launch()
{
_camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main;
// The benchmark keeps its bot match; otherwise the configured seat plays.
if (!LuneBenchmark.Running && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot))
if (!LuneBenchmark.Running && Net == null && (Driver.HumanSlot != HumanSlot || Driver.Runner == null || Driver.Runner.HumanSlot != Driver.HumanSlot))
{
Driver.HumanSlot = HumanSlot;
Driver.Restart();
@ -76,6 +88,10 @@ namespace Luneraria.UI
LuneBenchmark.UiMs = () => (Hud != null ? Hud.LastTickMs : 0.0) + _updateMs + _lateMs;
}
bool Networked { get { return Session != null && Session.IsNetworked(); } }
bool IsClient { get { return Networked && !Session.IsAuthority(); } }
void OnDestroy()
{
LuneBenchmark.UiMs = null;
@ -88,7 +104,23 @@ namespace Luneraria.UI
_lastStage = (LuneMatchRunner.Stage)(-1);
Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target);
Hud.CommandRequested += command => Feedback(Send(command));
Hud.UndoRequested += () => Feedback(Human ? Match.Undo(Seat) : new CommandResult(false, "Kein eigener Platz."));
Hud.UndoRequested += () =>
{
if (!Human)
{
Feedback(new CommandResult(false, "Kein eigener Platz."));
return;
}
if (Networked)
{
SessionResult result = Session.Submit(new Command { Type = "undo" });
Feedback(new CommandResult(result.Ok, result.Message));
}
else
{
Feedback(Match.Undo(Seat));
}
};
Hud.ReadyRequested += OnReady;
Hud.StarterRequested += Starter;
Hud.SpeedRequested += CycleSpeed;
@ -121,6 +153,11 @@ namespace Luneraria.UI
{
if (!Human)
return new CommandResult(false, "Zuschauer können nicht planen.");
if (Networked)
{
SessionResult result = Session.Submit(command);
return new CommandResult(result.Ok, result.Message);
}
return Match.Submit(Seat, command);
}
@ -150,24 +187,42 @@ namespace Luneraria.UI
{
if (Match.Phase != Phase.Planning || !Human)
return;
if (Networked)
Session.ReadyUp();
else
_runner.Ready();
Hud.MarkDirty();
}
void CycleSpeed()
{
if (IsClient)
{
Hud.Notify("Tempo und Pause bestimmt der Host.", true);
return;
}
Driver.CycleSpeed();
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
}
void TogglePause()
{
if (IsClient)
{
Hud.Notify("Tempo und Pause bestimmt der Host.", true);
return;
}
if (Driver.TogglePause())
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
}
void Rematch()
{
if (Net != null)
{
Net.Rematch();
return;
}
Driver.Seed += 1;
Driver.Restart();
View.Preview = null;
@ -395,8 +450,12 @@ namespace Luneraria.UI
return;
LuneMatchRunner.Stage previous = _lastStage;
_lastStage = stage;
if (previous == LuneMatchRunner.Stage.Combat && Networked && Session.IsAuthority())
Session.HostAfterCombat();
if (stage == LuneMatchRunner.Stage.Planning)
{
if (Networked && Session.IsAuthority())
Session.PlanBotSeats();
RebuildPreview();
}
else if (stage == LuneMatchRunner.Stage.Combat)
@ -577,9 +636,16 @@ namespace Luneraria.UI
VisualElement column = LuneUi.Box(true, 14f, panel);
column.style.alignItems = Align.Center;
LuneUi.Label("Partie verlassen?", "HeadingLabel", column);
LuneUi.Wrap(LuneUi.Label("Ein Hauptmenü gibt es im Unity-Port noch nicht; Verlassen beendet das Spiel.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter;
LuneUi.Wrap(LuneUi.Label("Zurück zum Hauptmenü mit Offline, Hosten und Beitreten.", "MutedLabel", column)).style.unityTextAlign = TextAnchor.MiddleCenter;
VisualElement row = LuneUi.Box(false, 12f, column);
new LuneButton("SPIEL BEENDEN", LuneButtons.Primary, row).Clicked += Quit;
new LuneButton("ZUM MENÜ", LuneButtons.Primary, row).Clicked += () =>
{
LuneUi.Show(_leave, false);
if (Net != null)
Net.ReturnToMenu();
else
Quit();
};
new LuneButton("Zurück", LuneButtons.Secondary, row).Clicked += () => LuneUi.Show(_leave, false);
LuneUi.Show(_leave, false);
Hud.Root.Add(_leave);

View file

@ -0,0 +1,177 @@
using System;
using Luneraria.Core;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Start screen of Champions of Luneraria (port of the network part of lune_menu.gd):
/// offline (local host with bot seats), host a direct connection, or join one. The menu is
/// the start screen of the game; the HUD's "Menü" returns here. Peer 1 is the host; free
/// seats stay bots. The host is authoritative.
/// </summary>
public sealed class LuneMainMenu : VisualElement
{
public event Action<MatchOptions> StartOffline;
public event Action<int, string> HostRequested;
public event Action<string, int, string> JoinRequested;
public event Action QuitRequested;
public string Bund = "wurzelbund";
public string Lane = "mid";
readonly TextField _name;
readonly TextField _address;
readonly TextField _port;
readonly Label _status;
LuneButton _laneTop;
LuneButton _laneMid;
LuneButton _laneBot;
public LuneMainMenu()
{
style.position = Position.Absolute;
style.left = 0f;
style.top = 0f;
style.right = 0f;
style.bottom = 0f;
style.justifyContent = Justify.Center;
style.alignItems = Align.Center;
style.backgroundColor = new Color(.02f, .03f, .05f, .92f);
var panel = new LunePanel(LunePanels.Menu);
panel.style.minWidth = 560f;
panel.style.maxWidth = 640f;
Add(panel);
VisualElement column = LuneUi.Box(true, 14f, panel);
column.style.alignItems = Align.Center;
LuneUi.Label("CHAMPIONS", "TitleLabel", column).style.fontSize = 56f;
LuneUi.Label("OF LUNERARIA", "TitleLabel", column).style.fontSize = 40f;
LuneUi.Label("DREI LANES · 3 GEGEN 3 · AUTOBATTLER", "EyebrowLabel", column);
LuneUi.Wrap(LuneUi.Label("Team Blau (Südwest) mit zwei Bot-Verbündeten gegen drei Bots in Team Rot. Kein Rundenlimit: Es gewinnt, wer den gegnerische Ancient zerstört. Für sechs Menschen hosten oder beitreten; freie Plätze bleiben Bots. Der Host ist autoritativ.", "MutedLabel", column));
LuneUi.Label("DEIN BUND", "SectionLabel", column);
VisualElement bundRow = LuneUi.Box(false, 10f, column);
new LuneButton("Glutpakt", LuneButtons.HudToggle, bundRow).Clicked += () => SetBund("glutpakt");
new LuneButton("Wurzelbund", LuneButtons.HudToggle, bundRow).Clicked += () => SetBund("wurzelbund");
LuneUi.Label("DEINE LANE", "SectionLabel", column);
VisualElement laneRow = LuneUi.Box(false, 10f, column);
_laneTop = new LuneButton("Top", LuneButtons.HudToggle, laneRow);
_laneMid = new LuneButton("Mid", LuneButtons.HudToggle, laneRow);
_laneBot = new LuneButton("Bottom", LuneButtons.HudToggle, laneRow);
_laneTop.ToggleMode = true;
_laneMid.ToggleMode = true;
_laneBot.ToggleMode = true;
_laneTop.Clicked += () => SetLane("top");
_laneMid.Clicked += () => SetLane("mid");
_laneBot.Clicked += () => SetLane("bot");
SetLane("mid");
LuneUi.Label("NETZWERK · DIREKTVERBINDUNG", "SectionLabel", column);
_name = Field(column, "Anzeigename", "Spieler");
VisualElement netRow = LuneUi.Box(false, 10f, column);
_address = Field(netRow, "Host-Adresse", "127.0.0.1");
_address.style.flexGrow = 1f;
_port = Field(netRow, "Port", LuneNet.DefaultPort.ToString());
_port.style.width = 100f;
VisualElement actions = LuneUi.Box(false, 12f, column);
new LuneButton("ALLEIN SPIELEN", LuneButtons.Primary, actions).Clicked += StartAlone;
new LuneButton("HOSTEN", LuneButtons.Secondary, actions).Clicked += StartHost;
new LuneButton("BEITRETEN", LuneButtons.Secondary, actions).Clicked += StartJoin;
new LuneButton("BEENDEN", LuneButtons.Secondary, actions).Clicked += () => QuitRequested?.Invoke();
_status = LuneUi.Label("", "MutedLabel", column);
_status.style.unityTextAlign = TextAnchor.MiddleCenter;
}
static TextField Field(VisualElement parent, string label, string value)
{
VisualElement box = LuneUi.Box(true, 4f, parent);
box.style.flexGrow = 1f;
LuneUi.Label(label, "EyebrowLabel", box);
var field = new TextField { value = value };
field.style.flexGrow = 1f;
field.style.minHeight = 32f;
field.style.fontSize = 16f;
box.Add(field);
return field;
}
void SetBund(string bund)
{
Bund = bund;
}
void SetLane(string lane)
{
Lane = lane;
_laneTop.On = lane == "top";
_laneMid.On = lane == "mid";
_laneBot.On = lane == "bot";
}
/// <summary>Own seat on team Blau: slots 0–2 map to top, mid, bot.</summary>
public MatchOptions OfflineOptions()
{
int seat = Array.IndexOf(LuneMap.Lanes, Lane);
if (seat < 0)
seat = 1;
var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = seat };
for (int slot = 0; slot < 6; slot++)
options.Bunds.Add(slot == seat ? Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]);
return options;
}
void StartAlone()
{
StartOffline?.Invoke(OfflineOptions());
}
void StartHost()
{
int port = ParsePort();
HostRequested?.Invoke(port, CleanName());
}
void StartJoin()
{
int port = ParsePort();
JoinRequested?.Invoke(_address.value.Trim(), port, CleanName());
}
int ParsePort()
{
int port;
return int.TryParse(_port.value.Trim(), out port) && port > 0 ? port : LuneNet.DefaultPort;
}
string CleanName()
{
string name = (_name.value ?? "").Trim();
if (name.Length == 0)
name = "Spieler";
return name.Length > 24 ? name.Substring(0, 24) : name;
}
public void Notify(string message, bool error = false)
{
_status.text = message ?? "";
_status.style.color = error ? LuneUiTheme.Hex("ff8a70") : LuneUiTheme.Muted;
}
public void Enter()
{
LuneUi.Show(this, true);
Notify("");
}
public void Leave()
{
LuneUi.Show(this, false);
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a1a6046e9cda249d3b7cf2e07a556e52

View file

@ -0,0 +1,245 @@
using System;
using Luneraria.Core;
using Luneraria.Net;
using Luneraria.View;
using UnityEngine;
using UnityEngine.UIElements;
namespace Luneraria.UI
{
/// <summary>
/// Owns the session and the TCP link (port of lune_main.gd): title menu, match, return,
/// rematch. Offline is a local host with bot-filled seats. Host/Join uses LuneSession over
/// LuneTcp. Every setup from the host (first join, rejoin, rematch) relaunches the match.
/// </summary>
[DefaultExecutionOrder(-50)]
public sealed class LuneNetPlay : MonoBehaviour
{
public LuneGame Game;
public LuneMatchDriver Driver;
public LuneMatchView View;
public LuneCameraRig Rig;
public LuneUiAssets UiAssets;
public LuneSession Session { get; private set; }
public LuneMainMenu Menu { get; private set; }
LuneTcp _tcp;
MatchOptions _lastOptions;
bool _relaunch;
UIDocument _document;
void Awake()
{
if (Game == null)
Game = GetComponent<LuneGame>();
if (Driver == null)
Driver = GetComponent<LuneMatchDriver>();
if (View == null)
View = GetComponent<LuneMatchView>();
if (Rig == null)
Rig = GetComponent<LuneCameraRig>();
_document = GetComponent<UIDocument>();
}
void Start()
{
var root = new VisualElement();
root.style.position = Position.Absolute;
root.style.left = 0f;
root.style.top = 0f;
root.style.right = 0f;
root.style.bottom = 0f;
if (_document != null && _document.rootVisualElement != null)
{
_document.rootVisualElement.Add(root);
}
else
{
// Tests and captures build their own panel; a runtime fallback still shows the menu.
root.name = "lune-menu-root";
gameObject.AddComponent<UIDocument>();
}
Menu = new LuneMainMenu();
root.Add(Menu);
Menu.StartOffline += StartOffline;
Menu.HostRequested += HostMatch;
Menu.JoinRequested += JoinMatch;
Menu.QuitRequested += Quit;
Menu.Enter();
if (Game != null)
{
Game.Net = this;
Game.enabled = false;
}
if (Driver != null)
Driver.enabled = false;
}
void Update()
{
if (_tcp != null)
_tcp.Pump();
if (_relaunch)
{
_relaunch = false;
Launch();
}
}
void StartOffline(MatchOptions options)
{
ResetSession();
Session = new LuneSession();
Session.HostOffline(options.HumanSlot, options);
_lastOptions = options;
Menu.Leave();
Launch();
}
void HostMatch(int port, string displayName)
{
ResetSession();
_tcp = new LuneTcp();
Session = new LuneSession { Link = _tcp };
_tcp.Session = Session;
Session.SessionMessage += OnMessage;
if (!_tcp.Host(port))
{
Menu.Notify(_tcp.LastError, true);
ResetSession();
return;
}
var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = 0 };
for (int slot = 0; slot < 6; slot++)
options.Bunds.Add(slot == 0 ? Menu.Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]);
if (!Session.HostNetwork(options, displayName))
{
Menu.Notify(Session.LastError, true);
ResetSession();
return;
}
_lastOptions = options;
Menu.Notify("Warte auf Gäste auf Port " + port + " …");
Menu.Leave();
Launch();
}
void JoinMatch(string address, int port, string displayName)
{
ResetSession();
_tcp = new LuneTcp();
Session = new LuneSession { Link = _tcp };
_tcp.Session = Session;
Session.SessionMessage += OnMessage;
Session.HostLost += () =>
{
string reason = Session != null ? Session.LastError : "";
ReturnToMenu();
if (Menu != null)
Menu.Notify(reason, true);
};
Session.MatchResetup += () =>
{
if (Session != null && Session.MatchState != null)
_relaunch = true;
};
if (!Session.JoinNetwork(displayName) || !_tcp.Join(address, port))
{
Menu.Notify(_tcp.LastError.Length > 0 ? _tcp.LastError : Session.LastError, true);
ResetSession();
return;
}
Menu.Notify("Verbinde mit " + address + ":" + port + " …");
}
void OnMessage(string text, bool isError)
{
if (Menu != null && LuneUi.Visible(Menu))
Menu.Notify(text, isError);
if (Game != null && Game.Hud != null && !isError)
Game.Hud.Notify(text, isError);
}
void Launch()
{
if (Game == null || Session == null || Session.MatchState == null)
return;
bool networked = Session.IsNetworked();
Game.Net = this;
Game.Session = networked ? Session : null;
Game.HumanSlot = Session.LocalSlot >= 0 ? Session.LocalSlot : 0;
if (Driver != null)
{
Driver.enabled = true;
if (networked)
Driver.Adopt(Session.MatchState, Session.LocalSlot, !Session.IsAuthority(), Session);
else
{
// Offline keeps the plain runner; the session already validated the setup path.
Driver.HumanSlot = Game.HumanSlot;
Driver.Bunds = _lastOptions != null && _lastOptions.Bunds != null ? _lastOptions.Bunds.ToArray() : null;
Driver.Seed = _lastOptions != null ? _lastOptions.Seed : Driver.Seed;
Driver.Restart();
}
}
Game.enabled = true;
Game.Launch();
}
/// <summary>Host rematch: a new match with the same seats; clients relaunch on setup.</summary>
public void Rematch()
{
if (Session == null || !Session.IsAuthority())
{
if (Game != null && Game.Hud != null)
Game.Hud.Notify("Eine Revanche startet der Host.", true);
return;
}
if (_lastOptions != null)
_lastOptions.Seed += 1;
MatchOptions options = _lastOptions ?? new MatchOptions();
Session.HostRematch(options);
_relaunch = true;
}
public void ReturnToMenu()
{
if (Game != null)
Game.enabled = false;
if (Driver != null)
Driver.enabled = false;
ResetSession();
if (Menu != null)
Menu.Enter();
}
void ResetSession()
{
if (_tcp != null)
{
_tcp.Close();
_tcp = null;
}
if (Session != null)
{
Session.Close();
Session = null;
}
}
void OnDestroy()
{
ResetSession();
}
static void Quit()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 717bc063d01154f21a8f46edd50def26

View file

@ -3,6 +3,7 @@
"rootNamespace": "Luneraria.UI",
"references": [
"Luneraria.Core",
"Luneraria.Net",
"Luneraria.View",
"Unity.RenderPipelines.Core.Runtime",
"Unity.RenderPipelines.Universal.Runtime"

View file

@ -34,6 +34,10 @@ namespace Luneraria.View
public LuneMatchRunner Runner { get; private set; }
/// <summary>Main-thread cost of the last frame's simulation (milliseconds).</summary>
public double LastAdvanceMs { get; private set; }
/// <summary>Bound network session (U4); null offline.</summary>
public LuneSession Session;
/// <summary>True on a guest: the host simulates, this side only shows snapshots.</summary>
public bool IsClient;
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
public int Tempo { get { return SpeedSteps[Mathf.Clamp(SpeedIndex, 0, SpeedSteps.Length - 1)]; } }
@ -46,6 +50,21 @@ namespace Luneraria.View
Runner.Trace = new LuneTrace();
SpeedIndex = 0;
Paused = false;
Session = null;
IsClient = false;
}
/// <summary>
/// Adopts the session's match (U4). The host still advances combat and ticks snapshots;
/// a guest never advances — its match is a display mirror of the host's snapshots.
/// </summary>
public void Adopt(LuneMatch match, int humanSlot, bool isClient, LuneSession session)
{
Session = session;
IsClient = isClient;
Runner = new LuneMatchRunner(match, humanSlot, session);
SpeedIndex = 0;
Paused = false;
}
/// <summary>Next tempo step (1× → 2× → 4× → 1×).</summary>
@ -71,10 +90,20 @@ namespace Luneraria.View
void Update()
{
if (Runner == null)
return;
_clock.Restart();
// A guest shows the host's snapshots and never advances the match.
if (IsClient)
{
LastAdvanceMs = 0.0;
return;
}
// Pause holds only the combat steps (Godot: a paused game still plans and settles).
if (!(Paused && Runner.Current == LuneMatchRunner.Stage.Combat))
Runner.Advance(Time.deltaTime * Speed * Tempo);
if (Session != null && Session.IsNetworked() && Session.IsAuthority())
Session.HostTick(Time.deltaTime * Speed * Tempo);
LastAdvanceMs = _clock.Elapsed.TotalMilliseconds;
}
}

View file

@ -45,6 +45,20 @@ namespace Luneraria.View
EnterPlanning();
}
/// <summary>Adopts an existing match (U4 network session: the host owns it, the guest mirrors it).</summary>
public LuneMatchRunner(LuneMatch match, int humanSlot, LuneSession session = null)
{
Match = match;
Clock = new LuneClock(Match.Balance.SimStep);
Profile = "";
Session = session;
HumanSlot = humanSlot >= 0 && humanSlot < 6 ? humanSlot : -1;
if (HumanSlot >= 0)
SettledSeconds = 0.0;
Current = match.Phase == Phase.Combat ? Stage.Combat : match.Phase == Phase.Over ? Stage.Over : Stage.Planning;
_stageTime = 0.0;
}
/// <summary>A match with the public fixtures the golden scenarios use (start round, gold).</summary>
public static LuneMatch NewMatch(MatchOptions options, int startRound, int extraGold)
{
@ -62,6 +76,8 @@ namespace Luneraria.View
{
Current = Stage.Planning;
_stageTime = 0.0;
if (Session != null)
return;
for (int slot = 0; slot < 6; slot++)
{
if (slot != HumanSlot)
@ -69,6 +85,9 @@ namespace Luneraria.View
}
}
/// <summary>Bound network session (U4); when set, the session owns planning and combat start.</summary>
public LuneSession Session;
/// <summary>Advances by simulated seconds (frame time times match speed).</summary>
public void Advance(double seconds)
{
@ -78,7 +97,13 @@ namespace Luneraria.View
case Stage.Planning:
_stageTime += seconds;
// A bot match starts after a short look at the plans; a human starts it with Ready.
if (HumanSlot < 0 ? _stageTime >= PlanningSeconds : Match.AllReady())
// With a session the host already began the combat when every seat was ready.
if (Session != null)
{
if (Match.Phase == Phase.Combat)
StartCombat();
}
else if (HumanSlot < 0 ? _stageTime >= PlanningSeconds : Match.AllReady())
StartCombat();
break;
case Stage.Combat:
@ -110,6 +135,7 @@ namespace Luneraria.View
void StartCombat()
{
if (Match.Phase != Phase.Combat)
Match.BeginCombat();
Clock.Reset();
Current = Stage.Combat;

View file

@ -43,6 +43,13 @@ Die Szene ändert kein Verhalten. Der Host treibt den Kern im festen Takt; die A
| Blickrichtung (`LuneUnit.Facing`) | Der Kern schreibt sie nur (Bewegungsrichtung ab 1 mm Schritt); keine Regel liest sie. Die Darstellung darf sie glätten. |
| Schriftgewichte (U3D) | Das Theme der Referenz deklariert Cantarell 560/700–760 und Cinzel 700, wendet sie aber nie an: `SaumwachtTheme.font` schreibt `variation_opentype = {"wght": …}` mit dem String-Schlüssel „wght“, den Godot 4.7.2 ignoriert. G3 zeigt Regular 400, der Port ebenso. Die Rollen bleiben getrennt, damit U5 echte Gewichte vergeben kann ([U3-UI.md](U3-UI.md#theme-symbole-schriften)). |
## Netzwerk (U4)
| Wert | Warum kein Regelunterschied |
| --- | --- |
| Transport TCP statt ENet | Der Host rechnet weiter autoritativ; Clients senden nur Absichten. Kampfrahmen laufen auf demselben Socket und werden über die Serial verworfen (wie `unreliable_ordered`). Unter Paketverlust kann TCP Head-of-Line-Blocking erzeugen — rein darstellerisch, kein Regelergebnis. |
| Mehr-Runden-Loopback im Prozess | `net_rounds.py` startet zwei Godot-Prozesse; `NetRoundsSuite` koppelt zwei `LuneSession` über `LuneLoopback`. Protokoll, Prüfsummen, Übernahme und Wiedereinstieg sind dieselben Pfade. |
## Technische Repräsentation
Klassen statt Dictionaries, stabile Einfügesortierung, 32-Bit-`int`, der ungenutzte Zufallsgenerator entfällt. Keiner dieser Unterschiede ändert Ergebnisse. Die Liste steht in [U1-RULES.md](U1-RULES.md#bewusst-andere-technische-repräsentation).

View file

@ -67,6 +67,18 @@ Hinweis: Die Projektdateien des .NET-Harness (`tools/dotnet/*/*.csproj`) fehlten
| Offen | Hauptmenü und Netzwerk (U4); Schriftgewichte, Grottenlicht und weitere Darstellung (U5); Windows-/Linux-Player (Build-Module) |
| Commits | `luneraria/u3`: U3A `4dee9b1`, U3B/U3C `2b86c0d`, U3D `66867f3` |
## U4 und U5
## U4 · Netzwerk
U4 (Netzwerk) und U5 (Visuelle Neuausrichtung) sind noch nicht begonnen.
| Punkt | Ergebnis |
| --- | --- |
| Tests | Protokoll `NetSuite` **43/43** (Port von `test_lune_net.gd`), Mehr-Runden-Loopback `NetRoundsSuite` **12/12** (Port von `net_rounds.py`, In-Prozess mit `LuneLoopback`), `LuneWireTests` Round-trip. EditMode 29/29, PlayMode 13/13, Kerntests .NET 457/457 (402 + 43 + 12) |
| Parität | Bot-Partien unverändert `DCE95C96556F3A1A`, `7B8E43AEFC0D60C5`; Mindestumfang M4 erfüllt (Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze) |
| Bild | Startbildschirm `LuneMainMenu` (Offline/Hosten/Beitreten); Partiefenster unverändert U3 |
| Leistung | keine eigenen Netz-Messungen; Rahmen 20/s wie in der Referenz (`LuneNet.FrameInterval` 0,05 s), 13 Floats je Einheit |
| Bekannte Unterschiede | TCP statt ENet (Rahmen auf demselben Socket, Verwerfen über Serial); Zwei-Prozess-Loopback als In-Prozess-Test; vereinfachtes Hauptmenü |
| Offen | Windows-/Linux-Player (Build-Module); Internetpartie über mehrere Rechner; optional unzuverlässiger Rahmenkanal |
| Commits | `luneraria/u4` (dieser Stand) |
## U5
U5 (Visuelle Neuausrichtung) ist noch nicht begonnen.

View file

@ -1,6 +1,6 @@
# Port-Status · Champions of Luneraria in Unity
Stand: 2. Oktober 2026.
Stand: 2. Oktober 2026 (U4).
Referenz ist der Godot-Stand `luneraria/g3` (`1b540f2`) in `../saumwacht-godot`; er wird nicht mehr verändert. Engine: Unity 6.3 LTS (6000.3.20f1), Renderer URP. HDRP ist ausgeschlossen, bis ein Vergleich mit einer echten Szene etwas anderes nahelegt.
@ -12,8 +12,8 @@ Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattfo
| U1.5 · Unity-Integrationsprüfung | **fertig**: Kern im Unity-Editor 402/402 (Release und Debug), Golden bitgleich nach expliziter float32-Rundung in `Vec2`, Editor-Benchmark | [U1.5-UNITY.md](U1.5-UNITY.md) |
| U2 · Unity-Spielwelt | **fertig**: U2A Blender→FBX-Pipeline (9 Assets geprüft), U2B G1-Karte mit Materialsystem, U2C fester Takt und Ansichten (bildratenunabhängig nachgewiesen), U2D vollständige Bot-Partien sichtbar und gleich dem Kern; danach Batch-Import aller 86 Modelle, Einheiten mit eigenen Modellen | [U2-WORLD.md](U2-WORLD.md) |
| U3 · G3-HUD portieren | **fertig** (`luneraria/u3`): UI Toolkit, Sichtmodell mit Privatsphäre, menschlicher Sitz, alle HUD-Bereiche, Eingabe und Befehle, Prüfung 718/0, 14 G3-Motive, Spieltest, Leistung mit HUD | [U3-UI.md](U3-UI.md) |
| U4 · Netzwerk | nächster Schritt; M4-Umfang ist erfasst | [U4-NETWORK.md](U4-NETWORK.md) |
| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nicht begonnen | [U5-VISUAL.md](U5-VISUAL.md) |
| U4 · Netzwerk | **fertig** (`luneraria/u4`): LuneSession (Protokoll 4), TCP-Transport, Startbildschirm Host/Join, 43+12 Prüfungen, Mindestumfang M4 | [U4-NETWORK.md](U4-NETWORK.md) |
| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nächster Schritt | [U5-VISUAL.md](U5-VISUAL.md) |
QA je Meilenstein: [PORT-QA.md](PORT-QA.md).
@ -37,6 +37,7 @@ Bekannte Verhaltensunterschiede, auch die bewusst übernommenen Referenzfehler:
- **Koordinaten:** Unity = (−x, Höhe, y) der Kartenpunkte; FBX-Pipeline, Welt und Kamera nutzen dieselbe Umrechnung, das Bild entspricht Godots.
- **Host-Schleife (U2C):** fester Takt mit Akkumulator; die Bildrate bestimmt nur die Zahl der Schritte je Bild. Nachweis über Fingerabdrücke der Partie.
- **Schriftgewichte wie in G3 (U3D):** Die Referenz deklariert Gewichte, wendet sie aber nie an (String-Schlüssel „wght“ wird von Godot ignoriert). Der Port zeigt wie G3 Regular 400. Die Stilrollen bleiben getrennt, damit U5 echte Gewichte vergeben kann.
- **Netzwerk über TCP (U4):** Die Referenz nutzt ENet; der Port nutzt TCP mit `LuneWire` (Nachrichten) und binären Kampfrahmen. Späte Rahmen werden über die Serial verworfen wie bei `unreliable_ordered`. Offline ist ein lokaler Host mit Bot-Plätzen (`LuneSession.HostOffline`).
- **Modelle direkt in Git** (1. Oktober 2026): Auch die Modelle des Batch-Imports liegen wie die U2A-Modelle im Repository, ohne Git LFS. Keine Datei ist größer als 40 MB; das Repository wächst auf etwa 240 MB.
- **Projektort außerhalb von iCloud Drive** (1. Oktober 2026): Der Arbeitsordner `TowerDefense` zieht von `~/Documents/Projekte` nach `~/Developer`. iCloud Drive synchronisierte `~/Documents` und legte Konfliktkopien an, die einen Player unbrauchbar machten (U2-WORLD.md, Batch-Import). Der Schutz in `LunePlayerBuild` bleibt.

View file

@ -1,27 +1,81 @@
# U4 · Netzwerk
Stand: 30. September 2026 · **nicht begonnen**. Das Netzwerk folgt erst nach funktionierendem lokalen Spiel.
Stand: 2. Oktober 2026 · **fertig** (`luneraria/u4`).
Es wird keine neue Architektur erfunden. Übertragen wird der M4-Umfang (`lune_session.gd`, Protokoll 4), serverautoritativ:
Es wurde keine neue Architektur erfunden. Übertragen ist der M4-Umfang (`lune_session.gd`, Protokoll 4), serverautoritativ:
- Clients senden Absichten.
- Der Host berechnet Gold, Käufe, Kampf, Schaden, Besitz, Boss und Sieg.
## Umsetzung
| Baustein | Ort | Inhalt |
| --- | --- | --- |
| `LuneSession` | `Assets/Luneraria/Core/Net/LuneSession.cs` | autoritative Sitzung (Port von `lune_session.gd`): Rollen, Sitze, Lobby, Hallo/Beitritt/Wiedereinstieg, Befehle, Bereit, Kampfbeginn, Momentaufnahmen, Abrechnung, Revanche, Disconnect |
| `NetModel` | `Assets/Luneraria/Core/Net/NetModel.cs` | Sitzungsmodelle (Lobby, öffentlicher Zustand, Einheitenätzeze, Rahmen) und `ILuneNetLink` |
| `LuneLoopback` | `Assets/Luneraria/Core/Net/LuneLoopback.cs` | In-Prozess-Transport für die Protokoll- und Mehr-Runden-Tests |
| `LuneWire` | `Assets/Luneraria/Net/LuneWire.cs` | Codierung der Nachrichten (Art + Payload, Cursor-Codec) und der Kampfrahmen |
| `LuneTcp` | `Assets/Luneraria/Net/LuneTcp.cs` | TCP-Transport (Ersatz für ENet): ein Socket je Gast, zuverlässige Nachrichten und Rahmen, Pump auf dem Spielthread |
| `LuneMainMenu` | `Assets/Luneraria/UI/Game/LuneMainMenu.cs` | Startbildschirm: Allein spielen, Hosten, Beitreten, Bund, Lane, Name, Adresse, Port |
| `LuneNetPlay` | `Assets/Luneraria/UI/Game/LuneNetPlay.cs` | Schale um Sitzung und Transport (Port von `lune_main.gd`): Menü, Partie, Rückkehr, Revanche |
| `LuneGame` | `Assets/Luneraria/UI/Game/LuneGame.cs` | Befehle, Rückgängig und Bereit gehen im Netzspiel über die Sitzung; Gast zeigt nur Host-Rahmen |
Offline ist ein lokaler Host mit Bot-Plätzen (`LuneSession.HostOffline`). Hosten und Beitreten nutzen `LuneTcp` mit `LuneWire`. Peer 1 ist der Host; freie Plätze bleiben Bots.
## Erfasster M4-Umfang
| Funktion | Verhalten in der Referenz |
| --- | --- |
| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast |
| Sechs Plätze | ein Peer genau ein Platz; freie Plätze spielen Bots; Lobbyeinträge mit Name, Bereit, Bot |
| Beitritt | Hallo mit Protokollversion und Name; falsche Version wird abgelehnt; Namen gekürzt (24 Zeichen); volle Partie abgelehnt |
| Private Planung | ein Gast sendet Befehle für seinen Platz; fremde Plätze werden abgelehnt (Spoof-Schutz); der Zustand enthält nur den eigenen Entwurf |
| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde |
| Kampfsynchronisation | Kampfbeginn mit Einheitendatensätzen und Gebäude-HP; alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit, ungeordnet-zuverlässig); Gast interpoliert, verspätete Frames werden verworfen |
| Abrechnung | Host sendet den öffentlichen Zustand nach dem Kampf |
| Disconnect | Platz geht an einen Bot, der in der Planung sofort plant; niemals Kernverlust |
| Wiedereinstieg | gleicher Name, solange der Platz bot-gehalten ist → Platz zurück, volle Synchronisation |
| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden |
| Revanche | Host startet neu, Plätze bleiben |
| Hostverlust | Gast kehrt mit Meldung ins Menü zurück |
| Tests | `test_lune_net` 43 Prüfungen, `net_rounds.py` Loopback mit zwei Prozessen 12 Prüfungen |
| Funktion | Verhalten in der Referenz | im Port |
| --- | --- | --- |
| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast | erfüllt (offline, Host, Gast; TCP statt ENet) |
| Sechs Plätze | ein Peer genau ein Platz; freie Plätze spielen Bots; Lobbyeinträge mit Name, Bereit, Bot | erfüllt |
| Beitritt | Hallo mit Protokollversion und Name; falsche Version wird abgelehnt; Namen gekürzt (24 Zeichen); volle Partie abgelehnt | erfüllt |
| Private Planung | ein Gast sendet Befehle für seinen Platz; fremde Plätze werden abgelehnt (Spoof-Schutz); der Zustand enthält nur den eigenen Entwurf | erfüllt |
| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde | erfüllt |
| Kampfsynchronisation | Kampfbeginn mit Einheitenätzen und Gebäude-HP; alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit, ungeordnet-zuverlässig); Gast interpoliert, verspätete Frames werden verworfen | erfüllt (Rahmen mit Serial; späte Frames verworfen) |
| Abrechnung | Host sendet den öffentlichen Zustand nach dem Kampf | erfüllt |
| Disconnect | Platz geht an einen Bot, der in der Planung sofort plant; niemals Kernverlust | erfüllt |
| Wiedereinstieg | gleicher Name, solange der Platz bot-gehalten ist → Platz zurück, volle Synchronisation | erfüllt |
| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden | erfüllt |
| Revanche | Host startet neu, Plätze bleiben | erfüllt |
| Hostverlust | Gast kehrt mit Meldung ins Menü zurück | erfüllt |
| Tests | `test_lune_net` 43 Prüfungen, `net_rounds.py` Loopback mit zwei Prozessen 12 Prüfungen | erfüllt: `NetSuite` 43/43, `NetRoundsSuite` 12/12 (In-Prozess-Loopback statt zwei Prozesse) |
Mindestumfang des Ports: Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze.
## Prüfung
| Test | Ergebnis |
| --- | --- |
| `NetSuite` (Port von `test_lune_net.gd`) | **43/43**: Sitzplatzbesitz und Spoof, veraltete Befehle, Doppelkauf, fremde UID, Disconnect ohne Kernschaden, sechs Plätze und Phasen, Hallo und Wiedereinstieg, Bot-Übernahme und einmaliges Bot-Planen, Bereit verbindlich und Privatsphäre der Entwürfe |
| `NetRoundsSuite` (Port von `net_rounds.py`) | **12/12**: Host und Gast spielen 5 Runden, Prüfsummen nach jeder Abrechnung gleich, Gast rechnet 0 Kampfschritte und erhält Rahmen, Gast A trennt im Kampf von Runde 2, Bot übernimmt, Gast B kehrt mit demselben Namen auf denselben Sitz zurück |
| `LuneWireTests` | Round-trip von Befehlen, öffentlichem Zustand (inkl. Entwurf, Hero, Buchungen), Einheitenätzen, Rahmen, Lobby und Optionen |
| EditMode gesamt | 29/29 (Kern 9 inkl. `NetProtocol` und `NetRounds`, Wire 4, Integration 2, UI-Modell 12, Bildraten 4) |
| PlayMode gesamt | 13/13 (Szene, Vertical Function Test, Capture, Verify A–I, UI-Toolkit-Probe); Bildwerkzeuge und Spieltest `Explicit` |
| Kerntests .NET | 457/457 (75 + 91 + 73 + 157 + 6 + 43 + 12) |
| Fingerabdrücke der Bot-Partien | unverändert `DCE95C96556F3A1A`, `7B8E43AEFC0D60C5` |
Das Loopback der Mehr-Runden-Partie läuft im Prozess mit zwei `LuneSession`-Instanzen und `LuneLoopback` (Nachrichten synchron in Sende-Reihenfolge). Damit sind Protokoll, Prüfsummen, Übernahme und Wiedereinstieg abgedeckt; eine echte Zwei-Prozess-Partie über Sockets bleibt eine optionale Erweiterung.
## Abnahme U4
| Vorgabe | Stand |
| --- | --- |
| Rollen offline / Host / Gast | erfüllt |
| Sechs Plätze, ein Peer ein Platz, freie Plätze Bots | erfüllt |
| Beitritt mit Protokollprüfung, Namenskürzung, volle Partie abgelehnt | erfüllt |
| Private Planung, Spoof-Schutz, nur eigener Entwurf im Zustand | erfüllt |
| Bereit verbindlich, alle bereit → Kampf, Bots einmal je Runde | erfüllt |
| Kampfsynchronisation (Beginn, neue Einheiten, Rahmen 20/s, späte Frames verworfen) | erfüllt |
| Abrechnung mit öffentlichem Zustand | erfüllt |
| Disconnect → Bot, kein Kernverlust | erfüllt |
| Wiedereinstieg über den Namen | erfüllt |
| Übernahme verwirft den Bot-Entwurf | erfüllt |
| Revanche durch den Host | erfüllt |
| Hostverlust → Meldung und Rückkehr ins Menü | erfüllt |
| Mindestumfang: Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze | erfüllt |
| Tests 43 + 12 | erfüllt |
## Bekannte Grenzen
- **Transport:** TCP statt ENet. Kampfrahmen laufen auf demselben Socket und werden über die Serial verworfen (wie `unreliable_ordered`). Unter Paketverlust kann TCP Head-of-Line-Blocking erzeugen; für eine Internetpartie wäre ein unzuverlässiger Kanal wie bei ENet der nächste Schritt.
- **Zwei-Prozess-Loopback** (`net_rounds.py`) ist als In-Prozess-Test umgesetzt (`NetRoundsSuite`). Ein Sockets-Lauf über zwei Player ist optional.
- **Windows auf x64-Hardware** und Netz über mehrere Rechner sind nicht gemessen (Build-Module fehlen, siehe [U1.5-UNITY.md](U1.5-UNITY.md)).
- **Hauptmenü** ist der Startbildschirm mit Offline/Hosten/Beitreten; das erweiterte Menü der Referenz (Emblem, Version, erweiterte Optionen) ist vereinfacht.
- **Kein Audio-Netzwerk** und keine Lobby-Chat-Nachrichten (waren nicht Teil des M4-Mindestumfangs).