Compare commits

...

23 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
Robin C. Ladiges dd0de0da78 build binary files via docker 2023-09-05 17:23:18 -06:00
Robin C. Ladiges d6a8df448c
Refactoring ban command (#48)
* rename command: `ban ...` => `ban player ...`

To enable adding other subcommands starting with `ban`.

Moving ban list and crash related code into its own class to tidy the Program class up.

Change Id values of the crash cmds, to fit into the 16 byte max length imposed by ChangeStagePacket.IdSize.

* add command: `ban ip <ipv4-address>`

To add an IPv4 address to the ban list.

* add command: `ban profile <profile-id>`

To add a profile ID to the ban list.

* add command: `unban ip <ipv4-address>`

To remove a banned IPv4 address from the ban list.

* add command: `unban profile <profile-id>`

To remove a banned profile ID from the ban list.

* add commands: `ban enable` and `ban disable`

To set the value of `BanList.Enabled` to `true` or `false` without editing the `settings.json` file.

* add command: `ban list`

To show the current ban list settings.

* fix: actually working ban functionality

Changes:
- ignore new sockets from banned IP addresses way earlier.
- ignore all packets by banned profiles.

Intentionally keeping the connection open instead of d/c banned clients.
This is to prevent endless server logs due to automatically reconnecting clients.

Before:
Reconnecting clients aren't entering `ClientJoined` and therefore the d/c is only working on first connections.
Effectively banned clients got a d/c and then automatically reconnected again without getting a d/c again.
Therefore allowing them to play normally.

* use SortedSet instead of List for settings

To enforce unique entries and maintain a stable order inside of the `settings.json`.

* add commands: `ban stage <stage-name>` and `unban stage <stage-name>`

To kick players from the server when they enter a banned stage.

<stage-name> can also be a kingdom alias, which bans/unbans all stages in that kingdom.

Because we aren't banning the player, d/c them would be no good, because of the client auto reconnect.
Instead send them the crash and ignore all packets by them until they d/c on their own.

This is an alternative solution for issue #43.

* Update Server.cs

---------

Co-authored-by: Sanae <32604996+Sanae6@users.noreply.github.com>
2023-09-05 17:14:54 -06:00
Robin C. Ladiges 86c79177fd fix: synchronization issues
- Send empty `TagPacket` and `CapturePacket` on new connections, to reset old data back that other players might still have in their puppet from an
earlier connection.
- Cache and send `CostumePacket`, `CapturePacket`, `TagPacket`, `GamePacket` and `PlayerPacket` to (re-)connecting players.
- Clear Metadata cache for existing clients that connect fresh (after a game restart).
2023-09-05 16:51:58 -06:00
Robin C. Ladiges 1e9d334d6f fix: wrong kingdom values and order like presented in-game 2023-07-25 14:10:54 -06:00
Robin C. Ladiges 71bb96bf1e verify stage values for send and sendall
Changes:
- Moved alias mapping from Constants.cs to Stages.cs.
- Added `odyssey` as an alias.
- Hardcoded all known stage values.
- Verfify that the stage input is either a alias or a known stage name.
- Added an option to append `!` to a stage name to force sending even if the stage is not known (e.g. for custom kingdoms).

Before it only checked that it was a known alias or that it contained `Stage` or `Zone`.
That made it impossible to send players to`MoonWorldShopRoom` and `MoonWorldSphinxRoom`.
And a typo would have resulted in a game crash.
2023-03-22 16:50:38 -06:00
Robin C. Ladiges 47fc1527bf fix: don't process and broadcast shine packets when shine sync is disabled
The breaks make the function return true, which causes the shine packets to be broadcasted to all connected clients.
Returning false will prevent the broadcast of the current shine packet.

Note: If shines are enabled, the clients will receive every shine twice.
Once from the `SyncShineBag();` and then a second time from the default broadcast caused by the remaining breaks.
We should probably replace every `break;` with `return false;` here?
2022-12-16 13:18:07 -06:00
Robin C. Ladiges a0642e6a30 fix: only send shines to connected clients and save only after sending
otherwise it will save that the client got it to the bag and then fail sending it, therefore forever preventing the client to get the shine.
2022-12-16 13:18:07 -06:00
Robin C. Ladiges f0d837190a only warn about the Discord channel settings when a Token is set
Servers that don't use Discord (default settings) don't need to be warned about not setting Discord channels.
2022-10-10 19:37:37 -06:00
Robin C. Ladiges 122a3cd80d fix: don't output empty player IDs or RemoteEndPoints in the log
Make and use a copy of the RemoteEndPoint at the start of the HandleSocket method.
Because in some cases when the socket is disposed, the RemoteEndPoint inside of it is cleared and isn't available for the following disconnect log entries.
Also: port scanners on the internet don't introduce themselves with a name and ID.

(cherry picked from commit 2f4cd0509a)
2022-10-10 11:13:32 -06:00
Robin C. Ladiges 69cef89953 fix: on reconnect do not disconnect the new client
Currently when a client connects that is already there,
the old socket is closed, and the code tries to reuse the existing client object by exchanging its socket.

Reusing the same client object and just changing its socket does cause issues though with copies of the client in other threads.
In the situations that I could reproduce, it always disconnected both sockets, the old one and then the new one.

Instead I make a copy of the client object, use the new socket, remove the old object and add the new object to the collection.

(cherry picked from commit 9e6c312c8e)
2022-10-10 11:12:28 -06:00
Robin C. Ladiges 6285abfc4e slightly increase docker build
The restore command doesn't need the full source code, but just the .csproj files.

(cherry picked from commit 391d020385)
2022-10-10 11:10:19 -06:00
Robin C. Ladiges 472c8856bc move the client.CurrentCostume update to the PacketHandler and log the packet
(cherry picked from commit 47505dbdd5)
2022-10-10 11:09:39 -06:00
Robin C. Ladiges 53442b598e only start listening for clients once everything is initialized
Otherwise clients might connect to the server before everything is ready for them.
E.g. when restarting the server, the clients will immediately try to reconnect.

Clients might connect before the `PacketHandler` is initialized, which results in some packets not being processed by the server correctly.

Same goes for the commands: Discord might send in commands before all commands were added to the `CommandHandler`.

Without the `ClientJoined` action, clients might even be allowed to connect if they are on the banlist.
(Though without this initialization they or regular clients might be broken in some ways?)

(cherry picked from commit 92e540aaa6)
2022-10-10 11:08:38 -06:00
Robin C. Ladiges 92d4bdd195 better DiscordBot channel exceptions
currently it always outputs `Failed to get log channel \"{Config.CommandChannel}\"` regardless if the error was with the command channel or the log channel.

await Run(); doesn't need to be in a try-catch block, because it has a try-catch block itself in it.
2022-09-07 10:36:57 -06:00
Robin C. Ladiges c41499f953 remove unused label
git cherry-pick accidentially picked up a line from another commit for the JSON API
2022-09-07 10:35:26 -06:00
Robin C. Ladiges 76fc4a80a6 only broadcast the DisconnectPacket if the client was connected
Otherwise port scans, banned players or clients failing to initialize correctly,
will cause the server to send unnecessary packets to all connected clients.

They currently are informed about a disconnect for a client that hasn't even connected correctly.

(cherry picked from commit 4b04a3d5be)
2022-09-07 10:35:26 -06:00
14 changed files with 1150 additions and 246 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
/Server/bin/
/Server/obj/
/Shared/bin/
/Shared/obj/

View File

@ -73,3 +73,35 @@ jobs:
platforms : linux/amd64,linux/arm/v7,linux/arm64/v8
cache-from : type=gha,scope=${{ github.workflow }}
cache-to : type=gha,scope=${{ github.workflow }},mode=max
-
name: Build binary files
run: |
./docker-build.sh all
-
name : Upload Server
uses : actions/upload-artifact@v3
with:
name : Server
path : ./bin/Server
if-no-files-found : error
-
name : Upload Server.arm
uses : actions/upload-artifact@v3
with:
name : Server.arm
path : ./bin/Server.arm
if-no-files-found : error
-
name : Upload Server.arm64
uses : actions/upload-artifact@v3
with:
name : Server.arm64
path : ./bin/Server.arm64
if-no-files-found : error
-
name : Upload Server.exe
uses : actions/upload-artifact@v3
with:
name : Server.exe
path : ./bin/Server.exe
if-no-files-found : error

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ riderModule.iml
.idea/
settings.json
.vs/
/cache/
/data/

View File

@ -5,8 +5,8 @@ FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:6.0 as build
WORKDIR /app/
COPY ./Server/ ./Server/
COPY ./Shared/ ./Shared/
COPY ./Shared/Shared.csproj ./Shared/Shared.csproj
COPY ./Server/Server.csproj ./Server/Server.csproj
ARG TARGETARCH
@ -16,6 +16,9 @@ RUN dotnet restore \
-r debian.11-`echo $TARGETARCH | sed 's@^amd@x@'` \
;
COPY ./Shared/ ./Shared/
COPY ./Server/ ./Server/
# Build application binary
RUN dotnet publish \
./Server/Server.csproj \

359
Server/BanLists.cs Normal file
View File

@ -0,0 +1,359 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using Shared;
using Shared.Packet.Packets;
namespace Server;
using MUCH = Func<string[], (HashSet<string> failToFind, HashSet<Client> toActUpon, List<(string arg, IEnumerable<string> amb)> ambig)>;
public static class BanLists {
public static bool Enabled {
get {
return Settings.Instance.BanList.Enabled;
}
private set {
Settings.Instance.BanList.Enabled = value;
}
}
private static ISet<string> IPs {
get {
return Settings.Instance.BanList.IpAddresses;
}
}
private static ISet<Guid> Profiles {
get {
return Settings.Instance.BanList.Players;
}
}
private static ISet<string> Stages {
get {
return Settings.Instance.BanList.Stages;
}
}
private static bool IsIPv4(string str) {
return IPAddress.TryParse(str, out IPAddress? ip)
&& ip != null
&& ip.AddressFamily == AddressFamily.InterNetwork;
;
}
public static bool IsIPv4Banned(Client user) {
IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint;
if (ipv4 == null) { return false; }
return IsIPv4Banned(ipv4.Address);
}
public static bool IsIPv4Banned(IPAddress ipv4) {
return IsIPv4Banned(ipv4.ToString());
}
public static bool IsIPv4Banned(string ipv4) {
return IPs.Contains(ipv4);
}
public static bool IsProfileBanned(Client user) {
return IsProfileBanned(user.Id);
}
public static bool IsProfileBanned(string str) {
if (!Guid.TryParse(str, out Guid id)) { return false; }
return IsProfileBanned(id);
}
public static bool IsProfileBanned(Guid id) {
return Profiles.Contains(id);
}
public static bool IsStageBanned(string stage) {
return Stages.Contains(stage);
}
public static bool IsClientBanned(Client user) {
return IsProfileBanned(user) || IsIPv4Banned(user);
}
private static void BanIPv4(Client user) {
IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint;
if (ipv4 != null) {
BanIPv4(ipv4.Address);
}
}
private static void BanIPv4(IPAddress ipv4) {
BanIPv4(ipv4.ToString());
}
private static void BanIPv4(string ipv4) {
IPs.Add(ipv4);
}
private static void BanProfile(Client user) {
BanProfile(user.Id);
}
private static void BanProfile(string str) {
if (!Guid.TryParse(str, out Guid id)) { return; }
BanProfile(id);
}
private static void BanProfile(Guid id) {
Profiles.Add(id);
}
private static void BanStage(string stage) {
Stages.Add(stage);
}
private static void BanClient(Client user) {
BanProfile(user);
BanIPv4(user);
}
private static void UnbanIPv4(Client user) {
IPEndPoint? ipv4 = (IPEndPoint?) user.Socket?.RemoteEndPoint;
if (ipv4 != null) {
UnbanIPv4(ipv4.Address);
}
}
private static void UnbanIPv4(IPAddress ipv4) {
UnbanIPv4(ipv4.ToString());
}
private static void UnbanIPv4(string ipv4) {
IPs.Remove(ipv4);
}
private static void UnbanProfile(Client user) {
UnbanProfile(user.Id);
}
private static void UnbanProfile(string str) {
if (!Guid.TryParse(str, out Guid id)) { return; }
UnbanProfile(id);
}
private static void UnbanProfile(Guid id) {
Profiles.Remove(id);
}
private static void UnbanStage(string stage) {
Stages.Remove(stage);
}
private static void Save() {
Settings.SaveSettings(true);
}
public static void Crash(
Client user,
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),
});
});
}
private static void CrashMultiple(string[] args, MUCH much) {
foreach (Client user in much(args).toActUpon) {
user.Banned = true;
Crash(user);
}
}
public static string HandleBanCommand(string[] args, MUCH much) {
if (args.Length == 0) {
return "Usage: ban {list|enable|disable|player|profile|ip|stage} ...";
}
string cmd = args[0];
args = args.Skip(1).ToArray();
switch (cmd) {
default:
return "Usage: ban {list|enable|disable|player|profile|ip|stage} ...";
case "list":
if (args.Length != 0) {
return "Usage: ban list";
}
StringBuilder list = new StringBuilder();
list.Append("BanList: " + (Enabled ? "enabled" : "disabled"));
if (IPs.Count > 0) {
list.Append("\nBanned IPv4 addresses:\n- ");
list.Append(string.Join("\n- ", IPs));
}
if (Profiles.Count > 0) {
list.Append("\nBanned profile IDs:\n- ");
list.Append(string.Join("\n- ", Profiles));
}
if (Stages.Count > 0) {
list.Append("\nBanned stages:\n- ");
list.Append(string.Join("\n- ", Stages));
}
return list.ToString();
case "enable":
if (args.Length != 0) {
return "Usage: ban enable";
}
Enabled = true;
Save();
return "BanList enabled.";
case "disable":
if (args.Length != 0) {
return "Usage: ban disable";
}
Enabled = false;
Save();
return "BanList disabled.";
case "player":
if (args.Length == 0) {
return "Usage: ban player <* | !* (usernames to not ban...) | (usernames to ban...)>";
}
var res = much(args);
StringBuilder sb = new StringBuilder();
sb.Append(res.toActUpon.Count > 0 ? "Banned players: " + string.Join(", ", res.toActUpon.Select(x => $"\"{x.Name}\"")) : "");
sb.Append(res.failToFind.Count > 0 ? "\nFailed to find matches for: " + string.Join(", ", res.failToFind.Select(x => $"\"{x.ToLower()}\"")) : "");
if (res.ambig.Count > 0) {
res.ambig.ForEach(x => {
sb.Append($"\nAmbiguous for \"{x.arg}\": {string.Join(", ", x.amb.Select(x => $"\"{x}\""))}");
});
}
foreach (Client user in res.toActUpon) {
user.Banned = true;
BanClient(user);
Crash(user);
}
Save();
return sb.ToString();
case "profile":
if (args.Length != 1) {
return "Usage: ban profile <profile-id>";
}
if (!Guid.TryParse(args[0], out Guid id)) {
return "Invalid profile ID value!";
}
if (IsProfileBanned(id)) {
return "Profile " + id.ToString() + " is already banned.";
}
BanProfile(id);
CrashMultiple(args, much);
Save();
return "Banned profile: " + id.ToString();
case "ip":
if (args.Length != 1) {
return "Usage: ban ip <ipv4-address>";
}
if (!IsIPv4(args[0])) {
return "Invalid IPv4 address!";
}
if (IsIPv4Banned(args[0])) {
return "IP " + args[0] + " is already banned.";
}
BanIPv4(args[0]);
CrashMultiple(args, much);
Save();
return "Banned ip: " + args[0];
case "stage":
if (args.Length != 1) {
return "Usage: ban stage <stage-name>";
}
string? stage = Shared.Stages.Input2Stage(args[0]);
if (stage == null) {
return "Invalid stage name!";
}
if (IsStageBanned(stage)) {
return "Stage " + stage + " is already banned.";
}
var stages = Shared.Stages
.StagesByInput(args[0])
.Where(s => !IsStageBanned(s))
.ToList()
;
foreach (string s in stages) {
BanStage(s);
}
Save();
return "Banned stage: " + string.Join(", ", stages);
}
}
public static string HandleUnbanCommand(string[] args) {
if (args.Length != 2) {
return "Usage: unban {profile|ip|stage} <value>";
}
string cmd = args[0];
string val = args[1];
switch (cmd) {
default:
return "Usage: unban {profile|ip|stage} <value>";
case "profile":
if (!Guid.TryParse(val, out Guid id)) {
return "Invalid profile ID value!";
}
if (!IsProfileBanned(id)) {
return "Profile " + id.ToString() + " is not banned.";
}
UnbanProfile(id);
Save();
return "Unbanned profile: " + id.ToString();
case "ip":
if (!IsIPv4(val)) {
return "Invalid IPv4 address!";
}
if (!IsIPv4Banned(val)) {
return "IP " + val + " is not banned.";
}
UnbanIPv4(val);
Save();
return "Unbanned ip: " + val;
case "stage":
string stage = Shared.Stages.Input2Stage(val) ?? val;
if (!IsStageBanned(stage)) {
return "Stage " + stage + " is not banned.";
}
var stages = Shared.Stages
.StagesByInput(val)
.Where(IsStageBanned)
.ToList()
;
foreach (string s in stages) {
UnbanStage(s);
}
Save();
return "Unbanned stage: " + string.Join(", ", stages);
}
}
}

