diff --git a/EvoSC.sln b/EvoSC.sln
index 2ec61fa13..5ed2b9e99 100644
--- a/EvoSC.sln
+++ b/EvoSC.sln
@@ -84,7 +84,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatchTrackerModule", "src\M
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MatchReadyModule", "src\Modules\MatchReadyModule\MatchReadyModule.csproj", "{0538B9AB-B556-45BF-8230-53087BA9D353}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scoreboard", "src\Modules\Scoreboard\Scoreboard.csproj", "{CD032D37-3BC8-4DE8-8C5B-45A0DE36932E}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScoreboardModule", "src\Modules\ScoreboardModule\ScoreboardModule.csproj", "{CD032D37-3BC8-4DE8-8C5B-45A0DE36932E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NextMapModule", "src\Modules\NextMapModule\NextMapModule.csproj", "{64688FA7-136C-4BB9-B716-4E96DD0AA82F}"
EndProject
@@ -148,6 +148,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpectatorCamModeModule", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpectatorCamModeModule.Tests", "tests\Modules\SpectatorCamModeModule.Tests\SpectatorCamModeModule.Tests.csproj", "{09A88256-8008-4085-A8E6-CA6DEFAC63E3}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerSyncModule", "src\Modules\ServerSyncModule\ServerSyncModule.csproj", "{AB39B49E-729D-42EF-93DE-98AD6329AAEC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ToornamentModule", "src\Modules\ToornamentModule\ToornamentModule.csproj", "{6F12A74A-BC4B-426D-B094-638FCB266FC6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContactAdminModule", "src\Modules\ContactAdminModule\ContactAdminModule.csproj", "{056D6390-6A39-4A5B-BFCE-7694CDD4EA62}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoundRankingModule", "src\Modules\RoundRankingModule\RoundRankingModule.csproj", "{41FD20E7-5064-425F-B110-CEBD53F80ECA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RoundRankingModule.Tests", "tests\Modules\RoundRankingModule.Tests\RoundRankingModule.Tests.csproj", "{2623A6E2-125F-49B5-B8E1-5883B6E36C1A}"
+EndProject
@@ -447,6 +457,26 @@ Global
{09A88256-8008-4085-A8E6-CA6DEFAC63E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{09A88256-8008-4085-A8E6-CA6DEFAC63E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{09A88256-8008-4085-A8E6-CA6DEFAC63E3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AB39B49E-729D-42EF-93DE-98AD6329AAEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AB39B49E-729D-42EF-93DE-98AD6329AAEC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AB39B49E-729D-42EF-93DE-98AD6329AAEC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AB39B49E-729D-42EF-93DE-98AD6329AAEC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6F12A74A-BC4B-426D-B094-638FCB266FC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6F12A74A-BC4B-426D-B094-638FCB266FC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6F12A74A-BC4B-426D-B094-638FCB266FC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6F12A74A-BC4B-426D-B094-638FCB266FC6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {056D6390-6A39-4A5B-BFCE-7694CDD4EA62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {056D6390-6A39-4A5B-BFCE-7694CDD4EA62}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {056D6390-6A39-4A5B-BFCE-7694CDD4EA62}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {056D6390-6A39-4A5B-BFCE-7694CDD4EA62}.Release|Any CPU.Build.0 = Release|Any CPU
+ {41FD20E7-5064-425F-B110-CEBD53F80ECA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {41FD20E7-5064-425F-B110-CEBD53F80ECA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {41FD20E7-5064-425F-B110-CEBD53F80ECA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {41FD20E7-5064-425F-B110-CEBD53F80ECA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2623A6E2-125F-49B5-B8E1-5883B6E36C1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2623A6E2-125F-49B5-B8E1-5883B6E36C1A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2623A6E2-125F-49B5-B8E1-5883B6E36C1A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2623A6E2-125F-49B5-B8E1-5883B6E36C1A}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -517,5 +547,10 @@ Global
{E4BF17BE-A517-4D3C-8DCA-DA99A100EBFE} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
{E9806703-6E24-4F05-A728-A04F7EB31749} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{09A88256-8008-4085-A8E6-CA6DEFAC63E3} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
+ {41FD20E7-5064-425F-B110-CEBD53F80ECA} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
+ {2623A6E2-125F-49B5-B8E1-5883B6E36C1A} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
+ {AB39B49E-729D-42EF-93DE-98AD6329AAEC} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
+ {6F12A74A-BC4B-426D-B094-638FCB266FC6} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
+ {056D6390-6A39-4A5B-BFCE-7694CDD4EA62} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
EndGlobalSection
EndGlobal
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 46640ee76..f242f71e4 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -3,8 +3,9 @@ services:
image: evoesports/trackmania
restart: always
ports:
- - 2350:2350/udp
- - 2350:2350/tcp
+ - "2350:2350/udp"
+ - "2350:2350/tcp"
+ - "5001:5000"
environment:
- MASTER_LOGIN=
- MASTER_PASSWORD=
@@ -19,21 +20,8 @@ services:
- POSTGRES_PASSWORD=evosc_sharp
- POSTGRES_USER=evosc_sharp
- POSTGRES_DB=evosc_sharp
-
- evosc_sharp:
- image: evoscsharp:latest
- restart: always
- depends_on:
- - trackmania
- - postgres
- environment:
- - EVOSC_DATABASE_HOST=postgres
- - EVOSC_DATABASE_NAME=evosc_sharp
- - EVOSC_DATABASE_USERNAME=evosc_sharp
- - EVOSC_DATABASE_PASSWORD=evosc_sharp
- - EVOSC_SERVER_HOST=trackmania
- volumes:
- - UserData:/server/UserData
+ ports:
+ - "5432:5432"
volumes:
PostgresData: null
diff --git a/src/EvoSC/EvoSC.csproj b/src/EvoSC/EvoSC.csproj
index 844ecefc8..69a102d20 100644
--- a/src/EvoSC/EvoSC.csproj
+++ b/src/EvoSC/EvoSC.csproj
@@ -12,18 +12,22 @@
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
@@ -34,6 +38,8 @@
+
+
@@ -49,12 +55,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/EvoSC/InternalModules.cs b/src/EvoSC/InternalModules.cs
index a7fa2c43b..bc9b5b91d 100644
--- a/src/EvoSC/InternalModules.cs
+++ b/src/EvoSC/InternalModules.cs
@@ -1,5 +1,8 @@
using EvoSC.Common.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule;
+using EvoSC.Modules.EvoEsports.ToornamentModule;
using EvoSC.Modules.Interfaces;
+using EvoSC.Modules.Nsgr.ContactAdminModule;
using EvoSC.Modules.Official.ASayModule;
using EvoSC.Modules.Official.CurrentMapModule;
using EvoSC.Modules.Official.ExampleModule;
@@ -20,7 +23,8 @@
using EvoSC.Modules.Official.OpenPlanetModule;
using EvoSC.Modules.Official.Player;
using EvoSC.Modules.Official.PlayerRecords;
-using EvoSC.Modules.Official.Scoreboard;
+using EvoSC.Modules.Official.RoundRankingModule;
+using EvoSC.Modules.Official.ScoreboardModule;
using EvoSC.Modules.Official.ServerManagementModule;
using EvoSC.Modules.Official.SetName;
using EvoSC.Modules.Official.SpectatorCamModeModule;
@@ -65,7 +69,11 @@ public static class InternalModules
typeof(TeamSettingsModule),
typeof(ServerManagementModule),
typeof(TeamInfoModule),
- typeof(TeamChatModule)
+ typeof(TeamChatModule),
+ typeof(ServerSyncModule),
+ typeof(ToornamentModule),
+ typeof(ContactAdminModule),
+ typeof(RoundRankingModule)
];
///
diff --git a/src/EvoSC/Properties/launchSettings.json b/src/EvoSC/Properties/launchSettings.json
index e3a1b92e6..e489a043f 100644
--- a/src/EvoSC/Properties/launchSettings.json
+++ b/src/EvoSC/Properties/launchSettings.json
@@ -2,7 +2,24 @@
"profiles": {
"EvoSC": {
"commandName": "Project",
- "commandLineArgs": "run"
+ "commandLineArgs": "run",
+ "environmentVariables": {
+ "EVOSC_SERVERSYNCMODULE_NATSSETTINGS_HOST": "116.202.26.112",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_APIKEY": "",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_CLIENTID": "",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_CLIENTSECRET": "",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_WHITELIST": "8ecce824-8ada-4fbf-9af1-64b2d8dd42a4",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_TOORNAMENTID": "7930620280195727360",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_USETOORNAMENTDISCIPLINE": "false",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_AUTOMATICMATCHSTART": "true",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_MAPTMXIDS": "186972,186973,186974,186870,186980",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_MAPIDS": "b0c2e735-15a9-43df-a772-f5f1f692fa29,e8233617-a929-4891-924d-6ba72fd546c7,c7ffcdfe-fd98-417b-80d9-b464927f76a1,ae1eb6cb-76ef-45ab-8149-07377e3e45bd,b653936e-1316-461a-b4cf-befa6630e839",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_MAPUIDS": "zS5d30EU7x6meq2eIM5Uhu4UbTg,VbW1c9rTPSwtZNHrlDE8FVPqIca,lKJQ8YrXza3XiEN1a1fPK3fl4wf,W6GjI5Nsr9MYdBBOBXIOd8JhZwj,YUTy7o9O0hDmWFNVQ4QuxaXzXD4",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_MAPMACHINENAMES": "blueprint,domino,fe4turing,karotte,schwaadlappe",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_DEFAULTGROUPID": "2",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_USEEXPERIMENTALFEATURES": "true",
+ "EVOSC_TOORNAMENTMODULE_TOORNAMENTSETTINGS_DISCIPLINES": "[{\"game_mode\":\"rounds\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":1,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":30,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":1,\"S_PointsLimit\":-1,\"S_PointsRepartition\":\"n-1\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":1,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":1,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":1,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":2,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":1,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":2,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":1,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":3,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":2,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":4,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":3,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":1,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":5,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":140,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":2,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":120,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":5,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":140,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":6,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":140,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":2,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":7,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":2,\"S_PointsLimit\":140,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true},{\"game_mode\":\"cup\",\"group_number\":3,\"plugins\":{\"S_UseAutoReady\":false},\"round_number\":1,\"scripts\":{\"S_DelayBeforeNextMap\":2000,\"S_FinishTimeout\":10,\"S_MapsPerMatch\":5,\"S_NbOfWinners\":3,\"S_PointsLimit\":140,\"S_PointsRepartition\":\"10,6,4,3\",\"S_RespawnBehaviour\":0,\"S_RoundsPerMap\":4,\"S_UseTieBreak\":false,\"S_WarmUpDuration\":60,\"S_WarmUpNb\":1},\"stage_number\":3,\"tracks_shuffle\":true}]"
+ }
}
}
}
\ No newline at end of file
diff --git a/src/Modules/ContactAdminModule/Config/IContactAdminSettings.cs b/src/Modules/ContactAdminModule/Config/IContactAdminSettings.cs
new file mode 100644
index 000000000..ebdc562f7
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Config/IContactAdminSettings.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.Nsgr.ContactAdminModule.Config;
+
+[Settings]
+public interface IContactAdminSettings
+{
+ [Option(DefaultValue = ""), Description("Specifies the Discord Webhook endpoint to POST to.")]
+ public string WebhookUrl { get; set; }
+
+ [Option(DefaultValue = ""), Description("A suffix that will be added to each message. Can be used for Discord pings")]
+ public string MessageSuffix { get; set; }
+}
diff --git a/src/Modules/ContactAdminModule/ContactAdminModule.cs b/src/Modules/ContactAdminModule/ContactAdminModule.cs
new file mode 100644
index 000000000..7f3b4b4a0
--- /dev/null
+++ b/src/Modules/ContactAdminModule/ContactAdminModule.cs
@@ -0,0 +1,12 @@
+using EvoSC.Modules.Attributes;
+using EvoSC.Modules.Interfaces;
+using EvoSC.Modules.Nsgr.ContactAdminModule.Interfaces;
+
+namespace EvoSC.Modules.Nsgr.ContactAdminModule;
+
+[Module]
+public class ContactAdminModule(IContactAdminService service) : EvoScModule, IToggleable
+{
+ public Task EnableAsync() => service.ShowWidgetAsync();
+ public Task DisableAsync() => service.HideWidgetAsync();
+}
diff --git a/src/Modules/ContactAdminModule/ContactAdminModule.csproj b/src/Modules/ContactAdminModule/ContactAdminModule.csproj
new file mode 100644
index 000000000..00e521166
--- /dev/null
+++ b/src/Modules/ContactAdminModule/ContactAdminModule.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.Nsgr.ContactAdminModule
+ false
+ ContactAdminModule
+ Contact Admin Module
+ Creates a button for players that when clicked, notifies a Discord webhook.
+ 1.0.0
+ Nsgr
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ContactAdminModule/Controllers/ContactAdminManialinkController.cs b/src/Modules/ContactAdminModule/Controllers/ContactAdminManialinkController.cs
new file mode 100644
index 000000000..605999205
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Controllers/ContactAdminManialinkController.cs
@@ -0,0 +1,11 @@
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Manialinks;
+using EvoSC.Modules.Nsgr.ContactAdminModule.Interfaces;
+
+namespace EvoSC.Modules.Nsgr.ContactAdminModule.Controllers;
+
+[Controller]
+public class ContactAdminManialinkController(IContactAdminService service) : ManialinkController
+{
+ public Task ContactAdminButtonAsync() => service.ContactAdminAsync(Context.Player);
+}
diff --git a/src/Modules/ContactAdminModule/Interfaces/IContactAdminService.cs b/src/Modules/ContactAdminModule/Interfaces/IContactAdminService.cs
new file mode 100644
index 000000000..7226bee54
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Interfaces/IContactAdminService.cs
@@ -0,0 +1,26 @@
+using EvoSC.Common.Interfaces.Models;
+
+namespace EvoSC.Modules.Nsgr.ContactAdminModule.Interfaces;
+
+public interface IContactAdminService
+{
+ ///
+ /// Shows the widget
+ ///
+ ///
+ Task ShowWidgetAsync();
+
+ ///
+ /// Hides the Widget
+ ///
+ ///
+ Task HideWidgetAsync();
+
+ ///
+ /// Posts a help message containing the Trackmania server name to the specified Webhook
+ /// If contextPlayer is specified, the chat message will mention who requested the contact.
+ ///
+ ///
+ ///
+ Task ContactAdminAsync(IOnlinePlayer? contextPlayer);
+}
diff --git a/src/Modules/ContactAdminModule/Localization.resx b/src/Modules/ContactAdminModule/Localization.resx
new file mode 100644
index 000000000..f2bbd290f
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Localization.resx
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Hello {0}
+
+
\ No newline at end of file
diff --git a/src/Modules/ContactAdminModule/Services/ContactAdminService.cs b/src/Modules/ContactAdminModule/Services/ContactAdminService.cs
new file mode 100644
index 000000000..69c514fa9
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Services/ContactAdminService.cs
@@ -0,0 +1,89 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Database.Repository;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Nsgr.ContactAdminModule.Config;
+using EvoSC.Modules.Nsgr.ContactAdminModule.Interfaces;
+using GbxRemoteNet.Events;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace EvoSC.Modules.Nsgr.ContactAdminModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class ContactAdminService(
+ IManialinkManager manialinkManager,
+ ILogger logger,
+ IServerClient client,
+ IChatService chat,
+ IContactAdminSettings settings
+)
+ : IContactAdminService
+{
+ private readonly HttpClient _http = new();
+
+ public async Task ShowWidgetAsync()
+ {
+ await manialinkManager.SendPersistentManialinkAsync("ContactAdminModule.ContactAdminWidget");
+ logger.LogDebug("Showing 'Contact Admin' widget");
+ }
+
+ public async Task HideWidgetAsync()
+ {
+ await manialinkManager.HideManialinkAsync("ContactAdminModule.ContactAdminWidget");
+ logger.LogDebug("Hiding 'Contact Admin' widget");
+ }
+
+ public async Task ContactAdminAsync(IOnlinePlayer? contextPlayer)
+ {
+ var serverName = await client.Remote.GetServerNameAsync();
+
+ var discordMessage = contextPlayer is null
+ ? $"Help requested on server {serverName}"
+ : $"{contextPlayer.NickName} requested help on server {serverName}";
+
+ // Check for suffix so we can ping people on Discord
+ if (settings.MessageSuffix.Length > 0)
+ {
+ discordMessage += $" {settings.MessageSuffix}";
+ }
+
+ var messageObject = new { content = discordMessage };
+ var json = JsonConvert.SerializeObject(messageObject);
+ var data = new StringContent(json, Encoding.UTF8, "application/json");
+
+ logger.LogTrace($"Requesting {settings.WebhookUrl}");
+
+ try
+ {
+ var response = await _http.PostAsync(settings.WebhookUrl, data);
+ if (!response.IsSuccessStatusCode) throw new Exception("The request status code was not successful.");
+
+ logger.LogDebug("Successfully executed webhook.");
+
+ var chatMessage = contextPlayer is null
+ ? "The admins were contacted."
+ : $"$<{contextPlayer.NickName}$> requested to contact the admins.";
+
+ await chat.InfoMessageAsync(chatMessage);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError($"Failed to execute Discord webhook: {ex.ToString()}");
+
+ if (contextPlayer is null)
+ {
+ await chat.ErrorMessageAsync("Could not notify admins. Please reach out manually.");
+ }
+ else
+ {
+ await chat.ErrorMessageAsync("Could not notify admins. Please reach out manually.", contextPlayer);
+ }
+ }
+ }
+}
diff --git a/src/Modules/ContactAdminModule/Templates/ContactAdminWidget.mt b/src/Modules/ContactAdminModule/Templates/ContactAdminWidget.mt
new file mode 100644
index 000000000..2eb6eb0cc
--- /dev/null
+++ b/src/Modules/ContactAdminModule/Templates/ContactAdminWidget.mt
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ContactAdminModule/info.toml b/src/Modules/ContactAdminModule/info.toml
new file mode 100644
index 000000000..3bb2ba71b
--- /dev/null
+++ b/src/Modules/ContactAdminModule/info.toml
@@ -0,0 +1,6 @@
+[info]
+name = "ContactAdminModule"
+title = "Contact Admin Module"
+summary = "Creates a button for players that when clicked, notifies a Discord webhook."
+version = "1.0.0"
+author = "Nsgr"
diff --git a/src/Modules/RoundRankingModule/Config/IRoundRankingSettings.cs b/src/Modules/RoundRankingModule/Config/IRoundRankingSettings.cs
new file mode 100644
index 000000000..08a0b9f0d
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Config/IRoundRankingSettings.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Common.Util.Manialinks;
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Config;
+
+[Settings]
+public interface IRoundRankingSettings
+{
+ [Option(DefaultValue = WidgetPosition.Left),
+ Description("Specifies on which side the widget is displayed. Valid values are Left | Right.")]
+ public WidgetPosition Position { get; set; }
+
+ [Option(DefaultValue = 15.0), Description("Defines the Y position of the widget.")]
+ public double Y { get; set; }
+
+ [Option(DefaultValue = 8), Description("Limits the rows shown in the widget.")]
+ public int MaxRows { get; set; }
+
+ [Option(DefaultValue = false),
+ Description("Shows the time difference to the leading player instead of individual times.")]
+ public bool DisplayTimeDifference { get; set; }
+
+ [Option(DefaultValue = true),
+ Description("Shows the gained points once a player crosses the finish line.")]
+ public bool DisplayGainedPoints { get; set; }
+}
diff --git a/src/Modules/RoundRankingModule/Controllers/RoundRankingCommandsController.cs b/src/Modules/RoundRankingModule/Controllers/RoundRankingCommandsController.cs
new file mode 100644
index 000000000..c5f0eba8a
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Controllers/RoundRankingCommandsController.cs
@@ -0,0 +1,142 @@
+using EvoSC.Commands.Attributes;
+using EvoSC.Commands.Interfaces;
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Interfaces.Models.Enums;
+using EvoSC.Common.Models.Players;
+using EvoSC.Common.Util;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Controllers;
+
+[Controller]
+public class RoundRankingCommandsController(IRoundRankingService roundRankingService)
+ : EvoScController
+{
+ [ChatCommand("test", "fakeplayer")]
+ public async Task TestRoundRankingCommandAsync()
+ {
+ var player1 = new OnlinePlayer
+ {
+ State = PlayerState.Playing, AccountId = "*fakeplayer1*", NickName = "Test Player #1"
+ };
+ var player2 = new OnlinePlayer
+ {
+ State = PlayerState.Playing, AccountId = "*fakeplayer2*", NickName = "TwoTwoTwoTwo"
+ };
+ var player3 = new OnlinePlayer
+ {
+ State = PlayerState.Playing, AccountId = "*fakeplayer3*", NickName = "Thrid Test Player"
+ };
+
+ Thread.Sleep(500);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player1,
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(1200),
+ IsFinish = false,
+ IsDNF = false
+ });
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player2,
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(1250),
+ IsFinish = false,
+ IsDNF = false
+ });
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player3,
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(1000),
+ IsFinish = false,
+ IsDNF = false
+ });
+
+ Thread.Sleep(2500);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player2,
+ CheckpointId = 1,
+ Time = RaceTime.FromMilliseconds(2000),
+ IsFinish = false,
+ IsDNF = false
+ });
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player3,
+ CheckpointId = 1,
+ Time = RaceTime.FromMilliseconds(2100),
+ IsFinish = false,
+ IsDNF = false
+ });
+
+ Thread.Sleep(1000);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player1,
+ CheckpointId = 1,
+ Time = RaceTime.FromMilliseconds(2555),
+ IsFinish = false,
+ IsDNF = false
+ });
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player3,
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(0),
+ IsFinish = false,
+ IsDNF = true
+ });
+
+ Thread.Sleep(1000);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player2,
+ CheckpointId = 2,
+ Time = RaceTime.FromMilliseconds(3500),
+ IsFinish = false,
+ IsDNF = false
+ });
+
+ Thread.Sleep(1500);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player1,
+ CheckpointId = 2,
+ Time = RaceTime.FromMilliseconds(4555),
+ IsFinish = false,
+ IsDNF = false
+ });
+
+ Thread.Sleep(2000);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player1,
+ CheckpointId = 3,
+ Time = RaceTime.FromMilliseconds(7500),
+ IsFinish = false,
+ IsDNF = false
+ });
+
+ Thread.Sleep(2000);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player1,
+ CheckpointId = 4,
+ Time = RaceTime.FromMilliseconds(8000),
+ IsFinish = true,
+ IsDNF = false
+ });
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Controllers/RoundRankingEventController.cs b/src/Modules/RoundRankingModule/Controllers/RoundRankingEventController.cs
new file mode 100644
index 000000000..3a6e79dcd
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Controllers/RoundRankingEventController.cs
@@ -0,0 +1,82 @@
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Events.Attributes;
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Remote;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Util;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Controllers;
+
+[Controller]
+public class RoundRankingEventController(
+ IRoundRankingService roundRankingService,
+ IPlayerManagerService playerManagerService
+) : EvoScController
+{
+ [Subscribe(ModeScriptEvent.WayPoint)]
+ public async Task OnWaypointAsync(object sender, WayPointEventArgs args)
+ {
+ if (!roundRankingService.ShouldCollectCheckpointData(args.AccountId))
+ {
+ return;
+ }
+
+ var player = await playerManagerService.GetOnlinePlayerAsync(args.AccountId);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player,
+ CheckpointId = args.CheckpointInLap,
+ Time = RaceTime.FromMilliseconds(args.LapTime),
+ IsFinish = args.IsEndLap,
+ IsDNF = false
+ });
+ }
+
+ [Subscribe(ModeScriptEvent.GiveUp)]
+ public async Task OnPlayerGiveUpAsync(object sender, PlayerUpdateEventArgs args)
+ {
+ var player = await playerManagerService.GetOnlinePlayerAsync(args.AccountId);
+
+ await roundRankingService.ConsumeCheckpointDataAsync(new CheckpointData
+ {
+ Player = player,
+ CheckpointId = -1,
+ Time = RaceTime.FromMilliseconds(0),
+ IsFinish = false,
+ IsDNF = true
+ });
+ }
+
+ [Subscribe(ModeScriptEvent.EndRoundEnd)]
+ public Task OnStartRoundAsync(object sender, EventArgs args) =>
+ roundRankingService.ClearCheckpointDataAsync();
+
+ [Subscribe(ModeScriptEvent.StartLine)]
+ public Task OnStartLineAsync(object sender, PlayerUpdateEventArgs args) =>
+ roundRankingService.RemovePlayerCheckpointDataAsync(args.AccountId);
+
+ [Subscribe(ModeScriptEvent.PodiumStart)]
+ public Task OnPodiumStartAsync(object sender, EventArgs args) =>
+ roundRankingService.HideRoundRankingWidgetAsync();
+
+ [Subscribe(GbxRemoteEvent.BeginMap)]
+ public async Task OnStartMapAsync(object sender, EventArgs args)
+ {
+ await roundRankingService.FetchAndCacheTeamInfoAsync();
+ await roundRankingService.LoadPointsRepartitionFromSettingsAsync();
+ await roundRankingService.ClearCheckpointDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpStart)]
+ public Task OnWarmUpStartAsync(object sender, EventArgs args) =>
+ roundRankingService.SetIsTimeAttackModeAsync(true);
+
+ [Subscribe(ModeScriptEvent.WarmUpEnd)]
+ public Task OnWarmUpEndAsync(object sender, EventArgs args) =>
+ roundRankingService.DetectModeAsync();
+}
diff --git a/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs
new file mode 100644
index 000000000..d5869d3ed
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs
@@ -0,0 +1,105 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+
+public interface IRoundRankingService
+{
+ ///
+ /// Handle new CheckpointData entry
+ ///
+ ///
+ ///
+ public Task ConsumeCheckpointDataAsync(CheckpointData checkpointData);
+
+ ///
+ /// Removes the checkpoint data of the player with the given account ID.
+ ///
+ ///
+ ///
+ public Task RemovePlayerCheckpointDataAsync(string accountId);
+
+ ///
+ /// Clears all checkpoint data.
+ ///
+ ///
+ public Task ClearCheckpointDataAsync();
+
+ ///
+ /// Send the round ranking widget to the players.
+ ///
+ ///
+ public Task SendRoundRankingWidgetAsync();
+
+ ///
+ /// Hides the round ranking widget from all players.
+ ///
+ ///
+ public Task HideRoundRankingWidgetAsync();
+
+ ///
+ /// Determines whether the checkpoint data repository can collect another player.
+ /// Should return false if max entries is reached.
+ ///
+ ///
+ ///
+ public bool ShouldCollectCheckpointData(string accountId);
+
+ ///
+ /// Gets the current points repartition value and caches it.
+ ///
+ ///
+ public Task LoadPointsRepartitionFromSettingsAsync();
+
+ ///
+ /// Sets TimeAttack mode active/inactive.
+ ///
+ ///
+ ///
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode);
+
+ ///
+ /// Detects the current game mode and sets up the module accordingly.
+ ///
+ ///
+ public Task DetectModeAsync();
+
+ ///
+ /// Gets the latest team infos and caches them.
+ ///
+ ///
+ public Task FetchAndCacheTeamInfoAsync();
+
+ ///
+ /// Returns the accent color for the given team.
+ ///
+ ///
+ ///
+ ///
+ public string? GetTeamAccentColor(PlayerTeam winnerTeam, PlayerTeam playerTeam);
+
+ ///
+ /// Determines the winning team based on the given checkpoint data.
+ ///
+ ///
+ ///
+ public PlayerTeam GetWinnerTeam(List checkpoints);
+
+ ///
+ /// Traverses the checkpoint data list and sets the gained points on each entry.
+ ///
+ ///
+ public void SetGainedPointsOnResult(List checkpoints);
+
+ ///
+ /// Traverses the checkpoint data, calculates and sets the time differences to the leading player.
+ ///
+ ///
+ public void CalculateAndSetTimeDifferenceOnResult(List checkpoints);
+
+ ///
+ /// Traverses the checkpoint data and sets the accent color on each entry.
+ ///
+ ///
+ public void SetGainedPointsBackgroundColorsOnResult(List checkpoints);
+}
diff --git a/src/Modules/Scoreboard/Localization.resx b/src/Modules/RoundRankingModule/Localization.resx
similarity index 100%
rename from src/Modules/Scoreboard/Localization.resx
rename to src/Modules/RoundRankingModule/Localization.resx
diff --git a/src/Modules/RoundRankingModule/Models/CheckpointData.cs b/src/Modules/RoundRankingModule/Models/CheckpointData.cs
new file mode 100644
index 000000000..721a1ee46
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/CheckpointData.cs
@@ -0,0 +1,22 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Util;
+using EvoSC.Common.Util;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+public class CheckpointData
+{
+ public required IOnlinePlayer Player { get; init; }
+ public required int CheckpointId { get; init; }
+ public required IRaceTime Time { get; set; }
+ public IRaceTime? TimeDifference { get; set; }
+ public required bool IsFinish { get; init; }
+ public required bool IsDNF { get; init; }
+ public int GainedPoints { get; set; }
+ public string? AccentColor { get; set; }
+
+ public IRaceTime GetTimeDifferenceAbsolute(CheckpointData checkpointData)
+ {
+ return RaceTime.FromMilliseconds(int.Abs(this.Time.TotalMilliseconds - checkpointData.Time.TotalMilliseconds));
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs b/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs
new file mode 100644
index 000000000..84e0917fe
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs
@@ -0,0 +1,12 @@
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+public class CheckpointsRepository : Dictionary
+{
+ public List GetSortedData()
+ {
+ return this.Values
+ .OrderByDescending(cpData => cpData.CheckpointId)
+ .ThenBy(cpData => cpData.Time.TotalMilliseconds)
+ .ToList();
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Models/PointsRepartition.cs b/src/Modules/RoundRankingModule/Models/PointsRepartition.cs
new file mode 100644
index 000000000..20e854751
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/PointsRepartition.cs
@@ -0,0 +1,23 @@
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+public class PointsRepartition : List
+{
+ public const string ModeScriptSetting = "S_PointsRepartition";
+ private const string DefaultValue = "10,6,4,3,2,1";
+
+ public PointsRepartition()
+ {
+ Update(DefaultValue);
+ }
+
+ public void Update(string pointsRepartitionString)
+ {
+ Clear();
+ AddRange(pointsRepartitionString.Split(',').Select(int.Parse).ToList());
+ }
+
+ public int GetGainedPoints(int rank)
+ {
+ return rank <= Count ? this[rank - 1] : this.LastOrDefault(0);
+ }
+}
diff --git a/src/Modules/RoundRankingModule/RoundRankingModule.cs b/src/Modules/RoundRankingModule/RoundRankingModule.cs
new file mode 100644
index 000000000..87fdafed1
--- /dev/null
+++ b/src/Modules/RoundRankingModule/RoundRankingModule.cs
@@ -0,0 +1,19 @@
+using EvoSC.Modules.Attributes;
+using EvoSC.Modules.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+
+namespace EvoSC.Modules.Official.RoundRankingModule;
+
+[Module(IsInternal = true)]
+public class RoundRankingModule(IRoundRankingService roundRankingService) : EvoScModule, IToggleable
+{
+ public async Task EnableAsync()
+ {
+ await roundRankingService.LoadPointsRepartitionFromSettingsAsync();
+ await roundRankingService.FetchAndCacheTeamInfoAsync();
+ await roundRankingService.DetectModeAsync();
+ await roundRankingService.SendRoundRankingWidgetAsync();
+ }
+
+ public Task DisableAsync() => Task.CompletedTask;
+}
diff --git a/src/Modules/RoundRankingModule/RoundRankingModule.csproj b/src/Modules/RoundRankingModule/RoundRankingModule.csproj
new file mode 100644
index 000000000..60049556a
--- /dev/null
+++ b/src/Modules/RoundRankingModule/RoundRankingModule.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.Official.RoundRankingModule
+
+
+
+
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Localization.Designer.cs
+
+
+ ResXFileCodeGenerator
+ Localization.Designer.cs
+
+
+
+
diff --git a/src/Modules/RoundRankingModule/Services/RoundRankingService.cs b/src/Modules/RoundRankingModule/Services/RoundRankingService.cs
new file mode 100644
index 000000000..175e9b5ff
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Services/RoundRankingService.cs
@@ -0,0 +1,217 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Interfaces.Themes;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util.MatchSettings;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Config;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class RoundRankingService(
+ IRoundRankingSettings settings,
+ IManialinkManager manialinkManager,
+ IMatchSettingsService matchSettingsService,
+ IThemeManager theme,
+ IServerClient server
+) : IRoundRankingService
+{
+ private const string WidgetTemplate = "RoundRankingModule.RoundRanking";
+
+ private readonly object _checkpointsRepositoryMutex = new();
+ private readonly CheckpointsRepository _checkpointsRepository = new();
+ private readonly PointsRepartition _pointsRepartition = new();
+ private readonly Dictionary _teamColors = new();
+ private bool _isTimeAttackMode;
+ private bool _isTeamsMode;
+
+ public async Task ConsumeCheckpointDataAsync(CheckpointData checkpointData)
+ {
+ lock (_checkpointsRepositoryMutex)
+ {
+ if (_isTimeAttackMode && checkpointData.IsDNF)
+ {
+ _checkpointsRepository.Remove(checkpointData.Player.AccountId);
+ }
+ else
+ {
+ _checkpointsRepository[checkpointData.Player.AccountId] = checkpointData;
+ }
+ }
+
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task RemovePlayerCheckpointDataAsync(string accountId)
+ {
+ if (!_isTimeAttackMode)
+ {
+ //In time attack mode the entries are cleared by new round event.
+ //Prevents flood of manialinks.
+ return;
+ }
+
+ lock (_checkpointsRepositoryMutex)
+ {
+ _checkpointsRepository.Remove(accountId);
+ }
+
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task ClearCheckpointDataAsync()
+ {
+ lock (_checkpointsRepositoryMutex)
+ {
+ _checkpointsRepository.Clear();
+ }
+
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task SendRoundRankingWidgetAsync()
+ {
+ CheckpointsRepository cpRepository;
+ lock (_checkpointsRepositoryMutex)
+ {
+ cpRepository = _checkpointsRepository;
+ }
+
+ var bestCheckpoints = cpRepository.GetSortedData();
+
+ if (bestCheckpoints.Count > 0)
+ {
+ if (settings.DisplayGainedPoints && !_isTimeAttackMode)
+ {
+ SetGainedPointsOnResult(bestCheckpoints);
+ }
+
+ SetGainedPointsBackgroundColorsOnResult(bestCheckpoints);
+
+ if (settings.DisplayTimeDifference)
+ {
+ CalculateAndSetTimeDifferenceOnResult(bestCheckpoints);
+ }
+ }
+
+ await manialinkManager.SendPersistentManialinkAsync(WidgetTemplate,
+ new { settings, bestCheckpoints = bestCheckpoints.Take(settings.MaxRows) });
+ }
+
+ public Task HideRoundRankingWidgetAsync() =>
+ manialinkManager.HideManialinkAsync(WidgetTemplate);
+
+ public bool ShouldCollectCheckpointData(string accountId)
+ {
+ lock (_checkpointsRepositoryMutex)
+ {
+ if (_checkpointsRepository.ContainsKey(accountId))
+ {
+ return true;
+ }
+
+ return _checkpointsRepository.Count < settings.MaxRows;
+ }
+ }
+
+ public async Task LoadPointsRepartitionFromSettingsAsync()
+ {
+ var modeScriptSettings = await matchSettingsService.GetCurrentScriptSettingsAsync();
+ var pointsRepartitionString = (string?)modeScriptSettings?[PointsRepartition.ModeScriptSetting];
+
+ if (pointsRepartitionString != null && pointsRepartitionString.Trim().Length > 0)
+ {
+ _pointsRepartition.Update(pointsRepartitionString);
+ }
+ }
+
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode)
+ {
+ _isTimeAttackMode = isTimeAttackMode;
+
+ return Task.CompletedTask;
+ }
+
+ public async Task DetectModeAsync()
+ {
+ var currentMode = await matchSettingsService.GetCurrentModeAsync();
+ _isTimeAttackMode = currentMode is DefaultModeScriptName.TimeAttack;
+ _isTeamsMode = currentMode is DefaultModeScriptName.Teams or DefaultModeScriptName.TmwtTeams;
+ }
+
+ public async Task FetchAndCacheTeamInfoAsync()
+ {
+ _teamColors[PlayerTeam.Team1] = (await server.Remote.GetTeamInfoAsync((int)PlayerTeam.Team1 + 1)).RGB;
+ _teamColors[PlayerTeam.Team2] = (await server.Remote.GetTeamInfoAsync((int)PlayerTeam.Team2 + 1)).RGB;
+ }
+
+ public void SetGainedPointsOnResult(List checkpoints)
+ {
+ var rank = 1;
+ foreach (var cpData in checkpoints.Where(checkpoint => checkpoint.IsFinish))
+ {
+ cpData.GainedPoints = _pointsRepartition.GetGainedPoints(rank++);
+ }
+ }
+
+ public string? GetTeamAccentColor(PlayerTeam winnerTeam, PlayerTeam playerTeam)
+ {
+ return winnerTeam == playerTeam || winnerTeam == PlayerTeam.Unknown
+ ? _teamColors[playerTeam]
+ : null;
+ }
+
+ public PlayerTeam GetWinnerTeam(List checkpoints)
+ {
+ var teamPoints = new Dictionary
+ {
+ { PlayerTeam.Unknown, 0 }, { PlayerTeam.Team1, 0 }, { PlayerTeam.Team2, 0 }
+ };
+
+ foreach (var cpData in checkpoints)
+ {
+ teamPoints[cpData.Player.Team] += cpData.GainedPoints;
+ }
+
+ return teamPoints.OrderByDescending(tp => tp.Value)
+ .First()
+ .Key;
+ }
+
+ public void CalculateAndSetTimeDifferenceOnResult(List checkpoints)
+ {
+ if (checkpoints.Count <= 1)
+ {
+ return;
+ }
+
+ var firstEntry = checkpoints.FirstOrDefault();
+ if (firstEntry == null)
+ {
+ return;
+ }
+
+ firstEntry.TimeDifference = null;
+ foreach (var cpData in checkpoints[1..])
+ {
+ cpData.TimeDifference = cpData.GetTimeDifferenceAbsolute(firstEntry);
+ }
+ }
+
+ public void SetGainedPointsBackgroundColorsOnResult(List checkpoints)
+ {
+ var winnerTeam = _isTeamsMode ? GetWinnerTeam(checkpoints) : PlayerTeam.Unknown;
+
+ foreach (var cpData in checkpoints.Where(checkpoint => checkpoint.GainedPoints > 0))
+ {
+ cpData.AccentColor = _isTeamsMode
+ ? GetTeamAccentColor(winnerTeam, cpData.Player.Team)
+ : (string)theme.Theme.UI_AccentPrimary;
+ }
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt
new file mode 100644
index 000000000..66d68aacc
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/RoundRankingModule/Templates/Components/RoundRankingStyles.mt b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingStyles.mt
new file mode 100644
index 000000000..1121e4ed0
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingStyles.mt
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/RoundRankingModule/Templates/RoundRanking.mt b/src/Modules/RoundRankingModule/Templates/RoundRanking.mt
new file mode 100644
index 000000000..fcd9e4fef
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/RoundRanking.mt
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/RoundRankingModule/Themes/DefaultRoundRankingTheme.cs b/src/Modules/RoundRankingModule/Themes/DefaultRoundRankingTheme.cs
new file mode 100644
index 000000000..87c1cb1c2
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Themes/DefaultRoundRankingTheme.cs
@@ -0,0 +1,28 @@
+using EvoSC.Common.Interfaces.Themes;
+using EvoSC.Common.Themes;
+using EvoSC.Common.Themes.Attributes;
+using EvoSC.Common.Util;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Themes;
+
+[Theme(Name = "Round Ranking", Description = "Default theme for the round rankings widget.")]
+public class DefaultRoundRankingTheme(IThemeManager theme) : Theme
+{
+ private readonly dynamic _theme = theme.Theme;
+
+ public override Task ConfigureAsync()
+ {
+ Set("UI.RoundRankingModule.Widget.AccentColor").To((int pos) => pos switch
+ {
+ 1 => _theme.Gold,
+ 2 => _theme.Silver,
+ 3 => _theme.Bronze,
+ _ => _theme.UI_AccentPrimary
+ });
+
+ Set("UI.RoundRankingModule.Widget.RowBg").To(_theme.UI_BgPrimary);
+ Set("UI.RoundRankingModule.Widget.RowBgHighlight").To(ColorUtils.Lighten(_theme.UI_BgPrimary));
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/RoundRankingModule/info.toml b/src/Modules/RoundRankingModule/info.toml
new file mode 100644
index 000000000..26cca330e
--- /dev/null
+++ b/src/Modules/RoundRankingModule/info.toml
@@ -0,0 +1,6 @@
+[info]
+name = "RoundRankingModule"
+title = "Round Ranking"
+summary = "Shows positions of players for ongoing rounds."
+version = "1.0.0"
+author = "Evo"
diff --git a/src/Modules/Scoreboard/Controllers/ScoreboardCommandsController.cs b/src/Modules/Scoreboard/Controllers/ScoreboardCommandsController.cs
deleted file mode 100644
index e36e18395..000000000
--- a/src/Modules/Scoreboard/Controllers/ScoreboardCommandsController.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using EvoSC.Commands.Attributes;
-using EvoSC.Commands.Interfaces;
-using EvoSC.Common.Controllers;
-using EvoSC.Common.Controllers.Attributes;
-using EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-namespace EvoSC.Modules.Official.Scoreboard.Controllers;
-
-[Controller]
-public class ScoreboardCommandsController(IScoreboardService scoreboardService) : EvoScController
-{
- [ChatCommand("scoreboard", "[Command.ShowScoreboard]")]
- public async Task ShowScoreboardAsync()
- {
- await scoreboardService.ShowScoreboardAsync(Context.Player);
- }
-}
diff --git a/src/Modules/Scoreboard/Controllers/ScoreboardEventController.cs b/src/Modules/Scoreboard/Controllers/ScoreboardEventController.cs
deleted file mode 100644
index 466a5545f..000000000
--- a/src/Modules/Scoreboard/Controllers/ScoreboardEventController.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using EvoSC.Common.Controllers;
-using EvoSC.Common.Controllers.Attributes;
-using EvoSC.Common.Events.Attributes;
-using EvoSC.Common.Interfaces.Controllers;
-using EvoSC.Common.Remote;
-using EvoSC.Common.Remote.EventArgsModels;
-using EvoSC.Modules.Official.MatchManagerModule.Events;
-using EvoSC.Modules.Official.MatchManagerModule.Events.EventArgObjects;
-using EvoSC.Modules.Official.Scoreboard.Interfaces;
-using GbxRemoteNet.Events;
-
-namespace EvoSC.Modules.Official.Scoreboard.Controllers;
-
-[Controller]
-public class ScoreboardEventController(IScoreboardService scoreboardService) : EvoScController
-{
- [Subscribe(GbxRemoteEvent.BeginMap)]
- public async Task OnBeginMapAsync(object sender, MapGbxEventArgs args)
- {
- await scoreboardService.LoadAndSendRequiredAdditionalInfoAsync();
- await scoreboardService.ShowScoreboardToAllAsync();
- }
-
- [Subscribe(MatchSettingsEvent.MatchSettingsLoaded)]
- public async Task OnMatchSettingsLoadedAsync(object sender, MatchSettingsLoadedEventArgs args)
- {
- await scoreboardService.LoadAndSendRequiredAdditionalInfoAsync();
- await scoreboardService.ShowScoreboardToAllAsync();
- }
-
- [Subscribe(ModeScriptEvent.StartRoundStart)]
- public async Task OnRoundStartAsync(object sender, RoundEventArgs args)
- {
- scoreboardService.SetCurrentRound(args.Count);
- await scoreboardService.SendRequiredAdditionalInfoAsync();
- }
-}
diff --git a/src/Modules/Scoreboard/Controllers/ScoreboardManialinkController.cs b/src/Modules/Scoreboard/Controllers/ScoreboardManialinkController.cs
deleted file mode 100644
index 70be12211..000000000
--- a/src/Modules/Scoreboard/Controllers/ScoreboardManialinkController.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using EvoSC.Common.Controllers.Attributes;
-using EvoSC.Manialinks;
-using EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-namespace EvoSC.Modules.Official.Scoreboard.Controllers;
-
-[Controller]
-public class ScoreboardManialinkController(IScoreboardService scoreboardService) : ManialinkController
-{
- public Task ResendScoreboardAsync() => scoreboardService.ShowScoreboardAsync(Context.Player);
-}
diff --git a/src/Modules/Scoreboard/Interfaces/IScoreboardService.cs b/src/Modules/Scoreboard/Interfaces/IScoreboardService.cs
deleted file mode 100644
index 2e6a4d11f..000000000
--- a/src/Modules/Scoreboard/Interfaces/IScoreboardService.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using EvoSC.Common.Interfaces.Models;
-
-namespace EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-public interface IScoreboardService
-{
- ///
- /// Sends the scoreboard manialink to all players.
- ///
- public Task ShowScoreboardToAllAsync();
-
- ///
- /// Sends the scoreboard manialink to a specific players.
- ///
- public Task ShowScoreboardAsync(IPlayer playerLogin);
-
- ///
- /// Hide the default game scoreboard.
- ///
- public Task HideNadeoScoreboardAsync();
-
- ///
- /// Shows the default game scoreboard.
- ///
- public Task ShowNadeoScoreboardAsync();
-
- ///
- /// Sends the manialink with additional values used by the scoreboard.
- ///
- public Task SendRequiredAdditionalInfoAsync();
-
- ///
- /// Refreshes the additionally required data and sends the manialink.
- ///
- public Task LoadAndSendRequiredAdditionalInfoAsync();
-
- ///
- /// Sets the current round.
- ///
- public void SetCurrentRound(int round);
-}
diff --git a/src/Modules/Scoreboard/Interfaces/IScoreboardTrackerService.cs b/src/Modules/Scoreboard/Interfaces/IScoreboardTrackerService.cs
deleted file mode 100644
index c00c7d63a..000000000
--- a/src/Modules/Scoreboard/Interfaces/IScoreboardTrackerService.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-public interface IScoreboardTrackerService
-{
- public int RoundsPerMap { get; set; }
-
- public int PointsLimit { get; set; }
-
- public int CurrentRound { get; set; }
-
- public int MaxPlayers { get; set; }
-}
diff --git a/src/Modules/Scoreboard/ScoreboardModule.cs b/src/Modules/Scoreboard/ScoreboardModule.cs
deleted file mode 100644
index 95bf6804a..000000000
--- a/src/Modules/Scoreboard/ScoreboardModule.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using EvoSC.Modules.Attributes;
-using EvoSC.Modules.Interfaces;
-using EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-namespace EvoSC.Modules.Official.Scoreboard;
-
-[Module(IsInternal = true)]
-public class ScoreboardModule(IScoreboardService scoreboardService) : EvoScModule, IToggleable
-{
- public Task EnableAsync()
- {
- scoreboardService.LoadAndSendRequiredAdditionalInfoAsync();
- scoreboardService.HideNadeoScoreboardAsync();
-
- return scoreboardService.ShowScoreboardToAllAsync();
- }
-
- public Task DisableAsync()
- {
- return scoreboardService.ShowNadeoScoreboardAsync();
- }
-}
diff --git a/src/Modules/Scoreboard/Services/ScoreboardTrackerService.cs b/src/Modules/Scoreboard/Services/ScoreboardTrackerService.cs
deleted file mode 100644
index bbf52a0e6..000000000
--- a/src/Modules/Scoreboard/Services/ScoreboardTrackerService.cs
+++ /dev/null
@@ -1,97 +0,0 @@
-using EvoSC.Common.Services.Attributes;
-using EvoSC.Common.Services.Models;
-using EvoSC.Modules.Official.Scoreboard.Interfaces;
-
-namespace EvoSC.Modules.Official.Scoreboard.Services;
-
-[Service(LifeStyle = ServiceLifeStyle.Singleton)]
-public class ScoreboardTrackerService : IScoreboardTrackerService
-{
- private int _roundsPerMap = -1;
- private readonly object _roundsPerMapLock = new();
-
- private int _pointsLimit = -1;
- private readonly object _pointsLimitLock = new();
-
- private int _currentRound = -1;
- private readonly object _currentRoundLock = new();
-
- private int _maxPlayers = -1;
- private readonly object _maxPlayersLock = new();
-
- public int RoundsPerMap
- {
- get
- {
- lock (_roundsPerMapLock)
- {
- return _roundsPerMap;
- }
- }
-
- set
- {
- lock (_roundsPerMapLock)
- {
- _roundsPerMap = value;
- }
- }
- }
-
- public int PointsLimit
- {
- get
- {
- lock (_pointsLimitLock)
- {
- return _pointsLimit;
- }
- }
-
- set
- {
- lock (_pointsLimitLock)
- {
- _pointsLimit = value;
- }
- }
- }
-
- public int CurrentRound
- {
- get
- {
- lock (_currentRoundLock)
- {
- return _currentRound;
- }
- }
-
- set
- {
- lock (_currentRoundLock)
- {
- _currentRound = value;
- }
- }
- }
-
- public int MaxPlayers
- {
- get
- {
- lock (_maxPlayersLock)
- {
- return _maxPlayers;
- }
- }
-
- set
- {
- lock (_maxPlayersLock)
- {
- _maxPlayers = value;
- }
- }
- }
-}
diff --git a/src/Modules/Scoreboard/Templates/Components/BackgroundBox.mt b/src/Modules/Scoreboard/Templates/Components/BackgroundBox.mt
deleted file mode 100644
index 55fbe511d..000000000
--- a/src/Modules/Scoreboard/Templates/Components/BackgroundBox.mt
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/CustomLabelBackground.mt b/src/Modules/Scoreboard/Templates/Components/PlayerRow/CustomLabelBackground.mt
deleted file mode 100644
index c21412bdf..000000000
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/CustomLabelBackground.mt
+++ /dev/null
@@ -1,51 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/Framemodel.mt b/src/Modules/Scoreboard/Templates/Components/PlayerRow/Framemodel.mt
deleted file mode 100644
index 37cdafd0d..000000000
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/Framemodel.mt
+++ /dev/null
@@ -1,195 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerRowBackground.mt b/src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerRowBackground.mt
deleted file mode 100644
index ad4154156..000000000
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerRowBackground.mt
+++ /dev/null
@@ -1,139 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PointsBox.mt b/src/Modules/Scoreboard/Templates/Components/PlayerRow/PointsBox.mt
deleted file mode 100644
index fcabef295..000000000
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PointsBox.mt
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PositionBox.mt b/src/Modules/Scoreboard/Templates/Components/PlayerRow/PositionBox.mt
deleted file mode 100644
index 9af6a75eb..000000000
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PositionBox.mt
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/ScoreboardHeader.mt b/src/Modules/Scoreboard/Templates/Components/ScoreboardHeader.mt
deleted file mode 100644
index 3ddbd19e8..000000000
--- a/src/Modules/Scoreboard/Templates/Components/ScoreboardHeader.mt
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Templates/Components/Scrollbar.mt b/src/Modules/Scoreboard/Templates/Components/Scrollbar.mt
deleted file mode 100644
index 61d55dddb..000000000
--- a/src/Modules/Scoreboard/Templates/Components/Scrollbar.mt
+++ /dev/null
@@ -1,73 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/src/Modules/Scoreboard/Themes/DefaultScoreboardTheme.cs b/src/Modules/Scoreboard/Themes/DefaultScoreboardTheme.cs
deleted file mode 100644
index e9a88b652..000000000
--- a/src/Modules/Scoreboard/Themes/DefaultScoreboardTheme.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using EvoSC.Common.Interfaces.Themes;
-using EvoSC.Common.Themes;
-using EvoSC.Common.Themes.Attributes;
-using EvoSC.Common.Util;
-
-namespace EvoSC.Modules.Official.Scoreboard.Themes;
-
-[Theme(Name = "Scoreboard", Description = "Default theme for the scoreboard.")]
-public class DefaultScoreboardTheme(IThemeManager theme) : Theme
-{
- private readonly dynamic _theme = theme.Theme;
-
- public override Task ConfigureAsync()
- {
- Set("ScoreboardModule.BackgroundBox.BgHeader").To(_theme.UI_BgPrimary);
- Set("ScoreboardModule.BackgroundBox.BgHeaderGrad").To(ColorUtils.Darken(_theme.UI_BgPrimary));
- Set("ScoreboardModule.BackgroundBox.BgList").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.ScoreboardHeader.Text").To(_theme.UI_TextPrimary);
- Set("ScoreboardModule.ScoreboardHeader.Logo").To(_theme.UI_LogoLight);
-
- Set("ScoreboardModule.ClubTag.Bg").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.PlayerRow.Text").To(_theme.UI_TextPrimary);
- Set("ScoreboardModule.PlayerRow.CustomLabelBackground.Bg").To(_theme.Black);
-
- Set("ScoreboardModule.PlayerRow.PlayerActions.BgHighlight").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.PlayerRow.PlayerRowBackground.Bg").To(ColorUtils.Lighten(_theme.UI_BgHighlight));
- Set("ScoreboardModule.PlayerRow.PlayerRowBackground.BgHighlight").To(ColorUtils.SetLightness(_theme.UI_BgHighlight, 70));
-
- Set("ScoreboardModule.PlayerRow.PointsBox.Bg").To(ColorUtils.SetLightness(_theme.UI_BgHighlight, 70));
- Set("ScoreboardModule.PlayerRow.PointsBox.Text").To(ColorUtils.SetLightness(_theme.UI_BgHighlight, 20));
-
- Set("ScoreboardModule.PlayerRow.PositionBox.Bg").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.PlayerRow.FrameModel.Bg").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.PlayerRow.FrameModel.Text").To(_theme.UI_TextPrimary);
- Set("ScoreboardModule.PlayerRow.FrameModel.BgRow").To(_theme.UI_BgHighlight);
- Set("ScoreboardModule.PlayerRow.FrameModel.TextRoundPoints").To(_theme.UI_TextSecondary);
-
- Set("ScoreboardModule.Scoreboard.BgPosition").To(_theme.UI_BgHighlight);
-
- Set("ScoreboardModule.Settings.Text").To(_theme.UI_TextPrimary);
-
- return Task.CompletedTask;
- }
-}
diff --git a/src/Modules/ScoreboardModule/Config/IScoreboardSettings.cs b/src/Modules/ScoreboardModule/Config/IScoreboardSettings.cs
new file mode 100644
index 000000000..60b50627b
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Config/IScoreboardSettings.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Config;
+
+[Settings]
+public interface IScoreboardSettings
+{
+ [Option(DefaultValue = 160.0), Description("Sets the width of the scoreboard.")]
+ public double Width { get; }
+
+ [Option(DefaultValue = 80.0), Description("Sets the height of the scoreboard.")]
+ public double Height { get; }
+}
diff --git a/src/Modules/ScoreboardModule/Controllers/ScoreboardEventController.cs b/src/Modules/ScoreboardModule/Controllers/ScoreboardEventController.cs
new file mode 100644
index 000000000..9943a7af6
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Controllers/ScoreboardEventController.cs
@@ -0,0 +1,28 @@
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Events.Attributes;
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Remote;
+using EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+using GbxRemoteNet.Events;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Controllers;
+
+[Controller]
+public class ScoreboardEventController(
+ IScoreboardService scoreboardService,
+ IScoreboardNicknamesService nicknamesService
+)
+ : EvoScController
+{
+ [Subscribe(GbxRemoteEvent.PlayerConnect)]
+ public Task OnPlayerConnectAsync(object sender, PlayerGbxEventArgs args) =>
+ nicknamesService.AddNicknameByLoginAsync(args.Login);
+
+ [Subscribe(GbxRemoteEvent.BeginMap)]
+ public async Task OnBeginMapAsync(object sender, MapGbxEventArgs args)
+ {
+ await nicknamesService.LoadNicknamesAsync();
+ await scoreboardService.SendScoreboardAsync();
+ }
+}
diff --git a/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs b/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs
new file mode 100644
index 000000000..a3023558b
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs
@@ -0,0 +1,50 @@
+namespace EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+public interface IScoreboardNicknamesService
+{
+ ///
+ /// Gets the online player by login and then sets their custom nickname in the repo.
+ ///
+ ///
+ ///
+ public Task AddNicknameByLoginAsync(string login);
+
+ ///
+ /// Clears the nicknames repo.
+ ///
+ ///
+ public Task ClearNicknamesAsync();
+
+ ///
+ /// Gets all online players and sets their custom nicknames in the repo.
+ ///
+ ///
+ public Task LoadNicknamesAsync();
+
+ ///
+ /// Sends the manialink containing the nicknames in the repo.
+ ///
+ ///
+ public Task SendNicknamesManialinkAsync();
+
+ ///
+ /// Converts the nickname repo to a ManiaScript array.
+ ///
+ ///
+ ///
+ public string ToManiaScriptArray(Dictionary nicknameMap);
+
+ ///
+ /// Converts an entry of the nickname repo to a ManiaScript array entry.
+ ///
+ ///
+ ///
+ public string ToManiaScriptArrayEntry(KeyValuePair loginNickname);
+
+ ///
+ /// Escapes a nickname to be safely inserted into a XMl comment.
+ ///
+ ///
+ ///
+ public string EscapeNickname(string nickname);
+}
diff --git a/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs b/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs
new file mode 100644
index 000000000..9dc5c0624
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs
@@ -0,0 +1,19 @@
+namespace EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+public interface IScoreboardService
+{
+ ///
+ /// Sends the scoreboard manialink to all players.
+ ///
+ public Task SendScoreboardAsync();
+
+ ///
+ /// Hide the default game scoreboard.
+ ///
+ public Task HideNadeoScoreboardAsync();
+
+ ///
+ /// Shows the default game scoreboard.
+ ///
+ public Task ShowNadeoScoreboardAsync();
+}
diff --git a/src/Modules/ScoreboardModule/Localization.resx b/src/Modules/ScoreboardModule/Localization.resx
new file mode 100644
index 000000000..02cf34b4b
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Localization.resx
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
\ No newline at end of file
diff --git a/src/Modules/ScoreboardModule/ScoreboardModule.cs b/src/Modules/ScoreboardModule/ScoreboardModule.cs
new file mode 100644
index 000000000..6d8235ca3
--- /dev/null
+++ b/src/Modules/ScoreboardModule/ScoreboardModule.cs
@@ -0,0 +1,20 @@
+using EvoSC.Modules.Attributes;
+using EvoSC.Modules.Interfaces;
+using EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+namespace EvoSC.Modules.Official.ScoreboardModule;
+
+[Module(IsInternal = true)]
+public class ScoreboardModule(IScoreboardService scoreboardService, IScoreboardNicknamesService nicknamesService)
+ : EvoScModule, IToggleable
+{
+ public async Task EnableAsync()
+ {
+ await nicknamesService.LoadNicknamesAsync();
+ await scoreboardService.HideNadeoScoreboardAsync();
+ await scoreboardService.SendScoreboardAsync();
+ }
+
+ public Task DisableAsync() =>
+ scoreboardService.ShowNadeoScoreboardAsync();
+}
diff --git a/src/Modules/Scoreboard/Scoreboard.csproj b/src/Modules/ScoreboardModule/ScoreboardModule.csproj
similarity index 93%
rename from src/Modules/Scoreboard/Scoreboard.csproj
rename to src/Modules/ScoreboardModule/ScoreboardModule.csproj
index b1ff40dea..456dbbf3f 100644
--- a/src/Modules/Scoreboard/Scoreboard.csproj
+++ b/src/Modules/ScoreboardModule/ScoreboardModule.csproj
@@ -4,7 +4,7 @@
net8.0
enable
enable
- EvoSC.Modules.Official.Scoreboard
+ EvoSC.Modules.Official.ScoreboardModule
diff --git a/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs b/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs
new file mode 100644
index 000000000..7619688c8
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs
@@ -0,0 +1,68 @@
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class ScoreboardNicknamesService(
+ IPlayerManagerService playerManagerService,
+ IManialinkManager manialinkManager
+) : IScoreboardNicknamesService
+{
+ private readonly Dictionary _nicknames = new();
+
+ public async Task AddNicknameByLoginAsync(string login)
+ {
+ var player = await playerManagerService.GetOnlinePlayerAsync(PlayerUtils.ConvertLoginToAccountId(login));
+
+ if (player.NickName == player.UbisoftName)
+ {
+ return;
+ }
+
+ _nicknames[login] = player.NickName;
+ }
+
+ public Task ClearNicknamesAsync()
+ {
+ _nicknames.Clear();
+
+ return Task.CompletedTask;
+ }
+
+ public async Task LoadNicknamesAsync()
+ {
+ var onlinePlayers = await playerManagerService.GetOnlinePlayersAsync();
+ foreach (var player in onlinePlayers.Where(player => player.NickName != player.UbisoftName))
+ {
+ _nicknames[player.GetLogin()] = player.NickName;
+ }
+ }
+
+ public Task SendNicknamesManialinkAsync() =>
+ manialinkManager.SendPersistentManialinkAsync("ScoreboardModule.PlayerNicknames",
+ new { nicknames = ToManiaScriptArray(_nicknames) });
+
+ public string ToManiaScriptArray(Dictionary nicknameMap)
+ {
+ var entriesList = nicknameMap.Select(ToManiaScriptArrayEntry).ToList();
+ var joinedEntries = string.Join(",\n", entriesList);
+
+ return $"[{joinedEntries}]";
+ }
+
+ public string ToManiaScriptArrayEntry(KeyValuePair loginNickname)
+ {
+ return $"\"{loginNickname.Key}\" => \"{EscapeNickname(loginNickname.Value)}\"";
+ }
+
+ public string EscapeNickname(string nickname)
+ {
+ return nickname.Replace("-->", "-\u2192", StringComparison.OrdinalIgnoreCase)
+ .Replace("\"", "\\\"", StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Modules/ScoreboardModule/Services/ScoreboardService.cs b/src/Modules/ScoreboardModule/Services/ScoreboardService.cs
new file mode 100644
index 000000000..f66570308
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Services/ScoreboardService.cs
@@ -0,0 +1,64 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Official.GameModeUiModule.Enums;
+using EvoSC.Modules.Official.GameModeUiModule.Interfaces;
+using EvoSC.Modules.Official.ScoreboardModule.Config;
+using EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class ScoreboardService(
+ IManialinkManager manialinks,
+ IServerClient server,
+ IScoreboardNicknamesService nicknamesService,
+ IScoreboardSettings settings,
+ IGameModeUiModuleService gameModeUiModuleService,
+ IMatchSettingsService matchSettingsService
+)
+ : IScoreboardService
+{
+ private const string ScoreboardTemplate = "ScoreboardModule.Scoreboard";
+
+ public async Task SendScoreboardAsync()
+ {
+ await manialinks.SendPersistentManialinkAsync(ScoreboardTemplate, await GetDataAsync());
+ await nicknamesService.SendNicknamesManialinkAsync();
+ }
+
+ private async Task GetDataAsync()
+ {
+ var currentNextMaxPlayers = await server.Remote.GetMaxPlayersAsync();
+ var currentNextMaxSpectators = await server.Remote.GetMaxSpectatorsAsync();
+ var modeScriptSettings = await matchSettingsService.GetCurrentScriptSettingsAsync();
+
+ return new
+ {
+ settings,
+ MaxPlayers = currentNextMaxPlayers.CurrentValue + currentNextMaxSpectators.CurrentValue,
+ PointsLimit = (int)(modeScriptSettings?["S_PointsLimit"] ?? 0),
+ RoundsPerMap = (int)(modeScriptSettings?["S_RoundsPerMap"] ?? 0),
+ };
+ }
+
+ public Task HideNadeoScoreboardAsync() =>
+ gameModeUiModuleService.ApplyComponentSettingsAsync(
+ GameModeUiComponents.ScoresTable,
+ false,
+ 0.0,
+ 0.0,
+ 1.0
+ );
+
+ public Task ShowNadeoScoreboardAsync() =>
+ gameModeUiModuleService.ApplyComponentSettingsAsync(
+ GameModeUiComponents.ScoresTable,
+ true,
+ 0.0,
+ 0.0,
+ 1.0
+ );
+}
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Body/Legend.mt b/src/Modules/ScoreboardModule/Templates/Components/Body/Legend.mt
new file mode 100644
index 000000000..b0ad6e0a8
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Body/Legend.mt
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderBackground.mt b/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderBackground.mt
new file mode 100644
index 000000000..78adc18e1
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderBackground.mt
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderContent.mt b/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderContent.mt
new file mode 100644
index 000000000..4415f8cc2
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderContent.mt
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Header/Logo.mt b/src/Modules/ScoreboardModule/Templates/Components/Header/Logo.mt
new file mode 100644
index 000000000..c1a8ad19a
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Header/Logo.mt
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/CustomLabelBackground.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/CustomLabelBackground.mt
new file mode 100644
index 000000000..74cf71d74
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/CustomLabelBackground.mt
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/Flag.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/Flag.mt
new file mode 100644
index 000000000..a53876481
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/Flag.mt
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/Framemodel.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/Framemodel.mt
new file mode 100644
index 000000000..e38ae2fbe
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/Framemodel.mt
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerActions.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt
similarity index 59%
rename from src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerActions.mt
rename to src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt
index 58ad3f4de..45d13b8a1 100644
--- a/src/Modules/Scoreboard/Templates/Components/PlayerRow/PlayerActions.mt
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt
@@ -2,36 +2,36 @@
-
-
+
-
+
+ focusareacolor1="{{ Theme.UI_HeaderBg }}"
+ focusareacolor2="{{ Theme.ScoreboardModule_Background_Hover_Color }}"
+ textcolor="{{ Theme.ScoreboardModule_Text_Color }}"/>
+ focusareacolor1="{{ Theme.UI_HeaderBg }}"
+ focusareacolor2="{{ Theme.ScoreboardModule_Background_Hover_Color }}"
+ textcolor="{{ Theme.ScoreboardModule_Text_Color }}"/>
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerRowBackground.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerRowBackground.mt
new file mode 100644
index 000000000..8499ed831
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerRowBackground.mt
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/PositionBox.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/PositionBox.mt
new file mode 100644
index 000000000..9286387b3
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/PositionBox.mt
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBg.mt b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBg.mt
new file mode 100644
index 000000000..edf86e156
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBg.mt
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBody.mt b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBody.mt
new file mode 100644
index 000000000..403d4b037
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardBody.mt
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/ScoreboardHeader.mt b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardHeader.mt
new file mode 100644
index 000000000..9f7d22109
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardHeader.mt
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/Scoreboard/Templates/Components/Settings/Form.mt b/src/Modules/ScoreboardModule/Templates/Components/Settings/Form.mt
similarity index 100%
rename from src/Modules/Scoreboard/Templates/Components/Settings/Form.mt
rename to src/Modules/ScoreboardModule/Templates/Components/Settings/Form.mt
diff --git a/src/Modules/Scoreboard/Templates/Components/Settings/Wrapper.mt b/src/Modules/ScoreboardModule/Templates/Components/Settings/Wrapper.mt
similarity index 100%
rename from src/Modules/Scoreboard/Templates/Components/Settings/Wrapper.mt
rename to src/Modules/ScoreboardModule/Templates/Components/Settings/Wrapper.mt
diff --git a/src/Modules/ScoreboardModule/Templates/PlayerNicknames.mt b/src/Modules/ScoreboardModule/Templates/PlayerNicknames.mt
new file mode 100644
index 000000000..5a252187d
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/PlayerNicknames.mt
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/Modules/Scoreboard/Templates/RoundsInfo.mt b/src/Modules/ScoreboardModule/Templates/RoundsInfo.mt
similarity index 100%
rename from src/Modules/Scoreboard/Templates/RoundsInfo.mt
rename to src/Modules/ScoreboardModule/Templates/RoundsInfo.mt
diff --git a/src/Modules/Scoreboard/Templates/Scoreboard.mt b/src/Modules/ScoreboardModule/Templates/Scoreboard.mt
similarity index 55%
rename from src/Modules/Scoreboard/Templates/Scoreboard.mt
rename to src/Modules/ScoreboardModule/Templates/Scoreboard.mt
index ed59bcc16..41e53b09e 100644
--- a/src/Modules/Scoreboard/Templates/Scoreboard.mt
+++ b/src/Modules/ScoreboardModule/Templates/Scoreboard.mt
@@ -1,84 +1,85 @@
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
-
+
+
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
-
-
@@ -115,7 +116,6 @@
declare Integer ScrollIndex;
declare Integer MaxPlayers;
declare CMlFrame RowsFrame;
- declare Boolean SettingsVisible;
declare Text[Integer] PositionColors;
Boolean ShouldShowPointsBox() {
@@ -125,50 +125,50 @@
}
Void UpdateScoreboardLayout() {
- declare persistent Boolean SB_Setting_ShowClubTags for LocalUser = True;
- declare persistent Boolean SB_Setting_ShowFlags for LocalUser = True;
- declare shouldShowPoints = ShouldShowPointsBox();
- declare Real flagWidth = {{ rowInnerHeight }} * 2.0;
- declare Real innerSpacing = {{ innerSpacing }} * 1.0;
+ UpdateLegend(ShouldShowPointsBox());
+ }
+
+ Text StripLeadingZeroes(Text timeString) {
+ return TL::RegexReplace("^[0.:]+", timeString, "", "");
+ }
+
+ Text StyleTime(Text timeString) {
+ declare mutedTextColor = "{{ Color.ToTextColor(Theme.UI_TextMuted) }}";
+ declare primaryTextColor = "{{ Color.ToTextColor(Theme.ScoreboardModule_Text_Color) }}";
+
+ if(timeString == "0:00.000") {
+ return mutedTextColor ^ "0" ^ timeString;
+ }
+
+ declare endPart = StripLeadingZeroes(timeString);
+ declare startPart = TL::Replace(timeString, endPart, "");
- foreach(Control in RowsFrame.Controls){
- declare Real offset = 0.0;
- declare playerRow = (Control as CMlFrame);
- declare pointsBoxFrame = (playerRow.GetFirstChild("points_box") as CMlFrame);
- declare flagQuad = (playerRow.GetFirstChild("flag") as CMlQuad);
- declare clubQuad = (playerRow.GetFirstChild("club_bg") as CMlQuad);
- declare clubLabel = (playerRow.GetFirstChild("club") as CMlLabel);
- declare nameLabel = (playerRow.GetFirstChild("name") as CMlLabel);
- declare pointsLabel = (playerRow.GetFirstChild("points") as CMlLabel);
-
- pointsBoxFrame.Visible = shouldShowPoints;
- pointsLabel.Visible = shouldShowPoints;
-
- if(SB_Setting_ShowFlags){
- flagQuad.RelativePosition_V3.X = offset;
- flagQuad.Show();
- offset += flagWidth;
- }else{
- flagQuad.Hide();
- }
-
- if(SB_Setting_ShowClubTags){
- clubQuad.RelativePosition_V3.X = offset;
- clubLabel.RelativePosition_V3.X = offset + (flagWidth / 2.0);
- clubQuad.Show();
- clubLabel.Show();
- offset += flagWidth;
- }else{
- clubQuad.Hide();
- clubLabel.Hide();
- }
-
- nameLabel.RelativePosition_V3.X = offset + innerSpacing;
+ return mutedTextColor ^ startPart ^ primaryTextColor ^ endPart;
+ }
+
+ Text StylePoints(Text points, Integer padding) {
+ declare mutedTextColor = "{{ Color.ToTextColor(Theme.UI_TextMuted) }}";
+ declare primaryTextColor = "{{ Color.ToTextColor(Theme.ScoreboardModule_Text_Color) }}";
+ declare out = mutedTextColor;
+
+ for(i, 1, padding - TL::Length(points)){
+ out ^= "0";
}
+
+ if(points == "0"){
+ return out ^ points;
+ }
+
+ return out ^ primaryTextColor ^ points;
}
- Text StripLeadingZeroes(Text input) {
- return TL::RegexReplace("^[0.:]+", input, "", "");
+ Text GetPlayerBestTimeStyled(CSmScore score) {
+ if(score.BestRaceTimes.count == 0){
+ return StyleTime("0:00.000");
+ }
+
+ declare bestTime = score.BestRaceTimes[score.BestRaceTimes.count - 1];
+ return StyleTime(TL::TimeToText(bestTime, True, True));
}
Integer[CSmScore] GetSortedScores() {
@@ -199,39 +199,11 @@
Void SetCountryFlag(CMlQuad flagQuad, Text login){
if(login != "" && !TL::StartsWith("*fakeplayer", login)){
flagQuad.ImageUrl = "file://ZoneFlags/Login/" ^ login ^ "/country";
- flagQuad.ModulateColor = <1.0, 1.0, 1.0>;
flagQuad.Opacity = 1.0;
}else{
- flagQuad.ImageUrl = "file://Media/Manialinks/Nadeo/TMNext/Menus/Common/Common_Flag_Mask.dds";
- flagQuad.ModulateColor = <0.0, 0.0, 0.0>;
- flagQuad.Opacity = 0.25;
- }
- }
-
- Void SetCustomLabel(CMlFrame playerRow, Text value, Text hexColor){
- declare customLabel = (playerRow.GetFirstChild("custom_label") as CMlLabel);
- declare customGradientFrame = (playerRow.GetFirstChild("custom_gradient") as CMlFrame);
-
- customLabel.Value = value;
- customLabel.TextColor = CL::HexToRgb(hexColor);
-
- Page.GetClassChildren("modulate", customGradientFrame, True);
- foreach(Control in Page.GetClassChildren_Result){
- (Control as CMlQuad).ModulateColor = customLabel.TextColor;
- }
- Page.GetClassChildren("set", customGradientFrame, True);
- foreach(Control in Page.GetClassChildren_Result){
- (Control as CMlQuad).BgColor = customLabel.TextColor;
+ flagQuad.ImageUrl = "file://ZoneFlags/World";
+ flagQuad.Opacity = 1.0;
}
-
- customGradientFrame.Show();
- }
-
- Void HideCustomLabel(CMlFrame playerRow){
- declare customLabel = (playerRow.GetFirstChild("custom_label") as CMlLabel);
- declare customGradientFrame = (playerRow.GetFirstChild("custom_gradient") as CMlFrame);
- customLabel.Value = "";
- customGradientFrame.Hide();
}
Void UpdateScoreAndPoints(CSmScore Score, CMlFrame playerRow, Integer position){
@@ -250,11 +222,14 @@
playerScore <=> Score;
declare scoreLabel = (playerRow.GetFirstChild("score") as CMlLabel);
+ declare bestTimeLabel = (playerRow.GetFirstChild("best_time") as CMlLabel);
declare specDisconnectedLabel = (playerRow.GetFirstChild("spec_disconnected_label") as CMlLabel);
- declare pointsLabel = (playerRow.GetFirstChild("points") as CMlLabel);
declare roundPointsLabel = (playerRow.GetFirstChild("round_points") as CMlLabel);
declare customLabel = (playerRow.GetFirstChild("custom_label") as CMlLabel);
+ scoreLabel.Value = "";
+ roundPointsLabel.Value = "";
+
if (!(CustomPointsEnabled && CurrentScoreMode != C_Mode_Trophy)) {
HideCustomLabel(playerRow);
}
@@ -279,17 +254,17 @@
}
} else if (CurrentScoreMode == C_Mode_Points) {
customLabel.Value = "";
- pointsLabel.Value = TL::ToText(Score.Points);
+ scoreLabel.Value = TL::ToText(Score.Points);
colorizePosition = Score.Points > 0;
- if(Score.PrevRaceTimes.count > 0 && Score.PrevRaceTimes[Score.PrevRaceTimes.count - 1] > 0){
- scoreLabel.Value = TL::TimeToText(Score.PrevRaceTimes[Score.PrevRaceTimes.count - 1], True, True);
+ if(Score.BestRaceTimes.count > 0 && Score.BestRaceTimes[Score.BestRaceTimes.count - 1] > 0){
+ bestTimeLabel.Value = TL::TimeToText(Score.BestRaceTimes[Score.BestRaceTimes.count - 1], True, True);
}else{
declare CSmPlayer::ESpawnStatus Race_ScoresTable_SpawnStatus for Score = CSmPlayer::ESpawnStatus::NotSpawned;
if(Race_ScoresTable_SpawnStatus == CSmPlayer::ESpawnStatus::NotSpawned && PlayerIsConnected && !Race_ScoresTable_IsSpectator){
- scoreLabel.Value = "DNF";
+ roundPointsLabel.Value = "DNF";
}else{
- scoreLabel.Value = "";
+ roundPointsLabel.Value = "";
}
}
@@ -326,11 +301,11 @@
} else if (CurrentScoreMode == C_Mode_Laps && Score.BestRaceTimes.count > 0) {
customLabel.Value = "";
scoreLabel.Value = TL::TimeToText(Score.BestRaceTimes[Score.BestRaceTimes.count - 1], True, True);
- pointsLabel.Value = ""^Score.BestRaceTimes.count;
+ scoreLabel.Value = ""^Score.BestRaceTimes.count;
} else if (CurrentScoreMode == C_Mode_RaceProgression) {
customLabel.Value = "";
declare netread Int2 Net_TMxSM_ScoresTable_RaceProgression for Score;
- pointsLabel.Value = ""^Net_TMxSM_ScoresTable_RaceProgression.X;
+ scoreLabel.Value = ""^Net_TMxSM_ScoresTable_RaceProgression.X;
if (Net_TMxSM_ScoresTable_RaceProgression.Y > 0) {
scoreLabel.Value = TL::TimeToText(Net_TMxSM_ScoresTable_RaceProgression.Y, True, True);
colorizePosition = True;
@@ -345,29 +320,35 @@
scoreLabel.Value = "0:00.000";
}
- scoreLabel.Value = StripLeadingZeroes(scoreLabel.Value);
+ if(ShouldShowPointsBox()){
+ scoreLabel.Value = StylePoints(scoreLabel.Value, 3);
+ bestTimeLabel.Value = GetPlayerBestTimeStyled(Score);
+ }else{
+ scoreLabel.Value = StyleTime(scoreLabel.Value);
+ bestTimeLabel.Value = "";
+ }
declare positionBox = (playerRow.GetFirstChild("position_box") as CMlFrame);
declare playerRowBg = (playerRow.GetFirstChild("player_row_bg") as CMlFrame);
- if(PositionColors.existskey(position) && colorizePosition){
- declare positionColor = PositionColors[position];
- SetPositionBackgroundColor(positionBox, CL::HexToRgb(positionColor));
- SetPlayerHighlightColor(playerRowBg, CL::HexToRgb(positionColor));
- }else{
- SetPositionBackgroundColor(positionBox, CL::HexToRgb("{{ Theme.ScoreboardModule_Scoreboard_BgPosition }}"));
- SetPlayerHighlightColor(playerRowBg, CL::HexToRgb("{{ Theme.ScoreboardModule_Scoreboard_BgPosition }}"));
+ if({{ Theme.ScoreboardModule_PositionBox_ShowAccent }}){
+ if(PositionColors.existskey(position) && colorizePosition){
+ declare positionColor = PositionColors[position];
+ SetPositionBoxColor(positionBox, CL::HexToRgb(positionColor));
+ }else{
+ SetPositionBoxColor(positionBox, CL::HexToRgb("{{ Theme.UI_AccentPrimary }}"));
+ }
}
if (PlayerIsConnected) {
//connected
if(Race_ScoresTable_IsSpectator){
- specDisconnectedLabel.Value = "";
+ specDisconnectedLabel.Value = "{{ Icons.VideoCamera }}";
}else{
specDisconnectedLabel.Value = "";
}
}else{
//disconnected
- specDisconnectedLabel.Value = "";
+ specDisconnectedLabel.Value = "{{ Icons.UserTimes }}";
}
//align items
@@ -375,27 +356,19 @@
declare x = scoreLabel.RelativePosition_V3.X;
if(scoreLabel.Value != ""){
- offset += scoreLabel.ComputeWidth(scoreLabel.Value) + {{ innerSpacing }};
+ offset += scoreLabel.ComputeWidth(scoreLabel.Value) + {{ columnSpacing / 2.0 }};
}
customLabel.RelativePosition_V3.X = x - offset;
if(customLabel.Value != ""){
- offset += customLabel.ComputeWidth(customLabel.Value) + {{ innerSpacing }};
+ offset += customLabel.ComputeWidth(customLabel.Value) + {{ columnSpacing / 2.0 }};
}
roundPointsLabel.RelativePosition_V3.X = x - offset;
if(roundPointsLabel.Value != ""){
- offset += roundPointsLabel.ComputeWidth(roundPointsLabel.Value) + {{ innerSpacing }};
+ offset += roundPointsLabel.ComputeWidth(roundPointsLabel.Value) + {{ columnSpacing / 2.0 }};
}
specDisconnectedLabel.RelativePosition_V3.X = x - offset;
}
- Void SetMapAndAuthorName() {
- declare mapNameLabel <=> (Page.MainFrame.GetFirstChild("map_name") as CMlLabel);
- declare authorName <=> (Page.MainFrame.GetFirstChild("author_name") as CMlLabel);
-
- mapNameLabel.Value = Map.MapName;
- authorName.Value = Map.AuthorNickName;
- }
-
Text GetRecordText() {
declare Integer SB_PointsLimit for UI = -2;
@@ -410,47 +383,26 @@
return "AUTHOR TIME | " ^ TL::TimeToText(Map.TMObjective_AuthorTime, True, True);
}
- Void UpdateHeaderInfo() {
- declare subTextLabel <=> (Page.MainFrame.GetFirstChild("sub_text") as CMlLabel);
- declare roundLabel <=> (Page.MainFrame.GetFirstChild("round_label") as CMlLabel);
-
- subTextLabel.Value = GetRecordText();
-
- declare Owner <=> MV_Utils::GetOwner(This);
+ Void UpdateScrollSize(Integer playerRowsFilled) {
+ declare filledHeight = playerRowsFilled * {{ rowHeight + rowSpacing }};
+ declare contentHeight = {{ settings.Height - headerHeight - legendHeight }};
- if (CurrentScoreMode == C_Mode_BestTime || CurrentScoreMode == C_Mode_PrevTime){
- declare timeLimit = RaceHelpers::GetTimeLimit(Teams[0]);
- roundLabel.Value = "TIME LIMIT | ";
- if(timeLimit <= 0){
- roundLabel.Value ^= "UNLIMITED";
- }else{
- roundLabel.Value ^= TL::TimeToText(timeLimit);
- }
- }else if (CurrentScoreMode == C_Mode_LapTime || CurrentScoreMode == C_Mode_Laps){
- declare Integer LapCurrent = -1;
- if(Owner != Null){
- declare Integer LapCurrent = RaceHelpers::GetPlayerLap(Owner);
- }
- declare LapsTotal = RaceHelpers::GetLapsNb(Teams[0]);
- roundLabel.Value = TL::Compose("%1 | %2 OF %3", _("|Race|Lap"), TL::ToText(LapCurrent), TL::ToText(LapsTotal));
- }else if (CurrentScoreMode == C_Mode_Points) {
- declare Integer SB_CurrentRound for UI = 0;
- declare Integer SB_RoundsPerMap for UI = 0;
- roundLabel.Value = TL::Compose("ROUND | %1 OF %2", TL::ToText(SB_CurrentRound), TL::ToText(SB_RoundsPerMap));
+ if(filledHeight > contentHeight) {
+ RowsFrame.ScrollMax.Y = (filledHeight - contentHeight) * 1.0;
}else{
- roundLabel.Value = "";
+ RowsFrame.ScrollMax.Y = 0.0;
}
-
- SetMapAndAuthorName();
+
+ PlayerRowsFilled = playerRowsFilled;
}
- Void UpdateScrollSize(Integer playerRowsFilled) {
- declare scrollN = 0;
- if(playerRowsFilled >= 8){
- scrollN = playerRowsFilled - 8;
+ Text GetNickname(CUser user) {
+ declare Text[Text] EvoSC_Player_Nicknames for UI = [];
+ if(EvoSC_Player_Nicknames.existskey(user.Login)){
+ return EvoSC_Player_Nicknames[user.Login];
}
- RowsFrame.ScrollMax = <0.0, {{ rowHeight + rowSpacing }} * scrollN * 1.0>;
- PlayerRowsFilled = playerRowsFilled;
+
+ return user.Name;
}
Void UpdateScoreTable() {
@@ -466,16 +418,13 @@
declare CSmPlayer::ESpawnStatus Race_ScoresTable_SpawnStatus for Player.Score = CSmPlayer::ESpawnStatus::NotSpawned;
Race_ScoresTable_SpawnStatus = Player.SpawnStatus;
}
-
+
declare cursor = 0;
- //declare startFill = ML::Max(ScrollIndex - PlayerRowsVisible, 0);
- //declare endFill = ML::Min(ScrollIndex + PlayerRowsVisible * 2, MaxPlayers - 1);
foreach(Score => Weight in GetSortedScores()){
- //if(cursor < startFill || cursor > endFill){
- // cursor += 1;
- // continue;
- //}
+ if(!RowsFrame.Controls.existskey(cursor)){
+ continue;
+ }
declare persistent Boolean SB_Setting_ShowSpectators for LocalUser = True;
declare persistent Boolean SB_Setting_ShowDisconnected for LocalUser = True;
@@ -493,24 +442,18 @@
continue;
}
}
-
+
declare playerRow = (RowsFrame.Controls[cursor] as CMlFrame);
- declare positionLabel = (playerRow.GetFirstChild("position") as CMlLabel);
- declare clubBg = (playerRow.GetFirstChild("club_bg") as CMlQuad);
declare clubLabel = (playerRow.GetFirstChild("club") as CMlLabel);
declare nameLabel = (playerRow.GetFirstChild("name") as CMlLabel);
declare flagQuad = (playerRow.GetFirstChild("flag") as CMlQuad);
- declare scoreLabel = (playerRow.GetFirstChild("score") as CMlLabel);
- declare pointsBoxFrame = (playerRow.GetFirstChild("points_box") as CMlFrame);
+ declare positionBoxFrame = (playerRow.GetFirstChild("position_box") as CMlFrame);
- positionLabel.Value = (cursor + 1) ^ "";
+ SetPlayerRank(positionBoxFrame, cursor + 1);
+ nameLabel.Value = GetNickname(Score.User);
clubLabel.Value = Score.User.ClubTag;
- nameLabel.Value = Score.User.Name;
-
- if(clubLabel.Value != ""){
- clubBg.Opacity = 0.95;
- }else{
- clubBg.Opacity = 0.25;
+ if(clubLabel.Value == ""){
+ clubLabel.Value = "-";
}
declare Boolean CustomLabelVisible for playerRow = False;
@@ -521,14 +464,6 @@
SetCountryFlag(flagQuad, Score.User.Login);
}
- if(ShouldShowPointsBox()){
- scoreLabel.RelativePosition_V3.X = pointsBoxFrame.RelativePosition_V3.X - {{ innerSpacing }};
- pointsBoxFrame.Show();
- }else{
- scoreLabel.RelativePosition_V3.X = {{ w - padding - innerSpacing }};
- pointsBoxFrame.Hide();
- }
-
playerRow.Show();
cursor += 1;
@@ -536,26 +471,15 @@
//Hide remaining rows
for(i, cursor, {{ MaxPlayers - 1 }}){
+ if(!RowsFrame.Controls.existskey(i)){
+ continue;
+ }
+
declare playerRow = (RowsFrame.Controls[i] as CMlFrame);
playerRow.Hide();
}
-
- UpdateHeaderInfo();
UpdateScrollSize(cursor);
}
-
- Void ToggleShowSettings() {
- declare wrapperInnerFrame <=> (Page.MainFrame.GetFirstChild("rows_inner") as CMlFrame);
- declare y = 0.0;
- SettingsVisible = !SettingsVisible;
-
- if(SettingsVisible) {
- y = {{ h + padding }} * -1.0;
- }
-
- declare targetState = "";
- AnimMgr.Add(wrapperInnerFrame, targetState, 320, CAnimManager::EAnimManagerEasing::ExpInOut);
- }
-->
@@ -572,16 +496,18 @@
RowsFrame.DisablePreload = True;
RowsFrame.ScrollGridSnap = True;
RowsFrame.ScrollMin = <0.0, 0.0>;
- RowsFrame.ScrollMax = <0.0, {{ MaxPlayers * (rowHeight + rowSpacing) - h }} * 1.0>;
RowsFrame.ScrollGrid = <0.0, {{ rowHeight + rowSpacing }} * 1.0>;
MaxPlayers = {{ MaxPlayers }};
- PlayerRowsVisible = {{ VisiblePlayers }};
+ PlayerRowsVisible = 0;
PlayerRowsFilled = -1;
CurrentScoreMode = -1;
- SettingsVisible = False;
- {! string.Join("\n", PositionColors.Select(pc => $"PositionColors[{pc.Key}] = \"{pc.Value}\";")) !}
+ PositionColors = [
+ 1 => "{{ Theme.Gold }}",
+ 2 => "{{ Theme.Silver }}",
+ 3 => "{{ Theme.Bronze }}"
+ ];
***
*** OnLoop ***
@@ -599,13 +525,6 @@
}
***
- *** OnMouseClick ***
- ***
- if (Event.Control.ControlId == "settings_icon") {
- ToggleShowSettings();
- }
- ***
-
*** OnScriptExecutionFinished ***
***
sleep(5000);
diff --git a/src/Modules/ScoreboardModule/Themes/DefaultScoreboardTheme.cs b/src/Modules/ScoreboardModule/Themes/DefaultScoreboardTheme.cs
new file mode 100644
index 000000000..c98d37b49
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Themes/DefaultScoreboardTheme.cs
@@ -0,0 +1,52 @@
+using EvoSC.Common.Interfaces.Themes;
+using EvoSC.Common.Themes;
+using EvoSC.Common.Themes.Attributes;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Themes;
+
+[Theme(Name = "Scoreboard", Description = "Default theme for the scoreboard.")]
+public class DefaultScoreboardTheme(IThemeManager theme) : Theme
+{
+ private readonly dynamic _theme = theme.Theme;
+
+ public override Task ConfigureAsync()
+ {
+ Set("ScoreboardModule.Text_Color").To(_theme.UI_TextPrimary);
+
+ Set("ScoreboardModule.Logo_URL").To("file://Media/Manialinks/Nadeo/Trackmania/Menus/TMLogo.dds");
+ Set("ScoreboardModule.Logo_Width").To("20.0");
+ Set("ScoreboardModule.Logo_Height").To("10.0");
+
+ Set("ScoreboardModule.Background_Opacity").To("0.0");
+ Set("ScoreboardModule.Background_Image").To("");
+
+ Set("ScoreboardModule.Background_Header_Color").To(_theme.UI_HeaderBg);
+ Set("ScoreboardModule.Background_Header_Opacity").To("0.95");
+
+ Set("ScoreboardModule.Background_Legend_Color").To(_theme.UI_HeaderBg);
+ Set("ScoreboardModule.Background_Legend_Opacity").To("1.0");
+ Set("ScoreboardModule.Background_Legend_Text_Color").To(_theme.UI_TextPrimary);
+ Set("ScoreboardModule.Background_Legend_Text_Opacity").To("0.75");
+
+ Set("ScoreboardModule.Background_Row_Color").To(_theme.UI_BgPrimary);
+ Set("ScoreboardModule.Background_Row_Opacity").To("0.9");
+
+ Set("ScoreboardModule.Background_Hover_Color").To(_theme.UI_BgHighlight);
+ Set("ScoreboardModule.Background_Hover_Opacity").To("0.9");
+
+ Set("ScoreboardModule.PositionBox_ShowAccent").To("True");
+ Set("ScoreboardModule.PositionBox_Color").To(_theme.UI_AccentSecondary);
+ Set("ScoreboardModule.PositionBox_Opacity").To("1.0");
+ Set("ScoreboardModule.PositionBox_TextColor").To(_theme.UI_TextSecondary);
+ Set("ScoreboardModule.PositionBox_TextOpacity").To("1.0");
+
+ Set("ScoreboardModule.GainedPoints.Color").To(_theme.UI_AccentPrimary);
+
+ Set("ScoreboardModule.Background_Row_Flag_AlphaMaskUrl").To("file://Media/Manialinks/Nadeo/Trackmania/Menus/Common/Common_Flag_Mask.dds");
+
+ Set("ScoreboardModule.FinalistColor").To("");
+ Set("ScoreboardModule.WinnerColor").To("");
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/ScoreboardModule/info.toml b/src/Modules/ScoreboardModule/info.toml
new file mode 100644
index 000000000..f7ad59d0d
--- /dev/null
+++ b/src/Modules/ScoreboardModule/info.toml
@@ -0,0 +1,9 @@
+[info]
+name = "ScoreboardModule"
+title = "Scoreboard Module"
+summary = "Custom EvoSC Scoreboards."
+version = "1.0.0"
+author = "Evo"
+
+[dependencies]
+GameModeUiModule = "1.0.0"
diff --git a/src/Modules/ServerSyncModule/Controllers/ServerSyncController.cs b/src/Modules/ServerSyncModule/Controllers/ServerSyncController.cs
new file mode 100644
index 000000000..d0837db42
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Controllers/ServerSyncController.cs
@@ -0,0 +1,71 @@
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Events.Attributes;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Remote;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Util;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using GbxRemoteNet.Events;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Controllers;
+
+[Controller]
+public class ServerSyncController : EvoScController
+{
+ private readonly IServerClient _server;
+ private readonly ISyncService _sync;
+ private readonly IPlayerManagerService _players;
+
+ public ServerSyncController(IServerClient server, ISyncService sync, IPlayerManagerService players)
+ {
+ _server = server;
+ _sync = sync;
+ _players = players;
+ }
+
+ [Subscribe(ServerSyncEvents.ChatMessage)]
+ public async Task OnRemoteChatMessageAsync(object sender, ChatStateMessageEventArgs args)
+ {
+ var player = await _players.GetOnlinePlayerAsync(args.ChatMessage.AccountId);
+ var chatMessage = FormattingUtils.FormatPlayerChatMessage(player, args.ChatMessage.Message, false);
+ await _server.Chat.SendChatMessageAsync(chatMessage);
+ }
+
+ [Subscribe(GbxRemoteEvent.PlayerChat)]
+ public async Task OnLocalChatMessageAsync(object sender, PlayerChatGbxEventArgs args)
+ {
+ var player = await _players.GetOnlinePlayerAsync(PlayerUtils.ConvertLoginToAccountId(args.Login));
+ await _sync.PublishChatMessageAsync(player, args.Text);
+ }
+
+ [Subscribe(GbxRemoteEvent.EndMap)]
+ public Task OnEndMapAsync(object sender, MapGbxEventArgs args) =>
+ _sync.PublishMapFinishedAsync();
+
+ [Subscribe(ModeScriptEvent.EndRoundStart)]
+ public Task OnEndRoundAsync(object sender, RoundEventArgs args) =>
+ _sync.PublishEndRoundAsync();
+
+ [Subscribe(GbxRemoteEvent.EndMatch)]
+ public Task OnEndMatchAsync(object sender, EndMatchGbxEventArgs args) =>
+ _sync.PublishEndMatchAsync();
+
+ [Subscribe(ModeScriptEvent.WayPoint)]
+ public async Task OnWayPointAsync(object sender, WayPointEventArgs args)
+ {
+ var player = await _players.GetOnlinePlayerAsync(PlayerUtils.ConvertLoginToAccountId(args.Login));
+ await _sync.PublishWayPointAsync(player, args.RaceTime, args.CheckpointInRace, args.CurrentRaceCheckpoints,
+ args.IsEndRace, args.Speed);
+ }
+
+ [Subscribe(ModeScriptEvent.Scores)]
+ public async Task OnScoresAsync(object sender, ScoresEventArgs args)
+ {
+ await _sync.PublishScoresAsync(args.Players, args.Teams, args.WinnerTeam, args.WinnerPlayer, args.Section, args.UseTeams);
+ }
+}
diff --git a/src/Modules/ServerSyncModule/Events/Args/ChatStateMessageEventArgs.cs b/src/Modules/ServerSyncModule/Events/Args/ChatStateMessageEventArgs.cs
new file mode 100644
index 000000000..43afb8c9a
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/Args/ChatStateMessageEventArgs.cs
@@ -0,0 +1,9 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+
+public class ChatStateMessageEventArgs : EventArgs
+{
+ public required IChatStateStateMessage ChatMessage { get; init; }
+}
diff --git a/src/Modules/ServerSyncModule/Events/Args/MapFinishedStateEventArgs.cs b/src/Modules/ServerSyncModule/Events/Args/MapFinishedStateEventArgs.cs
new file mode 100644
index 000000000..381feb500
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/Args/MapFinishedStateEventArgs.cs
@@ -0,0 +1,6 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+
+public class MapFinishedStateEventArgs : EventArgs
+{
+
+}
diff --git a/src/Modules/ServerSyncModule/Events/Args/Nats/NatsMessageEventArgs.cs b/src/Modules/ServerSyncModule/Events/Args/Nats/NatsMessageEventArgs.cs
new file mode 100644
index 000000000..e3aa99e29
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/Args/Nats/NatsMessageEventArgs.cs
@@ -0,0 +1,13 @@
+using NATS.Client;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args.Nats;
+
+public class NatsMessageEventArgs : EventArgs
+{
+ public required Msg Message { get; init; }
+}
+
+public class NatsMessageEventArgs : NatsMessageEventArgs
+{
+ public T? Data { get; init; }
+}
diff --git a/src/Modules/ServerSyncModule/Events/Args/PlayerStateUpdateEventArgs.cs b/src/Modules/ServerSyncModule/Events/Args/PlayerStateUpdateEventArgs.cs
new file mode 100644
index 000000000..a96e120d9
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/Args/PlayerStateUpdateEventArgs.cs
@@ -0,0 +1,8 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+
+public class PlayerStateUpdateEventArgs : EventArgs
+{
+ public required IPlayerStateUpdateMessage PlayerState { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Events/Args/WaypointUpdateEventArgs.cs b/src/Modules/ServerSyncModule/Events/Args/WaypointUpdateEventArgs.cs
new file mode 100644
index 000000000..d07983daa
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/Args/WaypointUpdateEventArgs.cs
@@ -0,0 +1,8 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+
+public class WaypointUpdateEventArgs : EventArgs
+{
+ public required IWaypointStateMessage WaypointState { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Events/ServerSyncEvents.cs b/src/Modules/ServerSyncModule/Events/ServerSyncEvents.cs
new file mode 100644
index 000000000..99628a591
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Events/ServerSyncEvents.cs
@@ -0,0 +1,19 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Events;
+
+public enum ServerSyncEvents
+{
+ ///
+ /// Always triggered whenever a message is received.
+ ///
+ PlayerStateUpdate,
+
+ ///
+ /// When a new chat message has been sent from one of the other servers.
+ ///
+ ChatMessage,
+
+ ///
+ /// When the map was finished on a server.
+ ///
+ MapFinished
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/IKeyValueStoreService.cs b/src/Modules/ServerSyncModule/Interfaces/IKeyValueStoreService.cs
new file mode 100644
index 000000000..140a1cc13
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/IKeyValueStoreService.cs
@@ -0,0 +1,45 @@
+using NATS.Client.KeyValue;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+
+public interface IKeyValueStoreService
+{
+ ///
+ /// Creates a new key value entry.
+ ///
+ ///
+ ///
+ /// Revision number of the key
+ ulong CreateEntry(string key, byte[] value);
+
+ ///
+ /// Create a new key value entry if one does not exist, if it does it updates.
+ ///
+ ///
+ ///
+ /// Revision number of the key
+ ulong CreateOrUpdateEntry(string key, byte[] value);
+
+ ///
+ /// Updates an existing key value entry.
+ ///
+ ///
+ ///
+ ///
+ void UpdateEntry(string key, byte[] value, ulong revision);
+
+ ///
+ /// Gets an existing key value entry.
+ ///
+ ///
+ ///
+ /// Key and value of the entry
+ KeyValueEntry GetEntry(string key, ulong revision);
+
+ ///
+ /// Deletes an existing key value entry.
+ ///
+ ///
+ ///
+ void DeleteEntry(string key, ulong revision);
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/INatsConnectionService.cs b/src/Modules/ServerSyncModule/Interfaces/INatsConnectionService.cs
new file mode 100644
index 000000000..16d7d59c3
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/INatsConnectionService.cs
@@ -0,0 +1,72 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args.Nats;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+using NATS.Client;
+using NATS.Client.JetStream;
+using NATS.Client.KeyValue;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+
+public interface INatsConnectionService
+{
+ ///
+ /// Connection to the NATS server.
+ ///
+ public IConnection Connection { get; }
+
+ ///
+ /// The stream context.
+ ///
+ public IJetStream JetStream { get; }
+
+ ///
+ /// The key value context.
+ ///
+ public IKeyValue KeyValue { get; }
+
+ ///
+ /// The ID/Name of the current client.
+ ///
+ public string ClientId { get; }
+
+ ///
+ /// Establish a connection to NATS.
+ ///
+ ///
+ internal Task ConnectAsync();
+
+ ///
+ /// Disconnect from NATS.
+ ///
+ ///
+ internal Task DisconnectAsync();
+
+ ///
+ /// Publish a state message to the NATS stream.
+ ///
+ /// The subject to publish to.
+ /// The message to publish.
+ ///
+ ///
+ public Task PublishStateAsync(string subject, TStateMsg message) where TStateMsg : IStateMessage;
+
+ ///
+ /// Publish a state message to the NATS stream.
+ ///
+ /// The subject to publish to.
+ /// The message to publish.
+ ///
+ ///
+ public Task PublishStateAsync(Enum subject, TStateMsg message) where TStateMsg : IStateMessage;
+
+ ///
+ /// When a player state update message has been received.
+ ///
+ public event EventHandler> PlayerStateUpdated;
+
+ ///
+ /// When a chat message has been received.
+ ///
+ public event EventHandler> ChatMessageReceived;
+
+ public event EventHandler> MapFinishedReceived;
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/IServerState.cs b/src/Modules/ServerSyncModule/Interfaces/IServerState.cs
new file mode 100644
index 000000000..6cf68de0b
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/IServerState.cs
@@ -0,0 +1,9 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+
+public interface IServerState
+{
+ ///
+ /// The time at which this state change occurred.
+ ///
+ public DateTime Timestamp { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/ISyncService.cs b/src/Modules/ServerSyncModule/Interfaces/ISyncService.cs
new file mode 100644
index 000000000..a5e4ade05
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/ISyncService.cs
@@ -0,0 +1,103 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+
+public interface ISyncService
+{
+ ///
+ /// Publish a chat message to all connected servers.
+ ///
+ /// The player that sent the chat message.
+ /// The message text.
+ ///
+ public Task PublishChatMessageAsync(IPlayer player, string message);
+
+ ///
+ /// Publish a player state to all connected servers.
+ ///
+ /// The player that was updated.
+ /// New position of the player.
+ /// Scores of the player.
+ /// Checkpoint scores/times of the player.
+ /// Times the player have driven.
+ ///
+ public Task PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores, IEnumerable checkpointScores, IEnumerable times);
+
+ ///
+ /// Publish the scores of a player to all connected servers.
+ ///
+ /// The player to update.
+ /// Scores of the player.
+ ///
+ public Task PublishPlayerStateAsync(IPlayer player, IEnumerable scores) =>
+ PublishPlayerStateAsync(player, 0, scores, Array.Empty(), Array.Empty());
+
+ ///
+ /// Publish the position of a player to all servers.
+ ///
+ /// Player to publish the position for.
+ /// New position of the player.
+ ///
+ public Task PublishPlayerStateAsync(IPlayer player, long position) =>
+ PublishPlayerStateAsync(player, position, Array.Empty(), Array.Empty(), Array.Empty());
+
+ ///
+ /// Publish the position and scores of a player to all servers.
+ ///
+ /// The player to update.
+ /// New position of the player.
+ /// Scores of the player.
+ ///
+ public Task PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores) =>
+ PublishPlayerStateAsync(player, position, scores, Array.Empty(), Array.Empty());
+
+ ///
+ /// Publish the position, scores and checkpoints of a player to all servers.
+ ///
+ /// The player to update.
+ /// New position of the player.
+ /// Scores of the player.
+ /// Checkpoint scores/times of the player.
+ ///
+ public Task PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores, IEnumerable checkpointScores)
+ => PublishPlayerStateAsync(player, position, scores, checkpointScores, Array.Empty());
+
+ ///
+ /// Publish a map finished/ended event to all servers.
+ ///
+ ///
+ public Task PublishMapFinishedAsync();
+
+ ///
+ /// Publish end round event to all servers.
+ ///
+ ///
+ public Task PublishEndRoundAsync();
+
+ ///
+ /// Publish end match event to all servers.
+ ///
+ ///
+ public Task PublishEndMatchAsync();
+
+ ///
+ /// Publish a waypoint event to all servers.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public Task PublishWayPointAsync(IOnlinePlayer player, int raceTime, int checkpointInRace,
+ IEnumerable currentRaceCheckpoints, bool isEndRace, float speed);
+
+ ///
+ /// Publish scores of players and teams to all servers.
+ ///
+ ///
+ public Task PublishScoresAsync(IEnumerable playerScores, IEnumerable teamScores, int winnerTeam, string? winnerPlayer, ModeScriptSection section, bool useTeams);
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IChatStateStateMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IChatStateStateMessage.cs
new file mode 100644
index 000000000..a884aab09
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IChatStateStateMessage.cs
@@ -0,0 +1,6 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IChatStateStateMessage : IPlayerStateMessage
+{
+ public string Message { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateMessage.cs
new file mode 100644
index 000000000..543d57633
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateMessage.cs
@@ -0,0 +1,14 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IPlayerStateMessage : IStateMessage
+{
+ ///
+ /// Account ID of the player.
+ ///
+ public string AccountId { get; set; }
+
+ ///
+ /// Nickname of the player. This can be the set name in EvoSC or default ubisoft name.
+ ///
+ public string NickName { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateUpdateMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateUpdateMessage.cs
new file mode 100644
index 000000000..8c53b6b70
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IPlayerStateUpdateMessage.cs
@@ -0,0 +1,46 @@
+using System.Text.Json.Serialization;
+using EvoSC.Common.Interfaces.Util;
+using EvoSC.Common.Util;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IPlayerStateUpdateMessage : IPlayerStateMessage
+{
+ ///
+ /// Scores of the player.
+ ///
+ public IEnumerable Scores { get; set; }
+
+ ///
+ /// Scoreboard of the player.
+ ///
+ public long Position { get; set; }
+
+ ///
+ /// Checkpoint scores/times of the player.
+ ///
+ public IEnumerable CheckpointScores { get; set; }
+
+ ///
+ /// Times driven by the player.
+ ///
+ public IEnumerable Times { get; set; }
+
+ ///
+ /// The player's current score.
+ ///
+ [JsonIgnore]
+ public long Score => Scores.First();
+
+ ///
+ /// The player's driven time.
+ ///
+ [JsonIgnore]
+ public IRaceTime Time => RaceTime.FromMilliseconds((int)Times.First());
+
+ ///
+ /// Checkpoint times driven by the player.
+ ///
+ [JsonIgnore]
+ public IEnumerable Checkpoints => CheckpointScores.Select(cs => RaceTime.FromMilliseconds((int)cs));
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IScoresMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IScoresMessage.cs
new file mode 100644
index 000000000..bd0284899
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IScoresMessage.cs
@@ -0,0 +1,14 @@
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IScoresMessage: IStateMessage
+{
+ IEnumerable Scores { get; set; }
+ IEnumerable TeamScores { get; set; }
+ int WinnerTeam { get; set; }
+ string? WinnerPlayer { get; set; }
+ ModeScriptSection Section { get; set; }
+ bool UseTeams { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IStateMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IStateMessage.cs
new file mode 100644
index 000000000..6e5a768d3
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IStateMessage.cs
@@ -0,0 +1,14 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IStateMessage
+{
+ ///
+ /// ID/Name of the client that sent this message.
+ ///
+ public string ClientId { get; set; }
+
+ ///
+ /// The time at which this message was sent.
+ ///
+ public DateTime Timestamp { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Interfaces/StateMessages/IWaypointStateMessage.cs b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IWaypointStateMessage.cs
new file mode 100644
index 000000000..42b338655
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Interfaces/StateMessages/IWaypointStateMessage.cs
@@ -0,0 +1,29 @@
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+public interface IWaypointStateMessage : IPlayerStateMessage
+{
+ ///
+ /// Race time in milliseconds
+ ///
+ public int RaceTime { get; set; }
+
+ ///
+ /// Checkpoints in race
+ ///
+ public int CheckpointInRace { get; set; }
+
+ ///
+ /// Current checkpoints in race
+ ///
+ public IEnumerable CurrentRaceCheckpoints { get; set; }
+
+ ///
+ /// Whether its the end of the race
+ ///
+ public bool IsEndRace { get; set; }
+
+ ///
+ /// Speed of the player through the checkpoint
+ ///
+ public float Speed { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Localization.resx b/src/Modules/ServerSyncModule/Localization.resx
new file mode 100644
index 000000000..10e3157c7
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Localization.resx
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
diff --git a/src/Modules/ServerSyncModule/Models/StateMessages/ChatStateStateMessage.cs b/src/Modules/ServerSyncModule/Models/StateMessages/ChatStateStateMessage.cs
new file mode 100644
index 000000000..15b523ed9
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateMessages/ChatStateStateMessage.cs
@@ -0,0 +1,12 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+public class ChatStateStateMessage : IChatStateStateMessage
+{
+ public string ClientId { get; set; }
+ public required DateTime Timestamp { get; set; }
+ public string NickName { get; set; }
+ public string AccountId { get; set; }
+ public string Message { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Models/StateMessages/PlayerStateUpdateMessage.cs b/src/Modules/ServerSyncModule/Models/StateMessages/PlayerStateUpdateMessage.cs
new file mode 100644
index 000000000..aceefd062
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateMessages/PlayerStateUpdateMessage.cs
@@ -0,0 +1,15 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+public class PlayerStateUpdateMessage : IPlayerStateUpdateMessage
+{
+ public string ClientId { get; set; }
+ public DateTime Timestamp { get; set; }
+ public string AccountId { get; set; }
+ public string NickName { get; set; }
+ public IEnumerable Scores { get; set; }
+ public long Position { get; set; }
+ public IEnumerable CheckpointScores { get; set; }
+ public IEnumerable Times { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Models/StateMessages/ScoresMessage.cs b/src/Modules/ServerSyncModule/Models/StateMessages/ScoresMessage.cs
new file mode 100644
index 000000000..f14253390
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateMessages/ScoresMessage.cs
@@ -0,0 +1,17 @@
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+public class ScoresMessage : IScoresMessage
+{
+ public string ClientId { get; set; }
+ public DateTime Timestamp { get; set; }
+ public IEnumerable Scores { get; set; }
+ public IEnumerable TeamScores { get; set; }
+ public int WinnerTeam { get; set; }
+ public string? WinnerPlayer { get; set; }
+ public ModeScriptSection Section { get; set; }
+ public bool UseTeams { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Models/StateMessages/StateMessage.cs b/src/Modules/ServerSyncModule/Models/StateMessages/StateMessage.cs
new file mode 100644
index 000000000..33b04aa47
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateMessages/StateMessage.cs
@@ -0,0 +1,9 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+public class StateMessage : IStateMessage
+{
+ public string ClientId { get; set; }
+ public DateTime Timestamp { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Models/StateMessages/WaypointMessage.cs b/src/Modules/ServerSyncModule/Models/StateMessages/WaypointMessage.cs
new file mode 100644
index 000000000..098b46f2f
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateMessages/WaypointMessage.cs
@@ -0,0 +1,16 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+public class WaypointMessage : IWaypointStateMessage
+{
+ public string ClientId { get; set; }
+ public DateTime Timestamp { get; set; }
+ public string AccountId { get; set; }
+ public string NickName { get; set; }
+ public int RaceTime { get; set; }
+ public int CheckpointInRace { get; set; }
+ public IEnumerable CurrentRaceCheckpoints { get; set; }
+ public bool IsEndRace { get; set; }
+ public float Speed { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Models/StateSubjects.cs b/src/Modules/ServerSyncModule/Models/StateSubjects.cs
new file mode 100644
index 000000000..34d804376
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Models/StateSubjects.cs
@@ -0,0 +1,45 @@
+using EvoSC.Common.Util.EnumIdentifier;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Models;
+
+public enum StateSubjects
+{
+ ///
+ /// Subject for published chat messages.
+ ///
+ [Identifier(NoPrefix = true)]
+ ChatMessages,
+
+ ///
+ /// Subject for published player state updates.
+ ///
+ [Identifier(NoPrefix = true)]
+ PlayerState,
+
+ [Identifier(NoPrefix = true)]
+ MapFinished,
+
+ ///
+ /// Subject for end match state updates.
+ ///
+ [Identifier(NoPrefix = true)]
+ EndMatch,
+
+ ///
+ /// Subject for end round state updates.
+ ///
+ [Identifier(NoPrefix = true)]
+ EndRound,
+
+ ///
+ /// Subject for published waypoint state updates.
+ ///
+ [Identifier(NoPrefix = true)]
+ Waypoint,
+
+ ///
+ /// Subject for published scores state updates.
+ ///
+ [Identifier(NoPrefix = true)]
+ Scores
+}
diff --git a/src/Modules/ServerSyncModule/ServerSyncModule.cs b/src/Modules/ServerSyncModule/ServerSyncModule.cs
new file mode 100644
index 000000000..e392883b8
--- /dev/null
+++ b/src/Modules/ServerSyncModule/ServerSyncModule.cs
@@ -0,0 +1,8 @@
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule;
+
+[Module(IsInternal = true)]
+public class ServerSyncModule : EvoScModule
+{
+}
diff --git a/src/Modules/ServerSyncModule/ServerSyncModule.csproj b/src/Modules/ServerSyncModule/ServerSyncModule.csproj
new file mode 100644
index 000000000..930e0065f
--- /dev/null
+++ b/src/Modules/ServerSyncModule/ServerSyncModule.csproj
@@ -0,0 +1,29 @@
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.EvoEsports.ServerSyncModule
+ false
+ ServerSyncModule
+ Server Synchronization
+ Synchronize multiple servers.
+ 1.0.0
+ EvoEsports
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ServerSyncModule/Services/KeyValueStoreService.cs b/src/Modules/ServerSyncModule/Services/KeyValueStoreService.cs
new file mode 100644
index 000000000..35a0916c1
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Services/KeyValueStoreService.cs
@@ -0,0 +1,49 @@
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using Microsoft.Extensions.Logging;
+using NATS.Client.KeyValue;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class KeyValueStoreService(INatsConnectionService nats, ILogger logger) : IKeyValueStoreService
+{
+ public ulong CreateEntry(string key, byte[] value)
+ {
+ logger.LogInformation("Creating key value entry from store with key: {Key}", key);
+ return nats.KeyValue.Create(key, value);
+ }
+
+ public ulong CreateOrUpdateEntry(string key, byte[] value)
+ {
+ var entryExists = nats.KeyValue.Get(key);
+
+ if (entryExists is null)
+ {
+ logger.LogInformation("Creating key value entry from store with key: {Key}", key);
+ return nats.KeyValue.Create(key, value);
+ }
+
+ logger.LogInformation("Updating existing key value entry with key: {Key}", key);
+ return nats.KeyValue.Update(entryExists.Key, value, entryExists.Revision);
+ }
+
+ public void UpdateEntry(string key, byte[] value, ulong revision)
+ {
+ logger.LogInformation("Updating key value entry from store with key: {Key}", key);
+ nats.KeyValue.Update(key, value, revision);
+ }
+
+ public KeyValueEntry GetEntry(string key, ulong revision)
+ {
+ logger.LogInformation("Getting key value entry from store with key: {Key}", key);
+ return nats.KeyValue.Get(key, revision);
+ }
+
+ public void DeleteEntry(string key, ulong revision)
+ {
+ logger.LogInformation("Deleting key value entry from store with key: {Key}", key);
+ nats.KeyValue.Delete(key, revision);
+ }
+}
diff --git a/src/Modules/ServerSyncModule/Services/NatsBackgroundService.cs b/src/Modules/ServerSyncModule/Services/NatsBackgroundService.cs
new file mode 100644
index 000000000..9b3126eef
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Services/NatsBackgroundService.cs
@@ -0,0 +1,70 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args.Nats;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Settings;
+using NATS.Client.JetStream;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class NatsBackgroundService : IBackgroundService
+{
+ private readonly INatsConnectionService _nats;
+ private readonly INatsSettings _natsSettings;
+ private readonly IEventManager _events;
+
+ public NatsBackgroundService(INatsConnectionService nats, INatsSettings natsSettings, IEventManager events)
+ {
+ _nats = nats;
+ _natsSettings = natsSettings;
+ _events = events;
+ }
+
+ public async Task StartAsync()
+ {
+ await _nats.ConnectAsync();
+
+ var opts = new PushSubscribeOptions.PushSubscribeOptionsBuilder()
+ .WithConfiguration(new ConsumerConfiguration.ConsumerConfigurationBuilder()
+ .WithDeliverPolicy(DeliverPolicy.Last)
+ .WithAckPolicy(AckPolicy.Explicit)
+ .Build()
+ )
+ .Build();
+
+ _nats.PlayerStateUpdated += NatsOnPlayerStateUpdated;
+ _nats.ChatMessageReceived += NatsOnChatMessageReceived;
+ _nats.MapFinishedReceived += NatsOnMapFinishedReceived;
+ }
+
+ private void NatsOnMapFinishedReceived(object? sender, NatsMessageEventArgs e) =>
+ _events.RaiseAsync(ServerSyncEvents.ChatMessage, new MapFinishedStateEventArgs());
+
+ private void NatsOnChatMessageReceived(object? sender, NatsMessageEventArgs e) =>
+ _events.RaiseAsync(ServerSyncEvents.ChatMessage, new ChatStateMessageEventArgs
+ {
+ ChatMessage = e.Data ?? throw new InvalidOperationException("Chat message state data is null.")
+ });
+
+ private void NatsOnPlayerStateUpdated(object? sender, NatsMessageEventArgs e) =>
+ _events.RaiseAsync(ServerSyncEvents.PlayerStateUpdate, new PlayerStateUpdateEventArgs
+ {
+ PlayerState = e.Data ?? throw new InvalidOperationException("Player state message data is null.")
+ });
+
+ public Task StopAsync()
+ {
+ _nats.PlayerStateUpdated -= NatsOnPlayerStateUpdated;
+ _nats.ChatMessageReceived -= NatsOnChatMessageReceived;
+
+ _nats.DisconnectAsync();
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/ServerSyncModule/Services/NatsConnectionService.cs b/src/Modules/ServerSyncModule/Services/NatsConnectionService.cs
new file mode 100644
index 000000000..452e13ffb
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Services/NatsConnectionService.cs
@@ -0,0 +1,279 @@
+using System.Text;
+using System.Text.Json;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util.EnumIdentifier;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Events.Args.Nats;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces.StateMessages;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Models;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Settings;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Utils;
+using Microsoft.Extensions.Logging;
+using NATS.Client;
+using NATS.Client.JetStream;
+using NATS.Client.KeyValue;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class NatsConnectionService(
+ INatsSettings natsSettings,
+ IServerClient tmServer,
+ ILogger logger)
+ : INatsConnectionService
+{
+ private readonly ConnectionFactory _natsConnFactory = new();
+
+ private List _subscriptions = new();
+
+ private IConnection? _natsConnection = null;
+ private IJetStream? _jetStream = null;
+ private IKeyValue? _keyValue = null;
+
+ public IConnection Connection => _natsConnection ?? throw new InvalidOperationException("Not connected to NATS.");
+
+ public IJetStream JetStream =>
+ _jetStream ?? throw new InvalidOperationException("The JetStream context has not been created.");
+
+ public IKeyValue KeyValue =>
+ _keyValue ?? throw new InvalidOperationException("The KeyValue context has not been created.");
+
+ public string ClientId { get; } = Guid.NewGuid().ToString();
+
+ public async Task ConnectAsync()
+ {
+ if (_natsConnection != null && !_natsConnection.IsClosed())
+ {
+ throw new InvalidOperationException("A connection to NATS is already open.");
+ }
+
+ var options = ConnectionFactory.GetDefaultOptions();
+ options.Url = natsSettings.GetConnectionUrl();
+ options.AllowReconnect = true;
+ options.MaxReconnect = Options.ReconnectForever;
+ options.ReconnectWait = 1000;
+
+ options.Name = ClientId;
+
+ var serverName = await tmServer.Remote.GetServerNameAsync();
+ var subjectPrefix = $"{natsSettings.MessageGroup}" + (serverName != null ? $".{serverName}" : "");
+
+ logger.LogDebug("Connecting to {ConnectionUrl} with subjectprefix {SubjectPrefix}...", natsSettings.GetConnectionUrl(),
+ subjectPrefix);
+ _natsConnection = _natsConnFactory.CreateConnection(options);
+
+ _jetStream = _natsConnection.CreateJetStreamContext();
+ _keyValue = _natsConnection.CreateKeyValueContext(natsSettings.KeyVaultBucketName);
+
+ logger.LogDebug("Connected to NATS server at {Url} with stream name {StreamName}", options.Url,
+ natsSettings.StreamName);
+
+ try
+ {
+ _jetStream.GetStreamContext(natsSettings.StreamName);
+ }
+ catch (NATSTimeoutException)
+ {
+ logger.LogWarning("No stream exists with the name {StreamName}, attempting to create one.",
+ natsSettings.StreamName);
+ }
+
+
+ try
+ {
+ _subscriptions.Add(JetStream.PushSubscribeAsync(
+ $"{subjectPrefix}.{StateSubjects.ChatMessages.GetIdentifier()}",
+ OnChatMessage,
+ false,
+ new PushSubscribeOptions.PushSubscribeOptionsBuilder()
+ .WithConfiguration(new ConsumerConfiguration.ConsumerConfigurationBuilder()
+ .WithDeliverPolicy(DeliverPolicy.All)
+ .WithAckPolicy(AckPolicy.Explicit)
+ .Build()
+ )
+ .WithStream(natsSettings.StreamName)
+ .Build()
+ )
+ );
+ }
+ catch (NATSJetStreamClientException e)
+ {
+ logger.LogError(e, "Could not subscribe to OnChatMessage with subject {Subject}",
+ $"{subjectPrefix}.{StateSubjects.ChatMessages.GetIdentifier()}");
+ }
+
+ try
+ {
+ _subscriptions.Add(_jetStream.PushSubscribeAsync(
+ $"{subjectPrefix}.{StateSubjects.PlayerState.GetIdentifier()}",
+ OnPlayerState,
+ false,
+ new PushSubscribeOptions.PushSubscribeOptionsBuilder()
+ .WithConfiguration(new ConsumerConfiguration.ConsumerConfigurationBuilder()
+ .WithDeliverPolicy(DeliverPolicy.All)
+ .WithStartSequence(natsSettings.PlayerStatesStartSequence)
+ .WithReplayPolicy(ReplayPolicy.Instant)
+ .WithAckPolicy(AckPolicy.Explicit)
+ .Build()
+ )
+ .WithStream(natsSettings.StreamName)
+ .Build()
+ )
+ );
+ }
+ catch (NATSJetStreamClientException e)
+ {
+ logger.LogError(e, "Could not subscribe to OnPlayerState with subject {Subject}",
+ $"{subjectPrefix}.{StateSubjects.PlayerState.GetIdentifier()}");
+ }
+
+ try
+ {
+ _subscriptions.Add(_jetStream.PushSubscribeAsync(
+ $"{subjectPrefix}.{StateSubjects.MapFinished.GetIdentifier()}",
+ OnMapFinished,
+ true,
+ new PushSubscribeOptions.PushSubscribeOptionsBuilder()
+ .WithConfiguration(new ConsumerConfiguration.ConsumerConfigurationBuilder()
+ .WithDeliverPolicy(DeliverPolicy.Last)
+ .WithReplayPolicy(ReplayPolicy.Instant)
+ .WithAckPolicy(AckPolicy.Explicit)
+ .Build()
+ )
+ .WithStream(natsSettings.StreamName)
+ .Build()
+ )
+ );
+ }
+ catch (NATSJetStreamClientException e)
+ {
+ logger.LogError(e, "Could not subscribe to OnMapFinished with subject {Subject}",
+ $"{subjectPrefix}.{StateSubjects.MapFinished.GetIdentifier()}");
+ }
+ }
+
+ private void OnMapFinished(object? sender, MsgHandlerEventArgs e)
+ {
+ if (IsSelf(e.Message))
+ {
+ e.Message.Ack();
+ return;
+ }
+
+ e.Message.InProgress();
+
+ try
+ {
+ var data = e.Message.Deserialize();
+ MapFinishedReceived?.Invoke(sender,
+ new NatsMessageEventArgs
+ {
+ Message = e.Message
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process map finished event");
+ e.Message.Nak();
+ return;
+ }
+
+ e.Message.Ack();
+ }
+
+ private void OnPlayerState(object? sender, MsgHandlerEventArgs e)
+ {
+ if (IsSelf(e.Message))
+ {
+ e.Message.Ack();
+ return;
+ }
+
+ e.Message.InProgress();
+
+ try
+ {
+ var data = e.Message.Deserialize();
+ PlayerStateUpdated?.Invoke(sender,
+ new NatsMessageEventArgs
+ {
+ Message = e.Message, Data = data
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process player state message");
+ e.Message.Nak();
+ return;
+ }
+
+ e.Message.Ack();
+ }
+
+ private void OnChatMessage(object? sender, MsgHandlerEventArgs e)
+ {
+ if (IsSelf(e.Message))
+ {
+ e.Message.Ack();
+ return;
+ }
+
+ e.Message.InProgress();
+
+ try
+ {
+ var data = e.Message.Deserialize();
+ ChatMessageReceived?.Invoke(sender,
+ new NatsMessageEventArgs
+ {
+ Message = e.Message, Data = data
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to process chat message");
+ e.Message.Nak();
+ return;
+ }
+
+ e.Message.Ack();
+ }
+
+ public async Task DisconnectAsync()
+ {
+ if (_natsConnection == null || _natsConnection.IsClosed())
+ {
+ throw new InvalidOperationException("The connection to the NATS server is already closed.");
+ }
+
+ await _natsConnection.DrainAsync();
+ _natsConnection.Close();
+ _natsConnection.Dispose();
+ }
+
+ public async Task PublishStateAsync(string subject, TStateMsg message) where TStateMsg : IStateMessage
+ {
+ message.ClientId = ClientId;
+ var serverName = await tmServer.Remote.GetServerNameAsync();
+ var subjectPrefix = $"{natsSettings.MessageGroup}" + (serverName != null ? $".{serverName}" : "");
+ var serialized = JsonSerializer.Serialize(message);
+ logger.LogDebug("{SubjectPrefix}.{Subject}", subjectPrefix, subject);
+ await JetStream.PublishAsync($"{subjectPrefix}.{subject}", Encoding.UTF8.GetBytes(serialized));
+ }
+
+ public Task PublishStateAsync(Enum subject, TStateMsg message) where TStateMsg : IStateMessage =>
+ PublishStateAsync(subject.GetIdentifier(), message);
+
+ public event EventHandler>? PlayerStateUpdated;
+ public event EventHandler>? ChatMessageReceived;
+ public event EventHandler>? MapFinishedReceived;
+
+ private bool IsSelf(Msg msg)
+ {
+ var data = msg.Deserialize();
+ return data != null && data.ClientId.Equals(ClientId, StringComparison.Ordinal);
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/ServerSyncModule/Services/SyncService.cs b/src/Modules/ServerSyncModule/Services/SyncService.cs
new file mode 100644
index 000000000..725930b2e
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Services/SyncService.cs
@@ -0,0 +1,98 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Models;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Models.StateMessages;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class SyncService : ISyncService
+{
+ private readonly INatsConnectionService _nats;
+
+ public SyncService(INatsConnectionService nats)
+ {
+ _nats = nats;
+ }
+
+ public Task PublishChatMessageAsync(IPlayer player, string message)
+ {
+ var stateMessage = new ChatStateStateMessage
+ {
+ Message = message,
+ AccountId = player.AccountId,
+ Timestamp = DateTime.Now,
+ NickName = player.NickName
+ };
+
+ return _nats.PublishStateAsync(StateSubjects.ChatMessages, stateMessage);
+ }
+
+ public Task PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores, IEnumerable checkpointScores, IEnumerable times)
+ {
+ var stateMessage = new PlayerStateUpdateMessage
+ {
+ Timestamp = DateTime.Now,
+ AccountId = player.AccountId,
+ NickName = player.NickName,
+ Scores = scores,
+ Position = position,
+ CheckpointScores = checkpointScores,
+ Times = times
+ };
+
+ return _nats.PublishStateAsync(StateSubjects.PlayerState, stateMessage);
+ }
+
+ public Task PublishMapFinishedAsync()
+ {
+ return _nats.PublishStateAsync(StateSubjects.MapFinished, new StateMessage());
+ }
+
+ public Task PublishEndRoundAsync()
+ {
+ return _nats.PublishStateAsync(StateSubjects.EndRound, new StateMessage());
+ }
+
+ public Task PublishEndMatchAsync()
+ {
+ return _nats.PublishStateAsync(StateSubjects.EndMatch, new StateMessage());
+ }
+
+ public Task PublishWayPointAsync(IOnlinePlayer player, int raceTime, int checkpointInRace,
+ IEnumerable currentRaceCheckpoints, bool isEndRace, float speed)
+ {
+ var stateMessage = new WaypointMessage
+ {
+ NickName = player.NickName,
+ AccountId = player.AccountId,
+ RaceTime = raceTime,
+ CheckpointInRace = checkpointInRace,
+ CurrentRaceCheckpoints = currentRaceCheckpoints,
+ IsEndRace = isEndRace,
+ Speed = speed
+ };
+
+ return _nats.PublishStateAsync(StateSubjects.Waypoint, stateMessage);
+ }
+
+ public Task PublishScoresAsync(IEnumerable playerScores, IEnumerable teamScores, int winnerTeam,
+ string? winnerPlayer, ModeScriptSection section, bool useTeams)
+ {
+ var message = new ScoresMessage
+ {
+ Scores = playerScores,
+ TeamScores = teamScores,
+ WinnerTeam = winnerTeam,
+ WinnerPlayer = winnerPlayer,
+ Section = section,
+ UseTeams = useTeams
+ };
+
+ return _nats.PublishStateAsync(StateSubjects.Scores, message);
+ }
+}
diff --git a/src/Modules/ServerSyncModule/Settings/INatsSettings.cs b/src/Modules/ServerSyncModule/Settings/INatsSettings.cs
new file mode 100644
index 000000000..9cbf37053
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Settings/INatsSettings.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Modules.Attributes;
+using LinqToDB.Common;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Settings;
+
+[Settings]
+public interface INatsSettings
+{
+ [Option(DefaultValue = "127.0.0.1"), Description("Host/IP of the NATS server connection.")]
+ public string Host { get; set; }
+
+ [Option(DefaultValue = 4222), Description("Port for the NATS server connection.")]
+ public int Port { get; set; }
+
+ [Option(DefaultValue = "EvoSC"), Description("The name of the stream to use for state messages.")]
+ public string StreamName { get; set; }
+
+ [Option(DefaultValue = "ServerSync")]
+ [Description("The message group this server is part of. Used to get messages from all other servers in the same group.")]
+ public string MessageGroup { get; set; }
+
+ [Option(DefaultValue = (ulong)0)]
+ [Description("The last player state update message ID.")]
+ public ulong PlayerStatesStartSequence { get; set; }
+
+ [Option(DefaultValue = "EvoSC-bucket")]
+ [Description("The name of the key vault bucket.")]
+ public string KeyVaultBucketName { get; set; }
+}
diff --git a/src/Modules/ServerSyncModule/Utils/NatsMsgExtensions.cs b/src/Modules/ServerSyncModule/Utils/NatsMsgExtensions.cs
new file mode 100644
index 000000000..c2fd49a07
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Utils/NatsMsgExtensions.cs
@@ -0,0 +1,10 @@
+using System.Text.Json;
+using NATS.Client;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Utils;
+
+public static class NatsMsgExtensions
+{
+ public static T? Deserialize(this Msg msg) =>
+ JsonSerializer.Deserialize(msg.Data);
+}
diff --git a/src/Modules/ServerSyncModule/Utils/NatsSettingsExtensions.cs b/src/Modules/ServerSyncModule/Utils/NatsSettingsExtensions.cs
new file mode 100644
index 000000000..dea7b948b
--- /dev/null
+++ b/src/Modules/ServerSyncModule/Utils/NatsSettingsExtensions.cs
@@ -0,0 +1,9 @@
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Settings;
+
+namespace EvoSC.Modules.EvoEsports.ServerSyncModule.Utils;
+
+public static class NatsSettingsExtensions
+{
+ public static string GetConnectionUrl(this INatsSettings settings) =>
+ $"nats://{settings.Host}:{settings.Port}";
+}
diff --git a/src/Modules/ServerSyncModule/info.toml b/src/Modules/ServerSyncModule/info.toml
new file mode 100644
index 000000000..8d986201e
--- /dev/null
+++ b/src/Modules/ServerSyncModule/info.toml
@@ -0,0 +1,11 @@
+[info]
+# A unique name for this module, this is used as a identifier
+name = "ServerSyncModule"
+# The title of the module
+title = "Server Synchronization"
+# A short description of what the module is and does
+summary = "Synchronize multiple servers."
+# The current version of this module, using SEMVER
+version = "1.0.0"
+# The name of the author that created this module
+author = "EvoEsports"
diff --git a/src/Modules/ServerSyncModule/readme.md b/src/Modules/ServerSyncModule/readme.md
new file mode 100644
index 000000000..ec4b4093f
--- /dev/null
+++ b/src/Modules/ServerSyncModule/readme.md
@@ -0,0 +1,67 @@
+# Server Synchronization Module
+This module exposes an API for synchronizing states between multiple servers.
+
+It works by leveraging the NATS message broker and JetStream for reliability and replay capabilities.
+
+## Installation
+### Setting up NATS
+First step is to set up a NATS stream.
+
+In addition, for the subjects to be accepted, we need to define them within the stream.
+The subjects used by the module is `.ChatMessages`, `.PlayerState` and `.MapFinished`.
+
+For example:
+```bash
+nats stream add --defaults --subjects "MyServerSyncStream.ChatMessages,MyServerSyncStream.PlayerState,MyServerSyncStream.MapFinished" MyServerSyncStream
+```
+
+You can also edit these subjects later on with the `nats stream edit` command.
+
+### Installing the module
+To install the module in EvoSC#, first build the project and open the output directory of the module project.
+
+Under the directory `/modules` create a new directory called `ServerSync` and copy the following files from the module project's output directory to that directory:
+- `info.toml`
+- `ServerSync.dll`
+- `NATS.Client.dll`
+
+You can now start EvoSC# and it should generate the module config in the database.
+
+### Configuring the module
+The module provides some configuration for connecting to the the NATS server.
+
+| Name | Description |
+|---------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
+| ServerSyncModule.NatsSettings.Host | The host address for the NATS server. Default is `127.0.0.1` |
+| ServerSyncModule.NatsSettings.Port | The port for the NATS server. Default is `4222` |
+| ServerSyncModule.NatsSettings.MessageGroup | The message group name for the stream. This is typically the stream name itself. Default is `ServerSync` |
+| ServerSyncModule.NatsSettings.PlayerStatesStartSequence | You typically don't need to edit this value. But essentially, when the module starts up, all messages from this start sequence ID will be replayed. |
+
+## Usage
+Using the module is done through the `ISyncService` service, as well as subscribing to events raised by the module.
+
+Chat messages and map finishes are automatically published to all the servers and it is generally not needed to call these methods from the `ISyncService` service.
+
+But to publish a player's state, one can call the `PublishPlayerStateAsync` method. It provides several overloads for your convenience.
+
+### `ISyncService` methods:
+
+| Method | Description |
+|--------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------|
+| `PublishChatMessageAsync(IPlayer player, string message);` | Publish a chat message to all connected servers. |
+| `PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores, IEnumerable checkpointScores, IEnumerable times);` | Publish a player state to all connected servers. |
+| `PublishPlayerStateAsync(IPlayer player, IEnumerable scores)` | Publish the scores of a player to all connected servers. |
+| `PublishPlayerStateAsync(IPlayer player, long position)` | Publish the position of a player to all servers. |
+| `PublishPlayerStateAsync(IPlayer player, long position)` | Publish the position of a player to all servers. |
+| `PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores)` | Publish the position and scores of a player to all servers. |
+| `PublishPlayerStateAsync(IPlayer player, long position, IEnumerable scores, IEnumerable checkpointScores)` | Publish the position, scores and checkpoints of a player to all servers. |
+| `PublishMapFinished()` | Publish a map finished/ended event to all servers. |
+| | |
+
+### Subscriptions
+
+| Name | Args | Description |
+|---------------------|------------------------------|----------------------------------------------------------|
+| `PlayerStateUpdate` | `PlayerStateUpdateEventArgs` | When a player state was updated from one of the servers. |
+| `ChatMessage` | `ChatStateMessageEventArgs` | When a chat message was sent from one of the servers. |
+| `MapFinished` | `MapFinishedStateEventArgs` | When a server has their map finished. |
diff --git a/src/Modules/ToornamentModule/Controllers/MatchConfigurationController.cs b/src/Modules/ToornamentModule/Controllers/MatchConfigurationController.cs
new file mode 100644
index 000000000..d058864e5
--- /dev/null
+++ b/src/Modules/ToornamentModule/Controllers/MatchConfigurationController.cs
@@ -0,0 +1,56 @@
+using EvoSC.Commands.Attributes;
+using EvoSC.Commands.Interfaces;
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Util;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Permissions;
+using Microsoft.Extensions.Logging;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Controllers;
+
+[Controller]
+public class MatchConfigurationController(IMatchService matchService, IServerClient server, ILogger logger) : EvoScController
+{
+ [ChatCommand("toornament_setup", "Setup the server for a match from toornament.", ToornamentPermissions.SetupMatch)]
+ public async Task ToornamentSetupAsync()
+ {
+ try
+ {
+ await server.Chat.InfoMessageAsync($"Setting up a new Toornament match, please wait ...");
+ await matchService.ShowSetupScreenAsync(Context.Player, string.Empty, string.Empty);
+ }
+ catch (InvalidOperationException ex)
+ {
+ var chatMessage = FormattingUtils.FormatPlayerChatMessage(Context.Player, $"(Toornament) {ex.Message}", false);
+ await server.Chat.ErrorMessageAsync(chatMessage);
+ }
+ catch (Exception)
+ {
+ var chatMessage = FormattingUtils.FormatPlayerChatMessage(Context.Player, $"(Toornament) An unknown error occured, check console.", false);
+ await server.Chat.ErrorMessageAsync(chatMessage);
+ throw;
+ }
+ }
+
+ [ChatCommand("toornament_startmatch", "Start a toornament match.", ToornamentPermissions.StartMatch)]
+ [CommandAlias("/toornament_start")]
+ public async Task ToornamentStartMatchAsync()
+ {
+ try
+ {
+ await matchService.StartMatchAsync();
+ }
+ catch (Exception ex)
+ {
+ var chatMessage = FormattingUtils.FormatPlayerChatMessage(Context.Player, $"Failed to start match: {ex.Message}", false);
+ await server.Chat.ErrorMessageAsync(chatMessage);
+ throw;
+ }
+ }
+
+ [ChatCommand("servername", "Set the current server name.", ToornamentPermissions.ServerName)]
+ public Task SetServerNameAsync(string name) => matchService.SetServerNameAsync(name);
+}
+
diff --git a/src/Modules/ToornamentModule/Controllers/MatchController.cs b/src/Modules/ToornamentModule/Controllers/MatchController.cs
new file mode 100644
index 000000000..0a80b1c64
--- /dev/null
+++ b/src/Modules/ToornamentModule/Controllers/MatchController.cs
@@ -0,0 +1,94 @@
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Events.Attributes;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Models;
+using EvoSC.Common.Remote;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using EvoSC.Modules.Official.MatchReadyModule.Events;
+using EvoSC.Modules.Official.MatchReadyModule.Events.Args;
+using GbxRemoteNet.Events;
+using Microsoft.Extensions.Logging;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Controllers;
+
+[Controller]
+public class MatchController(
+ IMatchService matchService,
+ IServerClient server,
+ IToornamentSettings settings,
+ ILogger logger,
+ IWhitelistService whitelistService) : EvoScController
+{
+ [Subscribe(MatchReadyEvents.AllPlayersReady)]
+ public async Task OnAllPlayersReadyAsync(object sender, AllPlayersReadyEventArgs args)
+ {
+ if (!settings.AutomaticMatchStart)
+ {
+ return;
+ }
+
+ try
+ {
+ await matchService.StartMatchAsync();
+ }
+ catch (Exception ex)
+ {
+ await server.Chat.ErrorMessageAsync(
+ "An error occured while trying to start the match. Contact match admin immediately.");
+ logger.LogError(ex, "Failed to start match on all players ready.");
+ }
+ }
+
+ [Subscribe(ModeScriptEvent.Scores)]
+ public async Task OnMatchStateTracked(object sender, ScoresEventArgs args)
+ {
+ if (args.Section is not (ModeScriptSection.EndMatch))
+ {
+ return;
+ }
+
+ try
+ {
+ await matchService.EndMatchAsync(args);
+ }
+ catch (Exception ex)
+ {
+ await server.Chat.ErrorMessageAsync(
+ "Failed to send match results. Take screenshots and contact a match admin.");
+ logger.LogError(ex, "Failed to send match results.");
+ }
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpStart)]
+ public async Task OnWarmupStart(object sender, EventArgs args)
+ {
+ try
+ {
+ await matchService.FinishServerSetupAsync();
+ await matchService.SetMatchGameMapAsync();
+ }
+ catch (Exception)
+ {
+ await server.Chat.ErrorMessageAsync("Failed to finish match setup. Contact a match admin immediately.");
+ throw;
+ }
+ }
+
+ [Subscribe(GbxRemoteEvent.PlayerConnect)]
+ public async Task ForcePlayersIntoSpectate(object sender, PlayerConnectGbxEventArgs args)
+ {
+ try
+ {
+ await whitelistService.ForcePlayerIntoSpectate(args.Login);
+ }
+ catch (Exception)
+ {
+ logger.LogWarning("Failed to put player {0} into spectate mode", args.Login);
+ throw;
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Controllers/MatchManialinkController.cs b/src/Modules/ToornamentModule/Controllers/MatchManialinkController.cs
new file mode 100644
index 000000000..42bed3c04
--- /dev/null
+++ b/src/Modules/ToornamentModule/Controllers/MatchManialinkController.cs
@@ -0,0 +1,47 @@
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Util;
+using EvoSC.Manialinks;
+using EvoSC.Manialinks.Attributes;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Permissions;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Controllers;
+
+[Controller]
+public class MatchManialinkController(IMatchService matchService, IServerClient server, IToornamentSettings settings) : ManialinkController
+{
+ [ManialinkRoute(Route = "SelectMatch/{tournamentId}/{stageId}/{matchId}", Permission = ToornamentPermissions.SetupMatch)]
+ public async Task SelectMatchAsync(string tournamentId, string stageId, string matchId)
+ {
+ await matchService.ShowConfirmSetupScreenAsync(Context.Player, tournamentId, stageId, matchId);
+ var chatMessage = FormattingUtils.FormatPlayerChatMessage(Context.Player, "Match successfully set up!", false);
+ await server.Chat.SuccessMessageAsync(chatMessage);
+ }
+
+ [ManialinkRoute(Route = "ConfirmMatch/{tournamentId}/{stageId}/{matchId}/True", Permission = ToornamentPermissions.SetupMatch)]
+ public async Task ConfirmMatchAsync(string tournamentId, string stageId, string matchId)
+ {
+ await HideAsync(Context.Player, "ToornamentModule.Dialogs.MatchInProgressDialog");
+ await matchService.SetupServerAsync(Context.Player, tournamentId, stageId, matchId);
+ }
+
+ [ManialinkRoute(Route = "ConfirmMatch/{tournamentId}/{stageId}/{matchId}/False", Permission = ToornamentPermissions.SetupMatch)]
+ public async Task CancelConfirmMatchAsync(string tournamentId, string stageId, string matchId)
+ {
+ await HideAsync(Context.Player, "ToornamentModule.Dialogs.MatchInProgressDialog");
+ }
+
+ [ManialinkRoute(Route = "SelectTournament/{tournamentId}", Permission = ToornamentPermissions.SetupMatch)]
+ public async Task SelectTournamentAsync(string tournamentId)
+ {
+ await matchService.ShowSetupScreenAsync(Context.Player, tournamentId, string.Empty);
+ }
+
+ [ManialinkRoute(Route = "SelectStage/{tournamentId}/{stageId}", Permission = ToornamentPermissions.SetupMatch)]
+ public async Task SelectStageAsync(string tournamentId, string stageId)
+ {
+ await matchService.ShowSetupScreenAsync(Context.Player, tournamentId, stageId);
+ }
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IDiscordNotifyService.cs b/src/Modules/ToornamentModule/Interfaces/IDiscordNotifyService.cs
new file mode 100644
index 000000000..4b3a58f2d
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IDiscordNotifyService.cs
@@ -0,0 +1,7 @@
+using EvoSC.Common.Interfaces.Models;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+public interface IDiscordNotifyService
+{
+ Task NotifyMatchInfoAsync(string matchName, List maps);
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IMatchMapService.cs b/src/Modules/ToornamentModule/Interfaces/IMatchMapService.cs
new file mode 100644
index 000000000..4aea4879d
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IMatchMapService.cs
@@ -0,0 +1,11 @@
+using EvoSC.Common.Interfaces.Models;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IMatchMapService
+{
+ Task> AddMapsAsync(IPlayer player);
+ Task> AddMapsFromNadeo(IPlayer player, IEnumerable mapIds);
+ Task> AddMapsFromTmx(IPlayer player, IEnumerable mapIds);
+
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IMatchPlayerService.cs b/src/Modules/ToornamentModule/Interfaces/IMatchPlayerService.cs
new file mode 100644
index 000000000..1c4dd8bd9
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IMatchPlayerService.cs
@@ -0,0 +1,9 @@
+using EvoSC.Common.Interfaces.Models;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IMatchPlayerService
+{
+ Task> GetPlayersFromOpponents(OpponentInfo[] opponents);
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IMatchService.cs b/src/Modules/ToornamentModule/Interfaces/IMatchService.cs
new file mode 100644
index 000000000..987d8d2ab
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IMatchService.cs
@@ -0,0 +1,23 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Remote.EventArgsModels;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IMatchService
+{
+ public Task ShowSetupScreenAsync(IPlayer player, string selectedTournamentId, string selectedStageId);
+
+ public Task SetupServerAsync(IPlayer player, string tournamentId, string stageId, string matchId);
+
+ public Task FinishServerSetupAsync();
+
+ public Task StartMatchAsync();
+
+ public Task EndMatchAsync(ScoresEventArgs timeline);
+
+ public Task SetServerNameAsync(string name);
+
+ public Task SetMatchGameMapAsync();
+
+ public Task ShowConfirmSetupScreenAsync(IPlayer player, string tournamentId, string stageId, string matchId);
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IMatchSettingsCreatorService.cs b/src/Modules/ToornamentModule/Interfaces/IMatchSettingsCreatorService.cs
new file mode 100644
index 000000000..89a920bf1
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IMatchSettingsCreatorService.cs
@@ -0,0 +1,14 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IMatchSettingsCreatorService
+{
+ Task CreateMatchSettingsAsync(TournamentBasicData tournament, MatchInfo matchInfo,
+ StageInfo stageInfo, GroupInfo groupInfo, RoundInfo roundInfo, IEnumerable maps);
+
+ Task CreateMatchSettings(string name, TrackmaniaIntegrationSettingsData settingsData,
+ List mapsToAdd);
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/INadeoMapService.cs b/src/Modules/ToornamentModule/Interfaces/INadeoMapService.cs
new file mode 100644
index 000000000..a22f95002
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/INadeoMapService.cs
@@ -0,0 +1,12 @@
+using EvoSC.Common.Interfaces.Models;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+public interface INadeoMapService
+{
+ ///
+ /// Downloads a map from Nadeo servers if it exists with the given ID.
+ ///
+ /// The maps ID
+ ///
+ Task FindAndDownloadMapAsync(string mapId);
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IStateService.cs b/src/Modules/ToornamentModule/Interfaces/IStateService.cs
new file mode 100644
index 000000000..e13bbe66f
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IStateService.cs
@@ -0,0 +1,55 @@
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IStateService
+{
+ ///
+ /// True when the server is initially set up.
+ ///
+ public bool IsInitialSetup { get; }
+
+ ///
+ /// The name of the MatchSettings for the match that is currently set up.
+ ///
+ public string MatchSettingsName { get; }
+
+ ///
+ /// True when the server has finished the match setup.
+ ///
+ public bool SetupFinished { get; }
+
+ ///
+ /// True if the server is waiting for the match to start.
+ ///
+ public bool WaitingForMatchStart { get; }
+
+ ///
+ /// True if the match has started.
+ ///
+ public bool MatchInProgress { get; }
+
+ ///
+ /// True if the match has ended.
+ ///
+ public bool MatchEnded { get; }
+
+ ///
+ /// Set the initial setup state.
+ ///
+ /// Name of the match settings to use.
+ public void SetInitialSetup(string matchSettingsName);
+
+ ///
+ /// Set the finish setup state.
+ ///
+ public void SetSetupFinished();
+
+ ///
+ /// Set that the match has started.
+ ///
+ public void SetMatchStarted();
+
+ ///
+ /// Set that the match has ended.
+ ///
+ public void SetMatchEnded();
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IToornamentService.cs b/src/Modules/ToornamentModule/Interfaces/IToornamentService.cs
new file mode 100644
index 000000000..397d7b5aa
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IToornamentService.cs
@@ -0,0 +1,23 @@
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+public interface IToornamentService
+{
+ Task GetDisciplineAsync(string disciplineId);
+ Task GetGroupAsync(string groupId);
+ Task?> GetGroupsAsync(string tournamentId, string stageId);
+ Task GetMatchAsync(string matchId);
+ Task?> GetMatchesAsync(string tournamentId, string stageId);
+ Task?> GetMatchGamesAsync(string matchId);
+ Task GetMatchGameAsync(string matchId, int gameNumber);
+ Task SetMatchGameStatusAsync(string matchId, int gameNumber, MatchGameStatus status);
+ Task SetMatchGameResultAsync(string matchId, int gameNumber, MatchGameInfo gameInfo);
+ Task SetMatchGameMapAsync(string matchId, int gameNumber, string mapName);
+ Task GetRoundAsync(string roundId);
+ Task?> GetRoundsAsync(string tournamentId, string stageId);
+ Task GetStageAsync(string stageId);
+ Task?> GetStagesAsync(string tournamentId);
+ Task GetTournamentAsync(string tournamentId);
+ Task?> GetTournamentsAsync();
+}
diff --git a/src/Modules/ToornamentModule/Interfaces/IWhitelistService.cs b/src/Modules/ToornamentModule/Interfaces/IWhitelistService.cs
new file mode 100644
index 000000000..8776daedb
--- /dev/null
+++ b/src/Modules/ToornamentModule/Interfaces/IWhitelistService.cs
@@ -0,0 +1,11 @@
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+public interface IWhitelistService
+{
+ Task WhitelistPlayers(OpponentInfo[] opponents);
+ Task WhitelistSpectators();
+ Task ForcePlayerIntoSpectate(string login);
+ Task KickNonWhitelistedPlayers();
+}
diff --git a/src/Modules/ToornamentModule/Localization.resx b/src/Modules/ToornamentModule/Localization.resx
new file mode 100644
index 000000000..10e3157c7
--- /dev/null
+++ b/src/Modules/ToornamentModule/Localization.resx
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
diff --git a/src/Modules/ToornamentModule/Models/MatchGameStatus.cs b/src/Modules/ToornamentModule/Models/MatchGameStatus.cs
new file mode 100644
index 000000000..e6acb501e
--- /dev/null
+++ b/src/Modules/ToornamentModule/Models/MatchGameStatus.cs
@@ -0,0 +1,7 @@
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+public enum MatchGameStatus
+{
+ Pending,
+ Running,
+ Completed
+}
diff --git a/src/Modules/ToornamentModule/Models/PluginData.cs b/src/Modules/ToornamentModule/Models/PluginData.cs
new file mode 100644
index 000000000..2b1a75477
--- /dev/null
+++ b/src/Modules/ToornamentModule/Models/PluginData.cs
@@ -0,0 +1,8 @@
+using System.Text.Json.Serialization;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+public class PluginData
+{
+ [JsonPropertyName("S_UseAutoReady")]
+ public bool S_UseAutoReady { get; set; } = false;
+}
diff --git a/src/Modules/ToornamentModule/Models/ScriptData.cs b/src/Modules/ToornamentModule/Models/ScriptData.cs
new file mode 100644
index 000000000..c75148178
--- /dev/null
+++ b/src/Modules/ToornamentModule/Models/ScriptData.cs
@@ -0,0 +1,38 @@
+using System.Text.Json.Serialization;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+public class ScriptData
+{
+ [JsonPropertyName("S_NbOfWinners")]
+ public int S_NbOfWinners { get; set; } = 1;
+
+ [JsonPropertyName("S_PointsLimit")]
+ public int S_PointsLimit { get; set; } = 50;
+
+ [JsonPropertyName("S_PointsRepartition")]
+ public string S_PointsRepartition { get; set; } = "10,6,4,3,2,1";
+
+ [JsonPropertyName("S_RespawnBehaviour")]
+ public int S_RespawnBehaviour { get; set; } = 0;
+
+ [JsonPropertyName("S_RoundsPerMap")]
+ public int S_RoundsPerMap { get; set; } = -1;
+
+ [JsonPropertyName("S_FinishTimeout")]
+ public int S_FinishTimeout { get; set; } = -1;
+
+ [JsonPropertyName("S_DelayBeforeNextMap")]
+ public int S_DelayBeforeNextMap { get; set; } = 2000;
+
+ [JsonPropertyName("S_WarmUpNb")]
+ public int S_WarmUpNb { get; set; } = 0;
+
+ [JsonPropertyName("S_WarmUpDuration")]
+ public int S_WarmUpDuration { get; set; } = 0;
+
+ [JsonPropertyName("S_MapsPerMatch")]
+ public int S_MapsPerMatch { get; set; } = -1;
+
+ [JsonPropertyName("S_UseTieBreak")]
+ public bool S_UseTieBreak { get; set; } = false;
+}
diff --git a/src/Modules/ToornamentModule/Models/TrackmaniaIntegrationSettingsData.cs b/src/Modules/ToornamentModule/Models/TrackmaniaIntegrationSettingsData.cs
new file mode 100644
index 000000000..756076e47
--- /dev/null
+++ b/src/Modules/ToornamentModule/Models/TrackmaniaIntegrationSettingsData.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+public class TrackmaniaIntegrationSettingsData
+{
+ [JsonPropertyName("tracks_shuffle")]
+ public bool TracksShuffle { get; set; } = false;
+
+ [JsonPropertyName("game_mode")]
+ public string GameMode { get; set; } = "time_attack";
+
+ [JsonPropertyName("stage_number")]
+ public int StageNumber { get; set; } = 0;
+
+ [JsonPropertyName("group_number")]
+ public int GroupNumber { get; set; } = 0;
+
+ [JsonPropertyName("round_number")]
+ public int RoundNumber { get; set; } = 0;
+
+ [JsonPropertyName("scripts")]
+ public ScriptData Scripts { get; set; } = new ScriptData();
+
+ [JsonPropertyName("plugins")]
+ public PluginData Plugins { get; set; } = new PluginData();
+
+ public static TrackmaniaIntegrationSettingsData CreateFromObject(object x)
+ {
+ var settingsData = new TrackmaniaIntegrationSettingsData();
+ if (x is JsonElement json)
+ {
+ settingsData = JsonSerializer.Deserialize(json);
+ }
+
+ return settingsData;
+ }
+
+}
diff --git a/src/Modules/ToornamentModule/Permissions/ToornamentPermissions.cs b/src/Modules/ToornamentModule/Permissions/ToornamentPermissions.cs
new file mode 100644
index 000000000..d29a328c6
--- /dev/null
+++ b/src/Modules/ToornamentModule/Permissions/ToornamentPermissions.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel;
+using EvoSC.Common.Permissions.Attributes;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Permissions;
+
+[PermissionGroup]
+public enum ToornamentPermissions
+{
+ [Description("Can start a match through toornament.")]
+ StartMatch,
+
+ [Description("Can set up the server for a match.")]
+ SetupMatch,
+
+ [Description("Can set custom points for a player.")]
+ SetPoints,
+
+ [Description("Allows to manage the server name.")]
+ ServerName
+}
diff --git a/src/Modules/ToornamentModule/README.md b/src/Modules/ToornamentModule/README.md
new file mode 100644
index 000000000..1bbdc3d42
--- /dev/null
+++ b/src/Modules/ToornamentModule/README.md
@@ -0,0 +1,107 @@
+# EvoSC-Sharp-Toornament
+This module is used to connect a server to the Toornament website. Toornament is a website to organize competitions for many games, including Trackmania. With this module it's possible to setup a server with specific match data provided by Toornament and at the end of a match the scores are relayed back to the Toornament website. This way a bracket and winner can be determined for a competition.
+
+
+## Setup Environment Variables
+
+The module has the following Environment Variables:
+
+|Name|Docker|Type|Required|Description|
+|----|------|-----|--------|-----------|
+|ApiKey|EVOSC_MODULE_TOORNAMENTMODULE_APIKEY|string|Yes|The Api key to connect with the Toornament Api.|
+|ClientId|EVOSC_MODULE_TOORNAMENTMODULE_CLIENTID|string|Yes|The ClientId to connect with the Toornament Api.|
+|ClientSecret|EVOSC_MODULE_TOORNAMENTMODULE_CLIENTSECRET|string|Yes|The Client secret to connect with the Toornament Api.|
+|ToornamentId|EVOSC_MODULE_TOORNAMENTMODULE_TOORNAMENTID|string|No|The Toornament Id to be pre-selected for this event.|
+|Whitelist|EVOSC_MODULE_TOORNAMENTMODULE_WHITELIST|string|No|The Comma-separated string of Account IDs (NOT LoginId) of players who should be whitelisted on the server. |
+|AutomaticMatchStart|EVOSC_MODULE_TOORNAMENTMODULE_AUTOMATICMATCHSTART|bool|No|Flag to determine whether a Match should start automatically when all the players are ready. If false, the admin has to use `/toornament_startmatch` to start the match manually.|
+|AssignedMatchId|EVOSC_MODULE_TOORNAMENTMODULE_ASSIGNEDMATCHID|string|No|NOT IMPLEMENTED TO BE SET AND USED FROM ENVIRONMENT VARIABLE. This is currently used internally to keep track of the Match this server is configured for.|
+|UseToornamentDiscipline|EVOSC_MODULE_TOORNAMENTMODULE_USETOORNAMENTDISCIPLINE|bool|Yes|UNTESTED! Flag to determine whether to use the Discipline defined on the Toornament website or not. The module has only been tested with the value `False` and the `Disciplines` variable set.|
+|Disciplines|EVOSC_MODULE_TOORNAMENTMODULE_DISCIPLINES|string|Yes*|Required if we don't use the Discipline configured on the Toornament website. Description, see below.|
+|MapTmxIds|EVOSC_MODULE_TOORNAMENTMODULE_MAPTMXIDS|string|Yes*|A comma separated string containing the Map Ids used on TMX. Required when `MapUids` and `MapIds` are empty.|
+|MapUids|EVOSC_MODULE_TOORNAMENTMODULE_MAPUIDS|string|Yes*|A comma separated string containing the Map Uids used on this server. It is used to determine whether the maps have already been provided or have to be downloaded. Required when `MapTmxIds` or `MapIds` are empty.|
+|MapIds|EVOSC_MODULE_TOORNAMENTMODULE_MAPIDS|string|Yes*|A comma separated string containing the Map Ids used on Nadeo servers. Required when `MapTmxIds` or `MapUids` are empty.|
+|MapMachineNames|EVOSC_MODULE_TOORNAMENTMODULE_MAPMACHINENAMES|Yes|A comma separated string containing the MachineNames of the maps as defined on Toornament. The Toornament API doesn't provide an endpoint for this, so we have to provide it|
+|SensitiveLogging|EVOSC_MODULE_TOORNAMENTMODULE_SENSITIVELOGGING|bool|No|Enable very sensitive logging, like logging Authorization tokens|
+|UseExperimentalFeatures|EVOSC_MODULE_TOORNAMENTMODULE_USEEXPERIMENTALFEATURES|bool|No|Flag to indicate whether to use experimental features or not. This is currently applying a whitelist to players on the server or players joining the server. If they are not on the whitelist, they will get kicked from the server. Default value is `False`.|
+|UseDefaultGameMode|EVOSC_MODULE_TOORNAMENTMODULE_USEDEFAULTGAMEMODE|bool|No|Flag to indicate whether to use a Nadeo gamemode or a custom gamemode. Default value is `true`.|
+|GameMode|EVOSC_MODULE_TOORNAMENTMODULE_GAMEMODE|string|Yes*|Required when using a custom gamemode script. This indicates the path to where the script can be found.|
+|WebhookUrl|EVOSC_MODULE_TOORNAMENTMODULE_WEBHOOKURL|string|No|String indicating a Discord webhook url where maporder will be posted to.|
+|MessageSuffix|EVOSC_MODULE_TOORNAMENTMODULE_MESSAGESUFFIX|string|No|Extra information that can be added to the Discord message. For example pinging certain @roles or @users.|
+
+## Disciplines
+
+A Tournament can have different matchsettings to be used during different stages of the tournament. In Toornament these settings are called Disciplines. We can define multiple Disciplines as an Json array in the `Disciplines` environment variable.
+
+A single Discipline has the following structure:
+```Json
+{
+ "game_mode": "rounds",
+ "group_number": 1,
+ "plugins":
+ {
+ "S_UseAutoReady": false
+ },
+ "round_number": 1,
+ "scripts":
+ {
+ "S_DelayBeforeNextMap": 2000,
+ "S_FinishTimeout": 3,
+ "S_MapsPerMatch": 2,
+ "S_NbOfWinners": 1,
+ "S_PointsLimit": -1,
+ "S_PointsRepartition": "n-1",
+ "S_RespawnBehaviour": 0,
+ "S_RoundsPerMap": 1,
+ "S_UseTieBreak": false,
+ "S_WarmUpDuration": 5,
+ "S_WarmUpNb": 1
+ },
+ "stage_number": 1,
+ "tracks_shuffle": true
+}
+```
+
+### GameMode
+The game mode to be used during this stage of the Tournament. Available options are:
+- rounds
+- cup
+- time_attack
+- knockout
+- laps
+- team
+
+### StageNumber, GroupNumber, RoundNumber
+The specific combination of StageNumber, GroupNumber and RoundNumber (as provided by Toornament) where this Discipline will be used. Since it's possible to provide an array of Disciplines, we can define different Disciplines for multiple stages / groups / rounds at once.
+
+### Plugins
+Unused and untested at the moment. This is configured in Toornament and can be used with the Nadeo competition tool. For each value, see https://doc.trackmania.com/club/competition-tool/plugin-settings/ for the specific explanations.
+
+### TracksShuffle
+Flag to indicate whether the maps should be shuffled or not. TODO: Shuffling is currently the only option to determine a different map order. Players have requested a Pick (and Ban) phase to determine the map order. This is currently not available.
+
+### Scripts
+Specific script settings to be used for this match as defined in a Rulebook. For each value, see https://wiki.trackmania.io/en/dedicated-server/Usage/OfficialGameModesSettings for the specific explanations.
+
+S_PointsRepartition is different though. This value can contain one of two options:
+- The default Comma-separated list of numbers to determine the point repartition from first to last (e.g. 10,6,4,3,2,1)
+- The string `"n-1"`. This will look at the number of Assigned players in a match (For Toornament this is `Match.Opponents.Length()`) and generate the Comma-separated list of numbers (e.g. Opponents=60 -> 60,59,58...3,2,1).
+
+
+
+## Chat commands
+
+### /toornament_setup
+This command will show a setup window where an organizer can select the specific Toornament from a list, followed by the specific Stage and Match to be played. When the match is selected, the server will setup the defined gamemode and gamesettings from either the `Disciplines` environment variable or from the discipline defined on the Toornament website.
+
+Video:
+
+https://github.com/user-attachments/assets/cb115c67-112a-40e9-9ce0-98f41099d6d1
+
+### /toornament_startmatch
+Start the match for the configured match on this server. Can only be called after `/toornament_setup`.
+
+### /setpoints \ \
+Change the points of the given playername to the provided number.
+
+### /servername \
+Sets the name of the server to the provided name.
diff --git a/src/Modules/ToornamentModule/Services/DiscordNotifyService.cs b/src/Modules/ToornamentModule/Services/DiscordNotifyService.cs
new file mode 100644
index 000000000..21446c46b
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/DiscordNotifyService.cs
@@ -0,0 +1,63 @@
+using System.Text;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using Microsoft.Extensions.Logging;
+using Newtonsoft.Json;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class DiscordNotifyService(
+ ILogger logger,
+ IToornamentSettings settings
+ ) : IDiscordNotifyService
+{
+ private readonly HttpClient _http = new();
+
+ public async Task NotifyMatchInfoAsync(string matchName, List maps)
+ {
+ if (string.IsNullOrEmpty(settings.WebhookUrl))
+ {
+ logger.LogDebug("Discord webhook url is not setup, so no matchinfo will be shared");
+ return;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine($"The map order for match {matchName} is: ");
+ sb.AppendLine();
+ foreach (var map in maps)
+ {
+ sb.AppendLine(map.Name);
+ }
+
+ // Check for suffix so we can ping people on Discord
+ if (!string.IsNullOrEmpty(settings.MessageSuffix))
+ {
+ sb.AppendLine();
+ sb.AppendLine(settings.MessageSuffix);
+ }
+
+ var messageObject = new { content = sb.ToString() };
+ var json = JsonConvert.SerializeObject(messageObject);
+ var data = new StringContent(json, Encoding.UTF8, "application/json");
+ logger.LogTrace($"Requesting {settings.WebhookUrl}");
+
+ try
+ {
+ var response = await _http.PostAsync(settings.WebhookUrl, data);
+ if (!response.IsSuccessStatusCode)
+ {
+ logger.LogWarning("Failed to send message to Discord webhook url with matchinfo");
+ }
+
+ logger.LogDebug("Successfully executed webhook.");
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to execute Discord webhook");
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/MatchMapService.cs b/src/Modules/ToornamentModule/Services/MatchMapService.cs
new file mode 100644
index 000000000..e372a4830
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/MatchMapService.cs
@@ -0,0 +1,203 @@
+using EvoSC.Common.Exceptions;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using EvoSC.Modules.Official.MapsModule.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class MatchMapService(
+ ILogger logger,
+ IMapService mapService,
+ INadeoMapService nadeoMapService,
+ IServerClient server,
+ IMxMapService mxMapService,
+ IToornamentSettings settings
+ ) : IMatchMapService
+{
+ public async Task> AddMapsAsync(IPlayer player)
+ {
+ logger.LogDebug("Begin of AddMapsAsync()");
+ List maps = [];
+ bool allMapsOnServer = true;
+
+ //Check if the maps are already on the server
+ try
+ {
+ foreach (var mapUid in GetMapUids())
+ {
+ logger.LogDebug("Checking if map with Uid {MapUid} exists on the server", mapUid);
+ IMap? existingMap = await mapService.GetMapByUidAsync(mapUid);
+ if (existingMap == null)
+ {
+ logger.LogDebug("Map with Uid {MapUid} was not found on the server", mapUid);
+ allMapsOnServer = false;
+ }
+ else
+ {
+ maps.Add(existingMap);
+ }
+ }
+ }
+ catch (ArgumentNullException)
+ {
+ //Silently catch Exception, since we can use Nadeo Servers or TMX as backup
+ }
+
+ //Try to download maps from Nadeo Servers using the MapId
+ if (!allMapsOnServer)
+ {
+ try
+ {
+ maps = await AddMapsFromNadeo(player, GetMapIds());
+ if (maps is not null)
+ {
+ allMapsOnServer = true;
+ }
+ }
+ catch (ArgumentNullException)
+ {
+ //Silently catch Exception, since we can use TMX as final backup
+ }
+ }
+
+ //Try to download maps from TMX using tmx Ids
+ if (!allMapsOnServer)
+ {
+ maps = await AddMapsFromTmx(player, GetTmxIds());
+ allMapsOnServer = true;
+ }
+
+ //Throw error if maps are still not found
+ if (!allMapsOnServer || maps?.Count == 0)
+ {
+ logger.LogWarning("Maps could not be found on the server, or downloaded from Nadeo servers or from TMX");
+ throw new ArgumentException(
+ "Maps could not be found on the server, or downloaded from Nadeo servers or from TMX");
+ }
+
+ logger.LogDebug("End of AddMapsAsync()");
+ return maps;
+ }
+
+ public async Task> AddMapsFromNadeo(IPlayer player, IEnumerable mapIds)
+ {
+ List maps = new List();
+ try
+ {
+ maps.AddRange(await Task.WhenAll(mapIds.Select(async m =>
+ {
+ try
+ {
+ logger.LogDebug("Downloading map with id {0} from Nadeo servers", m);
+ return await nadeoMapService.FindAndDownloadMapAsync(m);
+ }
+ catch (DuplicateMapException ex)
+ {
+ //Exception message is "Map with UID {MapUid} already exists in database", we need the MapUid to get the map from the server
+ var mapUid = ex.Message.Split(' ')[3];
+ return await mapService.GetMapByUidAsync(mapUid);
+ }
+ })));
+ }
+ catch (Exception)
+ {
+ logger.LogWarning("Failed to download map from Nadeo servers");
+ await server.Chat.ErrorMessageAsync("Failed to add map using the Nadeo servers");
+ throw;
+ }
+
+ if (maps.Count == mapIds.Count())
+ {
+ return maps;
+ }
+
+ await server.Chat.ErrorMessageAsync("Failed to add all maps from the Nadeo servers");
+ return null;
+
+ }
+
+ public async Task> AddMapsFromTmx(IPlayer player, IEnumerable mapIds)
+ {
+ List maps = [];
+ try
+ {
+ maps.AddRange(await Task.WhenAll(mapIds.Select(async m =>
+ {
+ try
+ {
+ logger.LogDebug("Downloading map with id {NadeoId} from Nadeo servers", m);
+ return await mxMapService.FindAndDownloadMapAsync(m, null, player);
+ }
+ catch (DuplicateMapException ex)
+ {
+ //Exception message is "Map with UID {MapUid} already exists in database", we need the MapUid to get the map from the server
+ var mapUid = ex.Message.Split(' ')[3];
+ return await mapService.GetMapByUidAsync(mapUid);
+ }
+ })));
+ }
+ catch (Exception)
+ {
+ logger.LogWarning("Failed to download map from TMX");
+ await server.Chat.ErrorMessageAsync("Failed to add map from TMX");
+ throw;
+ }
+
+ if (maps.Count() != mapIds.Count())
+ {
+ await server.Chat.ErrorMessageAsync("Failed to add all maps from TMX");
+ return null;
+ }
+
+ return maps;
+ }
+
+ private List GetTmxIds()
+ {
+ if (string.IsNullOrEmpty(settings.MapTmxIds))
+ {
+ throw new ArgumentNullException(nameof(settings.MapTmxIds),
+ @"Map TMX Ids not defined in environment settings");
+ }
+
+ var mapIds = new List();
+
+ foreach (var s in settings.MapTmxIds.Split(','))
+ {
+ int num;
+ if (int.TryParse(s, out num))
+ {
+ mapIds.Add(num);
+ }
+ }
+
+ return mapIds;
+ }
+
+ private List GetMapUids()
+ {
+ if (string.IsNullOrEmpty(settings.MapUids))
+ {
+ throw new ArgumentNullException(nameof(settings.MapUids), @"Map Uids not defined in environment settings");
+ }
+
+ return settings.MapUids.Split(',').ToList();
+ }
+
+ private List GetMapIds()
+ {
+ if (string.IsNullOrEmpty(settings.MapIds))
+ {
+ throw new ArgumentNullException(nameof(settings.MapIds), @"Map Ids not defined in environment settings");
+ }
+
+ return settings.MapIds.Split(',').ToList();
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/MatchPlayerService.cs b/src/Modules/ToornamentModule/Services/MatchPlayerService.cs
new file mode 100644
index 000000000..8faa8efcf
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/MatchPlayerService.cs
@@ -0,0 +1,46 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class MatchPlayerService(
+ IPlayerManagerService playerManagerService,
+ IPermissionManager permissionManager,
+ IToornamentSettings settings
+ ) : IMatchPlayerService
+{
+ public async Task> GetPlayersFromOpponents(OpponentInfo[] opponents)
+ {
+ List players = [];
+ foreach (var opponent in opponents)
+ {
+ if (opponent is not null)
+ {
+ IPlayer player = null;
+ if (opponent.Participant is not null)
+ {
+ var tmId = opponent.Participant.CustomFields["trackmania_id"];
+ if (tmId is not null)
+ {
+ player = await playerManagerService.GetOrCreatePlayerAsync(tmId.ToString(),
+ opponent.Participant.Name);
+ players.Add(player);
+ }
+ }
+
+ if (player is not null && player.Groups.Count() == 0)
+ {
+ await permissionManager.AddPlayerToGroupAsync(player, settings.DefaultGroupId);
+ }
+ }
+ }
+
+ return players;
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/MatchService.cs b/src/Modules/ToornamentModule/Services/MatchService.cs
new file mode 100644
index 000000000..62fd0abf9
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/MatchService.cs
@@ -0,0 +1,391 @@
+using System.Data;
+using System.Text;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using EvoSC.Modules.Official.MatchReadyModule.Interfaces;
+using EvoSC.Modules.Official.MatchTrackerModule.Interfaces;
+using Microsoft.Extensions.Logging;
+using NATS.Client.JetStream;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class MatchService(
+ IAuditService auditService,
+ IServerClient server,
+ IManialinkManager manialinkManager,
+ IToornamentSettings settings,
+ IPlayerManagerService playerManagerService,
+ IPlayerReadyService playerReadyService,
+ IPlayerReadyTrackerService playerReadyTrackerService,
+ IStateService stateService,
+ IMatchSettingsService matchSettings,
+ IMatchTracker matchTracker,
+ IToornamentService toornamentService,
+ IKeyValueStoreService keyValueStoreService,
+ ILogger logger,
+ IMatchSettingsCreatorService matchSettingsCreatorService,
+ IMatchMapService matchMapService,
+ IWhitelistService whitelistService,
+ IMatchPlayerService matchPlayerService
+) : IMatchService
+{
+ public async Task StartMatchAsync()
+ {
+ logger.LogDebug("Begin of StartMatchAsync()");
+ // create a new timeline and get the tracking ID
+ var matchTrackerId = await matchTracker.BeginMatchAsync();
+
+ await matchSettings.LoadMatchSettingsAsync(stateService.MatchSettingsName, false);
+ await server.Remote.RestartMapAsync();
+
+ // disable the ready widget
+ await playerReadyService.SetWidgetEnabled(false);
+
+ await server.Chat.InfoMessageAsync("Match is live after warmup. GLHF! ");
+
+ stateService.SetMatchStarted();
+
+ await whitelistService.KickNonWhitelistedPlayers();
+
+ // notify Toornament that the match has started
+ await toornamentService.SetMatchGameStatusAsync(settings.AssignedMatchId, 1, MatchGameStatus.Running);
+
+ auditService.NewInfoEvent("Toornament.StartMatch")
+ .HavingProperties(new { MatchTrackingId = matchTrackerId })
+ .Comment("Match was started.");
+
+ logger.LogDebug("End of StartMatchAsync()");
+ }
+
+ public async Task EndMatchAsync(ScoresEventArgs timeline)
+ {
+ if (stateService.WaitingForMatchStart || stateService.IsInitialSetup || stateService.SetupFinished ||
+ stateService.MatchEnded)
+ {
+ logger.LogDebug("Match hasn't been run or completed yet. Not updating MatchGame map on Toornament.");
+ return;
+ }
+
+ logger.LogDebug("Begin of EndMatchAsync()");
+
+ if (timeline is not { Section: ModeScriptSection.EndMatch })
+ {
+ throw new InvalidOperationException("Did not get a match end result to send to Toornament.");
+ }
+
+ //TODO check if match is needed to get Opponents.Players or if MatchGame includes this info as well.
+ var match = await toornamentService.GetMatchAsync(settings.AssignedMatchId);
+ var matchGameNumber = 1;
+ var matchGame = await toornamentService.GetMatchGameAsync(settings.AssignedMatchId, matchGameNumber);
+
+ List toornamentScores = new List();
+
+ int bottomRank = match.Opponents.Length;
+
+ foreach (var opponent in match.Opponents)
+ {
+ var matchOpponent = opponent;
+ if (matchOpponent is not null)
+ {
+ PlayerScore? player = null;
+ if (matchOpponent.Participant is not null)
+ {
+ var tmId = matchOpponent.Participant.CustomFields["trackmania_id"];
+ if (tmId is not null)
+ {
+ player = timeline.Players.FirstOrDefault(p => p.AccountId == tmId.ToString());
+ }
+
+ if (player is null)
+ {
+ //try to find player by ubisoft name
+ player = timeline.Players.FirstOrDefault(p => p.Name == matchOpponent.Participant.Name);
+ }
+ }
+
+ // The player showed up in the match and has a score
+ if (player is not null)
+ {
+ matchOpponent.Rank = player.Rank;
+ matchOpponent.Score = player.MatchPoints;
+ }
+
+ // The player didn't show up for the match
+ else
+ {
+ matchOpponent.Forfeit = true;
+ matchOpponent.Score = 0;
+ }
+
+ toornamentScores.Add(matchOpponent);
+ }
+ }
+
+ //override the scores in the MatchGame object
+ matchGame.Opponents = toornamentScores.ToArray();
+ matchGame.Status = MatchGameStatus.Completed.ToString().ToLower();
+ await toornamentService.SetMatchGameResultAsync(settings.AssignedMatchId, matchGameNumber, matchGame);
+
+ //Set GameMode back to the 1h warmup gamemode.
+ await matchSettings.LoadMatchSettingsAsync(stateService.MatchSettingsName + "_warmup", false);
+ await server.Remote.RestartMapAsync();
+
+ stateService.SetMatchEnded();
+
+ await server.Chat.SuccessMessageAsync("Match finished, thanks for playing!");
+ logger.LogDebug("End of EndMatchAsync()");
+ }
+
+ public async Task SetMatchGameMapAsync()
+ {
+ logger.LogDebug("Begin of SetMatchGameMapAsync()");
+ if (!stateService.MatchInProgress)
+ {
+ logger.LogDebug("Match hasn't started yet. Not updating MatchGame map on Toornament.");
+ return;
+ }
+
+ var currentMap = await server.Remote.GetCurrentMapInfoAsync();
+
+ if (currentMap is null)
+ {
+ logger.LogWarning("Current map could not be determined");
+ return;
+ }
+
+ if (string.IsNullOrEmpty(settings.MapMachineNames))
+ {
+ logger.LogWarning("No map names defined in environment");
+ return;
+ }
+
+ var mapMachineNames = settings.MapMachineNames.Split(',');
+
+ var toornamentMapName = "";
+
+ foreach (var name in mapMachineNames)
+ {
+ if (currentMap.Name.ToLowerInvariant().Contains(name))
+ {
+ toornamentMapName = name;
+ break;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(toornamentMapName))
+ {
+ await toornamentService.SetMatchGameMapAsync(settings.AssignedMatchId, 1, toornamentMapName);
+ }
+
+ logger.LogDebug("End of SetMatchGameMapAsync()");
+ }
+
+ public async Task SetServerNameAsync(string name)
+ {
+ return await server.Remote.SetServerNameAsync(name);
+ }
+
+ public async Task ShowSetupScreenAsync(IPlayer player, string selectedTournamentId, string selectedStageId)
+ {
+ logger.LogDebug("Begin of ShowSetupScreenAsync()");
+ if (string.IsNullOrEmpty(selectedTournamentId) && !string.IsNullOrEmpty(settings.ToornamentId) &&
+ settings.ToornamentId != "EVOSC_MODULE_TOORNAMENTMODULE_")
+ {
+ logger.LogDebug("Using toornamentId from settings: {0}", settings.ToornamentId);
+ selectedTournamentId = settings.ToornamentId;
+ }
+
+ var tournaments = await toornamentService.GetTournamentsAsync();
+ List stages = [];
+ List matches = [];
+ List groups = [];
+ List rounds = [];
+
+ if (!string.IsNullOrEmpty(selectedTournamentId))
+ {
+ stages = await toornamentService.GetStagesAsync(selectedTournamentId);
+ if (!string.IsNullOrEmpty(selectedStageId))
+ {
+ matches = await toornamentService.GetMatchesAsync(selectedTournamentId, selectedStageId);
+ groups = await toornamentService.GetGroupsAsync(selectedTournamentId, selectedStageId);
+ rounds = await toornamentService.GetRoundsAsync(selectedTournamentId, selectedStageId);
+ }
+ }
+
+ await manialinkManager.SendManialinkAsync(player, "ToornamentModule.TournamentSetupView",
+ new
+ {
+ tournaments,
+ stages,
+ matches,
+ groups,
+ rounds
+ });
+
+ logger.LogDebug("End of ShowSetupScreenAsync()");
+ }
+
+ public async Task SetupServerAsync(IPlayer player, string tournamentId, string stageId, string matchId)
+ {
+ logger.LogDebug("Begin of SetupServerAsync()");
+ if (string.IsNullOrEmpty(tournamentId))
+ {
+ logger.LogWarning("TournamentId is missing");
+ throw new ArgumentNullException(nameof(tournamentId), "Toornament Id cannot be empty");
+ }
+
+ if (string.IsNullOrEmpty(stageId))
+ {
+ logger.LogWarning("StageId is missing");
+ throw new ArgumentNullException(nameof(stageId), "Stage Id cannot be empty");
+ }
+
+ if (string.IsNullOrEmpty(matchId))
+ {
+ logger.LogWarning("MatchId is missing");
+ throw new ArgumentNullException(nameof(matchId), "Match Id cannot be empty");
+ }
+
+ logger.LogDebug("Getting ToornamentData, StageData, MatchData, GroupData and RoundData from Toornament");
+ //Get matchinfo from toornament
+ var match = await toornamentService.GetMatchAsync(matchId);
+ var stage = await toornamentService.GetStageAsync(stageId);
+ var tournament = await toornamentService.GetTournamentAsync(tournamentId);
+ var group = await toornamentService.GetGroupAsync(match.GroupId);
+ var round = await toornamentService.GetRoundAsync(match.RoundId);
+
+ settings.AssignedMatchId = matchId;
+
+ //Get maps for this match (if not included in matchinfo response)
+ //Download them from TMX or Nadeo (UID)?
+ logger.LogDebug("Adding maps");
+ var maps = await matchMapService.AddMapsAsync(player);
+
+ //Create matchsettings for server
+ logger.LogDebug("Creating match settings");
+ var matchSettingsName =
+ await matchSettingsCreatorService.CreateMatchSettingsAsync(tournament, match, stage, group, round, maps);
+ logger.LogDebug("Matchsettings created with name: {0}", matchSettingsName);
+
+ //Get participants for match from toornament
+ //Whitelist players + spectators for server
+ if (match.Opponents.First().Participant != null)
+ {
+ logger.LogDebug("Adding Players and spectators to the whitelist");
+ await SetupPlayersAndSpectatorsAsync(match.Opponents);
+ }
+
+ //Apply matchsettings to server (this includes maps)
+ logger.LogDebug("Loading the configured matchsetting {0}_warmup", matchSettingsName);
+ await matchSettings.LoadMatchSettingsAsync(matchSettingsName + "_warmup");
+ await server.Remote.SetServerNameAsync("Match#" + stage.Number + "." + group.Number + "." + round.Number + "." +
+ match.Number);
+
+ //Notify ServerSync module
+ logger.LogDebug("Notify the ServerSync module");
+ var serverName = Encoding.ASCII.GetBytes(await server.Remote.GetServerNameAsync());
+ try
+ {
+ keyValueStoreService.CreateOrUpdateEntry(matchId, serverName);
+ }
+ catch (NATSJetStreamException ex)
+ {
+ logger.LogWarning(ex, "Retrieved exception from NATS");
+ logger.LogWarning("Tried to create entry in KeyValueStore with Key {0} and Value {1}", matchId, serverName);
+ }
+
+ //Show ReadyForMatch widget (?)
+ logger.LogDebug("Show ReadyForMatch widget for the players");
+ await SetupReadyWidgetAsync(match.Opponents);
+
+ logger.LogDebug("Setting Initial State in state service");
+ stateService.SetInitialSetup(matchSettingsName);
+
+ logger.LogDebug("Hiding the Setup window");
+ await manialinkManager.HideManialinkAsync(player, "ToornamentModule.TournamentSetupView");
+
+ logger.LogDebug("End of SetupServerAsync()");
+ }
+
+ public async Task FinishServerSetupAsync()
+ {
+ logger.LogDebug("Begin of FinishServerSetupAsync()");
+ if (!stateService.IsInitialSetup)
+ {
+ return;
+ }
+
+ logger.LogDebug("Setting SetupFinished in state service");
+ stateService.SetSetupFinished();
+
+ logger.LogDebug("Loading the configured matchsetting {0}", stateService.MatchSettingsName);
+ await matchSettings.LoadMatchSettingsAsync(stateService.MatchSettingsName + "_warmup", false);
+
+ await whitelistService.KickNonWhitelistedPlayers();
+
+ await server.Chat.InfoMessageAsync($"Toornament match has been set up.");
+
+ logger.LogInformation("Toornament match has been set up.");
+
+ logger.LogDebug("End of FinishServerSetupAsync()");
+ }
+
+ private async Task SetupPlayersAndSpectatorsAsync(OpponentInfo[] opponents)
+ {
+ await server.Remote.CleanGuestListAsync();
+ await whitelistService.WhitelistPlayers(opponents);
+ await whitelistService.WhitelistSpectators();
+ await server.Remote.SetMaxPlayersAsync(opponents.Length);
+ }
+
+ private async Task SetupReadyWidgetAsync(OpponentInfo[] opponents)
+ {
+ var players = await matchPlayerService.GetPlayersFromOpponents(opponents);
+ await playerReadyService.ResetReadyWidgetAsync(true);
+ await playerReadyTrackerService.AddRequiredPlayersAsync(players);
+ await playerReadyService.SetWidgetEnabled(true);
+
+ var onlinePlayers = await playerManagerService.GetOnlinePlayersAsync();
+
+ var filteredList = onlinePlayers.SelectMany(op => players.Where(p => p.AccountId == op.AccountId));
+
+ foreach (var player in filteredList)
+ {
+ //TODO check if player is playing or spectating
+ await playerReadyService.SendWidgetAsync(player);
+ }
+ }
+
+ public async Task ShowConfirmSetupScreenAsync(IPlayer player, string tournamentId, string stageId, string matchId)
+ {
+ var match = await toornamentService.GetMatchAsync(matchId);
+
+ if (match is not null && match.Status != "pending")
+ {
+ await manialinkManager.SendManialinkAsync(player, "ToornamentModule.Dialogs.MatchInProgressDialog",
+ new
+ {
+ tournamentId,
+ stageId,
+ matchId,
+ });
+ }
+ else
+ {
+ await SetupServerAsync(player, tournamentId, stageId, matchId);
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/MatchSettingsCreatorService.cs b/src/Modules/ToornamentModule/Services/MatchSettingsCreatorService.cs
new file mode 100644
index 000000000..d06e85c09
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/MatchSettingsCreatorService.cs
@@ -0,0 +1,245 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util.MatchSettings;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using Microsoft.Extensions.Logging;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class MatchSettingsCreatorService(
+ ILogger logger,
+ IToornamentSettings toornamentSettings,
+ IToornamentService toornamentService,
+ IMatchSettingsService matchSettingsService,
+ IServerClient serverClient,
+ IDiscordNotifyService notifyService) : IMatchSettingsCreatorService
+{
+ private readonly Random _rng = new();
+
+ public async Task CreateMatchSettingsAsync(TournamentBasicData tournament, MatchInfo matchInfo,
+ StageInfo stageInfo, GroupInfo groupInfo, RoundInfo roundInfo, IEnumerable maps)
+ {
+ logger.LogDebug("Creating matchsettings");
+ var settingsData = new TrackmaniaIntegrationSettingsData();
+ if (toornamentSettings.UseToornamentDiscipline)
+ {
+ //DisciplineInfo contains the trackmania matchsettings (nrOfRounds, point distribution etc)
+ if (tournament != null)
+ {
+ logger.LogDebug("Fetching matchsettings defined in Toornament Discipline {Discipline}.",
+ tournament.Discipline);
+ DisciplineInfo? discipline;
+ discipline = await toornamentService.GetDisciplineAsync(tournament.Discipline);
+ var tmIntegration = discipline?.Features.FirstOrDefault(f => f.Name == "trackmania_integration");
+
+ if (tmIntegration == null)
+ {
+ throw new InvalidOperationException("No match discipline configuration found");
+ }
+
+ var settingsDefault = tmIntegration.Options.FirstOrDefault(o => o.Key == "settings_default").Value;
+ settingsData = TrackmaniaIntegrationSettingsData.CreateFromObject(settingsDefault);
+ }
+ }
+ else
+ {
+ logger.LogDebug("Using matchsettings defined in environment variable");
+ if (string.IsNullOrEmpty(toornamentSettings.Disciplines))
+ {
+ throw new ArgumentNullException(nameof(toornamentSettings.Disciplines),
+ "No disciplines defined in Environment settings");
+ }
+
+ var jsonSerializerOptions = new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
+ };
+ var allDisciplines =
+ JsonSerializer.Deserialize>(toornamentSettings.Disciplines,
+ jsonSerializerOptions);
+ settingsData = allDisciplines.FirstOrDefault(x =>
+ x.StageNumber == stageInfo.Number && x.GroupNumber == groupInfo.Number &&
+ x.RoundNumber == roundInfo.Number);
+ }
+
+ if (settingsData == null)
+ {
+ throw new ArgumentNullException("Settings are missing");
+ }
+
+ if (settingsData.Scripts.S_PointsRepartition.Equals("n-1"))
+ {
+ logger.LogDebug(
+ "Generating Points Repartition based on the number of Opponents in this match, which is {0} opponents",
+ matchInfo.Opponents.Count());
+ settingsData.Scripts.S_PointsRepartition = GeneratePointRepartition(matchInfo.Opponents.Count());
+ }
+
+ var name = $"tournament_{settingsData.StageNumber}_{settingsData.GroupNumber}_{settingsData.RoundNumber}";
+
+ var mapsToAdd = new List();
+
+ foreach (var map in maps)
+ {
+ try
+ {
+ logger.LogDebug("Checking if all the maps are available on the server");
+ var serverMap = await serverClient.Remote.GetMapInfoAsync(map.FilePath);
+
+ if (serverMap != null)
+ {
+ mapsToAdd.Add(map);
+ }
+ }
+ catch (Exception)
+ {
+ // do nothing
+ }
+ }
+
+ if (settingsData.TracksShuffle)
+ {
+ logger.LogDebug("Shuffling the map order");
+ mapsToAdd = Shuffle(mapsToAdd);
+ }
+
+ await CreateMatchSettings(name, settingsData, mapsToAdd);
+
+ var warmupSettingsData = settingsData;
+ warmupSettingsData.Scripts.S_WarmUpDuration = 3600;
+ warmupSettingsData.Scripts.S_WarmUpNb = 10;
+ await CreateMatchSettings(name + "_warmup", settingsData, mapsToAdd);
+
+ var matchName = "Match#" + stageInfo.Number + "." + groupInfo.Number + "." + roundInfo.Number + "." + matchInfo.Number;
+
+ await notifyService.NotifyMatchInfoAsync(matchName, mapsToAdd);
+
+ logger.LogDebug("End of CreateMatchSettingsAsync()");
+ return name;
+ }
+
+ public async Task CreateMatchSettings(string name, TrackmaniaIntegrationSettingsData settingsData,
+ List mapsToAdd)
+ {
+ try
+ {
+ await matchSettingsService.CreateMatchSettingsAsync(name, builder =>
+ {
+ if (toornamentSettings.UseDefaultGameMode)
+ {
+ var mode = settingsData.GameMode.ToLowerInvariant() switch
+ {
+ "rounds" => DefaultModeScriptName.Rounds,
+ "cup" => DefaultModeScriptName.Cup,
+ "time_attack" => DefaultModeScriptName.TimeAttack,
+ "knockout" => DefaultModeScriptName.Knockout,
+ "laps" => DefaultModeScriptName.Laps,
+ "team" => DefaultModeScriptName.Teams,
+ _ => DefaultModeScriptName.TimeAttack
+ };
+ logger.LogDebug("Creating match settings with gamemode {0}", mode);
+
+ builder.WithMode(mode);
+ }
+ else
+ {
+ if (string.IsNullOrEmpty(toornamentSettings.GameModes))
+ {
+ logger.LogWarning("Custom gamemode not defined in environment!!");
+ throw new ArgumentNullException(nameof(toornamentSettings.GameModes));
+ }
+
+ var availableCustomModes = toornamentSettings.GameModes.Trim().Split(',');
+
+ var selectedGameMode = availableCustomModes.FirstOrDefault(gm => gm.ToLowerInvariant().Contains(settingsData.GameMode));
+
+ if (string.IsNullOrEmpty(selectedGameMode))
+ {
+ logger.LogWarning("GameMode could not be determined from custom gamemodes. Falling back to Default scriptmodes");
+ var mode = settingsData.GameMode.ToLowerInvariant() switch
+ {
+ "rounds" => DefaultModeScriptName.Rounds,
+ "cup" => DefaultModeScriptName.Cup,
+ "time_attack" => DefaultModeScriptName.TimeAttack,
+ "knockout" => DefaultModeScriptName.Knockout,
+ "laps" => DefaultModeScriptName.Laps,
+ "team" => DefaultModeScriptName.Teams,
+ _ => DefaultModeScriptName.TimeAttack
+ };
+ logger.LogDebug("Creating match settings with gamemode {0}", mode);
+
+ builder.WithMode(mode);
+ }
+ else
+ {
+ var prefixedGameMode = "Trackmania/" + selectedGameMode;
+ logger.LogDebug("Creating match settings with custom gamemode {0}", prefixedGameMode);
+
+ builder.WithMode(prefixedGameMode);
+ }
+ }
+
+ builder.WithModeSettings(s =>
+ {
+ var type = settingsData.Scripts.GetType();
+ var properties = settingsData.Scripts.GetType().GetProperties();
+
+ logger.LogDebug("Creating match settings with scriptsettings: ");
+ foreach (var item in properties)
+ {
+ var value = item.GetValue(settingsData.Scripts);
+ logger.LogDebug("[{0}]: {1}", item.Name, value);
+ s[item.Name] = value;
+ }
+ });
+
+ builder.WithMaps(mapsToAdd);
+ });
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ }
+
+ private string GeneratePointRepartition(int number)
+ {
+ if (number < 1)
+ {
+ return string.Empty; // Return an empty string if the number is less than 1
+ }
+
+ var countdownArray = new string[number];
+ for (int i = 0; i < number; i++)
+ {
+ countdownArray[i] = (number - i).ToString();
+ }
+
+ return string.Join(",", countdownArray);
+ }
+
+ private List Shuffle(List list)
+ {
+ int n = list.Count;
+ while (n > 1)
+ {
+ n--;
+ int k = _rng.Next(n + 1);
+ IMap value = list[k];
+ list[k] = list[n];
+ list[n] = value;
+ }
+
+ return list;
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/NadeoMapService.cs b/src/Modules/ToornamentModule/Services/NadeoMapService.cs
new file mode 100644
index 000000000..bfccfb6d6
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/NadeoMapService.cs
@@ -0,0 +1,68 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Models.Maps;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.Official.MapsModule.Services;
+using GBX.NET;
+using GBX.NET.Engines.Game;
+using GBX.NET.LZO;
+using Hawf.Attributes;
+using Hawf.Client;
+using Microsoft.Extensions.Logging;
+using IMapService = EvoSC.Common.Interfaces.Services.IMapService;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+//TODO Abstract this away in separate module
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class NadeoMapService(ILogger logger, IMapService mapService) : INadeoMapService
+{
+ public async Task FindAndDownloadMapAsync(string mapId)
+ {
+ var nadeoApi = new MapsResource();
+ var mapFile = await nadeoApi.DownloadMapAsync(mapId);
+
+ if (mapFile == null)
+ {
+ logger.LogDebug("Could not find any map stream for ID {MapId} on Nadeo servers", mapId);
+ return null;
+ }
+
+ var mapMetadata = GetMapInfo(mapFile, mapId);
+ if (mapFile.CanSeek)
+ {
+ mapFile.Seek(0, SeekOrigin.Begin);
+ }
+ var map = new MapStream(mapMetadata, mapFile);
+
+ return await mapService.AddMapAsync(map);
+ }
+
+ private MapMetadata GetMapInfo(Stream mapFile, string mapId)
+ {
+ Gbx.LZO = new Lzo();
+ var map = Gbx.ParseNode(mapFile);
+
+ return new MapMetadata
+ {
+ MapUid = map.MapUid,
+ MapName = map.MapName,
+ AuthorId = map.AuthorLogin,
+ AuthorName = map.AuthorNickname ?? map.AuthorLogin,
+ ExternalId = mapId,
+ ExternalVersion = DateTime.UtcNow,
+ ExternalMapProvider = MapProviders.TrackmaniaIo
+ };
+ }
+}
+
+[ApiClient("https://core.trackmania.nadeo.live/maps", userAgent: "EvoSC#-Toornament-Module")]
+public class MapsResource() : ApiBase
+{
+ public Task DownloadMapAsync(string id, CancellationToken cancelToken = default)
+ {
+ return WithCancelToken(cancelToken).CacheResponseFor(TimeSpan.Zero).GetStreamAsync("/{id}/file", id);
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/StateService.cs b/src/Modules/ToornamentModule/Services/StateService.cs
new file mode 100644
index 000000000..f150b0e8b
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/StateService.cs
@@ -0,0 +1,201 @@
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class StateService : IStateService
+{
+ private bool _initialSetup;
+ private string _matchSettingsName = "";
+ private readonly object _initialSetupLock = new();
+ private bool _setupFinished;
+ private readonly object _setupFinishedLock = new();
+ private bool _waitingForMatchStart;
+ private readonly object _waitingForMatchStartLock = new();
+ private bool _matchInProgress;
+ private readonly object _matchInProgressLock = new();
+ private bool _matchEnded;
+ private readonly object _matchEndedLock = new();
+
+ public bool IsInitialSetup
+ {
+ get
+ {
+ lock (_initialSetupLock)
+ {
+ return _initialSetup;
+ }
+ }
+ }
+
+ public string MatchSettingsName
+ {
+ get
+ {
+ lock (_initialSetupLock)
+ {
+ return _matchSettingsName;
+ }
+ }
+ }
+
+ public bool SetupFinished
+ {
+ get
+ {
+ lock (_setupFinishedLock)
+ {
+ return _setupFinished;
+ }
+ }
+ }
+
+ public bool MatchInProgress
+ {
+ get
+ {
+ lock (_matchInProgressLock)
+ {
+ return _matchInProgress;
+ }
+ }
+ }
+
+ public bool MatchEnded
+ {
+ get
+ {
+ lock (_matchEndedLock)
+ {
+ return _matchEnded;
+ }
+ }
+ }
+
+ public bool WaitingForMatchStart
+ {
+ get
+ {
+ lock (_waitingForMatchStartLock)
+ {
+ return _waitingForMatchStart;
+ }
+ }
+ }
+
+ public void SetInitialSetup(string matchSettingsName)
+ {
+ lock (_initialSetupLock)
+ {
+ _initialSetup = true;
+ _matchSettingsName = matchSettingsName;
+ }
+
+ lock (_setupFinishedLock)
+ {
+ _setupFinished = false;
+ }
+
+ lock (_waitingForMatchStartLock)
+ {
+ _waitingForMatchStart = false;
+ }
+
+ lock (_matchInProgressLock)
+ {
+ _matchInProgress = false;
+ }
+
+ lock (_matchEndedLock)
+ {
+ _matchEnded = false;
+ }
+ }
+
+ public void SetSetupFinished()
+ {
+ lock (_initialSetupLock)
+ {
+ _initialSetup = false;
+ }
+
+ lock (_setupFinishedLock)
+ {
+ _setupFinished = true;
+ }
+
+ lock (_waitingForMatchStartLock)
+ {
+ _waitingForMatchStart = true;
+ }
+
+ lock (_matchInProgressLock)
+ {
+ _matchInProgress = false;
+ }
+
+ lock (_matchEndedLock)
+ {
+ _matchEnded = false;
+ }
+ }
+
+ public void SetMatchStarted()
+ {
+ lock (_initialSetupLock)
+ {
+ _initialSetup = false;
+ }
+
+ lock (_setupFinishedLock)
+ {
+ _setupFinished = false;
+ }
+
+ lock (_waitingForMatchStartLock)
+ {
+ _waitingForMatchStart = false;
+ }
+
+ lock (_matchInProgressLock)
+ {
+ _matchInProgress = true;
+ }
+
+ lock (_matchEndedLock)
+ {
+ _matchEnded = false;
+ }
+ }
+
+ public void SetMatchEnded()
+ {
+ lock (_initialSetupLock)
+ {
+ _initialSetup = false;
+ }
+
+ lock (_setupFinishedLock)
+ {
+ _setupFinished = false;
+ }
+
+ lock (_waitingForMatchStartLock)
+ {
+ _waitingForMatchStart = false;
+ }
+
+ lock (_matchInProgressLock)
+ {
+ _matchInProgress = false;
+ }
+
+ lock (_matchEndedLock)
+ {
+ _matchEnded = true;
+ _matchSettingsName = "";
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/ToornamentService.cs b/src/Modules/ToornamentModule/Services/ToornamentService.cs
new file mode 100644
index 000000000..bf03e5a18
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/ToornamentService.cs
@@ -0,0 +1,573 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using Microsoft.Extensions.Logging;
+using ToornamentApi;
+using ToornamentApi.Auth;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class ToornamentService : IToornamentService
+{
+ private readonly IToornamentSettings _settings;
+ private readonly IServerClient _serverClient;
+ private readonly ILogger _logger;
+ public ToornamentService(IServerClient serverClient, IToornamentSettings settings, ILogger logger)
+ {
+ _settings = settings;
+ _serverClient = serverClient;
+ _logger = logger;
+
+ if (string.IsNullOrEmpty(_settings.ApiKey) || string.IsNullOrEmpty(_settings.ClientId) ||
+ string.IsNullOrEmpty(_settings.ClientSecret))
+ throw new ArgumentNullException("Missing apikey, clientid or clientsecret to connect to Toornament");
+ }
+
+ public async Task?> GetTournamentsAsync()
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetTournamentsAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+
+ var tournaments = new List();
+
+ //Get First Page
+ var pagedTournaments = await toornamentClient.Tournament.Tournaments.GetAsync(sort: "scheduled_desc");
+
+ if (pagedTournaments.Tournaments is not null)
+ {
+ tournaments.AddRange(pagedTournaments.Tournaments);
+
+ int secondPageStart = 50;
+ int secondPageEnd = 99;
+ int pageSize = 50;
+ //Get Next Pages
+ for (; secondPageStart < pagedTournaments.Total; secondPageStart += pageSize, secondPageEnd += pageSize)
+ {
+ pagedTournaments = await toornamentClient.Tournament.Tournaments.GetAsync(pageStart: secondPageStart, pageEnd: secondPageEnd, sort: "scheduled_desc");
+
+ if (pagedTournaments.Tournaments is not null)
+ {
+ tournaments.AddRange(pagedTournaments.Tournaments);
+ }
+ }
+ }
+ _logger.LogInformation("End of GetTournamentsAsync()");
+ return tournaments;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetTournaments", "");
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetTournamentAsync(string tournamentId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetTournamentAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+
+ _logger.LogInformation("End of GetTournamentsAsync()");
+ return await toornamentClient.Tournament.Tournaments.GetTournamentAsync(tournamentId);
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetTournament", tournamentId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetDisciplineAsync(string disciplineId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetDisciplineAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+
+ _logger.LogInformation("End of GetDisciplineAsync()");
+ return await toornamentClient.Tournament.Disciplines.GetDisciplineAsync(disciplineId);
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetDiscipline", disciplineId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task?> GetStagesAsync(string tournamentId)
+ {
+ try
+ {
+ _logger.LogInformation("End of GetStagesAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+
+ var stages = new List();
+
+ //Get First Page
+ var pagedStages = await toornamentClient.Structure.Stages.GetAsync(tournamentIds: [tournamentId]);
+
+ if (pagedStages.Stages is not null)
+ {
+ stages.AddRange(pagedStages.Stages);
+
+ int secondPageStart = 50;
+ int secondPageEnd = 99;
+ int pageSize = 50;
+ //Get Next Pages
+ for (; secondPageStart < pagedStages.Total; secondPageStart += pageSize, secondPageEnd += pageSize)
+ {
+ pagedStages = await toornamentClient.Structure.Stages.GetAsync(pageStart: secondPageStart, pageEnd: secondPageEnd, tournamentIds: [tournamentId]);
+
+ if (pagedStages.Stages is not null)
+ {
+ stages.AddRange(pagedStages.Stages);
+ }
+ }
+ }
+ _logger.LogInformation("End of GetStagesAsync()");
+ return stages;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetStages", tournamentId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetStageAsync(string stageId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetStageAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ var stage = await toornamentClient.Structure.Stages.GetStageAsync(stageId);
+
+ _logger.LogInformation("End of GetStageAsync()");
+ return stage;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetStage", stageId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task?> GetMatchesAsync(string tournamentId, string stageId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetMatchesAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+
+ var matches = new List();
+
+ //Get First Page
+ var pagedMatches = await toornamentClient.Match.Matches.GetAsync(stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedMatches.Matches is not null)
+ {
+ matches.AddRange(pagedMatches.Matches);
+
+ int secondPageStart = 50;
+ int secondPageEnd = 99;
+ int pageSize = 50;
+ //Get Next Pages
+ for (; secondPageStart < pagedMatches.Total; secondPageStart += pageSize, secondPageEnd += pageSize)
+ {
+ pagedMatches = await toornamentClient.Match.Matches.GetAsync(pageStart: secondPageStart, pageEnd: secondPageEnd, stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedMatches.Matches is not null)
+ {
+ matches.AddRange(pagedMatches.Matches);
+ }
+ }
+ }
+
+ _logger.LogInformation("End of GetMatchesAsync()");
+ return matches;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetMatches", tournamentId, stageId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetMatchAsync(string matchId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetMatchAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+ var match = await toornamentClient.Match.Matches.GetMatchAsync(matchId);
+
+ _logger.LogInformation("End of GetMatchAsync()");
+ return match;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetMatch", matchId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task?> GetMatchGamesAsync(string matchId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetMatchGamesAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+ var matchgames = await toornamentClient.Match.MatchGames.GetAsync(matchId);
+
+ _logger.LogInformation("End of GetMatchGamesAsync()");
+ return matchgames.ToList();
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetMatchGames", matchId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetMatchGameAsync(string matchId, int gameNumber)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetMatchGameAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+
+ _logger.LogInformation("End of GetMatchGameAsync()");
+ return await toornamentClient.Match.MatchGames.GetMatchGameAsync(matchId, gameNumber);
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetMatchGame", matchId, gameNumber.ToString());
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task SetMatchGameStatusAsync(string matchId, int gameNumber, MatchGameStatus status)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of SetMatchGameStatusAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+ var currentGame = await toornamentClient.Match.MatchGames.GetMatchGameAsync(matchId, gameNumber);
+
+ if (currentGame != null)
+ {
+ currentGame.Status = status.ToString().ToLower();
+ //Give a temp score to 1 player to apply the match status
+ if (currentGame.Opponents is not null)
+ {
+ currentGame.Opponents.First().Score = 0;
+ _logger.LogInformation("End of SetMatchGameStatusAsync()");
+ return await toornamentClient.Match.MatchGames.UpdateMatchGameAsync(matchId, gameNumber, currentGame);
+ }
+ }
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "SetMatchGameStatus", matchId, gameNumber.ToString());
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task SetMatchGameResultAsync(string matchId, int gameNumber, MatchGameInfo gameInfo)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of SetMatchGameResultAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+
+ _logger.LogInformation("End of SetMatchGameResultAsync()");
+ return await toornamentClient.Match.MatchGames.UpdateMatchGameAsync(matchId, gameInfo.Number, gameInfo);
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "SetMatchGameResult", matchId, gameNumber.ToString());
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task?> GetGroupsAsync(string tournamentId, string stageId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetGroupsAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ var groups = new List();
+
+ //Get First Page
+ var pagedGroups = await toornamentClient.Structure.Groups.GetAsync(stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedGroups.Groups is not null)
+ {
+ groups.AddRange(pagedGroups.Groups);
+
+ int secondPageStart = 50;
+ int secondPageEnd = 99;
+ int pageSize = 50;
+ //Get Next Pages
+ for (; secondPageStart < pagedGroups.Total; secondPageStart += pageSize, secondPageEnd += pageSize)
+ {
+ pagedGroups = await toornamentClient.Structure.Groups.GetAsync(pageStart: secondPageStart, pageEnd: secondPageEnd, stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedGroups.Groups is not null)
+ {
+ groups.AddRange(pagedGroups.Groups);
+ }
+ }
+ }
+
+ _logger.LogInformation("End of GetGroupsAsync()");
+ return groups;
+
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetGroups", tournamentId, stageId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetGroupAsync(string groupId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetGroupAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ var group = await toornamentClient.Structure.Groups.GetGroupAsync(groupId);
+
+ _logger.LogInformation("End of GetGroupAsync()");
+ return group;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetGroup", groupId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task?> GetRoundsAsync(string tournamentId, string stageId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetRoundsAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ var rounds = new List();
+
+ //Get First Page
+ var pagedRounds = await toornamentClient.Structure.Rounds.GetAsync(stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedRounds.Rounds is not null)
+ {
+ rounds.AddRange(pagedRounds.Rounds);
+
+ int secondPageStart = 50;
+ int secondPageEnd = 99;
+ int pageSize = 50;
+ //Get Next Pages
+ for (; secondPageStart < pagedRounds.Total; secondPageStart += pageSize, secondPageEnd += pageSize)
+ {
+ pagedRounds = await toornamentClient.Structure.Rounds.GetAsync(pageStart: secondPageStart, pageEnd: secondPageEnd, stageIds: [stageId], tournamentIds: [tournamentId]);
+
+ if (pagedRounds.Rounds is not null)
+ {
+ rounds.AddRange(pagedRounds.Rounds);
+ }
+ }
+ }
+
+ _logger.LogInformation("End of GetRoundsAsync()");
+ return rounds;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetRounds", tournamentId, stageId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task GetRoundAsync(string roundId)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of GetRoundAsync()");
+ var toornamentClient = ToornamentApiClient.Create(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ var round = await toornamentClient.Structure.Rounds.GetRoundAsync(roundId);
+ _logger.LogInformation("End of GetRoundAsync()");
+ return round;
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "GetRound", roundId);
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ public async Task SetMatchGameMapAsync(string matchId, int gameNumber, string mapName)
+ {
+ try
+ {
+ _logger.LogInformation("Begin of SetMatchGameMapAsync()");
+ //We need the OrganizerResult scope to access the Matches api.
+ var matchScope = Scope.OrganizerView | Scope.OrganizerAdmin | Scope.OrganizerResult | Scope.OrganizerParticipant | Scope.OrganizerRegistration | Scope.OrganizerPermission | Scope.OrganizerFile;
+ var toornamentAuth = new ToornamentAuth(_settings.ApiKey, _settings.ClientId, _settings.ClientSecret);
+ await toornamentAuth.RequestAccessAsync(matchScope);
+
+ var toornamentClient = new ToornamentApiClient(toornamentAuth);
+ var currentGame = await toornamentClient.Match.MatchGames.GetMatchGameAsync(matchId, gameNumber);
+
+ if (currentGame != null)
+ {
+ if (currentGame.Properties.ContainsKey("track"))
+ {
+ currentGame.Properties.Remove("track");
+ }
+ currentGame.Properties.Add("track", mapName);
+ return await toornamentClient.Match.MatchGames.UpdateMatchGameAsync(matchId, gameNumber, currentGame);
+ }
+ }
+ catch (Hawf.Client.Exceptions.HawfResponseException ex)
+ {
+ await HandleHawfException(ex, "MatchGameMap", matchId, gameNumber.ToString());
+ }
+ catch (Exception)
+ {
+ throw;
+ }
+ return null;
+ }
+
+ private async Task HandleHawfException(Hawf.Client.Exceptions.HawfResponseException ex, string functionName, string matchId, string? gameNumber = null)
+ {
+ await _serverClient.Chat.ErrorMessageAsync($"Failed to set {functionName} from Toornament. Error: {ex.Message}");
+ _logger.LogWarning("Failed to call the {0} endpoint in Toornament. Retrieved statuscode: {1}", functionName, ex.StatusCode);
+ if (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ if (gameNumber is not null)
+ {
+ _logger.LogWarning("The {0} with Id {1} and number {2} was not found.", functionName, matchId, gameNumber);
+ }
+ else
+ {
+ _logger.LogWarning("The {0} with Id {1} was not found.", functionName, matchId);
+ }
+ }
+
+ if (ex.Response.RequestMessage is not null)
+ {
+ _logger.LogWarning("Requested url: {0}", ex.Response.RequestMessage.RequestUri);
+ _logger.LogWarning("With header: {0}", ex.Response.RequestMessage.Headers.Range);
+
+ if (_settings.SensitiveLogging)
+ {
+ _logger.LogWarning("With Bearer token: {0}", ex.Response.RequestMessage.Headers.Authorization);
+ }
+
+ if (ex.Response.RequestMessage.Content is not null && ex.Response.RequestMessage.Method != HttpMethod.Get)
+ {
+ _logger.LogWarning("With Body: {0}", await ex.Response.RequestMessage.Content.ReadAsStringAsync());
+ }
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Services/WhitelistService.cs b/src/Modules/ToornamentModule/Services/WhitelistService.cs
new file mode 100644
index 000000000..49eefbf4d
--- /dev/null
+++ b/src/Modules/ToornamentModule/Services/WhitelistService.cs
@@ -0,0 +1,180 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using GbxRemoteNet;
+using GbxRemoteNet.Structs;
+using Microsoft.Extensions.Logging;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class WhitelistService(
+ ILogger logger,
+ IServerClient server,
+ IToornamentSettings settings,
+ IStateService stateService,
+ IMatchPlayerService matchPlayerService,
+ IPlayerManagerService playerManagerService,
+ IPermissionManager permissionManager
+ ) : IWhitelistService
+{
+ public async Task WhitelistPlayers(OpponentInfo[] opponents)
+ {
+ logger.LogDebug("Begin of WhitelistPlayers()");
+ var multiCall = new MultiCall();
+
+ var players = await matchPlayerService.GetPlayersFromOpponents(opponents);
+
+ foreach (var player in players)
+ {
+ var login = PlayerUtils.ConvertAccountIdToLogin(player.AccountId);
+ multiCall.Add("AddGuest", login);
+ }
+
+ await server.Remote.MultiCallAsync(multiCall);
+ logger.LogDebug("End of WhitelistPlayers()");
+ }
+
+ public async Task WhitelistSpectators()
+ {
+ logger.LogDebug("Begin of WhitelistSpectators()");
+ if (!string.IsNullOrEmpty(settings.Whitelist))
+ {
+ var multiCall = new MultiCall();
+ foreach (var accountId in settings.Whitelist.Split(','))
+ {
+ var login = PlayerUtils.ConvertAccountIdToLogin(accountId);
+ multiCall.Add("AddGuest", login);
+ }
+
+ await server.Remote.MultiCallAsync(multiCall);
+ }
+
+ logger.LogDebug("End of WhitelistSpectators()");
+ }
+
+ public async Task ForcePlayerIntoSpectate(string login)
+ {
+ if (!settings.UseExperimentalFeatures)
+ {
+ return;
+ }
+
+ if (!stateService.WaitingForMatchStart && !stateService.MatchInProgress)
+ {
+ // No match in progress, so players won't get put into Spectate
+ return;
+ }
+
+ var accountId = PlayerUtils.ConvertLoginToAccountId(login);
+ var player = await playerManagerService.GetOrCreatePlayerAsync(accountId);
+
+ var guestList = await GetGuestListAsync();
+ var whitelistedSpectates = settings.Whitelist.Split(',');
+
+ //Player is not in the guestlist -> will get kicked
+ if (guestList.FirstOrDefault(g => g.Login == login) is null)
+ {
+ await KickAsync(player);
+ }
+
+ //Player is in the configured whitelist -> put into spectate mode
+ if (player is not null && whitelistedSpectates.Contains(accountId))
+ {
+ if (player.Groups.Count() == 0)
+ {
+ await permissionManager.AddPlayerToGroupAsync(player, settings.DefaultGroupId);
+ }
+
+ await ForceSpectatorAsync(player);
+ }
+ }
+
+ public async Task KickNonWhitelistedPlayers()
+ {
+ if (!settings.UseExperimentalFeatures)
+ {
+ return;
+ }
+
+ var connectedPlayers = await server.Remote.GetPlayerListAsync();
+
+ var guestList = await GetGuestListAsync();
+ var whitelistedSpectates = settings.Whitelist.Split(',');
+
+ foreach (var connectedPlayer in connectedPlayers)
+ {
+ if (connectedPlayer.IsServer())
+ {
+ continue;
+ }
+
+ var accountId = PlayerUtils.ConvertLoginToAccountId(connectedPlayer.Login);
+ var player = await playerManagerService.GetOrCreatePlayerAsync(accountId);
+
+ //Skip player if the player is Admin
+ if (player.Groups.Any(x => x.Id == 1))
+ {
+ continue;
+ }
+
+ //Player is not in the guestlist -> will get kicked
+ if (guestList.FirstOrDefault(g => g.Login == connectedPlayer.Login) is null)
+ {
+ await KickAsync(player);
+ }
+
+ //Player is in the configured whitelist -> put into spectate mode
+ if (player is not null && whitelistedSpectates.Contains(accountId))
+ {
+ if (player.Groups.Count() == 0)
+ {
+ await permissionManager.AddPlayerToGroupAsync(player, settings.DefaultGroupId);
+ }
+
+ await ForceSpectatorAsync(player);
+ }
+ }
+ }
+
+ private async Task KickAsync(IPlayer player)
+ {
+ if (await server.Remote.KickAsync(player.GetLogin(), ""))
+ {
+ logger.LogDebug(
+ "Kicked player {0} from server, because player was not whitelisted or expected as a participant of this match",
+ player.UbisoftName);
+ }
+ else
+ {
+ logger.LogWarning("Failed to kick player {0} from server", player.UbisoftName);
+ }
+ }
+
+ private Task ForceSpectatorAsync(IPlayer player) => server.Remote.ForceSpectatorAsync(player.GetLogin(), 1);
+
+ private async Task GetGuestListAsync()
+ {
+ var maxPlayers = await server.Remote.GetMaxPlayersAsync();
+ var nrOfPlayers = 0;
+ if (maxPlayers is not null)
+ {
+ nrOfPlayers += maxPlayers.CurrentValue;
+ }
+
+ var whitelistedSpectates = settings.Whitelist.Split(',');
+
+ if (!string.IsNullOrEmpty(settings.Whitelist))
+ {
+ nrOfPlayers += whitelistedSpectates.Count();
+ }
+
+ return await server.Remote.GetGuestListAsync(nrOfPlayers, 0);
+ }
+}
diff --git a/src/Modules/ToornamentModule/Settings/IToornamentSettings.cs b/src/Modules/ToornamentModule/Settings/IToornamentSettings.cs
new file mode 100644
index 000000000..7f5b52fac
--- /dev/null
+++ b/src/Modules/ToornamentModule/Settings/IToornamentSettings.cs
@@ -0,0 +1,70 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.EvoEsports.ToornamentModule.Settings
+{
+ [Settings]
+ public interface IToornamentSettings
+ {
+ [Option, Description("The API key for the Toornament API.")]
+ public string ApiKey { get; set; }
+
+ [Option, Description("The client id for the Toornament API.")]
+ public string ClientId { get; set; }
+
+ [Option, Description("The client secret for the Toornament API.")]
+ public string ClientSecret { get; set; }
+
+ [Option, Description("The Toornament ID for the Toornament API.")]
+ public string ToornamentId { get; set; }
+
+ [Option, Description("List of player's account IDs which are always whitelisted for matches.")]
+ public string Whitelist { get; }
+
+ [Option(DefaultValue = false), Description("Whether to start the match automatically when all players are ready.")]
+ public bool AutomaticMatchStart { get; set; }
+
+ [Option, Description("The MatchId assigned to this server.")]
+ public string AssignedMatchId { get; set; }
+
+ [Option, Description("Use the Discipline defined in Toornament. If false, give a json string in Discipline setting.")]
+ public bool UseToornamentDiscipline { get; set; }
+
+ [Option, Description("A json string containing the Discipline settings (match settings).")]
+ public string Disciplines { get; set; }
+
+ [Option, Description("A comma separated string containing the Map Ids used on TMX, example: 186972,186973,186974,186870,186980")]
+ public string MapTmxIds { get; set; }
+
+ [Option, Description("A comma separated string containing the Map Ids used on Nadeo servers, example: zS5d30EU7x6meq2eIM5Uhu4UbTg,VbW1c9rTPSwtZNHrlDE8FVPqIca,lKJQ8YrXza3XiEN1a1fPK3fl4wf,W6GjI5Nsr9MYdBBOBXIOd8JhZwj,YUTy7o9O0hDmWFNVQ4QuxaXzXD4")]
+ public string MapUids { get; set; }
+
+ [Option, Description("A comma separated string containing the Map Ids used on Nadeo servers, example: b0c2e735-15a9-43df-a772-f5f1f692fa29,e8233617-a929-4891-924d-6ba72fd546c7,c7ffcdfe-fd98-417b-80d9-b464927f76a1,ae1eb6cb-76ef-45ab-8149-07377e3e45bd,b653936e-1316-461a-b4cf-befa6630e839")]
+ public string MapIds { get; set; }
+
+ [Option, Description("A comma separated string containing the Map Machine names as defined on Toornament, example: blueprint,domino,fe4turing,karotte,schwaadlappe")]
+ public string MapMachineNames { get; set; }
+
+ [Option(DefaultValue = 2), Description("The Group Id where players will get assigned to. This should be the Default group.")]
+ public int DefaultGroupId { get; set; }
+
+ [Option(DefaultValue = false), Description("Enable very sensitive logging, like logging Authorization tokens")]
+ public bool SensitiveLogging { get; set; }
+
+ [Option(DefaultValue = false), Description("Enable experimental features")]
+ public bool UseExperimentalFeatures { get; set; }
+
+ [Option(DefaultValue = true), Description("Use the default gamemode as defined in Discipline. Default value is true")]
+ public bool UseDefaultGameMode { get; set; }
+
+ [Option, Description("The Gamemode to be used when UseDefaultGameMode setting is false.")]
+ public string GameModes { get; set; }
+
+ [Option, Description("Specifies the Discord Webhook endpoint to send matchinformation to")]
+ public string WebhookUrl { get; set; }
+
+ [Option, Description("A suffix that will be added to each message. E.g. can be used for pinging certain @Roles or @Persons")]
+ public string MessageSuffix { get; set; }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Templates/Dialogs/MatchInProgressDialog.mt b/src/Modules/ToornamentModule/Templates/Dialogs/MatchInProgressDialog.mt
new file mode 100644
index 000000000..cc433afb1
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/Dialogs/MatchInProgressDialog.mt
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ToornamentModule/Templates/Parts/MatchRow.mt b/src/Modules/ToornamentModule/Templates/Parts/MatchRow.mt
new file mode 100644
index 000000000..eb044fce8
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/Parts/MatchRow.mt
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Templates/Parts/StageRow.mt b/src/Modules/ToornamentModule/Templates/Parts/StageRow.mt
new file mode 100644
index 000000000..19157aec1
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/Parts/StageRow.mt
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Templates/Parts/TournamentRow.mt b/src/Modules/ToornamentModule/Templates/Parts/TournamentRow.mt
new file mode 100644
index 000000000..fb6fefa6d
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/Parts/TournamentRow.mt
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Templates/Styles.mt b/src/Modules/ToornamentModule/Templates/Styles.mt
new file mode 100644
index 000000000..c3f262c1d
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/Styles.mt
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Modules/ToornamentModule/Templates/TournamentSetupView.mt b/src/Modules/ToornamentModule/Templates/TournamentSetupView.mt
new file mode 100644
index 000000000..a55a15f59
--- /dev/null
+++ b/src/Modules/ToornamentModule/Templates/TournamentSetupView.mt
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Tests/Controllers/MatchConfigurationControllerTest.cs b/src/Modules/ToornamentModule/Tests/Controllers/MatchConfigurationControllerTest.cs
new file mode 100644
index 000000000..0dd25d9f7
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/Controllers/MatchConfigurationControllerTest.cs
@@ -0,0 +1,9 @@
+namespace Toornament.Controllers;
+
+public class MatchConfigurationControllerTest
+{
+ public MatchConfigurationControllerTest()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Tests/Controllers/MatchControllerTest.cs b/src/Modules/ToornamentModule/Tests/Controllers/MatchControllerTest.cs
new file mode 100644
index 000000000..34a4020f8
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/Controllers/MatchControllerTest.cs
@@ -0,0 +1,9 @@
+namespace Toornament.Controllers;
+
+public class MatchControllerTest
+{
+ public MatchControllerTest()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Tests/Controllers/MatchManialinkControllerTest.cs b/src/Modules/ToornamentModule/Tests/Controllers/MatchManialinkControllerTest.cs
new file mode 100644
index 000000000..fa88bb36c
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/Controllers/MatchManialinkControllerTest.cs
@@ -0,0 +1,9 @@
+namespace Toornament.Controllers;
+
+public class MatchManialinkControllerTest
+{
+ public MatchManialinkControllerTest()
+ {
+
+ }
+}
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Tests/GlobalUsings.cs b/src/Modules/ToornamentModule/Tests/GlobalUsings.cs
new file mode 100644
index 000000000..8c927eb74
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
\ No newline at end of file
diff --git a/src/Modules/ToornamentModule/Tests/Mocks/ToornamentServiceMock.cs b/src/Modules/ToornamentModule/Tests/Mocks/ToornamentServiceMock.cs
new file mode 100644
index 000000000..9d81e5857
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/Mocks/ToornamentServiceMock.cs
@@ -0,0 +1,257 @@
+using Bogus;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using ToornamentApi.Models.Api.TournamentApi;
+
+namespace ToornamentTest.Mocks
+{
+ public class ToornamentServiceMock : IToornamentService
+ {
+ List disciplines = new List();
+ List groupInfos = new List();
+ List matchInfos = new List();
+ List matchGameInfos = new List();
+ List roundInfos = new List();
+ List stageInfos = new List();
+ List tournamentInfos = new List();
+ OpponentInfo[] opponentInfos = [];
+
+ public ToornamentServiceMock(int nrOfTournaments = 1, int nrOfStages = 3, int nrOfGroups = 3, int nrOfRounds = 7, int nrOfMatches = 32, int nrOfMatchGames = 1, int nrOfDisciplines = 7, int nrOfOpponents = 300)
+ {
+ GenerateOpponents(nrOfOpponents);
+ GenerateTournament(nrOfTournaments, nrOfStages, nrOfGroups, nrOfRounds, nrOfMatches, nrOfMatchGames, nrOfDisciplines);
+ }
+
+ private void GenerateTournament(int nrOfTournaments = 1, int nrOfStages = 3, int nrOfGroups = 3, int nrOfRounds = 7, int nrOfMatches = 32, int nrOfMatchGames = 1, int nrOfDisciplines = 7)
+ {
+ tournamentInfos = new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.Discipline, f => f.Name.ToString())
+ .RuleFor(t => t.Name, f => f.Company.CompanyName())
+ .RuleFor(t => t.FullName, f => f.Company.CompanyName())
+ .RuleFor(t => t.Status, "pending")
+ .RuleFor(t => t.ScheduledDateStart, f => f.Date.Soon())
+ .RuleFor(t => t.ScheduledDateEnd, f => f.Date.Future())
+ .UseSeed(33888666)
+ .Generate(nrOfTournaments);
+
+ foreach (var tournament in tournamentInfos)
+ {
+ GenerateStages(tournament.Id, nrOfStages, nrOfGroups, nrOfRounds, nrOfMatches);
+ }
+ }
+
+ private void GenerateStages(string tournamentId, int nrOfStages, int nrOfGroups, int nrOfRounds, int nrOfMatches)
+ {
+ stageInfos = new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.TournamentId, tournamentId)
+ .RuleFor(t => t.Name, f => f.Company.CompanyName())
+ .RuleFor(t => t.Number, f => f.IndexFaker++)
+ .RuleFor(t => t.Type, "ffa")
+ .RuleFor(t => t.Closed, false)
+ .UseSeed(33888666)
+ .Generate(nrOfStages);
+
+ foreach (var stage in stageInfos)
+ {
+ GenerateGroups(tournamentId, stage.Id, nrOfGroups, nrOfRounds, nrOfMatches);
+ }
+ }
+
+ private void GenerateGroups(string tournamentId, string stageId, int nrOfGroups, int nrOfRounds, int nrOfMatches)
+ {
+ groupInfos = new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.TournamentId, tournamentId)
+ .RuleFor(t => t.StageId, stageId)
+ .RuleFor(t => t.Name, f => f.Company.CompanyName())
+ .RuleFor(t => t.Number, f => f.IndexFaker++)
+ .RuleFor(t => t.Closed, false)
+ .UseSeed(33888666)
+ .Generate(nrOfGroups);
+
+ foreach (var group in groupInfos)
+ {
+ GenerateRounds(tournamentId, stageId, group.Id, nrOfRounds, nrOfMatches);
+ }
+ }
+
+ private void GenerateRounds(string tournamentId, string stageId, string groupId, int nrOfRounds, int nrOfMatches)
+ {
+ roundInfos = new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.TournamentId, tournamentId)
+ .RuleFor(t => t.StageId, stageId)
+ .RuleFor(t => t.GroupId, groupId)
+ .RuleFor(t => t.Name, f => f.Company.CompanyName())
+ .RuleFor(t => t.Number, f => f.IndexFaker++)
+ .RuleFor(t => t.Closed, false)
+ .UseSeed(33888666)
+ .Generate(nrOfRounds);
+
+ foreach (var round in roundInfos)
+ {
+ GenerateMatches(tournamentId, stageId, groupId, round.Id, nrOfMatches);
+ }
+ }
+
+ private void GenerateMatches(string tournamentId, string stageId, string groupId, string roundId, int nrOfMatches)
+ {
+ matchInfos = new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.TournamentId, tournamentId)
+ .RuleFor(t => t.StageId, stageId)
+ .RuleFor(t => t.GroupId, groupId)
+ .RuleFor(t => t.RoundId, roundId)
+ .RuleFor(t => t.Type, "ffa")
+ .RuleFor(t => t.Number, f => f.IndexFaker++)
+ .RuleFor(t => t.Status, "pending")
+ .RuleFor(t => t.Opponents, opponentInfos)
+ .UseSeed(33888666)
+ .Generate(nrOfMatches);
+
+ foreach (var match in matchInfos)
+ {
+ GenerateMatchGames(match.Opponents);
+ }
+ }
+
+ private void GenerateOpponents(int nrOfOpponents)
+ {
+ opponentInfos = new Faker()
+ .RuleFor(t => t.Number, f => f.IndexFaker++)
+ .RuleFor(t => t.Position, f => f.IndexFaker++)
+ .RuleFor(t => t.Participant, (f, t) =>
+ {
+ return new Faker()
+ .RuleFor(t => t.Id, Guid.NewGuid().ToString())
+ .RuleFor(t => t.Name, f => f.Person.FullName)
+ .RuleFor(t => t.CustomUserIdentifier, f => f.Person.UserName)
+ .RuleFor(t => t.CustomFields, f =>
+ {
+ var dict = new Dictionary();
+ dict.Add("trackmania_id", Guid.NewGuid().ToString());
+ return dict;
+ })
+ .UseSeed(33888666)
+ .GenerateForever()
+ .Skip(f.IndexFaker++)
+ .Take(1)
+ .First();
+ }
+ )
+ .UseSeed(33888666)
+ .Generate(nrOfOpponents).ToArray();
+ }
+
+ private void GenerateMatchGames(OpponentInfo[] opponents)
+ {
+ matchGameInfos = new Faker()
+ .RuleFor(t => t.Number, 1)
+ .RuleFor(t => t.Opponents, opponents)
+ .UseSeed(33888666)
+ .Generate(matchInfos.Count);
+ }
+
+ public Task GetDisciplineAsync(string disciplineId)
+ {
+ return Task.Run(() => disciplines.FirstOrDefault(x => x.Id == disciplineId));
+ }
+
+ public Task GetGroupAsync(string groupId)
+ {
+ return Task.Run(() => groupInfos.FirstOrDefault(x => x.Id == groupId));
+ }
+
+ public Task?> GetGroupsAsync(string tournamentId, string stageId)
+ {
+ return Task.Run(() => groupInfos);
+ }
+
+ public Task GetMatchAsync(string matchId)
+ {
+ return Task.Run(() => matchInfos.FirstOrDefault(x => x.Id == matchId));
+ }
+
+ public Task?> GetMatchesAsync(string tournamentId, string stageId)
+ {
+ return Task.Run(() => matchInfos);
+ }
+
+ public Task GetMatchGameAsync(string matchId, int gameNumber)
+ {
+ return Task.Run(() => matchGameInfos.FirstOrDefault(x => x.Number == gameNumber));
+ }
+
+ public Task?> GetMatchGamesAsync(string matchId)
+ {
+ return Task.Run(() => matchGameInfos);
+ }
+
+ public Task GetRoundAsync(string roundId)
+ {
+ return Task.Run(() => roundInfos.FirstOrDefault(x => x.Id == roundId));
+ }
+
+ public Task?> GetRoundsAsync(string tournamentId, string stageId)
+ {
+ return Task.Run(() => roundInfos);
+ }
+
+ public Task GetStageAsync(string stageId)
+ {
+ return Task.Run(() => stageInfos.FirstOrDefault(x => x.Id == stageId));
+ }
+
+ public Task?> GetStagesAsync(string tournamentId)
+ {
+ return Task.Run(() => stageInfos);
+ }
+
+ public Task GetTournamentAsync(string tournamentId)
+ {
+ return Task.Run(() => tournamentInfos.FirstOrDefault(x => x.Id == tournamentId));
+ }
+
+ public Task?> GetTournamentsAsync()
+ {
+ return Task.Run(() => tournamentInfos);
+ }
+
+ public Task SetMatchGameResultAsync(string matchId, int gameNumber, MatchGameInfo gameInfo)
+ {
+ return Task.Run(() =>
+ {
+ var matchGameInfo = matchGameInfos.FirstOrDefault(x => x.Number == gameNumber);
+ matchGameInfo = gameInfo;
+ return matchGameInfo;
+ });
+ }
+
+ public Task SetMatchGameStatusAsync(string matchId, int gameNumber, MatchGameStatus status)
+ {
+ return Task.Run(() =>
+ {
+ var matchGameInfo = matchGameInfos.FirstOrDefault(x => x.Number == gameNumber);
+ matchGameInfo.Status = status.ToString();
+ return matchGameInfo;
+ });
+ }
+
+ public Task SetMatchGameMapAsync(string matchId, int gameNumber, string mapName)
+ {
+ return Task.Run(() =>
+ {
+ var matchGameInfo = matchGameInfos.FirstOrDefault(x => x.Number == gameNumber);
+
+ if (matchGameInfo.Properties.ContainsKey("track"))
+ {
+ matchGameInfo.Properties.Remove("track");
+ }
+ matchGameInfo.Properties.Add("track", mapName);
+ return matchGameInfo;
+ });
+ }
+ }
+}
diff --git a/src/Modules/ToornamentModule/Tests/Services/MatchServiceTest.cs b/src/Modules/ToornamentModule/Tests/Services/MatchServiceTest.cs
new file mode 100644
index 000000000..6d297a62b
--- /dev/null
+++ b/src/Modules/ToornamentModule/Tests/Services/MatchServiceTest.cs
@@ -0,0 +1,521 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Interfaces.Util.Auditing;
+using EvoSC.Common.Models.Callbacks;
+using EvoSC.Common.Models.Players;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Util.MatchSettings.Builders;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.EvoEsports.ServerSyncModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Interfaces;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Models;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Services;
+using EvoSC.Modules.EvoEsports.ToornamentModule.Settings;
+using EvoSC.Modules.Official.MapsModule.Interfaces;
+using EvoSC.Modules.Official.MatchReadyModule.Interfaces;
+using EvoSC.Modules.Official.MatchTrackerModule.Interfaces;
+using EvoSC.Testing;
+using GbxRemoteNet;
+using GbxRemoteNet.Interfaces;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using ToornamentApi.Models.Api.TournamentApi;
+using ToornamentTest.Mocks;
+using Xunit.Abstractions;
+using IMapService = EvoSC.Common.Interfaces.Services.IMapService;
+
+namespace Toornament.Services;
+
+public class MatchServiceTest
+{
+ private readonly ITestOutputHelper _output;
+ private readonly ILogger _loggerMock;
+ private readonly Mock _auditServiceMock = new();
+ private readonly Mock _serverClientMock = new();
+ private readonly Mock _manialinkManagerMock = new();
+ private readonly Mock _toornamentSettingsMock = new();
+ private readonly Mock