Add movement phase order validation logic

This commit is contained in:
Jaculabilis 2022-03-12 20:29:00 -08:00
parent 3ad0dbc086
commit 8a7e90b949
7 changed files with 618 additions and 3 deletions

View File

@ -0,0 +1,93 @@
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Adjudicate;
/// <summary>
/// Helper class for common operations shared between adjudicators.
/// </summary>
internal static class AdjudicatorHelpers
{
/// <summary>
/// Invalidate all orders that do not match a predicate.
/// </summary>
/// <param name="predicate">A predicate that invalid orders will fail to match.</param>
/// <param name="reason">The reason to be given for order invalidation.</param>
/// <param name="orders">The set of orders to check.</param>
/// <param name="validOrders">The list of orders that passed the predicate.</param>
/// <param name="invalidOrders">
/// A list of order validation results. Orders invalidated by the predicate will be appended
/// to this list.
/// </param>
public static void InvalidateIfNotMatching<OrderType>(
Func<OrderType, bool> predicate,
ValidationReason reason,
ref List<OrderType> orders,
ref List<OrderValidation> invalidOrders)
where OrderType : Order
{
ILookup<bool, OrderType> results = orders.ToLookup<OrderType, bool>(predicate);
invalidOrders = invalidOrders
.Concat(results[false].Select(order => order.Invalidate(reason)))
.ToList();
orders = results[true].ToList();
}
/// <summary>
/// Invalidate all orders that are not of an allowed order type.
/// </summary>
/// <param name="orders">The set of orders to check.</param>
/// <param name="validOrderTypes">A list of <see cref="Order"/> types that are allowed.</param>
/// <param name="validOrders">The list of orders of allowed types.</param>
/// <param name="invalidOrders">
/// A list of order validation results. Orders of invalid types will be appended to this list.
/// </param>
public static void InvalidateWrongTypes(
List<Type> validOrderTypes,
ref List<Order> orders,
ref List<OrderValidation> invalidOrders)
{
List<Type> nonOrderTypes = validOrderTypes
.Where(t => !t.IsSubclassOf(typeof(Order)))
.ToList();
if (nonOrderTypes.Any())
{
throw new ArgumentException($"Unknown order type: {nonOrderTypes.Select(t => t.FullName).First()}");
}
InvalidateIfNotMatching(
order => !validOrderTypes.Contains(order.GetType()),
ValidationReason.InvalidOrderTypeForPhase,
ref orders,
ref invalidOrders);
}
/// <summary>
/// Invalidate all orders for units not owned by the ordering power.
/// </summary>
/// <param name="orders">The set of orders to check.</param>
/// <param name="validOrders">The list of orders with valid targets.</param>
/// <param name="invalidOrders">
/// A list of order validation results. Orders by the wrong powers will be appended to this list.
/// </param>
public static void InvalidateWrongPower(
List<Order> orders,
ref List<Order> validOrders,
ref List<OrderValidation> invalidOrders)
{
InvalidateIfNotMatching(
order => order switch {
ConvoyOrder convoy => convoy.Power == convoy.Unit.Power,
DisbandOrder disband => disband.Power == disband.Unit.Power,
HoldOrder hold => hold.Power == hold.Unit.Power,
MoveOrder move => move.Power == move.Unit.Power,
RetreatOrder retreat => retreat.Power == retreat.Unit.Power,
SupportHoldOrder support => support.Power == support.Unit.Power,
SupportMoveOrder support => support.Power == support.Unit.Power,
// Any order not given to a unit by definition cannot be given to a unit of the wrong power
_ => true,
},
ValidationReason.InvalidUnitForPower,
ref orders,
ref invalidOrders);
}
}

View File

@ -1,4 +1,5 @@
using MultiversalDiplomacy.Orders;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Adjudicate;
@ -12,11 +13,12 @@ public interface IPhaseAdjudicator
/// which should be rejected before adjudication. Adjudication should be performed on
/// all orders in the output for which <see cref="OrderValidation.Valid"/> is true.
/// </summary>
/// <param name="world">The global game state.</param>
/// <param name="orders">Orders to validate for adjudication.</param>
/// <returns>
/// A list of order validation results. Note that this list may be longer than the input
/// list if illegal orders were replaced with hold orders, as there will be an invalid
/// result for the illegal order and a valid result for the replacement order.
/// </returns>
public IEnumerable<OrderValidation> ValidateOrders(IEnumerable<Order> orders);
public List<OrderValidation> ValidateOrders(World world, List<Order> orders);
}

View File

@ -0,0 +1,262 @@
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Adjudicate;
/// <summary>
/// Adjudicator for the movement phase.
/// </summary>
internal class MovementPhaseAdjudicator : IPhaseAdjudicator
{
public List<OrderValidation> ValidateOrders(World world, List<Order> orders)
{
// The basic workflow of this function will be to look for invalid orders, remove these
// from the working set of orders, and then perform one final check for duplicate orders
// at the end. This is to comply with DATC 4.D.3's requirement that a unit that receives
// a legal and an illegal order follows the legal order rather than holding.
List<OrderValidation> validationResults = new List<OrderValidation>();
// Invalidate any orders that aren't a legal type for this phase and remove them from the
// working set.
AdjudicatorHelpers.InvalidateWrongTypes(
new List<Type>
{
typeof(HoldOrder),
typeof(MoveOrder),
typeof(ConvoyOrder),
typeof(SupportHoldOrder),
typeof(SupportMoveOrder)
},
ref orders,
ref validationResults);
// Invalidate any orders by a power that were given to another power's units and remove
// them from the working set.
AdjudicatorHelpers.InvalidateWrongPower(orders, ref orders, ref validationResults);
// Since all the order types in this phase are UnitOrders, downcast to get the Unit.
List<UnitOrder> unitOrders = orders.OfType<UnitOrder>().ToList();
// Invalidate any order given to a unit in the past.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !order.Unit.Season.Futures.Any(),
ValidationReason.IneligibleForOrder,
ref unitOrders,
ref validationResults);
/***************
* HOLD ORDERS *
***************/
// Hold orders are always valid.
List<HoldOrder> holdOrders = unitOrders.OfType<HoldOrder>().ToList();
/***************
* MOVE ORDERS *
***************/
// Move order validity is far more complicated, due to multiversal time travel and convoys.
List<MoveOrder> moveOrders = unitOrders.OfType<MoveOrder>().ToList();
// Trivial check: armies cannot move to water and fleets cannot move to land.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => (order.Unit.Type == UnitType.Army && order.Location.Type == LocationType.Land)
|| (order.Unit.Type == UnitType.Fleet && order.Location.Type == LocationType.Water),
ValidationReason.IllegalDestinationType,
ref moveOrders,
ref validationResults);
// Trivial check: a unit cannot move to where it already is.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !(order.Location == order.Unit.Location && order.Season == order.Unit.Season),
ValidationReason.DestinationMatchesOrigin,
ref moveOrders,
ref validationResults);
// If the unit is moving to a valid destination that isn't where it already is, then the
// move order is valid if there is a path from the origin to the destination. In the easy
// case, the destination is directly adjacent to the origin with respect to the map, the
// turn, and the timeline. These moves are valid. Any other move must be checked for
// potential validity as a convoy move.
ILookup<bool, MoveOrder> moveOrdersByAdjacency = moveOrders
.ToLookup(order =>
// Map adjacency
order.Unit.Location.Adjacents.Contains(order.Location)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& order.Unit.Season.InAdjacentTimeline(order.Season));
List<MoveOrder> adjacentMoveOrders = moveOrdersByAdjacency[true].ToList();
List<MoveOrder> nonAdjacentMoveOrders = moveOrdersByAdjacency[false].ToList();
// Only armies can move to non-adjacent destinations, since fleets cannot be convoyed.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Unit.Type == UnitType.Army,
ValidationReason.UnreachableDestination,
ref nonAdjacentMoveOrders,
ref validationResults);
// For all remaining convoyable move orders, check if there is a path between the origin
// and the destination.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => PathFinder.ConvoyPathExists(world, order),
ValidationReason.UnreachableDestination,
ref nonAdjacentMoveOrders,
ref validationResults);
/*****************
* CONVOY ORDERS *
*****************/
// A convoy order must be to a fleet and target an army.
List<ConvoyOrder> convoyOrders = unitOrders.OfType<ConvoyOrder>().ToList();
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Unit.Type == UnitType.Fleet && order.Target.Type == UnitType.Army,
ValidationReason.InvalidOrderTypeForUnit,
ref convoyOrders,
ref validationResults);
// A convoy for an illegal move is illegal, which means all the move validity checks
// now need to be repeated for the convoy target.
// Trivial check: cannot convoy to non-coastal province.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Location.Type == LocationType.Land
&& order.Location.Province.Locations.Any(loc => loc.Type == LocationType.Water),
ValidationReason.IllegalDestinationType,
ref convoyOrders,
ref validationResults);
// Trivial check: cannot convoy a unit to its own location
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !(
order.Location == order.Target.Location
&& order.Season == order.Target.Season),
ValidationReason.DestinationMatchesOrigin,
ref convoyOrders,
ref validationResults);
// By definition, the move enabled by a convoy order is a convoyable move order, so it
// should be checked for a convoy path.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => PathFinder.ConvoyPathExists(world, order),
ValidationReason.UnreachableDestination,
ref convoyOrders,
ref validationResults);
/***********************
* SUPPORT-HOLD ORDERS *
***********************/
// Support-hold orders are typically valid if the supporting unit can move to the
// destination.
List<SupportHoldOrder> supportHoldOrders = unitOrders.OfType<SupportHoldOrder>().ToList();
// Support-hold orders are invalid if the unit supports itself.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Unit != order.Target,
ValidationReason.NoSelfSupport,
ref supportHoldOrders,
ref validationResults);
// Support-hold orders are invalid if the supporting unit couldn't move to the destination
// without a convoy. This is the same direct adjacency calculation as above, except that
// the supporting unit only needs to be able to move to the *province*, even if the target
// is holding in a location within that province that the supporting unit couldn't move to.
// The reverse is not true: a unit cannot support another province if that province is only
// reachable from a different location in the unit's province.
AdjudicatorHelpers.InvalidateIfNotMatching(
order =>
// Map adjacency with respect to province
order.Unit.Location.Adjacents.Any(
adjLocation => adjLocation.Province == order.Target.Location.Province)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Target.Season.Turn) <= 1
// Timeline adjacency
&& order.Unit.Season.InAdjacentTimeline(order.Target.Season),
ValidationReason.UnreachableSupport,
ref supportHoldOrders,
ref validationResults);
/***********************
* SUPPORT-MOVE ORDERS *
***********************/
// Support-move orders, like support-hold orders, are typically valid if the supporting
// unit can move to the destination.
List<SupportMoveOrder> supportMoveOrders = unitOrders.OfType<SupportMoveOrder>().ToList();
// Support-move orders are invalid if the unit supports a move to any location in its own
// province.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Unit.Location.Province != order.Location.Province,
ValidationReason.NoSupportMoveAgainstSelf,
ref supportMoveOrders,
ref validationResults);
// Support-move orders, like support-hold orders, are valid only if the supporting unit
// can reach the destination *province* of the move, even if the destination *location*
// is unreachable (DATC 6.B.4). The same is not true of reachability from another location
// in the supporting unit's province (DATC 6.B.5).
AdjudicatorHelpers.InvalidateIfNotMatching(
order =>
// Map adjacency with respect to province
order.Unit.Location.Adjacents.Any(
adjLocation => adjLocation.Province == order.Location.Province)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& order.Unit.Season.InAdjacentTimeline(order.Season),
ValidationReason.UnreachableSupport,
ref supportMoveOrders,
ref validationResults);
// One more edge case: support-move orders by a fleet for an army are illegal if that army
// requires a convoy and the supporting fleet is a part of the only convoy path (DATC
// 6.D.31).
// TODO: support convoy path check with "as if this fleet were missing"
// Collect the valid orders together
unitOrders =
holdOrders.Cast<UnitOrder>()
.Concat(adjacentMoveOrders)
.Concat(nonAdjacentMoveOrders)
.Concat(convoyOrders)
.Concat(supportHoldOrders)
.Concat(supportMoveOrders)
.ToList();
// DATC 4.D.3 prefers that multiple orders to the same unit in the same order set be
// replaced by a hold order. Since this function only takes one combined list of orders,
// it is assumed that the caller has combined the order sets from all powers in a way that
// is compliant with DATC 4.D.1-2. If there are still duplicate orders in the input, they
// were not addressed by 4.D.1-2 and will be handled according to 4.D.3, i.e. replaced with
// hold orders. Note that this happens last, after all other invalidations have been
// applied in order to comply with what 4.D.3 specifies about illegal orders.
List<Unit> duplicateOrderedUnits = unitOrders
.GroupBy(o => o.Unit)
.Where(orderGroup => orderGroup.Count() > 1)
.Select(orderGroup => orderGroup.Key)
.ToList();
List<UnitOrder> duplicateOrders = unitOrders
.Where(o => duplicateOrderedUnits.Contains(o.Unit))
.ToList();
List<UnitOrder> validOrders = unitOrders.Except(duplicateOrders).ToList();
validationResults = validationResults
.Concat(duplicateOrders.Select(o => o.Invalidate(ValidationReason.DuplicateOrders)))
.Concat(validOrders.Select(o => o.Validate(ValidationReason.Valid)))
.ToList();
// Finally, add implicit hold orders for units without legal orders.
List<Unit> allOrderableUnits = world.Units
.Where(unit => !unit.Season.Futures.Any())
.ToList();
HashSet<Unit> orderedUnits = validOrders.Select(order => order.Unit).ToHashSet();
List<Unit> unorderedUnits = allOrderableUnits
.Where(unit => !orderedUnits.Contains(unit))
.ToList();
List<HoldOrder> implicitHolds = unorderedUnits
.Select(unit => new HoldOrder(unit.Power, unit))
.ToList();
validationResults = validationResults
.Concat(implicitHolds.Select(o => o.Validate(ValidationReason.Valid)))
.ToList();
return validationResults;
}
}

