Compare commits

...

25 Commits

Author SHA1 Message Date
Jaculabilis 5d311bc08c tmp 2024-08-09 07:05:44 -07:00
Tim Van Baak 3db01c0ffd Update projects to dotnet 8 2024-08-09 06:53:22 -07:00
Tim Van Baak 0fd9c93a70 Get nix-ld to work with the Roslyn analyzer 2024-08-08 07:51:08 -07:00
Tim Van Baak 5b4758a4ed Update to dotnet 8 2024-08-08 07:49:39 -07:00
Tim Van Baak 7b2176b1d2 Update nixpkgs pin 2024-08-08 07:49:26 -07:00
Tim Van Baak 4bbd29ac93 Fix broken VS code shells
See https://github.com/NixOS/nix/issues/6982#issuecomment-1236743200
2024-08-08 07:47:37 -07:00
Tim Van Baak 26e268c3a0 Remove custom PS1 2024-08-08 07:45:27 -07:00
Jaculabilis 0f201610d2 Fix path separator in sln 2022-11-08 22:18:52 -08:00
Jaculabilis a63ff3992b Remove broken validation assert
Validation only happens for orders in the current batch, so the test can't validate an order from the previous batch
2022-11-08 19:59:27 -08:00
Jaculabilis b241d206f4 Only fork on new moves into a season 2022-11-08 19:12:03 -08:00
Jaculabilis 069cb4c548 Prevent orders from being double counted if they affect multiple seasons 2022-11-08 18:55:27 -08:00
Jaculabilis 95ed8c7682 Fix error caused by decisions with no history 2022-11-08 18:41:46 -08:00
Jaculabilis 39c3aabe45 Implement AdvanceTimeline resolution 2022-11-08 16:25:47 -08:00
Jaculabilis 7471a035f0 Log decision updates 2022-11-06 22:01:36 -08:00
Jaculabilis a565ee1b05 Add better ToString overrides to decision classes 2022-11-06 21:58:21 -08:00
Jaculabilis 25d707b3b8 Log recursive decision traversal 2022-11-06 21:55:46 -08:00
Jaculabilis c0a9330d2e Add depth tracking to decision resolution 2022-11-06 21:51:45 -08:00
Jaculabilis 23a826c815 Add adjudicator logger 2022-11-06 21:09:22 -08:00
Jaculabilis 46c28a087c Shorten string representations
The new format for representing timeline, province, and season is T-PRO@S. Hopefully this is easier to read than the PRO T:S format.
2022-11-06 20:27:28 -08:00
Jaculabilis 6347b52d4a Make the dev shell a bit prettier 2022-11-06 20:26:29 -08:00
Jaculabilis 63289bce54 Update flake 2022-11-06 20:26:29 -08:00
Jaculabilis 2e1d72d0f4 Add more VS Code profile settings 2022-11-06 20:26:29 -08:00
Jaculabilis 94037959e1 Replace README with rules document that better explains what is going on 2022-11-06 15:07:48 -08:00
Jaculabilis 15fde7340c Fix incorrect unit in 3.A.5 2022-11-06 14:43:45 -08:00
Jaculabilis 18c11c7ffd Add an AdvanceTimeline decision type 2022-11-06 14:39:01 -08:00
33 changed files with 589 additions and 337 deletions

10
.vscode/settings.json vendored
View File

@ -2,7 +2,15 @@
"terminal.integrated.profiles.linux": {
"nix develop": {
"path": "nix",
"args": ["develop"]
"args": ["develop", "--impure"]
}
},
"terminal.integrated.profiles.windows": {
"nix develop": {
"path": [
"${env:windir}\\System32\\wsl.exe"
],
"args": ["-d", "NixOS", "--", "nix", "develop"]
}
}
}

View File