View File

@ -12,6 +12,8 @@ namespace Server;
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;
@ -28,9 +30,21 @@ public class Client : IDisposable {
Logger = new Logger("Unknown User");
}
// copy Client to use existing data for a new reconnected connection with a new socket
public Client(Client other, Socket socket) {
Metadata = other.Metadata;
Connected = other.Connected;
CurrentCostume = other.CurrentCostume;
Id = other.Id;
Socket = socket;
Server = other.Server;
Logger = other.Logger;
}
public void Dispose() {
if (Socket?.Connected is true)
if (Socket?.Connected is true) {
Socket.Disconnect(false);
}
}
@ -39,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);
}
@ -57,14 +76,42 @@ 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);
}
public void CleanMetadataOnNewConnection() {
object? tmp;
Metadata.TryRemove("time", out tmp);
Metadata.TryRemove("seeking", out tmp);
Metadata.TryRemove("lastCostumePacket", out tmp);
Metadata.TryRemove("lastCapturePacket", 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;
}
@ -83,4 +130,4 @@ public class Client : IDisposable {
public override int GetHashCode() {
return Id.GetHashCode(); //relies upon same info as == operator.
}
}
}

View File

@ -24,11 +24,11 @@ public class DiscordBot {
Task.Run(Reconnect);
return "Restarting Discord bot";
});
if (Config.Token == null) return;
if (Config.CommandChannel == null)
Logger.Warn("You probably should set your CommandChannel in settings.json");
if (Config.LogChannel == null)
Logger.Warn("You probably should set your LogChannel in settings.json");
if (Config.Token == null) return;
Settings.LoadHandler += SettingsLoadHandler;
}
@ -39,19 +39,31 @@ public class DiscordBot {
}
private async void SettingsLoadHandler() {
try {
if (DiscordClient == null || Token != Config.Token)
await Run();
//CommandChannel not currently used
if (Config.CommandChannel != null)
CommandChannel = await (DiscordClient?.GetChannelAsync(ulong.Parse(Config.CommandChannel)) ??
throw new NullReferenceException("Discord client not setup yet!"));
if (Config.LogChannel != null)
LogChannel = await (DiscordClient?.GetChannelAsync(ulong.Parse(Config.LogChannel)) ??
throw new NullReferenceException("Discord client not setup yet!"));
} catch (Exception e) {
Logger.Error($"Failed to get log channel \"{Config.CommandChannel}\"");
Logger.Error(e);
if (DiscordClient == null || Token != Config.Token) {
await Run();
}
if (DiscordClient == null) {
Logger.Error(new NullReferenceException("Discord client not setup yet!"));
return;
}
if (Config.CommandChannel != null) {
try {
CommandChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.CommandChannel));
} catch (Exception e) {
Logger.Error($"Failed to get command channel \"{Config.CommandChannel}\"");
Logger.Error(e);
}
}
if (Config.LogChannel != null) {
try {
LogChannel = await DiscordClient.GetChannelAsync(ulong.Parse(Config.LogChannel));
} catch (Exception e) {
Logger.Error($"Failed to get log channel \"{Config.LogChannel}\"");
Logger.Error(e);
}
}
}
@ -145,4 +157,4 @@ public class DiscordBot {
Logger.Error(e);
}
}
}
}

