Save previous orders so time travel can re-adjudicate them

This commit is contained in:
Jaculabilis 2022-03-29 20:40:19 -07:00
parent 6a6810ef07
commit 6948db29df
5 changed files with 160 additions and 15 deletions

View File

@ -24,7 +24,7 @@ public class MovementDecisions
.Concat(this.PreventStrength.Values)
.Concat(this.DoesMove.Values);
public MovementDecisions(List<Order> orders)
public MovementDecisions(World world, List<Order> 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<Season> orderedSeasons = new();
foreach (UnitOrder order in orders.Cast<UnitOrder>())
{
_ = 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<Season> 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<UnitOrder>())
{
// Create a dislodge decision for this unit.

View File

@ -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<AdjudicationDecision> AdjudicateOrders(World world, List<Order> orders)
{
// Define all adjudication decisions to be made.
MovementDecisions decisions = new(orders);
List<AdjudicationDecision> 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<KeyValuePair<Season, ReadOnlyCollection<Order>>> newOrders = decisions
.OfType<IsDislodged>()
.GroupBy(
keySelector: d => d.Order.Unit.Season,
elementSelector: d => d.Order as Order)
.Where(group => !world.GivenOrders.ContainsKey(group.Key))
.Select(group => new KeyValuePair<Season, ReadOnlyCollection<Order>>(
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;
}

View File

@ -1,5 +1,7 @@
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Model;
/// <summary>
@ -37,6 +39,11 @@ public class World
/// </summary>
public ReadOnlyCollection<RetreatingUnit> RetreatingUnits { get; }
/// <summary>
/// Orders given to units in each season.
/// </summary>
public ReadOnlyDictionary<Season, ReadOnlyCollection<Order>> GivenOrders { get; }
/// <summary>
/// Immutable game options.
/// </summary>
@ -52,6 +59,7 @@ public class World
Season rootSeason,
ReadOnlyCollection<Unit> units,
ReadOnlyCollection<RetreatingUnit> retreatingUnits,
ReadOnlyDictionary<Season, ReadOnlyCollection<Order>> 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<Season>? seasons = null,
ReadOnlyCollection<Unit>? units = null,
ReadOnlyCollection<RetreatingUnit>? retreatingUnits = null,
ReadOnlyDictionary<Season, ReadOnlyCollection<Order>>? 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<Unit>()),
new(new List<RetreatingUnit>()),
new(new Dictionary<Season, ReadOnlyCollection<Order>>()),
new Options());
}
@ -110,12 +122,22 @@ public class World
public World Update(
IEnumerable<Season>? seasons = null,
IEnumerable<Unit>? units = null,
IEnumerable<RetreatingUnit>? retreats = null)
IEnumerable<RetreatingUnit>? retreats = null,
IEnumerable<KeyValuePair<Season, ReadOnlyCollection<Order>>>? 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)));
/// <summary>
/// Create a new world with new units created from unit specs. Units specs are in the format

View File

@ -76,7 +76,13 @@ public class TestCaseBuilder
/// <summary>
/// Give the unit a move order.
/// </summary>
public IOrderDefinedContext<MoveOrder> MovesTo(string provinceName, string? coast = null);
/// <param name="season">
/// The destination season. If not specified, defaults to the same season as the unit.
/// </param>
public IOrderDefinedContext<MoveOrder> MovesTo(
string provinceName,
Season? season = null,
string? coast = null);
/// <summary>
/// Give the unit a convoy order.
@ -372,15 +378,19 @@ public class TestCaseBuilder
return new OrderDefinedContext<HoldOrder>(this, order);
}
public IOrderDefinedContext<MoveOrder> MovesTo(string provinceName, string? coast = null)
public IOrderDefinedContext<MoveOrder> 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<MoveOrder>(this, moveOrder);

View File

@ -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));
}
}