diff --git a/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs b/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs index c8d8e39..14c5cc7 100644 --- a/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs +++ b/MultiversalDiplomacy/Adjudicate/Decision/MovementDecisions.cs @@ -24,7 +24,7 @@ public class MovementDecisions .Concat(this.PreventStrength.Values) .Concat(this.DoesMove.Values); - public MovementDecisions(List orders) + public MovementDecisions(World world, List orders) { this.IsDislodged = new(); this.HasPath = new(); @@ -35,6 +35,50 @@ public class MovementDecisions this.PreventStrength = new(); this.DoesMove = new(); + // Record which seasons are referenced by the order set. + HashSet orderedSeasons = new(); + foreach (UnitOrder order in orders.Cast()) + { + _ = orderedSeasons.Add(order.Unit.Season); + } + + // 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(); + foreach (Order order in orders) + { + switch (order) + { + case MoveOrder move: + if (!orderedSeasons.Contains(move.Season)) + { + affectedSeasons.Add(move.Season); + } + break; + + case SupportHoldOrder supportHold: + if (!orderedSeasons.Contains(supportHold.Target.Season)) + { + affectedSeasons.Add(supportHold.Target.Season); + } + break; + + case SupportMoveOrder supportMove: + if (!orderedSeasons.Contains(supportMove.Target.Season)) + { + affectedSeasons.Add(supportMove.Target.Season); + } + break; + } + } + foreach (Season season in affectedSeasons) + { + orders.AddRange(world.GivenOrders[season]); + } + + // Create the relevant decisions for each order. foreach (UnitOrder order in orders.Cast()) { // Create a dislodge decision for this unit. diff --git a/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs b/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs index b79f8f3..b982610 100644 --- a/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs +++ b/MultiversalDiplomacy/Adjudicate/MovementPhaseAdjudicator.cs @@ -1,3 +1,5 @@ +using System.Collections.ObjectModel; + using MultiversalDiplomacy.Adjudicate.Decision; using MultiversalDiplomacy.Model; using MultiversalDiplomacy.Orders; @@ -266,23 +268,21 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator public List AdjudicateOrders(World world, List orders) { // Define all adjudication decisions to be made. - MovementDecisions decisions = new(orders); - - List unresolvedDecisions = decisions.Values.ToList(); + MovementDecisions decisions = new(world, orders); // Adjudicate all decisions. bool progress = false; do { progress = false; - foreach (AdjudicationDecision decision in unresolvedDecisions.ToList()) + foreach (AdjudicationDecision decision in decisions.Values) { + // This will noop without progress if the decision is already resolved progress |= ResolveDecision(decision, world, decisions); - if (decision.Resolved) unresolvedDecisions.Remove(decision); } } while (progress); - if (unresolvedDecisions.Any()) + if (decisions.Values.Any(d => !d.Resolved)) { throw new ApplicationException("Some orders not resolved!"); } @@ -355,12 +355,23 @@ 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()))); + // TODO provide more structured information about order outcomes World updated = world.Update( seasons: world.Seasons.Concat(createdFutures.Values), units: world.Units.Concat(createdUnits), - retreats: retreats); + retreats: retreats, + orders: world.GivenOrders.Concat(newOrders)); return updated; } diff --git a/MultiversalDiplomacy/Model/World.cs b/MultiversalDiplomacy/Model/World.cs index 42161b5..602a9c3 100644 --- a/MultiversalDiplomacy/Model/World.cs +++ b/MultiversalDiplomacy/Model/World.cs @@ -1,5 +1,7 @@ using System.Collections.ObjectModel; +using MultiversalDiplomacy.Orders; + namespace MultiversalDiplomacy.Model; /// @@ -37,6 +39,11 @@ public class World /// public ReadOnlyCollection RetreatingUnits { get; } + /// + /// Orders given to units in each season. + /// + public ReadOnlyDictionary> GivenOrders { get; } + /// /// Immutable game options. /// @@ -52,6 +59,7 @@ public class World Season rootSeason, ReadOnlyCollection units, ReadOnlyCollection retreatingUnits, + ReadOnlyDictionary> givenOrders, Options options) { this.Provinces = provinces; @@ -60,6 +68,7 @@ public class World this.RootSeason = rootSeason; this.Units = units; this.RetreatingUnits = retreatingUnits; + this.GivenOrders = givenOrders; this.Options = options; } @@ -73,6 +82,7 @@ public class World ReadOnlyCollection? seasons = null, ReadOnlyCollection? units = null, ReadOnlyCollection? retreatingUnits = null, + ReadOnlyDictionary>? givenOrders = null, Options? options = null) : this( provinces ?? previous.Provinces, @@ -81,6 +91,7 @@ public class World previous.RootSeason, // Can't change the root season units ?? previous.Units, retreatingUnits ?? previous.RetreatingUnits, + givenOrders ?? previous.GivenOrders, options ?? previous.Options) { } @@ -98,6 +109,7 @@ public class World root, new(new List()), new(new List()), + new(new Dictionary>()), new Options()); } @@ -110,12 +122,22 @@ public class World public World Update( IEnumerable? seasons = null, IEnumerable? units = null, - IEnumerable? retreats = null) + IEnumerable? retreats = null, + IEnumerable>>? orders = null) => new World( previous: this, - seasons: seasons == null ? this.Seasons : new(seasons.ToList()), - units: units == null ? this.Units : new(units.ToList()), - retreatingUnits: retreats == null ? this.RetreatingUnits : new(retreats.ToList())); + seasons: seasons == null + ? this.Seasons + : new(seasons.ToList()), + units: units == null + ? this.Units + : new(units.ToList()), + retreatingUnits: retreats == null + ? this.RetreatingUnits + : new(retreats.ToList()), + givenOrders: orders == null + ? this.GivenOrders + : new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))); /// /// Create a new world with new units created from unit specs. Units specs are in the format diff --git a/MultiversalDiplomacyTests/TestCaseBuilder.cs b/MultiversalDiplomacyTests/TestCaseBuilder.cs index 8e019b6..9698f87 100644 --- a/MultiversalDiplomacyTests/TestCaseBuilder.cs +++ b/MultiversalDiplomacyTests/TestCaseBuilder.cs @@ -76,7 +76,13 @@ public class TestCaseBuilder /// /// Give the unit a move order. /// - public IOrderDefinedContext MovesTo(string provinceName, string? coast = null); + /// + /// The destination season. If not specified, defaults to the same season as the unit. + /// + public IOrderDefinedContext MovesTo( + string provinceName, + Season? season = null, + string? coast = null); /// /// Give the unit a convoy order. @@ -372,15 +378,19 @@ public class TestCaseBuilder return new OrderDefinedContext(this, order); } - public IOrderDefinedContext MovesTo(string provinceName, string? coast = null) + public IOrderDefinedContext MovesTo( + string provinceName, + Season? season = null, + string? coast = null) { Location destination = this.Unit.Type == UnitType.Army ? this.Builder.World.GetLand(provinceName) : this.Builder.World.GetWater(provinceName, coast); + Season destSeason = season ?? this.SeasonContext.Season; MoveOrder moveOrder = new MoveOrder( this.PowerContext.Power, this.Unit, - this.SeasonContext.Season, + destSeason, destination); this.Builder.OrderList.Add(moveOrder); return new OrderDefinedContext(this, moveOrder); diff --git a/MultiversalDiplomacyTests/TimeTravelTest.cs b/MultiversalDiplomacyTests/TimeTravelTest.cs new file mode 100644 index 0000000..d6b9945 --- /dev/null +++ b/MultiversalDiplomacyTests/TimeTravelTest.cs @@ -0,0 +1,58 @@ +using MultiversalDiplomacy.Adjudicate; +using MultiversalDiplomacy.Adjudicate.Decision; +using MultiversalDiplomacy.Model; + +using NUnit.Framework; + +namespace MultiversalDiplomacyTests; + +public class TimeTravelTest +{ + [Test] + public void MoveIntoOwnPast() + { + TestCaseBuilder setup = new(World.WithStandardMap()); + + // Hold once so the timeline has a past. + setup[(0, 0)] + .GetReference(out Season s0) + ["Germany"] + .Army("Mun").Holds().GetReference(out var mun0); + + setup.ValidateOrders(MovementPhaseAdjudicator.Instance); + setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance); + setup = new(setup.UpdateWorld(MovementPhaseAdjudicator.Instance)); + + // Move into the past of the same timeline. + setup[(1, 0)] + .GetReference(out Season s1) + ["Germany"] + .Army("Mun").MovesTo("Tyr", season: s0).GetReference(out var mun1); + + setup.ValidateOrders(MovementPhaseAdjudicator.Instance); + Assert.That(mun1, Is.Valid); + setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance); + Assert.That(mun1, Is.Victorious); + + World world = setup.UpdateWorld(MovementPhaseAdjudicator.Instance); + + // Confirm that there are now three seasons: the root, a future off the root, and a fork. + Assert.That(world.Seasons.Count, Is.EqualTo(3)); + Assert.That(world.Seasons.Where(s => s.Timeline != s0.Timeline).Count(), Is.EqualTo(1)); + Season fork = world.Seasons.Where(s => s.Timeline != s0.Timeline).Single(); + Assert.That(s0.Futures, Contains.Item(s1)); + Assert.That(s0.Futures, Contains.Item(fork)); + Assert.That(fork.Past, Is.EqualTo(s0)); + + // Confirm that there is a unit in Tyr 1:1 originating from Mun 1:0 + Unit originalUnit = world.GetUnitAt("Mun", s0.Coord); + Unit aMun0 = world.GetUnitAt("Mun", s1.Coord); + Unit aTyr = world.GetUnitAt("Tyr", fork.Coord); + Assert.That(aTyr.Past, Is.EqualTo(mun1.Order.Unit)); + Assert.That(aTyr.Past?.Past, Is.EqualTo(mun0.Order.Unit)); + + // Confirm that there is a unit in Mun 1:1 originating from Mun 0:0 + Unit aMun1 = world.GetUnitAt("Mun", fork.Coord); + Assert.That(aMun1.Past, Is.EqualTo(originalUnit)); + } +}