View File

@ -0,0 +1,108 @@
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Adjudicate;
/// <summary>
/// Helper class encapsulating the convoy pathfindind code.
/// </summary>
public static class PathFinder
{
/// <summary>
/// Determines if a convoy path exists for a move in a convoy order.
/// </summary>
public static bool ConvoyPathExists(World world, ConvoyOrder order)
=> ConvoyPathExists(world, order.Target, order.Location, order.Season);
/// <summary>
/// Determines if a convoy path exists for a move order.
/// </summary>
public static bool ConvoyPathExists(World world, MoveOrder order)
=> ConvoyPathExists(world, order.Unit, order.Location, order.Season);
private static bool ConvoyPathExists(
World world,
Unit movingUnit,
Location unitLocation,
Season unitSeason)
{
// A convoy path exists between two locations if both are land locations in provinces that
// also have coasts, and between those coasts there is a path of adjacent sea provinces
// (not coastal) that are occupied by fleets. The move order is valid even if the fleets
// belong to another power or were not given convoy orders; it will simply fail.
IDictionary<(Location location, Season season), Unit> fleets = world.Units
.Where(unit => unit.Type == UnitType.Fleet)
.ToDictionary(unit => (unit.Location, unit.Season));
// Verify that the origin is a coastal province.
if (movingUnit.Location.Type != LocationType.Land) return false;
IEnumerable<Location> originCoasts = movingUnit.Location.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!originCoasts.Any()) return false;
// Verify that the destination is a coastal province.
if (unitLocation.Type != LocationType.Land) return false;
IEnumerable<Location> destCoasts = unitLocation.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!destCoasts.Any()) return false;
// Seed the to-visit set with the origin coasts. Coastal locations will be filtered out of
// locations added to the to-visit set, but the logic will still work with these as
// starting points.
Queue<(Location location, Season season)> toVisit = new(
originCoasts.Select(location => (location, unitSeason)));
HashSet<(Location, Season)> visited = new();
// Begin pathfinding.
while (toVisit.Any())
{
// Visit the next point in the queue.
(Location currentLocation, Season currentSeason) = toVisit.Dequeue();
visited.Add((currentLocation, currentSeason));
var adjacents = GetAdjacentPoints(currentLocation, currentSeason);
foreach ((Location adjLocation, Season adjSeason) in adjacents)
{
// If the destination is adjacent, then a path exists.
if (destCoasts.Contains(adjLocation) && unitSeason == adjSeason) return true;
// If not, add this location to the to-visit set if it isn't a coast, has a fleet,
// and hasn't already been visited.
if (!adjLocation.Province.Locations.Any(l => l.Type == LocationType.Land)
&& fleets.ContainsKey((adjLocation, adjSeason))
&& !visited.Contains((adjLocation, adjSeason)))
{
toVisit.Enqueue((adjLocation, adjSeason));
}
}
}
// If the destination was never reached, then no path exists.
return false;
}
private static List<(Location, Season)> GetAdjacentPoints(Location location, Season season)
{
List<(Location, Season)> adjacentPoints = new();
List<Location> adjacentLocations = location.Adjacents.ToList();
List<Season> adjacentSeasons = season.GetAdjacentSeasons().ToList();
foreach (Location adjacentLocation in adjacentLocations)
{
adjacentPoints.Add((adjacentLocation, season));
}
foreach (Season adjacentSeason in adjacentSeasons)
{
adjacentPoints.Add((location, adjacentSeason));
}
foreach (Location adjacentLocation in adjacentLocations)
{
foreach (Season adjacentSeason in adjacentSeasons)
{
adjacentPoints.Add((adjacentLocation, adjacentSeason));
}
}
return adjacentPoints;
}
}