View File

@ -12,7 +12,6 @@ Server.Server server = new Server.Server();
HashSet<int> shineBag = new HashSet<int>();
CancellationTokenSource cts = new CancellationTokenSource();
bool restartRequested = false;
Task listenTask = server.Listen(cts.Token);
Logger consoleLogger = new Logger("Console");
DiscordBot bot = new DiscordBot();
await bot.Run();
@ -63,23 +62,11 @@ async Task LoadShines()
await LoadShines();
server.ClientJoined += (c, _) => {
if (Settings.Instance.BanList.Enabled
&& (Settings.Instance.BanList.Players.Contains(c.Id)
|| Settings.Instance.BanList.IpAddresses.Contains(
((IPEndPoint) c.Socket!.RemoteEndPoint!).Address.ToString())))
throw new Exception($"Banned player attempted join: {c.Name}");
c.Metadata["shineSync"] = new ConcurrentBag<int>();
c.Metadata["loadedSave"] = false;
c.Metadata["scenario"] = (byte?) 0;
c.Metadata["2d"] = false;
c.Metadata["speedrun"] = false;
foreach (Client client in server.ClientsConnected) {
try {
c.Send((GamePacket) client.Metadata["lastGamePacket"]!, client).Wait();
} catch {
// lol who gives a fuck
}
}
};
async Task ClientSyncShineBag(Client client) {
@ -87,11 +74,12 @@ 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()) {
clientBag.Add(shine);
foreach (int shine in shineBag.Except(clientBag).Except(Settings.Instance.Shines.Excluded).ToArray()) {
if (!client.Connected) return;
await client.Send(new ShinePacket {
ShineId = shine
});
clientBag.Add(shine);
}
} catch {
// errors that can happen when sending will crash the server :)
@ -101,7 +89,7 @@ async Task ClientSyncShineBag(Client client) {
async void SyncShineBag() {
try {
await PersistShines();
await Parallel.ForEachAsync(server.Clients.ToArray(), async (client, _) => await ClientSyncShineBag(client));
await Parallel.ForEachAsync(server.ClientsConnected.ToArray(), async (client, _) => await ClientSyncShineBag(client));
} catch {
// errors that can happen shines change will crash the server :)
}
@ -115,13 +103,50 @@ timer.Start();
float MarioSize(bool is2d) => is2d ? 180 : 160;
void flipPlayer(Client c, ref PlayerPacket pp) {
pp.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!);
pp.Rotation *= (
Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI))
* Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI))
);
};
void logError(Task x) {
if (x.Exception != null) {
consoleLogger.Error(x.Exception.ToString());
}
};
server.PacketHandler = (c, p) => {
switch (p) {
case GamePacket gamePacket: {
// 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
object? old = null;
c.Metadata.TryGetValue("lastGamePacket", out old);
if (old != null && ((GamePacket) old).Stage != gamePacket.Stage) {
c.Metadata["lastPlayerPacket"] = null;
}
c.Metadata["scenario"] = gamePacket.ScenarioNum;
c.Metadata["2d"] = gamePacket.Is2d;
c.Metadata["lastGamePacket"] = gamePacket;
switch (gamePacket.Stage) {
case "CapWorldHomeStage" when gamePacket.ScenarioNum == 0:
c.Metadata["speedrun"] = true;
@ -145,8 +170,7 @@ server.PacketHandler = (c, p) => {
server.BroadcastReplace(gamePacket, c, (from, to, gp) => {
gp.ScenarioNum = (byte?) to.Metadata["scenario"] ?? 200;
#pragma warning disable CS4014
to.Send(gp, from)
.ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } });
to.Send(gp, from).ContinueWith(logError);
#pragma warning restore CS4014
});
return false;
@ -154,19 +178,43 @@ 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}");
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);
break;
}
case CostumePacket:
case CapturePacket capturePacket: {
// c.Logger.Info($"Got capture packet: {capturePacket.ModelName}");
c.Metadata["lastCapturePacket"] = capturePacket;
break;
}
case CostumePacket costumePacket: {
c.Logger.Info($"Got costume packet: {costumePacket.BodyName}, {costumePacket.CapName}");
c.Metadata["lastCostumePacket"] = costumePacket;
c.CurrentCostume = costumePacket;
#pragma warning disable CS4014
ClientSyncShineBag(c); //no point logging since entire def has try/catch
#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);
@ -176,75 +224,89 @@ server.PacketHandler = (c, p) => {
SyncShineBag();
break;
}
case PlayerPacket playerPacket when Settings.Instance.Flip.Enabled
&& Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Others
&& Settings.Instance.Flip.Players.Contains(c.Id): {
playerPacket.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!);
playerPacket.Rotation *= Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI))
* Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI));
case PlayerPacket playerPacket: {
c.Metadata["lastPlayerPacket"] = playerPacket;
// flip for all
if ( Settings.Instance.Flip.Enabled
&& Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Others
&& Settings.Instance.Flip.Players.Contains(c.Id)
) {
flipPlayer(c, ref playerPacket);
#pragma warning disable CS4014
server.Broadcast(playerPacket, c)
.ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } });
server.Broadcast(playerPacket, c).ContinueWith(logError);
#pragma warning restore CS4014
return false;
}
case PlayerPacket playerPacket when Settings.Instance.Flip.Enabled
&& Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Self
&& !Settings.Instance.Flip.Players.Contains(c.Id): {
server.BroadcastReplace(playerPacket, c, (from, to, sp) => {
if (Settings.Instance.Flip.Players.Contains(to.Id)) {
sp.Position += Vector3.UnitY * MarioSize((bool) c.Metadata["2d"]!);
sp.Rotation *= Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationX(MathF.PI))
* Quaternion.CreateFromRotationMatrix(Matrix4x4.CreateRotationY(MathF.PI));
}
return false;
}
// flip only for specific clients
if ( Settings.Instance.Flip.Enabled
&& Settings.Instance.Flip.Pov is FlipOptions.Both or FlipOptions.Self
&& !Settings.Instance.Flip.Players.Contains(c.Id)
) {
server.BroadcastReplace(playerPacket, c, (from, to, sp) => {
if (Settings.Instance.Flip.Players.Contains(to.Id)) {
flipPlayer(c, ref sp);
}
#pragma warning disable CS4014
to.Send(sp, from)
.ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } });
to.Send(sp, from).ContinueWith(logError);
#pragma warning restore CS4014
});
return false;
});
return false;
}
break;
}
}
return true;
return true; // Broadcast packet to all other clients
};
(HashSet<string> failToFind, HashSet<Client> toActUpon, List<(string arg, IEnumerable<string> amb)> ambig) MultiUserCommandHelper(string[] args) {
HashSet<string> failToFind = new();
HashSet<Client> toActUpon;
List<(string arg, IEnumerable<string> amb)> ambig = new();
if (args[0] == "*")
if (args[0] == "*") {
toActUpon = new(server.Clients.Where(c => c.Connected));
}
else {
toActUpon = args[0] == "!*" ? new(server.Clients.Where(c => c.Connected)) : new();
for (int i = (args[0] == "!*" ? 1 : 0); i < args.Length; i++) {
string arg = args[i];
IEnumerable<Client> search = server.Clients.Where(c => c.Connected &&
(c.Name.ToLower().StartsWith(arg.ToLower()) || (Guid.TryParse(arg, out Guid res) && res == c.Id)));
if (!search.Any())
IEnumerable<Client> search = server.Clients.Where(c => c.Connected && (
c.Name.ToLower().StartsWith(arg.ToLower())
|| (Guid.TryParse(arg, out Guid res) && res == c.Id)
|| (IPAddress.TryParse(arg, out IPAddress? ip) && ip.Equals(((IPEndPoint) c.Socket!.RemoteEndPoint!).Address))
));
if (!search.Any()) {
failToFind.Add(arg); //none found
}
else if (search.Count() > 1) {
Client? exact = search.FirstOrDefault(x => x.Name == arg);
if (!ReferenceEquals(exact, null)) {
//even though multiple matches, since exact match, it isn't ambiguous
if (args[0] == "!*")
if (args[0] == "!*") {
toActUpon.Remove(exact);
else
}
else {
toActUpon.Add(exact);
}
}
else {
if (!ambig.Any(x => x.arg == arg))
if (!ambig.Any(x => x.arg == arg)) {
ambig.Add((arg, search.Select(x => x.Name))); //more than one match
foreach (var rem in search.ToList()) //need copy because can't remove from list while iterating over it
}
foreach (var rem in search.ToList()) { //need copy because can't remove from list while iterating over it
toActUpon.Remove(rem);
}
}
}
else {
//only one match, so autocomplete
if (args[0] == "!*")
if (args[0] == "!*") {
toActUpon.Remove(search.First());
else
}
else {
toActUpon.Add(search.First());
}
}
}
}
@ -291,71 +353,28 @@ CommandHandler.RegisterCommand("crash", args => {
}
foreach (Client user in res.toActUpon) {
Task.Run(async () => {
await user.Send(new ChangeStagePacket {
Id = "$among$us/SubArea",
Stage = "$agogusStage",
Scenario = 21,
SubScenarioType = 69 // invalid id
});
user.Dispose();
});
BanLists.Crash(user);
}
return sb.ToString();
});
CommandHandler.RegisterCommand("ban", args => {
if (args.Length == 0) {
return "Usage: ban <* | !* (usernames to not ban...) | (usernames to ban...)>";
}
var res = MultiUserCommandHelper(args);
StringBuilder sb = new StringBuilder();
sb.Append(res.toActUpon.Count > 0 ? "Banned: " + string.Join(", ", res.toActUpon.Select(x => $"\"{x.Name}\"")) : "");
sb.Append(res.failToFind.Count > 0 ? "\nFailed to find matches for: " + string.Join(", ", res.failToFind.Select(x => $"\"{x.ToLower()}\"")) : "");
if (res.ambig.Count > 0) {
res.ambig.ForEach(x => {
sb.Append($"\nAmbiguous for \"{x.arg}\": {string.Join(", ", x.amb.Select(x => $"\"{x}\""))}");
});
}
foreach (Client user in res.toActUpon) {
Task.Run(async () => {
await user.Send(new ChangeStagePacket {
Id = "$agogus/banned4lyfe",
Stage = "$ejected",
Scenario = 69,
SubScenarioType = 21 // invalid id
});
IPEndPoint? endpoint = (IPEndPoint?) user.Socket?.RemoteEndPoint;
Settings.Instance.BanList.Players.Add(user.Id);
if (endpoint != null) Settings.Instance.BanList.IpAddresses.Add(endpoint.ToString());
user.Dispose();
});
}
Settings.SaveSettings();
return sb.ToString();
});
CommandHandler.RegisterCommand("ban", args => { return BanLists.HandleBanCommand(args, (args) => MultiUserCommandHelper(args)); });
CommandHandler.RegisterCommand("unban", args => { return BanLists.HandleUnbanCommand(args); });
CommandHandler.RegisterCommand("send", args => {
const string optionUsage = "Usage: send <stage> <id> <scenario[-1..127]> <player/*>";
if (args.Length < 4)
if (args.Length < 4) {
return optionUsage;
}
string? stage = Stages.Input2Stage(args[0]);
if (stage == null) {
return "Invalid Stage Name! ```" + Stages.KingdomAliasMapping() + "```";
}
string stage = args[0];
string id = args[1];
if (Constants.MapNames.TryGetValue(stage.ToLower(), out string? mapName)) {
stage = mapName;
}
if (!stage.Contains("Stage") && !stage.Contains("Zone")) {
return "Invalid Stage Name! ```cap -> Cap Kingdom\ncascade -> Cascade Kingdom\nsand -> Sand Kingdom\nlake -> Lake Kingdom\nwooded -> Wooded Kingdom\ncloud -> Cloud Kingdom\nlost -> Lost Kingdom\nmetro -> Metro Kingdom\nsea -> Sea Kingdom\nsnow -> Snow Kingdom\nlunch -> Luncheon Kingdom\nruined -> Ruined Kingdom\nbowser -> Bowser's Kingdom\nmoon -> Moon Kingdom\nmush -> Mushroom Kingdom\ndark -> Dark Side\ndarker -> Darker Side```";
}
if (!sbyte.TryParse(args[2], out sbyte scenario) || scenario < -1)
return $"Invalid scenario number {args[2]} (range: [-1 to 127])";
Client[] players = args[3] == "*"
@ -377,17 +396,13 @@ CommandHandler.RegisterCommand("send", args => {
CommandHandler.RegisterCommand("sendall", args => {
const string optionUsage = "Usage: sendall <stage>";
if (args.Length < 1)
if (args.Length < 1) {
return optionUsage;
string stage = args[0];
if (Constants.MapNames.TryGetValue(stage.ToLower(), out string? mapName)) {
stage = mapName;
}
if (!stage.Contains("Stage") && !stage.Contains("Zone")) {
return "Invalid Stage Name! ```cap -> Cap Kingdom\ncascade -> Cascade Kingdom\nsand -> Sand Kingdom\nlake -> Lake Kingdom\nwooded -> Wooded Kingdom\ncloud -> Cloud Kingdom\nlost -> Lost Kingdom\nmetro -> Metro Kingdom\nsea -> Sea Kingdom\nsnow -> Snow Kingdom\nlunch -> Luncheon Kingdom\nruined -> Ruined Kingdom\nbowser -> Bowser's Kingdom\nmoon -> Moon Kingdom\nmush -> Mushroom Kingdom\ndark -> Dark Side\ndarker -> Darker Side```";
string? stage = Stages.Input2Stage(args[0]);
if (stage == null) {
return "Invalid Stage Name! ```" + Stages.KingdomAliasMapping() + "```";
}
Client[] players = server.Clients.Where(c => c.Connected).ToArray();
@ -562,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 () => {
@ -604,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;
}
@ -651,10 +685,11 @@ Task.Run(() => {
}
}
}
}).ContinueWith(x => { if (x.Exception != null) { consoleLogger.Error(x.Exception.ToString()); } });
}).ContinueWith(logError);
#pragma warning restore CS4014
await listenTask;
await server.Listen(cts.Token);
if (restartRequested) //need to do this here because this needs to happen after the listener closes, and there isn't an
//easy way to sync in the restartserver command without it exiting Main()
{
@ -666,4 +701,4 @@ if (restartRequested) //need to do this here because this needs to happen after
}
else
consoleLogger.Info(unableToStartMsg);
}
}

View File

@ -31,6 +31,7 @@ public class Server {
Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}");
// start sub thread to handle client
try {
#pragma warning disable CS4014
Task.Run(() => HandleSocket(socket))
@ -64,7 +65,7 @@ public class Server {
public static void FillPacket<T>(PacketHeader header, T packet, Memory<byte> memory) where T : struct, IPacket {
Span<byte> data = memory.Span;
header.Serialize(data[..Constants.HeaderSize]);
packet.Serialize(data[Constants.HeaderSize..]);
}
@ -73,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);
@ -107,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();
}
@ -117,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) {
@ -127,10 +130,9 @@ public class Server {
private async void HandleSocket(Socket socket) {
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) {
@ -142,7 +144,7 @@ public class Server {
int size = await socket.ReceiveAsync(readMem[readOffset..readSize], SocketFlags.None);
if (size == 0) {
// treat it as a disconnect and exit
Logger.Info($"Socket {socket.RemoteEndPoint} disconnected.");
Logger.Info($"Socket {remote} disconnected.");
if (socket.Connected) await socket.DisconnectAsync(false);
return false;
}
@ -153,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) {
@ -162,67 +165,101 @@ 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;
}
}
// 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]);
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.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 = false;
// detect and handle reconnections
bool isClientNew = true;
switch (connect.ConnectionType) {
case ConnectPacket.ConnectionTypes.FirstConnection: {
firstConn = true;
if (FindExistingClient(header.Id) is { } newClient) {
if (newClient.Connected) {
newClient.Logger.Info($"Disconnecting already connected client {newClient.Socket?.RemoteEndPoint} for {client.Socket?.RemoteEndPoint}");
newClient.Dispose();
}
newClient.Socket = client.Socket;
client = newClient;
}
break;
}
case ConnectPacket.ConnectionTypes.FirstConnection:
case ConnectPacket.ConnectionTypes.Reconnecting: {
client.Id = header.Id;
if (FindExistingClient(header.Id) is { } newClient) {
if (newClient.Connected) {
newClient.Logger.Info($"Disconnecting already connected client {newClient.Socket?.RemoteEndPoint} for {client.Socket?.RemoteEndPoint}");
newClient.Dispose();
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();
}
newClient.Socket = client.Socket;
client = newClient;
} else {
firstConn = true;
}
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) {
// 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);
client.Id = header.Id;
if (isClientNew) {
// do any cleanup required when it comes to new clients
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);
Parallel.ForEachAsync(toDisconnect, (c, token) => c.Socket!.DisconnectAsync(false, token));
@ -230,26 +267,35 @@ public class Server {
ClientJoined?.Invoke(client, connect);
}
// a known client reconnects, but with a new first connection (e.g. after a restart)
else if (wasFirst) {
client.CleanMetadataOnNewConnection();
}
}
List<Client> otherConnectedPlayers = Clients.FindAll(c => c.Id != header.Id && c.Connected && c.Socket != null);
// for all other clients that are already connected
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
PacketHeader connectHeader = new PacketHeader {
Id = other.Id,
Type = PacketType.Connect,
PacketSize = connect.Size
Id = other.Id,
Type = PacketType.Connect,
PacketSize = connect.Size,
};
connectHeader.Serialize(tempBuffer.Memory.Span[..Constants.HeaderSize]);
ConnectPacket connectPacket = new ConnectPacket {
ConnectionType = ConnectPacket.ConnectionTypes.FirstConnection, // doesn't matter what it is
MaxPlayers = Settings.Instance.Server.MaxPlayers,
ClientName = other.Name
MaxPlayers = Settings.Instance.Server.MaxPlayers,
ClientName = other.Name,
};
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
if (other.CurrentCostume.HasValue) {
connectHeader.Type = PacketType.Costume;
connectHeader.Type = PacketType.Costume;
connectHeader.PacketSize = other.CurrentCostume.Value.Size;
connectHeader.Serialize(tempBuffer.Memory.Span[..Constants.HeaderSize]);
other.CurrentCostume.Value.Serialize(tempBuffer.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + connectHeader.PacketSize)]);
@ -257,25 +303,30 @@ public class Server {
}
tempBuffer.Dispose();
// 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);
}
});
Logger.Info($"Client {client.Name} ({client.Id}/{socket.RemoteEndPoint}) connected.");
} else if (header.Id != client.Id && client.Id != Guid.Empty) {
Logger.Info($"Client {client.Name} ({client.Id}/{remote}) connected.");
// send missing or outdated packets from others to the new client
await ResendPackets(client);
}
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}");
}
if (header.Type == PacketType.Costume) {
CostumePacket costumePacket = new CostumePacket {
BodyName = ""
};
costumePacket.Deserialize(memory.Memory.Span[Constants.HeaderSize..(Constants.HeaderSize + costumePacket.Size)]);
client.CurrentCostume = costumePacket;
}
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;
}
@ -283,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
@ -292,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
@ -305,10 +359,15 @@ public class Server {
memory?.Dispose();
}
disconnect:
Logger.Info($"Client {socket.RemoteEndPoint} ({client.Name}/{client.Id}) disconnected from the server");
// 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");
}
else {
Logger.Info($"Client {remote} disconnected from the server");
}
// Clients.Remove(client)
bool wasConnected = client.Connected;
client.Connected = false;
try {
client.Dispose();
@ -316,11 +375,49 @@ public class Server {
catch { /*lol*/ }
#pragma warning disable CS4014
Task.Run(() => Broadcast(new DisconnectPacket(), client))
.ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } });
if (wasConnected) {
Task.Run(() => Broadcast(new DisconnectPacket(), client))
.ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } });
}
#pragma warning restore CS4014
}
private async Task ResendPackets(Client client) {
async Task trySendPack<T>(Client other, T? packet) where T : struct, IPacket {
if (packet == null) { return; }
try {
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 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");
});
}
private async Task SendEmptyPackets(Client client, Client other) {
await other.Send(new TagPacket {
UpdateType = TagPacket.TagUpdate.State | TagPacket.TagUpdate.Time,
IsIt = false,
Seconds = 0,
Minutes = 0,
}, client);
await other.Send(new CapturePacket {
ModelName = "",
}, client);
}
private static PacketHeader GetHeader(Span<byte> data) {
//no need to error check, the client will disconnect when the packet is invalid :)
PacketHeader header = new PacketHeader();

View File

@ -30,10 +30,10 @@ public class Settings {
LoadHandler?.Invoke();
}
public static void SaveSettings() {
public static void SaveSettings(bool silent = false) {
try {
File.WriteAllText("settings.json", JsonConvert.SerializeObject(Instance, Formatting.Indented, new StringEnumConverter(new CamelCaseNamingStrategy())));
Logger.Info("Saved settings to settings.json");
if (!silent) { Logger.Info("Saved settings to settings.json"); }
}
catch (Exception e) {
Logger.Error($"Failed to save settings.json {e}");
@ -43,7 +43,7 @@ public class Settings {
public ServerTable Server { get; set; } = new ServerTable();
public FlipTable Flip { get; set; } = new FlipTable();
public ScenarioTable Scenario { get; set; } = new ScenarioTable();
public BannedPlayers BanList { get; set; } = new BannedPlayers();
public BanListTable BanList { get; set; } = new BanListTable();
public DiscordTable Discord { get; set; } = new DiscordTable();
public ShineTable Shines { get; set; } = new ShineTable();
public PersistShinesTable PersistShines { get; set; } = new PersistShinesTable();
@ -58,15 +58,16 @@ public class Settings {
public bool MergeEnabled { get; set; } = false;
}
public class BannedPlayers {
public class BanListTable {
public bool Enabled { get; set; } = false;
public List<Guid> Players { get; set; } = new List<Guid>();
public List<string> IpAddresses { get; set; } = new List<string>();
public ISet<Guid> Players { get; set; } = new SortedSet<Guid>();
public ISet<string> IpAddresses { get; set; } = new SortedSet<string>();
public ISet<string> Stages { get; set; } = new SortedSet<string>();
}
public class FlipTable {
public bool Enabled { get; set; } = true;
public List<Guid> Players { get; set; } = new List<Guid>();
public ISet<Guid> Players { get; set; } = new SortedSet<Guid>();
public FlipOptions Pov { get; set; } = FlipOptions.Both;
}
@ -79,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
@ -86,4 +88,4 @@ public class Settings {
public bool Enabled { get; set; } = false;
public string Filename { get; set; } = "./moons.json";
}
}
}

View File

@ -21,24 +21,4 @@ public static class Constants {
.ToDictionary(type => type.GetCustomAttribute<PacketAttribute>()!.Type, type => type);
public static int HeaderSize { get; } = PacketHeader.StaticSize;
public static readonly Dictionary<string, string> MapNames = new Dictionary<string, string>() {
{"cap", "CapWorldHomeStage"},
{"cascade", "WaterfallWorldHomeStage"},
{"sand", "SandWorldHomeStage"},
{"lake", "LakeWorldHomeStage"},
{"wooded", "ForestWorldHomeStage"},
{"cloud", "CloudWorldHomeStage"},
{"lost", "ClashWorldHomeStage"},
{"metro", "CityWorldHomeStage"},
{"sea", "SeaWorldHomeStage"},
{"snow", "SnowWorldHomeStage"},
{"lunch", "LavaWorldHomeStage"},
{"ruined", "BossRaidWorldHomeStage"},
{"bowser", "SkyWorldHomeStage"},
{"moon", "MoonWorldHomeStage"},
{"mush", "PeachWorldHomeStage"},
{"dark", "Special1WorldHomeStage"},
{"darker", "Special2WorldHomeStage"}
};
}
}

278
Shared/Stages.cs Normal file
View File

@ -0,0 +1,278 @@
using System.Collections;
using System.Collections.Specialized;
namespace Shared;
public static class Stages {
public static string? Input2Stage(string input) {
// alias value
if (Alias2Stage.TryGetValue(input.ToLower(), out string? mapName)) {
return mapName;
}
// exact stage value
if (IsStage(input)) {
return input;
}
// force input value with a !
if (input.EndsWith("!")) {
return input.Substring(0, input.Length - 1);
}
return null;
}
public static string KingdomAliasMapping() {
string result = "";
foreach (DictionaryEntry item in Alias2Kingdom) {
result += item.Key + " -> " + item.Value + "\n";
}
return result;
}
public static bool IsAlias(string input) {
return Alias2Stage.ContainsKey(input);
}
public static bool IsStage(string input) {
return Stage2Alias.ContainsKey(input);
}
public static IEnumerable<string> StagesByInput(string input) {
if (IsAlias(input)) {
var stages = Stage2Alias
.Where(e => e.Value == input)
.Select(e => e.Key)
;
foreach (string stage in stages) {
yield return stage;
}
}
else {
string? stage = Input2Stage(input);
if (stage != null) {
yield return stage;
}
}
}
public static readonly Dictionary<string, string> Alias2Stage = new Dictionary<string, string>() {
{ "cap", "CapWorldHomeStage" },
{ "cascade", "WaterfallWorldHomeStage" },
{ "sand", "SandWorldHomeStage" },
{ "lake", "LakeWorldHomeStage" },
{ "wooded", "ForestWorldHomeStage" },
{ "cloud", "CloudWorldHomeStage" },
{ "lost", "ClashWorldHomeStage" },
{ "metro", "CityWorldHomeStage" },
{ "snow", "SnowWorldHomeStage" },
{ "sea", "SeaWorldHomeStage" },
{ "lunch", "LavaWorldHomeStage" },
{ "ruined", "BossRaidWorldHomeStage" },
{ "bowser", "SkyWorldHomeStage" },
{ "moon", "MoonWorldHomeStage" },
{ "mush", "PeachWorldHomeStage" },
{ "dark", "Special1WorldHomeStage" },
{ "darker", "Special2WorldHomeStage" },
{ "odyssey", "HomeShipInsideStage" },
};
public static readonly OrderedDictionary Alias2Kingdom = new OrderedDictionary() {
{ "cap", "Cap Kingdom" },
{ "cascade", "Cascade Kingdom" },
{ "sand", "Sand Kingdom" },
{ "lake", "Lake Kingdom" },
{ "wooded", "Wooded Kingdom" },
{ "cloud", "Cloud Kingdom" },
{ "lost", "Lost Kingdom" },
{ "metro", "Metro Kingdom" },
{ "snow", "Snow Kingdom" },
{ "sea", "Seaside Kingdom" },
{ "lunch", "Luncheon Kingdom" },
{ "ruined", "Ruined Kingdom" },
{ "bowser", "Bowser's Kingdom" },
{ "moon", "Moon Kingdom" },
{ "mush", "Mushroom Kingdom" },
{ "dark", "Dark Side" },
{ "darker", "Darker Side" },
{ "odyssey", "Odyssey" },
};
public static readonly Dictionary<string, string> Stage2Alias = new Dictionary<string, string>() {
{ "CapWorldHomeStage" , "cap" },
{ "CapWorldTowerStage" , "cap" },
{ "FrogSearchExStage" , "cap" },
{ "PoisonWaveExStage" , "cap" },
{ "PushBlockExStage" , "cap" },
{ "RollingExStage" , "cap" },
{ "WaterfallWorldHomeStage" , "cascade" },
{ "TrexPoppunExStage" , "cascade" },
{ "Lift2DExStage" , "cascade" },
{ "WanwanClashExStage" , "cascade" },
{ "CapAppearExStage" , "cascade" },
{ "WindBlowExStage" , "cascade" },
{ "SandWorldHomeStage" , "sand" },
{ "SandWorldShopStage" , "sand" },
{ "SandWorldSlotStage" , "sand" },
{ "SandWorldVibrationStage" , "sand" },
{ "SandWorldSecretStage" , "sand" },
{ "SandWorldMeganeExStage" , "sand" },
{ "SandWorldKillerExStage" , "sand" },
{ "SandWorldPressExStage" , "sand" },
{ "SandWorldSphinxExStage" , "sand" },
{ "SandWorldCostumeStage" , "sand" },
{ "SandWorldPyramid000Stage" , "sand" },
{ "SandWorldPyramid001Stage" , "sand" },
{ "SandWorldUnderground000Stage" , "sand" },
{ "SandWorldUnderground001Stage" , "sand" },
{ "SandWorldRotateExStage" , "sand" },
{ "MeganeLiftExStage" , "sand" },
{ "RocketFlowerExStage" , "sand" },
{ "WaterTubeExStage" , "sand" },
{ "LakeWorldHomeStage" , "lake" },
{ "LakeWorldShopStage" , "lake" },
{ "FastenerExStage" , "lake" },
{ "TrampolineWallCatchExStage" , "lake" },
{ "GotogotonExStage" , "lake" },
{ "FrogPoisonExStage" , "lake" },
{ "ForestWorldHomeStage" , "wooded" },
{ "ForestWorldWaterExStage" , "wooded" },
{ "ForestWorldTowerStage" , "wooded" },
{ "ForestWorldBossStage" , "wooded" },
{ "ForestWorldBonusStage" , "wooded" },
{ "ForestWorldCloudBonusExStage" , "wooded" },
{ "FogMountainExStage" , "wooded" },
{ "RailCollisionExStage" , "wooded" },
{ "ShootingElevatorExStage" , "wooded" },
{ "ForestWorldWoodsStage" , "wooded" },
{ "ForestWorldWoodsTreasureStage" , "wooded" },
{ "ForestWorldWoodsCostumeStage" , "wooded" },
{ "PackunPoisonExStage" , "wooded" },
{ "AnimalChaseExStage" , "wooded" },
{ "KillerRoadExStage" , "wooded" },
{ "CloudWorldHomeStage" , "cloud" },
{ "FukuwaraiKuriboStage" , "cloud" },
{ "Cube2DExStage" , "cloud" },
{ "ClashWorldHomeStage" , "lost" },
{ "ClashWorldShopStage" , "lost" },
{ "ImomuPoisonExStage" , "lost" },
{ "JangoExStage" , "lost" },
{ "CityWorldHomeStage" , "metro" },
{ "CityWorldMainTowerStage" , "metro" },
{ "CityWorldFactoryStage" , "metro" },
{ "CityWorldShop01Stage" , "metro" },
{ "CityWorldSandSlotStage" , "metro" },
{ "CityPeopleRoadStage" , "metro" },
{ "PoleGrabCeilExStage" , "metro" },
{ "TrexBikeExStage" , "metro" },
{ "PoleKillerExStage" , "metro" },
{ "Note2D3DRoomExStage" , "metro" },
{ "ShootingCityExStage" , "metro" },
{ "CapRotatePackunExStage" , "metro" },
{ "RadioControlExStage" , "metro" },
{ "ElectricWireExStage" , "metro" },
{ "Theater2DExStage" , "metro" },
{ "DonsukeExStage" , "metro" },
{ "SwingSteelExStage" , "metro" },
{ "BikeSteelExStage" , "metro" },
{ "SnowWorldHomeStage" , "snow" },
{ "SnowWorldTownStage" , "snow" },
{ "SnowWorldShopStage" , "snow" },
{ "SnowWorldLobby000Stage" , "snow" },
{ "SnowWorldLobby001Stage" , "snow" },
{ "SnowWorldRaceTutorialStage" , "snow" },
{ "SnowWorldRace000Stage" , "snow" },
{ "SnowWorldRace001Stage" , "snow" },
{ "SnowWorldCostumeStage" , "snow" },
{ "SnowWorldCloudBonusExStage" , "snow" },
{ "IceWalkerExStage" , "snow" },
{ "IceWaterBlockExStage" , "snow" },
{ "ByugoPuzzleExStage" , "snow" },
{ "IceWaterDashExStage" , "snow" },
{ "SnowWorldLobbyExStage" , "snow" },
{ "SnowWorldRaceExStage" , "snow" },
{ "SnowWorldRaceHardExStage" , "snow" },
{ "KillerRailCollisionExStage" , "snow" },
{ "SeaWorldHomeStage" , "sea" },
{ "SeaWorldUtsuboCaveStage" , "sea" },
{ "SeaWorldVibrationStage" , "sea" },
{ "SeaWorldSecretStage" , "sea" },
{ "SeaWorldCostumeStage" , "sea" },
{ "SeaWorldSneakingManStage" , "sea" },
{ "SenobiTowerExStage" , "sea" },
{ "CloudExStage" , "sea" },
{ "WaterValleyExStage" , "sea" },
{ "ReflectBombExStage" , "sea" },
{ "TogezoRotateExStage" , "sea" },
{ "LavaWorldHomeStage" , "lunch" },
{ "LavaWorldUpDownExStage" , "lunch" },
{ "LavaBonus1Zone" , "lunch" },
{ "LavaWorldShopStage" , "lunch" },
{ "LavaWorldCostumeStage" , "lunch" },
{ "ForkExStage" , "lunch" },
{ "LavaWorldExcavationExStage" , "lunch" },
{ "LavaWorldClockExStage" , "lunch" },
{ "LavaWorldBubbleLaneExStage" , "lunch" },
{ "LavaWorldTreasureStage" , "lunch" },
{ "GabuzouClockExStage" , "lunch" },
{ "CapAppearLavaLiftExStage" , "lunch" },
{ "LavaWorldFenceLiftExStage" , "lunch" },
{ "BossRaidWorldHomeStage" , "ruined" },
{ "DotTowerExStage" , "ruined" },
{ "BullRunExStage" , "ruined" },
{ "SkyWorldHomeStage" , "bowser" },
{ "SkyWorldShopStage" , "bowser" },
{ "SkyWorldCostumeStage" , "bowser" },
{ "SkyWorldCloudBonusExStage" , "bowser" },
{ "SkyWorldTreasureStage" , "bowser" },
{ "JizoSwitchExStage" , "bowser" },
{ "TsukkunRotateExStage" , "bowser" },
{ "KaronWingTowerStage" , "bowser" },
{ "TsukkunClimbExStage" , "bowser" },
{ "MoonWorldHomeStage" , "moon" },
{ "MoonWorldCaptureParadeStage" , "moon" },
{ "MoonWorldWeddingRoomStage" , "moon" },
{ "MoonWorldKoopa1Stage" , "moon" },
{ "MoonWorldBasementStage" , "moon" },
{ "MoonWorldWeddingRoom2Stage" , "moon" },
{ "MoonWorldKoopa2Stage" , "moon" },
{ "MoonWorldShopRoom" , "moon" },
{ "MoonWorldSphinxRoom" , "moon" },
{ "MoonAthleticExStage" , "moon" },
{ "Galaxy2DExStage" , "moon" },
{ "PeachWorldHomeStage" , "mush" },
{ "PeachWorldShopStage" , "mush" },
{ "PeachWorldCastleStage" , "mush" },
{ "PeachWorldCostumeStage" , "mush" },
{ "FukuwaraiMarioStage" , "mush" },
{ "DotHardExStage" , "mush" },
{ "YoshiCloudExStage" , "mush" },
{ "PeachWorldPictureBossMagmaStage" , "mush" },
{ "RevengeBossMagmaStage" , "mush" },
{ "PeachWorldPictureGiantWanderBossStage" , "mush" },
{ "RevengeGiantWanderBossStage" , "mush" },
{ "PeachWorldPictureBossKnuckleStage" , "mush" },
{ "RevengeBossKnuckleStage" , "mush" },
{ "PeachWorldPictureBossForestStage" , "mush" },
{ "RevengeForestBossStage" , "mush" },
{ "PeachWorldPictureMofumofuStage" , "mush" },
{ "RevengeMofumofuStage" , "mush" },
{ "PeachWorldPictureBossRaidStage" , "mush" },
{ "RevengeBossRaidStage" , "mush" },
{ "Special1WorldHomeStage" , "dark" },
{ "Special1WorldTowerStackerStage" , "dark" },
{ "Special1WorldTowerBombTailStage" , "dark" },
{ "Special1WorldTowerFireBlowerStage" , "dark" },
{ "Special1WorldTowerCapThrowerStage" , "dark" },
{ "KillerRoadNoCapExStage" , "dark" },
{ "PackunPoisonNoCapExStage" , "dark" },
{ "BikeSteelNoCapExStage" , "dark" },
{ "ShootingCityYoshiExStage" , "dark" },
{ "SenobiTowerYoshiExStage" , "dark" },
{ "LavaWorldUpDownYoshiExStage" , "dark" },
{ "Special2WorldHomeStage" , "darker" },
{ "Special2WorldLavaStage" , "darker" },
{ "Special2WorldCloudStage" , "darker" },
{ "Special2WorldKoopaStage" , "darker" },
{ "HomeShipInsideStage" , "odyssey" },
};
}

51
docker-build.sh Executable file
View File

@ -0,0 +1,51 @@
#!/bin/bash
set -euo pipefail
if [[ "$#" == "0" ]] || [[ "$#" > "1" ]] || ! [[ "$1" =~ ^(all|x64|arm|arm64|win64)$ ]] ; then
echo "Usage: docker-build.sh {all|x64|arm|arm64|win64}"
exit 1
fi
DIR=$(dirname "$(realpath $0)")
cd "$DIR"
declare -A archs=(
["x64"]="linux-x64"
["arm"]="linux-arm"
["arm64"]="linux-arm64"
["win64"]="win-x64"
)
for sub in "${!archs[@]}" ; do
arch="${archs[$sub]}"
if [[ "$1" != "all" ]] && [[ "$1" != "$sub" ]] ; then
continue
fi
docker run \
-u `id -u`:`id -g` \
-v "/$DIR/"://app/ \
-w //app/ \
-e DOTNET_CLI_HOME=//app/cache/ \
-e XDG_DATA_HOME=//app/cache/ \
mcr.microsoft.com/dotnet/sdk:6.0 \
dotnet publish \
./Server/Server.csproj \
-r $arch \
-c Release \
-o /app/bin/$sub/ \
--self-contained \
-p:publishSingleFile=true \
;
filename="Server"
ext=""
if [[ "$sub" == "arm" ]] ; then filename="Server.arm";
elif [[ "$sub" == "arm64" ]] ; then filename="Server.arm64";
elif [[ "$sub" == "win64" ]] ; then filename="Server.exe"; ext=".exe";
fi
mv ./bin/$sub/Server$ext ./bin/$filename
rm -rf ./bin/$sub/
done

View File

@ -6,6 +6,7 @@ services:
#build: .
#user: 1000:1000
stdin_open: true
restart: unless-stopped
ports:
- 1027:1027
volumes: