diff --git a/src/Application/CTF.Application.csproj b/src/Application/CTF.Application.csproj
index 7d5bf1dc..7be5084b 100644
--- a/src/Application/CTF.Application.csproj
+++ b/src/Application/CTF.Application.csproj
@@ -1,12 +1,13 @@
-
+ true
+
@@ -29,4 +30,8 @@
+
+
+
+
diff --git a/src/Application/Common/Resources/Messages.Designer.cs b/src/Application/Common/Resources/Messages.Designer.cs
index ad17f5f2..2bc82903 100644
--- a/src/Application/Common/Resources/Messages.Designer.cs
+++ b/src/Application/Common/Resources/Messages.Designer.cs
@@ -78,6 +78,24 @@ internal static string EmptyWeaponPackage {
}
}
+ ///
+ /// Looks up a localized string similar to The interval must be between 0 to {Max}.
+ ///
+ internal static string InvalidInterval {
+ get {
+ return ResourceManager.GetString("InvalidInterval", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Invalid map id has been passed.
+ ///
+ internal static string InvalidMap {
+ get {
+ return ResourceManager.GetString("InvalidMap", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Must be 3-20 characters long and only contain valid characters (0-9, a-z, A-Z, [], (), $, @ . _ and = only).
///
@@ -132,6 +150,24 @@ internal static string InvalidWeapon {
}
}
+ ///
+ /// Looks up a localized string similar to The spawn location list cannot be empty.
+ ///
+ internal static string LocationListCannotBeEmpty {
+ get {
+ return ResourceManager.GetString("LocationListCannotBeEmpty", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Map has not been found.
+ ///
+ internal static string MapNotFound {
+ get {
+ return ResourceManager.GetString("MapNotFound", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Player '{Name}' is a member that already exists.
///
@@ -186,6 +222,15 @@ internal static string PlayerNotFound {
}
}
+ ///
+ /// Looks up a localized string similar to A spawn location can only be obtained for the alpha or beta team.
+ ///
+ internal static string SpawnLocationFailure {
+ get {
+ return ResourceManager.GetString("SpawnLocationFailure", resourceCulture);
+ }
+ }
+
///
/// Looks up a localized string similar to Points must be between -1 to -100.
///
diff --git a/src/Application/Common/Resources/Messages.resx b/src/Application/Common/Resources/Messages.resx
index f70b5d9a..7dff94a2 100644
--- a/src/Application/Common/Resources/Messages.resx
+++ b/src/Application/Common/Resources/Messages.resx
@@ -123,6 +123,12 @@
You have no items in your weapon package
+
+ The interval must be between 0 to {Max}
+
+
+ Invalid map id has been passed
+
Must be 3-20 characters long and only contain valid characters (0-9, a-z, A-Z, [], (), $, @ . _ and = only)
@@ -141,6 +147,12 @@
Invalid weapon has been passed
+
+ The spawn location list cannot be empty
+
+
+ Map has not been found
+
Player '{Name}' is a member that already exists
@@ -159,6 +171,9 @@
Player '{Name}' is not found
+
+ A spawn location can only be obtained for the alpha or beta team
+
Points must be between -1 to -100
diff --git a/src/Application/Common/Result.cs b/src/Application/Common/Result.cs
index 8d2b669d..6faf606a 100644
--- a/src/Application/Common/Result.cs
+++ b/src/Application/Common/Result.cs
@@ -3,32 +3,32 @@
///
/// Represents the result of an operation that does not return a value.
///
-public readonly ref struct Result
+public ref struct Result
{
public Result() { }
///
/// Gets the description of a result.
///
- public string Message { get; init; } = string.Empty;
+ public string Message { get; private set; } = string.Empty;
///
/// A value indicating that the result was successful.
///
- public bool IsSuccess { get; init; } = true;
+ public bool IsSuccess { get; private set; } = false;
///
/// A value that indicates that the result was a failure.
///
public bool IsFailed => !IsSuccess;
- public static Result Success() => new();
- public static Result Success(string message) => new() { Message = message };
-
- public static Result Failure() => new() { IsSuccess = false };
- public static Result Failure(string message) => new()
- {
- IsSuccess = false,
- Message = message
+ public static Result Success() => new() { IsSuccess = true };
+ public static Result Success(string message) => new()
+ {
+ IsSuccess = true,
+ Message = message
};
+
+ public static Result Failure() => new();
+ public static Result Failure(string message) => new() { Message = message };
}
diff --git a/src/Application/Common/ResultOfT.cs b/src/Application/Common/ResultOfT.cs
index f3b5ff5f..cbba91e7 100644
--- a/src/Application/Common/ResultOfT.cs
+++ b/src/Application/Common/ResultOfT.cs
@@ -4,41 +4,59 @@
/// Represents the result of an operation that returns a value.
///
/// A value associated to the result.
-public readonly ref struct Result
+public ref struct Result
{
+ private TValue _value;
public Result() { }
///
/// Gets the value associated with the result.
///
- public TValue Value { get; init; } = default;
+ ///
+ /// when is true.
+ ///
+ public TValue Value
+ {
+ get
+ {
+ return IsSuccess ?
+ _value :
+ throw new InvalidOperationException("The value of a failure result can not be accessed.");
+ }
+ private set
+ {
+ _value = value;
+ }
+ }
///
/// Gets the description of a result.
///
- public string Message { get; init; } = string.Empty;
+ public string Message { get; private set; } = string.Empty;
///
/// A value indicating that the result was successful.
///
- public bool IsSuccess { get; init; } = true;
+ public bool IsSuccess { get; private set; } = false;
///
/// A value that indicates that the result was a failure.
///
public bool IsFailed => !IsSuccess;
- public static Result Success(TValue value) => new() { Value = value };
+ public static Result Success(TValue value) => new()
+ {
+ IsSuccess = true,
+ Value = value
+ };
+
public static Result Success(TValue value, string message) => new()
{
+ IsSuccess = true,
Value = value,
Message = message
};
- public static Result Failure() => new() { IsSuccess = false };
- public static Result Failure(string message) => new()
- {
- IsSuccess = false,
- Message = message
- };
+ public static Result Failure() => new();
+ public static Result Failure(string message) => new() { Message = message };
}
diff --git a/src/Application/Maps/CurrentMap.cs b/src/Application/Maps/CurrentMap.cs
new file mode 100644
index 00000000..6f41b42b
--- /dev/null
+++ b/src/Application/Maps/CurrentMap.cs
@@ -0,0 +1,65 @@
+namespace CTF.Application.Maps;
+
+///
+/// Represents the current information of a map.
+///
+public class CurrentMap
+{
+ private IMap _nextMap;
+ private readonly Random _random = new();
+ public const int DefaultInterior = 0;
+ public const int DefaultWeather = 10;
+ public const int DefaultWorldTime = 12;
+ public int Id { get; }
+ public string Name { get; }
+ public IReadOnlyList AlphaTeamLocations { get; }
+ public IReadOnlyList BetaTeamLocations { get; }
+ public int Interior { get; }
+ public int Weather { get; }
+ public int WorldTime { get; }
+ public IMap NextMap => _nextMap;
+ public int IsLoading { get; set; }
+
+ public CurrentMap(
+ IMap map,
+ IReadOnlyList alphaTeamLocations,
+ IReadOnlyList betaTeamLocations,
+ int interior = DefaultInterior,
+ int weather = DefaultWeather,
+ int worldTime = DefaultWorldTime)
+ {
+ ArgumentNullException.ThrowIfNull(map);
+ ArgumentNullException.ThrowIfNull(alphaTeamLocations);
+ ArgumentNullException.ThrowIfNull(betaTeamLocations);
+
+ if (alphaTeamLocations.Count == 0)
+ throw new ArgumentException(Messages.LocationListCannotBeEmpty, nameof(alphaTeamLocations));
+
+ if (betaTeamLocations.Count == 0)
+ throw new ArgumentException(Messages.LocationListCannotBeEmpty, nameof(betaTeamLocations));
+
+ Id = map.Id;
+ Name = map.Name;
+ AlphaTeamLocations = alphaTeamLocations;
+ BetaTeamLocations = betaTeamLocations;
+ Interior = interior;
+ Weather = weather;
+ WorldTime = worldTime;
+ int nextMapId = (Id + 1) % MapCollection.Count;
+ _nextMap = MapCollection.GetById(nextMapId).Value;
+ }
+
+ public string GetMapNameAsText() => $"Map: ~w~{Name}";
+ public void SetNextMap(IMap nextMap)
+ {
+ ArgumentNullException.ThrowIfNull(nextMap);
+ _nextMap = nextMap;
+ }
+
+ public SpawnLocation GetRandomSpawnLocation(TeamId team) => team switch
+ {
+ TeamId.Alpha => AlphaTeamLocations[_random.Next(AlphaTeamLocations.Count)],
+ TeamId.Beta => BetaTeamLocations[_random.Next(BetaTeamLocations.Count)],
+ _ => throw new NotSupportedException(Messages.SpawnLocationFailure)
+ };
+}
diff --git a/scriptfiles/maps/Aim_Headshot.ini b/src/Application/Maps/Files/Aim_Headshot.ini
similarity index 100%
rename from scriptfiles/maps/Aim_Headshot.ini
rename to src/Application/Maps/Files/Aim_Headshot.ini
diff --git a/scriptfiles/maps/Aim_Headshot2.ini b/src/Application/Maps/Files/Aim_Headshot2.ini
similarity index 100%
rename from scriptfiles/maps/Aim_Headshot2.ini
rename to src/Application/Maps/Files/Aim_Headshot2.ini
diff --git a/scriptfiles/maps/Area51.ini b/src/Application/Maps/Files/Area51.ini
similarity index 100%
rename from scriptfiles/maps/Area51.ini
rename to src/Application/Maps/Files/Area51.ini
diff --git a/scriptfiles/maps/Area66.ini b/src/Application/Maps/Files/Area66.ini
similarity index 100%
rename from scriptfiles/maps/Area66.ini
rename to src/Application/Maps/Files/Area66.ini
diff --git a/scriptfiles/maps/Compound.ini b/src/Application/Maps/Files/Compound.ini
similarity index 100%
rename from scriptfiles/maps/Compound.ini
rename to src/Application/Maps/Files/Compound.ini
diff --git a/scriptfiles/maps/CrackFactory.ini b/src/Application/Maps/Files/CrackFactory.ini
similarity index 100%
rename from scriptfiles/maps/CrackFactory.ini
rename to src/Application/Maps/Files/CrackFactory.ini
diff --git a/scriptfiles/maps/DesertGlory.ini b/src/Application/Maps/Files/DesertGlory.ini
similarity index 100%
rename from scriptfiles/maps/DesertGlory.ini
rename to src/Application/Maps/Files/DesertGlory.ini
diff --git a/scriptfiles/maps/GateToHell.ini b/src/Application/Maps/Files/GateToHell.ini
similarity index 100%
rename from scriptfiles/maps/GateToHell.ini
rename to src/Application/Maps/Files/GateToHell.ini
diff --git a/scriptfiles/maps/RC_Battlefield.ini b/src/Application/Maps/Files/RC_Battlefield.ini
similarity index 100%
rename from scriptfiles/maps/RC_Battlefield.ini
rename to src/Application/Maps/Files/RC_Battlefield.ini
diff --git a/scriptfiles/maps/SA_Hill.ini b/src/Application/Maps/Files/SA_Hill.ini
similarity index 100%
rename from scriptfiles/maps/SA_Hill.ini
rename to src/Application/Maps/Files/SA_Hill.ini
diff --git a/scriptfiles/maps/Simpson.ini b/src/Application/Maps/Files/Simpson.ini
similarity index 100%
rename from scriptfiles/maps/Simpson.ini
rename to src/Application/Maps/Files/Simpson.ini
diff --git a/scriptfiles/maps/TheBunker.ini b/src/Application/Maps/Files/TheBunker.ini
similarity index 100%
rename from scriptfiles/maps/TheBunker.ini
rename to src/Application/Maps/Files/TheBunker.ini
diff --git a/scriptfiles/maps/TheConstruction.ini b/src/Application/Maps/Files/TheConstruction.ini
similarity index 100%
rename from scriptfiles/maps/TheConstruction.ini
rename to src/Application/Maps/Files/TheConstruction.ini
diff --git a/scriptfiles/maps/TheWild.ini b/src/Application/Maps/Files/TheWild.ini
similarity index 100%
rename from scriptfiles/maps/TheWild.ini
rename to src/Application/Maps/Files/TheWild.ini
diff --git a/scriptfiles/maps/WarZone.ini b/src/Application/Maps/Files/WarZone.ini
similarity index 100%
rename from scriptfiles/maps/WarZone.ini
rename to src/Application/Maps/Files/WarZone.ini
diff --git a/scriptfiles/maps/ZM_Italy.ini b/src/Application/Maps/Files/ZM_Italy.ini
similarity index 100%
rename from scriptfiles/maps/ZM_Italy.ini
rename to src/Application/Maps/Files/ZM_Italy.ini
diff --git a/scriptfiles/maps/cs_assault.ini b/src/Application/Maps/Files/cs_assault.ini
similarity index 100%
rename from scriptfiles/maps/cs_assault.ini
rename to src/Application/Maps/Files/cs_assault.ini
diff --git a/scriptfiles/maps/cs_deagle5.ini b/src/Application/Maps/Files/cs_deagle5.ini
similarity index 100%
rename from scriptfiles/maps/cs_deagle5.ini
rename to src/Application/Maps/Files/cs_deagle5.ini
diff --git a/scriptfiles/maps/cs_rockwar.ini b/src/Application/Maps/Files/cs_rockwar.ini
similarity index 100%
rename from scriptfiles/maps/cs_rockwar.ini
rename to src/Application/Maps/Files/cs_rockwar.ini
diff --git a/scriptfiles/maps/de_aztec.ini b/src/Application/Maps/Files/de_aztec.ini
similarity index 100%
rename from scriptfiles/maps/de_aztec.ini
rename to src/Application/Maps/Files/de_aztec.ini
diff --git a/scriptfiles/maps/de_dust2.ini b/src/Application/Maps/Files/de_dust2.ini
similarity index 100%
rename from scriptfiles/maps/de_dust2.ini
rename to src/Application/Maps/Files/de_dust2.ini
diff --git a/scriptfiles/maps/de_dust2_small.ini b/src/Application/Maps/Files/de_dust2_small.ini
similarity index 100%
rename from scriptfiles/maps/de_dust2_small.ini
rename to src/Application/Maps/Files/de_dust2_small.ini
diff --git a/scriptfiles/maps/de_dust2_x1.ini b/src/Application/Maps/Files/de_dust2_x1.ini
similarity index 100%
rename from scriptfiles/maps/de_dust2_x1.ini
rename to src/Application/Maps/Files/de_dust2_x1.ini
diff --git a/scriptfiles/maps/de_dust2_x2.ini b/src/Application/Maps/Files/de_dust2_x2.ini
similarity index 100%
rename from scriptfiles/maps/de_dust2_x2.ini
rename to src/Application/Maps/Files/de_dust2_x2.ini
diff --git a/scriptfiles/maps/de_dust2_x3.ini b/src/Application/Maps/Files/de_dust2_x3.ini
similarity index 100%
rename from scriptfiles/maps/de_dust2_x3.ini
rename to src/Application/Maps/Files/de_dust2_x3.ini
diff --git a/scriptfiles/maps/de_dust5.ini b/src/Application/Maps/Files/de_dust5.ini
similarity index 100%
rename from scriptfiles/maps/de_dust5.ini
rename to src/Application/Maps/Files/de_dust5.ini
diff --git a/scriptfiles/maps/fy_iceworld.ini b/src/Application/Maps/Files/fy_iceworld.ini
similarity index 100%
rename from scriptfiles/maps/fy_iceworld.ini
rename to src/Application/Maps/Files/fy_iceworld.ini
diff --git a/scriptfiles/maps/fy_iceworld2.ini b/src/Application/Maps/Files/fy_iceworld2.ini
similarity index 100%
rename from scriptfiles/maps/fy_iceworld2.ini
rename to src/Application/Maps/Files/fy_iceworld2.ini
diff --git a/scriptfiles/maps/fy_snow.ini b/src/Application/Maps/Files/fy_snow.ini
similarity index 100%
rename from scriptfiles/maps/fy_snow.ini
rename to src/Application/Maps/Files/fy_snow.ini
diff --git a/scriptfiles/maps/fy_snow2.ini b/src/Application/Maps/Files/fy_snow2.ini
similarity index 100%
rename from scriptfiles/maps/fy_snow2.ini
rename to src/Application/Maps/Files/fy_snow2.ini
diff --git a/scriptfiles/maps/mp_island.ini b/src/Application/Maps/Files/mp_island.ini
similarity index 100%
rename from scriptfiles/maps/mp_island.ini
rename to src/Application/Maps/Files/mp_island.ini
diff --git a/scriptfiles/maps/mp_jetdoor.ini b/src/Application/Maps/Files/mp_jetdoor.ini
similarity index 100%
rename from scriptfiles/maps/mp_jetdoor.ini
rename to src/Application/Maps/Files/mp_jetdoor.ini
diff --git a/scriptfiles/maps/zone_paintball.ini b/src/Application/Maps/Files/zone_paintball.ini
similarity index 100%
rename from scriptfiles/maps/zone_paintball.ini
rename to src/Application/Maps/Files/zone_paintball.ini
diff --git a/src/Application/Maps/IMap.cs b/src/Application/Maps/IMap.cs
new file mode 100644
index 00000000..f011dccd
--- /dev/null
+++ b/src/Application/Maps/IMap.cs
@@ -0,0 +1,7 @@
+namespace CTF.Application.Maps;
+
+public interface IMap
+{
+ int Id { get; }
+ string Name { get; }
+}
diff --git a/src/Application/Maps/LoadTime.cs b/src/Application/Maps/LoadTime.cs
new file mode 100644
index 00000000..97b21a33
--- /dev/null
+++ b/src/Application/Maps/LoadTime.cs
@@ -0,0 +1,58 @@
+namespace CTF.Application.Maps;
+
+///
+/// Represents the total wait time for the new map to load.
+///
+public class LoadTime
+{
+ private readonly Action _onLoadingMap;
+ private readonly Action _onLoadedMap;
+ private int _interval = MaxLoadTime;
+ public const int MaxLoadTime = 6;
+
+ ///
+ /// Displays the load time in the game.
+ ///
+ public string GameText { get; private set; } = string.Empty;
+
+ ///
+ /// Represents the interval in seconds.
+ ///
+ public int Interval => _interval;
+
+ public LoadTime(Action onLoadingMap, Action onLoadedMap)
+ {
+ ArgumentNullException.ThrowIfNull(onLoadingMap);
+ ArgumentNullException.ThrowIfNull(onLoadedMap);
+ _onLoadingMap = onLoadingMap;
+ _onLoadedMap = onLoadedMap;
+ }
+
+ ///
+ /// Reduces the load time until it reaches zero.
+ ///
+ public void Decrease()
+ {
+ if (_interval == 0)
+ {
+ Reset();
+ _onLoadedMap();
+ return;
+ }
+
+ if (_interval == MaxLoadTime)
+ {
+ _onLoadingMap();
+ }
+
+ _interval--;
+ UpdateGameText();
+ }
+
+ private void UpdateGameText() => GameText = $"Loading map... ({_interval})";
+ private void Reset()
+ {
+ _interval = MaxLoadTime;
+ GameText = string.Empty;
+ }
+}
diff --git a/src/Application/Maps/MapCollection.cs b/src/Application/Maps/MapCollection.cs
new file mode 100644
index 00000000..e10a5aa7
--- /dev/null
+++ b/src/Application/Maps/MapCollection.cs
@@ -0,0 +1,59 @@
+namespace CTF.Application.Maps;
+
+public class MapCollection
+{
+ private static Map[] s_maps;
+ static MapCollection() => SetMapNamesFromFileSystem();
+ private static void SetMapNamesFromFileSystem()
+ {
+ var path = Path.Combine(AppContext.BaseDirectory, "Maps", "Files");
+ var random = new Random();
+ string[] names = Directory.GetFiles(path);
+ random.Shuffle(names);
+ s_maps = new Map[names.Length];
+ for (int i = 0; i < names.Length; i++)
+ {
+ var map = new Map
+ {
+ Id = i,
+ Name = Path.GetFileNameWithoutExtension(names[i])
+ };
+ s_maps[i] = map;
+ }
+ }
+
+ public static int Count => s_maps.Length;
+ public static IReadOnlyList GetAll() => s_maps;
+ public static IEnumerable GetAll(string findBy)
+ {
+ foreach(Map map in s_maps)
+ {
+ if (map.Name.StartsWith(findBy, StringComparison.OrdinalIgnoreCase))
+ yield return map;
+ }
+ }
+
+ public static Result GetById(int id)
+ {
+ if (id < 0 || id >= Count)
+ return Result.Failure(Messages.InvalidMap);
+
+ Map map = s_maps[id];
+ return Result.Success(map);
+ }
+
+ public static Result GetByName(string mapName)
+ {
+ Map map = s_maps
+ .FirstOrDefault(map => map.Name.Equals(mapName, StringComparison.OrdinalIgnoreCase));
+ return map is null ?
+ Result.Failure(Messages.MapNotFound) :
+ Result.Success(map);
+ }
+
+ private class Map : IMap
+ {
+ public int Id { get; init; }
+ public string Name { get; init; }
+ }
+}
diff --git a/src/Application/Maps/Services/MapInfoService.cs b/src/Application/Maps/Services/MapInfoService.cs
new file mode 100644
index 00000000..b2a5ca68
--- /dev/null
+++ b/src/Application/Maps/Services/MapInfoService.cs
@@ -0,0 +1,77 @@
+namespace CTF.Application.Maps.Services;
+
+///
+/// Represents a service to load information from a map.
+///
+public class MapInfoService
+{
+ private CurrentMap _currentMap;
+ public MapInfoService()
+ {
+ int defaultMapId = 0;
+ IMap defaultMap = MapCollection.GetById(defaultMapId).Value;
+ Load(defaultMap);
+ }
+
+ ///
+ /// Reads the current information from a map.
+ ///
+ public CurrentMap Read() => _currentMap;
+
+ ///
+ /// Loads map information from the file system.
+ ///
+ /// The map to load.
+ ///
+ public void Load(IMap map)
+ {
+ ArgumentNullException.ThrowIfNull(map);
+ var basePath = AppContext.BaseDirectory;
+ var path = Path.Combine(basePath, "Maps", "Files", $"{map.Name}.ini");
+ ISectionsData sections = SectionsFile.Load(path);
+ SpawnLocation[] alphaTeamLocations = GetLocations(sections["Alpha"]);
+ SpawnLocation[] betaTeamLocations = GetLocations(sections["Beta"]);
+ sections.TryGetData(section: "Interior", out ISectionData retrievedInterior);
+ sections.TryGetData(section: "Weather", out ISectionData retrievedWeather);
+ sections.TryGetData(section: "WorldTime", out ISectionData retrievedWorldTime);
+
+ int interior = retrievedInterior is null ?
+ CurrentMap.DefaultInterior :
+ int.Parse(retrievedInterior.First());
+
+ int weather = retrievedWeather is null ?
+ CurrentMap.DefaultWeather :
+ int.Parse(retrievedWeather.First());
+
+ int worldTime = retrievedWorldTime is null ?
+ CurrentMap.DefaultWorldTime :
+ int.Parse(retrievedWorldTime.First());
+
+ _currentMap = new CurrentMap(
+ map,
+ alphaTeamLocations,
+ betaTeamLocations,
+ interior,
+ weather,
+ worldTime);
+ }
+
+ private static SpawnLocation[] GetLocations(ISectionData section)
+ {
+ var locations = new SpawnLocation[section.Count];
+ for(int i = 0; i < section.Count; i++)
+ {
+ string data = section[i];
+ string[] coordinates = data.Split(',');
+ var position = new Vector3(
+ float.Parse(coordinates[0], CultureInfo.InvariantCulture),
+ float.Parse(coordinates[1], CultureInfo.InvariantCulture),
+ float.Parse(coordinates[2], CultureInfo.InvariantCulture)
+ );
+ float angle = float.Parse(coordinates[3], CultureInfo.InvariantCulture);
+ var spawnLocation = new SpawnLocation(position, angle);
+ locations[i] = spawnLocation;
+ }
+ return locations;
+ }
+}
diff --git a/src/Application/Maps/SpawnLocation.cs b/src/Application/Maps/SpawnLocation.cs
new file mode 100644
index 00000000..333b71dc
--- /dev/null
+++ b/src/Application/Maps/SpawnLocation.cs
@@ -0,0 +1,19 @@
+namespace CTF.Application.Maps;
+
+public class SpawnLocation
+{
+ public static readonly SpawnLocation Empty = new(0, 0, 0, 0);
+ public Vector3 Position { get; }
+ public float Angle { get; }
+ public SpawnLocation(float x, float y, float z, float angle)
+ {
+ Position = new Vector3(x, y, z);
+ Angle = angle;
+ }
+
+ public SpawnLocation(Vector3 position, float angle)
+ {
+ Position = position;
+ Angle = angle;
+ }
+}
diff --git a/src/Application/Maps/TimeLeft.cs b/src/Application/Maps/TimeLeft.cs
new file mode 100644
index 00000000..d1a08eb2
--- /dev/null
+++ b/src/Application/Maps/TimeLeft.cs
@@ -0,0 +1,110 @@
+namespace CTF.Application.Maps;
+
+///
+/// Represents the time left on the current map.
+///
+public class TimeLeft
+{
+ private const int MaxRoundTime = 900;
+ ///
+ /// Represents the interval in seconds.
+ ///
+ private int _interval = MaxRoundTime;
+
+ ///
+ /// Represents the time left in a text draw.
+ ///
+ // This property can never be mutable.
+ // If this property is modified from the outside, it may cause buffer overflow.
+ public string TextDraw { get; } = "00:00";
+
+ public TimeLeft() => UpdateTextDraw();
+
+ ///
+ /// Checks if the countdown has ended.
+ ///
+ public bool IsCompleted() => _interval == 0;
+
+ public Result SetInterval(Minutes minutes)
+ {
+ if (minutes.Value < 0 || minutes.Value > (MaxRoundTime / 60))
+ {
+ var message = Smart.Format(Messages.InvalidInterval, new { Max = MaxRoundTime / 60 });
+ return Result.Failure(message);
+ }
+
+ _interval = minutes.Value * 60;
+ UpdateTextDraw();
+ return Result.Success();
+ }
+
+ public Result SetInterval(Seconds seconds)
+ {
+ if (seconds.Value < 0 || seconds.Value > MaxRoundTime)
+ {
+ var message = Smart.Format(Messages.InvalidInterval, new { Max = MaxRoundTime });
+ return Result.Failure(message);
+ }
+
+ _interval = seconds.Value;
+ UpdateTextDraw();
+ return Result.Success();
+ }
+
+ ///
+ /// Reduces the time remaining until it reaches zero.
+ ///
+ public void Decrease()
+ {
+ if(_interval == 0)
+ return;
+
+ _interval--;
+ UpdateTextDraw();
+ }
+
+ public void Reset()
+ {
+ _interval = MaxRoundTime;
+ UpdateTextDraw();
+ }
+
+ ///
+ /// The purpose is to manipulate the buffer directly with pointers
+ /// to avoid memory reallocations caused by string interpolation.
+ ///
+ ///
+ /// This decision was made because the text will be updated every 1 ms by a timer.
+ ///
+ private unsafe void UpdateTextDraw()
+ {
+ int minutes = _interval / 60;
+ int seconds = _interval % 60;
+
+ int digit1 = minutes % 10;
+ int digit0 = minutes / 10 % 10;
+
+ int digit4 = seconds % 10;
+ int digit3 = seconds / 10 % 10;
+
+ fixed (char* text = TextDraw)
+ {
+ text[0] = (char)(digit0 + '0');
+ text[1] = (char)(digit1 + '0');
+ text[3] = (char)(digit3 + '0');
+ text[4] = (char)(digit4 + '0');
+ }
+ }
+}
+
+public readonly ref struct Minutes
+{
+ public int Value { get; }
+ public Minutes(int value) => Value = value;
+}
+
+public readonly ref struct Seconds
+{
+ public int Value { get; }
+ public Seconds(int value) => Value = value;
+}
diff --git a/src/Application/Usings.cs b/src/Application/Usings.cs
index 213af353..e155e2b8 100644
--- a/src/Application/Usings.cs
+++ b/src/Application/Usings.cs
@@ -1,4 +1,5 @@
global using System.Collections;
+global using System.Globalization;
global using System.Text.RegularExpressions;
global using SampSharp.Core;
global using SampSharp.Entities;
@@ -6,6 +7,7 @@
global using Color = SampSharp.Entities.SAMP.Color;
global using SampSharp.Entities.SAMP.Commands;
global using SmartFormat;
+global using SeztionParser;
global using Microsoft.Extensions.DependencyInjection;
global using CTF.Application.Common;
diff --git a/tests/Application.Tests/Maps/CurrentMapTests.cs b/tests/Application.Tests/Maps/CurrentMapTests.cs
new file mode 100644
index 00000000..132cdfb2
--- /dev/null
+++ b/tests/Application.Tests/Maps/CurrentMapTests.cs
@@ -0,0 +1,184 @@
+namespace CTF.Application.Tests.Maps;
+
+public class CurrentMapTests
+{
+ [Test]
+ public void Constructor_WhenMapIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ IMap map = default;
+ List locations = [SpawnLocation.Empty];
+
+ // Act
+ Action act = () =>
+ {
+ var currentMap = new CurrentMap(map, locations, locations);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(map));
+ }
+
+ [Test]
+ public void Constructor_WhenAlphaTeamLocationsIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List alphaTeamLocations = default;
+ List betaTeamLocations = [SpawnLocation.Empty];
+
+ // Act
+ Action act = () =>
+ {
+ var currentMap = new CurrentMap(map, alphaTeamLocations, betaTeamLocations);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(alphaTeamLocations));
+ }
+
+ [Test]
+ public void Constructor_WhenBetaTeamLocationsIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List betaTeamLocations = default;
+ List alphaTeamLocations = [SpawnLocation.Empty];
+
+ // Act
+ Action act = () =>
+ {
+ var currentMap = new CurrentMap(map, alphaTeamLocations, betaTeamLocations);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(betaTeamLocations));
+ }
+
+ [Test]
+ public void Constructor_WhenAlphaTeamLocationsIsEmpty_ShouldThrowArgumentException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List alphaTeamLocations = [];
+ List betaTeamLocations = [SpawnLocation.Empty];
+
+ // Act
+ Action act = () =>
+ {
+ var currentMap = new CurrentMap(map, alphaTeamLocations, betaTeamLocations);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(alphaTeamLocations));
+ }
+
+ [Test]
+ public void Constructor_WhenBetaTeamLocationsIsEmpty_ShouldThrowArgumentException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List alphaTeamLocations = [SpawnLocation.Empty];
+ List betaTeamLocations = [];
+
+ // Act
+ Action act = () =>
+ {
+ var currentMap = new CurrentMap(map, alphaTeamLocations, betaTeamLocations);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(betaTeamLocations));
+ }
+
+ [TestCaseSource(typeof(GetNextMapTestCases))]
+ public void NextMap_WhenMapIsObtained_ShouldReturnsNextMap((IMap Map, IMap Expected) data)
+ {
+ // Arrange
+ List locations = [SpawnLocation.Empty];
+ var currentMap = new CurrentMap(data.Map, locations, locations);
+
+ // Act
+ IMap actual = currentMap.NextMap;
+
+ // Assert
+ actual.Should().Be(data.Expected);
+ }
+
+ [Test]
+ public void GetMapNameAsText_WhenNameIsObtained_ShouldReturnsValidStringFormat()
+ {
+ // Arrange
+ IMap map = MapCollection.GetByName("RC_Battlefield").Value;
+ List locations = [SpawnLocation.Empty];
+ var currentMap = new CurrentMap(map, locations, locations);
+ var expectedString = "Map: ~w~RC_Battlefield";
+
+ // Act
+ string actual = currentMap.GetMapNameAsText();
+
+ // Assert
+ actual.Should().Be(expectedString);
+ }
+
+ [Test]
+ public void SetNextMap_WhenArgumentIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ IMap nextMap = default;
+ List locations = [SpawnLocation.Empty];
+ var currentMap = new CurrentMap(map, locations, locations);
+
+ // Act
+ Action act = () => currentMap.SetNextMap(nextMap);
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(nextMap));
+ }
+
+ [TestCase(TeamId.Alpha)]
+ [TestCase(TeamId.Beta)]
+ public void GetRandomSpawnLocation_WhenTeamIsAlphaOrBeta_ShouldReturnsSpawnLocation(TeamId team)
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List locations = [SpawnLocation.Empty];
+ var currentMap = new CurrentMap(map, locations, locations);
+ SpawnLocation expectedSpawnLocation = locations[0];
+
+ // Act
+ SpawnLocation actual = currentMap.GetRandomSpawnLocation(team);
+
+ // Assert
+ actual.Should().Be(expectedSpawnLocation);
+ }
+
+ [Test]
+ public void GetRandomSpawnLocation_WhenTeamIsNotAlphaOrBeta_ShouldThrowNotSupportedException()
+ {
+ // Arrange
+ IMap map = MapCollection.GetById(0).Value;
+ List locations = [SpawnLocation.Empty];
+ TeamId team = TeamId.NoTeam;
+ var currentMap = new CurrentMap(map, locations, locations);
+
+ // Act
+ Action act = () => currentMap.GetRandomSpawnLocation(team);
+
+ // Assert
+ act.Should().Throw();
+ }
+}
diff --git a/tests/Application.Tests/Maps/DecreaseTimeLeftTestCases.cs b/tests/Application.Tests/Maps/DecreaseTimeLeftTestCases.cs
new file mode 100644
index 00000000..85de1a7f
--- /dev/null
+++ b/tests/Application.Tests/Maps/DecreaseTimeLeftTestCases.cs
@@ -0,0 +1,191 @@
+namespace CTF.Application.Tests.Maps;
+
+public class DecreaseTimeLeftTestCases : IEnumerable
+{
+ public IEnumerator GetEnumerator()
+ {
+ yield return "02:59";
+ yield return "02:58";
+ yield return "02:57";
+ yield return "02:56";
+ yield return "02:55";
+ yield return "02:54";
+ yield return "02:53";
+ yield return "02:52";
+ yield return "02:51";
+ yield return "02:50";
+ yield return "02:49";
+ yield return "02:48";
+ yield return "02:47";
+ yield return "02:46";
+ yield return "02:45";
+ yield return "02:44";
+ yield return "02:43";
+ yield return "02:42";
+ yield return "02:41";
+ yield return "02:40";
+ yield return "02:39";
+ yield return "02:38";
+ yield return "02:37";
+ yield return "02:36";
+ yield return "02:35";
+ yield return "02:34";
+ yield return "02:33";
+ yield return "02:32";
+ yield return "02:31";
+ yield return "02:30";
+ yield return "02:29";
+ yield return "02:28";
+ yield return "02:27";
+ yield return "02:26";
+ yield return "02:25";
+ yield return "02:24";
+ yield return "02:23";
+ yield return "02:22";
+ yield return "02:21";
+ yield return "02:20";
+ yield return "02:19";
+ yield return "02:18";
+ yield return "02:17";
+ yield return "02:16";
+ yield return "02:15";
+ yield return "02:14";
+ yield return "02:13";
+ yield return "02:12";
+ yield return "02:11";
+ yield return "02:10";
+ yield return "02:09";
+ yield return "02:08";
+ yield return "02:07";
+ yield return "02:06";
+ yield return "02:05";
+ yield return "02:04";
+ yield return "02:03";
+ yield return "02:02";
+ yield return "02:01";
+ yield return "02:00";
+ yield return "01:59";
+ yield return "01:58";
+ yield return "01:57";
+ yield return "01:56";
+ yield return "01:55";
+ yield return "01:54";
+ yield return "01:53";
+ yield return "01:52";
+ yield return "01:51";
+ yield return "01:50";
+ yield return "01:49";
+ yield return "01:48";
+ yield return "01:47";
+ yield return "01:46";
+ yield return "01:45";
+ yield return "01:44";
+ yield return "01:43";
+ yield return "01:42";
+ yield return "01:41";
+ yield return "01:40";
+ yield return "01:39";
+ yield return "01:38";
+ yield return "01:37";
+ yield return "01:36";
+ yield return "01:35";
+ yield return "01:34";
+ yield return "01:33";
+ yield return "01:32";
+ yield return "01:31";
+ yield return "01:30";
+ yield return "01:29";
+ yield return "01:28";
+ yield return "01:27";
+ yield return "01:26";
+ yield return "01:25";
+ yield return "01:24";
+ yield return "01:23";
+ yield return "01:22";
+ yield return "01:21";
+ yield return "01:20";
+ yield return "01:19";
+ yield return "01:18";
+ yield return "01:17";
+ yield return "01:16";
+ yield return "01:15";
+ yield return "01:14";
+ yield return "01:13";
+ yield return "01:12";
+ yield return "01:11";
+ yield return "01:10";
+ yield return "01:09";
+ yield return "01:08";
+ yield return "01:07";
+ yield return "01:06";
+ yield return "01:05";
+ yield return "01:04";
+ yield return "01:03";
+ yield return "01:02";
+ yield return "01:01";
+ yield return "01:00";
+ yield return "00:59";
+ yield return "00:58";
+ yield return "00:57";
+ yield return "00:56";
+ yield return "00:55";
+ yield return "00:54";
+ yield return "00:53";
+ yield return "00:52";
+ yield return "00:51";
+ yield return "00:50";
+ yield return "00:49";
+ yield return "00:48";
+ yield return "00:47";
+ yield return "00:46";
+ yield return "00:45";
+ yield return "00:44";
+ yield return "00:43";
+ yield return "00:42";
+ yield return "00:41";
+ yield return "00:40";
+ yield return "00:39";
+ yield return "00:38";
+ yield return "00:37";
+ yield return "00:36";
+ yield return "00:35";
+ yield return "00:34";
+ yield return "00:33";
+ yield return "00:32";
+ yield return "00:31";
+ yield return "00:30";
+ yield return "00:29";
+ yield return "00:28";
+ yield return "00:27";
+ yield return "00:26";
+ yield return "00:25";
+ yield return "00:24";
+ yield return "00:23";
+ yield return "00:22";
+ yield return "00:21";
+ yield return "00:20";
+ yield return "00:19";
+ yield return "00:18";
+ yield return "00:17";
+ yield return "00:16";
+ yield return "00:15";
+ yield return "00:14";
+ yield return "00:13";
+ yield return "00:12";
+ yield return "00:11";
+ yield return "00:10";
+ yield return "00:09";
+ yield return "00:08";
+ yield return "00:07";
+ yield return "00:06";
+ yield return "00:05";
+ yield return "00:04";
+ yield return "00:03";
+ yield return "00:02";
+ yield return "00:01";
+ yield return "00:00";
+ }
+
+ IEnumerator IEnumerable.GetEnumerator()
+ => this.GetEnumerator();
+}
diff --git a/tests/Application.Tests/Maps/GetNextMapTestCases.cs b/tests/Application.Tests/Maps/GetNextMapTestCases.cs
new file mode 100644
index 00000000..e49ca391
--- /dev/null
+++ b/tests/Application.Tests/Maps/GetNextMapTestCases.cs
@@ -0,0 +1,26 @@
+namespace CTF.Application.Tests.Maps;
+
+public class GetNextMapTestCases : IEnumerable<(IMap Map, IMap Expected)>
+{
+ public IEnumerator<(IMap Map, IMap Expected)> GetEnumerator()
+ {
+ yield return (GetCurrentMap(0), GetNextMap(1));
+ yield return (GetCurrentMap(1), GetNextMap(2));
+ yield return (GetCurrentMap(2), GetNextMap(3));
+ yield return (GetCurrentMap(3), GetNextMap(4));
+ yield return (GetCurrentMap(4), GetNextMap(5));
+ yield return (GetCurrentMap(5), GetNextMap(6));
+ yield return (GetCurrentMap(6), GetNextMap(7));
+ yield return (GetCurrentMap(7), GetNextMap(8));
+ yield return (GetCurrentMap(31), GetNextMap(32));
+ yield return (
+ GetCurrentMap(MapCollection.Count - 1),
+ GetNextMap(0)
+ );
+ }
+
+ private static IMap GetCurrentMap(int id) => MapCollection.GetById(id).Value;
+ private static IMap GetNextMap(int id) => MapCollection.GetById(id).Value;
+ IEnumerator IEnumerable.GetEnumerator()
+ => this.GetEnumerator();
+}
diff --git a/tests/Application.Tests/Maps/LoadTimeTests.cs b/tests/Application.Tests/Maps/LoadTimeTests.cs
new file mode 100644
index 00000000..feaa017e
--- /dev/null
+++ b/tests/Application.Tests/Maps/LoadTimeTests.cs
@@ -0,0 +1,112 @@
+namespace CTF.Application.Tests.Maps;
+
+public class LoadTimeTests
+{
+ static readonly int[] ExpectedIntervalCases = [5, 4, 3, 2, 1, 0];
+ private readonly LoadTime _loadTime;
+ public LoadTimeTests()
+ {
+ static void OnLoadingMap() { }
+ static void OnLoadedMap() { }
+ _loadTime = new LoadTime(OnLoadingMap, OnLoadedMap);
+ }
+
+ [Test]
+ public void Constructor_WhenOnLoadingMapIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ Action onLoadingMap = default;
+ static void OnLoadedMap() { }
+
+ // Act
+ Action act = () =>
+ {
+ var loadTime = new LoadTime(onLoadingMap, OnLoadedMap);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(onLoadingMap));
+ }
+
+ [Test]
+ public void Constructor_WhenOnLoadedMapIsNull_ShouldThrowArgumentNullException()
+ {
+ // Arrange
+ Action onLoadedMap = default;
+ static void OnLoadingMap() { }
+
+ // Act
+ Action act = () =>
+ {
+ var loadTime = new LoadTime(OnLoadingMap, onLoadedMap);
+ };
+
+ // Assert
+ act.Should()
+ .Throw()
+ .WithParameterName(nameof(onLoadedMap));
+ }
+
+ [TestCaseSource(nameof(ExpectedIntervalCases))]
+ public void Decrease_WhenLoadTimeIsNotCompleted_ShouldContinueToDecrease(int expectedInterval)
+ {
+ // Arrange
+ var expectedText = $"Loading map... ({expectedInterval})";
+
+ // Act
+ _loadTime.Decrease();
+ int currentInterval = _loadTime.Interval;
+
+ // Asserts
+ currentInterval.Should().Be(expectedInterval);
+ _loadTime.GameText.Should().Be(expectedText);
+ }
+
+ [Test]
+ public void Decrease_WhenIntervalIsEqualsToZero_ShouldInvokeOnLoadedMap()
+ {
+ // Arrange
+ bool loadedMap = false;
+ static void OnLoadingMap() { }
+ void OnLoadedMap() => loadedMap = true;
+ var loadTime = new LoadTime(OnLoadingMap, OnLoadedMap);
+ int expectedInterval = LoadTime.MaxLoadTime;
+ loadTime.Decrease(); // 5
+ loadTime.Decrease(); // 4
+ loadTime.Decrease(); // 3
+ loadTime.Decrease(); // 2
+ loadTime.Decrease(); // 1
+ loadTime.Decrease(); // 0
+
+ // Act
+ // Invoke to OnLoadedMap
+ loadTime.Decrease();
+
+ // Asserts
+ loadedMap.Should().BeTrue();
+ loadTime.Interval.Should().Be(expectedInterval);
+ loadTime.GameText.Should().BeEmpty();
+ }
+
+ [Test]
+ public void Decrease_WhenIntervalIsEqualsToMaxLoadTime_ShouldInvokeOnLoadingMap()
+ {
+ // Arrange
+ bool loadingMap = false;
+ void OnLoadingMap() => loadingMap = true;
+ static void OnLoadedMap() { }
+ var loadTime = new LoadTime(OnLoadingMap, OnLoadedMap);
+ int expectedInterval = 5;
+
+ // Act
+ // Invoke to OnLoadingMap
+ loadTime.Decrease();
+
+ // Asserts
+ loadingMap.Should().BeTrue();
+ loadTime.Interval.Should().Be(expectedInterval);
+ loadTime.GameText.Should().NotBeEmpty();
+ }
+}
diff --git a/tests/Application.Tests/Maps/MapCollectionTests.cs b/tests/Application.Tests/Maps/MapCollectionTests.cs
new file mode 100644
index 00000000..dc1b4e25
--- /dev/null
+++ b/tests/Application.Tests/Maps/MapCollectionTests.cs
@@ -0,0 +1,96 @@
+namespace CTF.Application.Tests.Maps;
+
+public class MapCollectionTests
+{
+ static readonly int[] InvalidMapCases = [-1, 1000, MapCollection.Count];
+
+ [TestCase("de")]
+ [TestCase("DE")]
+ [TestCase("dE")]
+ [TestCase("De")]
+ public void GetAll_WhenAllMapsAreObtainedWithFindBy_ShouldReturnsEnumerable(string findBy)
+ {
+ // Arrange
+ string[] expectedMaps =
+ [
+ "de_aztec",
+ "de_dust2",
+ "de_dust2_small",
+ "de_dust2_x1",
+ "de_dust2_x2",
+ "de_dust2_x3",
+ "de_dust5",
+ "DesertGlory"
+ ];
+
+ // Act
+ IEnumerable maps = MapCollection.GetAll(findBy);
+ string[] actual = maps.Select(map => map.Name).ToArray();
+
+ // Assert
+ actual.Should().BeEquivalentTo(expectedMaps);
+ }
+
+ [TestCaseSource(nameof(InvalidMapCases))]
+ public void GetById_WhenMapIdIsInvalid_ShouldReturnsFailureResult(int mapId)
+ {
+ // Arrange
+ string expectedMessage = Messages.InvalidMap;
+
+ // Act
+ Result result = MapCollection.GetById(mapId);
+
+ // Asserts
+ result.IsSuccess.Should().BeFalse();
+ result.Message.Should().Be(expectedMessage);
+ }
+
+ [TestCase(0)]
+ [TestCase(1)]
+ [TestCase(2)]
+ [TestCase(3)]
+ [TestCase(4)]
+ public void GetById_WhenMapIdIsValid_ShouldReturnsSuccessResult(int mapId)
+ {
+ // Arrange
+
+ // Act
+ Result result = MapCollection.GetById(mapId);
+
+ // Asserts
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Id.Should().Be(mapId);
+ result.Value.Name.Should().NotBeNullOrEmpty();
+ }
+
+ [Test]
+ public void GetByName_WhenMapNameIsNotFound_ShouldReturnsFailureResult()
+ {
+ // Arrange
+ string mapName = "NotFound";
+ string expectedMessage = Messages.MapNotFound;
+
+ // Act
+ Result result = MapCollection.GetByName(mapName);
+
+ // Asserts
+ result.IsSuccess.Should().BeFalse();
+ result.Message.Should().Be(expectedMessage);
+ }
+
+ [TestCase("de_aztec")]
+ [TestCase("DE_AZTEC")]
+ [TestCase("De_Aztec")]
+ public void GetByName_WhenMapNameIsFound_ShouldReturnsSuccessResult(string mapName)
+ {
+ // Arrange
+ string expectedMapName = "de_aztec";
+
+ // Act
+ Result result = MapCollection.GetByName(mapName);
+
+ // Asserts
+ result.IsSuccess.Should().BeTrue();
+ result.Value.Name.Should().Be(expectedMapName);
+ }
+}
diff --git a/tests/Application.Tests/Maps/MapInfoServiceTests.cs b/tests/Application.Tests/Maps/MapInfoServiceTests.cs
new file mode 100644
index 00000000..a0f5bcab
--- /dev/null
+++ b/tests/Application.Tests/Maps/MapInfoServiceTests.cs
@@ -0,0 +1,163 @@
+namespace CTF.Application.Tests.Maps;
+
+public class MapInfoServiceTests
+{
+ [Test]
+ public void Constructor_WhenLoadMethodIsNotInvoked_CurrentMapShouldNotBeNull()
+ {
+ // Arrange
+
+ // Act
+ var service = new MapInfoService();
+ CurrentMap currentMap = service.Read();
+
+ // Assert
+ currentMap.Should().NotBeNull();
+ }
+
+ [TestCaseSource(typeof(MapInfoServiceTestCases))]
+ public void Load_WhenMapIsLoadedFromFileSystem_ShouldCreateInstanceOfTypeCurrentMap(CurrentMap expectedCurrentMap)
+ {
+ // Arrange
+ var service = new MapInfoService();
+ IMap map = MapCollection.GetById(expectedCurrentMap.Id).Value;
+
+ // Act
+ service.Load(map);
+ CurrentMap actual = service.Read();
+
+ // Assert
+ actual.Should().BeEquivalentTo(expectedCurrentMap);
+ }
+
+ private class MapInfoServiceTestCases : IEnumerable
+ {
+ public IEnumerator GetEnumerator()
+ {
+ yield return new CurrentMap(
+ map: GetMap("Aim_Headshot"),
+ alphaTeamLocations:
+ [
+ new SpawnLocation(-129.5612f,81.0056f,3.1172f,156.7189f),
+ new SpawnLocation(-127.6526f,87.7695f,3.1172f,156.7189f),
+ new SpawnLocation(-134.9525f,90.2646f,3.1172f,167.0590f),
+ new SpawnLocation(-138.4023f,83.5352f,3.1172f,163.6123f),
+ new SpawnLocation(-145.3216f,85.1476f,3.1172f,163.6123f),
+ new SpawnLocation(-152.4534f,80.4830f,3.1094f,163.6123f),
+ new SpawnLocation(-161.3326f,83.2152f,3.1094f,167.3724f),
+ new SpawnLocation(-159.6665f,98.1220f,3.1121f,165.8291f),
+ new SpawnLocation(-173.8980f,102.8791f,3.1668f,162.3824f),
+ new SpawnLocation(-186.4793f,93.0401f,3.1172f,162.3824f)
+ ],
+ betaTeamLocations:
+ [
+ new SpawnLocation(-277.0338f,-85.0175f,2.8617f,345.0341f),
+ new SpawnLocation(-277.7510f,-90.4126f,2.7030f,345.0341f),
+ new SpawnLocation(-270.0297f,-92.0674f,3.0969f,345.0341f),
+ new SpawnLocation(-263.8904f,-93.2464f,3.1172f,345.0341f),
+ new SpawnLocation(-262.2849f,-87.2403f,3.1172f,345.0341f),
+ new SpawnLocation(-255.3565f,-84.1217f,3.1172f,345.0341f),
+ new SpawnLocation(-245.9794f,-86.1564f,3.1172f,345.0341f),
+ new SpawnLocation(-247.4499f,-99.9677f,3.1172f,345.0341f),
+ new SpawnLocation(-235.9863f,-102.5671f,3.1094f,345.3474f),
+ new SpawnLocation(-220.4884f,-110.9102f,3.1172f,352.5542f)
+ ]);
+
+ yield return new CurrentMap(
+ map: GetMap("RC_Battlefield"),
+ alphaTeamLocations:
+ [
+ new SpawnLocation(-1136.4539f,1098.1666f,1345.8258f,269.4457f),
+ new SpawnLocation(-1135.1912f,1093.8064f,1345.8119f,267.8791f),
+ new SpawnLocation(-1135.6179f,1088.5378f,1345.8096f,267.8791f),
+ new SpawnLocation(-1135.4375f,1082.0457f,1345.8021f,267.8791f),
+ new SpawnLocation(-1135.2130f,1075.4181f,1345.7941f,267.8791f),
+ new SpawnLocation(-1135.0983f,1066.7756f,1345.7872f,267.8791f),
+ new SpawnLocation(-1129.9963f,1067.1566f,1345.7528f,274.1458f),
+ new SpawnLocation(-1129.5995f,1074.0201f,1345.7581f,274.1458f),
+ new SpawnLocation(-1129.0844f,1081.9758f,1345.7615f,268.5057f),
+ new SpawnLocation(-1131.0197f,1092.0566f,1345.7826f,264.1190f)
+ ],
+ betaTeamLocations:
+ [
+ new SpawnLocation(-969.8113f,1021.9630f,1345.0767f,89.9275f),
+ new SpawnLocation(-970.1436f,1028.6224f,1345.0679f,88.9874f),
+ new SpawnLocation(-970.4197f,1036.5406f,1345.0582f,88.9874f),
+ new SpawnLocation(-969.6072f,1046.1462f,1345.0544f,88.9874f),
+ new SpawnLocation(-970.4572f,1055.3048f,1345.0397f,88.9874f),
+ new SpawnLocation(-976.7345f,1054.0677f,1344.9979f,90.8674f),
+ new SpawnLocation(-976.9385f,1044.5823f,1345.0059f,88.6741f),
+ new SpawnLocation(-977.0988f,1035.4301f,1345.0137f,88.6741f),
+ new SpawnLocation(-976.4217f,1024.6754f,1345.0288f,92.1208f),
+ new SpawnLocation(-976.2883f,1020.3914f,1345.0339f,93.3741f)
+ ],
+ interior: 10);
+
+ yield return new CurrentMap(
+ map: GetMap("TheBunker"),
+ alphaTeamLocations:
+ [
+ new SpawnLocation(592.8154f,-2433.9148f,10.8944f,79.0350f),
+ new SpawnLocation(593.3266f,-2430.6772f,10.8968f,75.2750f),
+ new SpawnLocation(593.7708f,-2427.1843f,10.9065f,81.8550f),
+ new SpawnLocation(594.2177f,-2425.0789f,10.9081f,78.0950f),
+ new SpawnLocation(591.5002f,-2424.4595f,10.9011f,77.1550f),
+ new SpawnLocation(590.2183f,-2427.9417f,10.8983f,77.1550f),
+ new SpawnLocation(588.6526f,-2431.8225f,10.8952f,77.1550f),
+ new SpawnLocation(584.6981f,-2430.9185f,10.9023f,348.4808f),
+ new SpawnLocation(575.6008f,-2417.5117f,10.9036f,252.9133f),
+ new SpawnLocation(584.4872f,-2417.1072f,10.9053f,166.1425f)
+ ],
+ betaTeamLocations:
+ [
+ new SpawnLocation(891.1278f,-2397.3025f,19.8204f,87.1585f),
+ new SpawnLocation(889.6392f,-2407.8728f,20.2593f,80.5784f),
+ new SpawnLocation(887.4858f,-2418.1658f,21.0038f,80.5784f),
+ new SpawnLocation(885.3065f,-2425.9482f,21.5207f,49.8714f),
+ new SpawnLocation(875.5358f,-2419.4922f,21.7868f,67.4183f),
+ new SpawnLocation(876.6066f,-2412.1060f,22.0539f,80.2651f),
+ new SpawnLocation(877.5095f,-2404.7627f,22.3557f,80.2651f),
+ new SpawnLocation(878.8505f,-2396.0938f,22.6503f,92.4852f),
+ new SpawnLocation(870.1289f,-2393.8157f,23.9384f,92.4852f),
+ new SpawnLocation(864.5809f,-2400.9045f,25.0956f,94.6786f)
+ ],
+ worldTime: 23);
+
+ yield return new CurrentMap(
+ map: GetMap("CrackFactory"),
+ alphaTeamLocations:
+ [
+ new SpawnLocation(2548.7009f,-1283.2224f,1060.9844f,230.3022f),
+ new SpawnLocation(2565.8301f,-1281.7773f,1065.3672f,238.1356f),
+ new SpawnLocation(2575.7759f,-1283.3206f,1065.3672f,177.9750f),
+ new SpawnLocation(2580.8525f,-1284.6443f,1065.3579f,88.0476f),
+ new SpawnLocation(2568.5518f,-1283.6564f,1060.9844f,181.0851f),
+ new SpawnLocation(2565.0220f,-1290.9614f,1060.9844f,224.6389f),
+ new SpawnLocation(2565.4963f,-1301.7297f,1060.9844f,275.0860f),
+ new SpawnLocation(2558.1384f,-1304.1283f,1060.9844f,272.2661f),
+ new SpawnLocation(2558.3372f,-1298.2233f,1060.9844f,272.2661f),
+ new SpawnLocation(2558.2466f,-1293.2781f,1062.0391f,260.6727f)
+ ],
+ betaTeamLocations:
+ [
+ new SpawnLocation(2532.1660f,-1283.6971f,1031.4219f,270.0725f),
+ new SpawnLocation(2532.5823f,-1292.2178f,1031.4219f,275.7126f),
+ new SpawnLocation(2532.9485f,-1302.3477f,1031.4219f,269.4458f),
+ new SpawnLocation(2541.2852f,-1303.9135f,1031.4219f,269.4458f),
+ new SpawnLocation(2542.1389f,-1293.7726f,1031.4219f,262.8658f),
+ new SpawnLocation(2542.5986f,-1282.2380f,1031.4219f,264.1191f),
+ new SpawnLocation(2550.1204f,-1282.6653f,1031.4219f,263.8058f),
+ new SpawnLocation(2551.0386f,-1293.5071f,1031.4219f,277.2793f),
+ new SpawnLocation(2553.0278f,-1305.5601f,1031.4219f,277.2793f),
+ new SpawnLocation(2560.5957f,-1291.2533f,1031.4219f,265.3724f)
+ ],
+ interior: 2,
+ weather: 9,
+ worldTime: 22);
+ }
+
+ private static IMap GetMap(string name) => MapCollection.GetByName(name).Value;
+ IEnumerator IEnumerable.GetEnumerator()
+ => this.GetEnumerator();
+ }
+}
diff --git a/tests/Application.Tests/Maps/TimeLeftTests.cs b/tests/Application.Tests/Maps/TimeLeftTests.cs
new file mode 100644
index 00000000..c846dcb4
--- /dev/null
+++ b/tests/Application.Tests/Maps/TimeLeftTests.cs
@@ -0,0 +1,194 @@
+namespace CTF.Application.Tests.Maps;
+
+public class TimeLeftTests
+{
+ private readonly TimeLeft _timeLeft;
+ public TimeLeftTests()
+ {
+ _timeLeft = new TimeLeft();
+ var minutes = new Minutes(3);
+ _timeLeft.SetInterval(minutes);
+ }
+
+ [Test]
+ public void IsCompleted_WhenTimeLeftIsCompleted_ShouldReturnsTrue()
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var seconds = new Seconds(0);
+ var expectedText = "00:00";
+ timeLeft.SetInterval(seconds);
+
+ // Act
+ bool actual = timeLeft.IsCompleted();
+
+ // Asserts
+ actual.Should().BeTrue();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [Test]
+ public void IsCompleted_WhenTimeLeftIsNotCompleted_ShouldReturnsFalse()
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var minutes = new Minutes(10);
+ var expectedText = "10:00";
+ timeLeft.SetInterval(minutes);
+
+ // Act
+ bool actual = timeLeft.IsCompleted();
+
+ // Asserts
+ actual.Should().BeFalse();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [TestCase(-1)]
+ [TestCase(-2)]
+ [TestCase(16)]
+ [TestCase(17)]
+ public void SetInterval_WhenMinutesIntervalIsOutOfRange_ShouldReturnsFailureResult(int value)
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var minutes = new Minutes(value);
+ var expectedText = "15:00";
+
+ // Act
+ Result result = timeLeft.SetInterval(minutes);
+
+ // Asserts
+ result.IsSuccess.Should().BeFalse();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [TestCase(0, "00:00")]
+ [TestCase(1, "01:00")]
+ [TestCase(3, "03:00")]
+ [TestCase(5, "05:00")]
+ [TestCase(9, "09:00")]
+ [TestCase(10, "10:00")]
+ [TestCase(11, "11:00")]
+ [TestCase(12, "12:00")]
+ [TestCase(14, "14:00")]
+ [TestCase(15, "15:00")]
+ public void SetInterval_WhenMinutesIntervalIsNotOutOfRange_ShouldReturnsSuccessResult(int value, string expectedText)
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var minutes = new Minutes(value);
+
+ // Act
+ Result result = timeLeft.SetInterval(minutes);
+
+ // Asserts
+ result.IsSuccess.Should().BeTrue();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [TestCase(-1)]
+ [TestCase(-2)]
+ [TestCase(901)]
+ [TestCase(902)]
+ public void SetInterval_WhenSecondsIntervalIsOutOfRange_ShouldReturnsFailureResult(int value)
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var seconds = new Seconds(value);
+ var expectedText = "15:00";
+
+ // Act
+ Result result = timeLeft.SetInterval(seconds);
+
+ // Asserts
+ result.IsSuccess.Should().BeFalse();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [TestCase(0, "00:00")]
+ [TestCase(1, "00:01")]
+ [TestCase(5, "00:05")]
+ [TestCase(60, "01:00")]
+ [TestCase(300, "05:00")]
+ [TestCase(428, "07:08")]
+ [TestCase(590, "09:50")]
+ [TestCase(608, "10:08")]
+ [TestCase(840, "14:00")]
+ [TestCase(900, "15:00")]
+ public void SetInterval_WhenSecondsIntervalIsNotOutOfRange_ShouldReturnsSuccessResult(int value, string expectedText)
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var seconds = new Seconds(value);
+
+ // Act
+ Result result = timeLeft.SetInterval(seconds);
+
+ // Asserts
+ result.IsSuccess.Should().BeTrue();
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [Test]
+ public void Constructor_WhenObjectIsCreated_TextDrawShouldBeTheDefault()
+ {
+ // Arrange
+ var expectedText = "15:00";
+
+ // Act
+ var timeLeft = new TimeLeft();
+
+ // Assert
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [Test]
+ public void Reset()
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var minutes = new Minutes(5);
+ var expectedText = "15:00";
+ timeLeft.SetInterval(minutes);
+
+ // Act
+ timeLeft.Reset();
+
+ // Assert
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [TestCaseSource(typeof(DecreaseTimeLeftTestCases))]
+ public void Decrease_WhenTimeRemainingIsNotCompleted_ShouldContinueToDecrease(string expectedText)
+ {
+ // Arrange
+
+ // Act
+ _timeLeft.Decrease();
+
+ // Assert
+ _timeLeft.TextDraw.Should().Be(expectedText);
+ }
+
+ [Test]
+ public void Decrease_WhenTimeRemainingIsZero_ShouldNotContinueToDecrease()
+ {
+ // Arrange
+ var timeLeft = new TimeLeft();
+ var minutes = new Seconds(4);
+ var expectedText = "00:00";
+ timeLeft.SetInterval(minutes);
+
+ // Act
+ timeLeft.Decrease(); // 00:03
+ timeLeft.Decrease(); // 00:02
+ timeLeft.Decrease(); // 00:01
+ timeLeft.Decrease(); // 00:00
+ timeLeft.Decrease(); // 00:00
+ timeLeft.Decrease(); // 00:00
+
+ // Assert
+ timeLeft.TextDraw.Should().Be(expectedText);
+ }
+}
diff --git a/tests/Application.Tests/Usings.cs b/tests/Application.Tests/Usings.cs
index e0eb58bb..b2198178 100644
--- a/tests/Application.Tests/Usings.cs
+++ b/tests/Application.Tests/Usings.cs
@@ -11,3 +11,5 @@
global using CTF.Application.Players.Accounts;
global using CTF.Application.Teams;
global using CTF.Application.Teams.Flags;
+global using CTF.Application.Maps;
+global using CTF.Application.Maps.Services;