View File

@ -18,7 +18,52 @@ public enum ValidationReason
IllegalOrderReplacedWithHold = 2,
/// <summary>
/// Another order was submitted that replaced this order.
/// The power that issued this order does not control the ordered unit.
/// </summary>
SupersededByLaterOrder = 3,
InvalidUnitForPower = 3,
/// <summary>
/// The ordered unit received conflicting orders and all orders were invalidated.
/// </summary>
DuplicateOrders = 4,
/// <summary>
/// An army was ordered into the sea or a fleet was ordered onto the land.
/// </summary>
IllegalDestinationType = 5,
/// <summary>
/// A unit was ordered to move to where it already is.
/// </summary>
DestinationMatchesOrigin = 6,
/// <summary>
/// The destination of the move is not reachable from the origin.
/// </summary>
UnreachableDestination = 7,
/// <summary>
/// The order type is not valid for the unit.
/// </summary>
InvalidOrderTypeForUnit = 8,
/// <summary>
/// A unit was ordered to support itself.
/// </summary>
NoSelfSupport = 9,
/// <summary>
/// A unit was ordered to support a location it could not reach.
/// </summary>
UnreachableSupport = 10,
/// <summary>
/// A unit was ordered to support a move to its own province.
/// </summary>
NoSupportMoveAgainstSelf = 11,
/// <summary>
/// A unit was ordered that is not currently eligible to receive orders.
/// </summary>
IneligibleForOrder = 12,
}

