Compare commits

...

7 Commits

Author SHA1 Message Date
Robin C. Ladiges 497b5b44d6 fix: KeyNotFoundException
KeyNotFoundException: The given key 'time' was not present in the dictionary.
2024-04-27 17:11:13 -04:00
Robin C. Ladiges 4de654b6e4 ignore & crash instead of disconnect clients after reaching the MaxPlayers limit
Otherwise they'll enter an endless disconnect-reconnnect loop spamming the server with new TCP connections.
2024-04-27 15:14:38 -04:00
Robin C. Ladiges 082e480b1e crash ignored players
Otherwise they keep sending all their packets (including positional updates) to the server which costs bandwidth and processing power.
2024-04-27 15:14:38 -04:00
Robin C. Ladiges 9511d07f09 send server init after the client init
To make service discovery by internet scan bots harder.
2024-04-27 15:14:38 -04:00
Robin C. Ladiges dc20a9c831 small refactorings
- use brackets when possible
- set client.Id and client.Name earlier
- use client.Id instead of header.Id
- rename firstConn to isClientNew
2024-04-27 15:14:38 -04:00
Robin C. Ladiges 61e6fcf2a3 new setting: Shines/Excluded
To exclude specific shines to be synced to other clients.

Pre-initialized with shine 496 (Moon Shards in the Sand) that causes people to get stuck in front of the inverted pyramid.

This commit fixes issue #31
2024-04-27 14:46:43 -04:00
Robin C. Ladiges 20ee74d0d6 fix: construct tag packet instead of caching it in memory
Because the tag packet received from the client could have an UpdateType that isn't both State and Time.
(Though currently the client always updates both together.)
2024-04-27 14:45:41 -04:00
5 changed files with 183 additions and 85 deletions

View File

