From 39c3aabe452bfb7ac972c2194638d0595b63f0f1 Mon Sep 17 00:00:00 2001 From: Jaculabilis Date: Tue, 8 Nov 2022 16:25:47 -0800 Subject: [PATCH] Implement AdvanceTimeline resolution --- .../AdjudicationDictionaryExtensions.cs | 21 ++ .../Adjudicate/Decision/AdvanceTimeline.cs | 3 + .../Adjudicate/Decision/MovementDecisions.cs | 144 +++++++------- .../Adjudicate/MovementPhaseAdjudicator.cs | 183 +++++++++++++++--- MultiversalDiplomacy/Model/OrderHistory.cs | 28 +++ MultiversalDiplomacy/Model/Season.cs | 2 +- MultiversalDiplomacy/Model/World.cs | 18 +- 7 files changed, 290 insertions(+), 109 deletions(-) create mode 100644 MultiversalDiplomacy/Adjudicate/AdjudicationDictionaryExtensions.cs create mode 100644 MultiversalDiplomacy/Model/OrderHistory.cs diff --git a/MultiversalDiplomacy/Adjudicate/AdjudicationDictionaryExtensions.cs b/MultiversalDiplomacy/Adjudicate/AdjudicationDictionaryExtensions.cs new file mode 100644 index 0000000..75c0306 --- /dev/null +++ b/MultiversalDiplomacy/Adjudicate/AdjudicationDictionaryExtensions.cs @@ -0,0 +1,21 @@ +namespace System.Collections.Generic; + +public static class AdjudicationDictionaryExtensions +{ + /// + /// Create and add a value to a dictionary only if the key is not already present. + /// + /// The dictionary to check for the key. + /// The key to check and use if it isn't already present. + /// A function that returns the value to insert if the key is not present. + public static void Ensure( + this IDictionary dictionary, + TKey key, + Func valueFunc) + { + if (!dictionary.ContainsKey(key)) + { + dictionary[key] = valueFunc(); + } + } +} \ No newline at end of file diff --git a/MultiversalDiplomacy/Adjudicate/Decision/AdvanceTimeline.cs b/MultiversalDiplomacy/Adjudicate/Decision/AdvanceTimeline.cs index 19f087c..aa4f7e7 100644 --- a/MultiversalDiplomacy/Adjudicate/Decision/AdvanceTimeline.cs +++ b/MultiversalDiplomacy/Adjudicate/Decision/AdvanceTimeline.cs @@ -8,6 +8,9 @@ public class AdvanceTimeline : BinaryAdjudicationDecision public Season Season { get; } public List Orders { get; } + public override string ToString() + => $"AdvanceTimeline({Season})"; + public AdvanceTimeline(Season season, IEnumerable orders) { this.Season = season; diff --git a/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs b/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs index 7379beb..34e4845 100644 --- a/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs +++ b/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs @@ -16,139 +16,139 @@ public class MovementDecisions public Dictionary AdvanceTimeline { get; } public IEnumerable Values => - this.IsDislodged.Values.Cast() - .Concat(this.HasPath.Values) - .Concat(this.GivesSupport.Values) - .Concat(this.HoldStrength.Values) - .Concat(this.AttackStrength.Values) - .Concat(this.DefendStrength.Values) - .Concat(this.PreventStrength.Values) - .Concat(this.DoesMove.Values) - .Concat(this.AdvanceTimeline.Values); + IsDislodged.Values.Cast() + .Concat(HasPath.Values) + .Concat(GivesSupport.Values) + .Concat(HoldStrength.Values) + .Concat(AttackStrength.Values) + .Concat(DefendStrength.Values) + .Concat(PreventStrength.Values) + .Concat(DoesMove.Values) + .Concat(AdvanceTimeline.Values); public MovementDecisions(World world, List orders) { - this.IsDislodged = new(); - this.HasPath = new(); - this.GivesSupport = new(); - this.HoldStrength = new(); - this.AttackStrength = new(); - this.DefendStrength = new(); - this.PreventStrength = new(); - this.DoesMove = new(); - this.AdvanceTimeline = new(); + IsDislodged = new(); + HasPath = new(); + GivesSupport = new(); + HoldStrength = new(); + AttackStrength = new(); + DefendStrength = new(); + PreventStrength = new(); + DoesMove = new(); + AdvanceTimeline = new(); - // Record which seasons are referenced by the order set. - HashSet orderedSeasons = new(); - foreach (UnitOrder order in orders.Cast()) + // The orders argument only contains the submitted orders. The adjudicator will need to adjudicate not only + // presently submitted orders, but also previously submitted orders if present orders affect the past. This + // necessitates doing some lookups to find all affected seasons. + + // At a minimum, the submitted orders imply a dislodge decision for each unit, which affects every season those + // orders were given to. + var submittedOrdersBySeason = orders.Cast().ToLookup(order => order.Unit.Season); + foreach (var group in submittedOrdersBySeason) { - _ = orderedSeasons.Add(order.Unit.Season); + AdvanceTimeline[group.Key] = new(group.Key, group); } - // Expand the order list to include any other seasons that are potentially affected. - // In the event that those seasons don't end up affected (all moves to it fail, all - // supports to it are cut), it is still safe to re-adjudicate everything because - // adjudication is deterministic and doesn't produce side effects. - HashSet affectedSeasons = new(); + // Create timeline decisions for each season potentially affected by the submitted orders. + // Since adjudication is deterministic and pure, if none of the affecting orders succeed, + // the adjudication decisions for the extra seasons will resolve the same way and the + // advance decision for the timeline will resolve false. foreach (Order order in orders) { switch (order) { case MoveOrder move: - if (!orderedSeasons.Contains(move.Season)) - { - affectedSeasons.Add(move.Season); - } + AdvanceTimeline.Ensure( + move.Season, + () => new(move.Season, world.OrderHistory[move.Season].Orders)); + AdvanceTimeline[move.Season].Orders.Add(move); break; case SupportHoldOrder supportHold: - if (!orderedSeasons.Contains(supportHold.Target.Season)) - { - affectedSeasons.Add(supportHold.Target.Season); - } + AdvanceTimeline.Ensure( + supportHold.Target.Season, + () => new(supportHold.Target.Season, world.OrderHistory[supportHold.Target.Season].Orders)); + AdvanceTimeline[supportHold.Target.Season].Orders.Add(supportHold); break; case SupportMoveOrder supportMove: - if (!orderedSeasons.Contains(supportMove.Target.Season)) - { - affectedSeasons.Add(supportMove.Target.Season); - } + AdvanceTimeline.Ensure( + supportMove.Target.Season, + () => new(supportMove.Target.Season, world.OrderHistory[supportMove.Target.Season].Orders)); + AdvanceTimeline[supportMove.Target.Season].Orders.Add(supportMove); + AdvanceTimeline.Ensure( + supportMove.Season, + () => new(supportMove.Season, world.OrderHistory[supportMove.Season].Orders)); + AdvanceTimeline[supportMove.Season].Orders.Add(supportMove); break; } } - foreach (Season season in affectedSeasons) + + // Get the orders in the affected timelines. + List relevantOrders = AdvanceTimeline.Values.SelectMany(at => at.Orders).ToList(); + + // Create a hold strength decision with an associated order for every province with a unit. + foreach (UnitOrder order in relevantOrders) { - orders.AddRange(world.GivenOrders[season]); + HoldStrength[order.Unit.Point] = new(order.Unit.Point, order); } - // Create the relevant decisions for each order. - foreach (UnitOrder order in orders.Cast()) + // Create all other relevant decisions for each order in the affected timelines. + foreach (UnitOrder order in relevantOrders) { // Create a dislodge decision for this unit. - List incoming = orders + List incoming = relevantOrders .OfType() .Where(order.IsIncoming) .ToList(); - this.IsDislodged[order.Unit] = new(order, incoming); - - // Ensure a hold strength decision exists. Overwrite any previous once, since it may - // have been created without an order by a previous move or support. - this.HoldStrength[order.Unit.Point] = new(order.Unit.Point, order); + IsDislodged[order.Unit] = new(order, incoming); if (order is MoveOrder move) { // Find supports corresponding to this move. - List supports = orders + List supports = relevantOrders .OfType() .Where(support => support.IsSupportFor(move)) .ToList(); // Determine if this move is a head-to-head battle. - MoveOrder? opposingMove = orders + MoveOrder? opposingMove = relevantOrders .OfType() - .FirstOrDefault(other => other != null && other.IsOpposing(move), null); + .FirstOrDefault(other => other!.IsOpposing(move), null); // Find competing moves. - List competing = orders + List competing = relevantOrders .OfType() .Where(move.IsCompeting) .ToList(); // Create the move-related decisions. - this.HasPath[move] = new(move); - this.AttackStrength[move] = new(move, supports, opposingMove); - this.DefendStrength[move] = new(move, supports); - this.PreventStrength[move] = new(move, supports, opposingMove); - this.DoesMove[move] = new(move, opposingMove, competing); + HasPath[move] = new(move); + AttackStrength[move] = new(move, supports, opposingMove); + DefendStrength[move] = new(move, supports); + PreventStrength[move] = new(move, supports, opposingMove); + DoesMove[move] = new(move, opposingMove, competing); // Ensure a hold strength decision exists for the destination. - if (!this.HoldStrength.ContainsKey(move.Point)) - { - this.HoldStrength[move.Point] = new(move.Point); - } + HoldStrength.Ensure(move.Point, () => new(move.Point)); } else if (order is SupportOrder support) { // Create the support decision. - this.GivesSupport[support] = new(support, incoming); + GivesSupport[support] = new(support, incoming); // Ensure a hold strength decision exists for the target's province. - if (!this.HoldStrength.ContainsKey(support.Target.Point)) - { - this.HoldStrength[support.Target.Point] = new(support.Target.Point); - } + HoldStrength.Ensure(support.Target.Point, () => new(support.Target.Point)); if (support is SupportHoldOrder supportHold) { - this.HoldStrength[support.Target.Point].Supports.Add(supportHold); + HoldStrength[support.Target.Point].Supports.Add(supportHold); } else if (support is SupportMoveOrder supportMove) { // Ensure a hold strength decision exists for the target's destination. - if (!this.HoldStrength.ContainsKey(supportMove.Point)) - { - this.HoldStrength[supportMove.Point] = new(supportMove.Point); - } + HoldStrength.Ensure(supportMove.Point, () => new(supportMove.Point)); } } } diff --git a/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs b/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs index faab4ce..bb4c712 100644 --- a/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs +++ b/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs @@ -291,7 +291,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator // This will noop without progress if the decision is already resolved progress |= ResolveDecision(decision, world, decisions, depth: 2); } - } while (progress); + } while (progress && decisions.Values.Any(decision => !decision.Resolved)); if (decisions.Values.Any(d => !d.Resolved)) { @@ -304,9 +304,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator public World UpdateWorld(World world, List decisions) { + logger.Log(0, "Updating world"); Dictionary moves = decisions .OfType() .ToDictionary(dm => dm.Order); + Dictionary dislodges = decisions + .OfType() + .ToDictionary(dm => dm.Order.Unit); // All moves to a particular season in a single phase result in the same future. Keep a // record of when a future season has been created. @@ -314,25 +318,37 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator List createdUnits = new(); List retreats = new(); + // Populate createdFutures with the timeline fork decisions + logger.Log(1, "Processing AdvanceTimeline decisions"); + foreach (AdvanceTimeline advanceTimeline in decisions.OfType()) + { + logger.Log(2, "{0} = {1}", advanceTimeline, advanceTimeline.Outcome?.ToString() ?? "?"); + if (advanceTimeline.Outcome == true) + { + // A timeline that doesn't have a future yet simply continues. Otherwise, it forks. + createdFutures[advanceTimeline.Season] = !advanceTimeline.Season.Futures.Any() + ? advanceTimeline.Season.MakeNext() + : advanceTimeline.Season.MakeFork(); + } + } + // Successful move orders result in the unit moving to the destination and creating a new // future, while unsuccessful move orders are processed the same way as non-move orders. + logger.Log(1, "Processing successful moves"); foreach (DoesMove doesMove in moves.Values) { - if (doesMove.Outcome == true) + logger.Log(2, "{0} = {1}", doesMove, doesMove.Outcome?.ToString() ?? "?"); + Season moveSeason = doesMove.Order.Season; + if (doesMove.Outcome == true && createdFutures.ContainsKey(moveSeason)) { - if (!createdFutures.TryGetValue(doesMove.Order.Season, out Season? future)) - { - // A timeline that doesn't have a future yet simply continues. Otherwise, it forks. - future = !doesMove.Order.Season.Futures.Any() - ? doesMove.Order.Season.MakeNext() - : doesMove.Order.Season.MakeFork(); - createdFutures[doesMove.Order.Season] = future; - } - createdUnits.Add(doesMove.Order.Unit.Next(doesMove.Order.Location, future)); + Unit next = doesMove.Order.Unit.Next(doesMove.Order.Location, createdFutures[moveSeason]); + logger.Log(3, "Advancing unit to {0}", next); + createdUnits.Add(next); } } // Process unsuccessful moves, all holds, and all supports. + logger.Log(1, "Processing stationary orders"); foreach (IsDislodged isDislodged in decisions.OfType()) { UnitOrder order = isDislodged.Order; @@ -343,22 +359,26 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator continue; } - if (!createdFutures.TryGetValue(order.Unit.Season, out Season? future)) + logger.Log(2, "{0} = {1}", isDislodged, isDislodged.Outcome?.ToString() ?? "?"); + if (!createdFutures.ContainsKey(order.Unit.Season)) { - // Any unit given an order is, by definition, at the front of a timeline. - future = order.Unit.Season.MakeNext(); - createdFutures[order.Unit.Season] = future; + logger.Log(3, "Skipping order because no future was created"); + continue; } - // For each stationary unit that wasn't dislodged, continue it into the future. + Season future = createdFutures[order.Unit.Season]; if (isDislodged.Outcome == false) { - createdUnits.Add(order.Unit.Next(order.Unit.Location, future)); + // Non-dislodged units continue into the future. + Unit next = order.Unit.Next(order.Unit.Location, future); + logger.Log(3, "Advancing unit to {0}", next); + createdUnits.Add(next); } else { // Create a retreat for each dislodged unit. // TODO check valid retreats and disbands + logger.Log(3, "Creating retreat for {0}", order.Unit); var validRetreats = order.Unit.Location.Adjacents .Select(loc => (future, loc)) .ToList(); @@ -367,15 +387,35 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator } } - // Sort the new orders by season to save for posterity in case of attacks from the future. - IEnumerable>> newOrders = decisions - .OfType() - .GroupBy( - keySelector: d => d.Order.Unit.Season, - elementSelector: d => d.Order as Order) - .Where(group => !world.GivenOrders.ContainsKey(group.Key)) - .Select(group => new KeyValuePair>( - group.Key, new(group.ToList()))); + // Record the adjudication results to the season's order history + Dictionary newHistory = new(); + foreach (UnitOrder unitOrder in decisions.OfType().Select(d => d.Order)) + { + newHistory.Ensure(unitOrder.Unit.Season, () => new()); + OrderHistory history = newHistory[unitOrder.Unit.Season]; + // TODO does this add every order to every season?? + history.Orders.Add(unitOrder); + history.IsDislodgedOutcomes[unitOrder.Unit] = dislodges[unitOrder.Unit].Outcome == true; + if (unitOrder is MoveOrder moveOrder) + { + history.DoesMoveOutcomes[moveOrder] = moves[moveOrder].Outcome == true; + } + } + + // Log the new order history + foreach ((Season season, OrderHistory history) in newHistory) + { + string verb = world.OrderHistory.ContainsKey(season) ? "Updating" : "Adding"; + logger.Log(1, "{0} history for {1}", verb, season); + foreach (UnitOrder order in history.Orders) + { + logger.Log(2, "{0}", order); + } + } + + IEnumerable> updatedHistory = world.OrderHistory + .Where(kvp => !newHistory.ContainsKey(kvp.Key)) + .Concat(newHistory); // TODO provide more structured information about order outcomes @@ -383,7 +423,9 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator seasons: world.Seasons.Concat(createdFutures.Values), units: world.Units.Concat(createdUnits), retreats: retreats, - orders: world.GivenOrders.Concat(newOrders)); + orders: updatedHistory); + + logger.Log(0, "Completed update"); return updated; } @@ -423,6 +465,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator logger.Log(depth, "ResolveDecision({0})", decision); return decision.Resolved ? false : decision switch { + AdvanceTimeline d => ResolveAdvanceTimeline(d, world, decisions, depth + 1), IsDislodged d => ResolveIsUnitDislodged(d, world, decisions, depth + 1), HasPath d => ResolveDoesMoveHavePath(d, world, decisions, depth + 1), GivesSupport d => ResolveIsSupportGiven(d, world, decisions, depth + 1), @@ -435,6 +478,92 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator }; } + private bool ResolveAdvanceTimeline( + AdvanceTimeline decision, + World world, + MovementDecisions decisions, + int depth) + { + logger.Log(depth, "AdvanceTimeline({0})", decision.Season); + bool progress = false; + + // A season at the head of a timeline always advances. + if (!decision.Season.Futures.Any()) + { + progress |= LoggedUpdate(decision, true, depth, "A timeline head always advances"); + return progress; + } + + // The season target of a successful move always advances. + IEnumerable incomingMoveOrders = decision.Orders + .OfType() + .Where(order => order.Season == decision.Season); + logger.Log(depth, "decision.Orders = {0}", string.Join(", ", decision.Orders)); + logger.Log(depth, "incomingMoveOrders = {0}", string.Join(", ", incomingMoveOrders)); + foreach (MoveOrder moveOrder in incomingMoveOrders) + { + DoesMove doesMove = decisions.DoesMove[moveOrder]; + progress |= ResolveDecision(doesMove, world, decisions, depth + 1); + if (doesMove.Outcome == true) + { + progress |= LoggedUpdate(decision, true, depth, $"Advanced by {doesMove.Order}"); + return progress; + } + } + + // TODO needs to also resolve true if anyone moves into this timeline + + // Seasons not at the head of a timeline advance if the outcome of a battle is changed. + // The outcome of a battle is changed if: + // 1. The outcome of a dislodge decision is changed, + // 2. The outcome of an intra-timeline move decision is changed, or + // 3. The outcome of an inter-timeline move decision with that season as the destination is + // changed. + OrderHistory history = world.OrderHistory[decision.Season]; + bool anyUnresolved = false; + foreach (UnitOrder order in decision.Orders) + { + // TODO these aren't timeline-specific + IsDislodged dislodged = decisions.IsDislodged[order.Unit]; + progress |= ResolveDecision(dislodged, world, decisions, depth + 1); + bool previous = history.IsDislodgedOutcomes[order.Unit]; + if (dislodged.Resolved && dislodged.Outcome != previous) + { + progress |= LoggedUpdate( + decision, + true, + depth, + $"History changed for {order.Unit}: dislodge {previous} => {dislodged.Outcome}"); + return progress; + } + anyUnresolved |= !dislodged.Resolved; + + if (order is MoveOrder moveOrder) + { + DoesMove moves = decisions.DoesMove[moveOrder]; + progress |= ResolveDecision(moves, world, decisions, depth + 1); + bool previousMove = history.DoesMoveOutcomes[moveOrder]; + if (moves.Resolved && moves.Outcome != previousMove) + { + progress |= LoggedUpdate( + decision, + true, + depth, + $"History changed for {order}: moves {previousMove} => {moves.Outcome}"); + return progress; + } + anyUnresolved |= !moves.Resolved; + } + } + + if (!anyUnresolved) + { + progress |= LoggedUpdate(decision, false, depth, "No resolved changes to history"); + } + + return progress; + } + private bool ResolveIsUnitDislodged( IsDislodged decision, World world, diff --git a/MultiversalDiplomacy/Model/OrderHistory.cs b/MultiversalDiplomacy/Model/OrderHistory.cs new file mode 100644 index 0000000..c52cc45 --- /dev/null +++ b/MultiversalDiplomacy/Model/OrderHistory.cs @@ -0,0 +1,28 @@ +using System.Collections.ObjectModel; + +using MultiversalDiplomacy.Orders; + +namespace MultiversalDiplomacy.Model; + +public class OrderHistory +{ + public List Orders; + + public Dictionary IsDislodgedOutcomes; + + public Dictionary DoesMoveOutcomes; + + public OrderHistory() + : this(new(), new(), new()) + {} + + public OrderHistory( + List orders, + Dictionary isDislodgedOutcomes, + Dictionary doesMoveOutcomes) + { + this.Orders = new(orders); + this.IsDislodgedOutcomes = new(isDislodgedOutcomes); + this.DoesMoveOutcomes = new(doesMoveOutcomes); + } +} \ No newline at end of file diff --git a/MultiversalDiplomacy/Model/Season.cs b/MultiversalDiplomacy/Model/Season.cs index 5337379..cbbc7d8 100644 --- a/MultiversalDiplomacy/Model/Season.cs +++ b/MultiversalDiplomacy/Model/Season.cs @@ -71,7 +71,7 @@ public class Season public override string ToString() { - return $"{this.Turn}:{this.Timeline}"; + return $"{this.Timeline}@{this.Turn}"; } /// diff --git a/MultiversalDiplomacy/Model/World.cs b/MultiversalDiplomacy/Model/World.cs index 0d145cb..799933e 100644 --- a/MultiversalDiplomacy/Model/World.cs +++ b/MultiversalDiplomacy/Model/World.cs @@ -42,7 +42,7 @@ public class World /// /// Orders given to units in each season. /// - public ReadOnlyDictionary> GivenOrders { get; } + public ReadOnlyDictionary OrderHistory { get; } /// /// Immutable game options. @@ -59,7 +59,7 @@ public class World Season rootSeason, ReadOnlyCollection units, ReadOnlyCollection retreatingUnits, - ReadOnlyDictionary> givenOrders, + ReadOnlyDictionary orderHistory, Options options) { this.Provinces = provinces; @@ -68,7 +68,7 @@ public class World this.RootSeason = rootSeason; this.Units = units; this.RetreatingUnits = retreatingUnits; - this.GivenOrders = givenOrders; + this.OrderHistory = orderHistory; this.Options = options; } @@ -82,7 +82,7 @@ public class World ReadOnlyCollection? seasons = null, ReadOnlyCollection? units = null, ReadOnlyCollection? retreatingUnits = null, - ReadOnlyDictionary>? givenOrders = null, + ReadOnlyDictionary? orderHistory = null, Options? options = null) : this( provinces ?? previous.Provinces, @@ -91,7 +91,7 @@ public class World previous.RootSeason, // Can't change the root season units ?? previous.Units, retreatingUnits ?? previous.RetreatingUnits, - givenOrders ?? previous.GivenOrders, + orderHistory ?? previous.OrderHistory, options ?? previous.Options) { } @@ -109,7 +109,7 @@ public class World root, new(new List()), new(new List()), - new(new Dictionary>()), + new(new Dictionary()), new Options()); } @@ -123,7 +123,7 @@ public class World IEnumerable? seasons = null, IEnumerable? units = null, IEnumerable? retreats = null, - IEnumerable>>? orders = null) + IEnumerable>? orders = null) => new World( previous: this, seasons: seasons == null @@ -135,8 +135,8 @@ public class World retreatingUnits: retreats == null ? this.RetreatingUnits : new(retreats.ToList()), - givenOrders: orders == null - ? this.GivenOrders + orderHistory: orders == null + ? this.OrderHistory : new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))); ///