View File

@ -44,12 +44,24 @@ public class Season
/// </summary>
private TimelineFactory Timelines { get; }
/// <summary>
/// Future seasons created directly from this season.
/// </summary>
public IEnumerable<Season> Futures => this.FutureList;
private List<Season> FutureList { get; }
private Season(Season? past, int turn, int timeline, TimelineFactory factory)
{
this.Past = past;
this.Turn = turn;
this.Timeline = timeline;
this.Timelines = factory;
this.FutureList = new();
if (past != null)
{
past.FutureList.Add(this);
}
}
/// <summary>
@ -89,6 +101,9 @@ public class Season
/// <summary>
/// Returns whether this season is in an adjacent timeline to another season.
/// Seasons are considered to be in adjacent timelines if they are in the same timeline,
/// one is in a timeline that branched from the other's timeline, or both are in timelines
/// that branched from the same point.
/// </summary>
public bool InAdjacentTimeline(Season other)
{
@ -106,4 +121,55 @@ public class Season
// Both branched off of the same point
|| thisRoot.Past == otherRoot.Past;
}
/// <summary>
/// Returns all seasons that are adjacent to this season.
/// </summary>
public IEnumerable<Season> GetAdjacentSeasons()
{
List<Season> adjacents = new();
// The immediate past and all immediate futures are adjacent.
if (this.Past != null) adjacents.Add(this.Past);
adjacents.AddRange(this.FutureList);
// Find all adjacent timelines by finding all timelines that branched off of this season's
// timeline, i.e. all futures of this season's past that have different timelines. Also
// include any timelines that branched off of the timeline this timeline branched off from.
List<Season> adjacentTimelineRoots = new();
Season? current;
for (current = this;
current?.Past?.Timeline != null && current.Past.Timeline == current.Timeline;
current = current.Past)
{
adjacentTimelineRoots.AddRange(
current.FutureList.Where(s => s.Timeline != current.Timeline));
}
// At the end of the for loop, if this season is part of the first timeline, then current
// is the root season (current.past == null); if this season is in a branched timeline,
// then current is the branch timeline's root season (current.past.timeline !=
// current.timeline). There are co-branches if this season is in a branched timeline, since
// the first timeline by definition cannot have co-branches.
if (current?.Past != null)
{
IEnumerable<Season> cobranchRoots = current.Past.FutureList
.Where(s => s.Timeline != current.Timeline && s.Timeline != current.Past.Timeline);
adjacentTimelineRoots.AddRange(cobranchRoots);
}
// Walk up all alternate timelines to find seasons within one turn of this season.
foreach (Season timelineRoot in adjacentTimelineRoots)
{
for (Season? branchSeason = timelineRoot;
branchSeason != null && branchSeason.Turn <= this.Turn + 1;
branchSeason = branchSeason.FutureList
.FirstOrDefault(s => s?.Timeline == branchSeason.Timeline, null))
{
if (branchSeason.Turn >= this.Turn - 1) adjacents.Add(branchSeason);
}
}
return adjacents;
}
}

View File

@ -0,0 +1,39 @@
namespace MultiversalDiplomacy.Model;
/// <summary>
/// The global game state.
/// </summary>
public class World
{
/// <summary>
/// The game map.
/// </summary>
public IEnumerable<Province> Provinces { get; }
/// <summary>
/// The game powers.
/// </summary>
public IEnumerable<Power> Powers { get; }
/// <summary>
/// The state of the multiverse.
/// </summary>
public IEnumerable<Season> Seasons { get; }
/// <summary>
/// All units in the multiverse.
/// </summary>
public IEnumerable<Unit> Units { get; }
public World(
IEnumerable<Province> provinces,
IEnumerable<Power> powers,
IEnumerable<Season> seasons,
IEnumerable<Unit> units)
{
this.Provinces = provinces;
this.Powers = powers;
this.Seasons = seasons;
this.Units = units;
}
}