@ -2,9 +2,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.30114.105
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiversalDiplomacy", "MultiversalDiplomacy\MultiversalDiplomacy.csproj", "{DD4C458A-EB75-4DFA-B06D-7F2BF8470460}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiversalDiplomacy", "MultiversalDiplomacy/MultiversalDiplomacy.csproj", "{DD4C458A-EB75-4DFA-B06D-7F2BF8470460}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiversalDiplomacyTests", "MultiversalDiplomacyTests\MultiversalDiplomacyTests.csproj", "{6DD39198-428C-4385-B870-A1BDED1E5F8A}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MultiversalDiplomacyTests", "MultiversalDiplomacyTests/MultiversalDiplomacyTests.csproj", "{6DD39198-428C-4385-B870-A1BDED1E5F8A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View File

@ -0,0 +1,21 @@
namespace System.Collections.Generic;
public static class AdjudicationDictionaryExtensions
{
/// <summary>
/// Create and add a value to a dictionary only if the key is not already present.
/// </summary>
/// <param name="dictionary">The dictionary to check for the key.</param>
/// <param name="key">The key to check and use if it isn't already present.</param>
/// <param name="valueFunc">A function that returns the value to insert if the key is not present.</param>
public static void Ensure<TKey, TValue>(
this IDictionary<TKey, TValue> dictionary,
TKey key,
Func<TValue> valueFunc)
{
if (!dictionary.ContainsKey(key))
{
dictionary[key] = valueFunc();
}
}
}

View File

@ -0,0 +1,19 @@
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Adjudicate.Decision;
public class AdvanceTimeline : BinaryAdjudicationDecision
{
public Season Season { get; }
public List<UnitOrder> Orders { get; }
public override string ToString()
=> $"AdvanceTimeline({Season})";
public AdvanceTimeline(Season season, IEnumerable<UnitOrder> orders)
{
this.Season = season;
this.Orders = orders.ToList();
}
}

View File

@ -8,6 +8,9 @@ public class AttackStrength : NumericAdjudicationDecision
public List<SupportMoveOrder> Supports { get; }
public MoveOrder? OpposingMove { get; }
public override string ToString()
=> $"AttackStrength({Order})";
public AttackStrength(MoveOrder order, IEnumerable<SupportMoveOrder> supports, MoveOrder? opposingMove = null)
{
this.Order = order;

View File

@ -6,11 +6,6 @@ public abstract class BinaryAdjudicationDecision : AdjudicationDecision
public override bool Resolved => this.Outcome != null;
public override string ToString()
{
return $"{this.GetType().Name}={this.Outcome}";
}
public bool Update(bool outcome)
{
if (this.Outcome == null)

View File

@ -7,6 +7,9 @@ public class DefendStrength : NumericAdjudicationDecision
public MoveOrder Order { get; }
public List<SupportMoveOrder> Supports { get; }
public override string ToString()
=> $"DefendStrength({Order})";
public DefendStrength(MoveOrder order, IEnumerable<SupportMoveOrder> supports)
{
this.Order = order;

View File

@ -8,6 +8,9 @@ public class DoesMove : BinaryAdjudicationDecision
public MoveOrder? OpposingMove { get; }
public List<MoveOrder> Competing { get; }
public override string ToString()
=> $"DoesMove({Order})";
public DoesMove(MoveOrder order, MoveOrder? opposingMove, IEnumerable<MoveOrder> competing)
{
this.Order = order;

View File

@ -7,6 +7,9 @@ public class GivesSupport : BinaryAdjudicationDecision
public SupportOrder Order { get; }
public List<MoveOrder> Cuts { get; }
public override string ToString()
=> $"GivesSupport({Order})";
public GivesSupport(SupportOrder order, IEnumerable<MoveOrder> cuts)
{
this.Order = order;

View File

@ -6,6 +6,9 @@ public class HasPath : BinaryAdjudicationDecision
{
public MoveOrder Order { get; }
public override string ToString()
=> $"HasPath({Order})";
public HasPath(MoveOrder order)
{
this.Order = order;

View File

@ -10,6 +10,11 @@ public class HoldStrength : NumericAdjudicationDecision
public UnitOrder? Order { get; }
public List<SupportHoldOrder> Supports { get; }
public override string ToString()
=> Order is null
? $"HoldStrength({Province.Abbreviations[0]})"
: $"HoldStrength({Order.Unit})";
public HoldStrength(Province province, Season season, UnitOrder? order = null)
{
this.Province = province;

View File

@ -7,6 +7,9 @@ public class IsDislodged : BinaryAdjudicationDecision
public UnitOrder Order { get; }
public List<MoveOrder> Incoming { get; }
public override string ToString()
=> $"IsDislodged({Order.Unit})";
public IsDislodged(UnitOrder order, IEnumerable<MoveOrder> incoming)
{
this.Order = order;

View File

@ -13,139 +13,145 @@ public class MovementDecisions
public Dictionary<MoveOrder, DefendStrength> DefendStrength { get; }
public Dictionary<MoveOrder, PreventStrength> PreventStrength { get; }
public Dictionary<MoveOrder, DoesMove> DoesMove { get; }
public Dictionary<Season, AdvanceTimeline> AdvanceTimeline { get; }
public IEnumerable<AdjudicationDecision> Values =>
this.IsDislodged.Values.Cast<AdjudicationDecision>()
.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);
IsDislodged.Values.Cast<AdjudicationDecision>()
.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<Order> 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();
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<Season> orderedSeasons = new();
foreach (UnitOrder order in orders.Cast<UnitOrder>())
// 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<UnitOrder>().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<Season> 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<UnitOrder> relevantOrders = AdvanceTimeline.Values
.SelectMany(at => at.Orders)
.Distinct()
.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<UnitOrder>())
// 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<MoveOrder> incoming = orders
List<MoveOrder> incoming = relevantOrders
.OfType<MoveOrder>()
.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<SupportMoveOrder> supports = orders
List<SupportMoveOrder> supports = relevantOrders
.OfType<SupportMoveOrder>()
.Where(support => support.IsSupportFor(move))
.ToList();
// Determine if this move is a head-to-head battle.
MoveOrder? opposingMove = orders
MoveOrder? opposingMove = relevantOrders
.OfType<MoveOrder>()
.FirstOrDefault(other => other != null && other.IsOpposing(move), null);
.FirstOrDefault(other => other!.IsOpposing(move), null);
// Find competing moves.
List<MoveOrder> competing = orders
List<MoveOrder> competing = relevantOrders
.OfType<MoveOrder>()
.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));
}
}
}

View File

@ -8,6 +8,9 @@ public class PreventStrength : NumericAdjudicationDecision
public List<SupportMoveOrder> Supports { get; }
public MoveOrder? OpposingMove { get; }
public override string ToString()
=> $"PreventStrength({Order})";
public PreventStrength(MoveOrder order, IEnumerable<SupportMoveOrder> supports, MoveOrder? opposingMove = null)
{
this.Order = order;

View File

@ -0,0 +1,13 @@
namespace MultiversalDiplomacy.Adjudicate.Logging;
public class ConsoleLogger : IAdjudicatorLogger
{
public static ConsoleLogger Instance { get; } = new();
public void Log(int contextLevel, string message, params object[] args)
{
string spacing = string.Format($"{{0,{2 * contextLevel}}}", string.Empty);
string formattedMessage = string.Format(message, args);
Console.WriteLine(spacing + formattedMessage);
}
}

View File

@ -0,0 +1,6 @@
namespace MultiversalDiplomacy.Adjudicate.Logging;
public interface IAdjudicatorLogger
{
public void Log(int contextLevel, string message, params object[] args);
}

View File

@ -1,6 +1,5 @@
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Adjudicate.Logging;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
@ -11,7 +10,14 @@ namespace MultiversalDiplomacy.Adjudicate;
/// </summary>
public class MovementPhaseAdjudicator : IPhaseAdjudicator
{
public static IPhaseAdjudicator Instance { get; } = new MovementPhaseAdjudicator();
public static IPhaseAdjudicator Instance { get; } = new MovementPhaseAdjudicator(ConsoleLogger.Instance);
private IAdjudicatorLogger logger { get; }
public MovementPhaseAdjudicator(IAdjudicatorLogger logger)
{
this.logger = logger;
}
public List<OrderValidation> ValidateOrders(World world, List<Order> orders)
{
@ -267,34 +273,42 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
public List<AdjudicationDecision> AdjudicateOrders(World world, List<Order> orders)
{
logger.Log(0, "Beginning adjudication");
// Define all adjudication decisions to be made.
MovementDecisions decisions = new(world, orders);
// Adjudicate all decisions.
bool progress = false;
int loopNum = 1;
do
{
logger.Log(1, "Beginning loop {0}", loopNum++);
progress = false;
foreach (AdjudicationDecision decision in decisions.Values)
{
// This will noop without progress if the decision is already resolved
progress |= ResolveDecision(decision, world, decisions);
progress |= ResolveDecision(decision, world, decisions, depth: 2);
}
} while (progress);
} while (progress && decisions.Values.Any(decision => !decision.Resolved));
if (decisions.Values.Any(d => !d.Resolved))
{
throw new ApplicationException("Some orders not resolved!");
}
logger.Log(0, "Completed adjudication");
return decisions.Values.ToList();
}
public World UpdateWorld(World world, List<AdjudicationDecision> decisions)
{
logger.Log(0, "Updating world");
Dictionary<MoveOrder, DoesMove> moves = decisions
.OfType<DoesMove>()
.ToDictionary(dm => dm.Order);
Dictionary<Unit, IsDislodged> dislodges = decisions
.OfType<IsDislodged>()
.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.
@ -302,25 +316,37 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
List<Unit> createdUnits = new();
List<RetreatingUnit> retreats = new();
// 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.
foreach (DoesMove doesMove in moves.Values)
// Populate createdFutures with the timeline fork decisions
logger.Log(1, "Processing AdvanceTimeline decisions");
foreach (AdvanceTimeline advanceTimeline in decisions.OfType<AdvanceTimeline>())
{
if (doesMove.Outcome == true)
{
if (!createdFutures.TryGetValue(doesMove.Order.Season, out Season? future))
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.
future = !doesMove.Order.Season.Futures.Any()
? doesMove.Order.Season.MakeNext()
: doesMove.Order.Season.MakeFork();
createdFutures[doesMove.Order.Season] = future;
createdFutures[advanceTimeline.Season] = !advanceTimeline.Season.Futures.Any()
? advanceTimeline.Season.MakeNext()
: advanceTimeline.Season.MakeFork();
}
createdUnits.Add(doesMove.Order.Unit.Next(doesMove.Order.Location, future));
}
// 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)
{
logger.Log(2, "{0} = {1}", doesMove, doesMove.Outcome?.ToString() ?? "?");
Season moveSeason = doesMove.Order.Season;
if (doesMove.Outcome == true && createdFutures.ContainsKey(moveSeason))
{
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<IsDislodged>())
{
UnitOrder order = isDislodged.Order;
@ -331,22 +357,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();
@ -355,15 +385,35 @@ 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())));
// Record the adjudication results to the season's order history
Dictionary<Season, OrderHistory> newHistory = new();
foreach (UnitOrder unitOrder in decisions.OfType<IsDislodged>().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<KeyValuePair<Season, OrderHistory>> updatedHistory = world.OrderHistory
.Where(kvp => !newHistory.ContainsKey(kvp.Key))
.Concat(newHistory);
// TODO provide more structured information about order outcomes
@ -371,33 +421,153 @@ 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;
}
private bool LoggedUpdate(BinaryAdjudicationDecision decision, bool outcome, int depth, string message)
{
bool updated = decision.Update(outcome);
if (updated)
{
logger.Log(depth, "{0}: {1}", outcome, message);
}
return updated;
}
private bool LoggedUpdate(NumericAdjudicationDecision decision, int min, int max, int depth, string message)
{
bool updated = decision.Update(min, max);
if (updated)
{
logger.Log(depth, "{0}, {1}: {2}", min, max, message);
}
return updated;
}
/// <summary>
/// Attempt to resolve an adjudication decision.
/// </summary>
/// <returns>
/// If any adjudication was further determined, returns true. If nothing was further determined, returns false.
/// </returns>
private bool ResolveDecision(
AdjudicationDecision decision,
World world,
MovementDecisions decisions)
=> decision.Resolved ? false : decision switch
MovementDecisions decisions,
int depth)
{
IsDislodged d => ResolveIsUnitDislodged(d, world, decisions),
HasPath d => ResolveDoesMoveHavePath(d, world, decisions),
GivesSupport d => ResolveIsSupportGiven(d, world, decisions),
HoldStrength d => ResolveHoldStrength(d, world, decisions),
AttackStrength d => ResolveAttackStrength(d, world, decisions),
DefendStrength d => ResolveDefendStrength(d, world, decisions),
PreventStrength d => ResolvePreventStrength(d, world, decisions),
DoesMove d => ResolveDoesUnitMove(d, world, decisions),
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),
HoldStrength d => ResolveHoldStrength(d, world, decisions, depth + 1),
AttackStrength d => ResolveAttackStrength(d, world, decisions, depth + 1),
DefendStrength d => ResolveDefendStrength(d, world, decisions, depth + 1),
PreventStrength d => ResolvePreventStrength(d, world, decisions, depth + 1),
DoesMove d => ResolveDoesUnitMove(d, world, decisions, depth + 1),
_ => throw new NotSupportedException($"Unknown decision type: {decision.GetType()}")
};
}
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 new (i.e. not previously adjudicated) and successful move always advances.
IEnumerable<MoveOrder> newIncomingMoves = decision.Orders
.OfType<MoveOrder>()
.Where(order => order.Season == decision.Season
&& !world.OrderHistory[order.Season].DoesMoveOutcomes.ContainsKey(order));
foreach (MoveOrder moveOrder in newIncomingMoves)
{
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;
}
}
// 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);
if (history.IsDislodgedOutcomes.TryGetValue(order.Unit, out bool previous)
&& 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);
if (history.DoesMoveOutcomes.TryGetValue(moveOrder, out bool previousMove)
&& moves.Resolved
&& moves.Outcome != previousMove)
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,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "IsUnitDislodged({0})", decision.Order.Unit);
bool progress = false;
// If this unit was ordered to move and is doing so successfully, it cannot be dislodged
@ -405,13 +575,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (decision.Order is MoveOrder moveOrder)
{
DoesMove move = decisions.DoesMove[moveOrder];
progress |= ResolveDecision(move, world, decisions);
progress |= ResolveDecision(move, world, decisions, depth + 1);
// If this unit received a move order and the move is successful, it cannot be
// dislodged.
if (move.Outcome == true)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "Unit successfully moves");
return progress;
}
@ -429,12 +599,12 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (MoveOrder dislodger in decision.Incoming)
{
DoesMove move = decisions.DoesMove[dislodger];
progress |= ResolveDecision(move, world, decisions);
progress |= ResolveDecision(move, world, decisions, depth + 1);
// If at least one invader will move, this unit is dislodged.
if (move.Outcome == true)
{
progress |= decision.Update(true);
progress |= LoggedUpdate(decision, true, depth, "Invading unit successfully moves");
return progress;
}
@ -448,7 +618,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (!potentialDislodger)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "No invader can move");
}
return progress;
@ -457,8 +627,10 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveDoesMoveHavePath(
HasPath decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "DoesMoveHavePath({0})", decision.Order);
bool progress= false;
// If the origin and destination are adjacent, then there is a path.
@ -469,8 +641,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Timeline adjacency
&& decision.Order.Unit.Season.InAdjacentTimeline(decision.Order.Season))
{
progress |= decision.Update(true);
return progress;
bool update = LoggedUpdate(decision, true, depth, "Adjacent move");
return progress | update;
}
// If the origin and destination are not adjacent, then the decision resolves to whether
@ -486,8 +658,10 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveIsSupportGiven(
GivesSupport decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "IsSupportGiven({0})", decision.Order);
bool progress = false;
// Support is cut when a unit moves into the supporting unit's province with nonzero
@ -496,13 +670,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (MoveOrder cut in decision.Cuts)
{
AttackStrength attack = decisions.AttackStrength[cut];
progress |= ResolveDecision(attack, world, decisions);
progress |= ResolveDecision(attack, world, decisions, depth + 1);
// If at least one attack has a nonzero minimum, the support decision can be resolved
// to false.
if (attack.MinValue > 0)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "An attacker has nonzero attack strength");
return progress;
}
@ -516,10 +690,10 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Support is also cut if the unit is dislodged.
IsDislodged dislodge = decisions.IsDislodged[decision.Order.Unit];
progress |= ResolveDecision(dislodge, world, decisions);
progress |= ResolveDecision(dislodge, world, decisions, depth + 1);
if (dislodge.Outcome == true)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "Unit dislodged");
return progress;
}
@ -527,7 +701,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// resolved to false, then the support is given.
if (!potentialNonzeroAttack && dislodge.Outcome == false)
{
progress |= decision.Update(true);
progress |= LoggedUpdate(decision, true, depth, "No successful attack or dislodge");
return progress;
}
@ -538,14 +712,16 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveHoldStrength(
HoldStrength decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "HoldStrength({0})", decision.Province);
bool progress = false;
// If no unit is in the province, the hold strength is zero.
if (decision.Order == null)
{
progress |= decision.Update(0, 0);
progress |= LoggedUpdate(decision, 0, 0, depth, "No unit in the province");
return progress;
}
@ -553,10 +729,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (decision.Order is MoveOrder move)
{
DoesMove moves = decisions.DoesMove[move];
progress |= ResolveDecision(moves, world, decisions);
progress |= decision.Update(
moves.Outcome != false ? 0 : 1,
moves.Outcome == true ? 0 : 1);
progress |= ResolveDecision(moves, world, decisions, depth + 1);
progress |= LoggedUpdate(
decision,
min: moves.Outcome != false ? 0 : 1,
max: moves.Outcome == true ? 0 : 1,
depth,
"Updated based on unit's move success");
return progress;
}
// If a unit without a move order is in the province, add up the supports.
@ -567,11 +746,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (SupportHoldOrder support in decision.Supports)
{
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
}
@ -579,16 +758,18 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveAttackStrength(
AttackStrength decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "AttackStrength({0})", decision.Order);
bool progress = false;
// If there is no path, the attack strength is zero.
var hasPath = decisions.HasPath[decision.Order];
progress |= ResolveDecision(hasPath, world, decisions);
progress |= ResolveDecision(hasPath, world, decisions, depth + 1);
if (hasPath.Outcome == false)
{
progress |= decision.Update(0, 0);
progress |= LoggedUpdate(decision, 0, 0, depth, "No path");
return progress;
}
@ -601,7 +782,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
: null;
if (destMoveAway != null)
{
progress |= ResolveDecision(destMoveAway, world, decisions);
progress |= ResolveDecision(destMoveAway, world, decisions, depth + 1);
}
if (// In any case here, there will have to be a unit at the destination with an order,
// which means that destOrder will have to be populated. Including this in the if
@ -618,7 +799,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (decision.Order.Unit.Power == destPower)
{
// Cannot dislodge own unit.
progress |= decision.Update(0, 0);
progress |= LoggedUpdate(decision, 0, 0, depth, "Cannot dislodge own unit");
return progress;
}
else
@ -630,11 +811,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
{
if (support.Unit.Power == destPower) continue;
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports from other powers");
return progress;
}
}
@ -650,13 +831,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (SupportMoveOrder support in decision.Supports)
{
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (support.Unit.Power != destPower && givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
// Force min to zero in case of an attempt to disloge a unit of the same power.
if (decision.Order.Unit.Power == destPower) min = 0;
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports");
return progress;
}
else
@ -668,11 +849,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (SupportMoveOrder support in decision.Supports)
{
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports from all powers");
return progress;
}
}
@ -680,8 +861,10 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveDefendStrength(
DefendStrength decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "DefendStrength({0})", decision.Order);
bool progress = false;
// The defend strength is equal to one plus, at least, the number of known successful
@ -691,11 +874,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (SupportMoveOrder support in decision.Supports)
{
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
@ -703,16 +886,18 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolvePreventStrength(
PreventStrength decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "PreventStrength({0})", decision.Order);
bool progress = false;
// If there is no path, the prevent strength is zero.
var hasPath = decisions.HasPath[decision.Order];
progress |= ResolveDecision(hasPath, world, decisions);
progress |= ResolveDecision(hasPath, world, decisions, depth + 1);
if (hasPath.Outcome == false)
{
progress |= decision.Update(0, 0);
progress |= LoggedUpdate(decision, 0, 0, depth, "No path to prevent");
return progress;
}
@ -721,7 +906,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (decision.OpposingMove != null
&& decisions.DoesMove[decision.OpposingMove].Outcome == true)
{
progress |= decision.Update(0, 0);
progress |= LoggedUpdate(decision, 0, 0, depth, "Cannot prevent in lost head-to-head");
return progress;
}
@ -733,7 +918,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (SupportMoveOrder support in decision.Supports)
{
GivesSupport givesSupport = decisions.GivesSupport[support];
progress |= ResolveDecision(givesSupport, world, decisions);
progress |= ResolveDecision(givesSupport, world, decisions, depth + 1);
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
@ -747,7 +932,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
min = 0;
}
progress |= decision.Update(min, max);
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
@ -755,13 +940,15 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
private bool ResolveDoesUnitMove(
DoesMove decision,
World world,
MovementDecisions decisions)
MovementDecisions decisions,
int depth)
{
logger.Log(depth, "DoesUnitMove({0})", decision.Order);
bool progress = false;
// Resolve the move's attack strength.
AttackStrength attack = decisions.AttackStrength[decision.Order];
progress |= ResolveDecision(attack, world, decisions);
progress |= ResolveDecision(attack, world, decisions, depth + 1);
// In a head to head battle, the threshold for the attack strength to beat is the opposing
// defend strength. Outside a head to head battle, the threshold is the destination's hold
@ -769,12 +956,12 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
NumericAdjudicationDecision defense = decision.OpposingMove != null
? decisions.DefendStrength[decision.OpposingMove]
: decisions.HoldStrength[decision.Order.Point];
progress |= ResolveDecision(defense, world, decisions);
progress |= ResolveDecision(defense, world, decisions, depth + 1);
// If the attack doesn't beat the defense, resolve the move to false.
if (attack.MaxValue <= defense.MinValue)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "Attack can't beat defense");
return progress;
}
@ -783,11 +970,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (MoveOrder order in decision.Competing)
{
PreventStrength prevent = decisions.PreventStrength[order];
progress |= ResolveDecision(prevent, world, decisions);
progress |= ResolveDecision(prevent, world, decisions, depth + 1);
// If attack doesn't beat the prevent, resolve the move to false.
if (attack.MaxValue <= prevent.MinValue)
{
progress |= decision.Update(false);
progress |= LoggedUpdate(decision, false, depth, "Attack can't beat prevent");
return progress;
}
// If the attack doesn't beat the prevent, it can't resolve to true.
@ -799,7 +986,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// If the attack didn't resolve to false because the defense or a prevent beat it, then
// attempt to resolve it to true based on whether it beat the defense and all prevents.
progress |= decision.Update(attack.MinValue > defense.MaxValue && beatsAllCompetingMoves);
progress |= LoggedUpdate(
decision,
attack.MinValue > defense.MaxValue && beatsAllCompetingMoves,
depth,
"Updated based on competing moves");
return progress;
}
}

