411 lines
21 KiB
C#
411 lines
21 KiB
C#
using System.Net.Sockets;
|
|
using MSCO.Server;
|
|
|
|
// Protokoll-Tests gegen den echten Server-Code: startet einen Server offline (ohne Master, ohne Lizenz) auf
|
|
// Port 17777 und spielt mehrere Clients durch - Beitreten, Ping, Weltzustand, Chat, Admin-Rechte, kaputte Daten.
|
|
// Ausführen im Server-Ordner: dotnet run --project Tests
|
|
const int Port = 17777;
|
|
const string AdminId = "76561198910227878";
|
|
string dir = Path.Combine(Path.GetTempPath(), "msco-servertest-" + Guid.NewGuid().ToString("N")[..8]);
|
|
Directory.CreateDirectory(Path.Combine(dir, "save"));
|
|
// Platzhalter-Spielstand (der Inhalt ist für die Protokoll-Tests egal)
|
|
foreach (var name in new[] { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" })
|
|
File.WriteAllText(Path.Combine(dir, "save", name), "test " + name);
|
|
string configPath = Path.Combine(dir, "server.json");
|
|
var config = new ServerConfig { LicenseKey = "test", Name = "Test", Port = Port, MaxPlayers = 4, RequireSteam = false, Public = false, Admins = new() { AdminId } };
|
|
config.Save(configPath);
|
|
|
|
var server = new GameServer(config, dir, configPath, "Tester", offline: true);
|
|
server.SaveUploadTimeoutSeconds = 1; // Test-Spieler laden meist keinen Spielstand hoch - nicht lange warten
|
|
if (!server.LoadSave()) { Console.WriteLine("FAIL: LoadSave"); return 1; }
|
|
var serverThread = new Thread(server.Run) { IsBackground = true };
|
|
serverThread.Start();
|
|
Thread.Sleep(500);
|
|
|
|
int failed = 0, passed = 0;
|
|
void Check(bool ok, string what) { if (ok) passed++; else failed++; Console.WriteLine((ok ? " ok " : " FAIL ") + what); }
|
|
|
|
try
|
|
{
|
|
Console.WriteLine("== Join");
|
|
var alice = await TestClient.Join("Alice", 0, null);
|
|
Check(alice.Welcome != null, "Alice gets Welcome");
|
|
Check(alice.Id > 0, "Alice has id " + alice.Id);
|
|
Check(alice.AdminStatus == false, "Alice is not admin");
|
|
Check(alice.SaveFiles == 5, "Alice received save files (" + alice.SaveFiles + ")");
|
|
Check(alice.GotStartGame, "Alice got StartGame");
|
|
|
|
Console.WriteLine("== Ping");
|
|
alice.Send(MsgType.Ping, BitConverter.GetBytes(12.5f));
|
|
var pong = await alice.WaitFor(MsgType.Pong);
|
|
Check(pong != null && BitConverter.ToSingle(pong, 0) == 12.5f, "Ping is echoed as Pong with the same timestamp");
|
|
|
|
Console.WriteLine("== Ready / snapshot");
|
|
alice.Send(MsgType.Ready, Array.Empty<byte>());
|
|
var snap = await alice.WaitFor(MsgType.WorldSnapshot);
|
|
Check(snap != null, "WorldSnapshot received");
|
|
var s0 = Snapshot.Parse(snap);
|
|
Check(s0.Ok, "Snapshot parses completely (" + s0.Info + ")");
|
|
|
|
Console.WriteLine("== Second player, chat");
|
|
var bob = await TestClient.Join("Bob", 0, null);
|
|
Check(bob.Welcome != null, "Bob joins");
|
|
var joined = await alice.WaitFor(MsgType.PlayerJoined);
|
|
Check(joined != null, "Alice is told that Bob joined");
|
|
alice.Send(MsgType.Chat, Wire.Build(w => w.Write("hello bob")));
|
|
var chat = await bob.WaitFor(MsgType.Chat);
|
|
Check(chat != null && new BinaryReader(new MemoryStream(chat)).ReadInt32() == alice.Id, "Bob receives Alice's chat with her id");
|
|
Check(chat != null && ReadStringAfterSender(chat) == "hello bob", "Chat text arrives unchanged");
|
|
|
|
Console.WriteLine("== Object states (valid / invalid)");
|
|
alice.Send(MsgType.ObjStates, ObjStates((111, 10, 1, 20, 0, 0, 0, 1), (222, float.NaN, 0, 0, 0, 0, 0, 1), (333, 0, -5000, 0, 0, 0, 0, 1), (444, 1, 1, 1, 0, 0, 0, 0)));
|
|
var relay = await bob.WaitFor(MsgType.ObjStates);
|
|
Check(relay != null, "Object states are relayed to Bob");
|
|
alice.Send(MsgType.VarSync, Wire.Build(w => { w.Write(2); w.Write(42); w.Write((byte)1); w.Write(3.5f); w.Write(43); w.Write((byte)3); w.Write(true); }));
|
|
await bob.WaitFor(MsgType.VarSync);
|
|
var carol = await TestClient.Join("Carol", 0, null);
|
|
carol.Send(MsgType.Ready, Array.Empty<byte>());
|
|
var snap2 = Snapshot.Parse(await carol.WaitFor(MsgType.WorldSnapshot));
|
|
Check(snap2.ObjectIds.Contains(111), "Valid object pose is stored");
|
|
Check(!snap2.ObjectIds.Contains(222), "NaN pose is rejected");
|
|
Check(!snap2.ObjectIds.Contains(333), "Pose far below the map is rejected");
|
|
Check(!snap2.ObjectIds.Contains(444), "Invalid quaternion is rejected");
|
|
Check(snap2.Vars == 2, "Vehicle values are stored and sent to new players (" + snap2.Vars + ")");
|
|
|
|
Console.WriteLine("== Server full");
|
|
var eve = await TestClient.Join("Eve", 0, null);
|
|
var dave = await TestClient.Join("Dave", 0, null, expectReject: true);
|
|
Check(dave.RejectReason != null && dave.RejectReason.Contains("full"), "Player over the limit is rejected: " + dave.RejectReason);
|
|
eve.Close();
|
|
|
|
Console.WriteLine("== Admin rights");
|
|
alice.Send(MsgType.AdminListRequest, Array.Empty<byte>());
|
|
var notAdmin = await alice.WaitFor(MsgType.AdminResult);
|
|
Check(notAdmin != null && ReadString(notAdmin).Contains("not an admin"), "Non-admin cannot open the admin list");
|
|
alice.Send(MsgType.AdminAction, Wire.Build(w => { w.Write((byte)1); w.Write(bob.Id); w.Write("x"); }));
|
|
var kickTry = await alice.WaitFor(MsgType.AdminResult);
|
|
Check(kickTry != null && ReadString(kickTry).Contains("not an admin"), "Non-admin cannot kick");
|
|
carol.Close();
|
|
await Task.Delay(300);
|
|
|
|
// Jemand gibt sich mit der SteamID des Admins aus (ohne Steam Web API Key wird das Ticket nicht geprüft)
|
|
var faker = await TestClient.Join("Faker", ulong.Parse(AdminId), new byte[] { 1, 2, 3, 4 });
|
|
Check(faker.Welcome != null, "Player with a claimed SteamID joins (tickets not verified without key)");
|
|
Check(faker.AdminStatus == false, "Claimed (unverified) SteamID must NOT get admin rights");
|
|
var faker2 = await TestClient.Join("Faker2", ulong.Parse(AdminId), new byte[] { 9 }, expectReject: true);
|
|
Check(faker2.RejectReason != null && faker2.RejectReason.Contains("already connected"),
|
|
"Second player with the same unverified SteamID cannot push out the first: " + faker2.RejectReason);
|
|
Check(!faker.IsClosedByServer(), "The first player stays connected");
|
|
faker.Close();
|
|
await Task.Delay(300);
|
|
|
|
Console.WriteLine("== Robustness");
|
|
var evil = await TestClient.Join("Evil", 0, null);
|
|
evil.SendRaw(BitConverter.GetBytes(5 * 1024 * 1024)); // Paket, das 5 MB groß sein will
|
|
await Task.Delay(500);
|
|
Check(evil.IsClosedByServer(), "Oversized packet closes only that connection");
|
|
var bob2 = await TestClient.Join("Bob2", 0, null);
|
|
Check(bob2.Welcome != null, "Server still accepts players afterwards");
|
|
bob2.Send(MsgType.VarSync, new byte[] { 1, 2 }); // kaputte Nachricht
|
|
await Task.Delay(500);
|
|
Check(bob2.IsClosedByServer(), "Malformed message closes only that connection");
|
|
alice.Send(MsgType.Ping, BitConverter.GetBytes(1f));
|
|
Check(await alice.WaitFor(MsgType.Pong) != null, "Other players are not affected");
|
|
|
|
Console.WriteLine("== Chat limits");
|
|
alice.Send(MsgType.Chat, Wire.Build(w => w.Write(new string('x', 5000))));
|
|
var longChat = await bob.WaitFor(MsgType.Chat, 800);
|
|
Check(longChat == null, "Huge chat messages are not relayed");
|
|
|
|
Console.WriteLine("== Bought / consumed items");
|
|
alice.Send(MsgType.ItemAppeared, Wire.Build(w => { w.Write(4242); w.Write("beer case(itemx)"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
|
|
Check(await bob.WaitFor(MsgType.ItemAppeared) != null, "Bought item is relayed to the other players");
|
|
alice.Send(MsgType.ObjDestroy, Wire.Build(w => w.Write(777)));
|
|
Check(await bob.WaitFor(MsgType.ObjDestroy) != null, "Consumed item is relayed to the other players");
|
|
alice.Send(MsgType.VehicleDamage, Wire.Build(w => { w.Write(1); w.Write(10); w.Write(1); w.Write(3); w.Write(0.1f); w.Write(0.2f); w.Write(0.3f); }));
|
|
Check(await bob.WaitFor(MsgType.VehicleDamage) != null, "Crash damage is relayed");
|
|
alice.Send(MsgType.WorldVars, Wire.Build(w => { w.Write(1); w.Write(5); w.Write((byte)3); w.Write(true); }));
|
|
Check(await bob.WaitFor(MsgType.WorldVars) != null, "Job/shop values are relayed");
|
|
byte[] trafficPayload = Wire.Build(w => { w.Write(0); });
|
|
bob.Send(MsgType.TrafficStates, trafficPayload);
|
|
Check(await alice.WaitFor(MsgType.TrafficStates, 800) == null, "Traffic from a player who is not the world authority is ignored");
|
|
alice.Send(MsgType.TrafficStates, trafficPayload);
|
|
Check(await bob.WaitFor(MsgType.TrafficStates) != null, "Traffic from the world authority is relayed");
|
|
await Task.Delay(200);
|
|
var late = await TestClient.Join("Late", 0, null);
|
|
late.Send(MsgType.Ready, Array.Empty<byte>());
|
|
var lateSnap = Snapshot.Parse(await late.WaitFor(MsgType.WorldSnapshot));
|
|
Check(lateSnap.Ok && lateSnap.Appeared == 1 && lateSnap.Destroyed.Contains(777),
|
|
"Players joining later get bought and consumed items: " + lateSnap.Info);
|
|
late.Close();
|
|
|
|
Console.WriteLine("== Last position (spawn)");
|
|
var pos1 = await TestClient.Join("Walker", 0, null);
|
|
pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // Startpunkt des Spielstands (Toilette) - vor Ready
|
|
pos1.Send(MsgType.Ready, Array.Empty<byte>());
|
|
Check(Snapshot.Parse(await pos1.WaitFor(MsgType.WorldSnapshot)).Spawn == null, "Position before joining is not stored (no spawn yet)");
|
|
pos1.Send(MsgType.Player, PlayerPos(-15.6f, -0.3f, 7f)); // direkt nach Ready: noch nicht übernehmen
|
|
await Task.Delay(8500);
|
|
pos1.Send(MsgType.Player, PlayerPos(250f, 3f, -120f)); // echte Position nach dem Umsetzen
|
|
await Task.Delay(300);
|
|
pos1.Send(MsgType.Player, PlayerPos(2.7e9f, 9.4e10f, -8.5e8f)); // weggeschleudert - darf nicht gespeichert werden
|
|
await Task.Delay(300);
|
|
pos1.Close();
|
|
await Task.Delay(500);
|
|
var pos2 = await TestClient.Join("Walker", 0, null);
|
|
pos2.Send(MsgType.Ready, Array.Empty<byte>());
|
|
var spawn = Snapshot.Parse(await pos2.WaitFor(MsgType.WorldSnapshot)).Spawn;
|
|
Check(spawn != null && Math.Abs(spawn[0] - 250f) < 0.01f && Math.Abs(spawn[2] + 120f) < 0.01f,
|
|
"Rejoining returns the last real position, not the save's start point or a flung-away one: " + (spawn == null ? "none" : string.Join(", ", spawn)));
|
|
pos2.Close();
|
|
|
|
Console.WriteLine("== Admin: spawn items / unban");
|
|
var noAdmin = await TestClient.Join("NoAdmin", 0, null);
|
|
var watcher = await TestClient.Join("Watcher", 0, null);
|
|
noAdmin.Send(MsgType.SpawnItem, Wire.Build(w => { w.Write("beer case"); w.Write(1f); w.Write(2f); w.Write(3f); w.Write(0f); w.Write(0f); w.Write(0f); w.Write(1f); }));
|
|
Check(await watcher.WaitFor(MsgType.SpawnItem, 800) == null, "Non-admin cannot spawn items (not relayed)");
|
|
watcher.Send(MsgType.Ready, Array.Empty<byte>());
|
|
Check(Snapshot.Parse(await watcher.WaitFor(MsgType.WorldSnapshot)).Spawns == 0, "Non-admin spawn is not stored");
|
|
server.EnqueueCommand("ban " + noAdmin.Id + " test");
|
|
await Task.Delay(500);
|
|
var bannedTry = await TestClient.Join("NoAdmin", 0, null, expectReject: true);
|
|
Check(bannedTry.RejectReason != null && bannedTry.RejectReason.Contains("banned"), "Banned player cannot join: " + bannedTry.RejectReason);
|
|
server.EnqueueCommand("unban name:noadmin");
|
|
await Task.Delay(500);
|
|
var unbanned = await TestClient.Join("NoAdmin", 0, null);
|
|
Check(unbanned.Welcome != null, "After unban the player can join again");
|
|
unbanned.Close();
|
|
watcher.Close();
|
|
await Task.Delay(300);
|
|
|
|
Console.WriteLine("== Kick reason arrives while the player keeps sending");
|
|
var spammer = await TestClient.Join("Spammer", 0, null);
|
|
var spamming = true;
|
|
var spamTask = Task.Run(async () => { while (spamming) { spammer.Send(MsgType.Player, PlayerPos(1f, 2f, 3f)); await Task.Delay(5); } });
|
|
await Task.Delay(300);
|
|
server.EnqueueCommand("kick " + spammer.Id + " testing");
|
|
var kickMsg = await spammer.WaitFor(MsgType.Reject, 3000);
|
|
spamming = false;
|
|
await spamTask;
|
|
Check(kickMsg != null && new BinaryReader(new MemoryStream(kickMsg)).ReadString().Contains("testing"),
|
|
"Kicked player receives the reason even while sending data (no hard reset)");
|
|
spammer.Close();
|
|
await Task.Delay(300);
|
|
|
|
Console.WriteLine("== reset-world makes everyone reconnect");
|
|
var r1 = await TestClient.Join("Resetter", 0, null);
|
|
server.EnqueueCommand("reset-world");
|
|
var rec = await r1.WaitFor(MsgType.Reconnect);
|
|
Check(rec != null && new BinaryReader(new MemoryStream(rec)).ReadString().Contains("reset"), "Players get the 'world is being reset' reconnect message");
|
|
await Task.Delay(300);
|
|
Check(r1.IsClosedByServer(), "Their connection is closed so they load the fresh world");
|
|
|
|
Console.WriteLine("== Joining gets the CURRENT save game from a player in the game");
|
|
var inGame = await TestClient.Join("InGame", 0, null);
|
|
inGame.Send(MsgType.Ready, Array.Empty<byte>());
|
|
await inGame.WaitFor(MsgType.WorldSnapshot);
|
|
inGame.Send(MsgType.FsmEvent, Wire.Build(w => { w.Write("SATSUMA|bolt#0"); w.Write("Screw"); w.Write("TIGHTEN"); w.Write("Tight"); w.Write(0f); w.Write(0f); w.Write(0f); }));
|
|
await Task.Delay(200);
|
|
var joinTask = TestClient.Join("Newcomer", 0, null);
|
|
var request = await inGame.WaitFor(MsgType.SaveRequest, 5000);
|
|
Check(request != null, "Server asks the player in the game for the current save");
|
|
if (request != null)
|
|
{
|
|
int reqId = BitConverter.ToInt32(request, 0);
|
|
byte[] fresh = System.Text.Encoding.UTF8.GetBytes(new string('S', 300_000) + "CURRENT-SAVE");
|
|
for (int off = 0; off < fresh.Length; off += 100_000)
|
|
{
|
|
int len = Math.Min(100_000, fresh.Length - off);
|
|
inGame.Send(MsgType.SaveUploadPart, Wire.Build(w => { w.Write(reqId); w.Write("defaultES2File.txt"); w.Write(fresh.Length); w.Write(off); w.Write(len); w.Write(fresh, off, len); }));
|
|
}
|
|
inGame.Send(MsgType.SaveUploadDone, Wire.Build(w => { w.Write(reqId); w.Write(1); }));
|
|
}
|
|
var newcomer = await joinTask;
|
|
Check(newcomer.Files.TryGetValue("defaultES2File.txt", out var got) && System.Text.Encoding.UTF8.GetString(got).EndsWith("CURRENT-SAVE"),
|
|
"Newcomer receives exactly the uploaded current save (" + (got?.Length ?? 0) + " bytes, sent in parts)");
|
|
newcomer.Send(MsgType.Ready, Array.Empty<byte>());
|
|
var fresh2 = Snapshot.Parse(await newcomer.WaitFor(MsgType.WorldSnapshot));
|
|
Check(fresh2.Ok && fresh2.Info.Contains("0 events"), "Replayed actions are cleared - they are in the save now (" + fresh2.Info + ")");
|
|
Check(Directory.Exists(Path.Combine(dir, "save_backups")) && Directory.GetDirectories(Path.Combine(dir, "save_backups")).Length > 0,
|
|
"The previous save game is kept as a backup");
|
|
inGame.Close();
|
|
newcomer.Close();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
failed++;
|
|
Console.WriteLine(" FAIL exception: " + e);
|
|
}
|
|
|
|
Console.WriteLine($"\n{passed} passed, {failed} failed");
|
|
server.EnqueueCommand("stop");
|
|
Thread.Sleep(300);
|
|
try { Directory.Delete(dir, true); } catch { }
|
|
return failed == 0 ? 0 : 1;
|
|
|
|
static byte[] PlayerPos(float x, float y, float z) => Wire.Build(w => { w.Write(x); w.Write(y); w.Write(z); w.Write(0f); });
|
|
static string ReadString(byte[] p) => new BinaryReader(new MemoryStream(p)).ReadString();
|
|
static string ReadStringAfterSender(byte[] p) { var r = new BinaryReader(new MemoryStream(p)); r.ReadInt32(); return r.ReadString(); }
|
|
|
|
static byte[] ObjStates(params (int id, float x, float y, float z, float qx, float qy, float qz, float qw)[] items) => Wire.Build(w =>
|
|
{
|
|
w.Write(items.Length);
|
|
foreach (var i in items)
|
|
{
|
|
w.Write(i.id); w.Write(i.x); w.Write(i.y); w.Write(i.z);
|
|
w.Write(i.qx); w.Write(i.qy); w.Write(i.qz); w.Write(i.qw);
|
|
w.Write(0f); w.Write(0f); w.Write(0f);
|
|
}
|
|
});
|
|
|
|
sealed class Snapshot
|
|
{
|
|
public bool Ok;
|
|
public string Info;
|
|
public HashSet<int> ObjectIds = new();
|
|
public float[] Spawn;
|
|
public int Spawns = -1;
|
|
public int Appeared = -1;
|
|
public List<int> Destroyed = new();
|
|
public int Vars = -1;
|
|
|
|
public static Snapshot Parse(byte[] data)
|
|
{
|
|
var s = new Snapshot();
|
|
if (data == null) { s.Info = "no data"; return s; }
|
|
try
|
|
{
|
|
var r = new BinaryReader(new MemoryStream(data));
|
|
if (r.ReadBoolean()) s.Spawn = new[] { r.ReadSingle(), r.ReadSingle(), r.ReadSingle() };
|
|
int objects = r.ReadInt32();
|
|
for (int i = 0; i < objects; i++) { s.ObjectIds.Add(r.ReadInt32()); for (int k = 0; k < 7; k++) r.ReadSingle(); }
|
|
int events = r.ReadInt32();
|
|
for (int i = 0; i < events; i++) r.ReadBytes(r.ReadInt32());
|
|
float money = r.ReadSingle();
|
|
int doors = r.ReadInt32();
|
|
for (int i = 0; i < doors; i++) { r.ReadString(); r.ReadString(); }
|
|
s.Vars = r.ReadInt32();
|
|
for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); }
|
|
s.Spawns = r.ReadInt32();
|
|
for (int i = 0; i < s.Spawns; i++) r.ReadBytes(r.ReadInt32());
|
|
s.Appeared = r.ReadInt32();
|
|
for (int i = 0; i < s.Appeared; i++) r.ReadBytes(r.ReadInt32());
|
|
int destroyed = r.ReadInt32();
|
|
for (int i = 0; i < destroyed; i++) s.Destroyed.Add(r.ReadInt32());
|
|
s.Ok = r.BaseStream.Position == r.BaseStream.Length;
|
|
s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars";
|
|
}
|
|
catch (Exception e) { s.Info = "parse error " + e.Message; }
|
|
return s;
|
|
}
|
|
}
|
|
|
|
sealed class TestClient
|
|
{
|
|
const int Port = 17777;
|
|
TcpClient tcp;
|
|
NetworkStream stream;
|
|
readonly List<(MsgType type, byte[] payload)> inbox = new();
|
|
readonly object gate = new();
|
|
bool closed;
|
|
|
|
public byte[] Welcome;
|
|
public int Id;
|
|
public bool? AdminStatus;
|
|
public int SaveFiles;
|
|
public bool GotStartGame;
|
|
public string RejectReason;
|
|
public Dictionary<string, byte[]> Files = new();
|
|
|
|
public static async Task<TestClient> Join(string name, ulong steamId, byte[] ticket, bool expectReject = false)
|
|
{
|
|
var c = new TestClient { tcp = new TcpClient() };
|
|
await c.tcp.ConnectAsync("127.0.0.1", Port);
|
|
c.stream = c.tcp.GetStream();
|
|
_ = Task.Run(c.ReadLoop);
|
|
c.Send(MsgType.Hello, Wire.Build(w =>
|
|
{
|
|
w.Write(GameServer.ModVersion);
|
|
w.Write(name);
|
|
w.Write(steamId);
|
|
w.Write(ticket?.Length ?? 0);
|
|
if (ticket != null) w.Write(ticket);
|
|
}));
|
|
if (expectReject)
|
|
{
|
|
var rej = await c.WaitFor(MsgType.Reject);
|
|
if (rej != null) c.RejectReason = new BinaryReader(new MemoryStream(rej)).ReadString();
|
|
return c;
|
|
}
|
|
c.Welcome = await c.WaitFor(MsgType.Welcome);
|
|
if (c.Welcome != null) c.Id = BitConverter.ToInt32(c.Welcome, 0);
|
|
var admin = await c.WaitFor(MsgType.AdminStatus);
|
|
if (admin != null) c.AdminStatus = admin[0] != 0;
|
|
await c.WaitFor(MsgType.SaveBegin, 20000); // kann warten, bis ein anderer Spieler den aktuellen Spielstand hochlädt
|
|
byte[] sf;
|
|
while ((sf = await c.WaitFor(MsgType.SaveFile, 1500)) != null)
|
|
{
|
|
c.SaveFiles++;
|
|
var fr = new BinaryReader(new MemoryStream(sf));
|
|
string fname = fr.ReadString();
|
|
c.Files[fname] = fr.ReadBytes(fr.ReadInt32());
|
|
}
|
|
c.GotStartGame = await c.WaitFor(MsgType.StartGame, 1500) != null || c.SaveFiles > 0;
|
|
return c;
|
|
}
|
|
|
|
async Task ReadLoop()
|
|
{
|
|
var header = new byte[4];
|
|
try
|
|
{
|
|
while (true)
|
|
{
|
|
await ReadExact(header, 4);
|
|
int len = BitConverter.ToInt32(header, 0);
|
|
var body = new byte[len];
|
|
await ReadExact(body, len);
|
|
lock (gate) inbox.Add(((MsgType)body[0], body[1..]));
|
|
}
|
|
}
|
|
catch { closed = true; }
|
|
}
|
|
|
|
async Task ReadExact(byte[] buf, int count)
|
|
{
|
|
int off = 0;
|
|
while (off < count)
|
|
{
|
|
int n = await stream.ReadAsync(buf.AsMemory(off, count - off));
|
|
if (n <= 0) throw new IOException("closed");
|
|
off += n;
|
|
}
|
|
}
|
|
|
|
public void Send(MsgType type, byte[] payload)
|
|
{
|
|
byte[] frame = Wire.Frame(type, payload);
|
|
try { stream.Write(frame, 0, frame.Length); } catch { closed = true; }
|
|
}
|
|
|
|
public void SendRaw(byte[] data) { try { stream.Write(data, 0, data.Length); stream.Write(new byte[16], 0, 16); } catch { closed = true; } }
|
|
|
|
public async Task<byte[]> WaitFor(MsgType type, int timeoutMs = 3000)
|
|
{
|
|
var end = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
|
while (DateTime.UtcNow < end)
|
|
{
|
|
lock (gate)
|
|
{
|
|
int i = inbox.FindIndex(m => m.type == type);
|
|
if (i >= 0) { var p = inbox[i].payload; inbox.RemoveAt(i); return p; }
|
|
}
|
|
await Task.Delay(20);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public bool IsClosedByServer() => closed;
|
|
public void Close() { try { tcp.Close(); } catch { } }
|
|
}
|