2.8: relay bought/consumed items (stored for joiners), crash damage, job values, traffic (world authority only); tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:58:47 +02:00
co-authored by Claude Opus 5.5
parent b9c7d33294
commit 6f8474d66b
5 changed files with 85 additions and 4 deletions
+14 -2
View File
@@ -12,7 +12,7 @@ namespace MSCO.Server;
/// </summary> /// </summary>
public sealed class GameServer public sealed class GameServer
{ {
public const string ModVersion = "2.7"; // muss mit MSCO.ModVersion übereinstimmen public const string ModVersion = "2.8"; // muss mit MSCO.ModVersion übereinstimmen
public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ... public const int ServerId = 0; // Absender-ID des Servers; Spieler bekommen 1, 2, ...
static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" }; static readonly string[] SaveFiles = { "defaultES2File.txt", "items.txt", "meshsave.txt", "graveyard.txt", "notepad.txt" };
@@ -287,7 +287,8 @@ public sealed class GameServer
static bool IsGameMessage(MsgType t) => static bool IsGameMessage(MsgType t) =>
t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent t is MsgType.Player or MsgType.ObjStates or MsgType.ObjOwn or MsgType.ObjRelease or MsgType.FsmEvent
or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync or MsgType.VehicleFx or MsgType.WorldTime or MsgType.MoneyDelta or MsgType.DoorState or MsgType.Chat or MsgType.VarSync
or MsgType.SpawnItem; or MsgType.SpawnItem or MsgType.ItemAppeared or MsgType.ObjDestroy or MsgType.VehicleDamage
or MsgType.TrafficStates or MsgType.WorldVars;
/// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary> /// <summary>Uhrzeit/Wetter gibt der Spieler mit der kleinsten ID vor (so rechnen es auch die Clients).</summary>
int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min(); int WorldAuthorityId => clients.Values.Where(c => c.State == ClientState.Joined).Select(c => c.Id).DefaultIfEmpty(-1).Min();
@@ -403,10 +404,21 @@ public sealed class GameServer
world.AddSpawn(m.Payload); world.AddSpawn(m.Payload);
Log($"{c.Name} spawned {SpawnName(m.Payload)}"); Log($"{c.Name} spawned {SpawnName(m.Payload)}");
break; break;
case MsgType.ItemAppeared:
if (m.Payload.Length > 256) return;
world.AddAppeared(m.Payload);
break;
case MsgType.ObjDestroy:
if (m.Payload.Length != 4) return;
world.AddDestroyed(BitConverter.ToInt32(m.Payload, 0));
break;
case MsgType.Chat: case MsgType.Chat:
if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten if (m.Payload.Length > 1024) return; // Spam/Unsinn nicht weiterleiten
Log($"[chat] {c.Name}: {m.Reader().ReadString()}"); Log($"[chat] {c.Name}: {m.Reader().ReadString()}");
break; break;
case MsgType.TrafficStates:
if (c.Id != WorldAuthorityId) return; // nur einer simuliert den Verkehr
break;
case MsgType.WorldTime: case MsgType.WorldTime:
if (c.Id != WorldAuthorityId) return; // nur einer gibt Uhrzeit/Wetter vor if (c.Id != WorldAuthorityId) return; // nur einer gibt Uhrzeit/Wetter vor
lastWorldTime = m.Payload; lastWorldTime = m.Payload;
+1 -1
View File
@@ -7,7 +7,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>MSCO.Server</RootNamespace> <RootNamespace>MSCO.Server</RootNamespace>
<AssemblyName>MSCO.Server</AssemblyName> <AssemblyName>MSCO.Server</AssemblyName>
<Version>2.7.0</Version> <Version>2.8.0</Version>
<Company>MBM</Company> <Company>MBM</Company>
<Product>MSCO Dedicated Server</Product> <Product>MSCO Dedicated Server</Product>
<InvariantGlobalization>true</InvariantGlobalization> <InvariantGlobalization>true</InvariantGlobalization>
+6 -1
View File
@@ -40,7 +40,12 @@ public enum MsgType : byte
Reconnect = 33, // Server -> Client: string Meldung; Client geht ins Menü und verbindet sich neu (z.B. Welt zurückgesetzt) Reconnect = 33, // Server -> Client: string Meldung; Client geht ins Menü und verbindet sich neu (z.B. Welt zurückgesetzt)
SaveRequest = 34, // Server -> Client: int Anfrage; bitte im Spiel speichern und den Spielstand hochladen SaveRequest = 34, // Server -> Client: int Anfrage; bitte im Spiel speichern und den Spielstand hochladen
SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes SaveUploadPart = 35, // Client -> Server: int Anfrage, string Datei, int Gesamtgröße, int Offset, int Länge, bytes
SaveUploadDone = 36 // Client -> Server: int Anfrage, int Anzahl Dateien SaveUploadDone = 36, // Client -> Server: int Anfrage, int Anzahl Dateien
ItemAppeared = 37, // neues Item bei einem Spieler (Einkauf ...): int Objekt-ID, string Objektname, 3 float Pos, 4 float Rot
ObjDestroy = 38, // Item verbraucht/zerstört (gegessen, getrunken ...): int Objekt-ID
VehicleDamage = 39, // Unfallschaden (verformte Karosserie): int Mesh-Schlüssel, int Eckpunkte, int n, je [int Index, 3 float]
TrafficStates = 40, // Verkehr, nur von der Welt-Autorität: int n, je [int Schlüssel, 3 float Pos, 4 float Rot, 3 float Geschw.]
WorldVars = 41 // Jobs/Laden: int n, je [int Schlüssel, byte Typ, Wert]
} }
public readonly record struct Message(MsgType Type, byte[] Payload) public readonly record struct Message(MsgType Type, byte[] Payload)
+28
View File
@@ -117,6 +117,28 @@ try
var longChat = await bob.WaitFor(MsgType.Chat, 800); var longChat = await bob.WaitFor(MsgType.Chat, 800);
Check(longChat == null, "Huge chat messages are not relayed"); 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)"); Console.WriteLine("== Last position (spawn)");
var pos1 = await TestClient.Join("Walker", 0, null); 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.Player, PlayerPos(-15.6f, -0.3f, 7f)); // Startpunkt des Spielstands (Toilette) - vor Ready
@@ -243,6 +265,8 @@ sealed class Snapshot
public HashSet<int> ObjectIds = new(); public HashSet<int> ObjectIds = new();
public float[] Spawn; public float[] Spawn;
public int Spawns = -1; public int Spawns = -1;
public int Appeared = -1;
public List<int> Destroyed = new();
public int Vars = -1; public int Vars = -1;
public static Snapshot Parse(byte[] data) public static Snapshot Parse(byte[] data)
@@ -264,6 +288,10 @@ sealed class Snapshot
for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); } for (int i = 0; i < s.Vars; i++) { r.ReadInt32(); byte t = r.ReadByte(); r.ReadBytes(t == 3 ? 1 : 4); }
s.Spawns = r.ReadInt32(); s.Spawns = r.ReadInt32();
for (int i = 0; i < s.Spawns; i++) r.ReadBytes(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.Ok = r.BaseStream.Position == r.BaseStream.Length;
s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars"; s.Info = $"{objects} objects, {events} events, money {money}, {doors} doors, {s.Vars} vars";
} }
+36
View File
@@ -30,6 +30,12 @@ public sealed class WorldState
/// <summary>Von Admins gespawnte Items (SpawnItem-Nachrichten, Base64), damit später Beitretende sie auch haben.</summary> /// <summary>Von Admins gespawnte Items (SpawnItem-Nachrichten, Base64), damit später Beitretende sie auch haben.</summary>
public List<string> Spawns { get; set; } = new(); public List<string> Spawns { get; set; } = new();
public const int MaxSpawns = 2000; public const int MaxSpawns = 2000;
/// <summary>Seit dem letzten Spielstand gekaufte/ausgepackte Items (ItemAppeared, Base64) - stecken danach im Spielstand.</summary>
public List<string> Appeared { get; set; } = new();
/// <summary>Seit dem letzten Spielstand verbrauchte Items (Objekt-IDs).</summary>
public List<int> Destroyed { get; set; } = new();
public const int MaxAppeared = 2000;
public const int MaxDestroyed = 5000;
public bool Dirty { get; private set; } public bool Dirty { get; private set; }
@@ -61,12 +67,16 @@ public sealed class WorldState
MoneyDelta = 0f; MoneyDelta = 0f;
Doors.Clear(); Doors.Clear();
Vars.Clear(); Vars.Clear();
Appeared.Clear();
Destroyed.Clear();
Dirty = true; Dirty = true;
} }
public void Reset() public void Reset()
{ {
Spawns.Clear(); Spawns.Clear();
Appeared.Clear();
Destroyed.Clear();
Objects.Clear(); Objects.Clear();
Events.Clear(); Events.Clear();
PlayerPositions.Clear(); PlayerPositions.Clear();
@@ -172,6 +182,21 @@ public sealed class WorldState
Dirty = true; Dirty = true;
} }
public void AddAppeared(byte[] payload)
{
if (Appeared.Count >= MaxAppeared) return;
Appeared.Add(Convert.ToBase64String(payload));
Dirty = true;
}
public void AddDestroyed(int id)
{
if (Destroyed.Count >= MaxDestroyed || Destroyed.Contains(id)) return;
Destroyed.Add(id);
Objects.Remove(id);
Dirty = true;
}
public void AddFsmEvent(byte[] payload) public void AddFsmEvent(byte[] payload)
{ {
if (Events.Count >= MaxEvents) return; if (Events.Count >= MaxEvents) return;
@@ -244,6 +269,17 @@ public sealed class WorldState
w.Write(bytes.Length); w.Write(bytes.Length);
w.Write(bytes); w.Write(bytes);
} }
// Gekaufte Items: int n, je [int Länge, ItemAppeared-Payload]; verbrauchte: int n, je int ID
w.Write(Appeared.Count);
foreach (string a in Appeared)
{
byte[] bytes = Convert.FromBase64String(a);
w.Write(bytes.Length);
w.Write(bytes);
}
w.Write(Destroyed.Count);
foreach (int id in Destroyed) w.Write(id);
}); });
} }
} }