@ -148,30 +148,27 @@ public static class BanLists {
public static void Crash(
Client user,
bool permanent = false,
bool dispose_user = true,
int delay_ms = 0
int delay_ms = 0
) {
user.Ignored = true;
Task.Run(async () => {
if (delay_ms > 0) {
await Task.Delay(delay_ms);
}
bool permanent = user.Banned;
await user.Send(new ChangeStagePacket {
Id = (permanent ? "$agogus/ban4lyfe" : "$among$us/cr4sh%"),
Stage = (permanent ? "$ejected" : "$agogusStage"),
Scenario = (sbyte) (permanent ? 69 : 21),
SubScenarioType = (byte) (permanent ? 21 : 69),
});
if (dispose_user) {
user.Dispose();
}
});
}
private static void CrashMultiple(string[] args, MUCH much) {
foreach (Client user in much(args).toActUpon) {
Crash(user, true);
user.Banned = true;
Crash(user);
}
}
@ -245,8 +242,9 @@ public static class BanLists {
}
foreach (Client user in res.toActUpon) {
user.Banned = true;
BanClient(user);
Crash(user, true);
Crash(user);
}
Save();

View File

@ -13,6 +13,7 @@ public class Client : IDisposable {
public readonly ConcurrentDictionary<string, object?> Metadata = new ConcurrentDictionary<string, object?>(); // can be used to store any information about a player
public bool Connected = false;
public bool Ignored = false;
public bool Banned = false;
public CostumePacket? CurrentCostume = null; // required for proper client sync
public string Name {
get => Logger.Name;
@ -41,8 +42,9 @@ public class Client : IDisposable {
}
public void Dispose() {
if (Socket?.Connected is true)
if (Socket?.Connected is true) {
Socket.Disconnect(false);
}
}
@ -51,9 +53,14 @@ public class Client : IDisposable {
PacketAttribute packetAttribute = Constants.PacketMap[typeof(T)];
try {
// don't send most packets to ignored players
if (Ignored && packetAttribute.Type != PacketType.Init && packetAttribute.Type != PacketType.ChangeStage) {
memory.Dispose();
return;
}
Server.FillPacket(new PacketHeader {
Id = sender?.Id ?? Id,
Type = packetAttribute.Type,
Id = sender?.Id ?? Id,
Type = packetAttribute.Type,
PacketSize = packet.Size
}, packet, memory.Memory);
}
@ -69,11 +76,17 @@ public class Client : IDisposable {
public async Task Send(Memory<byte> data, Client? sender) {
PacketHeader header = new PacketHeader();
header.Deserialize(data.Span);
if (!Connected && header.Type is not PacketType.Connect) {
if (!Connected && !Ignored && header.Type != PacketType.Connect) {
Server.Logger.Error($"Didn't send {header.Type} to {Id} because they weren't connected yet");
return;
}
// don't send most packets to ignored players
if (Ignored && header.Type != PacketType.Init && header.Type != PacketType.ChangeStage) {
return;
}
await Socket!.SendAsync(data[..(Constants.HeaderSize + header.PacketSize)], SocketFlags.None);
}
@ -83,11 +96,22 @@ public class Client : IDisposable {
Metadata.TryRemove("seeking", out tmp);
Metadata.TryRemove("lastCostumePacket", out tmp);
Metadata.TryRemove("lastCapturePacket", out tmp);
Metadata.TryRemove("lastTagPacket", out tmp);
Metadata.TryRemove("lastGamePacket", out tmp);
Metadata.TryRemove("lastPlayerPacket", out tmp);
}
public TagPacket? GetTagPacket() {
var time = (Time?) (this.Metadata.ContainsKey("time") ? this.Metadata["time"] : null);
var seek = (bool?) (this.Metadata.ContainsKey("seeking") ? this.Metadata["seeking"] : null);
if (time == null && seek == null) { return null; }
return new TagPacket {
UpdateType = (seek != null ? TagPacket.TagUpdate.State : 0) | (time != null ? TagPacket.TagUpdate.Time: 0),
IsIt = seek ?? false,
Seconds = (byte) (time?.Seconds ?? 0),
Minutes = (ushort) (time?.Minutes ?? 0),
};
}
public static bool operator ==(Client? left, Client? right) {
return left is { } leftClient && right is { } rightClient && leftClient.Id == rightClient.Id;
}

View File

@ -74,7 +74,7 @@ async Task ClientSyncShineBag(Client client) {
try {
if ((bool?) client.Metadata["speedrun"] ?? false) return;
ConcurrentBag<int> clientBag = (ConcurrentBag<int>) (client.Metadata["shineSync"] ??= new ConcurrentBag<int>());
foreach (int shine in shineBag.Except(clientBag).ToArray()) {
foreach (int shine in shineBag.Except(clientBag).Except(Settings.Instance.Shines.Excluded).ToArray()) {
if (!client.Connected) return;
await client.Send(new ShinePacket {
ShineId = shine
@ -120,11 +120,20 @@ void logError(Task x) {
server.PacketHandler = (c, p) => {
switch (p) {
case GamePacket gamePacket: {
if (BanLists.Enabled && BanLists.IsStageBanned(gamePacket.Stage)) {
c.Logger.Warn($"Crashing player for entering banned stage {gamePacket.Stage}.");
BanLists.Crash(c, false, false, 500);
// crash ignored player
if (c.Ignored) {
c.Logger.Info($"Crashing ignored player after entering stage {gamePacket.Stage}.");
BanLists.Crash(c, 500);
return false;
}
// crash player entering a banned stage
if (BanLists.Enabled && BanLists.IsStageBanned(gamePacket.Stage)) {
c.Logger.Warn($"Crashing player for entering banned stage {gamePacket.Stage}.");
BanLists.Crash(c, 500);
return false;
}
c.Logger.Info($"Got game packet {gamePacket.Stage}->{gamePacket.ScenarioNum}");
// reset lastPlayerPacket on stage changes
@ -170,9 +179,13 @@ server.PacketHandler = (c, p) => {
break;
}
// ignore all other packets from ignored players
case IPacket pack when c.Ignored: {
return false;
}
case TagPacket tagPacket: {
// c.Logger.Info($"Got tag packet: {tagPacket.IsIt}");
c.Metadata["lastTagPacket"] = tagPacket;
if ((tagPacket.UpdateType & TagPacket.TagUpdate.State) != 0) c.Metadata["seeking"] = tagPacket.IsIt;
if ((tagPacket.UpdateType & TagPacket.TagUpdate.Time) != 0)
c.Metadata["time"] = new Time(tagPacket.Minutes, tagPacket.Seconds, DateTime.Now);
@ -185,7 +198,7 @@ server.PacketHandler = (c, p) => {
break;
}
case CostumePacket costumePacket:
case CostumePacket costumePacket: {
c.Logger.Info($"Got costume packet: {costumePacket.BodyName}, {costumePacket.CapName}");
c.Metadata["lastCostumePacket"] = costumePacket;
c.CurrentCostume = costumePacket;
@ -194,9 +207,14 @@ server.PacketHandler = (c, p) => {
#pragma warning restore CS4014
c.Metadata["loadedSave"] = true;
break;
}
case ShinePacket shinePacket: {
if (!Settings.Instance.Shines.Enabled) return false;
if (Settings.Instance.Shines.Excluded.Contains(shinePacket.ShineId)) {
c.Logger.Info($"Got moon {shinePacket.ShineId} (excluded)");
return false;
}
if (c.Metadata["loadedSave"] is false) break;
ConcurrentBag<int> playerBag = (ConcurrentBag<int>)c.Metadata["shineSync"]!;
shineBag.Add(shinePacket.ShineId);
@ -559,12 +577,16 @@ CommandHandler.RegisterCommand("flip", args => {
});
CommandHandler.RegisterCommand("shine", args => {
const string optionUsage = "Valid options: list, clear, sync, send, set";
const string optionUsage = "Valid options: list, clear, sync, send, set, include, exclude";
if (args.Length < 1)
return optionUsage;
switch (args[0]) {
case "list" when args.Length == 1:
return $"Shines: {string.Join(", ", shineBag)}";
return $"Shines: {string.Join(", ", shineBag)}" + (
Settings.Instance.Shines.Excluded.Count() > 0
? "\nExcluded Shines: " + string.Join(", ", Settings.Instance.Shines.Excluded)
: ""
);
case "clear" when args.Length == 1:
shineBag.Clear();
Task.Run(async () => {
@ -601,6 +623,21 @@ CommandHandler.RegisterCommand("shine", args => {
return optionUsage;
}
case "exclude" when args.Length == 2:
case "include" when args.Length == 2: {
if (int.TryParse(args[1], out int sid)) {
if (args[0] == "exclude") {
Settings.Instance.Shines.Excluded.Add(sid);
Settings.SaveSettings();
return $"Exclude shine {sid} from syncing.";
} else {
Settings.Instance.Shines.Excluded.Remove(sid);
Settings.SaveSettings();
return $"No longer exclude shine {sid} from syncing.";
}
}
return optionUsage;
}
default:
return optionUsage;
}

View File

@ -29,13 +29,9 @@ public class Server {
Socket socket = token.HasValue ? await serverSocket.AcceptAsync(token.Value) : await serverSocket.AcceptAsync();
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.NoDelay, true);
if (BanLists.Enabled && BanLists.IsIPv4Banned(((IPEndPoint) socket.RemoteEndPoint!).Address!)) {
Logger.Warn($"Ignoring banned IPv4 address {socket.RemoteEndPoint}");
continue;
}
Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}");
// start sub thread to handle client
try {
#pragma warning disable CS4014
Task.Run(() => HandleSocket(socket))
@ -78,27 +74,29 @@ public class Server {
public delegate void PacketReplacer<in T>(Client from, Client to, T value); // replacer must send
public void BroadcastReplace<T>(T packet, Client sender, PacketReplacer<T> packetReplacer) where T : struct, IPacket {
foreach (Client client in Clients.Where(client => client.Connected && sender.Id != client.Id)) packetReplacer(sender, client, packet);
foreach (Client client in Clients.Where(c => c.Connected && !c.Ignored && sender.Id != c.Id)) {
packetReplacer(sender, client, packet);
}
}
public async Task Broadcast<T>(T packet, Client sender) where T : struct, IPacket {
IMemoryOwner<byte> memory = MemoryPool<byte>.Shared.RentZero(Constants.HeaderSize + packet.Size);
PacketHeader header = new PacketHeader {
Id = sender?.Id ?? Guid.Empty,
Type = Constants.PacketMap[typeof(T)].Type,
PacketSize = packet.Size
Id = sender?.Id ?? Guid.Empty,
Type = Constants.PacketMap[typeof(T)].Type,
PacketSize = packet.Size,
};
FillPacket(header, packet, memory.Memory);
await Broadcast(memory, sender);
}
public Task Broadcast<T>(T packet) where T : struct, IPacket {
return Task.WhenAll(Clients.Where(c => c.Connected).Select(async client => {
return Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored).Select(async client => {
IMemoryOwner<byte> memory = MemoryPool<byte>.Shared.RentZero(Constants.HeaderSize + packet.Size);
PacketHeader header = new PacketHeader {
Id = client.Id,
Type = Constants.PacketMap[typeof(T)].Type,
PacketSize = packet.Size
Id = client.Id,
Type = Constants.PacketMap[typeof(T)].Type,
PacketSize = packet.Size,
};
FillPacket(header, packet, memory.Memory);
await client.Send(memory.Memory, client);
@ -112,7 +110,7 @@ public class Server {
/// <param name="data">Memory owner to dispose once done</param>
/// <param name="sender">Optional sender to not broadcast data to</param>
public async Task Broadcast(IMemoryOwner<byte> data, Client? sender = null) {
await Task.WhenAll(Clients.Where(c => c.Connected && c != sender).Select(client => client.Send(data.Memory, sender)));
await Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored && c != sender).Select(client => client.Send(data.Memory, sender)));
data.Dispose();
}
@ -122,7 +120,7 @@ public class Server {
/// <param name="data">Memory to send to the clients</param>
/// <param name="sender">Optional sender to not broadcast data to</param>
public async void Broadcast(Memory<byte> data, Client? sender = null) {
await Task.WhenAll(Clients.Where(c => c.Connected && c != sender).Select(client => client.Send(data, sender)));
await Task.WhenAll(Clients.Where(c => c.Connected && !c.Ignored && c != sender).Select(client => client.Send(data, sender)));
}
public Client? FindExistingClient(Guid id) {
@ -134,9 +132,7 @@ public class Server {
Client client = new Client(socket) {Server = this};
var remote = socket.RemoteEndPoint;
IMemoryOwner<byte> memory = null!;
await client.Send(new InitPacket {
MaxPlayers = Settings.Instance.Server.MaxPlayers
});
bool first = true;
try {
while (true) {
@ -159,8 +155,9 @@ public class Server {
return true;
}
if (!await Read(memory.Memory[..Constants.HeaderSize], Constants.HeaderSize, 0))
if (!await Read(memory.Memory[..Constants.HeaderSize], Constants.HeaderSize, 0)) {
break;
}
PacketHeader header = GetHeader(memory.Memory.Span[..Constants.HeaderSize]);
Range packetRange = Constants.HeaderSize..(Constants.HeaderSize + header.PacketSize);
if (header.PacketSize > 0) {
@ -168,71 +165,100 @@ public class Server {
memory = memoryPool.Rent(Constants.HeaderSize + header.PacketSize);
memTemp.Memory.Span[..Constants.HeaderSize].CopyTo(memory.Memory.Span[..Constants.HeaderSize]);
memTemp.Dispose();
if (!await Read(memory.Memory, header.PacketSize, Constants.HeaderSize))
if (!await Read(memory.Memory, header.PacketSize, Constants.HeaderSize)) {
break;
}
if (client.Ignored) {
memory.Dispose();
continue;
}
}
// connection initialization
if (first) {
first = false;
if (header.Type != PacketType.Connect) throw new Exception($"First packet was not init, instead it was {header.Type}");
first = false; // only do this once
// first client packet has to be the client init
if (header.Type != PacketType.Connect) {
throw new Exception($"First packet was not init, instead it was {header.Type} ({remote})");
}
ConnectPacket connect = new ConnectPacket();
connect.Deserialize(memory.Memory.Span[packetRange]);
bool wasFirst = connect.ConnectionType == ConnectPacket.ConnectionTypes.FirstConnection;
client.Id = header.Id;
client.Name = connect.ClientName;
if (BanLists.Enabled && BanLists.IsProfileBanned(header.Id)) {
client.Id = header.Id;
client.Name = connect.ClientName;
// is the IPv4 address banned?
if (BanLists.Enabled && BanLists.IsIPv4Banned(((IPEndPoint) socket.RemoteEndPoint!).Address!)) {
Logger.Warn($"Ignoring banned IPv4 address for {client.Name} ({client.Id}/{remote})");
client.Ignored = true;
client.Logger.Warn($"Ignoring banned profile ID {header.Id}");
client.Banned = true;
}
// is the profile ID banned?
else if (BanLists.Enabled && BanLists.IsProfileBanned(client.Id)) {
client.Logger.Warn($"Ignoring banned profile ID for {client.Name} ({client.Id}/{remote})");
client.Ignored = true;
client.Banned = true;
}
// is the server full?
else if (Clients.Count(x => x.Connected) >= Settings.Instance.Server.MaxPlayers) {
client.Logger.Error($"Ignoring player {client.Name} ({client.Id}/{remote}) as server reached max players of {Settings.Instance.Server.MaxPlayers}");
client.Ignored = true;
}
// send server init (required to crash ignored players later)
await client.Send(new InitPacket {
MaxPlayers = (client.Ignored ? (ushort) 1 : Settings.Instance.Server.MaxPlayers),
});
// don't init or announce an ignored client to other players any further
if (client.Ignored) {
memory.Dispose();
continue;
}
bool wasFirst = connect.ConnectionType == ConnectPacket.ConnectionTypes.FirstConnection;
// add client to the set of connected players
lock (Clients) {
if (Clients.Count(x => x.Connected) == Settings.Instance.Server.MaxPlayers) {
client.Logger.Error($"Turned away as server is at max clients");
// is the server full? (check again, to prevent race conditions)
if (Clients.Count(x => x.Connected) >= Settings.Instance.Server.MaxPlayers) {
client.Logger.Error($"Ignoring player {client.Name} ({client.Id}/{remote}) as server reached max players of {Settings.Instance.Server.MaxPlayers}");
client.Ignored = true;
memory.Dispose();
goto disconnect;
continue;
}
bool firstConn = true;
// detect and handle reconnections
bool isClientNew = true;
switch (connect.ConnectionType) {
case ConnectPacket.ConnectionTypes.FirstConnection:
case ConnectPacket.ConnectionTypes.Reconnecting: {
client.Id = header.Id;
if (FindExistingClient(header.Id) is { } oldClient) {
firstConn = false;
if (FindExistingClient(client.Id) is { } oldClient) {
isClientNew = false;
client = new Client(oldClient, socket);
client.Name = connect.ClientName;
Clients.Remove(oldClient);
Clients.Add(client);
if (oldClient.Connected) {
oldClient.Logger.Info($"Disconnecting already connected client {oldClient.Socket?.RemoteEndPoint} for {client.Socket?.RemoteEndPoint}");
oldClient.Dispose();
}
} else {
}
else {
connect.ConnectionType = ConnectPacket.ConnectionTypes.FirstConnection;
}
break;
}
default:
throw new Exception($"Invalid connection type {connect.ConnectionType}");
default: {
throw new Exception($"Invalid connection type {connect.ConnectionType} for {client.Name} ({client.Id}/{remote})");
}
}
client.Name = connect.ClientName;
client.Connected = true;
if (firstConn) {
if (isClientNew) {
// do any cleanup required when it comes to new clients
List<Client> toDisconnect = Clients.FindAll(c => c.Id == header.Id && c.Connected && c.Socket != null);
Clients.RemoveAll(c => c.Id == header.Id);
List<Client> toDisconnect = Clients.FindAll(c => c.Id == client.Id && c.Connected && c.Socket != null);
Clients.RemoveAll(c => c.Id == client.Id);
Clients.Add(client);
@ -240,18 +266,19 @@ public class Server {
// done disconnecting and removing stale clients with the same id
ClientJoined?.Invoke(client, connect);
// a new connection, not a reconnect, for an existing client
} else if (wasFirst) {
}
// a known client reconnects, but with a new first connection (e.g. after a restart)
else if (wasFirst) {
client.CleanMetadataOnNewConnection();
}
}
// for all other clients that are already connected
List<Client> otherConnectedPlayers = Clients.FindAll(c => c.Id != header.Id && c.Connected && c.Socket != null);
List<Client> otherConnectedPlayers = Clients.FindAll(c => c.Id != client.Id && c.Connected && c.Socket != null);
await Parallel.ForEachAsync(otherConnectedPlayers, async (other, _) => {
IMemoryOwner<byte> tempBuffer = MemoryPool<byte>.Shared.RentZero(Constants.HeaderSize + (other.CurrentCostume.HasValue ? Math.Max(connect.Size, other.CurrentCostume.Value.Size) : connect.Size));
// make the other client known to the (new) client
// make the other client known to the new client
PacketHeader connectHeader = new PacketHeader {
Id = other.Id,
Type = PacketType.Connect,
@ -266,7 +293,7 @@ public class Server {
connectPacket.Serialize(tempBuffer.Memory.Span[Constants.HeaderSize..]);
await client.Send(tempBuffer.Memory[..(Constants.HeaderSize + connect.Size)], null);
// tell the (new) client what costume the other client has
// tell the new client what costume the other client has
if (other.CurrentCostume.HasValue) {
connectHeader.Type = PacketType.Costume;
connectHeader.PacketSize = other.CurrentCostume.Value.Size;
@ -277,7 +304,7 @@ public class Server {
tempBuffer.Dispose();
// make the other client reset their puppet cache for this client, if it is a new connection (after restart)
// make the other client reset their puppet cache for this new client, if it is a new connection (after restart)
if (wasFirst) {
await SendEmptyPackets(client, other);
}
@ -287,14 +314,19 @@ public class Server {
// send missing or outdated packets from others to the new client
await ResendPackets(client);
} else if (header.Id != client.Id && client.Id != Guid.Empty) {
}
else if (header.Id != client.Id && client.Id != Guid.Empty) {
throw new Exception($"Client {client.Name} sent packet with invalid client id {header.Id} instead of {client.Id}");
}
try {
// parse the packet
IPacket packet = (IPacket) Activator.CreateInstance(Constants.PacketIdMap[header.Type])!;
packet.Deserialize(memory.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + packet.Size)]);
// process the packet
if (PacketHandler?.Invoke(client, packet) is false) {
// don't broadcast the packet to everyone
memory.Dispose();
continue;
}
@ -302,7 +334,9 @@ public class Server {
catch (Exception e) {
client.Logger.Error($"Packet handler warning: {e}");
}
#pragma warning disable CS4014
// broadcast the packet to everyone
Broadcast(memory, client)
.ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } });
#pragma warning restore CS4014
@ -311,7 +345,8 @@ public class Server {
catch (Exception e) {
if (e is SocketException {SocketErrorCode: SocketError.ConnectionReset}) {
client.Logger.Info($"Disconnected from the server: Connection reset");
} else {
}
else {
client.Logger.Error($"Disconnecting due to exception: {e}");
if (socket.Connected) {
#pragma warning disable CS4014
@ -324,7 +359,7 @@ public class Server {
memory?.Dispose();
}
disconnect:
// client disconnected
if (client.Name != "Unknown User" && client.Id != Guid.Parse("00000000-0000-0000-0000-000000000000")) {
Logger.Info($"Client {remote} ({client.Name}/{client.Id}) disconnected from the server");
}
@ -333,7 +368,6 @@ public class Server {
}
bool wasConnected = client.Connected;
// Clients.Remove(client)
client.Connected = false;
try {
client.Dispose();
@ -349,22 +383,26 @@ public class Server {
}
private async Task ResendPackets(Client client) {
async Task trySend<T>(Client other, string packetType) where T : struct, IPacket {
if (! other.Metadata.ContainsKey(packetType)) { return; }
async Task trySendPack<T>(Client other, T? packet) where T : struct, IPacket {
if (packet == null) { return; }
try {
await client.Send((T) other.Metadata[packetType]!, other);
await client.Send((T) packet, other);
}
catch {
// lol who gives a fuck
}
};
async Task trySendMeta<T>(Client other, string packetType) where T : struct, IPacket {
if (!other.Metadata.ContainsKey(packetType)) { return; }
await trySendPack<T>(other, (T) other.Metadata[packetType]!);
};
await Parallel.ForEachAsync(this.ClientsConnected, async (other, _) => {
if (client.Id == other.Id) { return; }
await trySend<CostumePacket>(other, "lastCostumePacket");
await trySend<CapturePacket>(other, "lastCapturePacket");
await trySend<TagPacket>(other, "lastTagPacket");
await trySend<GamePacket>(other, "lastGamePacket");
await trySend<PlayerPacket>(other, "lastPlayerPacket");
await trySendMeta<CostumePacket>(other, "lastCostumePacket");
await trySendMeta<CapturePacket>(other, "lastCapturePacket");
await trySendPack<TagPacket>(other, other.GetTagPacket());
await trySendMeta<GamePacket>(other, "lastGamePacket");
await trySendMeta<PlayerPacket>(other, "lastPlayerPacket");
});
}

View File

@ -80,6 +80,7 @@ public class Settings {
public class ShineTable {
public bool Enabled { get; set; } = true;
public ISet<int> Excluded { get; set; } = new SortedSet<int> { 496 };
}
public class PersistShinesTable