Compare commits

...

82 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
Jack Baron e14616030c move clear task 2022-09-05 18:56:24 -06:00
Jack Baron 24a34c35a1 ignore file not found errors 2022-09-05 18:56:24 -06:00
TheUbMunster 783b876e09 You can run commands in the command channel or log channel 2022-08-22 18:58:42 -06:00
TheUbMunster 4d743b3e1b Made changes for pr, cleaned up formatting 2022-08-22 18:58:42 -06:00
TheUbMunster 67a740dec9 By default bot does not respond to dm with error message 2022-08-22 18:58:42 -06:00
TheUbMunster 4c4ce35f14 Removed some log messages as they would trigger before prefix checking, would start spamming the server if they remained 2022-08-22 18:58:42 -06:00
TheUbMunster 86fb18962f Differentiated command channel and log channel 2022-08-22 18:58:42 -06:00
TheUbMunster 06e3b5d40a Made debug message simpler 2022-08-22 18:58:42 -06:00
TheUbMunster 02f936816c Clarified log message 2022-08-22 18:58:42 -06:00
TheUbMunster 82e94334c9 added log message that prints the channel the command came from and the channel commands are accepted from 2022-08-22 18:58:42 -06:00
TheUbMunster b6e634ada4 changed isPrivate check to type check of "is DiscordDmChannel" 2022-08-22 18:58:42 -06:00
TheUbMunster d828a704c1 Updated with Sanae's no private channel logic.
Behavior: No DM'ing commands under any circumstance
if Config.LogChannel == null, commands can be in any non-private channel
if Config.LogChannel != null, commands can only be in the log channel
2022-08-22 18:58:42 -06:00
TheUbMunster 08d1020770 You can no longer dm a bot for a command (has been tested) 2022-08-22 18:58:42 -06:00
Sanae 9c01f30c5e Add the ability to disable shine sync 2022-08-20 23:31:31 -06:00
Piplup cee8bd580f cleared up some confusion 2022-08-10 15:04:00 -06:00
Piplup fa1db28037 added PersistShines 2022-08-10 15:04:00 -06:00
raym55 c86be55717 PIPERS 2022-08-10 15:03:34 -06:00
Piplup 31361d54ca a little change to the readme 2022-08-02 18:01:59 -06:00
Piplup 427acbcf88 updated readme with tty inputs 2022-08-02 18:01:59 -06:00
Piplup 75eea98d8e added TTY input 2022-08-02 18:01:59 -06:00
Sanae e7a3347a37
Merge pull request #19 from TheUbMunster/restart-command 2022-07-29 13:19:04 -06:00
Sanae fcea1d898e
Merge pull request #22 from TheUbMunster/fix-discord-2000-charlimit 2022-07-29 12:55:50 -06:00
TheUbMunster 8baf75155e removed testing code 2022-07-29 12:55:14 -06:00
TheUbMunster fc2f9b1417 "banning the same person multiple times" should now be fixed 2022-07-29 12:55:14 -06:00
TheUbMunster 33fdd69e15 readded persisten shinies 2022-07-29 12:55:14 -06:00
TheUbMunster 5af8001398 Fixed adding quotes around Ambiguous for "user": , fixed showing correct casing of arguments. 2022-07-29 12:55:14 -06:00
TheUbMunster ba02c88b13 added !* variants for ban, crash, rejoin 2022-07-29 12:55:14 -06:00
TheUbMunster 3b8ba17217 fix #14 2022-07-29 12:55:01 -06:00
TheUbMunster 95c918b5c4 Removed fire-and-forget warnings, null-forgave some metadata. 2022-07-29 12:55:01 -06:00
TheUbMunster 8350133d49 Fixed "Couldn't determine a response for this query" and removed testing command. 2022-07-29 11:16:24 -06:00
TheUbMunster d3b8ede229 Messages are now split up into lengths small enough to not cause the discord bot to throw an exception with too long a message. 2022-07-28 19:16:24 -06:00
TheUbMunster 216db7ba15 Merge branch 'restart-command' of https://github.com/TheUbMunster/SmoOnlineServer into restart-command 2022-07-28 18:00:22 -06:00
TheUbMunster 3a8e7ffb93 Changed "Running (pid)" to "Server Running on (pid)" 2022-07-28 18:00:18 -06:00
TheUbMunster 12c84792e8 Changed "Running (pid)" to "Server Running on (pid)" 2022-07-28 17:57:56 -06:00
TheUbMunster d2c8c8d3cd Fixed race condition with restart to make sure it only happens after the listener closes. 2022-07-28 00:11:40 -06:00
TheUbMunster 068cc7c06d Added restart server command 2022-07-27 23:56:50 -06:00
Sanae 66114bdecb
Always update client's name on connect
Since the client variable would be changed in a reconnect, the name is not set on the old client object.
2022-07-27 14:00:58 -06:00
TheUbMunster db05586dd0 Added quotable arguments, potential fix for ban * not banning anyone. 2022-07-27 12:14:56 -06:00
TheUbMunster d604c59a77 Added quotable arguments, potential fix for ban * not banning anyone. 2022-07-27 12:14:56 -06:00
TheUbMunster 5ca5b10db8 Fixed backwards ternaries in ban, crash, rejoin, 0 arguments no longer valid. 2022-07-27 12:14:56 -06:00
Jack Baron f305c3001b save/load moons from file 2022-07-22 18:56:00 -06:00
Jack Baron 8d90f50fa9 add moon persistence settings 2022-07-22 18:56:00 -06:00
Sanae f948a05bd3
Add workflow_dispatch trigger to deploy action
for Build and Deploy
2022-07-17 13:28:09 -06:00
Robin C. Ladiges ccccdecb6a
Provide docker image (#6)
* add Dockerfile

* add docker-compose.yml

* Github workflow to build and deploy docker image

* workdir /data/ instead of /app/

* use a local directory instead of a named volume

This makes the settings.json more accessible from the outside by
default, but is less portable.

The -v command with $PWD might not work on native Windows shells, but
rather wants an absolute Windows path like C:\User\... or /c/User/...

And on Linux, because the /data/ directory and the settings.json will be
owned by root. Though that can be changed.

* more docker-compose command examples

* add linux/arm/v7 ; improve build & runtime

* fix: just arm not arm32

* test docker build for PRs

* back to the microsoft runtime
2022-07-17 13:00:31 -06:00
Piplup b5a6a87649
Add blurbs about settings to readme (#11)
Co-authored-by: Sanae <32604996+Sanae6@users.noreply.github.com>
2022-07-12 20:46:27 -06:00
CraftyBoss 967a89a55b Merge branch 'master' of https://github.com/Sanae6/SmoOnlineServer 2022-07-08 23:13:44 +00:00
CraftyBoss 11015bd445 only send game packet on join to connected clients 2022-07-08 23:13:35 +00:00
Piplup aec13bb4a6
Added a simple systemd service (#5)
Co-authored-by: Sanae <32604996+Sanae6@users.noreply.github.com>
2022-07-01 12:10:15 -06:00
Sanae ead6292f94
Merge pull request #7 from piplup55/botupdates 2022-06-29 13:31:56 -06:00
raym55 787491456f Update Program.cs 2022-06-29 06:54:14 +01:00
Piplup cbfc24abfd
Merge branch 'Sanae6:master' into botupdates 2022-06-29 06:01:26 +01:00
Sanae 72061215ce Add wildcard to rejoin, crash, ban commands 2022-06-28 14:34:03 -06:00
Sanae f9c20e73a8 Fix accidental squashed command handler 2022-06-20 13:10:35 -06:00
Piplup d065253699
Merge branch 'Sanae6:master' into botupdates 2022-06-20 20:02:33 +01:00
ecumber 67449ffa54 Add date to log messages 2022-06-20 12:57:45 -06:00
Sanae b5825b537f Implements Added Bot Reconnect Command (#1)
Add dscrestart command
Update Program.cs
2022-06-20 12:55:01 -06:00
Sanae 60249feef8 Remove the "Console log:" from discord logs 2022-06-20 12:53:30 -06:00
Sanae 5d78814620 Reformat Program.cs 2022-06-20 12:52:46 -06:00
raym55 075fbd1bb4 Update Program.cs 2022-06-20 06:36:02 +01:00
20 changed files with 1738 additions and 271 deletions

4
.dockerignore Normal file
View File

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

107
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,107 @@
name: Build and deploy
on:
push:
branches:
- '**'
tags:
- '*.*.*'
- 'v*.*.*'
workflow_dispatch: # or manual
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: ${{ github.repository_owner }}/smo-online-server
jobs:
deploy:
runs-on: ubuntu-latest
steps:
-
name: Environment
run: |
IMAGE=`echo ${{ env.IMAGE }} | tr '[:upper:]' '[:lower:]'`
echo "IMAGE=$IMAGE" >>$GITHUB_ENV
-
name: Checkout
uses: actions/checkout@v3
-
id: meta
name: Docker meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ env.IMAGE }}
flavor: |
latest=false
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{major}}.{{minor}}.{{patch}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=ref,event=branch
labels: |
org.opencontainers.image.licenses=UNLICENSED
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: amd64,arm64,arm
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Login to GHCR
uses: docker/login-action@v2
with:
registry : ghcr.io
username : ${{ github.repository_owner }}
password : ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
uses: docker/build-push-action@v3
with:
pull : true
push : true
context : .
file : ./Dockerfile
tags : ${{ steps.meta.outputs.tags }}
labels : ${{ steps.meta.outputs.labels }}
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

37
.github/workflows/test-pr.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: Test PR
on:
pull_request:
branches:
- '**'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v3
-
name: Set up QEMU
uses: docker/setup-qemu-action@v2
with:
platforms: amd64,arm64,arm
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
-
name: Build
uses: docker/build-push-action@v3
with:
pull : true
push : false
context : .
file : ./Dockerfile
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

4
.gitignore vendored
View File

@ -5,3 +5,7 @@ riderModule.iml
/_ReSharper.Caches/
.idea/
settings.json
.vs/
/cache/
/data/

48
Dockerfile Normal file
View File

@ -0,0 +1,48 @@
################################################################################
################################################################## build ###
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:6.0 as build
WORKDIR /app/
COPY ./Shared/Shared.csproj ./Shared/Shared.csproj
COPY ./Server/Server.csproj ./Server/Server.csproj
ARG TARGETARCH
# Download NuGet dependencies
RUN dotnet restore \
./Server/Server.csproj \
-r debian.11-`echo $TARGETARCH | sed 's@^amd@x@'` \
;
COPY ./Shared/ ./Shared/
COPY ./Server/ ./Server/
# Build application binary
RUN dotnet publish \
./Server/Server.csproj \
-r debian.11-`echo $TARGETARCH | sed 's@^amd@x@'` \
-c Release \
-o ./out/ \
--no-restore \
--self-contained \
-p:publishSingleFile=true \
;
################################################################## build ###
################################################################################
################################################################ runtime ###
FROM mcr.microsoft.com/dotnet/runtime:6.0 as runtime
# Copy application binary from build stage
COPY --from=build /app/out/ /app/
ENTRYPOINT [ "/app/Server" ]
EXPOSE 1027/tcp
WORKDIR /data/
VOLUME /data/
################################################################ runtime ###
################################################################################

View File

@ -4,6 +4,7 @@ The official server for the [Super Mario Odyssey: Online](https://github.com/Cra
## Windows Setup
1. Download latest build from [Releases](https://github.com/Sanae6/SmoOnlineServer/releases)
2. Run `Server.exe`
3. `settings.json` is autogenerated in step 2, modify it however you'd like.
@ -20,6 +21,46 @@ dotnet run --project Server/Server.csproj -c Release
```
If you ran `dotnet build` instead of `dotnet run`, you can find the binary at `Server/bin/net6.0/Release/Server.exe`
## Running under systemd
If you have systemd, you can use the existing systemd serivce.
```shell
cp smo.serivce /etc/systemd/system/smo.service
# edit ExecStart to your path for the server executable and change WorkingDirectory to the server directory
chmod +x filepath to the server executable
systemctl enable --now smo.service
# for TTY access i would recommand conspy but there is also reptyr, chvt
```
## Run docker image
If you have [docker](https://docs.docker.com/) on your system, you can use the existing docker image.
That way you don't have to build this server yourself or manually handle executables.
```shell
docker run --rm -it -p 1027:1027 -v "/$PWD/data/://data/" ghcr.io/sanae6/smo-online-server
# on Windows, depending on the shell you're using, $PWD might not work. Use an absolute path instead.
```
To always check for and use the latest server version you can add `--pull=always` to the options.
Alternatively there's a `docker-compose.yml` for [docker-compose](https://docs.docker.com/compose/) to simplify the command line options:
```shell
# update server
docker-compose pull
# start server
docker-compose up -d
# open the server cli
docker attach `docker-compose ps -q` --sig-proxy=false
# watch server logs
docker-compose logs --tail=20 --follow
# stop server
docker-compose stop
```
## Commands
@ -27,4 +68,21 @@ Run `help` to get what commands are available in the server console.
Run the `loadsettings` command in the console to update the settings without restarting.
Server address and port will require a server restart, but everything else should update when you run `loadsettings`.
[//]: # (TODO: Document all commands, possibly rename them too.)
[//]: # (TODO: Document all commands, possibly rename them too.)
## Settings
### Server
Address: the ip address of the server, default: 0.0.0.0 # this shouldn't be changed
Port: the port of the server, default 1027
Maxplayers: the max amount of players that can join, default: 8
Flip: flips the player upside down, defaults: enabled: true, pov: both
Scenario: sync's scenario's for all players on the server, default: false
Banlist: banned people are unable to join the server, default: false
PersistShines/Moons: Allows the server to remember moon progress across crashes/restarts
### Discord
Note: Token and LogChannel needs to a string puts "" around it
Token: the token of the bot you want to load into, default: null
Prefix: the bot prefix to be used, default: $
LogChannel: logs the server console to that channel, default: null

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;
@ -20,7 +22,7 @@ public class Client : IDisposable {
public Guid Id;
public Socket? Socket;
public Server Server { get; init; }
public Server Server { get; init; } = null!; //init'd in object initializer
public Logger Logger { get; }
public Client(Socket socket) {
@ -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;
}
@ -72,4 +119,15 @@ public class Client : IDisposable {
public static bool operator !=(Client? left, Client? right) {
return !(left == right);
}
}
public override bool Equals(object? obj) {
if (obj is Client)
return this == (Client)obj;
else
return false;
}
public override int GetHashCode() {
return Id.GetHashCode(); //relies upon same info as == operator.
}
}

View File

@ -1,4 +1,5 @@
namespace Server;
using System.Text;
namespace Server;
public static class CommandHandler {
public delegate Response Handler(string[] args);
@ -19,10 +20,39 @@ public static class CommandHandler {
}
}
public static Response GetResult(string input) {
/// <summary>
/// Modified by <b>TheUbMunster</b>
/// </summary>
public static Response GetResult(string input)
{
try {
string[] args = input.Split(' ');
if (args.Length == 0) return "No command entered, see help command for valid commands";
//this part is to allow single arguments that contain spaces (since the game seems to be able to handle usernames with spaces, we need to as well)
List<string> newArgs = new List<string>();
newArgs.Add(args[0]);
for (int i = 1; i < args.Length; i++) {
if (args[i].Length == 0) continue; //empty string (>1 whitespace between arguments).
else if (args[i][0] == '\"') {
//concatenate args until a string ends with a quote
StringBuilder sb = new StringBuilder();
i--; //fix off-by-one issue
do
{
i++;
sb.Append(args[i] + " "); //add space back removed by the string.Split(' ')
if (i >= args.Length) {
return "Unmatching quotes, make sure that whenever quotes are used, another quote is present to close it (no action was performed).";
}
} while (args[i][^1] != '\"');
newArgs.Add(sb.ToString(1, sb.Length - 3)); //remove quotes and extra space at the end.
}
else
{
newArgs.Add(args[i]);
}
}
args = newArgs.ToArray();
string commandName = args[0];
return Handlers.TryGetValue(commandName, out Handler? handler) ? handler(args[1..]) : $"Invalid command {args[0]}, see help command for valid commands";
}

View File

@ -11,42 +11,87 @@ public class DiscordBot {
private Settings.DiscordTable Config => Settings.Instance.Discord;
private string Prefix => Config.Prefix;
private readonly Logger Logger = new Logger("Discord");
private DiscordChannel? CommandChannel;
private DiscordChannel? LogChannel;
private bool Reconnecting;
public DiscordBot() {
Token = Config.Token;
Logger.AddLogHandler(Log);
CommandHandler.RegisterCommand("dscrestart", _ => {
// this should be async'ed but i'm lazy
Reconnecting = true;
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");
Settings.LoadHandler += SettingsLoadHandler;
}
private async Task Reconnect() {
if (DiscordClient != null) // usually null prop works, not here though...`
await DiscordClient.DisconnectAsync();
await Run();
}
private async void SettingsLoadHandler() {
try {
if (DiscordClient == null || Token != Config.Token)
Run();
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.LogChannel}\"");
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);
}
}
}
private static List<string> SplitMessage(string message, int maxSizePerElem = 2000)
{
List<string> result = new List<string>();
for (int i = 0; i < message.Length; i += maxSizePerElem)
{
result.Add(message.Substring(i, message.Length - i < maxSizePerElem ? message.Length - i : maxSizePerElem));
}
return result;
}
private async void Log(string source, string level, string text, ConsoleColor _) {
try {
if (DiscordClient != null && LogChannel != null) {
await DiscordClient.SendMessageAsync(LogChannel,
$"Console log:```{Logger.PrefixNewLines(text, $"{level} [{source}]")}```");
foreach (string mesg in SplitMessage(Logger.PrefixNewLines(text, $"{level} [{source}]"), 1994)) //room for 6 '`'
await DiscordClient.SendMessageAsync(LogChannel, $"```{mesg}```");
}
} catch (Exception e) {
// don't log again, it'll just stack overflow the server!
if (Reconnecting) return; // skip if reconnecting
await Console.Error.WriteLineAsync("Exception in discord logger");
await Console.Error.WriteLineAsync(e.ToString());
}
}
public async void Run() {
public async Task Run() {
Token = Config.Token;
DiscordClient?.Dispose();
if (Config.Token == null) {
@ -61,18 +106,37 @@ public class DiscordBot {
});
await DiscordClient.ConnectAsync(new DiscordActivity("Hide and Seek", ActivityType.Competing));
SettingsLoadHandler();
string mentionPrefix = $"{DiscordClient.CurrentUser.Mention} ";
Logger.Info(
$"Discord bot logged in as {DiscordClient.CurrentUser.Username}#{DiscordClient.CurrentUser.Discriminator}");
Reconnecting = false;
string mentionPrefix = $"{DiscordClient.CurrentUser.Mention}";
DiscordClient.MessageCreated += async (_, args) => {
if (args.Author.IsCurrent) return; //dont respond to commands from ourselves (prevent "sql-injection" esq attacks)
//prevent commands via dm and non-public channels
if (CommandChannel == null) {
if (args.Channel is DiscordDmChannel)
return; //no dm'ing the bot allowed!
}
else if (args.Channel.Id != CommandChannel.Id && (LogChannel != null && args.Channel.Id != LogChannel.Id))
return;
//run command
try {
DiscordMessage msg = args.Message;
if (msg.Content.StartsWith(Prefix)) {
string? resp = null;
if (string.IsNullOrEmpty(Prefix)) {
await msg.Channel.TriggerTypingAsync();
await msg.RespondAsync(string.Join('\n',
CommandHandler.GetResult(msg.Content[Prefix.Length..]).ReturnStrings));
resp = string.Join('\n', CommandHandler.GetResult(msg.Content).ReturnStrings);
} else if (msg.Content.StartsWith(Prefix)) {
await msg.Channel.TriggerTypingAsync();
resp = string.Join('\n', CommandHandler.GetResult(msg.Content[Prefix.Length..]).ReturnStrings);
} else if (msg.Content.StartsWith(mentionPrefix)) {
await msg.Channel.TriggerTypingAsync();
await msg.RespondAsync(string.Join('\n',
CommandHandler.GetResult(msg.Content[mentionPrefix.Length..]).ReturnStrings));
resp = string.Join('\n', CommandHandler.GetResult(msg.Content[mentionPrefix.Length..].TrimStart()).ReturnStrings);
}
if (resp != null)
{
foreach (string mesg in SplitMessage(resp))
await msg.RespondAsync(mesg);
}
} catch (Exception e) {
Logger.Error(e);
@ -93,4 +157,4 @@ public class DiscordBot {
Logger.Error(e);
}
}
}
}

View File

@ -2,6 +2,7 @@
using System.Net;
using System.Numerics;
using System.Text;
using System.Text.Json;
using Server;
using Shared;
using Shared.Packet.Packets;
@ -10,51 +11,86 @@ using Timer = System.Timers.Timer;
Server.Server server = new Server.Server();
HashSet<int> shineBag = new HashSet<int>();
CancellationTokenSource cts = new CancellationTokenSource();
Task listenTask = server.Listen(cts.Token);
bool restartRequested = false;
Logger consoleLogger = new Logger("Console");
DiscordBot bot = new DiscordBot();
bot.Run();
await bot.Run();
async Task PersistShines()
{
if (!Settings.Instance.PersistShines.Enabled)
{
return;
}
try
{
string shineJson = JsonSerializer.Serialize(shineBag);
await File.WriteAllTextAsync(Settings.Instance.PersistShines.Filename, shineJson);
}
catch (Exception ex)
{
consoleLogger.Error(ex);
}
}
async Task LoadShines()
{
if (!Settings.Instance.PersistShines.Enabled)
{
return;
}
try
{
string shineJson = await File.ReadAllTextAsync(Settings.Instance.PersistShines.Filename);
var loadedShines = JsonSerializer.Deserialize<HashSet<int>>(shineJson);
if (loadedShines is not null) shineBag = loadedShines;
}
catch (FileNotFoundException)
{
// Ignore
}
catch (Exception ex)
{
consoleLogger.Error(ex);
}
}
// Load shines table from file
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["scenario"] = (byte?) 0;
c.Metadata["2d"] = false;
c.Metadata["speedrun"] = false;
foreach (Client client in server.Clients.Where(client => client.Metadata.ContainsKey("lastGamePacket")).ToArray()) {
try {
c.Send((GamePacket) client.Metadata["lastGamePacket"]!, client).Wait();
}
catch {
// lol who gives a fuck
}
}
};
async Task ClientSyncShineBag(Client client) {
if (!Settings.Instance.Shines.Enabled) return;
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 {
} catch {
// errors that can happen when sending will crash the server :)
}
}
async void SyncShineBag() {
try {
await Parallel.ForEachAsync(server.Clients.ToArray(), async (client, _) => await ClientSyncShineBag(client));
}
catch {
await PersistShines();
await Parallel.ForEachAsync(server.ClientsConnected.ToArray(), async (client, _) => await ClientSyncShineBag(client));
} catch {
// errors that can happen shines change will crash the server :)
}
}
@ -67,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;
@ -92,27 +165,58 @@ server.PacketHandler = (c, p) => {
});
break;
}
if (Settings.Instance.Scenario.MergeEnabled) {
server.BroadcastReplace(gamePacket, c, (from, to, gp) => {
gp.ScenarioNum = (byte?) to.Metadata["scenario"] ?? 200;
to.Send(gp, from);
#pragma warning disable CS4014
to.Send(gp, from).ContinueWith(logError);
#pragma warning restore CS4014
});
return false;
}
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);
if ((tagPacket.UpdateType & TagPacket.TagUpdate.Time) != 0)
c.Metadata["time"] = new Time(tagPacket.Minutes, tagPacket.Seconds, DateTime.Now);
break;
}
case CostumePacket:
ClientSyncShineBag(c);
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"];
ConcurrentBag<int> playerBag = (ConcurrentBag<int>)c.Metadata["shineSync"]!;
shineBag.Add(shinePacket.ShineId);
if (playerBag.Contains(shinePacket.ShineId)) break;
c.Logger.Info($"Got moon {shinePacket.ShineId}");
@ -120,116 +224,165 @@ 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));
server.Broadcast(playerPacket, c);
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));
}
to.Send(sp, from);
});
return false;
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(logError);
#pragma warning restore CS4014
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(logError);
#pragma warning restore CS4014
});
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] == "*") {
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)
|| (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] == "!*") {
toActUpon.Remove(exact);
}
else {
toActUpon.Add(exact);
}
}
else {
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
toActUpon.Remove(rem);
}
}
}
else {
//only one match, so autocomplete
if (args[0] == "!*") {
toActUpon.Remove(search.First());
}
else {
toActUpon.Add(search.First());
}
}
}
}
return (failToFind, toActUpon, ambig);
}
CommandHandler.RegisterCommand("rejoin", args => {
bool moreThanOne = false;
StringBuilder builder = new StringBuilder();
foreach (Client user in server.Clients.Where(c => c.Connected && args.Any(x => c.Name.StartsWith(x) ||
(Guid.TryParse(x, out Guid result) && result == c.Id)))) {
if (moreThanOne) builder.Append(", ");
builder.Append(user.Name);
user.Dispose();
moreThanOne = true;
if (args.Length == 0) {
return "Usage: rejoin <* | !* (usernames to not rejoin...) | (usernames to rejoin...)>";
}
return moreThanOne ? $"Caused {builder} to rejoin" : "Usage: rejoin <usernames...>";
var res = MultiUserCommandHelper(args);
StringBuilder sb = new StringBuilder();
sb.Append(res.toActUpon.Count > 0 ? "Rejoined: " + 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.Dispose();
}
return sb.ToString();
});
CommandHandler.RegisterCommand("crash", args => {
bool moreThanOne = false;
StringBuilder builder = new StringBuilder();
foreach (Client user in server.Clients.Where(c => c.Connected && args.Any(x => c.Name.StartsWith(x) ||
(Guid.TryParse(x, out Guid result) && result == c.Id)))) {
if (moreThanOne) builder.Append(", ");
moreThanOne = true;
builder.Append(user.Name);
Task.Run(async () => {
await user.Send(new ChangeStagePacket {
Id = "$among$us/SubArea",
Stage = "$agogusStage",
Scenario = 21,
SubScenarioType = 69 // invalid id
});
user.Dispose();
if (args.Length == 0) {
return "Usage: crash <* | !* (usernames to not crash...) | (usernames to crash...)>";
}
var res = MultiUserCommandHelper(args);
StringBuilder sb = new StringBuilder();
sb.Append(res.toActUpon.Count > 0 ? "Crashed: " + 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}\""))}");
});
}
return moreThanOne ? $"Crashed {builder}" : "Usage: crash <usernames...>";
});
CommandHandler.RegisterCommand("ban", args => {
bool moreThanOne = false;
StringBuilder builder = new StringBuilder();
foreach (Client user in server.Clients.Where(c => c.Connected && args.Any(x => c.Name.StartsWith(x) ||
(Guid.TryParse(x, out Guid result) && result == c.Id)))) {
if (moreThanOne) builder.Append(", ");
moreThanOne = true;
builder.Append(user.Name);
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();
});
foreach (Client user in res.toActUpon) {
BanLists.Crash(user);
}
if (moreThanOne) {
Settings.SaveSettings();
return $"Banned {builder}.";
}
return "Usage: ban <usernames...>";
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!";
}
if (!sbyte.TryParse(args[2], out sbyte scenario) || scenario < -1) return $"Invalid scenario number {args[2]} (range: [-1 to 127])";
Client[] players = args[3] == "*" ? server.Clients.Where(c => c.Connected).ToArray() : server.Clients.Where(c => c.Connected && args[3..].Any(x => c.Name.StartsWith(x) ||
(Guid.TryParse(x, out Guid result) && result == c.Id))).ToArray();
if (!sbyte.TryParse(args[2], out sbyte scenario) || scenario < -1)
return $"Invalid scenario number {args[2]} (range: [-1 to 127])";
Client[] players = args[3] == "*"
? server.Clients.Where(c => c.Connected).ToArray()
: server.Clients.Where(c =>
c.Connected
&& args[3..].Any(x => c.Name.StartsWith(x) || (Guid.TryParse(x, out Guid result) && result == c.Id)))
.ToArray();
Parallel.ForEachAsync(players, async (c, _) => {
await c.Send(new ChangeStagePacket {
Stage = stage,
@ -243,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!";
string? stage = Stages.Input2Stage(args[0]);
if (stage == null) {
return "Invalid Stage Name! ```" + Stages.KingdomAliasMapping() + "```";
}
Client[] players = server.Clients.Where(c => c.Connected).ToArray();
@ -293,15 +442,18 @@ CommandHandler.RegisterCommand("scenario", args => {
});
CommandHandler.RegisterCommand("tag", args => {
const string optionUsage = "Valid options:\n\ttime <user/*> <minutes[0-65535]> <seconds[0-59]>\n\tseeking <user/*> <true/false>\n\tstart <time> <seekers>";
const string optionUsage =
"Valid options:\n\ttime <user/*> <minutes[0-65535]> <seconds[0-59]>\n\tseeking <user/*> <true/false>\n\tstart <time> <seekers>";
if (args.Length < 3)
return optionUsage;
switch (args[0]) {
case "time" when args.Length == 4: {
if (args[1] != "*" && server.Clients.All(x => x.Name != args[1])) return $"Cannot find user {args[1]}";
Client? client = server.Clients.FirstOrDefault(x => x.Name == args[1]);
if (!ushort.TryParse(args[2], out ushort minutes)) return $"Invalid time for minutes {args[2]} (range: 0-65535)";
if (!byte.TryParse(args[3], out byte seconds) || seconds >= 60) return $"Invalid time for seconds {args[3]} (range: 0-59)";
if (!ushort.TryParse(args[2], out ushort minutes))
return $"Invalid time for minutes {args[2]} (range: 0-65535)";
if (!byte.TryParse(args[3], out byte seconds) || seconds >= 60)
return $"Invalid time for seconds {args[3]} (range: 0-59)";
TagPacket tagPacket = new TagPacket {
UpdateType = TagPacket.TagUpdate.Time,
Minutes = minutes,
@ -332,7 +484,8 @@ CommandHandler.RegisterCommand("tag", args => {
string[] seekerNames = args[2..];
Client[] seekers = server.Clients.Where(c => seekerNames.Contains(c.Name)).ToArray();
if (seekers.Length != seekerNames.Length)
return $"Couldn't find seeker{(seekerNames.Length > 1 ? "s" : "")}: {string.Join(", ", seekerNames.Where(name => server.Clients.All(c => c.Name != name)))}";
return
$"Couldn't find seeker{(seekerNames.Length > 1 ? "s" : "")}: {string.Join(", ", seekerNames.Where(name => server.Clients.All(c => c.Name != name)))}";
Task.Run(async () => {
int realTime = 1000 * time;
await Task.Delay(realTime);
@ -369,10 +522,12 @@ CommandHandler.RegisterCommand("maxplayers", args => {
return $"Saved and set max players to {maxPlayers}";
});
CommandHandler.RegisterCommand("list", _ => $"List: {string.Join("\n\t", server.Clients.Where(x => x.Connected).Select(x => $"{x.Name} ({x.Id})"))}");
CommandHandler.RegisterCommand("list",
_ => $"List: {string.Join("\n\t", server.Clients.Where(x => x.Connected).Select(x => $"{x.Name} ({x.Id})"))}");
CommandHandler.RegisterCommand("flip", args => {
const string optionUsage = "Valid options: \n\tlist\n\tadd <user id>\n\tremove <user id>\n\tset <true/false>\n\tpov <both/self/others>";
const string optionUsage =
"Valid options: \n\tlist\n\tadd <user id>\n\tremove <user id>\n\tset <true/false>\n\tpov <both/self/others>";
if (args.Length < 1)
return optionUsage;
switch (args[0]) {
@ -389,7 +544,9 @@ CommandHandler.RegisterCommand("flip", args => {
}
case "remove" when args.Length == 2: {
if (Guid.TryParse(args[1], out Guid result)) {
string output = Settings.Instance.Flip.Players.Remove(result) ? $"Removed {result} to flipped players" : $"User {result} wasn't in the flipped players list";
string output = Settings.Instance.Flip.Players.Remove(result)
? $"Removed {result} to flipped players"
: $"User {result} wasn't in the flipped players list";
Settings.SaveSettings();
return output;
}
@ -420,15 +577,24 @@ CommandHandler.RegisterCommand("flip", args => {
});
CommandHandler.RegisterCommand("shine", args => {
const string optionUsage = "Valid options: list, clear, sync, send";
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();
foreach (ConcurrentBag<int> playerBag in server.Clients.Select(serverClient => (ConcurrentBag<int>) serverClient.Metadata["shineSync"])) playerBag.Clear();
Task.Run(async () => {
await PersistShines();
});
foreach (ConcurrentBag<int> playerBag in server.Clients.Select(serverClient =>
(ConcurrentBag<int>)serverClient.Metadata["shineSync"]!)) playerBag?.Clear();
return "Cleared shine bags";
case "sync" when args.Length == 1:
@ -436,7 +602,9 @@ CommandHandler.RegisterCommand("shine", args => {
return "Synced shine bag automatically";
case "send" when args.Length >= 3:
if (int.TryParse(args[1], out int id)) {
Client[] players = args[2] == "*" ? server.Clients.Where(c => c.Connected).ToArray() : server.Clients.Where(c => c.Connected && args[3..].Contains(c.Name)).ToArray();
Client[] players = args[2] == "*"
? server.Clients.Where(c => c.Connected).ToArray()
: server.Clients.Where(c => c.Connected && args[3..].Contains(c.Name)).ToArray();
Parallel.ForEachAsync(players, async (c, _) => {
await c.Send(new ShinePacket {
ShineId = id
@ -446,6 +614,30 @@ CommandHandler.RegisterCommand("shine", args => {
}
return optionUsage;
case "set" when args.Length == 2: {
if (bool.TryParse(args[1], out bool result)) {
Settings.Instance.Shines.Enabled = result;
Settings.SaveSettings();
return result ? "Enabled shine sync" : "Disabled shine sync";
}
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;
}
@ -456,6 +648,21 @@ CommandHandler.RegisterCommand("loadsettings", _ => {
return "Loaded settings.json";
});
CommandHandler.RegisterCommand("restartserver", args =>
{
if (args.Length != 0)
{
return "Usage: restartserver (no arguments)";
}
else
{
consoleLogger.Info("Received restartserver command");
restartRequested = true;
cts.Cancel();
return "Restarting...";
}
});
Console.CancelKeyPress += (_, e) => {
e.Cancel = true;
consoleLogger.Info("Received Ctrl+C");
@ -467,6 +674,7 @@ CommandHandler.RegisterCommandAliases(_ => {
return "Shutting down";
}, "exit", "quit", "q");
#pragma warning disable CS4014
Task.Run(() => {
consoleLogger.Info("Run help command for valid commands.");
while (true) {
@ -477,6 +685,20 @@ Task.Run(() => {
}
}
}
});
}).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()
{
string? path = System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name;
const string unableToStartMsg = "Unable to ascertain the executable location, you'll need to re-run the server manually.";
if (path != null) //path is probably just "Server", but in the context of the assembly, that's all you need to restart it.
{
Console.WriteLine($"Server Running on (pid): {System.Diagnostics.Process.Start(path)?.Id.ToString() ?? unableToStartMsg}");
}
else
consoleLogger.Info(unableToStartMsg);
}

View File

@ -10,6 +10,7 @@ namespace Server;
public class Server {
public readonly List<Client> Clients = new List<Client>();
public IEnumerable<Client> ClientsConnected => Clients.Where(client => client.Metadata.ContainsKey("lastGamePacket") && client.Connected);
public readonly Logger Logger = new Logger("Server");
private readonly MemoryPool<byte> memoryPool = MemoryPool<byte>.Shared;
public Func<Client, IPacket, bool>? PacketHandler = null!;
@ -30,8 +31,12 @@ public class Server {
Logger.Warn($"Accepted connection for client {socket.RemoteEndPoint}");
// start sub thread to handle client
try {
Task.Run(() => HandleSocket(socket));
#pragma warning disable CS4014
Task.Run(() => HandleSocket(socket))
.ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } });
#pragma warning restore CS4014
}
catch (Exception e) {
Logger.Error($"Error occured while setting up socket handler? {e}");
@ -54,12 +59,13 @@ public class Server {
}
Logger.Info("Server closed");
Console.WriteLine("\n\n\n"); //for the sake of the restart command.
}
}
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..]);
}
@ -68,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);
@ -102,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();
}
@ -112,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) {
@ -122,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) {
@ -137,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;
}
@ -148,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) {
@ -157,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) {
client.Name = connect.ClientName;
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.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));
@ -225,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)]);
@ -252,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;
}
@ -279,31 +335,87 @@ public class Server {
client.Logger.Error($"Packet handler warning: {e}");
}
Broadcast(memory, client);
#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
}
}
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) Task.Run(() => socket.DisconnectAsync(false));
if (socket.Connected) {
#pragma warning disable CS4014
Task.Run(() => socket.DisconnectAsync(false))
.ContinueWith(x => { if (x.Exception != null) { Logger.Error(x.Exception.ToString()); } });
#pragma warning restore CS4014
}
}
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();
}
catch { /*lol*/ }
Task.Run(() => Broadcast(new DisconnectPacket(), client));
#pragma warning disable CS4014
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) {
@ -312,4 +424,4 @@ public class Server {
header.Deserialize(data[..Constants.HeaderSize]);
return header;
}
}
}

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,8 +43,10 @@ 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();
public class ServerTable {
public string Address { get; set; } = IPAddress.Any.ToString();
@ -56,21 +58,34 @@ 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;
}
public class DiscordTable {
public string? Token { get; set; }
public string Prefix { get; set; } = "$";
public string? CommandChannel { get; set; }
public string? LogChannel { get; set; }
}
}
public class ShineTable {
public bool Enabled { get; set; } = true;
public ISet<int> Excluded { get; set; } = new SortedSet<int> { 496 };
}
public class PersistShinesTable
{
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"}
};
}
}

View File

@ -34,8 +34,9 @@ public class Logger {
static Logger() {
AddLogHandler((source, level, text, color) => {
DateTime logtime = DateTime.Now;
Console.ForegroundColor = color;
Console.Write(PrefixNewLines(text, $"{level} [{source}]"));
Console.Write(PrefixNewLines(text, $"{{{logtime}}} {level} [{source}]"));
});
}
}

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" },
};
}

View File

@ -21,7 +21,7 @@ PacketType[] reboundPackets = {
// PacketType.Shine
};
string lastCapture = "";
//string lastCapture = ""; //not referenced
List<TcpClient> clients = new List<TcpClient>();
async Task S(string n, Guid otherId, Guid ownId) {
@ -77,13 +77,15 @@ async Task S(string n, Guid otherId, Guid ownId) {
continue;
}
if (type == PacketType.Player) {
#pragma warning disable CS4014
Task.Run(async () => {
await Task.Delay(1000);
header.Id = ownId;
MemoryMarshal.Write(owner.Memory.Span[..Constants.HeaderSize], ref header);
await stream.WriteAsync(owner.Memory[..(Constants.HeaderSize + header.PacketSize)]);
owner.Dispose();
});
}).ContinueWith(x => { if (x.Exception != null) { logger.Error(x.Exception.ToString()); } });
#pragma warning restore CS4014
continue;
}
header.Id = ownId;

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

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
version: "3.9"
services:
server:
image: ghcr.io/sanae6/smo-online-server
#build: .
#user: 1000:1000
stdin_open: true
restart: unless-stopped
ports:
- 1027:1027
volumes:
- ./data/:/data/

24
smo.service Normal file
View File

@ -0,0 +1,24 @@
[Unit]
Description="Super Mario Odyssey Multiplayer Server"
After=network.target
StartLimitIntervalSec=0
[Service]
# Uncommnt User to run under a different user
# User=user
# change ExecStart to the path of the server file
ExecStart="/home/user/SMOServer/Server"
# change WorkingDirectory to the folder of your server
WorkingDirectory=/home/user/SMOServer/
# ensure the service restarts after crashing
Restart=always
# amount of time to wait before restarting the service
RestartSec=5
#TTY Input
StandardInput=tty-force
TTYVHangup=yes
TTYPath=/dev/tty20
TTYReset=yes
[Install]
WantedBy=multi-user.target