View File

@ -0,0 +1,23 @@
namespace MultiversalDiplomacy.Model;
public static class ModelExtensions
{
/// <summary>
/// Short representation of a <see cref="UnitType"/>.
/// </summary>
public static string ToShort(this UnitType unitType)
=> unitType switch
{
UnitType.Army => "A",
UnitType.Fleet => "F",
_ => throw new NotSupportedException($"Unknown unit type {unitType}"),
};
/// <summary>
/// Short representation of a multiversal location.
/// </summary>
public static string ToShort(this (Province province, Season season) coord)
{
return $"{coord.season.Timeline}-{coord.province.Abbreviations[0]}@{coord.season.Turn}";
}
}

View File

@ -0,0 +1,28 @@
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Model;
public class OrderHistory
{
public List<UnitOrder> Orders;
public Dictionary<Unit, bool> IsDislodgedOutcomes;
public Dictionary<MoveOrder, bool> DoesMoveOutcomes;
public OrderHistory()
: this(new(), new(), new())
{}
public OrderHistory(
List<UnitOrder> orders,
Dictionary<Unit, bool> isDislodgedOutcomes,
Dictionary<MoveOrder, bool> doesMoveOutcomes)
{
this.Orders = new(orders);
this.IsDislodgedOutcomes = new(isDislodgedOutcomes);
this.DoesMoveOutcomes = new(doesMoveOutcomes);
}
}

