diff --git a/Dockerfile b/Dockerfile
index 3dc39a4fb..a39f518d0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,15 +6,6 @@ COPY . .
RUN dotnet publish "src/EvoSC/EvoSC.csproj" -r linux-musl-x64 --self-contained true -c Release -o /publish
-# Set user & permissions
-FROM alpine:latest as run-chown
-
-WORKDIR /app
-COPY --from=build /publish .
-RUN true \
- && chown 9999:9999 -R /app \
- && true
-
# Create the image
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine3.20 as create-image
@@ -35,12 +26,21 @@ LABEL org.opencontainers.image.title="EvoSC#" \
org.opencontainers.image.revision=${REVISION}
WORKDIR /app
-COPY --from=run-chown /app .
RUN true \
+ && set -eux \
+ && addgroup -g 9999 evosc \
+ && adduser -u 9999 -Hh /app -G evosc -s /sbin/nologin -D evosc \
+ && install -d -o evosc -g evosc -m 775 /app \
&& apk add --no-cache icu-libs \
- && adduser --disabled-password --home /app -u 9999 evosc \
- && true \
+ && true
+
+RUN true \
+ && chown evosc:evosc -Rf /app \
+ && true
+
+COPY --from=build --chown=evosc:evosc /publish /app
USER evosc
-ENTRYPOINT ["./EvoSC", "run"]
+
+ENTRYPOINT ["./EvoSC", "run"]
\ No newline at end of file
diff --git a/EvoSC.sln b/EvoSC.sln
index 2ec61fa13..fe07f52f4 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,12 @@ 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}") = "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
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScoreboardModule.Tests", "tests\Modules\ScoreboardModule.Tests\ScoreboardModule.Tests.csproj", "{99C2D889-4F35-41D7-8CAB-7F3760D811FD}"
+EndProject
@@ -447,6 +453,18 @@ 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
+ {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
+ {99C2D889-4F35-41D7-8CAB-7F3760D811FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {99C2D889-4F35-41D7-8CAB-7F3760D811FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {99C2D889-4F35-41D7-8CAB-7F3760D811FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {99C2D889-4F35-41D7-8CAB-7F3760D811FD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -517,5 +535,8 @@ 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}
+ {99C2D889-4F35-41D7-8CAB-7F3760D811FD} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 31b4892a7..18d4f8664 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,21 @@ For a roadmap of planned features and what we're currently working on, have a lo
* **DO NOT USE IN A PRODUCTION SCENARIO, THE SOFTWARE IS STILL HEAVILY IN DEVELOPMENT.**
* **DO NOT ASK FOR ASSISTANCE IN USING THE SOFTWARE IN ITS UNFINISHED STATE.**
+## Installation with Docker
+
+> [!WARNING]
+> EvoSC# is pre-release software. It is not recommended to be used in production environments yet.
+>
+> Please continue only if you know what you're doing.
+
+Refer to our example [docker-compose.yml](https://github.com/EvoEsports/EvoSC-sharp/blob/master/docker/docker-compose.yml) file.
+An (incomplete) list of available environment variables can be found [here](https://github.com/EvoEsports/EvoSC-sharp/blob/master/docker/evosc.env.example).
+
## Developing for EvoSC#
+> [!WARNING]
+> The setup below is strictly meant for development purposes and does not reflect best practices on how to use EvoSC# in a production setting.
+
To setup a development environment for EvoSC#, we recommend having Docker installed and using the following Docker Compose template.
It sets up a TM2020 dedicated server for you as well as all the required other services.
@@ -46,9 +59,9 @@ services:
- 2350:2350/tcp
- "5001:5000/tcp" # Be careful opening XMLRPC! Only if you really need to.
environment:
- MASTER_LOGIN: "CHANGEME :)" # Create server credentials at https://www.trackmania.com/player/dedicated-servers
- MASTER_PASSWORD: "CHANGEME :)" # Create server credentials at https://www.trackmania.com/player/dedicated-servers
- XMLRPC_ALLOWREMOTE: "True"
+ TM_MASTERSERVER_LOGIN: "CHANGEME :)" # Create server credentials at https://www.trackmania.com/player/dedicated-servers
+ TM_MASTERSERVER_PASSWORD: "CHANGEME :)" # Create server credentials at https://www.trackmania.com/player/dedicated-servers
+ TM_SYSTEM_XMLRPC_ALLOWREMOTE: "True"
volumes:
- UserData:/server/UserData
db:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 46640ee76..9a57dccf5 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -6,9 +6,9 @@ services:
- 2350:2350/udp
- 2350:2350/tcp
environment:
- - MASTER_LOGIN=
- - MASTER_PASSWORD=
- - XMLRPC_ALLOWREMOTE=True
+ - TM_MASTERSERVER_LOGIN=
+ - TM_MASTERSERVER_PASSWORD=
+ - TM_SYSTEM_XMLRPC_ALLOWREMOTE=True
volumes:
- UserData:/server/UserData
@@ -19,9 +19,12 @@ services:
- POSTGRES_PASSWORD=evosc_sharp
- POSTGRES_USER=evosc_sharp
- POSTGRES_DB=evosc_sharp
-
+
evosc_sharp:
- image: evoscsharp:latest
+ image: evoscsharp
+ build:
+ context: .
+ dockerfile: ../Dockerfile
restart: always
depends_on:
- trackmania
@@ -32,6 +35,7 @@ services:
- EVOSC_DATABASE_USERNAME=evosc_sharp
- EVOSC_DATABASE_PASSWORD=evosc_sharp
- EVOSC_SERVER_HOST=trackmania
+ - EVOSC_PATH_MAPS=/server/UserData/Maps
volumes:
- UserData:/server/UserData
diff --git a/src/EvoSC/EvoSC.csproj b/src/EvoSC/EvoSC.csproj
index 844ecefc8..667cf0679 100644
--- a/src/EvoSC/EvoSC.csproj
+++ b/src/EvoSC/EvoSC.csproj
@@ -24,46 +24,37 @@
+
+
+
+
-
-
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
diff --git a/src/EvoSC/InternalModules.cs b/src/EvoSC/InternalModules.cs
index a7fa2c43b..ace73bb6b 100644
--- a/src/EvoSC/InternalModules.cs
+++ b/src/EvoSC/InternalModules.cs
@@ -20,7 +20,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 +66,8 @@ public static class InternalModules
typeof(TeamSettingsModule),
typeof(ServerManagementModule),
typeof(TeamInfoModule),
- typeof(TeamChatModule)
+ typeof(TeamChatModule),
+ typeof(RoundRankingModule)
];
///
diff --git a/src/Modules/GameModeUiModule/Config/IGameModeUiModuleSettings.cs b/src/Modules/GameModeUiModule/Config/IGameModeUiModuleSettings.cs
index 2d298f6de..c63f9919c 100644
--- a/src/Modules/GameModeUiModule/Config/IGameModeUiModuleSettings.cs
+++ b/src/Modules/GameModeUiModule/Config/IGameModeUiModuleSettings.cs
@@ -96,6 +96,21 @@ public interface IGameModeUiModuleSettings
[Option(DefaultValue = 1.0), Description("The scale of the ScoresTable module.")]
public double ScoresTableScale { get; set; }
+
+ /*
+ * Settings for Race_SmallScoresTable
+ */
+ [Option(DefaultValue = true), Description("The visibility of the SmallScoresTable module.")]
+ public bool SmallScoresTableVisible { get; set; }
+
+ [Option(DefaultValue = -160.0), Description("The x position of the SmallScoresTable module.")]
+ public double SmallScoresTableX { get; set; }
+
+ [Option(DefaultValue = 10.0), Description("The y position of the SmallScoresTable module.")]
+ public double SmallScoresTableY { get; set; }
+
+ [Option(DefaultValue = 1.0), Description("The scale of the SmallScoresTable module.")]
+ public double SmallScoresTableScale { get; set; }
/*
* Settings for Race_DisplayMessage
diff --git a/src/Modules/GameModeUiModule/Enums/GameModeUiComponents.cs b/src/Modules/GameModeUiModule/Enums/GameModeUiComponents.cs
index 2d871da86..e836a9a23 100644
--- a/src/Modules/GameModeUiModule/Enums/GameModeUiComponents.cs
+++ b/src/Modules/GameModeUiModule/Enums/GameModeUiComponents.cs
@@ -8,6 +8,7 @@ public static class GameModeUiComponents
public static readonly string LapsCounter = "Race_LapsCounter";
public static readonly string TimeGap = "Race_TimeGap";
public static readonly string ScoresTable = "Race_ScoresTable";
+ public static readonly string SmallScoresTable = "Rounds_SmallScoresTable";
public static readonly string DisplayMessage = "Race_DisplayMessage";
public static readonly string Countdown = "Race_Countdown";
public static readonly string SpectatorBaseName = "Race_SpectatorBase_Name";
diff --git a/src/Modules/GameModeUiModule/Services/GameModeUiModuleService.cs b/src/Modules/GameModeUiModule/Services/GameModeUiModuleService.cs
index 8f87e5578..0544f463d 100644
--- a/src/Modules/GameModeUiModule/Services/GameModeUiModuleService.cs
+++ b/src/Modules/GameModeUiModule/Services/GameModeUiModuleService.cs
@@ -92,6 +92,13 @@ public List GetDefaultSettings()
settings.ScoresTableY,
settings.ScoresTableScale
),
+ new GameModeUiComponentSettings(
+ GameModeUiComponents.SmallScoresTable,
+ settings.SmallScoresTableVisible,
+ settings.SmallScoresTableX,
+ settings.SmallScoresTableY,
+ settings.SmallScoresTableScale
+ ),
new GameModeUiComponentSettings(
GameModeUiComponents.DisplayMessage,
settings.DisplayMessageVisible,
diff --git a/src/Modules/RoundRankingModule/Config/IRoundRankingSettings.cs b/src/Modules/RoundRankingModule/Config/IRoundRankingSettings.cs
new file mode 100644
index 000000000..d748b739c
--- /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/RoundRankingEventController.cs b/src/Modules/RoundRankingModule/Controllers/RoundRankingEventController.cs
new file mode 100644
index 000000000..56b3ff78b
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Controllers/RoundRankingEventController.cs
@@ -0,0 +1,74 @@
+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.RoundRankingModule.Interfaces;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Controllers;
+
+[Controller]
+public class RoundRankingEventController(
+ IRoundRankingService roundRankingService,
+ IRoundRankingStateService roundRankingStateService
+)
+ : EvoScController
+{
+ [Subscribe(ModeScriptEvent.WayPoint)]
+ public async Task OnWaypointAsync(object sender, WayPointEventArgs args)
+ {
+ await roundRankingService.ConsumeCheckpointAsync(
+ args.AccountId,
+ args.CheckpointInLap,
+ args.LapTime,
+ args.IsEndLap,
+ false
+ );
+ }
+
+ [Subscribe(ModeScriptEvent.GiveUp)]
+ public Task OnPlayerGiveUpAsync(object sender, PlayerUpdateEventArgs args) =>
+ roundRankingService.ConsumeDnfAsync(args.AccountId);
+
+ [Subscribe(ModeScriptEvent.EndRoundEnd)]
+ public Task OnEndRoundAsync(object sender, EventArgs args) =>
+ roundRankingService.ClearCheckpointDataAsync();
+
+ [Subscribe(ModeScriptEvent.WarmUpEndRound)]
+ public Task OnWarmUpEndRoundAsync(object sender, WarmUpRoundEventArgs args) =>
+ roundRankingService.ClearCheckpointDataAsync();
+
+ [Subscribe(ModeScriptEvent.StartMatchStart)]
+ public Task OnStartMatchAsync(object sender, MatchEventArgs args) =>
+ roundRankingService.ClearCheckpointDataAsync();
+
+ [Subscribe(ModeScriptEvent.StartLine)]
+ public Task OnStartLineAsync(object sender, PlayerUpdateEventArgs args) =>
+ roundRankingService.RemovePlayerCheckpointDataAsync(args.AccountId);
+
+ [Subscribe(ModeScriptEvent.Respawn)]
+ public Task OnRespawnAsync(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.DetectAndSetIsTeamsModeAsync();
+ await roundRankingService.FetchAndCacheTeamInfoAsync();
+ await roundRankingService.LoadPointsRepartitionFromSettingsAsync();
+ await roundRankingService.ClearCheckpointDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpStart)]
+ public Task OnWarmUpStartAsync(object sender, EventArgs args) =>
+ roundRankingStateService.SetIsTimeAttackModeAsync(true);
+
+ [Subscribe(ModeScriptEvent.WarmUpEnd)]
+ public Task OnWarmUpEndAsync(object sender, EventArgs args) =>
+ roundRankingStateService.SetIsTimeAttackModeAsync(false);
+}
diff --git a/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs
new file mode 100644
index 000000000..8901335c7
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingService.cs
@@ -0,0 +1,79 @@
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+
+public interface IRoundRankingService
+{
+ ///
+ /// Process a new entry for the checkpoint data repository.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public Task ConsumeCheckpointAsync(string accountId, int checkpointId, int time, bool isFinish, bool isDnf);
+
+ ///
+ /// Sets a player as DNF in the checkpoint repository.
+ ///
+ ///
+ public Task ConsumeDnfAsync(string accountId);
+
+ ///
+ /// Removes the checkpoint data of the player with the given account ID.
+ ///
+ ///
+ ///
+ public Task RemovePlayerCheckpointDataAsync(string accountId);
+
+ ///
+ /// Sorts and returns the current checkpoint data.
+ ///
+ ///
+ public Task> GetSortedCheckpointsAsync();
+
+ ///
+ /// 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();
+
+ ///
+ /// Gets the current points repartition value and caches it.
+ ///
+ ///
+ public Task LoadPointsRepartitionFromSettingsAsync();
+
+ ///
+ /// Sets TimeAttack mode active/inactive.
+ ///
+ ///
+ ///
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode);
+
+ ///
+ /// Detects if team mode is active and caches the result.
+ ///
+ ///
+ public Task DetectAndSetIsTeamsModeAsync();
+
+ ///
+ /// Gets the latest team infos and caches them.
+ ///
+ ///
+ public Task FetchAndCacheTeamInfoAsync();
+}
diff --git a/src/Modules/RoundRankingModule/Interfaces/IRoundRankingStateService.cs b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingStateService.cs
new file mode 100644
index 000000000..99a8108e0
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Interfaces/IRoundRankingStateService.cs
@@ -0,0 +1,89 @@
+using System.Collections.Concurrent;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+
+public interface IRoundRankingStateService
+{
+ ///
+ /// Returns the currently set points repartition.
+ ///
+ ///
+ public Task GetPointsRepartitionAsync();
+
+ ///
+ /// Sets a new points repartition.
+ ///
+ ///
+ ///
+ public Task UpdatePointsRepartitionAsync(string pointsRepartitionString);
+
+ ///
+ /// Returns the repository containing all collected checkpoints for the ongoing round.
+ ///
+ ///
+ public Task GetRepositoryAsync();
+
+ ///
+ /// Sets or updates the checkpoint data for the given account ID.
+ ///
+ ///
+ ///
+ ///
+ public Task UpdateRepositoryEntryAsync(string accountId, CheckpointData checkpointData);
+
+ ///
+ /// Returns the checkpoint data for the given account ID.
+ ///
+ ///
+ ///
+ public Task RemoveRepositoryEntryAsync(string accountId);
+
+ ///
+ /// Removes all entries from the repository.
+ ///
+ ///
+ public Task ClearRepositoryAsync();
+
+ ///
+ /// Returns the currently set team colors.
+ ///
+ ///
+ public Task> GetTeamColorsAsync();
+
+ ///
+ /// Overwrites the current team colors with new values.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public Task SetTeamColorsAsync(string team1Color, string team2Color, string teamUnknownColor);
+
+ ///
+ /// Updates whether teams mode is active.
+ ///
+ ///
+ ///
+ public Task SetIsTeamsModeAsync(bool isTeamsMode);
+
+ ///
+ /// Updates whether time attack mode is active.
+ ///
+ ///
+ ///
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode);
+
+ ///
+ /// Returns whether teams mode is active.
+ ///
+ ///
+ public Task GetIsTeamsModeAsync();
+
+ ///
+ /// Returns whether time attack mode is active.
+ ///
+ ///
+ public Task GetIsTimeAttackModeAsync();
+}
diff --git a/src/Modules/RoundRankingModule/Localization.resx b/src/Modules/RoundRankingModule/Localization.resx
new file mode 100644
index 000000000..02cf34b4b
--- /dev/null
+++ b/src/Modules/RoundRankingModule/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/RoundRankingModule/Models/CheckpointData.cs b/src/Modules/RoundRankingModule/Models/CheckpointData.cs
new file mode 100644
index 000000000..2a5cd3b3d
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/CheckpointData.cs
@@ -0,0 +1,129 @@
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Interfaces.Util;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Util;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+///
+/// Model that contains a player instance and their checkpoint information.
+/// This class is used to pass data to the template engine.
+///
+public class CheckpointData
+{
+ ///
+ /// The player associated with the checkpoint data.
+ ///
+ public required IOnlinePlayer Player { get; init; }
+
+ ///
+ /// The current checkpoint index the player is at.
+ ///
+ public required int CheckpointId { get; init; }
+
+ ///
+ /// The time at the current checkpoint.
+ ///
+ public required IRaceTime Time { get; set; }
+
+ ///
+ /// The time difference between the leading an current player.
+ ///
+ public IRaceTime? TimeDifference { get; set; }
+
+ ///
+ /// Whether the checkpoint is the finish.
+ ///
+ public required bool IsFinish { get; init; }
+
+ ///
+ /// Whether the players did not finish their round.
+ ///
+ public required bool IsDNF { get; init; }
+
+ ///
+ /// The gained points for the current round.
+ ///
+ public int GainedPoints { get; set; }
+
+ ///
+ /// The background color for the gained points box in the widget.
+ ///
+ public string? AccentColor { get; set; }
+
+ ///
+ /// Returns the time string displayed in the widget.
+ ///
+ ///
+ public string? FormattedTime()
+ {
+ if (IsDNF)
+ {
+ return "DNF";
+ }
+
+ var formattedTime = (TimeDifference ?? Time).ToString();
+
+ return formattedTime?.TrimStart('0');
+ }
+
+ ///
+ /// Returns the index/gained points string in the widget.
+ ///
+ ///
+ public string IndexText()
+ {
+ if (IsDNF)
+ {
+ return GameIcons.Icons.FlagO;
+ }
+
+ return IsFinish ? GameIcons.Icons.FlagCheckered : (CheckpointId + 1).ToString();
+ }
+
+ ///
+ /// Creates a race time containing the time difference of the given checkpoint data, relative to this checkpoint data.
+ ///
+ ///
+ ///
+ public IRaceTime GetTimeDifferenceAbsolute(CheckpointData checkpointData)
+ {
+ return RaceTime.FromMilliseconds(int.Abs(this.Time.TotalMilliseconds - checkpointData.Time.TotalMilliseconds));
+ }
+
+ ///
+ /// Creates a CheckpointData instance from WayPointEventArgs.
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static CheckpointData FromWaypointEventArgs(IOnlinePlayer player, WayPointEventArgs args)
+ {
+ return new CheckpointData
+ {
+ Player = player,
+ CheckpointId = args.CheckpointInLap,
+ Time = RaceTime.FromMilliseconds(args.LapTime),
+ IsFinish = args.IsEndLap,
+ IsDNF = false
+ };
+ }
+
+ ///
+ /// Creates a CheckpointData instance for DNFs.
+ ///
+ ///
+ ///
+ public static CheckpointData CreateDnfEntry(IOnlinePlayer player)
+ {
+ return new CheckpointData
+ {
+ Player = player,
+ CheckpointId = -1,
+ Time = RaceTime.FromMilliseconds(0),
+ IsFinish = false,
+ IsDNF = true
+ };
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs b/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs
new file mode 100644
index 000000000..c102d866f
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/CheckpointsRepository.cs
@@ -0,0 +1,22 @@
+using System.Collections.Concurrent;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+///
+/// CheckpointsRepository contains all for the ongoing round,
+/// where the key of the dictionary is the players account-ID.
+///
+public class CheckpointsRepository : ConcurrentDictionary
+{
+ ///
+ /// Sorts and returns the contents of the dictionary by the checkpoint progression and times at each checkpoint.
+ ///
+ ///
+ 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..78a5a5bc4
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Models/PointsRepartition.cs
@@ -0,0 +1,42 @@
+namespace EvoSC.Modules.Official.RoundRankingModule.Models;
+
+///
+/// This class is used to easily access the currently set points repartition on the dedicated server and
+/// to calculate gained points based on it.
+///
+public class PointsRepartition : List
+{
+ public static readonly string ModeScriptSetting = "S_PointsRepartition";
+ public static readonly string DefaultValue = "10,6,4,3,2,1";
+
+ public PointsRepartition()
+ {
+ Update(DefaultValue);
+ }
+
+ public PointsRepartition(string pointsRepartitionString)
+ {
+ Update(pointsRepartitionString);
+ }
+
+ ///
+ /// Consumes new a points repartition.
+ /// Values are comma separated.
+ ///
+ ///
+ public void Update(string pointsRepartitionString)
+ {
+ Clear();
+ AddRange(pointsRepartitionString.Split(',').Select(int.Parse).ToList());
+ }
+
+ ///
+ /// Returns the gained points for the given rank.
+ ///
+ ///
+ ///
+ 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..f613b8f3b
--- /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.DetectAndSetIsTeamsModeAsync();
+ await roundRankingService.LoadPointsRepartitionFromSettingsAsync();
+ await roundRankingService.FetchAndCacheTeamInfoAsync();
+ 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..f3085adfc
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Services/RoundRankingService.cs
@@ -0,0 +1,180 @@
+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;
+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;
+using EvoSC.Modules.Official.RoundRankingModule.Utils;
+using LinqToDB.Common;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Transient)]
+public class RoundRankingService(
+ IRoundRankingStateService stateService,
+ IRoundRankingSettings settings,
+ IManialinkManager manialinkManager,
+ IPlayerManagerService playerManagerService,
+ IMatchSettingsService matchSettingsService,
+ IThemeManager theme,
+ IServerClient server
+) : IRoundRankingService
+{
+ private const string WidgetTemplate = "RoundRankingModule.RoundRanking";
+
+ public async Task ConsumeCheckpointAsync(string accountId, int checkpointId, int time, bool isFinish, bool isDnf)
+ {
+ await stateService.UpdateRepositoryEntryAsync(accountId,
+ new CheckpointData
+ {
+ Player = await playerManagerService.GetOnlinePlayerAsync(accountId),
+ CheckpointId = checkpointId,
+ Time = RaceTime.FromMilliseconds(time),
+ IsFinish = isFinish,
+ IsDNF = isDnf
+ });
+
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task ConsumeDnfAsync(string accountId)
+ {
+ var isTimeAttackMode = await stateService.GetIsTimeAttackModeAsync();
+
+ if (!isTimeAttackMode)
+ {
+ await ConsumeCheckpointAsync(accountId, -1, 0, false, true);
+ return;
+ }
+
+ await stateService.RemoveRepositoryEntryAsync(accountId);
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task RemovePlayerCheckpointDataAsync(string accountId)
+ {
+ var isTimeAttackMode = await stateService.GetIsTimeAttackModeAsync();
+
+ if (!isTimeAttackMode)
+ {
+ //In time attack mode the entries are cleared by new round event.
+ //Prevents flood of manialinks.
+ return;
+ }
+
+ await stateService.RemoveRepositoryEntryAsync(accountId);
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task> GetSortedCheckpointsAsync()
+ {
+ var cpRepository = await stateService.GetRepositoryAsync();
+
+ return cpRepository.IsNullOrEmpty() ? [] : cpRepository.GetSortedData();
+ }
+
+ public async Task ClearCheckpointDataAsync()
+ {
+ await stateService.ClearRepositoryAsync();
+ await SendRoundRankingWidgetAsync();
+ }
+
+ public async Task SendRoundRankingWidgetAsync()
+ {
+ var bestCheckpoints = await GetSortedCheckpointsAsync();
+ var isTeamsMode = await stateService.GetIsTeamsModeAsync();
+ var teamColors = await stateService.GetTeamColorsAsync();
+
+ if (bestCheckpoints.Count > 0)
+ {
+ var isTimeAttackMode = await stateService.GetIsTimeAttackModeAsync();
+ bestCheckpoints = bestCheckpoints.Take(settings.MaxRows).ToList();
+
+ if (settings.DisplayGainedPoints && !isTimeAttackMode)
+ {
+ RoundRankingUtils.SetGainedPointsOnResult(
+ bestCheckpoints,
+ await stateService.GetPointsRepartitionAsync(),
+ (string)theme.Theme.UI_AccentPrimary
+ );
+ }
+
+ if (isTeamsMode)
+ {
+ RoundRankingUtils.ApplyTeamColorsAsAccentColors(bestCheckpoints, teamColors);
+ }
+
+ if (settings.DisplayTimeDifference)
+ {
+ RoundRankingUtils.CalculateAndSetTimeDifferenceOnResult(bestCheckpoints);
+ }
+ }
+
+ var showWinnerTeam = isTeamsMode && RoundRankingUtils.HasPlayerInFinish(bestCheckpoints);
+ var winnerTeamName = "DRAW";
+ var winnerTeamColor = theme.Theme?.UI_AccentSecondary ?? "000000";
+ var winnerTeam = PlayerTeam.Unknown;
+
+ if (showWinnerTeam)
+ {
+ winnerTeam = RoundRankingUtils.GetWinnerTeam(bestCheckpoints);
+
+ if (winnerTeam != PlayerTeam.Unknown)
+ {
+ winnerTeamColor = teamColors[winnerTeam];
+ winnerTeamName = (await server.Remote.GetTeamInfoAsync((int)winnerTeam + 1)).Name;
+ }
+ }
+
+ await manialinkManager.SendPersistentManialinkAsync(WidgetTemplate,
+ new
+ {
+ settings,
+ showWinnerTeam,
+ winnerTeam,
+ winnerTeamName,
+ winnerTeamColor,
+ bestCheckpoints = bestCheckpoints.Take(settings.MaxRows),
+ });
+ }
+
+ public async Task LoadPointsRepartitionFromSettingsAsync()
+ {
+ var modeScriptSettings = await matchSettingsService.GetCurrentScriptSettingsAsync();
+ var pointsRepartitionString =
+ (string?)modeScriptSettings?.GetValueOrDefault(PointsRepartition.ModeScriptSetting);
+
+ if (pointsRepartitionString == null || pointsRepartitionString.Trim().Length == 0)
+ {
+ return;
+ }
+
+ await stateService.UpdatePointsRepartitionAsync(pointsRepartitionString);
+ }
+
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode)
+ => stateService.SetIsTimeAttackModeAsync(isTimeAttackMode);
+
+ public async Task DetectAndSetIsTeamsModeAsync()
+ {
+ var currentMode = await matchSettingsService.GetCurrentModeAsync();
+ await stateService.SetIsTeamsModeAsync(currentMode == DefaultModeScriptName.Teams);
+ }
+
+ public async Task FetchAndCacheTeamInfoAsync()
+ {
+ var team1Info = await server.Remote.GetTeamInfoAsync((int)PlayerTeam.Team1 + 1);
+ var team2Info = await server.Remote.GetTeamInfoAsync((int)PlayerTeam.Team2 + 1);
+
+ await stateService.SetTeamColorsAsync(team1Info.RGB, team2Info.RGB, theme.Theme.UI_AccentSecondary);
+ }
+
+ public Task HideRoundRankingWidgetAsync() =>
+ manialinkManager.HideManialinkAsync(WidgetTemplate);
+}
diff --git a/src/Modules/RoundRankingModule/Services/RoundRankingStateService.cs b/src/Modules/RoundRankingModule/Services/RoundRankingStateService.cs
new file mode 100644
index 000000000..7a2934419
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Services/RoundRankingStateService.cs
@@ -0,0 +1,115 @@
+using System.Collections.Concurrent;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class RoundRankingStateService : IRoundRankingStateService
+{
+ private readonly object _pointsRepartitionMutex = new();
+ private readonly object _isTimeAttackModeMutex = new();
+ private readonly object _isTeamsModeMutex = new();
+ private readonly PointsRepartition _pointsRepartition = [];
+ private readonly CheckpointsRepository _checkpointsRepository = new();
+ private readonly ConcurrentDictionary _teamColors = new();
+ private bool _isTimeAttackMode;
+ private bool _isTeamsMode;
+
+ public Task GetPointsRepartitionAsync()
+ {
+ lock (_pointsRepartitionMutex)
+ {
+ return Task.FromResult(_pointsRepartition);
+ }
+ }
+
+ public Task UpdatePointsRepartitionAsync(string pointsRepartitionString)
+ {
+ lock (_pointsRepartitionMutex)
+ {
+ _pointsRepartition.Update(pointsRepartitionString);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task GetRepositoryAsync()
+ {
+ return Task.FromResult(_checkpointsRepository);
+ }
+
+ public Task UpdateRepositoryEntryAsync(string accountId, CheckpointData checkpointData)
+ {
+ _checkpointsRepository[accountId] = checkpointData;
+
+ return Task.CompletedTask;
+ }
+
+ public Task RemoveRepositoryEntryAsync(string accountId)
+ {
+ _checkpointsRepository.Remove(accountId, out var removedCheckpointData);
+
+ return Task.CompletedTask;
+ }
+
+ public Task ClearRepositoryAsync()
+ {
+ _checkpointsRepository.Clear();
+
+ return Task.CompletedTask;
+ }
+
+ public Task> GetTeamColorsAsync()
+ {
+ return Task.FromResult(_teamColors);
+ }
+
+ public Task SetTeamColorsAsync(string team1Color, string team2Color, string teamUnknownColor)
+ {
+ _teamColors[PlayerTeam.Team1] = team1Color;
+ _teamColors[PlayerTeam.Team2] = team2Color;
+ _teamColors[PlayerTeam.Unknown] = teamUnknownColor;
+
+ return Task.CompletedTask;
+ }
+
+ public Task SetIsTeamsModeAsync(bool isTeamsMode)
+ {
+ lock (_isTeamsModeMutex)
+ {
+ _isTeamsMode = isTeamsMode;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task SetIsTimeAttackModeAsync(bool isTimeAttackMode)
+ {
+ lock (_isTimeAttackModeMutex)
+ {
+ _isTimeAttackMode = isTimeAttackMode;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task GetIsTeamsModeAsync()
+ {
+ lock (_isTeamsModeMutex)
+ {
+ return Task.FromResult(_isTeamsMode);
+ }
+ }
+
+ public Task GetIsTimeAttackModeAsync()
+ {
+ lock (_isTimeAttackModeMutex)
+ {
+ return Task.FromResult(_isTimeAttackMode);
+ }
+ }
+}
diff --git a/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt
new file mode 100644
index 000000000..8b4c4be1d
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/Components/RoundRankingRow.mt
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/Components/WinnerTeam.mt b/src/Modules/RoundRankingModule/Templates/Components/WinnerTeam.mt
new file mode 100644
index 000000000..02038f1b5
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/Components/WinnerTeam.mt
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..a604dbb88
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Templates/RoundRanking.mt
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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/Utils/RoundRankingUtils.cs b/src/Modules/RoundRankingModule/Utils/RoundRankingUtils.cs
new file mode 100644
index 000000000..6055c3c10
--- /dev/null
+++ b/src/Modules/RoundRankingModule/Utils/RoundRankingUtils.cs
@@ -0,0 +1,100 @@
+using System.Collections.Concurrent;
+using EvoSC.Common.Interfaces.Models;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Utils;
+
+public abstract class RoundRankingUtils
+{
+ ///
+ /// Determines whether a player in the given list passed the finish line.
+ ///
+ ///
+ ///
+ public static bool HasPlayerInFinish(List checkpoints) =>
+ checkpoints.Exists(checkpoint => checkpoint.IsFinish);
+
+ ///
+ /// Determines the winning team based on the given checkpoint data.
+ ///
+ ///
+ ///
+ public static PlayerTeam GetWinnerTeam(List checkpoints)
+ {
+ var gainedPointsPerTeam = new Dictionary
+ {
+ { PlayerTeam.Unknown, 0 }, { PlayerTeam.Team1, 0 }, { PlayerTeam.Team2, 0 }
+ };
+
+ foreach (var cpData in checkpoints)
+ {
+ gainedPointsPerTeam[cpData.Player.Team] += cpData.GainedPoints;
+ }
+
+ if (gainedPointsPerTeam[PlayerTeam.Team1] == gainedPointsPerTeam[PlayerTeam.Team2])
+ {
+ return PlayerTeam.Unknown;
+ }
+
+ return gainedPointsPerTeam.Where(tp => tp.Value > 0)
+ .OrderByDescending(tp => tp.Value)
+ .Select(tp => tp.Key)
+ .FirstOrDefault(PlayerTeam.Unknown);
+ }
+
+ ///
+ /// Calculates the differences in time for each player compared to the leading player.
+ /// The calculated times are set on the objects in the list.
+ ///
+ ///
+ public static 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);
+ }
+ }
+
+ ///
+ /// Use the players team color as the accent color for their gained points in the widget.
+ ///
+ ///
+ ///
+ public static void ApplyTeamColorsAsAccentColors(List checkpoints,
+ ConcurrentDictionary teamColors)
+ {
+ foreach (var cpData in checkpoints.Where(checkpoint => checkpoint.GainedPoints > 0))
+ {
+ cpData.AccentColor = teamColors[cpData.Player.Team];
+ }
+ }
+
+ ///
+ /// Takes the points repartition and applies the gained points to the checkpoint data list.
+ ///
+ ///
+ ///
+ ///
+ public static void SetGainedPointsOnResult(List checkpoints, PointsRepartition currentPointsRepartition,
+ string accentColor)
+ {
+ var rank = 1;
+ foreach (var cpData in checkpoints.Where(checkpoint => checkpoint.IsFinish))
+ {
+ cpData.AccentColor = accentColor;
+ cpData.GainedPoints = currentPointsRepartition.GetGainedPoints(rank++);
+ }
+ }
+}
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/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..78c4481bc
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Controllers/ScoreboardEventController.cs
@@ -0,0 +1,69 @@
+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.ScoreboardModule.Interfaces;
+using GbxRemoteNet.Events;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Controllers;
+
+[Controller]
+public class ScoreboardEventController(
+ IScoreboardService scoreboardService,
+ IScoreboardStateService scoreboardStateService,
+ IScoreboardNicknamesService nicknamesService
+)
+ : EvoScController
+{
+ [Subscribe(GbxRemoteEvent.PlayerConnect)]
+ public Task OnPlayerConnectAsync(object sender, PlayerGbxEventArgs args) =>
+ nicknamesService.FetchAndAddNicknameByLoginAsync(args.Login);
+
+ [Subscribe(GbxRemoteEvent.BeginMap)]
+ public async Task OnBeginMapAsync(object sender, MapGbxEventArgs args)
+ {
+ await nicknamesService.InitializeNicknamesAsync();
+ await scoreboardStateService.SetCurrentRoundNumberAsync(1);
+ await scoreboardService.SendScoreboardAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.StartRoundStart)]
+ public async Task OnStartRoundAsync(object sender, RoundEventArgs args)
+ {
+ await scoreboardStateService.SetCurrentRoundNumberAsync(args.Count);
+ await scoreboardService.SendMetaDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.EndRoundEnd)]
+ public async Task OnEndRoundAsync(object sender, RoundEventArgs args)
+ {
+ await scoreboardStateService.SetCurrentRoundNumberAsync(args.Count + 1);
+ await scoreboardService.SendMetaDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpStart)]
+ public async Task OnWarmUpStartAsync(object sender, EventArgs args)
+ {
+ await scoreboardStateService.SetIsWarmUpAsync(true);
+ await scoreboardStateService.SetCurrentRoundNumberAsync(1);
+ await scoreboardService.SendMetaDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpEnd)]
+ public async Task OnWarmUpEndAsync(object sender, EventArgs args)
+ {
+ await scoreboardStateService.SetIsWarmUpAsync(false);
+ await scoreboardStateService.SetCurrentRoundNumberAsync(1);
+ await scoreboardService.SendMetaDataAsync();
+ }
+
+ [Subscribe(ModeScriptEvent.WarmUpStartRound)]
+ public async Task OnWarmUpStartRoundAsync(object sender, WarmUpRoundEventArgs args)
+ {
+ await scoreboardStateService.SetIsWarmUpAsync(true);
+ await scoreboardStateService.SetCurrentRoundNumberAsync(args.Current);
+ await scoreboardService.SendMetaDataAsync();
+ }
+}
diff --git a/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs b/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs
new file mode 100644
index 000000000..573c2674b
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Interfaces/IScoreboardNicknamesService.cs
@@ -0,0 +1,37 @@
+using System.Collections.Concurrent;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+public interface IScoreboardNicknamesService
+{
+ ///
+ /// Gets all online players and sets their custom nicknames in the repo.
+ ///
+ ///
+ public Task InitializeNicknamesAsync();
+
+ ///
+ /// Gets the online player by login and then sets their custom nickname in the repo.
+ ///
+ ///
+ ///
+ public Task FetchAndAddNicknameByLoginAsync(string login);
+
+ ///
+ /// Returns the nicknames map.
+ ///
+ ///
+ public Task> GetNicknamesAsync();
+
+ ///
+ /// Clears the nicknames repo.
+ ///
+ ///
+ public Task ClearNicknamesAsync();
+
+ ///
+ /// Sends the manialink containing the nicknames in the repo.
+ ///
+ ///
+ public Task SendNicknamesManialinkAsync();
+}
diff --git a/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs b/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs
new file mode 100644
index 000000000..bb9e2f5c5
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Interfaces/IScoreboardService.cs
@@ -0,0 +1,25 @@
+namespace EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+public interface IScoreboardService
+{
+ ///
+ /// Sends the scoreboard manialink to all players.
+ ///
+ public Task SendScoreboardAsync();
+
+ ///
+ /// Sends the MetaData manialink.
+ ///
+ ///
+ public Task SendMetaDataAsync();
+
+ ///
+ /// Hide the default game scoreboard.
+ ///
+ public Task HideNadeoScoreboardAsync();
+
+ ///
+ /// Shows the default game scoreboard.
+ ///
+ public Task ShowNadeoScoreboardAsync();
+}
diff --git a/src/Modules/ScoreboardModule/Interfaces/IScoreboardStateService.cs b/src/Modules/ScoreboardModule/Interfaces/IScoreboardStateService.cs
new file mode 100644
index 000000000..d274b9415
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Interfaces/IScoreboardStateService.cs
@@ -0,0 +1,30 @@
+namespace EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+public interface IScoreboardStateService
+{
+ ///
+ /// Sets the current round number.
+ ///
+ ///
+ ///
+ public Task SetCurrentRoundNumberAsync(int roundNumber);
+
+ ///
+ /// Returns the number of the current round.
+ ///
+ ///
+ public Task GetCurrentRoundNumberAsync();
+
+ ///
+ /// Sets whether warm is currently ongoing.
+ ///
+ ///
+ ///
+ public Task SetIsWarmUpAsync(bool isWarmUp);
+
+ ///
+ /// Returns whether the game mode is in warm up mode.
+ ///
+ ///
+ public Task GetIsWarmUpAsync();
+}
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..4d96b28dd
--- /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.InitializeNicknamesAsync();
+ await scoreboardService.HideNadeoScoreboardAsync();
+ await scoreboardService.SendScoreboardAsync();
+ }
+
+ public Task DisableAsync() =>
+ scoreboardService.ShowNadeoScoreboardAsync();
+}
diff --git a/src/Modules/ScoreboardModule/ScoreboardModule.csproj b/src/Modules/ScoreboardModule/ScoreboardModule.csproj
new file mode 100644
index 000000000..456dbbf3f
--- /dev/null
+++ b/src/Modules/ScoreboardModule/ScoreboardModule.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.Official.ScoreboardModule
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
diff --git a/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs b/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs
new file mode 100644
index 000000000..6f70b3b23
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Services/ScoreboardNicknamesService.cs
@@ -0,0 +1,54 @@
+using System.Collections.Concurrent;
+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;
+using EvoSC.Modules.Official.ScoreboardModule.Utils;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class ScoreboardNicknamesService(IPlayerManagerService playerManagerService, IManialinkManager manialinkManager)
+ : IScoreboardNicknamesService
+{
+ private readonly ConcurrentDictionary _nicknames = new();
+
+ public async Task InitializeNicknamesAsync()
+ {
+ var onlinePlayers = await playerManagerService.GetOnlinePlayersAsync();
+ foreach (var player in onlinePlayers.Where(player => player.NickName != player.UbisoftName))
+ {
+ _nicknames[player.GetLogin()] = player.NickName;
+ }
+ }
+
+ public async Task FetchAndAddNicknameByLoginAsync(string login)
+ {
+ var player = await playerManagerService.GetOnlinePlayerAsync(PlayerUtils.ConvertLoginToAccountId(login));
+
+ if (player.NickName == player.UbisoftName)
+ {
+ return;
+ }
+
+ _nicknames[login] = player.NickName;
+ }
+
+ public Task> GetNicknamesAsync()
+ {
+ return Task.FromResult(_nicknames);
+ }
+
+ public Task ClearNicknamesAsync()
+ {
+ _nicknames.Clear();
+
+ return Task.CompletedTask;
+ }
+
+ public Task SendNicknamesManialinkAsync() =>
+ manialinkManager.SendPersistentManialinkAsync("ScoreboardModule.PlayerNicknames",
+ new { nicknames = ScoreboardNicknameUtils.ToManiaScriptArray(_nicknames) });
+}
diff --git a/src/Modules/ScoreboardModule/Services/ScoreboardService.cs b/src/Modules/ScoreboardModule/Services/ScoreboardService.cs
new file mode 100644
index 000000000..4b0e07ff4
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Services/ScoreboardService.cs
@@ -0,0 +1,75 @@
+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(
+ IScoreboardStateService scoreboardStateService,
+ IManialinkManager manialinks,
+ IServerClient server,
+ IScoreboardNicknamesService nicknamesService,
+ IScoreboardSettings settings,
+ IGameModeUiModuleService gameModeUiModuleService,
+ IMatchSettingsService matchSettingsService
+)
+ : IScoreboardService
+{
+ private const string ScoreboardTemplate = "ScoreboardModule.Scoreboard";
+ private const string MetaDataTemplate = "ScoreboardModule.MetaData";
+
+ public async Task SendScoreboardAsync()
+ {
+ var currentNextMaxPlayers = await server.Remote.GetMaxPlayersAsync();
+ var currentNextMaxSpectators = await server.Remote.GetMaxSpectatorsAsync();
+
+ await SendMetaDataAsync();
+ await manialinks.SendPersistentManialinkAsync(ScoreboardTemplate, new
+ {
+ settings,
+ MaxPlayers = currentNextMaxPlayers.CurrentValue + currentNextMaxSpectators.CurrentValue,
+ });
+ await nicknamesService.SendNicknamesManialinkAsync();
+ }
+
+ public async Task SendMetaDataAsync()
+ {
+ var modeScriptSettings = await matchSettingsService.GetCurrentScriptSettingsAsync();
+ int currentRoundNumber = await scoreboardStateService.GetCurrentRoundNumberAsync();
+ bool isWarmUp = await scoreboardStateService.GetIsWarmUpAsync();
+
+ await manialinks.SendPersistentManialinkAsync(MetaDataTemplate, new
+ {
+ roundNumber = currentRoundNumber,
+ isWarmUp,
+ warmUpCount = (int)(modeScriptSettings?.GetValueOrDefault("S_WarmUpNb") ?? 0),
+ roundsPerMap = (int)(modeScriptSettings?.GetValueOrDefault("S_RoundsPerMap") ?? 0),
+ pointsLimit = (int)(modeScriptSettings?.GetValueOrDefault("S_PointsLimit") ?? 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/Services/ScoreboardStateService.cs b/src/Modules/ScoreboardModule/Services/ScoreboardStateService.cs
new file mode 100644
index 000000000..c81448899
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Services/ScoreboardStateService.cs
@@ -0,0 +1,50 @@
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Modules.Official.ScoreboardModule.Interfaces;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class ScoreboardStateService : IScoreboardStateService
+{
+ private readonly object _currentRoundNumberMutex = new();
+ private readonly object _isWarmUpMutex = new();
+ private int _currentRoundNumber = 1;
+ private bool _isWarmUp;
+
+ public Task SetCurrentRoundNumberAsync(int roundNumber)
+ {
+ lock (_currentRoundNumberMutex)
+ {
+ _currentRoundNumber = roundNumber;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task GetCurrentRoundNumberAsync()
+ {
+ lock (_currentRoundNumberMutex)
+ {
+ return Task.FromResult(_currentRoundNumber);
+ }
+ }
+
+ public Task SetIsWarmUpAsync(bool isWarmUp)
+ {
+ lock (_isWarmUpMutex)
+ {
+ _isWarmUp = isWarmUp;
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public Task GetIsWarmUpAsync()
+ {
+ lock (_isWarmUpMutex)
+ {
+ return Task.FromResult(_isWarmUp);
+ }
+ }
+}
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..af21ab3d6
--- /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..3fad11939
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Header/HeaderContent.mt
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..92df8d0c6
--- /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..f11156240
--- /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..15f36768b
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/Framemodel.mt
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt
new file mode 100644
index 000000000..e2366252d
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerActions.mt
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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..a07eb4606
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/Row/PlayerRowBackground.mt
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..e20b58ad6
--- /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..d88df67e1
--- /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..94afd30df
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Components/ScoreboardHeader.mt
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/MetaData.mt b/src/Modules/ScoreboardModule/Templates/MetaData.mt
new file mode 100644
index 000000000..6426dd260
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/MetaData.mt
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
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/ScoreboardModule/Templates/RoundsInfo.mt b/src/Modules/ScoreboardModule/Templates/RoundsInfo.mt
new file mode 100644
index 000000000..46d9cdb6f
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/RoundsInfo.mt
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/ScoreboardModule/Templates/Scoreboard.mt b/src/Modules/ScoreboardModule/Templates/Scoreboard.mt
new file mode 100644
index 000000000..04a9936cd
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Scoreboard.mt
@@ -0,0 +1,534 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/ScoreboardModule/Templates/Scripts/Framemodel.ms b/src/Modules/ScoreboardModule/Templates/Scripts/Framemodel.ms
new file mode 100644
index 000000000..a66450b35
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Scripts/Framemodel.ms
@@ -0,0 +1,97 @@
+
diff --git a/src/Modules/ScoreboardModule/Templates/Scripts/Header.ms b/src/Modules/ScoreboardModule/Templates/Scripts/Header.ms
new file mode 100644
index 000000000..37a49d79c
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Scripts/Header.ms
@@ -0,0 +1,62 @@
+
diff --git a/src/Modules/ScoreboardModule/Templates/Scripts/PlayerRowBg.ms b/src/Modules/ScoreboardModule/Templates/Scripts/PlayerRowBg.ms
new file mode 100644
index 000000000..88a4e7456
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Templates/Scripts/PlayerRowBg.ms
@@ -0,0 +1,24 @@
+
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/Utils/ScoreboardNicknameUtils.cs b/src/Modules/ScoreboardModule/Utils/ScoreboardNicknameUtils.cs
new file mode 100644
index 000000000..9e864c45c
--- /dev/null
+++ b/src/Modules/ScoreboardModule/Utils/ScoreboardNicknameUtils.cs
@@ -0,0 +1,40 @@
+using System.Collections.Concurrent;
+
+namespace EvoSC.Modules.Official.ScoreboardModule.Utils;
+
+public static class ScoreboardNicknameUtils
+{
+ ///
+ /// Converts the nickname repo to a ManiaScript array.
+ ///
+ ///
+ ///
+ public static string ToManiaScriptArray(ConcurrentDictionary nicknameMap)
+ {
+ var entriesList = nicknameMap.Select(ToManiaScriptArrayEntry).ToList();
+ var joinedEntries = string.Join(",\n", entriesList);
+
+ return $"[{joinedEntries}]";
+ }
+
+ ///
+ /// Converts an entry of the nickname repo to a ManiaScript array entry.
+ ///
+ ///
+ ///
+ public static string ToManiaScriptArrayEntry(KeyValuePair loginNickname)
+ {
+ return $"\"{loginNickname.Key}\" => \"{EscapeNickname(loginNickname.Value)}\"";
+ }
+
+ ///
+ /// Escapes a nickname to be safely inserted into a XMl comment.
+ ///
+ ///
+ ///
+ public static string EscapeNickname(string nickname)
+ {
+ return nickname.Replace("-->", "-\u2192", StringComparison.OrdinalIgnoreCase)
+ .Replace("\"", "\\\"", StringComparison.OrdinalIgnoreCase);
+ }
+}
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/tests/Modules/RoundRankingModule.Tests/Controllers/RoundRankingEventControllerTests.cs b/tests/Modules/RoundRankingModule.Tests/Controllers/RoundRankingEventControllerTests.cs
new file mode 100644
index 000000000..dbe8c4e6d
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/Controllers/RoundRankingEventControllerTests.cs
@@ -0,0 +1,139 @@
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Modules.Official.RoundRankingModule.Controllers;
+using EvoSC.Modules.Official.RoundRankingModule.Interfaces;
+using EvoSC.Testing.Controllers;
+using Moq;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Tests.Controllers;
+
+public class RoundRankingEventControllerTests : ControllerMock
+{
+ private Mock _roundRankingService = new();
+ private Mock _roundRankingStateService = new();
+
+ public RoundRankingEventControllerTests()
+ {
+ InitMock(_roundRankingService, _roundRankingStateService);
+ }
+
+ [Fact]
+ public async Task Consumes_Checkpoint_Data_On_Waypoint_Event()
+ {
+ const string AccountId = "*fakeplayer1*";
+ var waypointEventArgs = new WayPointEventArgs
+ {
+ Login = AccountId,
+ AccountId = AccountId,
+ RaceTime = 1234,
+ LapTime = 1234,
+ CheckpointInRace = 1,
+ CheckpointInLap = 1,
+ IsEndRace = false,
+ IsEndLap = false,
+ CurrentRaceCheckpoints = [],
+ CurrentLapCheckpoints = [],
+ BlockId = "",
+ Speed = 0,
+ Time = 0
+ };
+
+ await Controller.OnWaypointAsync(null, waypointEventArgs);
+
+ _roundRankingService.Verify(rrs => rrs.ConsumeCheckpointAsync(
+ AccountId,
+ 1,
+ 1234,
+ false,
+ false
+ ), Times.Once());
+ }
+
+ [Fact]
+ public async Task Creates_Dnf_Entry_On_Player_Give_Up()
+ {
+ const string AccountId = "*fakeplayer1*";
+ var playerGiveUpEventArgs = new PlayerUpdateEventArgs { Login = AccountId, AccountId = AccountId, Time = 0 };
+
+ await Controller.OnPlayerGiveUpAsync(null, playerGiveUpEventArgs);
+
+ _roundRankingService.Verify(rrs => rrs.ConsumeDnfAsync(AccountId), Times.Once());
+ }
+
+ [Fact]
+ public async Task Clears_Checkpoints_Repository_On_Round_End()
+ {
+ await Controller.OnEndRoundAsync(null, EventArgs.Empty);
+
+ _roundRankingService.Verify(rrs => rrs.ClearCheckpointDataAsync(), Times.Once());
+ }
+
+ [Fact]
+ public async Task Clears_Checkpoints_Repository_On_Warmup_Round_End()
+ {
+ await Controller.OnWarmUpEndRoundAsync(null, new WarmUpRoundEventArgs { Current = 0, Total = 0 });
+
+ _roundRankingService.Verify(rrs => rrs.ClearCheckpointDataAsync(), Times.Once());
+ }
+
+ [Fact]
+ public async Task Clears_Checkpoints_Repository_On_Start_Match()
+ {
+ await Controller.OnStartMatchAsync(null, new MatchEventArgs());
+
+ _roundRankingService.Verify(rrs => rrs.ClearCheckpointDataAsync(), Times.Once());
+ }
+
+ [Fact]
+ public async Task Removes_Player_Checkpoint_Data_When_They_Start_Driving()
+ {
+ await Controller.OnStartLineAsync(null,
+ new PlayerUpdateEventArgs { Login = "*fakeplayer1*", AccountId = "*fakeplayer1*", Time = 0 });
+
+ _roundRankingService.Verify(rrs => rrs.RemovePlayerCheckpointDataAsync("*fakeplayer1*"), Times.Once());
+ }
+
+ [Fact]
+ public async Task Removes_Checkpoint_Data_Of_Player_On_Respawn()
+ {
+ await Controller.OnRespawnAsync(null,
+ new PlayerUpdateEventArgs { Login = "*fakeplayer1*", AccountId = "*fakeplayer1*", Time = 0 });
+
+ _roundRankingService.Verify(rrs => rrs.RemovePlayerCheckpointDataAsync("*fakeplayer1*"), Times.Once());
+ }
+
+ [Fact]
+ public async Task Hides_Widget_On_Podium_Start()
+ {
+ await Controller.OnPodiumStartAsync(null, EventArgs.Empty);
+
+ _roundRankingService.Verify(rrs => rrs.HideRoundRankingWidgetAsync(), Times.Once());
+ }
+
+ [Fact]
+ public async Task Sets_Up_Environment_On_Map_Start()
+ {
+ await Controller.OnStartMapAsync(null, EventArgs.Empty);
+
+ _roundRankingService.Verify(rrs => rrs.DetectAndSetIsTeamsModeAsync(), Times.Once());
+ _roundRankingService.Verify(rrs => rrs.FetchAndCacheTeamInfoAsync(), Times.Once());
+ _roundRankingService.Verify(rrs => rrs.LoadPointsRepartitionFromSettingsAsync(), Times.Once());
+ _roundRankingService.Verify(rrs => rrs.ClearCheckpointDataAsync(), Times.Once());
+ }
+
+ [Fact]
+ public async Task Sets_Service_To_TimeAttack_Mode_During_Warm_Ups()
+ {
+ await Controller.OnWarmUpStartAsync(null, EventArgs.Empty);
+
+ _roundRankingStateService.Verify(rrs => rrs.SetIsTimeAttackModeAsync(true), Times.Once());
+ }
+
+ [Fact]
+ public async Task Disables_TimeAttack_Mode_After_Warm_Up()
+ {
+ await Controller.OnWarmUpEndAsync(null, EventArgs.Empty);
+
+ _roundRankingStateService.Verify(rrs => rrs.SetIsTimeAttackModeAsync(false), Times.Once());
+ }
+}
diff --git a/tests/Modules/RoundRankingModule.Tests/GlobalUsings.cs b/tests/Modules/RoundRankingModule.Tests/GlobalUsings.cs
new file mode 100644
index 000000000..9df1d4217
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/Modules/RoundRankingModule.Tests/Models/CheckpointDataTests.cs b/tests/Modules/RoundRankingModule.Tests/Models/CheckpointDataTests.cs
new file mode 100644
index 000000000..106134dd1
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/Models/CheckpointDataTests.cs
@@ -0,0 +1,129 @@
+using EvoSC.Common.Interfaces.Models.Enums;
+using EvoSC.Common.Models.Players;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Common.Util;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Tests.Models;
+
+public class CheckpointDataTests
+{
+ [Theory]
+ [InlineData("*fakeplayer1*", 1, 0, false)]
+ [InlineData("*fakeplayer2*", 2, 10, false)]
+ [InlineData("*fakeplayer3*", 3, 100, false)]
+ [InlineData("*fakeplayer4*", 4, 1000, true)]
+ public void Converts_Waypoint_Event_Args_To_Checkpoint_Data(string accountId, int cpId, int time,
+ bool isEndRace)
+ {
+ var player = new OnlinePlayer { State = PlayerState.Playing, AccountId = accountId };
+
+ var waypointEventArgs = new WayPointEventArgs
+ {
+ Login = player.GetLogin(),
+ AccountId = player.AccountId,
+ RaceTime = time,
+ LapTime = time,
+ CheckpointInRace = cpId,
+ CheckpointInLap = cpId,
+ IsEndRace = isEndRace,
+ IsEndLap = false,
+ CurrentRaceCheckpoints = [],
+ CurrentLapCheckpoints = [],
+ BlockId = "",
+ Speed = 0,
+ Time = 0
+ };
+
+ var checkpointData = CheckpointData.FromWaypointEventArgs(player, waypointEventArgs);
+
+ Assert.Equal(player, checkpointData.Player);
+ Assert.Equal(time, checkpointData.Time.TotalMilliseconds);
+ Assert.Equal(cpId, checkpointData.CheckpointId);
+ Assert.False(checkpointData.IsFinish);
+ Assert.False(checkpointData.IsDNF);
+ }
+
+ [Fact]
+ public void Creates_DNF_Entry()
+ {
+ var player = new OnlinePlayer { State = PlayerState.Playing, AccountId = "*fakeplayer1*" };
+ var checkpointData = CheckpointData.CreateDnfEntry(player);
+
+ Assert.Equal(player, checkpointData.Player);
+ Assert.Equal(0, checkpointData.Time.TotalMilliseconds);
+ Assert.Equal(-1, checkpointData.CheckpointId);
+ Assert.False(checkpointData.IsFinish);
+ Assert.True(checkpointData.IsDNF);
+ }
+
+ [Theory]
+ [InlineData(1000, 1234, 234)]
+ [InlineData(1234, 1000, 234)]
+ [InlineData(-100, 100, 200)]
+ [InlineData(-100, -20, 80)]
+ public void Calculates_Absolute_Time_Difference(int time1, int time2, int expectedDifference)
+ {
+ var cpData1 = new CheckpointData
+ {
+ Player = new OnlinePlayer { State = PlayerState.Playing, AccountId = "*fakeplayer1*" },
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(time1),
+ IsFinish = false,
+ IsDNF = false
+ };
+ var cpData2 = new CheckpointData
+ {
+ Player = new OnlinePlayer { State = PlayerState.Playing, AccountId = "*fakeplayer2*" },
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(time2),
+ IsFinish = false,
+ IsDNF = false
+ };
+
+ Assert.Equal(expectedDifference, cpData1.GetTimeDifferenceAbsolute(cpData2).TotalMilliseconds);
+ }
+
+ [Theory]
+ [InlineData(0, 1234, false, false, "1")]
+ [InlineData(2, 1234, false, false, "3")]
+ [InlineData(4, 1234, true, false, "")] //GameIcons.Icons.FlagO
+ [InlineData(6, 1234, true, true, "")] //GameIcons.Icons.FlagO
+ [InlineData(8, 1234, false, true, "")] //GameIcons.Icons.FlagCheckered
+ public void Return_Correct_Index_Text(int checkpointId, int time, bool isDnf, bool isFinish,
+ string expectedText)
+ {
+ var cpData = new CheckpointData
+ {
+ Player = new OnlinePlayer { State = PlayerState.Playing, AccountId = "*fakeplayer1*" },
+ CheckpointId = checkpointId,
+ Time = RaceTime.FromMilliseconds(time),
+ IsFinish = isFinish,
+ IsDNF = isDnf
+ };
+
+ Assert.Equal(expectedText, cpData.IndexText());
+ }
+
+ [Theory]
+ [InlineData(500, null, false, ".500")]
+ [InlineData(1000, null, false, "1.000")]
+ [InlineData(10_123, null, false, "10.123")]
+ [InlineData(0, null, true, "DNF")]
+ [InlineData(1000, 500, false, ".500")]
+ [InlineData(1234, 999, false, ".999")]
+ public void Formats_Time_Correctly(int time, int? timeDifference, bool isDnf, string expectedText)
+ {
+ var cpData = new CheckpointData
+ {
+ Player = new OnlinePlayer { State = PlayerState.Playing, AccountId = "*fakeplayer1*" },
+ CheckpointId = 0,
+ Time = RaceTime.FromMilliseconds(time),
+ TimeDifference = timeDifference != null ? RaceTime.FromMilliseconds((int)timeDifference) : null,
+ IsFinish = false,
+ IsDNF = isDnf
+ };
+
+ Assert.Equal(expectedText, cpData.FormattedTime());
+ }
+}
diff --git a/tests/Modules/RoundRankingModule.Tests/Models/CheckpointsRepositoryTests.cs b/tests/Modules/RoundRankingModule.Tests/Models/CheckpointsRepositoryTests.cs
new file mode 100644
index 000000000..fe0b679eb
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/Models/CheckpointsRepositoryTests.cs
@@ -0,0 +1,45 @@
+using EvoSC.Common.Interfaces.Models.Enums;
+using EvoSC.Common.Models.Players;
+using EvoSC.Common.Util;
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Tests.Models;
+
+public class CheckpointsRepositoryTests
+{
+ private static CheckpointData CreateFakeCheckpointData(string accountId, int cpId, int time)
+ {
+ return new CheckpointData
+ {
+ Player = new OnlinePlayer { State = PlayerState.Playing, AccountId = accountId },
+ CheckpointId = cpId,
+ Time = RaceTime.FromMilliseconds(time),
+ IsFinish = false,
+ IsDNF = cpId == -1
+ };
+ }
+
+ [Fact]
+ public void Sorts_Entries_Correctly()
+ {
+ var cpRepository = new CheckpointsRepository
+ {
+ ["*fakeplayer1*"] = CreateFakeCheckpointData("*fakeplayer1*", 2, 500), // 2.
+ ["*fakeplayer2*"] = CreateFakeCheckpointData("*fakeplayer2*", 2, 250), // 1.
+ ["*fakeplayer3*"] = CreateFakeCheckpointData("*fakeplayer3*", 1, 1200), // 4.
+ ["*fakeplayer4*"] = CreateFakeCheckpointData("*fakeplayer4*", 1, 1000), // 3.
+ ["*fakeplayer5*"] = CreateFakeCheckpointData("*fakeplayer5*", 1, 1400), // 5.
+ ["*fakeplayer6*"] = CreateFakeCheckpointData("*fakeplayer6*", -1, 2000), // 6.
+ ["*fakeplayer7*"] = CreateFakeCheckpointData("*fakeplayer7*", -1, 2500), // 7.
+ };
+
+ var sorted = cpRepository.GetSortedData();
+ Assert.Equal("*fakeplayer1*", sorted[1].Player.AccountId);
+ Assert.Equal("*fakeplayer2*", sorted[0].Player.AccountId);
+ Assert.Equal("*fakeplayer3*", sorted[3].Player.AccountId);
+ Assert.Equal("*fakeplayer4*", sorted[2].Player.AccountId);
+ Assert.Equal("*fakeplayer5*", sorted[4].Player.AccountId);
+ Assert.Equal("*fakeplayer6*", sorted[5].Player.AccountId);
+ Assert.Equal("*fakeplayer7*", sorted[6].Player.AccountId);
+ }
+}
diff --git a/tests/Modules/RoundRankingModule.Tests/Models/PointsRepartitionTests.cs b/tests/Modules/RoundRankingModule.Tests/Models/PointsRepartitionTests.cs
new file mode 100644
index 000000000..86ef0f160
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/Models/PointsRepartitionTests.cs
@@ -0,0 +1,42 @@
+using EvoSC.Modules.Official.RoundRankingModule.Models;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Tests.Models;
+
+public class PointsRepartitionTests
+{
+ [Theory]
+ [InlineData("1,2,3,4,5,6,7", new[] { 1, 2, 3, 4, 5, 6, 7 })]
+ [InlineData("10,6,4,3,2,1", new[] { 10, 6, 4, 3, 2, 1 })]
+ [InlineData("0, 0, 1, 2, -4, -5", new[] { 0, 0, 1, 2, -4, -5 })]
+ public void Sets_Points_From_String(string pointsRepartition, int[] expectedPoints)
+ {
+ Assert.Equal(expectedPoints, new PointsRepartition(pointsRepartition).ToArray());
+ }
+
+ [Fact]
+ public void Initializes_PointsRepartition_With_Default()
+ {
+ var expected = PointsRepartition.DefaultValue.Split(',').Select(int.Parse).ToList();
+
+ Assert.Equal(expected, new PointsRepartition());
+ }
+
+ [Theory]
+ [InlineData("10,6,4,3,2,1", 1, 10)]
+ [InlineData("10,6,4,3,2,1", 2, 6)]
+ [InlineData("10,6,4,3,2,1", 3, 4)]
+ [InlineData("10,6,4,3,2,1", 4, 3)]
+ [InlineData("10,6,4,3,2,1", 5, 2)]
+ [InlineData("10,6,4,3,2,1", 6, 1)]
+ [InlineData("10,6,4,3,2,1", 7, 1)]
+ [InlineData("-1,-2,0,7,3", 1, -1)]
+ [InlineData("-1,-2,0,7,3", 2, -2)]
+ [InlineData("-1,-2,0,7,3", 3, 0)]
+ [InlineData("-1,-2,0,7,3", 4, 7)]
+ [InlineData("-1,-2,0,7,3", 5, 3)]
+ [InlineData("-1,-2,0,7,3", 6, 3)]
+ public void Gets_Gained_Points_Correctly(string pointsRepartition, int rank, int expectedGainedPoints)
+ {
+ Assert.Equal(expectedGainedPoints, new PointsRepartition(pointsRepartition).GetGainedPoints(rank));
+ }
+}
diff --git a/tests/Modules/RoundRankingModule.Tests/RoundRankingModule.Tests.csproj b/tests/Modules/RoundRankingModule.Tests/RoundRankingModule.Tests.csproj
new file mode 100644
index 000000000..dfaca1f23
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/RoundRankingModule.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.Official.RoundRankingModule.Tests
+
+
+
+
+
+
+
+
+
+ ..\..\..\..\..\.nuget\packages\xunit.extensibility.core\2.8.1\lib\netstandard1.1\xunit.core.dll
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
diff --git a/tests/Modules/RoundRankingModule.Tests/Services/RoundRankingServiceTests.cs b/tests/Modules/RoundRankingModule.Tests/Services/RoundRankingServiceTests.cs
new file mode 100644
index 000000000..b45acada4
--- /dev/null
+++ b/tests/Modules/RoundRankingModule.Tests/Services/RoundRankingServiceTests.cs
@@ -0,0 +1,173 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Interfaces.Services;
+using EvoSC.Common.Interfaces.Themes;
+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.Services;
+using EvoSC.Testing;
+using GbxRemoteNet.Interfaces;
+using Moq;
+
+namespace EvoSC.Modules.Official.RoundRankingModule.Tests.Services;
+
+public class RoundRankingServiceTests
+{
+ private readonly Mock _settings = new();
+ private readonly Mock _manialinkManager = new();
+ private readonly Mock _playerManagerService = new();
+ private readonly Mock _matchSettingsService = new();
+ private readonly Mock _theme = new();
+
+ private readonly (Mock Client, Mock Remote, Mock Chat) _server =
+ Mocking.NewServerClientMock();
+
+ private IRoundRankingService RoundRankingServiceMock()
+ {
+ return new RoundRankingService(
+ new RoundRankingStateService(),
+ _settings.Object,
+ _manialinkManager.Object,
+ _playerManagerService.Object,
+ _matchSettingsService.Object,
+ _theme.Object,
+ _server.Client.Object
+ );
+ }
+
+ [Fact]
+ public async Task Adds_Checkpoint_Data_To_Repository()
+ {
+ var roundRankingService = RoundRankingServiceMock();
+
+ await roundRankingService.ConsumeCheckpointAsync("*fakeplayer1*", 1, 1000, true, false);
+ await roundRankingService.ConsumeCheckpointAsync("*fakeplayer2*", 2, 1200, true, false);
+ await roundRankingService.ConsumeCheckpointAsync("*fakeplayer3*", 3, 1300, true, false);
+
+ var sortedCheckpoints = await roundRankingService.GetSortedCheckpointsAsync();
+
+ Assert.Equal(3, sortedCheckpoints.Count);
+ Assert.Equal(3, sortedCheckpoints.First().CheckpointId);
+ Assert.Equal(1300, sortedCheckpoints.First().Time.TotalMilliseconds);
+
+ _manialinkManager.Verify(mlm =>
+ mlm.SendPersistentManialinkAsync("RoundRankingModule.RoundRanking", It.IsAny