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:
parent
e2aadcbc74
commit
769e622952
37 changed files with 3823 additions and 32 deletions
8
Assets/Luneraria/Core/Net.meta
Normal file
8
Assets/Luneraria/Core/Net.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 944742311374a4ae6959443b9438e3d6
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
89
Assets/Luneraria/Core/Net/LuneLoopback.cs
Normal file
89
Assets/Luneraria/Core/Net/LuneLoopback.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Core/Net/LuneLoopback.cs.meta
Normal file
2
Assets/Luneraria/Core/Net/LuneLoopback.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 82657628be8544758877088854dcb44e
|
||||||
1058
Assets/Luneraria/Core/Net/LuneSession.cs
Normal file
1058
Assets/Luneraria/Core/Net/LuneSession.cs
Normal file
File diff suppressed because it is too large
Load diff
2
Assets/Luneraria/Core/Net/LuneSession.cs.meta
Normal file
2
Assets/Luneraria/Core/Net/LuneSession.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ca592cc38517f48cd9e5a98a2ebed089
|
||||||
122
Assets/Luneraria/Core/Net/NetModel.cs
Normal file
122
Assets/Luneraria/Core/Net/NetModel.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Core/Net/NetModel.cs.meta
Normal file
2
Assets/Luneraria/Core/Net/NetModel.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 30e31c765f3d34155ba019f7106ef715
|
||||||
8
Assets/Luneraria/Net.meta
Normal file
8
Assets/Luneraria/Net.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ade424a3ec6b740b086bd3d062edffb9
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
375
Assets/Luneraria/Net/LuneTcp.cs
Normal file
375
Assets/Luneraria/Net/LuneTcp.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Net/LuneTcp.cs.meta
Normal file
2
Assets/Luneraria/Net/LuneTcp.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c45d8ceac322a4ffeb1e173cdb3529b6
|
||||||
840
Assets/Luneraria/Net/LuneWire.cs
Normal file
840
Assets/Luneraria/Net/LuneWire.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Net/LuneWire.cs.meta
Normal file
2
Assets/Luneraria/Net/LuneWire.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a70c5b2967c2445619bb789979871fad
|
||||||
16
Assets/Luneraria/Net/Luneraria.Net.asmdef
Normal file
16
Assets/Luneraria/Net/Luneraria.Net.asmdef
Normal 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
|
||||||
|
}
|
||||||
7
Assets/Luneraria/Net/Luneraria.Net.asmdef.meta
Normal file
7
Assets/Luneraria/Net/Luneraria.Net.asmdef.meta
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 593ed984c4ffe4880a001bc56139f673
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests
|
||||||
|
|
||||||
public static IList<CheckSuite> All()
|
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() };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
190
Assets/Luneraria/Tests/Core/NetRoundsSuite.cs
Normal file
190
Assets/Luneraria/Tests/Core/NetRoundsSuite.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta
Normal file
2
Assets/Luneraria/Tests/Core/NetRoundsSuite.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 13157be6de19046ca982d224c4d7393c
|
||||||
228
Assets/Luneraria/Tests/Core/NetSuite.cs
Normal file
228
Assets/Luneraria/Tests/Core/NetSuite.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Tests/Core/NetSuite.cs.meta
Normal file
2
Assets/Luneraria/Tests/Core/NetSuite.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 88ddfdbec08764d09985b51f6de3fd1f
|
||||||
|
|
@ -49,5 +49,7 @@ namespace Luneraria.Core.Tests
|
||||||
[Test] public void M2Rules() { RunSuite(new M2RulesSuite()); }
|
[Test] public void M2Rules() { RunSuite(new M2RulesSuite()); }
|
||||||
[Test] public void GoldenReplays() { RunSuite(new GoldenSuite()); }
|
[Test] public void GoldenReplays() { RunSuite(new GoldenSuite()); }
|
||||||
[Test] public void Determinism() { RunSuite(new DeterminismSuite()); }
|
[Test] public void Determinism() { RunSuite(new DeterminismSuite()); }
|
||||||
|
[Test] public void NetProtocol() { RunSuite(new NetSuite()); }
|
||||||
|
[Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
8
Assets/Luneraria/Tests/Net.meta
Normal file
8
Assets/Luneraria/Tests/Net.meta
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5914768ea6d0240b7b72fca4a7e75b0b
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
170
Assets/Luneraria/Tests/Net/LuneWireTests.cs
Normal file
170
Assets/Luneraria/Tests/Net/LuneWireTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta
Normal file
2
Assets/Luneraria/Tests/Net/LuneWireTests.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8779292e0688a4cf083bcc73518df0e2
|
||||||
25
Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef
Normal file
25
Assets/Luneraria/Tests/Net/Luneraria.Net.Tests.asmdef
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 340d183f865dd44998bab569b71ae410
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
|
|
@ -29,9 +29,13 @@ namespace Luneraria.UI
|
||||||
public int HumanSlot = 1;
|
public int HumanSlot = 1;
|
||||||
/// <summary>Optional render target for tests and captures (null: the screen).</summary>
|
/// <summary>Optional render target for tests and captures (null: the screen).</summary>
|
||||||
public RenderTexture Target;
|
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 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>
|
/// <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; } }
|
int Seat { get { return _runner != null ? _runner.HumanSlot : -1; } }
|
||||||
bool Human { get { return Seat >= 0; } }
|
bool Human { get { return Seat >= 0; } }
|
||||||
|
|
@ -53,10 +57,18 @@ namespace Luneraria.UI
|
||||||
MaterialPropertyBlock _block;
|
MaterialPropertyBlock _block;
|
||||||
|
|
||||||
void Start()
|
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;
|
_camera = View != null && View.ViewCamera != null ? View.ViewCamera : Camera.main;
|
||||||
// The benchmark keeps its bot match; otherwise the configured seat plays.
|
// 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.HumanSlot = HumanSlot;
|
||||||
Driver.Restart();
|
Driver.Restart();
|
||||||
|
|
@ -76,6 +88,10 @@ namespace Luneraria.UI
|
||||||
LuneBenchmark.UiMs = () => (Hud != null ? Hud.LastTickMs : 0.0) + _updateMs + _lateMs;
|
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()
|
void OnDestroy()
|
||||||
{
|
{
|
||||||
LuneBenchmark.UiMs = null;
|
LuneBenchmark.UiMs = null;
|
||||||
|
|
@ -88,7 +104,23 @@ namespace Luneraria.UI
|
||||||
_lastStage = (LuneMatchRunner.Stage)(-1);
|
_lastStage = (LuneMatchRunner.Stage)(-1);
|
||||||
Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target);
|
Hud.Init(_runner.Match, Viewer, View.Library, UiAssets, Target);
|
||||||
Hud.CommandRequested += command => Feedback(Send(command));
|
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.ReadyRequested += OnReady;
|
||||||
Hud.StarterRequested += Starter;
|
Hud.StarterRequested += Starter;
|
||||||
Hud.SpeedRequested += CycleSpeed;
|
Hud.SpeedRequested += CycleSpeed;
|
||||||
|
|
@ -121,6 +153,11 @@ namespace Luneraria.UI
|
||||||
{
|
{
|
||||||
if (!Human)
|
if (!Human)
|
||||||
return new CommandResult(false, "Zuschauer können nicht planen.");
|
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);
|
return Match.Submit(Seat, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,24 +187,42 @@ namespace Luneraria.UI
|
||||||
{
|
{
|
||||||
if (Match.Phase != Phase.Planning || !Human)
|
if (Match.Phase != Phase.Planning || !Human)
|
||||||
return;
|
return;
|
||||||
_runner.Ready();
|
if (Networked)
|
||||||
|
Session.ReadyUp();
|
||||||
|
else
|
||||||
|
_runner.Ready();
|
||||||
Hud.MarkDirty();
|
Hud.MarkDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void CycleSpeed()
|
void CycleSpeed()
|
||||||
{
|
{
|
||||||
|
if (IsClient)
|
||||||
|
{
|
||||||
|
Hud.Notify("Tempo und Pause bestimmt der Host.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Driver.CycleSpeed();
|
Driver.CycleSpeed();
|
||||||
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TogglePause()
|
void TogglePause()
|
||||||
{
|
{
|
||||||
|
if (IsClient)
|
||||||
|
{
|
||||||
|
Hud.Notify("Tempo und Pause bestimmt der Host.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Driver.TogglePause())
|
if (Driver.TogglePause())
|
||||||
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
Hud.SetSpeedLabel(Driver.Tempo, Driver.Paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
void Rematch()
|
void Rematch()
|
||||||
{
|
{
|
||||||
|
if (Net != null)
|
||||||
|
{
|
||||||
|
Net.Rematch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
Driver.Seed += 1;
|
Driver.Seed += 1;
|
||||||
Driver.Restart();
|
Driver.Restart();
|
||||||
View.Preview = null;
|
View.Preview = null;
|
||||||
|
|
@ -395,8 +450,12 @@ namespace Luneraria.UI
|
||||||
return;
|
return;
|
||||||
LuneMatchRunner.Stage previous = _lastStage;
|
LuneMatchRunner.Stage previous = _lastStage;
|
||||||
_lastStage = stage;
|
_lastStage = stage;
|
||||||
|
if (previous == LuneMatchRunner.Stage.Combat && Networked && Session.IsAuthority())
|
||||||
|
Session.HostAfterCombat();
|
||||||
if (stage == LuneMatchRunner.Stage.Planning)
|
if (stage == LuneMatchRunner.Stage.Planning)
|
||||||
{
|
{
|
||||||
|
if (Networked && Session.IsAuthority())
|
||||||
|
Session.PlanBotSeats();
|
||||||
RebuildPreview();
|
RebuildPreview();
|
||||||
}
|
}
|
||||||
else if (stage == LuneMatchRunner.Stage.Combat)
|
else if (stage == LuneMatchRunner.Stage.Combat)
|
||||||
|
|
@ -577,9 +636,16 @@ namespace Luneraria.UI
|
||||||
VisualElement column = LuneUi.Box(true, 14f, panel);
|
VisualElement column = LuneUi.Box(true, 14f, panel);
|
||||||
column.style.alignItems = Align.Center;
|
column.style.alignItems = Align.Center;
|
||||||
LuneUi.Label("Partie verlassen?", "HeadingLabel", column);
|
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);
|
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);
|
new LuneButton("Zurück", LuneButtons.Secondary, row).Clicked += () => LuneUi.Show(_leave, false);
|
||||||
LuneUi.Show(_leave, false);
|
LuneUi.Show(_leave, false);
|
||||||
Hud.Root.Add(_leave);
|
Hud.Root.Add(_leave);
|
||||||
|
|
|
||||||
177
Assets/Luneraria/UI/Game/LuneMainMenu.cs
Normal file
177
Assets/Luneraria/UI/Game/LuneMainMenu.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta
Normal file
2
Assets/Luneraria/UI/Game/LuneMainMenu.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a1a6046e9cda249d3b7cf2e07a556e52
|
||||||
245
Assets/Luneraria/UI/Game/LuneNetPlay.cs
Normal file
245
Assets/Luneraria/UI/Game/LuneNetPlay.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta
Normal file
2
Assets/Luneraria/UI/Game/LuneNetPlay.cs.meta
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 717bc063d01154f21a8f46edd50def26
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
"rootNamespace": "Luneraria.UI",
|
"rootNamespace": "Luneraria.UI",
|
||||||
"references": [
|
"references": [
|
||||||
"Luneraria.Core",
|
"Luneraria.Core",
|
||||||
|
"Luneraria.Net",
|
||||||
"Luneraria.View",
|
"Luneraria.View",
|
||||||
"Unity.RenderPipelines.Core.Runtime",
|
"Unity.RenderPipelines.Core.Runtime",
|
||||||
"Unity.RenderPipelines.Universal.Runtime"
|
"Unity.RenderPipelines.Universal.Runtime"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ namespace Luneraria.View
|
||||||
public LuneMatchRunner Runner { get; private set; }
|
public LuneMatchRunner Runner { get; private set; }
|
||||||
/// <summary>Main-thread cost of the last frame's simulation (milliseconds).</summary>
|
/// <summary>Main-thread cost of the last frame's simulation (milliseconds).</summary>
|
||||||
public double LastAdvanceMs { get; private set; }
|
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();
|
readonly System.Diagnostics.Stopwatch _clock = new System.Diagnostics.Stopwatch();
|
||||||
|
|
||||||
public int Tempo { get { return SpeedSteps[Mathf.Clamp(SpeedIndex, 0, SpeedSteps.Length - 1)]; } }
|
public int Tempo { get { return SpeedSteps[Mathf.Clamp(SpeedIndex, 0, SpeedSteps.Length - 1)]; } }
|
||||||
|
|
@ -46,6 +50,21 @@ namespace Luneraria.View
|
||||||
Runner.Trace = new LuneTrace();
|
Runner.Trace = new LuneTrace();
|
||||||
SpeedIndex = 0;
|
SpeedIndex = 0;
|
||||||
Paused = false;
|
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>
|
/// <summary>Next tempo step (1× → 2× → 4× → 1×).</summary>
|
||||||
|
|
@ -71,10 +90,20 @@ namespace Luneraria.View
|
||||||
|
|
||||||
void Update()
|
void Update()
|
||||||
{
|
{
|
||||||
|
if (Runner == null)
|
||||||
|
return;
|
||||||
_clock.Restart();
|
_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).
|
// Pause holds only the combat steps (Godot: a paused game still plans and settles).
|
||||||
if (!(Paused && Runner.Current == LuneMatchRunner.Stage.Combat))
|
if (!(Paused && Runner.Current == LuneMatchRunner.Stage.Combat))
|
||||||
Runner.Advance(Time.deltaTime * Speed * Tempo);
|
Runner.Advance(Time.deltaTime * Speed * Tempo);
|
||||||
|
if (Session != null && Session.IsNetworked() && Session.IsAuthority())
|
||||||
|
Session.HostTick(Time.deltaTime * Speed * Tempo);
|
||||||
LastAdvanceMs = _clock.Elapsed.TotalMilliseconds;
|
LastAdvanceMs = _clock.Elapsed.TotalMilliseconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,20 @@ namespace Luneraria.View
|
||||||
EnterPlanning();
|
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>
|
/// <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)
|
public static LuneMatch NewMatch(MatchOptions options, int startRound, int extraGold)
|
||||||
{
|
{
|
||||||
|
|
@ -62,6 +76,8 @@ namespace Luneraria.View
|
||||||
{
|
{
|
||||||
Current = Stage.Planning;
|
Current = Stage.Planning;
|
||||||
_stageTime = 0.0;
|
_stageTime = 0.0;
|
||||||
|
if (Session != null)
|
||||||
|
return;
|
||||||
for (int slot = 0; slot < 6; slot++)
|
for (int slot = 0; slot < 6; slot++)
|
||||||
{
|
{
|
||||||
if (slot != HumanSlot)
|
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>
|
/// <summary>Advances by simulated seconds (frame time times match speed).</summary>
|
||||||
public void Advance(double seconds)
|
public void Advance(double seconds)
|
||||||
{
|
{
|
||||||
|
|
@ -78,7 +97,13 @@ namespace Luneraria.View
|
||||||
case Stage.Planning:
|
case Stage.Planning:
|
||||||
_stageTime += seconds;
|
_stageTime += seconds;
|
||||||
// A bot match starts after a short look at the plans; a human starts it with Ready.
|
// 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();
|
StartCombat();
|
||||||
break;
|
break;
|
||||||
case Stage.Combat:
|
case Stage.Combat:
|
||||||
|
|
@ -110,7 +135,8 @@ namespace Luneraria.View
|
||||||
|
|
||||||
void StartCombat()
|
void StartCombat()
|
||||||
{
|
{
|
||||||
Match.BeginCombat();
|
if (Match.Phase != Phase.Combat)
|
||||||
|
Match.BeginCombat();
|
||||||
Clock.Reset();
|
Clock.Reset();
|
||||||
Current = Stage.Combat;
|
Current = Stage.Combat;
|
||||||
CombatStarted?.Invoke(Match.Combat);
|
CombatStarted?.Invoke(Match.Combat);
|
||||||
|
|
|
||||||
|
|
@ -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. |
|
| 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)). |
|
| 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
|
## 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).
|
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).
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| 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` |
|
| 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.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Port-Status · Champions of Luneraria in Unity
|
# 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.
|
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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) | nicht begonnen | [U5-VISUAL.md](U5-VISUAL.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).
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,81 @@
|
||||||
# U4 · Netzwerk
|
# 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.
|
- Clients senden Absichten.
|
||||||
- Der Host berechnet Gold, Käufe, Kampf, Schaden, Besitz, Boss und Sieg.
|
- 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
|
## Erfasster M4-Umfang
|
||||||
|
|
||||||
| Funktion | Verhalten in der Referenz |
|
| Funktion | Verhalten in der Referenz | im Port |
|
||||||
| --- | --- |
|
| --- | --- | --- |
|
||||||
| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde | erfüllt |
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden | erfüllt |
|
||||||
| Revanche | Host startet neu, Plätze bleiben |
|
| Revanche | Host startet neu, Plätze bleiben | erfüllt |
|
||||||
| Hostverlust | Gast kehrt mit Meldung ins Menü zurück |
|
| 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 |
|
| 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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue