diff --git a/server/.editorconfig b/server/.editorconfig index 05762d5..0df2083 100644 --- a/server/.editorconfig +++ b/server/.editorconfig @@ -61,6 +61,7 @@ dotnet_diagnostic.IDE0077.severity = error dotnet_diagnostic.IDE0043.severity = error dotnet_diagnostic.IDE0059.severity = error dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0048.severity = none # Xml files [*.xml] @@ -142,7 +143,7 @@ dotnet_style_allow_statement_immediately_after_block_experimental = false:error dotnet_code_quality_unused_parameters = all:error dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:error dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:error -dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:error +dotnet_style_parentheses_in_other_binary_operators = never_if_unnecessary:error dotnet_style_parentheses_in_other_operators = never_if_unnecessary:error dotnet_style_qualification_for_field = false:error dotnet_style_qualification_for_property = false:error diff --git a/server/Adjudication/Adjudicator.cs b/server/Adjudication/Adjudicator.cs index e2bb8a2..9c7272d 100644 --- a/server/Adjudication/Adjudicator.cs +++ b/server/Adjudication/Adjudicator.cs @@ -1,27 +1,21 @@ using Entities; using Enums; +using Utilities; namespace Adjudication; -public class Adjudicator +public class Adjudicator(Validator validator) { - public void Adjudicate(World world, List map) + private readonly Validator validator = validator; + + public void Adjudicate(World world, List regions) { - foreach (var order in world.Orders.Where(o => o.Status == OrderStatus.New)) - { - order.Status = OrderStatus.Failure; - } + validator.Validate(world, regions); var previousBoard = world.Boards.Last(); var year = previousBoard.Phase == Phase.Winter ? previousBoard.Year + 1 : previousBoard.Year; - var phase = previousBoard.Phase switch - { - Phase.Spring => Phase.Fall, - Phase.Fall => Phase.Winter, - Phase.Winter => Phase.Spring, - _ => throw new ArgumentOutOfRangeException("Phase not found") - }; + var phase = previousBoard.Phase.NextPhase(); world.Boards.Add(new Board { @@ -54,5 +48,7 @@ public void Adjudicate(World world, List map) }, }).ToList(), }); + + world.Iteration++; } } diff --git a/server/Adjudication/Validation/AdjacencyValidator.cs b/server/Adjudication/Validation/AdjacencyValidator.cs new file mode 100644 index 0000000..1960ed2 --- /dev/null +++ b/server/Adjudication/Validation/AdjacencyValidator.cs @@ -0,0 +1,64 @@ +using Entities; +using Enums; +using Utilities; + +namespace Adjudication; + +public class AdjacencyValidator(List regions) +{ + private readonly List regions = regions; + + public bool IsValidDirectMove(Unit unit, Location location, Location destination) + { + if (location.Phase == Phase.Winter || destination.Phase == Phase.Winter) + { + return false; + } + + var isSameBoard = location.Timeline == destination.Timeline + && location.Year == destination.Year + && location.Phase == destination.Phase; + + return isSameBoard + ? IsValidIntraBoardMove(unit, location, destination) + : !unit.MustRetreat && IsValidInterBoardMove(unit, location, destination); + } + + public bool IsValidInterBoardMove(Unit unit, Location location, Location destination) + { + var locationId = location.RegionId; + var destinationId = destination.RegionId; + + if (Constants.UseStrictAdjacencies ? locationId != destinationId : !IsValidIntraBoardMove(unit, location, destination)) + { + return false; + } + + var yearDistance = location.Year - destination.Year; + var phaseDistance = (int)location.Phase - (int)destination.Phase; + var timeDistance = Math.Abs(2 * yearDistance + phaseDistance); + + var multiverseDistance = Math.Abs(location.Timeline - destination.Timeline); + + return timeDistance <= 1 && multiverseDistance <= 1 && (timeDistance == 0 || multiverseDistance == 0); + } + + public bool IsValidIntraBoardMove(Unit unit, Location location, Location destination) + { + var locationId = location.RegionId; + var destinationId = destination.RegionId; + + var region = regions.First(r => r.Id == locationId); + + var connection = region.Connections.FirstOrDefault(c => c.Regions.Any(r => r.Id == destinationId)); + if (connection == null) + { + return false; + } + + var isValidArmyMove = unit.Type == UnitType.Army && connection.Type != ConnectionType.Sea; + var isValidFleetMove = unit.Type == UnitType.Fleet && connection.Type != ConnectionType.Land; + return isValidArmyMove || isValidFleetMove; + } +} + diff --git a/server/Adjudication/Validation/ConvoyPathValidator.cs b/server/Adjudication/Validation/ConvoyPathValidator.cs new file mode 100644 index 0000000..3af555f --- /dev/null +++ b/server/Adjudication/Validation/ConvoyPathValidator.cs @@ -0,0 +1,72 @@ +using Entities; +using Enums; + +namespace Adjudication; + +public class ConvoyPathValidator(World world, List regions, AdjacencyValidator adjacencyValidator) +{ + private readonly World world = world; + private readonly List regions = regions; + + private readonly AdjacencyValidator adjacencyValidator = adjacencyValidator; + + public bool HasPath(Unit unit, Location location, Location destination) + { + if (unit.Type == UnitType.Fleet) + { + return false; + } + + var startRegion = regions.First(r => r.Id == location.RegionId); + var endRegion = regions.First(r => r.Id == destination.RegionId); + + var startsOnCoast = startRegion.Type == RegionType.Coast + || regions.Where(r => r.ParentId == startRegion.Id).Any(r => r.Type == RegionType.Coast); + var endsOnCoast = endRegion.Type == RegionType.Coast + || regions.Where(r => r.ParentId == endRegion.Id).Any(r => r.Type == RegionType.Coast); + + if (!startsOnCoast || !endsOnCoast) + { + return false; + } + + var convoysInPath = world.Orders.OfType().Where(c => + c.NeedsValidation + && c.Midpoint == location + && c.Destination == destination).ToList(); + + if (convoysInPath.Count == 0) + { + return false; + } + + var depthFirstSearch = new DepthFirstSearch(convoysInPath, adjacencyValidator); + return depthFirstSearch.HasPath(unit, location, destination); + } + + private class DepthFirstSearch(List convoys, AdjacencyValidator adjacencyValidator) + { + private readonly List convoys = convoys; + private readonly AdjacencyValidator adjacencyValidator = adjacencyValidator; + + private readonly List visitedConvoys = []; + + public bool HasPath(Unit unit, Location location, Location destination) + { + if (adjacencyValidator.IsValidDirectMove(unit, location, destination)) + { + return true; + } + + var convoy = convoys.FirstOrDefault(c => c.Location == location); + if (convoy != null) + { + visitedConvoys.Add(convoy); + } + + return convoys + .Where(c => !visitedConvoys.Contains(c) && adjacencyValidator.IsValidDirectMove(c.Unit!, c.Location, location)) + .Any(c => HasPath(c.Unit!, c.Location, destination)); + } + } +} diff --git a/server/Adjudication/Validation/Validator.cs b/server/Adjudication/Validation/Validator.cs new file mode 100644 index 0000000..57f9e63 --- /dev/null +++ b/server/Adjudication/Validation/Validator.cs @@ -0,0 +1,138 @@ +using Entities; +using Enums; +using Factories; + +namespace Adjudication; + +public class Validator(DefaultWorldFactory defaultWorldFactory) +{ + private readonly DefaultWorldFactory defaultWorldFactory = defaultWorldFactory; + + private AdjacencyValidator adjacencyValidator = null!; + private ConvoyPathValidator convoyPathValidator = null!; + private World world = null!; + private List regions = null!; + + public void Validate(World world, List regions) + { + this.world = world; + this.regions = regions; + adjacencyValidator = new(regions); + convoyPathValidator = new(world, regions, adjacencyValidator); + + ValidateMoves(); + ValidateSupports(); + ValidateConvoys(); + ValidateBuilds(); + ValidateDisbands(); + ValidateRetreats(); + } + + private void ValidateMoves() + { + var moves = world.Orders.Where(o => o.NeedsValidation && !o.Unit!.MustRetreat).OfType(); + + foreach (var move in moves) + { + var canDirectMove = adjacencyValidator.IsValidDirectMove(move.Unit!, move.Location, move.Destination); + var canConvoyMove = convoyPathValidator.HasPath(move.Unit!, move.Location, move.Destination); + + move.Status = canDirectMove || canConvoyMove ? OrderStatus.New : OrderStatus.Invalid; + } + } + + private void ValidateSupports() + { + var supports = world.Orders.Where(o => o.NeedsValidation && !o.Unit!.MustRetreat).OfType(); + + foreach (var support in supports) + { + var canSupport = adjacencyValidator.IsValidDirectMove(support.Unit!, support.Location, support.Destination); + var hasMatchingMove = world.Orders + .OfType() + .Any(m => m.Location == support.Midpoint && m.Destination == support.Destination); + + support.Status = canSupport && hasMatchingMove ? OrderStatus.New : OrderStatus.Invalid; + } + } + + private void ValidateConvoys() + { + var convoys = world.Orders.Where(o => o.NeedsValidation && !o.Unit!.MustRetreat).OfType(); + + foreach (var convoy in convoys) + { + // TODO fix for convoying to/from land regions with child coasts + var convoysFromCoast = regions.First(r => r.Id == convoy.Midpoint.RegionId); + var convoysToCoast = regions.First(r => r.Id == convoy.Destination.RegionId); + var hasMatchingMove = world.Orders + .OfType() + .Any(m => m.Location == convoy.Midpoint && m.Destination == convoy.Destination); + + convoy.Status = hasMatchingMove ? OrderStatus.New : OrderStatus.Invalid; + } + } + + private void ValidateBuilds() + { + var builds = world.Orders.Where(o => o.NeedsValidation && !o.Unit!.MustRetreat).OfType(); + var homeCentres = defaultWorldFactory.CreateCentres(); + + foreach (var build in builds) + { + if (build.Location.Phase != Phase.Winter) + { + build.Status = OrderStatus.Invalid; + continue; + } + + var board = world.Boards + .FirstOrDefault(b => b.Timeline == build.Location.Timeline && b.Year == build.Location.Year && b.Phase == Phase.Winter); + var region = regions.First(r => r.Id == build.Location.RegionId); + var centre = homeCentres.FirstOrDefault(c => c.Location == build.Location); + var unit = build.Unit!; + + if (board == null || centre == null) + { + build.Status = OrderStatus.Invalid; + continue; + } + + var isCompatibleRegion = centre.Owner == unit.Owner; + var isCompatibleUnit = unit.Type == UnitType.Army && region.Type != RegionType.Sea + || unit.Type == UnitType.Fleet && region.Type == RegionType.Coast; + build.Status = isCompatibleRegion && isCompatibleUnit ? OrderStatus.New : OrderStatus.Invalid; + + // NB validation based on available build count to be done as part of adjudication + } + } + + private void ValidateDisbands() + { + var disbands = world.Orders.Where(o => o.NeedsValidation && !o.Unit!.MustRetreat).OfType(); + + foreach (var disband in disbands) + { + disband.Status = disband.Location.Phase != Phase.Winter ? OrderStatus.New : OrderStatus.Invalid; + + // NB validation based on available build count to be done as part of adjudication + } + } + + private void ValidateRetreats() + { + var retreats = world.Orders.Where(o => o.NeedsValidation && o.Unit!.MustRetreat); + + foreach (var retreat in retreats) + { + retreat.Status = retreat switch + { + Move move => adjacencyValidator.IsValidDirectMove(move.Unit!, move.Location, move.Destination) + ? OrderStatus.New + : OrderStatus.Invalid, + Disband => OrderStatus.New, + _ => OrderStatus.Invalid, + }; + } + } +} diff --git a/server/Entities/Orders/Convoy.cs b/server/Entities/Orders/Convoy.cs index e6289a7..683b7c0 100644 --- a/server/Entities/Orders/Convoy.cs +++ b/server/Entities/Orders/Convoy.cs @@ -1,7 +1,13 @@ -namespace Entities; +using Enums; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities; public class Convoy : Order { public Location Midpoint { get; set; } = null!; public Location Destination { get; set; } = null!; + + [NotMapped] + public override bool NeedsValidation => Status is OrderStatus.Invalid or OrderStatus.New; } diff --git a/server/Entities/Orders/Order.cs b/server/Entities/Orders/Order.cs index 1daf55a..7f5a0b2 100644 --- a/server/Entities/Orders/Order.cs +++ b/server/Entities/Orders/Order.cs @@ -1,4 +1,5 @@ using Enums; +using System.ComponentModel.DataAnnotations.Schema; namespace Entities; @@ -11,6 +12,9 @@ public abstract class Order public OrderStatus Status { get; set; } public int? UnitId { get; set; } - public virtual Unit? Unit { get; set; } + public virtual Unit? Unit { get; set; } // Nullability of this is annoying... public Location Location { get; set; } = null!; + + [NotMapped] + public virtual bool NeedsValidation => Status == OrderStatus.New; } diff --git a/server/Entities/Orders/Support.cs b/server/Entities/Orders/Support.cs index a08916e..2d5d2c1 100644 --- a/server/Entities/Orders/Support.cs +++ b/server/Entities/Orders/Support.cs @@ -1,7 +1,13 @@ -namespace Entities; +using Enums; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Entities; public class Support : Order { public Location Midpoint { get; set; } = null!; public Location Destination { get; set; } = null!; + + [NotMapped] + public override bool NeedsValidation => Status is OrderStatus.Invalid or OrderStatus.New; } diff --git a/server/Factories/DefaultWorldFactory.cs b/server/Factories/DefaultWorldFactory.cs index f83c0fd..ffcbc3e 100644 --- a/server/Factories/DefaultWorldFactory.cs +++ b/server/Factories/DefaultWorldFactory.cs @@ -1,7 +1,7 @@ using Entities; using Enums; using System.Text.Json; -using Utils; +using Utilities; namespace Factories; @@ -22,7 +22,7 @@ public World CreateWorld() return world; } - private Board CreateBoard() => new() + public Board CreateBoard() => new() { Timeline = 1, Year = 1901, @@ -32,7 +32,7 @@ public World CreateWorld() Units = CreateUnits(), }; - private List CreateCentres() + public List CreateCentres() { var centresFile = File.ReadAllText(CentresFilePath); var centres = JsonSerializer.Deserialize>(centresFile, Constants.JsonOptions) @@ -48,7 +48,7 @@ private List CreateCentres() return centres; } - private List CreateUnits() + public List CreateUnits() { var unitsFile = File.ReadAllText(UnitsFilePath); var units = JsonSerializer.Deserialize>(unitsFile, Constants.JsonOptions) diff --git a/server/Factories/MapFactory.cs b/server/Factories/MapFactory.cs index bc36741..421ff4b 100644 --- a/server/Factories/MapFactory.cs +++ b/server/Factories/MapFactory.cs @@ -1,7 +1,7 @@ using Entities; using Enums; using System.Text.Json; -using Utils; +using Utilities; namespace Factories; diff --git a/server/Mappers/ModelMapper.cs b/server/Mappers/ModelMapper.cs index 39eb9d4..a464b00 100644 --- a/server/Mappers/ModelMapper.cs +++ b/server/Mappers/ModelMapper.cs @@ -39,7 +39,7 @@ public class ModelMapper(GameContext context) Midpoint = MapLocation(support.SupportLocation), Destination = MapLocation(support.Destination), }, - Models.Convoy convoy => new Entities.Support + Models.Convoy convoy => new Entities.Convoy { Status = status, Unit = unit, diff --git a/server/Program.cs b/server/Program.cs index 591c651..d3b4f8b 100644 --- a/server/Program.cs +++ b/server/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); diff --git a/server/Repositories/GameRepository.cs b/server/Repositories/GameRepository.cs index 2ab2d62..4cd7415 100644 --- a/server/Repositories/GameRepository.cs +++ b/server/Repositories/GameRepository.cs @@ -2,7 +2,7 @@ using Entities; using Enums; using Factories; -using Utils; +using Utilities; namespace Repositories; diff --git a/server/Repositories/WorldRepository.cs b/server/Repositories/WorldRepository.cs index a837b97..90bc295 100644 --- a/server/Repositories/WorldRepository.cs +++ b/server/Repositories/WorldRepository.cs @@ -3,7 +3,7 @@ using Entities; using Enums; using Microsoft.EntityFrameworkCore; -using Utils; +using Utilities; namespace Repositories; @@ -45,13 +45,11 @@ public async Task AddOrders(int gameId, Nation[] players, IEnumerable ord game.PlayersSubmitted = []; - var map = await context.Regions - .AsNoTracking() - .Include(r => r.Connections) + var regions = await context.Regions + .Include(r => r.Connections).ThenInclude(c => c.Regions) .ToListAsync(); - adjudicator.Adjudicate(world, map); - world.Iteration++; + adjudicator.Adjudicate(world, regions); logger.LogInformation("Adjudicated game {GameId}", gameId); } diff --git a/server/Utils/Constants.cs b/server/Utilities/Constants.cs similarity index 78% rename from server/Utils/Constants.cs rename to server/Utilities/Constants.cs index 3435ca1..6960a39 100644 --- a/server/Utils/Constants.cs +++ b/server/Utilities/Constants.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Utils; +namespace Utilities; public static class Constants { @@ -16,5 +16,7 @@ public static class Constants }; public static readonly List Nations = Enum.GetValues(typeof(Nation)).OfType().ToList(); + + public static readonly bool UseStrictAdjacencies = true; // TODO allow specified per game? } diff --git a/server/Utilities/Extensions.cs b/server/Utilities/Extensions.cs new file mode 100644 index 0000000..4446e5d --- /dev/null +++ b/server/Utilities/Extensions.cs @@ -0,0 +1,24 @@ +using Enums; + +namespace Utilities; + +public static class Extensions +{ + public static Phase NextPhase(this Phase phase) + => phase switch + { + Phase.Spring => Phase.Fall, + Phase.Fall => Phase.Winter, + Phase.Winter => Phase.Spring, + _ => throw new ArgumentOutOfRangeException(nameof(phase)) + }; + + public static Phase NextMajorPhase(this Phase phase) + => phase switch + { + Phase.Spring => Phase.Fall, + Phase.Fall => Phase.Spring, + Phase.Winter => Phase.Spring, + _ => throw new ArgumentOutOfRangeException(nameof(phase)) + }; +}