View File

@ -71,7 +71,7 @@ public class Season
public override string ToString()
{
return $"{this.Turn}:{this.Timeline}";
return $"{this.Timeline}@{this.Turn}";
}
/// <summary>

View File

@ -51,7 +51,7 @@ public class Unit
public override string ToString()
{
return $"{this.Power} {this.Type} {this.Province} {this.Season}";
return $"{this.Power.Name[0]} {this.Type.ToShort()} {(this.Province, this.Season).ToShort()}";
}
/// <summary>

View File

@ -42,7 +42,7 @@ public class World
/// <summary>
/// Orders given to units in each season.
/// </summary>
public ReadOnlyDictionary<Season, ReadOnlyCollection<Order>> GivenOrders { get; }
public ReadOnlyDictionary<Season, OrderHistory> OrderHistory { get; }
/// <summary>
/// Immutable game options.
@ -59,7 +59,7 @@ public class World
Season rootSeason,
ReadOnlyCollection<Unit> units,
ReadOnlyCollection<RetreatingUnit> retreatingUnits,
ReadOnlyDictionary<Season, ReadOnlyCollection<Order>> givenOrders,
ReadOnlyDictionary<Season, OrderHistory> 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<Season>? seasons = null,
ReadOnlyCollection<Unit>? units = null,
ReadOnlyCollection<RetreatingUnit>? retreatingUnits = null,
ReadOnlyDictionary<Season, ReadOnlyCollection<Order>>? givenOrders = null,
ReadOnlyDictionary<Season, OrderHistory>? 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<Unit>()),
new(new List<RetreatingUnit>()),
new(new Dictionary<Season, ReadOnlyCollection<Order>>()),
new(new Dictionary<Season, OrderHistory>()),
new Options());
}
@ -123,7 +123,7 @@ public class World
IEnumerable<Season>? seasons = null,
IEnumerable<Unit>? units = null,
IEnumerable<RetreatingUnit>? retreats = null,
IEnumerable<KeyValuePair<Season, ReadOnlyCollection<Order>>>? orders = null)
IEnumerable<KeyValuePair<Season, OrderHistory>>? 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)));
/// <summary>

View File

@ -2,9 +2,13 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
</ItemGroup>
</Project>

View File

@ -37,6 +37,6 @@ public class ConvoyOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} convoys {this.Target} -> {this.Province} {this.Season}";
return $"{this.Unit} C {this.Target} -> {(this.Province, this.Season).ToShort()}";
}
}

View File

@ -36,7 +36,7 @@ public class MoveOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} -> {this.Province} {this.Season}";
return $"{this.Unit} -> {(this.Province, this.Season).ToShort()}";
}
/// <summary>

View File

@ -14,6 +14,6 @@ public class SupportHoldOrder : SupportOrder
public override string ToString()
{
return $"{this.Unit} supports {this.Target}";
return $"{this.Unit} S {this.Target}";
}
}

View File

@ -36,7 +36,7 @@ public class SupportMoveOrder : SupportOrder
public override string ToString()
{
return $"{this.Unit} supports {this.Target} -> {this.Province} {this.Season}";
return $"{this.Unit} S {this.Target} -> {(this.Province, this.Season).ToShort()}";
}
public bool IsSupportFor(MoveOrder move)

View File

@ -1,12 +1,41 @@
using System;
using CommandLine;
using CommandLine.Text;
namespace MultiversalDiplomacy
{
[Verb("adjudicate", HelpText = "")]
internal class AdjudicateOptions
{
[Option('i', "input")]
public string? InputFile { get; set; }
}
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("stab");
var parser = new Parser(options =>
{
options.AutoVersion = false;
options.HelpWriter = null;
});
var parseResult = parser.ParseArguments(args, typeof(AdjudicateOptions));
var helpText = HelpText.AutoBuild(parseResult, options =>
{
options.AdditionalNewLineAfterOption = false;
return HelpText.DefaultParsingErrorsHandler(parseResult, options);
});
parseResult
.WithParsed<AdjudicateOptions>(Adjudicate)
.WithNotParsed(errs => Console.WriteLine(helpText));
}
static void Adjudicate(AdjudicateOptions args)
{
}
}
}

View File

@ -195,7 +195,7 @@ public class TimeTravelTest
setup[(0, 0)]
.GetReference(out var s0_0)
["England"].Army("Lon").Holds()
["Austria"].Army("Boh").Holds()
["Austria"].Army("Tyr").Holds()
["Germany"].Army("Mun").Holds()
.Execute()
[(1, 0)]
@ -288,7 +288,6 @@ public class TimeTravelTest
// The attack on Mun 2:1 is repelled, but the support is cut.
setup.ValidateOrders();
Assert.That(tyr2_2, Is.Valid);
Assert.That(mun2_1, Is.Valid);
setup.AdjudicateOrders();
Assert.That(tyr2_2, Is.Repelled);
Assert.That(mun2_1, Is.NotDislodged);

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>

150
README.md
View File

@ -1,151 +1,29 @@
# 5D Diplomacy With Multiversal Time Travel
## So you want to conquer Europe with a declarative build system
_5D Diplomacy with Multiversal Time Travel_ is a _Diplomacy_ variant that adds multiversal time travel in the style of its namesake, _5D Chess with Multiversal Time Travel_.
Let's start out by initializing the project. I always hate this part of projects; it's much easier to pick up something with an established codebase and ecosystem and figure out how to modify it to be slightly different than it is to strain genius from the empty space of possibility _de novo_. The ultimate goal of this project is summoning military aid from beyond space and time, though, so we're going to have to get used to it.
## Acknowledgements
A `nix flake init` gives us a fairly useless flake template:
This project was inspired by [Oliver Lugg's proof-of-concept version](https://github.com/Oliveriver/5d-diplomacy-with-multiverse-time-travel). The implementation is based on the algorithms described by Lucas B. Kruijswijk in the chapter "The Process of Adjudication" found in the [Diplomacy Adjudicator Test Cases](http://web.inter.nl.net/users/L.B.Kruijswijk/#5) as well as ["The Math of Adjudication"](http://uk.diplom.org/pouch/Zine/S2009M/Kruijswijk/DipMath_Chp1.htm). Some of the data model is inspired by that of Martin Bruse's [godip](https://github.com/zond/godip).
```
{
description = "A very basic flake";
## Variant rules
outputs = { self, nixpkgs }: {
### Multiversal time travel and timeline forks
packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
_Diplomacy_ is played on a single board, on which are placed armies and fleets. Sequential sets of orders modify the positions of these units, changing the board as time progresses. This may be described as something like an "inner" view of a single timeline. Consider instead the view from "above" the timeline, from which each successive state of the game board is comprehended in sequence. From "above", each turn from the beginning of the game to the present can be considered separately. In _5D Diplomacy with Multiversal Time Travel_, units moving to another province may also move to another turn, potentially changing the past.
defaultPackage.x86_64-linux = self.packages.x86_64-linux.hello;
If the outcome of a battle in the past of a timeline is changed by time travel, then the subsequent future will be different. Since the future of the original outcome is already determined, history forks, and the alternate future proceeds in an alternate timeline.
};
}
```
Just as units in _Diplomacy_ may only move to adjacent spaces, units in _5D Diplomacy with Multiversal Time Travel_ may only move to adjacent times. For the purposes of attacking, supporting, or convoying, turns within one season of each other adjacent. Branching timelines and the timelines they branched off of are adjacent, as well as timelines that branched off of the same turn in the same timeline. A unit cannot move to the province it is currently in, but it can move to the same province in another turn or another timeline.
We're going to replace every line in this file, but at least we got a start. Let's also `git init` and set that part up.
When a unit changes the outcome of a battle in the past, only the timeline of the battle forks. If an army from one timeline dislodges an army in the past of a second timeline that was supporting a move in a third timeline, an alternate future is created where the army in the second timeline is dislodged. The third timeline does not fork, since the support was given in the original timeline. Similarly, if a unit moves into another timeline and causes a previously-successful move from a third timeline to become a bounce, the destination timeline forks because the outcome of the move changed, but the newly-bounced unit's origin timeline does not fork because the move succeeded in the original timeline.
```
$ git init
$ git config --add user.name Jaculabilis
$ git config --add user.email jaculabilis@git.alogoulogoi.com
$ git add flake.nix README.md
$ git commit -m "Initial commit"
$ git remote add origin gitea@git.alogoulogoi.com:Jaculabilis/5dplomacy.git
$ git push -u origin master
```
### Sustaining timelines and time centers
We're doing this in .NET, so we need the .NET SDK. To do that, we're going to delcare a development environment in the flake config.
Since there are many ways to create new timelines, the game would rapidly expand beyond all comprehension if this were not counterbalanced in some way. This happens during the _sustain phase_, which occurs after the fall movement and retreat phases and before the winter buid/disband phase.
```
inputs.flake-utils.url = "github:numtide/flake-utils";
(TODO)
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let pkgs = nixpkgs.legacyPackages.${system};
in rec {
devShell = pkgs.mkShell {
packages = [ pkgs.dotnet-sdk ];
};
}
);
```
### Victory conditions
Declaring `inputs.flake-utils` adds the `flake-utils` package as a dependency, which just gives us Nix helper functions. What's important here is that the `packages.x86_64-linux.hello` above has been abstracted away behind the `eachDefaultSystem` function: now we define our outputs with the `system` input as context, and flake-utils will define our outputs for each default system.
Basically, stripping the boilerplate, we're just doing this:
```
rec {
devShell = pkgs.mkShell {
packages = [ pkgs.dotnet-sdk ];
};
}
```
`pkgs.mkShell` is the derivation builder that creates shell environments. It takes `packages` as a list of input packages that will be made available in the shell environment it creates. We add `dotnet-sdk` to this, commit the changes to git, and enter our new shell with `nix develop`:
```
$ which dotnet
/nix/store/87s452c8wj2zmy21q8q394f6rzf5y1br-dotnet-sdk-6.0.100/bin/dotnet
```
So now we have our development tools (well, tool). The `dotnet --help` text tells us a few things about telemetry, so let's define a prompt so we know when we're in the nix shell and then set the telemetry opt-out.
```
shellHook = ''
PS1="5dplomacy:\W$ "
'';
DOTNET_CLI_TELEMETRY_OPTOUT = 1;
```
Now let's start creating the project. dotnet has a lot of template options. We'll eventually want to have a web client and server, for true multiplayer, but first we want to build the core infrastructure that we can slap a server on top of. So, we're just going to make a console app for now.
```
5dplomacy$ dotnet new console -n MultiversalDiplomacy -o MultiversalDiplomacy
```
.NET 6 makes the Main() method implicit, but this program is going to become more complicated than a single Main(), so let's put the whole boilerplate back.
```
using System;
namespace MultiversalDiplomacy
{
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("stab");
}
}
}
```
And when we run it through `dotnet`:
```
5dplomacy$ dotnet run --project MultiversalDiplomacy/
stab
```
Neat. VS Code doesn't seamlessly work with nix, so to get the extensions working we'll need to restart it from an existing `nix develop` shell so `dotnet` is on the path. For the integrated terminal, we can creating a profile for `nix develop` and set it as the default profile.
```
"terminal.integrated.profiles.linux": {
"nix develop": {
"path": "nix",
"args": ["develop"]
}
}
```
## Comprehending all of time and space
Now for the data model. The state of the world can be described in three layers, so to speak.
1. The board is the same across time and multiverse. Since it consists of (i) provinces and (ii) borders connecting two provinces, we're going to model it like a graph. However, a simple graph won't work because provinces are differently accessible to armies and fleets, and coasts are part of the same province while having distinct connectivity. Following the data model described in [godip](https://github.com/zond/godip), we will model this by subdividing provinces and making connections between those subdivisions. This will effectively create multiple distinct graphs for fleets and armies within the map, with some nodes grouped for control purposes into provinces. Since the map itself does not change across time, we can make this "layer" completely immutable and shared between turns and timelines.
2. Since we need to preserve the state of the past in order to time travel effectively, we won't be mutating a board state. Instead, we'll use orders submitted for each turn to append a new copy of the board state. This can't be represented by a simple list, since we can have more than one timeline branch off of a particular turn, so instead we'll use something like a directed graph and create turns pointing to their immediate past.
3. Given the map of the board and the state of all timelines, all units have a spatial location on the board and a temporal location in one of the turns. As the game progresses and timelines are extended, we'll create copies of the unit in each turn it appears in.
This is going to get complicated, and we're looking forward to implementing the [Diplomacy Adjudicator Test Cases](http://web.inter.nl.net/users/L.B.Kruijswijk/), so let's also create a test project:
```
5dplomacy:5dplomacy$ dotnet new nunit --name MultiversalDiplomacyTests --output MultiversalDiplomacyTests
```
I think dotnet will fetch NUnit when it needs it, but to get it into our environment so VS Code recognizes it, we add it to the nix shell:
```
packages = [ pkgs.dotnet-sdk pkgs.dotnetPackages.NUnit3 ];
```
After writing some basic tests, we can run them with:
```
5dplomacy:5dplomacy$ dotnet test MultiversalDiplomacyTests/
[...]
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 29 ms - 5dplomacy/MultiversalDiplomacyTests/bin/Debug/net6.0/MultiversalDiplomacyTests.dll (net6.0)
```
Neat.
The Great Powers of Europe can only wage multiversal wars because they are lead by extradimensional beings masquerading as human politicians. When a country is eliminated in one timeline, its extradimensional leader is executed, killing them in all timelines.

View File

@ -2,11 +2,11 @@
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1644229661,
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"type": "github"
},
"original": {
@ -17,16 +17,18 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1644486793,
"narHash": "sha256-EeijR4guVHgVv+JpOX3cQO+1XdrkJfGmiJ9XVsVU530=",
"lastModified": 1717179513,
"narHash": "sha256-vboIEwIQojofItm2xGCdZCzW96U85l9nDW3ifMuAIdM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1882c6b7368fd284ad01b0a5b5601ef136321292",
"rev": "63dacb46bf939521bdc93981b4cbb7ecb58427a0",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
"owner": "NixOS",
"ref": "24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {

View File

@ -1,6 +1,7 @@
{
description = "5D Diplomacy With Multiversal Time Travel";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/24.05";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
@ -8,11 +9,14 @@
let pkgs = nixpkgs.legacyPackages.${system};
in rec {
devShell = pkgs.mkShell {
shellHook = ''
PS1="5dplomacy:\W$ "
'';
DOTNET_CLI_TELEMETRY_OPTOUT = 1;
packages = [ pkgs.dotnet-sdk pkgs.dotnetPackages.NUnit3 ];
NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
NIX_LD = builtins.readFile "${pkgs.stdenv.cc}/nix-support/dynamic-linker";
packages = [
pkgs.bashInteractive
pkgs.dotnet-sdk_8
pkgs.dotnetPackages.NUnit3
];
};
}
);