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 @@ + + + +