diff --git a/EvoSC.sln b/EvoSC.sln
index e68f57b82..d4eb28d4b 100644
--- a/EvoSC.sln
+++ b/EvoSC.sln
@@ -118,11 +118,15 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocalRecordsModule.Tests",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ForceTeamModule", "src/Modules/ForceTeamModule/ForceTeamModule.csproj", "{ACFF2B23-0518-493C-93A0-08CF9FD4F8E6}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ForceTeamModule.Tests", "tests\Modules\ForceTeamModule.Tests\ForceTeamModule.Tests.csproj", "{DE9532E8-F0ED-400B-9592-AF8F74BC83EB}"
+EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamSettingsModule", "src\Modules\TeamSettingsModule\TeamSettingsModule.csproj", "{C10A11E5-4AD8-4229-81F1-57D7DC364E27}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamSettingsModule.Tests", "tests\Modules\TeamSettingsModule.Tests\TeamSettingsModule.Tests.csproj", "{B1745099-0081-4443-BF79-26A888765E16}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ForceTeamModule.Tests", "tests\Modules\ForceTeamModule.Tests\ForceTeamModule.Tests.csproj", "{DE9532E8-F0ED-400B-9592-AF8F74BC83EB}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamInfoModule", "src\Modules\TeamInfoModule\TeamInfoModule.csproj", "{5E178B0D-5F29-4990-8DCF-DC7FF113C4F0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamInfoModule.Tests", "tests\Modules\TeamInfoModule.Tests\TeamInfoModule.Tests.csproj", "{71D8BDA5-6787-471A-8064-CEEE4203EBEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ServerManagementModule", "src/Modules/ServerManagementModule/ServerManagementModule.csproj", "{AAE1AC39-4C4B-48A9-8B7B-08D2CD461FC1}"
EndProject
@@ -379,6 +383,14 @@ Global
{F8C7FE5E-B389-4BA8-B0DF-6D9D0A9B0949}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F8C7FE5E-B389-4BA8-B0DF-6D9D0A9B0949}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F8C7FE5E-B389-4BA8-B0DF-6D9D0A9B0949}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5E178B0D-5F29-4990-8DCF-DC7FF113C4F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5E178B0D-5F29-4990-8DCF-DC7FF113C4F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5E178B0D-5F29-4990-8DCF-DC7FF113C4F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5E178B0D-5F29-4990-8DCF-DC7FF113C4F0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {71D8BDA5-6787-471A-8064-CEEE4203EBEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {71D8BDA5-6787-471A-8064-CEEE4203EBEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {71D8BDA5-6787-471A-8064-CEEE4203EBEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {71D8BDA5-6787-471A-8064-CEEE4203EBEF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -433,9 +445,11 @@ Global
{098D1F9B-054D-4158-BB6C-AC908C4595F6} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
{1D8DBFC8-EC21-4DDA-9D88-DE7CA04C8449} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{7401429B-B842-4316-B7A2-B77E9AD966CB} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
- {ACFF2B23-0518-493C-93A0-08CF9FD4F8E6} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{C10A11E5-4AD8-4229-81F1-57D7DC364E27} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{B1745099-0081-4443-BF79-26A888765E16} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
+ {5E178B0D-5F29-4990-8DCF-DC7FF113C4F0} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
+ {71D8BDA5-6787-471A-8064-CEEE4203EBEF} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
+ {ACFF2B23-0518-493C-93A0-08CF9FD4F8E6} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{DE9532E8-F0ED-400B-9592-AF8F74BC83EB} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
{AAE1AC39-4C4B-48A9-8B7B-08D2CD461FC1} = {DC47658A-F421-4BA4-B617-090A7DFB3900}
{F8C7FE5E-B389-4BA8-B0DF-6D9D0A9B0949} = {6D75D6A2-6ECD-4DE4-96C5-CAD7D134407A}
diff --git a/src/EvoSC/EvoSC.csproj b/src/EvoSC/EvoSC.csproj
index f805432f5..89c709a3e 100644
--- a/src/EvoSC/EvoSC.csproj
+++ b/src/EvoSC/EvoSC.csproj
@@ -50,6 +50,7 @@
+
@@ -69,5 +70,4 @@
-
diff --git a/src/EvoSC/InternalModules.cs b/src/EvoSC/InternalModules.cs
index 524043756..b0ee64b76 100644
--- a/src/EvoSC/InternalModules.cs
+++ b/src/EvoSC/InternalModules.cs
@@ -23,6 +23,7 @@
using EvoSC.Modules.Official.ServerManagementModule;
using EvoSC.Modules.Official.SetName;
using EvoSC.Modules.Official.SpectatorTargetInfoModule;
+using EvoSC.Modules.Official.TeamInfoModule;
using EvoSC.Modules.Official.TeamSettingsModule;
using EvoSC.Modules.Official.WorldRecordModule;
using FluentMigrator.Runner.Exceptions;
@@ -57,7 +58,8 @@ public static class InternalModules
typeof(LocalRecordsModule),
typeof(ForceTeamModule),
typeof(TeamSettingsModule),
- typeof(ServerManagementModule)
+ typeof(ServerManagementModule),
+ typeof(TeamInfoModule)
];
///
diff --git a/src/Modules/TeamInfoModule/Config/ITeamInfoSettings.cs b/src/Modules/TeamInfoModule/Config/ITeamInfoSettings.cs
new file mode 100644
index 000000000..2e9293ed7
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Config/ITeamInfoSettings.cs
@@ -0,0 +1,18 @@
+using System.ComponentModel;
+using Config.Net;
+using EvoSC.Modules.Attributes;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Config;
+
+[Settings]
+public interface ITeamInfoSettings
+{
+ [Option(DefaultValue = 0.0), Description("Specifies the horizontal center position of the widget.")]
+ public double X { get; set; }
+
+ [Option(DefaultValue = 80.0), Description("Specifies the vertical top edge position of the wigdet. Values from -90 to 90 allowed.")]
+ public double Y { get; set; }
+
+ [Option(DefaultValue = false), Description("If enabled a smaller version of the widget is shown, without the large team name.")]
+ public bool CompactMode { get; set; }
+}
diff --git a/src/Modules/TeamInfoModule/Controllers/TeamInfoEventController.cs b/src/Modules/TeamInfoModule/Controllers/TeamInfoEventController.cs
new file mode 100644
index 000000000..cd0032f07
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Controllers/TeamInfoEventController.cs
@@ -0,0 +1,81 @@
+using EvoSC.Common.Controllers;
+using EvoSC.Common.Controllers.Attributes;
+using EvoSC.Common.Events.Attributes;
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Models;
+using EvoSC.Common.Remote;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+using GbxRemoteNet.Events;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Controllers;
+
+[Controller]
+public class TeamInfoEventController(ITeamInfoService teamInfoService) : EvoScController
+{
+ [Subscribe(ModeScriptEvent.Scores)]
+ public async Task OnScoresAsync(object sender, ScoresEventArgs eventArgs)
+ {
+ var isTeamsModeActive = await teamInfoService.GetModeIsTeamsAsync();
+
+ if (!eventArgs.UseTeams)
+ {
+ if (!isTeamsModeActive)
+ {
+ return;
+ }
+
+ await teamInfoService.SetModeIsTeamsAsync(false);
+ await teamInfoService.HideTeamInfoWidgetEveryoneAsync();
+
+ return;
+ }
+
+ if (!isTeamsModeActive)
+ {
+ await teamInfoService.SetModeIsTeamsAsync(true);
+ }
+
+ var teamInfos = eventArgs.Teams.ToList();
+ var team1Points = teamInfos[0]!.MatchPoints;
+ var team2Points = teamInfos[1]!.MatchPoints;
+
+ if (eventArgs.Section is ModeScriptSection.EndRound or ModeScriptSection.Undefined)
+ {
+ await teamInfoService.UpdatePointsAsync(team1Points, team2Points);
+ }
+ }
+
+ [Subscribe(ModeScriptEvent.StartRoundStart)]
+ public async Task OnRoundStartAsync(object sender, RoundEventArgs args)
+ {
+ if (!await teamInfoService.GetModeIsTeamsAsync())
+ {
+ return;
+ }
+
+ await teamInfoService.UpdateRoundNumberAsync(args.Count);
+ }
+
+ [Subscribe(ModeScriptEvent.PodiumStart)]
+ public async Task OnPodiumStartAsync(object sender, PodiumEventArgs args)
+ {
+ if (!await teamInfoService.GetModeIsTeamsAsync())
+ {
+ return;
+ }
+
+ await teamInfoService.HideTeamInfoWidgetEveryoneAsync();
+ }
+
+ [Subscribe(GbxRemoteEvent.EndMap)]
+ public async Task OnEndMapAsync(object sender, MapGbxEventArgs args)
+ {
+ if (!await teamInfoService.GetModeIsTeamsAsync())
+ {
+ return;
+ }
+
+ await teamInfoService.HideTeamInfoWidgetEveryoneAsync();
+ }
+}
diff --git a/src/Modules/TeamInfoModule/Interfaces/ITeamInfoService.cs b/src/Modules/TeamInfoModule/Interfaces/ITeamInfoService.cs
new file mode 100644
index 000000000..7c9dd524b
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Interfaces/ITeamInfoService.cs
@@ -0,0 +1,81 @@
+using EvoSC.Common.Util.MatchSettings.Models.ModeScriptSettingsModels;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+
+public interface ITeamInfoService
+{
+ ///
+ /// Triggers detection if team mode is active.
+ ///
+ ///
+ public Task InitializeModuleAsync();
+
+ ///
+ /// Gets the current mode script settings for teams mode.
+ ///
+ ///
+ public Task GetModeScriptTeamSettingsAsync();
+
+ ///
+ /// Get the text displayed at the bottom of the team info widget.
+ ///
+ ///
+ ///
+ public Task GetInfoBoxTextAsync(TeamsModeScriptSettings modeScriptTeamSettings);
+
+ ///
+ /// Gets all necessary data for the widget.
+ ///
+ ///
+ public Task GetWidgetDataAsync();
+
+ ///
+ /// Determines whether a team has match point.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public Task DoesTeamHaveMatchPointAsync(int teamPoints, int opponentPoints, int? pointsLimit, int? pointsGap);
+
+ ///
+ /// Sends the team info widget to all players.
+ ///
+ ///
+ public Task SendTeamInfoWidgetEveryoneAsync();
+
+ ///
+ /// Hides the team info widget for all players.
+ ///
+ ///
+ public Task HideTeamInfoWidgetEveryoneAsync();
+
+ ///
+ /// Sets the current round number.
+ ///
+ ///
+ ///
+ public Task UpdateRoundNumberAsync(int round);
+
+ ///
+ /// Updates the points for both teams.
+ ///
+ ///
+ ///
+ ///
+ public Task UpdatePointsAsync(int team1Points, int team2Points);
+
+ ///
+ /// Tells whether the service is currently active for teams mode.
+ ///
+ ///
+ public Task GetModeIsTeamsAsync();
+
+ ///
+ /// Sets whether the service is active for teams mode or not.
+ ///
+ ///
+ ///
+ public Task SetModeIsTeamsAsync(bool modeIsTeams);
+}
diff --git a/src/Modules/TeamInfoModule/Localization.resx b/src/Modules/TeamInfoModule/Localization.resx
new file mode 100644
index 000000000..02cf34b4b
--- /dev/null
+++ b/src/Modules/TeamInfoModule/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/TeamInfoModule/Services/TeamInfoService.cs b/src/Modules/TeamInfoModule/Services/TeamInfoService.cs
new file mode 100644
index 000000000..9bc9d02f4
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Services/TeamInfoService.cs
@@ -0,0 +1,146 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Services.Attributes;
+using EvoSC.Common.Services.Models;
+using EvoSC.Common.Util.MatchSettings.Models.ModeScriptSettingsModels;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Official.TeamInfoModule.Config;
+using EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+using LinqToDB.Common;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Services;
+
+[Service(LifeStyle = ServiceLifeStyle.Singleton)]
+public class TeamInfoService(
+ IServerClient server,
+ IManialinkManager manialinks,
+ ITeamInfoSettings settings
+) : ITeamInfoService
+{
+ private const string WidgetTemplate = "TeamInfoModule.TeamInfoWidget";
+
+ private bool _modeIsTeams;
+ private int _currentRound;
+ private int _team1Points;
+ private int _team2Points;
+
+ public async Task InitializeModuleAsync()
+ {
+ await server.Remote.TriggerModeScriptEventArrayAsync("Trackmania.GetScores");
+ }
+
+ public async Task GetWidgetDataAsync()
+ {
+ var modeScriptSettings = await GetModeScriptTeamSettingsAsync();
+ var infoBoxText = await GetInfoBoxTextAsync(modeScriptSettings);
+ var team1 = await server.Remote.GetTeamInfoAsync(1);
+ var team2 = await server.Remote.GetTeamInfoAsync(2);
+ var team1MatchPoint = await DoesTeamHaveMatchPointAsync(_team1Points, _team2Points,
+ modeScriptSettings.PointsLimit,
+ modeScriptSettings.PointsGap);
+ var team2MatchPoint = await DoesTeamHaveMatchPointAsync(_team2Points, _team1Points,
+ modeScriptSettings.PointsLimit,
+ modeScriptSettings.PointsGap);
+
+ return new
+ {
+ team1,
+ team2,
+ infoBoxText,
+ team1MatchPoint,
+ team2MatchPoint,
+ settings,
+ roundNumber = _currentRound,
+ team1Points = _team1Points,
+ team2Points = _team2Points,
+ neutralEmblemUrl = modeScriptSettings.NeutralEmblemUrl
+ };
+ }
+
+ public async Task GetModeScriptTeamSettingsAsync()
+ {
+ var modeScriptSettings = await server.Remote.GetModeScriptSettingsAsync();
+
+ return new TeamsModeScriptSettings
+ {
+ PointsLimit = (int)modeScriptSettings.GetValueOrDefault("S_PointsLimit", 5),
+ FinishTimeout = (int)modeScriptSettings.GetValueOrDefault("S_FinishTimeout", -1),
+ MaxPointsPerRound = (int)modeScriptSettings.GetValueOrDefault("S_MaxPointsPerRound", 6),
+ PointsGap = (int)modeScriptSettings.GetValueOrDefault("S_PointsGap", 1),
+ RoundsPerMap = (int)modeScriptSettings.GetValueOrDefault("S_RoundsPerMap", -1),
+ MapsPerMatch = (int)modeScriptSettings.GetValueOrDefault("S_MapsPerMatch", -1),
+ NeutralEmblemUrl = (string)modeScriptSettings.GetValueOrDefault("S_NeutralEmblemUrl", "")
+ };
+ }
+
+ public Task GetInfoBoxTextAsync(TeamsModeScriptSettings modeScriptTeamSettings)
+ {
+ var infoBoxText = new List();
+
+ //Add point limit and gap
+ if (modeScriptTeamSettings.PointsLimit > 0)
+ {
+ infoBoxText.Add("FIRST TO " + modeScriptTeamSettings.PointsLimit);
+
+ if (modeScriptTeamSettings.PointsGap > 1)
+ {
+ infoBoxText.Add($"GAP {modeScriptTeamSettings.PointsGap}");
+ }
+ }
+
+ //Add rounds per map
+ if (modeScriptTeamSettings.RoundsPerMap > 0)
+ {
+ infoBoxText.Add($"{modeScriptTeamSettings.RoundsPerMap} ROUNDS/MAP");
+ }
+
+ return Task.FromResult(infoBoxText.IsNullOrEmpty() ? null : string.Join(" | ", infoBoxText));
+ }
+
+ public Task DoesTeamHaveMatchPointAsync(int teamPoints, int opponentPoints, int? pointsLimit, int? pointsGap)
+ {
+ if (pointsLimit == null)
+ {
+ return Task.FromResult(false);
+ }
+
+ pointsGap ??= 1;
+
+ return Task.FromResult(teamPoints >= pointsLimit - 1 &&
+ teamPoints - (pointsGap - 1) >= opponentPoints);
+ }
+
+ public async Task SendTeamInfoWidgetEveryoneAsync()
+ {
+ await manialinks.SendPersistentManialinkAsync(WidgetTemplate, await GetWidgetDataAsync());
+ }
+
+ public async Task HideTeamInfoWidgetEveryoneAsync()
+ {
+ await manialinks.HideManialinkAsync(WidgetTemplate);
+ }
+
+ public async Task UpdateRoundNumberAsync(int round)
+ {
+ _currentRound = round;
+ await SendTeamInfoWidgetEveryoneAsync();
+ }
+
+ public async Task UpdatePointsAsync(int team1Points, int team2Points)
+ {
+ _team1Points = team1Points;
+ _team2Points = team2Points;
+ await SendTeamInfoWidgetEveryoneAsync();
+ }
+
+ public Task GetModeIsTeamsAsync()
+ {
+ return Task.FromResult(_modeIsTeams);
+ }
+
+ public Task SetModeIsTeamsAsync(bool modeIsTeams)
+ {
+ _modeIsTeams = modeIsTeams;
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/TeamInfoModule/TeamInfoModule.cs b/src/Modules/TeamInfoModule/TeamInfoModule.cs
new file mode 100644
index 000000000..6a068e297
--- /dev/null
+++ b/src/Modules/TeamInfoModule/TeamInfoModule.cs
@@ -0,0 +1,13 @@
+using EvoSC.Modules.Attributes;
+using EvoSC.Modules.Interfaces;
+using EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+
+namespace EvoSC.Modules.Official.TeamInfoModule;
+
+[Module(IsInternal = true)]
+public class TeamInfoModule(ITeamInfoService teamInfoService) : EvoScModule, IToggleable
+{
+ public Task EnableAsync() => teamInfoService.InitializeModuleAsync();
+
+ public Task DisableAsync() => Task.CompletedTask;
+}
diff --git a/src/Modules/TeamInfoModule/TeamInfoModule.csproj b/src/Modules/TeamInfoModule/TeamInfoModule.csproj
new file mode 100644
index 000000000..2d4a34b53
--- /dev/null
+++ b/src/Modules/TeamInfoModule/TeamInfoModule.csproj
@@ -0,0 +1,20 @@
+
+
+
+ net8.0
+ enable
+ enable
+ EvoSC.Modules.Official.TeamInfoModule
+
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Localization.Designer.cs
+
+
+
diff --git a/src/Modules/TeamInfoModule/Templates/Components/BottomInfoBox.mt b/src/Modules/TeamInfoModule/Templates/Components/BottomInfoBox.mt
new file mode 100644
index 000000000..5006b9c54
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/BottomInfoBox.mt
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/EmblemBox.mt b/src/Modules/TeamInfoModule/Templates/Components/EmblemBox.mt
new file mode 100644
index 000000000..ca0ed89ad
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/EmblemBox.mt
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/GainedPoints.mt b/src/Modules/TeamInfoModule/Templates/Components/GainedPoints.mt
new file mode 100644
index 000000000..3c5c2a287
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/GainedPoints.mt
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/MatchPointBox.mt b/src/Modules/TeamInfoModule/Templates/Components/MatchPointBox.mt
new file mode 100644
index 000000000..b8ea8a044
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/MatchPointBox.mt
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/PointsBox.mt b/src/Modules/TeamInfoModule/Templates/Components/PointsBox.mt
new file mode 100644
index 000000000..d6534cbf8
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/PointsBox.mt
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/RoundCounter.mt b/src/Modules/TeamInfoModule/Templates/Components/RoundCounter.mt
new file mode 100644
index 000000000..78d7e3ca4
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/RoundCounter.mt
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/Components/TeamFullName.mt b/src/Modules/TeamInfoModule/Templates/Components/TeamFullName.mt
new file mode 100644
index 000000000..8c64165a1
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/Components/TeamFullName.mt
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Modules/TeamInfoModule/Templates/TeamInfoWidget.mt b/src/Modules/TeamInfoModule/Templates/TeamInfoWidget.mt
new file mode 100644
index 000000000..1207bd0b6
--- /dev/null
+++ b/src/Modules/TeamInfoModule/Templates/TeamInfoWidget.mt
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/TeamInfoModule/info.toml b/src/Modules/TeamInfoModule/info.toml
new file mode 100644
index 000000000..cb09a0b8b
--- /dev/null
+++ b/src/Modules/TeamInfoModule/info.toml
@@ -0,0 +1,11 @@
+[info]
+# A unique name for this module, this is used as a identifier
+name = "TeamInfoModule"
+# The title of the module
+title = "TeamInfo Module"
+# A short description of what the module is and does
+summary = "Displays teams and their points in a widget on the screen."
+# The current version of this module, using SEMVER
+version = "1.0.0"
+# The name of the author that created this module
+author = "Evo"
diff --git a/src/Modules/TeamSettingsModule/Templates/Scripts/ColorInput.ms b/src/Modules/TeamSettingsModule/Templates/Scripts/ColorInput.ms
index 52d8b309a..63e446ab9 100644
--- a/src/Modules/TeamSettingsModule/Templates/Scripts/ColorInput.ms
+++ b/src/Modules/TeamSettingsModule/Templates/Scripts/ColorInput.ms
@@ -35,7 +35,7 @@ if (Event.Control.ControlId == "picker") {
declare entryFrame <=> (colorInputFrame.Controls[3] as CMlFrame);
declare colorEntry <=> (entryFrame.Controls[1] as CMlEntry);
- colorEntry.Value = ColorLib::RgbToHex(picker.Color);
+ colorEntry.Value = ColorLib::RgbToHex6(picker.Color);
previewLabel.ModulateColor = picker.Color;
}
***
diff --git a/tests/Modules/TeamInfoModule.Tests/Controllers/TeamInfoEventControllerTests.cs b/tests/Modules/TeamInfoModule.Tests/Controllers/TeamInfoEventControllerTests.cs
new file mode 100644
index 000000000..e62dfa126
--- /dev/null
+++ b/tests/Modules/TeamInfoModule.Tests/Controllers/TeamInfoEventControllerTests.cs
@@ -0,0 +1,142 @@
+using EvoSC.Common.Interfaces.Controllers;
+using EvoSC.Common.Models;
+using EvoSC.Common.Models.Callbacks;
+using EvoSC.Common.Remote.EventArgsModels;
+using EvoSC.Modules.Official.TeamInfoModule.Controllers;
+using EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+using EvoSC.Testing.Controllers;
+using GbxRemoteNet.Events;
+using Moq;
+using Xunit;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Tests.Controllers;
+
+public class TeamInfoEventControllerTests : ControllerMock
+{
+ private Mock _teamInfoService = new();
+
+ public TeamInfoEventControllerTests()
+ {
+ InitMock(_teamInfoService);
+ }
+
+ [Fact]
+ public async Task Sets_Team_Mode_Disabled_If_UseTeams_Is_False()
+ {
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(true));
+
+ await Controller.OnScoresAsync(null,
+ new ScoresEventArgs
+ {
+ Section = ModeScriptSection.Undefined,
+ UseTeams = false,
+ WinnerTeam = 0,
+ WinnerPlayer = null,
+ Teams = new List(),
+ Players = new List()
+ });
+
+ _teamInfoService.Verify(s => s.SetModeIsTeamsAsync(false), Times.Once);
+ _teamInfoService.Verify(s => s.SetModeIsTeamsAsync(true), Times.Never);
+ _teamInfoService.Verify(s => s.UpdatePointsAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task Sets_Team_Mode_Enabled_If_Required_And_Updates_Points()
+ {
+ var team1Points = 4;
+ var team2Points = 7;
+
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(false));
+
+ await Controller.OnScoresAsync(null,
+ new ScoresEventArgs
+ {
+ Section = ModeScriptSection.Undefined,
+ UseTeams = true,
+ WinnerTeam = 0,
+ WinnerPlayer = null,
+ Teams = new List
+ {
+ new TeamScore { MatchPoints = team1Points },
+ new TeamScore { MatchPoints = team2Points },
+ },
+ Players = new List()
+ });
+
+ _teamInfoService.Verify(s => s.SetModeIsTeamsAsync(true), Times.Once);
+ _teamInfoService.Verify(s => s.UpdatePointsAsync(team1Points, team2Points), Times.Once);
+ }
+
+ [Fact]
+ public async Task Updates_Points_On_New_Scores_While_Already_In_Teams_Mode()
+ {
+ var team1Points = 45;
+ var team2Points = 56;
+
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(true));
+
+ await Controller.OnScoresAsync(null,
+ new ScoresEventArgs
+ {
+ Section = ModeScriptSection.EndRound,
+ UseTeams = true,
+ WinnerTeam = 0,
+ WinnerPlayer = null,
+ Teams = new List
+ {
+ new TeamScore { MatchPoints = team1Points },
+ new TeamScore { MatchPoints = team2Points },
+ },
+ Players = new List()
+ });
+
+ _teamInfoService.Verify(s => s.SetModeIsTeamsAsync(false), Times.Never);
+ _teamInfoService.Verify(s => s.SetModeIsTeamsAsync(true), Times.Never);
+ _teamInfoService.Verify(s => s.UpdatePointsAsync(team1Points, team2Points), Times.Once);
+ }
+
+ [Fact]
+ public async Task Updates_Round_Number_On_New_Round()
+ {
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(true));
+
+ var roundNumber = 777;
+ await Controller.OnRoundStartAsync(null, new RoundEventArgs { Count = roundNumber, Time = 0 });
+ _teamInfoService.Verify(s => s.UpdateRoundNumberAsync(roundNumber), Times.Once);
+ }
+
+ [Fact]
+ public async Task Doesnt_Update_Round_Number_On_New_Round_If_Mode_Is_Not_Teams()
+ {
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(false));
+
+ await Controller.OnRoundStartAsync(null, new RoundEventArgs { Count = 0, Time = 0 });
+ _teamInfoService.Verify(s => s.UpdateRoundNumberAsync(0), Times.Never);
+ }
+
+ [Fact]
+ public async Task Hides_Widget_On_Podium_Start()
+ {
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(true));
+
+ await Controller.OnPodiumStartAsync(null, new PodiumEventArgs { Time = 0 });
+ _teamInfoService.Verify(s => s.HideTeamInfoWidgetEveryoneAsync(), Times.Once);
+ }
+
+ [Fact]
+ public async Task Hides_Widget_On_End_Map()
+ {
+ _teamInfoService.Setup(s => s.GetModeIsTeamsAsync())
+ .Returns(Task.FromResult(true));
+
+ await Controller.OnEndMapAsync(null, new MapGbxEventArgs());
+ _teamInfoService.Verify(s => s.HideTeamInfoWidgetEveryoneAsync(), Times.Once);
+ }
+}
diff --git a/tests/Modules/TeamInfoModule.Tests/Services/TeamInfoServiceTests.cs b/tests/Modules/TeamInfoModule.Tests/Services/TeamInfoServiceTests.cs
new file mode 100644
index 000000000..4c289db07
--- /dev/null
+++ b/tests/Modules/TeamInfoModule.Tests/Services/TeamInfoServiceTests.cs
@@ -0,0 +1,262 @@
+using EvoSC.Common.Interfaces;
+using EvoSC.Common.Util.MatchSettings.Models.ModeScriptSettingsModels;
+using EvoSC.Manialinks.Interfaces;
+using EvoSC.Modules.Official.TeamInfoModule.Config;
+using EvoSC.Modules.Official.TeamInfoModule.Interfaces;
+using EvoSC.Modules.Official.TeamInfoModule.Services;
+using EvoSC.Testing;
+using GbxRemoteNet.Interfaces;
+using GbxRemoteNet.Structs;
+using GbxRemoteNet.XmlRpc.ExtraTypes;
+using Moq;
+using Xunit;
+
+namespace EvoSC.Modules.Official.TeamInfoModule.Tests.Services;
+
+public class TeamInfoServiceTests
+{
+ private readonly Mock _manialinkManager = new();
+ private readonly Mock _settings = new();
+
+ private readonly (Mock Client, Mock Remote)
+ _server = Mocking.NewServerClientMock();
+
+ private ITeamInfoService TeamInfoServiceMock()
+ {
+ return new TeamInfoService(_server.Client.Object, _manialinkManager.Object, _settings.Object);
+ }
+
+ [Fact]
+ public async Task Initializes_Module_Service()
+ {
+ await TeamInfoServiceMock().InitializeModuleAsync();
+
+ _server.Remote.Verify(remote => remote.TriggerModeScriptEventArrayAsync("Trackmania.GetScores"), Times.Once);
+ }
+
+ [Theory]
+ [InlineData(7, 6, 0, 1, true)]
+ [InlineData(7, 6, 5, 1, true)]
+ [InlineData(7, 6, 6, 1, true)]
+ [InlineData(7, 0, 0, 1, false)]
+ [InlineData(7, 0, 0, null, false)]
+ [InlineData(7, 5, 0, 1, false)]
+ [InlineData(7, 6, 5, 2, true)]
+ [InlineData(7, 7, 6, 2, true)]
+ [InlineData(7, 6, 6, 2, false)]
+ [InlineData(7, 7, 7, 2, false)]
+ [InlineData(null, 7, 7, 2, false)]
+ public async Task Detects_Map_Point(int? pointsLimit, int teamPoints, int opponentPoints, int? pointsGap,
+ bool shouldHaveMapPoint)
+ {
+ Assert.Equal(
+ shouldHaveMapPoint,
+ await TeamInfoServiceMock().DoesTeamHaveMatchPointAsync(teamPoints, opponentPoints, pointsLimit, pointsGap)
+ );
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Sets_And_Gets_Mode_Is_Teams(bool isTeamsMode)
+ {
+ var teamInfoService = TeamInfoServiceMock();
+ await teamInfoService.SetModeIsTeamsAsync(isTeamsMode);
+
+ Assert.Equal(isTeamsMode, await teamInfoService.GetModeIsTeamsAsync());
+ }
+
+ [Fact]
+ public async Task Parses_Mode_Script_Team_Settings()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+ var mockModeSettings = new GbxDynamicObject
+ {
+ ["S_PointsLimit"] = 1,
+ ["S_FinishTimeout"] = 2,
+ ["S_MaxPointsPerRound"] = 3,
+ ["S_PointsGap"] = 4,
+ ["S_RoundsPerMap"] = 5,
+ ["S_MapsPerMatch"] = 6,
+ ["S_NeutralEmblemUrl"] = "https://domain.tld/image.png",
+ };
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(mockModeSettings));
+
+ var modeScriptTeamSettings = await teamInfoService.GetModeScriptTeamSettingsAsync();
+
+ _server.Remote.Verify(remote => remote.GetModeScriptSettingsAsync(), Times.Once);
+
+ Assert.Equal(1, modeScriptTeamSettings.PointsLimit);
+ Assert.Equal(2, modeScriptTeamSettings.FinishTimeout);
+ Assert.Equal(3, modeScriptTeamSettings.MaxPointsPerRound);
+ Assert.Equal(4, modeScriptTeamSettings.PointsGap);
+ Assert.Equal(5, modeScriptTeamSettings.RoundsPerMap);
+ Assert.Equal(6, modeScriptTeamSettings.MapsPerMatch);
+ Assert.Equal("https://domain.tld/image.png", modeScriptTeamSettings.NeutralEmblemUrl);
+ }
+
+ [Fact]
+ public async Task Returns_Default_Mode_Script_Team_Settings_For_Unset_Settings()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+ var mockModeSettings = new GbxDynamicObject();
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(mockModeSettings));
+
+ var modeScriptTeamSettings = await teamInfoService.GetModeScriptTeamSettingsAsync();
+
+ _server.Remote.Verify(remote => remote.GetModeScriptSettingsAsync(), Times.Once);
+
+ Assert.Equal(5, modeScriptTeamSettings.PointsLimit);
+ Assert.Equal(-1, modeScriptTeamSettings.FinishTimeout);
+ Assert.Equal(6, modeScriptTeamSettings.MaxPointsPerRound);
+ Assert.Equal(1, modeScriptTeamSettings.PointsGap);
+ Assert.Equal(-1, modeScriptTeamSettings.RoundsPerMap);
+ Assert.Equal(-1, modeScriptTeamSettings.MapsPerMatch);
+ Assert.Equal("", modeScriptTeamSettings.NeutralEmblemUrl);
+ }
+
+ [Fact]
+ public async Task Sends_Widget_To_Everyone()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(new GbxDynamicObject()));
+
+ await teamInfoService.SendTeamInfoWidgetEveryoneAsync();
+
+ _server.Remote.Verify(remote => remote.GetModeScriptSettingsAsync(), Times.Once);
+ _manialinkManager.Verify(
+ m => m.SendPersistentManialinkAsync("TeamInfoModule.TeamInfoWidget", It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Hides_Widget_For_Everyone()
+ {
+ await TeamInfoServiceMock().HideTeamInfoWidgetEveryoneAsync();
+
+ _manialinkManager.Verify(
+ m => m.HideManialinkAsync("TeamInfoModule.TeamInfoWidget"),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Updates_Round_Number()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(new GbxDynamicObject()));
+
+ await teamInfoService.UpdateRoundNumberAsync(777);
+
+ _manialinkManager.Verify(
+ m => m.SendPersistentManialinkAsync("TeamInfoModule.TeamInfoWidget", It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Updates_Teams_Points()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(new GbxDynamicObject()));
+
+ await teamInfoService.UpdatePointsAsync(4, 5);
+
+ _manialinkManager.Verify(
+ m => m.SendPersistentManialinkAsync("TeamInfoModule.TeamInfoWidget", It.IsAny()),
+ Times.Once);
+ }
+
+ [Theory]
+ [InlineData(5, 1, 0, "FIRST TO 5")]
+ [InlineData(6, 2, 0, "FIRST TO 6 | GAP 2")]
+ [InlineData(7, 3, 0, "FIRST TO 7 | GAP 3")]
+ [InlineData(0, 1, 3, "3 ROUNDS/MAP")]
+ [InlineData(9, 1, 4, "FIRST TO 9 | 4 ROUNDS/MAP")]
+ [InlineData(9, 5, 4, "FIRST TO 9 | GAP 5 | 4 ROUNDS/MAP")]
+ [InlineData(0, 1, 0, null)]
+ public async Task Generates_Info_Box_Text(int pointsLimit, int pointsGap, int roundsPerMap, string? expected)
+ {
+ var teamInfoService = TeamInfoServiceMock();
+ var modeScriptTeamSettings = new TeamsModeScriptSettings
+ {
+ PointsLimit = pointsLimit, PointsGap = pointsGap, RoundsPerMap = roundsPerMap
+ };
+
+ Assert.Equal(expected, await teamInfoService.GetInfoBoxTextAsync(modeScriptTeamSettings));
+ }
+
+ [Fact]
+ public async Task Gets_Widget_Data()
+ {
+ var teamInfoService = TeamInfoServiceMock();
+ var team1Info = new TmTeamInfo { Name = "unit1" };
+ var team2Info = new TmTeamInfo { Name = "unit2" };
+ var expectedTeam1Points = 4;
+ var expectedTeam2Points = 0;
+ var expectedRoundNumber = 777;
+
+ _server.Remote.Setup(s => s.GetModeScriptSettingsAsync())
+ .Returns(Task.FromResult(new GbxDynamicObject()));
+ _server.Remote.Setup(s => s.GetTeamInfoAsync(1))
+ .Returns(Task.FromResult(team1Info));
+ _server.Remote.Setup(s => s.GetTeamInfoAsync(2))
+ .Returns(Task.FromResult(team2Info));
+
+ await teamInfoService.UpdateRoundNumberAsync(expectedRoundNumber);
+ await teamInfoService.UpdatePointsAsync(expectedTeam1Points, expectedTeam2Points);
+ var widgetData = await teamInfoService.GetWidgetDataAsync();
+
+ _server.Remote.Verify(remote => remote.GetModeScriptSettingsAsync(), Times.Exactly(3));
+ _server.Remote.Verify(remote => remote.GetTeamInfoAsync(1), Times.Exactly(3));
+ _server.Remote.Verify(remote => remote.GetTeamInfoAsync(2), Times.Exactly(3));
+
+ var team1Property = widgetData.GetType().GetProperty("team1");
+ var returnedTeam1Data = team1Property.GetValue(widgetData, null);
+ Assert.IsType(returnedTeam1Data);
+ Assert.Equal("unit1", returnedTeam1Data.Name);
+
+ var team2Property = widgetData.GetType().GetProperty("team2");
+ var returnedTeam2Data = team2Property.GetValue(widgetData, null);
+ Assert.IsType(returnedTeam2Data);
+ Assert.Equal("unit2", returnedTeam2Data.Name);
+
+ var infoBoxText = widgetData.GetType().GetProperty("infoBoxText");
+ var returnedInfoBoxText = infoBoxText.GetValue(widgetData, null);
+ Assert.Equal("FIRST TO 5", returnedInfoBoxText);
+
+ var team1MatchPointProperty = widgetData.GetType().GetProperty("team1MatchPoint");
+ var returnedTeam1MatchPoint = team1MatchPointProperty.GetValue(widgetData, null);
+ Assert.IsType(returnedTeam1MatchPoint);
+ Assert.True(returnedTeam1MatchPoint);
+
+ var team2MatchPointProperty = widgetData.GetType().GetProperty("team2MatchPoint");
+ var returnedTeam2MatchPoint = team2MatchPointProperty.GetValue(widgetData, null);
+ Assert.IsType(returnedTeam2MatchPoint);
+ Assert.False(returnedTeam2MatchPoint);
+
+ var roundNumberProperty = widgetData.GetType().GetProperty("roundNumber");
+ var returnedRoundNumber = roundNumberProperty.GetValue(widgetData, null);
+ Assert.Equal(expectedRoundNumber, returnedRoundNumber);
+
+ var team1PointsProperty = widgetData.GetType().GetProperty("team1Points");
+ var returnedTeam1Points = team1PointsProperty.GetValue(widgetData, null);
+ Assert.Equal(expectedTeam1Points, returnedTeam1Points);
+
+ var team2PointsProperty = widgetData.GetType().GetProperty("team2Points");
+ var returnedTeam2Points = team2PointsProperty.GetValue(widgetData, null);
+ Assert.Equal(expectedTeam2Points, returnedTeam2Points);
+
+ var neutralEmblemUrlProperty = widgetData.GetType().GetProperty("neutralEmblemUrl");
+ var returnedNeutralEmblemUrl = neutralEmblemUrlProperty.GetValue(widgetData, null);
+ Assert.Empty(returnedNeutralEmblemUrl);
+ }
+}
diff --git a/tests/Modules/TeamInfoModule.Tests/TeamInfoModule.Tests.csproj b/tests/Modules/TeamInfoModule.Tests/TeamInfoModule.Tests.csproj
new file mode 100644
index 000000000..c5b5aa077
--- /dev/null
+++ b/tests/Modules/TeamInfoModule.Tests/TeamInfoModule.Tests.csproj
@@ -0,0 +1,37 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+ EvoSC.Modules.Official.TeamInfoModule.Tests
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\..\..\.nuget\packages\moq\4.20.70\lib\net6.0\Moq.dll
+
+
+
+