Compare commits

..

No commits in common. "eaafdeb5a9bb7fbcb580ca03106671263bd3d72b" and "9fd63f43178384293f09145d1ed9ea1033e7f3a3" have entirely different histories.

45 changed files with 1308 additions and 1726 deletions

View File

@ -236,13 +236,17 @@
<a name="2.B"><h3>2.B. ORDER NOTATION</h3></a>
<p>The order notation in this document is as in DATC, with the following additions for multiversal time travel.</p>
<ul>
<li>Timelines are designated by letters, e.g. "a", "b". Turns are designated by numbers, e.g. 0, 1, 2. A board in a timeline X at turn N is designated XN. A location LOC on that board is designated X-LOC@N.</li>
<li>In examples that cover multiple turns, orders are given in sets. Each order set is adjudicated before moving on to the next set.</li>
<li>Units are fully designated by unit type and multiversal location, e.g. "A b-Munich@3". Destinations for move orders or support-to-move orders are fully designated by multiversal location, e.g. <code>a-Munich@1</code>. Where orders are not fully designated, the full designations are implied according to these rules:</li>
<li>A season within a particular timeline is designated in the format X:Y, where X is the turn (starting from 0 and advancing with each movement phase) and Y is the timeline number (starting from 0 and advancing with each timeline fork).</li>
<li>Adjudication is implied to be done between successive seasons. For example, if orders are listed for 0:0 and then for 1:0, it is implied that the orders for 0:0 were adjudicated.</li>
<li>Units are designated by unit type, province, and season, e.g. "A Munich 1:0". A destination for a move order or support-to-move order is designated by province and season, e.g. "Munich 1:0".
<ul>
<li>If only the timeline of a location is specified, the turn is the latest turn in that timeline. E.g. if timeline "a" is at turn 2, <code>a-Munich</code> is interpreted as <code>a-Munich@2</code>.</li>
<li>If the timeline or turn are unspecified for the target of a move or support-hold order, the timeline and turn are those of the ordered unit. E.g. if timeline "b" is at turn 1, <code>A b-Tyrolia - Munich</code> is interpreted as <code>b-Tyrolia@1 - b-Munich@1</code>.</li>
<li>If only the province is specified for the target of a support-move order, the timeline and turn are those of the supported unit. E.g. if timeline "a" is at turn 2 and "b" at turn 1, <code>A a-Munich supports A b-Tyrolia - Munich</code> is interpreted as <code>A a-Munich@2 supports A b-Tyrolia@1 - b-Munich@1</code>.</li>
<li>If season of the ordered unit is not specified, the season is the season to which the orders are being given.</li>
<li>If the season of a unit supported to hold is not specified, the season is the same season as the supporting unit.</li>
<li>If the season of the destination of a move order or the season of the destination of a supported move order is not specified, the season is the season of the moving unit.</li>
<li>For example:
<pre>Germany 2:0
A Munich supports A Munich 1:1 - Tyrolia</pre>
The order here is for Army Munich in 2:0. The move being supported is for Army Munich in 1:1 to move to Tyrolia in 1:1.</li>
</ul>
</ul>
@ -254,15 +258,13 @@
<summary><h4><a href="#3.A.1">3.A.1</a>. TEST CASE, MOVE INTO OWN PAST FORKS TIMELINE</h4></summary>
<p>A unit that moves into its own immediate past causes the timeline to fork.</p>
<pre>
Germany:
A a-Munich hold
Germany 0:0
A Munich hold
---
Germany:
A a-Munich - a-Tyrolia@0
Germany 1:0
A Munich - Tyrolia 0:0
</pre>
<p>A a-Munich@1 moves to a-Tyrolia@0. The main timeline advances to a2 with an empty board. A forked timeline advances to b1 with armies in Munich and Tyrolia.</p>
<p>A Munich 1:0 moves to Tyrolia 0:0. The main timeline advances to 2:0 with an empty board. A forked timeline advances to 1:1 with armies in Munich and Tyrolia.</p>
<div class="figures">
<canvas id="canvas-3-A-1-before" width="0" height="0"></canvas>
<script>
@ -309,21 +311,19 @@
<summary><h4><a href="#3.A.2">3.A.2</a>. TEST CASE, SUPPORT TO REPELLED PAST MOVE FORKS TIMELINE</h4></summary>
<p>A unit that supports a move that previously failed in the past, such that it now succeeds, causes the timeline to fork.</p>
<pre>
Austria:
Austria 0:0
A Tyrolia hold
Germany:
Germany 0:0
A Munich - Tyrolia
---
Austria:
Austria 1:0
A Tyrolia hold
Germany:
A Munich supports A a-Munich@0 - Tyrolia
Germany 1:0
A Munich supports A Munich 0:0 - Tyrolia 0:0
</pre>
<p>With the support from A a-Munich@1, A a-Munich@0 dislodges A a-Tyrolia@0. A forked timeline advances to b1 where A Tyrolia has been dislodged. The main timeline advances to a2 where A Munich and A Tyrolia are in their initial positions.</p>
<p>With the support from A Munich 1:0, A Munich 0:0 dislodges A Tyrolia 0:0. A forked timeline advances to 1:1 where A Tyrolia 0:0 has been dislodged. The main timeline advances to 2:0 where A Munich and A Tyrolia are in their initial positions.</p>
<div class="figures">
<canvas id="canvas-3-A-2-before" width="0" height="0"></canvas>
<script>
@ -379,21 +379,19 @@
<summary><h4><a href="#3.A.3">3.A.3</a>. TEST CASE, FAILED MOVE DOES NOT FORK TIMELINE</h4></summary>
<p>A unit that attempts to move into the past, but is repelled, does not cause the timeline to fork.</p>
<pre>
Austria:
Austria 0:0
A Tyrolia hold
Germany:
Germany 0:0
A Munich hold
---
Austria:
Austria 1:0
A Tyrolia hold
Germany:
A Munich - a-Tyrolia@0
Germany 1:0
A Munich - Tyrolia 0:0
</pre>
<p>The move by A a-Munich@1 fails. The main timeline advances to a2 with both armies in their initial positions. No alternate timeline is created.</p>
<p>The move by A Munich 1:0 fails. The main timeline advances to 2:0 with both armies in their initial positions. No alternate timeline is created.</p>
<div class="figures">
<canvas id="canvas-3-A-3-before" width="0" height="0"></canvas>
<script>
@ -443,17 +441,15 @@
<summary><h4><a href="#3.A.4">3.A.4</a>. TEST CASE, SUPERFLUOUS SUPPORT DOES NOT FORK TIMELINE</h4></summary>
<p>A unit that supports a move that succeeded in the past and still succeeds after the additional future support does not cause the timeline to fork.</p>
<pre>
Germany:
Germany 0:0
A Munich - Tyrolia
A Bohemia hold
---
Germany:
Germany 1:0
A Tyrolia hold
A Bohemia supports A a-Munich@0 - Tyrolia
A Bohemia supports A Munich 0:0 - Tyrolia
</pre>
<p>Both units in a1 continue to a2. No alternate timeline is created.</p>
<p>Both units in 1:0 continue to 2:0. No alternate timeline is created.</p>
<div class="figures">
<canvas id="canvas-3-A-4-before" width="0" height="0"></canvas>
<script>
@ -505,15 +501,15 @@
<summary><h4><a href="#3.A.5">3.A.5</a>. TEST CASE, CROSS-TIMELINE SUPPORT DOES NOT FORK HEAD</h4></summary>
<p>In this test case, a unit elsewhere on the map moves into the past to cause a timeline fork. Once there are two parallel timelines, a support from one to the head of the other should not cause any forking, since timeline forks only occur when the past changes, not the present.</p>
<pre>
Austria:
A a-Tyrolia hold
A b-Tyrolia hold
Austria
A Tyrolia 2:0 hold
A Tyrolia 1:1 hold
Germany
A a-Munich - Tyrolia
A b-Munich supports A a-Munich - Tyrolia
A Munich 2:0 - Tyrolia
A Munich 1:1 supports A Munich 2:0 - Tyrolia
</pre>
<p>A a-Munich dislodges A a-Tyrolia. No alternate timeline is created.</p>
<p>A Munich 2:0 dislodges A Tyrolia 2:0. No alternate timeline is created.</p>
<div class="figures">
<canvas id="canvas-3-A-5-before" width="0" height="0"></canvas>
<script>
@ -571,11 +567,19 @@
<p>Following <a href="#3.A.5">3.A.5</a>, a cross-timeline support that previously succeeded is cut.</p>
<pre>
Germany
A a-Tyrolia holds
A b-Munich holds
A Munich 2:0 - Tyrolia
A Munich 1:1 supports A Munich 2:0 - Tyrolia
Austria
A b-Tyrolia@2 - b-Munich@1
A Tyrolia 2:0 holds
A Tyrolia 1:1 holds
Germany
A Tyrolia 3:0 holds
A Munich 2:1 holds
Austria
A Tyrolia 2:1 - Munich 1:1
</pre>
<p>Cutting the support does not change the past or cause a timeline fork.</p>
<div class="figures">

View File

@ -1,10 +0,0 @@
.PHONY: *
help: ## display this help
@awk 'BEGIN{FS = ":.*##"; printf "\033[1m\nUsage\n \033[1;92m make\033[0;36m <target>\033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST)
tests: ## run all tests
dotnet test MultiversalDiplomacyTests
test: ## name=[test name]: run a single test with logging
dotnet test MultiversalDiplomacyTests -l "console;verbosity=normal" --filter $(name)

View File

@ -1,21 +1,19 @@
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using static MultiversalDiplomacy.Model.Location;
namespace MultiversalDiplomacy.Adjudicate.Decision;
public class MovementDecisions
{
public Dictionary<string, IsDislodged> IsDislodged { get; }
public Dictionary<Unit, IsDislodged> IsDislodged { get; }
public Dictionary<MoveOrder, HasPath> HasPath { get; }
public Dictionary<SupportOrder, GivesSupport> GivesSupport { get; }
public Dictionary<(string, string), HoldStrength> HoldStrength { get; }
public Dictionary<(Province, Season), HoldStrength> HoldStrength { get; }
public Dictionary<MoveOrder, AttackStrength> AttackStrength { get; }
public Dictionary<MoveOrder, DefendStrength> DefendStrength { get; }
public Dictionary<MoveOrder, PreventStrength> PreventStrength { get; }
public Dictionary<MoveOrder, DoesMove> DoesMove { get; }
public Dictionary<string, AdvanceTimeline> AdvanceTimeline { get; }
public Dictionary<Season, AdvanceTimeline> AdvanceTimeline { get; }
public IEnumerable<AdjudicationDecision> Values =>
IsDislodged.Values.Cast<AdjudicationDecision>()
@ -49,7 +47,7 @@ public class MovementDecisions
var submittedOrdersBySeason = orders.Cast<UnitOrder>().ToLookup(order => order.Unit.Season);
foreach (var group in submittedOrdersBySeason)
{
AdvanceTimeline[group.Key.Key] = new(group.Key, group);
AdvanceTimeline[group.Key] = new(group.Key, group);
}
// Create timeline decisions for each season potentially affected by the submitted orders.
@ -62,27 +60,27 @@ public class MovementDecisions
{
case MoveOrder move:
AdvanceTimeline.Ensure(
move.Season.Key,
() => new(move.Season, world.OrderHistory[move.Season.Key].Orders));
AdvanceTimeline[move.Season.Key].Orders.Add(move);
move.Season,
() => new(move.Season, world.OrderHistory[move.Season].Orders));
AdvanceTimeline[move.Season].Orders.Add(move);
break;
case SupportHoldOrder supportHold:
AdvanceTimeline.Ensure(
supportHold.Target.Season.Key,
() => new(supportHold.Target.Season, world.OrderHistory[supportHold.Target.Season.Key].Orders));
AdvanceTimeline[supportHold.Target.Season.Key].Orders.Add(supportHold);
supportHold.Target.Season,
() => new(supportHold.Target.Season, world.OrderHistory[supportHold.Target.Season].Orders));
AdvanceTimeline[supportHold.Target.Season].Orders.Add(supportHold);
break;
case SupportMoveOrder supportMove:
AdvanceTimeline.Ensure(
supportMove.Target.Season.Key,
() => new(supportMove.Target.Season, world.OrderHistory[supportMove.Target.Season.Key].Orders));
AdvanceTimeline[supportMove.Target.Season.Key].Orders.Add(supportMove);
supportMove.Target.Season,
() => new(supportMove.Target.Season, world.OrderHistory[supportMove.Target.Season].Orders));
AdvanceTimeline[supportMove.Target.Season].Orders.Add(supportMove);
AdvanceTimeline.Ensure(
supportMove.Season.Key,
() => new(supportMove.Season, world.OrderHistory[supportMove.Season.Key].Orders));
AdvanceTimeline[supportMove.Season.Key].Orders.Add(supportMove);
supportMove.Season,
() => new(supportMove.Season, world.OrderHistory[supportMove.Season].Orders));
AdvanceTimeline[supportMove.Season].Orders.Add(supportMove);
break;
}
}
@ -93,68 +91,39 @@ public class MovementDecisions
.Distinct()
.ToList();
(string province, string season) UnitPoint(Unit unit)
=> (world.Map.GetLocation(unit.Location).Province.Name, unit.Season.Key);
(string province, string season) MovePoint(MoveOrder move)
=> (SplitKey(move.Location).province, move.Season.Key);
// Create a hold strength decision with an associated order for every province with a unit.
foreach (UnitOrder order in relevantOrders)
{
HoldStrength[UnitPoint(order.Unit)] = new(
world.Map.GetLocation(order.Unit.Location).Province,
order.Unit.Season,
order);
HoldStrength[order.Unit.Point] = new(order.Unit.Point, order);
}
bool IsIncoming(UnitOrder me, MoveOrder other)
=> me != other
&& other.Season == me.Unit.Season
&& SplitKey(other.Location).province == world.Map.GetLocation(me.Unit).Province.Name;
bool IsSupportFor(SupportMoveOrder me, MoveOrder move)
=> me.Target.Key == move.Unit.Key
&& me.Season == move.Season
&& me.Location.Key == move.Location;
bool AreOpposing(MoveOrder one, MoveOrder two)
=> one.Season == two.Unit.Season
&& two.Season == one.Unit.Season
&& SplitKey(one.Location).province == world.Map.GetLocation(two.Unit).Province.Name
&& SplitKey(two.Location).province == world.Map.GetLocation(one.Unit).Province.Name;
bool AreCompeting(MoveOrder one, MoveOrder two)
=> one != two
&& one.Season == two.Season
&& SplitKey(one.Location).province == SplitKey(two.Location).province;
// 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 = relevantOrders
.OfType<MoveOrder>()
.Where(other => IsIncoming(order, other))
.Where(order.IsIncoming)
.ToList();
IsDislodged[order.Unit.Key] = new(order, incoming);
IsDislodged[order.Unit] = new(order, incoming);
if (order is MoveOrder move)
{
// Find supports corresponding to this move.
List<SupportMoveOrder> supports = relevantOrders
.OfType<SupportMoveOrder>()
.Where(support => IsSupportFor(support, move))
.Where(support => support.IsSupportFor(move))
.ToList();
// Determine if this move is a head-to-head battle.
MoveOrder? opposingMove = relevantOrders
.OfType<MoveOrder>()
.FirstOrDefault(other => AreOpposing(move, other!), null);
.FirstOrDefault(other => other!.IsOpposing(move), null);
// Find competing moves.
List<MoveOrder> competing = relevantOrders
.OfType<MoveOrder>()
.Where(other => AreCompeting(move, other))
.Where(move.IsCompeting)
.ToList();
// Create the move-related decisions.
@ -165,7 +134,7 @@ public class MovementDecisions
DoesMove[move] = new(move, opposingMove, competing);
// Ensure a hold strength decision exists for the destination.
HoldStrength.Ensure(MovePoint(move), () => new(world.Map.GetLocation(move.Location).Province, move.Season));
HoldStrength.Ensure(move.Point, () => new(move.Point));
}
else if (order is SupportOrder support)
{
@ -173,20 +142,16 @@ public class MovementDecisions
GivesSupport[support] = new(support, incoming);
// Ensure a hold strength decision exists for the target's province.
HoldStrength.Ensure(UnitPoint(support.Target), () => new(
world.Map.GetLocation(support.Target.Location).Province,
support.Target.Season));
HoldStrength.Ensure(support.Target.Point, () => new(support.Target.Point));
if (support is SupportHoldOrder supportHold)
{
HoldStrength[UnitPoint(support.Target)].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.
HoldStrength.Ensure(
(supportMove.Province.Name, supportMove.Season.Key),
() => new(supportMove.Province, supportMove.Season));
HoldStrength.Ensure(supportMove.Point, () => new(supportMove.Point));
}
}
}

View File

@ -3,8 +3,6 @@ using MultiversalDiplomacy.Adjudicate.Logging;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using static MultiversalDiplomacy.Model.Location;
namespace MultiversalDiplomacy.Adjudicate;
/// <summary>
@ -52,7 +50,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Invalidate any order given to a unit in the past.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !world.Timelines.GetFutures(order.Unit.Season).Any(),
order => !order.Unit.Season.Futures.Any(),
ValidationReason.IneligibleForOrder,
ref unitOrders,
ref validationResults);
@ -71,8 +69,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Trivial check: armies cannot move to water and fleets cannot move to land.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => (order.Unit.Type == UnitType.Army && world.Map.GetLocation(order.Location).Type == LocationType.Land)
|| (order.Unit.Type == UnitType.Fleet && world.Map.GetLocation(order.Location).Type == LocationType.Water),
order => (order.Unit.Type == UnitType.Army && order.Location.Type == LocationType.Land)
|| (order.Unit.Type == UnitType.Fleet && order.Location.Type == LocationType.Water),
ValidationReason.IllegalDestinationType,
ref moveOrders,
ref validationResults);
@ -92,11 +90,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
ILookup<bool, MoveOrder> moveOrdersByAdjacency = moveOrders
.ToLookup(order =>
// Map adjacency
world.Map.GetLocation(order.Unit).Adjacents.Select(loc => loc.Key).Contains(order.Location)
order.Unit.Location.Adjacents.Contains(order.Location)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season));
&& order.Unit.Season.InAdjacentTimeline(order.Season));
List<MoveOrder> adjacentMoveOrders = moveOrdersByAdjacency[true].ToList();
List<MoveOrder> nonAdjacentMoveOrders = moveOrdersByAdjacency[false].ToList();
@ -140,7 +138,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Trivial check: cannot convoy a unit to its own location
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !(
order.Location.Key == order.Target.Location
order.Location == order.Target.Location
&& order.Season == order.Target.Season),
ValidationReason.DestinationMatchesOrigin,
ref convoyOrders,
@ -163,7 +161,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Support-hold orders are invalid if the unit supports itself.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => order.Unit.Key != order.Target.Key,
order => order.Unit != order.Target,
ValidationReason.NoSelfSupport,
ref supportHoldOrders,
ref validationResults);
@ -177,12 +175,12 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
AdjudicatorHelpers.InvalidateIfNotMatching(
order =>
// Map adjacency with respect to province
world.Map.GetLocation(order.Unit).Adjacents.Any(
adjLocation => adjLocation.Province == world.Map.GetLocation(order.Target).Province)
order.Unit.Location.Adjacents.Any(
adjLocation => adjLocation.Province == order.Target.Province)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Target.Season.Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Target.Season),
&& order.Unit.Season.InAdjacentTimeline(order.Target.Season),
ValidationReason.UnreachableSupport,
ref supportHoldOrders,
ref validationResults);
@ -197,7 +195,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Support-move orders are invalid if the unit supports a move to any location in its own
// province.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => world.Map.GetLocation(order.Unit).Province != order.Province,
order => order.Unit.Province != order.Province,
ValidationReason.NoSupportMoveAgainstSelf,
ref supportMoveOrders,
ref validationResults);
@ -209,12 +207,12 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
AdjudicatorHelpers.InvalidateIfNotMatching(
order =>
// Map adjacency with respect to province
world.Map.GetLocation(order.Unit).Adjacents.Any(
order.Unit.Location.Adjacents.Any(
adjLocation => adjLocation.Province == order.Province)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season),
&& order.Unit.Season.InAdjacentTimeline(order.Season),
ValidationReason.UnreachableSupport,
ref supportMoveOrders,
ref validationResults);
@ -241,13 +239,13 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// were not addressed by 4.D.1-2 and will be handled according to 4.D.3, i.e. replaced with
// hold orders. Note that this happens last, after all other invalidations have been
// applied in order to comply with what 4.D.3 specifies about illegal orders.
List<string> duplicateOrderedUnits = unitOrders
.GroupBy(o => o.Unit.Key)
List<Unit> duplicateOrderedUnits = unitOrders
.GroupBy(o => o.Unit)
.Where(orderGroup => orderGroup.Count() > 1)
.Select(orderGroup => orderGroup.Key)
.ToList();
List<UnitOrder> duplicateOrders = unitOrders
.Where(o => duplicateOrderedUnits.Contains(o.Unit.Key))
.Where(o => duplicateOrderedUnits.Contains(o.Unit))
.ToList();
List<UnitOrder> validOrders = unitOrders.Except(duplicateOrders).ToList();
validationResults = validationResults
@ -257,11 +255,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Finally, add implicit hold orders for units without legal orders.
List<Unit> allOrderableUnits = world.Units
.Where(unit => !world.Timelines.GetFutures(unit.Season).Any())
.Where(unit => !unit.Season.Futures.Any())
.ToList();
HashSet<string> orderedUnits = validOrders.Select(order => order.Unit.Key).ToHashSet();
HashSet<Unit> orderedUnits = validOrders.Select(order => order.Unit).ToHashSet();
List<Unit> unorderedUnits = allOrderableUnits
.Where(unit => !orderedUnits.Contains(unit.Key))
.Where(unit => !orderedUnits.Contains(unit))
.ToList();
List<HoldOrder> implicitHolds = unorderedUnits
.Select(unit => new HoldOrder(unit.Power, unit))
@ -308,16 +306,15 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
Dictionary<MoveOrder, DoesMove> moves = decisions
.OfType<DoesMove>()
.ToDictionary(dm => dm.Order);
Dictionary<string, IsDislodged> dislodges = decisions
Dictionary<Unit, IsDislodged> dislodges = decisions
.OfType<IsDislodged>()
.ToDictionary(dm => dm.Order.Unit.Key);
.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.
Dictionary<Season, Season> createdFutures = [];
List<Unit> createdUnits = [];
List<RetreatingUnit> retreats = [];
Timelines newTimelines = world.Timelines;
Dictionary<Season, Season> createdFutures = new();
List<Unit> createdUnits = new();
List<RetreatingUnit> retreats = new();
// Populate createdFutures with the timeline fork decisions
logger.Log(1, "Processing AdvanceTimeline decisions");
@ -327,8 +324,9 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (advanceTimeline.Outcome == true)
{
// A timeline that doesn't have a future yet simply continues. Otherwise, it forks.
newTimelines = newTimelines.WithNewSeason(advanceTimeline.Season, out var newFuture);
createdFutures[advanceTimeline.Season] = newFuture;
createdFutures[advanceTimeline.Season] = !advanceTimeline.Season.Futures.Any()
? advanceTimeline.Season.MakeNext()
: advanceTimeline.Season.MakeFork();
}
}
@ -339,9 +337,9 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
{
logger.Log(2, "{0} = {1}", doesMove, doesMove.Outcome?.ToString() ?? "?");
Season moveSeason = doesMove.Order.Season;
if (doesMove.Outcome == true && createdFutures.TryGetValue(moveSeason, out Season future))
if (doesMove.Outcome == true && createdFutures.ContainsKey(moveSeason))
{
Unit next = 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);
}
@ -370,7 +368,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (isDislodged.Outcome == false)
{
// Non-dislodged units continue into the future.
Unit next = order.Unit.Next(world.Map.GetLocation(order.Unit).Key, future);
Unit next = order.Unit.Next(order.Unit.Location, future);
logger.Log(3, "Advancing unit to {0}", next);
createdUnits.Add(next);
}
@ -379,7 +377,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Create a retreat for each dislodged unit.
// TODO check valid retreats and disbands
logger.Log(3, "Creating retreat for {0}", order.Unit);
var validRetreats = world.Map.GetLocation(order.Unit).Adjacents
var validRetreats = order.Unit.Location.Adjacents
.Select(loc => (future, loc))
.ToList();
RetreatingUnit retreat = new(order.Unit, validRetreats);
@ -388,22 +386,22 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
}
// Record the adjudication results to the season's order history
Dictionary<string, OrderHistory> newHistory = [];
Dictionary<Season, OrderHistory> newHistory = new();
foreach (UnitOrder unitOrder in decisions.OfType<IsDislodged>().Select(d => d.Order))
{
newHistory.Ensure(unitOrder.Unit.Season.Key, () => new([], [], []));
OrderHistory history = newHistory[unitOrder.Unit.Season.Key];
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.Key] = dislodges[unitOrder.Unit.Key].Outcome == true;
history.IsDislodgedOutcomes[unitOrder.Unit] = dislodges[unitOrder.Unit].Outcome == true;
if (unitOrder is MoveOrder moveOrder)
{
history.DoesMoveOutcomes[moveOrder.Unit.Key] = moves[moveOrder].Outcome == true;
history.DoesMoveOutcomes[moveOrder] = moves[moveOrder].Outcome == true;
}
}
// Log the new order history
foreach ((string season, OrderHistory history) in newHistory)
foreach ((Season season, OrderHistory history) in newHistory)
{
string verb = world.OrderHistory.ContainsKey(season) ? "Updating" : "Adding";
logger.Log(1, "{0} history for {1}", verb, season);
@ -413,17 +411,17 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
}
}
IEnumerable<KeyValuePair<string, OrderHistory>> updatedHistory = world.OrderHistory
IEnumerable<KeyValuePair<Season, OrderHistory>> updatedHistory = world.OrderHistory
.Where(kvp => !newHistory.ContainsKey(kvp.Key))
.Concat(newHistory);
// 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,
orders: updatedHistory,
timelines: newTimelines);
orders: updatedHistory);
logger.Log(0, "Completed update");
@ -488,7 +486,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
bool progress = false;
// A season at the head of a timeline always advances.
if (!world.Timelines.GetFutures(decision.Season).Any())
if (!decision.Season.Futures.Any())
{
progress |= LoggedUpdate(decision, true, depth, "A timeline head always advances");
return progress;
@ -498,7 +496,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
IEnumerable<MoveOrder> newIncomingMoves = decision.Orders
.OfType<MoveOrder>()
.Where(order => order.Season == decision.Season
&& !world.OrderHistory[order.Season.Key].DoesMoveOutcomes.ContainsKey(order.Unit.Key));
&& !world.OrderHistory[order.Season].DoesMoveOutcomes.ContainsKey(order));
foreach (MoveOrder moveOrder in newIncomingMoves)
{
DoesMove doesMove = decisions.DoesMove[moveOrder];
@ -515,14 +513,14 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// 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.Key];
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.Key];
IsDislodged dislodged = decisions.IsDislodged[order.Unit];
progress |= ResolveDecision(dislodged, world, decisions, depth + 1);
if (history.IsDislodgedOutcomes.TryGetValue(order.Unit.Key, out bool previous)
if (history.IsDislodgedOutcomes.TryGetValue(order.Unit, out bool previous)
&& dislodged.Resolved
&& dislodged.Outcome != previous)
{
@ -539,7 +537,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
{
DoesMove moves = decisions.DoesMove[moveOrder];
progress |= ResolveDecision(moves, world, decisions, depth + 1);
if (history.DoesMoveOutcomes.TryGetValue(moveOrder.Unit.Key, out bool previousMove)
if (history.DoesMoveOutcomes.TryGetValue(moveOrder, out bool previousMove)
&& moves.Resolved
&& moves.Outcome != previousMove)
if (moves.Resolved && moves.Outcome != previousMove)
@ -620,10 +618,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (!potentialDislodger)
{
string reason = decision.Incoming.Count == 0
? "No unit is attacking"
: "All attacks failed";
progress |= LoggedUpdate(decision, false, depth, reason);
progress |= LoggedUpdate(decision, false, depth, "No invader can move");
}
return progress;
@ -640,11 +635,11 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// If the origin and destination are adjacent, then there is a path.
if (// Map adjacency
world.Map.GetLocation(decision.Order.Unit).Adjacents.Select(loc => loc.Key).Contains(decision.Order.Location)
decision.Order.Unit.Location.Adjacents.Contains(decision.Order.Location)
// Turn adjacency
&& Math.Abs(decision.Order.Unit.Season.Turn - decision.Order.Season.Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(decision.Order.Unit.Season, decision.Order.Season))
&& decision.Order.Unit.Season.InAdjacentTimeline(decision.Order.Season))
{
bool update = LoggedUpdate(decision, true, depth, "Adjacent move");
return progress | update;
@ -694,7 +689,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
}
// Support is also cut if the unit is dislodged.
IsDislodged dislodge = decisions.IsDislodged[decision.Order.Unit.Key];
IsDislodged dislodge = decisions.IsDislodged[decision.Order.Unit];
progress |= ResolveDecision(dislodge, world, decisions, depth + 1);
if (dislodge.Outcome == true)
{
@ -755,7 +750,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= LoggedUpdate(decision, min, max, depth, $"Updated based on {decision.Supports.Count} hold supports");
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
}
@ -781,7 +776,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// If there is a head to head battle, a unit at the destination that isn't moving away, or
// a unit at the destination that will fail to move away, then the attacking unit will have
// to dislodge it.
UnitOrder? destOrder = decisions.HoldStrength[(SplitKey(decision.Order.Location).province, decision.Order.Season.Key)].Order;
UnitOrder? destOrder = decisions.HoldStrength[decision.Order.Point].Order;
DoesMove? destMoveAway = destOrder is MoveOrder moveAway
? decisions.DoesMove[moveAway]
: null;
@ -791,7 +786,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
}
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
// condition lets the compiler know it won't be null in the if block.
//condition lets the compiler know it won't be null in the if block.
destOrder != null
&& (// Is head to head
decision.OpposingMove != null
@ -800,7 +795,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Is failing to move away
|| destMoveAway.Outcome == false))
{
string destPower = destOrder.Unit.Power;
Power destPower = destOrder.Unit.Power;
if (decision.Order.Unit.Power == destPower)
{
// Cannot dislodge own unit.
@ -820,7 +815,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= LoggedUpdate(decision, min, max, depth, $"Updated with {decision.Supports.Count} (?) move supports from third parties");
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports from other powers");
return progress;
}
}
@ -830,7 +825,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// the case where it doesn't move and the attack strength is mitigated by supports not
// helping to dislodge units of the same power as the support. The maximum tracks the
// case where it does move and the attack strength is unmitigated.
string destPower = destMoveAway.Order.Unit.Power;
Power destPower = destMoveAway.Order.Unit.Power;
int min = 1;
int max = 1;
foreach (SupportMoveOrder support in decision.Supports)
@ -842,7 +837,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
}
// 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 |= LoggedUpdate(decision, min, max, depth, $"Updated with {decision.Supports.Count} (?) move supports");
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports");
return progress;
}
else
@ -858,7 +853,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= LoggedUpdate(decision, min, max, depth, $"Updated with {decision.Supports.Count} move supports from all powers");
progress |= LoggedUpdate(decision, min, max, depth, "Updated with supports from all powers");
return progress;
}
}
@ -883,7 +878,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (givesSupport.Outcome == true) min += 1;
if (givesSupport.Outcome != false) max += 1;
}
progress |= LoggedUpdate(decision, min, max, depth, $"Updated based on {decision.Supports.Count} supports");
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
@ -937,7 +932,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
min = 0;
}
progress |= LoggedUpdate(decision, min, max, depth, $"Updated based on {decision.Supports.Count} supports");
progress |= LoggedUpdate(decision, min, max, depth, "Updated based on unit's supports");
return progress;
}
@ -960,7 +955,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// strength.
NumericAdjudicationDecision defense = decision.OpposingMove != null
? decisions.DefendStrength[decision.OpposingMove]
: decisions.HoldStrength[(SplitKey(decision.Order.Location).province, decision.Order.Season.Key)];
: decisions.HoldStrength[decision.Order.Point];
progress |= ResolveDecision(defense, world, decisions, depth + 1);
// If the attack doesn't beat the defense, resolve the move to false.
@ -995,7 +990,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
decision,
attack.MinValue > defense.MaxValue && beatsAllCompetingMoves,
depth,
$"Updated based on {decision.Competing.Count} competing moves");
"Updated based on competing moves");
return progress;
}
}

View File

@ -12,41 +12,37 @@ public static class PathFinder
/// Determines if a convoy path exists for a move in a convoy order.
/// </summary>
public static bool ConvoyPathExists(World world, ConvoyOrder order)
=> ConvoyPathExists(world, world.Map.GetLocation(order.Target), order.Location, order.Season);
=> ConvoyPathExists(world, order.Target, order.Location, order.Season);
/// <summary>
/// Determines if a convoy path exists for a move order.
/// </summary>
public static bool ConvoyPathExists(World world, MoveOrder order)
=> ConvoyPathExists(
world,
world.Map.GetLocation(order.Unit),
world.Map.GetLocation(order.Location),
order.Season);
=> ConvoyPathExists(world, order.Unit, order.Location, order.Season);
private static bool ConvoyPathExists(
World world,
Unit movingUnit,
Location unitLocation,
Location destLocation,
Season destSeason)
Season unitSeason)
{
// A convoy path exists between two locations if both are land locations in provinces that
// also have coasts, and between those coasts there is a path of adjacent sea provinces
// (not coastal) that are occupied by fleets. The move order is valid even if the fleets
// belong to another power or were not given convoy orders; it will simply fail.
IDictionary<(string location, Season season), Unit> fleets = world.Units
IDictionary<(Location location, Season season), Unit> fleets = world.Units
.Where(unit => unit.Type == UnitType.Fleet)
.ToDictionary(unit => (unit.Location, unit.Season));
// Verify that the origin is a coastal province.
if (unitLocation.Type != LocationType.Land) return false;
IEnumerable<Location> originCoasts = unitLocation.Province.Locations
if (movingUnit.Location.Type != LocationType.Land) return false;
IEnumerable<Location> originCoasts = movingUnit.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!originCoasts.Any()) return false;
// Verify that the destination is a coastal province.
if (destLocation.Type != LocationType.Land) return false;
IEnumerable<Location> destCoasts = destLocation.Province.Locations
if (unitLocation.Type != LocationType.Land) return false;
IEnumerable<Location> destCoasts = unitLocation.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!destCoasts.Any()) return false;
@ -54,7 +50,7 @@ public static class PathFinder
// locations added to the to-visit set, but the logic will still work with these as
// starting points.
Queue<(Location location, Season season)> toVisit = new(
originCoasts.Select(location => (location, destSeason)));
originCoasts.Select(location => (location, unitSeason)));
HashSet<(Location, Season)> visited = new();
// Begin pathfinding.
@ -64,16 +60,16 @@ public static class PathFinder
(Location currentLocation, Season currentSeason) = toVisit.Dequeue();
visited.Add((currentLocation, currentSeason));
var adjacents = GetAdjacentPoints(world, currentLocation, currentSeason);
var adjacents = GetAdjacentPoints(currentLocation, currentSeason);
foreach ((Location adjLocation, Season adjSeason) in adjacents)
{
// If the destination is adjacent, then a path exists.
if (destCoasts.Contains(adjLocation) && destSeason == adjSeason) return true;
if (destCoasts.Contains(adjLocation) && unitSeason == adjSeason) return true;
// If not, add this location to the to-visit set if it isn't a coast, has a fleet,
// and hasn't already been visited.
if (!adjLocation.Province.Locations.Any(l => l.Type == LocationType.Land)
&& fleets.ContainsKey((adjLocation.Key, adjSeason))
&& fleets.ContainsKey((adjLocation, adjSeason))
&& !visited.Contains((adjLocation, adjSeason)))
{
toVisit.Enqueue((adjLocation, adjSeason));
@ -85,11 +81,11 @@ public static class PathFinder
return false;
}
private static List<(Location, Season)> GetAdjacentPoints(World world, Location location, Season season)
private static List<(Location, Season)> GetAdjacentPoints(Location location, Season season)
{
List<(Location, Season)> adjacentPoints = [];
List<(Location, Season)> adjacentPoints = new();
List<Location> adjacentLocations = location.Adjacents.ToList();
List<Season> adjacentSeasons = GetAdjacentSeasons(world, season).ToList();
List<Season> adjacentSeasons = season.GetAdjacentSeasons().ToList();
foreach (Location adjacentLocation in adjacentLocations)
{
@ -109,59 +105,4 @@ public static class PathFinder
return adjacentPoints;
}
/// <summary>
/// Returns all seasons that are adjacent to a season.
/// </summary>
public static IEnumerable<Season> GetAdjacentSeasons(World world, Season season)
{
var pasts = world.Timelines.Pasts;
List<Season> adjacents = [];
// The immediate past and all immediate futures are adjacent.
if (pasts[season.Key] is Season immediatePast) adjacents.Add(immediatePast);
adjacents.AddRange(world.Timelines.GetFutures(season));
// Find all adjacent timelines by finding all timelines that branched off of this season's
// timeline, i.e. all futures of this season's past that have different timelines. Also
// include any timelines that branched off of the timeline this timeline branched off from.
List<Season> adjacentTimelineRoots = [];
Season? current = season;
for (;
pasts[current?.Key!] is Season currentPast && currentPast.Timeline == current?.Timeline;
current = pasts[current?.Key!])
{
adjacentTimelineRoots.AddRange(
world.Timelines.GetFutures(current.Value).Where(s => s.Timeline != current?.Timeline));
}
// At the end of the for loop, if this season is part of the first timeline, then current
// is the root season (current.past == null); if this season is in a branched timeline,
// then current is the branch timeline's root season (current.past.timeline !=
// current.timeline). Check for co-branches if this season is in a branched timeline, since
// the first timeline by definition cannot have co-branches.
if (pasts[current?.Key!] is Season rootPast)
{
IEnumerable<Season> cobranchRoots = world.Timelines
.GetFutures(rootPast)
.Where(s => s.Timeline != current?.Timeline && s.Timeline != rootPast.Timeline);
adjacentTimelineRoots.AddRange(cobranchRoots);
}
// Walk up all alternate timelines to find seasons within one turn of this season.
foreach (Season timelineRoot in adjacentTimelineRoots)
{
for (Season? branchSeason = timelineRoot;
branchSeason is Season branch && branch.Turn <= season.Turn + 1;
branchSeason = world.Timelines
.GetFutures(branchSeason!.Value)
.Cast<Season?>()
.FirstOrDefault(s => s?.Timeline == branchSeason?.Timeline, null))
{
if (branchSeason?.Turn >= season.Turn - 1) adjacents.Add(branchSeason.Value);
}
}
return adjacents;
}
}

View File

@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
@ -11,21 +9,17 @@ public class Location
{
/// <summary>
/// The province to which this location belongs.
/// </summary>
[JsonIgnore]
public Province Province { get; }
public string ProvinceName => Province.Name;
/// <summary>
/// The location's full human-readable name.
/// </summary>
public string Name { get; }
public string? Name { get; }
/// <summary>
/// The location's shorthand abbreviation.
/// </summary>
public string Abbreviation { get; }
public string? Abbreviation { get; }
/// <summary>
/// The location's type.
@ -35,16 +29,10 @@ public class Location
/// <summary>
/// The locations that border this location.
/// </summary>
[JsonIgnore]
public IEnumerable<Location> Adjacents => this.AdjacentList;
private List<Location> AdjacentList { get; set; }
/// <summary>
/// The unique name of this location in the map.
/// </summary>
public string Key => $"{this.ProvinceName}/{this.Abbreviation}";
public Location(Province province, string name, string abbreviation, LocationType type)
public Location(Province province, string? name, string? abbreviation, LocationType type)
{
this.Province = province;
this.Name = name;
@ -53,15 +41,9 @@ public class Location
this.AdjacentList = new List<Location>();
}
public static (string province, string location) SplitKey(string locationKey)
{
var split = locationKey.Split(['/'], 2);
return (split[0], split[1]);
}
public override string ToString()
{
return this.Name == "land" || this.Name == "water"
return this.Name == null
? $"{this.Province.Name} ({this.Type})"
: $"{this.Province.Name} ({this.Type}:{this.Name}]";
}

View File

@ -1,570 +0,0 @@
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Encapsulation of the world map and playable powers constituting a Diplomacy variant.
/// </summary>
public class Map
{
/// <summary>
/// The map type.
/// </summary>
public MapType Type { get; }
/// <summary>
/// The game map.
/// </summary>
public IReadOnlyCollection<Province> Provinces => _Provinces.AsReadOnly();
private List<Province> _Provinces { get; }
private Dictionary<string, Location> LocationLookup { get; }
/// <summary>
/// The game powers.
/// </summary>
public IReadOnlyCollection<string> Powers => _Powers.AsReadOnly();
private List<string> _Powers { get; }
private Map(MapType type, IEnumerable<Province> provinces, IEnumerable<string> powers)
{
Type = type;
_Provinces = provinces.ToList();
_Powers = powers.ToList();
LocationLookup = Provinces
.SelectMany(province => province.Locations)
.ToDictionary(location => location.Key);
}
/// <summary>
/// Get a province by name. Throws if the province is not found.
/// </summary>
public Province GetProvince(string provinceName)
=> GetProvince(provinceName, this.Provinces);
/// <summary>
/// Get a province by name. Throws if the province is not found.
/// </summary>
private static Province GetProvince(string provinceName, IEnumerable<Province> provinces)
=> provinces.SingleOrDefault(
p => p!.Name.Equals(provinceName, StringComparison.InvariantCultureIgnoreCase)
|| p.Abbreviations.Any(
a => a.Equals(provinceName, StringComparison.InvariantCultureIgnoreCase)),
null)
?? throw new KeyNotFoundException($"Province {provinceName} not found");
/// <summary>
/// Get the location in a province matching a predicate. Throws if there is not exactly one
/// such location.
/// </summary>
private Location GetLocation(string provinceName, Func<Location, bool> predicate)
=> GetProvince(provinceName).Locations.SingleOrDefault(
l => l != null && predicate(l), null)
?? throw new KeyNotFoundException($"No such location in {provinceName}");
public Location GetLocation(string designation)
=> LocationLookup[designation];
public Location GetLocation(Unit unit)
=> GetLocation(unit.Location);
/// <summary>
/// Get the sole land location of a province.
/// </summary>
public Location GetLand(string provinceName)
=> GetLocation(provinceName, l => l.Type == LocationType.Land);
/// <summary>
/// Get the sole water location of a province, optionally specifying a named coast.
/// </summary>
public Location GetWater(string provinceName, string? coastName = null)
=> coastName == null
? GetLocation(provinceName, l => l.Type == LocationType.Water)
: GetLocation(provinceName, l => l.Name == coastName || l.Abbreviation == coastName);
/// <summary>
/// Get a power by full or partial name. Throws if there is not exactly one such power.
/// </summary>
public string GetPower(string powerName)
=> Powers.SingleOrDefault(p => p!.EqualsAnyCase(powerName) || p!.StartsWithAnyCase(powerName), null)
?? throw new KeyNotFoundException($"Power {powerName} not found (powers: {string.Join(", ", Powers)})");
public static Map FromType(MapType type)
=> type switch {
MapType.Test => Test,
MapType.Classical => Classical,
_ => throw new NotImplementedException($"Unknown variant {type}"),
};
public static Map Test => _Test.Value;
private static readonly Lazy<Map> _Test = new(() => {
Province lef = Province.Time("Left", "Lef")
.AddLandLocation();
Province cen = Province.Empty("Center", "Cen")
.AddLandLocation();
Province rig = Province.Time("Right", "Rig")
.AddLandLocation();
Location center = cen.Locations.First();
center.AddBorder(lef.Locations.First());
center.AddBorder(rig.Locations.First());
return new(MapType.Test, [lef, cen, rig], ["Alpha", "Beta"]);
});
public static Map Classical => _Classical.Value;
private static readonly Lazy<Map> _Classical = new(() => {
// Define the provinces of the standard world map.
List<Province> provinces =
[
#region Provinces
Province.Empty("North Africa", "NAF")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Tunis", "TUN")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Bohemia", "BOH")
.AddLandLocation(),
Province.Supply("Budapest", "BUD")
.AddLandLocation(),
Province.Empty("Galacia", "GAL")
.AddLandLocation(),
Province.Supply("Trieste", "TRI")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Tyrolia", "TYR")
.AddLandLocation(),
Province.Time("Vienna", "VIE")
.AddLandLocation(),
Province.Empty("Albania", "ALB")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Bulgaria", "BUL")
.AddLandLocation()
.AddCoastLocation("east coast", "ec")
.AddCoastLocation("south coast", "sc"),
Province.Supply("Greece", "GRE")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Rumania", "RUM", "RMA")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Serbia", "SER")
.AddLandLocation(),
Province.Empty("Clyde", "CLY")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Edinburgh", "EDI")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Liverpool", "LVP", "LPL")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("London", "LON")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Wales", "WAL")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Yorkshire", "YOR")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Brest", "BRE")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Burgundy", "BUR")
.AddLandLocation(),
Province.Empty("Gascony", "GAS")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Marseilles", "MAR")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("Paris", "PAR")
.AddLandLocation(),
Province.Empty("Picardy", "PIC")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("Berlin", "BER")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Kiel", "KIE")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Munich", "MUN")
.AddLandLocation(),
Province.Empty("Prussia", "PRU")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Ruhr", "RUH", "RHR")
.AddLandLocation(),
Province.Empty("Silesia", "SIL")
.AddLandLocation(),
Province.Supply("Spain", "SPA")
.AddLandLocation()
.AddCoastLocation("north coast", "nc")
.AddCoastLocation("south coast", "sc"),
Province.Supply("Portugal", "POR")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Apulia", "APU")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Naples", "NAP")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Piedmont", "PIE")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("Rome", "ROM", "RME")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Tuscany", "TUS")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Venice", "VEN")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Belgium", "BEL")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Holland", "HOL")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Finland", "FIN")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Livonia", "LVN", "LVA")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("Moscow", "MOS")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Sevastopol", "SEV")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Saint Petersburg", "STP")
.AddLandLocation()
.AddCoastLocation("north coast", "nc")
.AddCoastLocation("west coast", "wc"),
Province.Empty("Ukraine", "UKR")
.AddLandLocation(),
Province.Supply("Warsaw", "WAR")
.AddLandLocation(),
Province.Supply("Denmark", "DEN")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Norway", "NWY")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Sweden", "SWE")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Ankara", "ANK")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Armenia", "ARM")
.AddLandLocation()
.AddOceanLocation(),
Province.Time("Constantinople", "CON")
.AddLandLocation()
.AddOceanLocation(),
Province.Supply("Smyrna", "SMY")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Syria", "SYR")
.AddLandLocation()
.AddOceanLocation(),
Province.Empty("Barents Sea", "BAR")
.AddOceanLocation(),
Province.Empty("English Channel", "ENC", "ECH")
.AddOceanLocation(),
Province.Empty("Heligoland Bight", "HEL", "HGB")
.AddOceanLocation(),
Province.Empty("Irish Sea", "IRS", "IRI")
.AddOceanLocation(),
Province.Empty("Mid-Atlantic Ocean", "MAO", "MID")
.AddOceanLocation(),
Province.Empty("North Atlantic Ocean", "NAO", "NAT")
.AddOceanLocation(),
Province.Empty("North Sea", "NTH", "NTS")
.AddOceanLocation(),
Province.Empty("Norwegian Sea", "NWS", "NWG")
.AddOceanLocation(),
Province.Empty("Skagerrak", "SKA", "SKG")
.AddOceanLocation(),
Province.Empty("Baltic Sea", "BAL")
.AddOceanLocation(),
Province.Empty("Guld of Bothnia", "GOB", "BOT")
.AddOceanLocation(),
Province.Empty("Adriatic Sea", "ADS", "ADR")
.AddOceanLocation(),
Province.Empty("Aegean Sea", "AEG")
.AddOceanLocation(),
Province.Empty("Black Sea", "BLA")
.AddOceanLocation(),
Province.Empty("Eastern Mediterranean Sea", "EMS", "EAS")
.AddOceanLocation(),
Province.Empty("Gulf of Lyons", "GOL", "LYO")
.AddOceanLocation(),
Province.Empty("Ionian Sea", "IOS", "ION", "INS")
.AddOceanLocation(),
Province.Empty("Tyrrhenian Sea", "TYS", "TYN")
.AddOceanLocation(),
Province.Empty("Western Mediterranean Sea", "WMS", "WES")
.AddOceanLocation(),
#endregion
];
// Declare some helpers for border definitions
Location Land(string provinceName) => GetProvince(provinceName, provinces)
.Locations.Single(l => l.Type == LocationType.Land);
Location Water(string provinceName) => GetProvince(provinceName, provinces)
.Locations.Single(l => l.Type == LocationType.Water);
Location Coast(string provinceName, string coastName)
=> GetProvince(provinceName, provinces)
.Locations.Single(l => l.Name == coastName || l.Abbreviation == coastName);
static void AddBordersTo(Location location, Func<string, Location> LocationType, params string[] borders)
{
foreach (string bordering in borders)
{
location.AddBorder(LocationType(bordering));
}
}
void AddBorders(string provinceName, Func<string, Location> LocationType, params string[] borders)
=> AddBordersTo(LocationType(provinceName), LocationType, borders);
#region Borders
AddBorders("NAF", Land, "TUN");
AddBorders("NAF", Water, "MAO", "WES", "TUN");
AddBorders("TUN", Land, "NAF");
AddBorders("TUN", Water, "NAF", "WES", "TYS", "ION");
AddBorders("BOH", Land, "MUN", "SIL", "GAL", "VIE", "TYR");
AddBorders("BUD", Land, "VIE", "GAL", "RUM", "SER", "TRI");
AddBorders("GAL", Land, "BOH", "SIL", "WAR", "UKR", "RUM", "BUD", "VIE");
AddBorders("TRI", Land, "TYR", "VIE", "BUD", "SER", "ALB");
AddBorders("TRI", Water, "ALB", "ADR", "VEN");
AddBorders("TYR", Land, "MUN", "BOH", "VIE", "TRI", "VEN", "PIE");
AddBorders("VIE", Land, "TYR", "BOH", "GAL", "BUD", "TRI");
AddBorders("ALB", Land, "TRI", "SER", "GRE");
AddBorders("ALB", Water, "TRI", "ADR", "ION", "GRE");
AddBorders("BUL", Land, "GRE", "SER", "RUM", "CON");
AddBordersTo(Coast("BUL", "ec"), Water, "BLA", "CON");
AddBordersTo(Coast("BUL", "sc"), Water, "CON", "AEG", "GRE");
AddBorders("GRE", Land, "ALB", "SER", "BUL");
AddBorders("GRE", Water, "ALB", "ION", "AEG");
Water("GRE").AddBorder(Coast("BUL", "sc"));
AddBorders("RUM", Land, "BUL", "SER", "BUD", "GAL", "UKR", "SEV");
AddBorders("RUM", Water, "SEV", "BLA");
Water("RUM").AddBorder(Coast("BUL", "ec"));
AddBorders("SER", Land, "BUD", "RUM", "BUL", "GRE", "ALB", "TRI");
AddBorders("CLY", Land, "EDI", "LVP");
AddBorders("CLY", Water, "LVP", "NAO", "NWG", "EDI");
AddBorders("EDI", Land, "YOR", "LVP", "CLY");
AddBorders("EDI", Water, "CLY", "NWG", "NTH", "YOR");
AddBorders("LVP", Land, "CLY", "EDI", "YOR", "WAL");
AddBorders("LVP", Water, "WAL", "IRS", "NAO", "CLY");
AddBorders("LON", Land, "WAL", "YOR");
AddBorders("LON", Water, "WAL", "ENC", "NTH", "YOR");
AddBorders("WAL", Land, "LVP", "YOR", "LON");
AddBorders("WAL", Water, "LON", "ENC", "IRS", "LVP");
AddBorders("YOR", Land, "LON", "WAL", "LVP", "EDI");
AddBorders("YOR", Water, "EDI", "NTH", "LON");
AddBorders("BRE", Land, "PIC", "PAR", "GAS");
AddBorders("BRE", Water, "GAS", "MAO", "ENC", "PIC");
AddBorders("BUR", Land, "BEL", "RUH", "MUN", "MAR", "GAS", "PAR", "PIC");
AddBorders("GAS", Land, "BRE", "PAR", "BUR", "MAR", "SPA");
AddBorders("GAS", Water, "MAO", "BRE");
Water("GAS").AddBorder(Coast("SPA", "nc"));
AddBorders("MAR", Land, "SPA", "GAS", "BUR", "PIE");
AddBorders("MAR", Water, "LYO", "PIE");
Water("MAR").AddBorder(Coast("SPA", "sc"));
AddBorders("PAR", Land, "PIC", "BUR", "GAS", "BRE");
AddBorders("PIC", Land, "BEL", "BUR", "PAR", "BRE");
AddBorders("PIC", Water, "BRE", "ENC", "BEL");
AddBorders("BER", Land, "PRU", "SIL", "MUN", "KIE");
AddBorders("BER", Water, "KIE", "BAL", "PRU");
AddBorders("KIE", Land, "BER", "MUN", "RUH", "HOL", "DEN");
AddBorders("KIE", Water, "HOL", "HEL", "DEN", "BAL", "BER");
AddBorders("MUN", Land, "BUR", "RUH", "KIE", "BER", "SIL", "BOH", "TYR");
AddBorders("PRU", Land, "LVN", "WAR", "SIL", "BER");
AddBorders("PRU", Water, "BER", "BAL", "LVN");
AddBorders("RUH", Land, "KIE", "MUN", "BUR", "BEL", "HOL");
AddBorders("SIL", Land, "PRU", "WAR", "GAL", "BOH", "MUN", "BER");
AddBorders("SPA", Land, "POR", "GAS", "MAR");
AddBordersTo(Coast("SPA", "nc"), Water, "POR", "MAO", "GAS");
AddBordersTo(Coast("SPA", "sc"), Water, "POR", "MAO", "WES", "LYO", "MAR");
AddBorders("POR", Land, "SPA");
AddBorders("POR", Water, "MAO");
Water("POR").AddBorder(Coast("SPA", "nc"));
Water("POR").AddBorder(Coast("SPA", "sc"));
AddBorders("APU", Land, "NAP", "ROM", "VEN");
AddBorders("APU", Water, "VEN", "ADR", "IOS", "NAP");
AddBorders("NAP", Land, "ROM", "APU");
AddBorders("NAP", Water, "APU", "IOS", "TYS", "ROM");
AddBorders("PIE", Land, "MAR", "TYR", "VEN", "TUS");
AddBorders("PIE", Water, "TUS", "LYO", "MAR");
AddBorders("ROM", Land, "TUS", "VEN", "APU", "NAP");
AddBorders("ROM", Water, "NAP", "TYS", "TUS");
AddBorders("TUS", Land, "PIE", "VEN", "ROM");
AddBorders("TUS", Water, "ROM", "TYS", "LYO", "PIE");
AddBorders("VEN", Land, "APU", "ROM", "TUS", "PIE", "TYR", "TRI");
AddBorders("VEN", Water, "TRI", "ADR", "APU");
AddBorders("BEL", Land, "HOL", "RUH", "BUR", "PIC");
AddBorders("BEL", Water, "PIC", "ENC", "NTH", "HOL");
AddBorders("HOL", Land, "BEL", "RUH", "KIE");
AddBorders("HOL", Water, "NTH", "HEL");
AddBorders("FIN", Land, "SWE", "NWY", "STP");
AddBorders("FIN", Water, "SWE", "BOT");
Water("FIN").AddBorder(Coast("STP", "wc"));
AddBorders("LVN", Land, "STP", "MOS", "WAR", "PRU");
AddBorders("LVN", Water, "PRU", "BAL", "BOT");
Water("LVN").AddBorder(Coast("STP", "wc"));
AddBorders("MOS", Land, "SEV", "UKR", "WAR", "LVN", "STP");
AddBorders("SEV", Land, "RUM", "UKR", "MOS", "ARM");
AddBorders("SEV", Water, "ARM", "BLA", "RUM");
AddBorders("STP", Land, "MOS", "LVN", "FIN");
AddBordersTo(Coast("STP", "nc"), Water, "BAR", "NWY");
AddBordersTo(Coast("STP", "wc"), Water, "LVN", "BOT", "FIN");
AddBorders("UKR", Land, "MOS", "SEV", "RUM", "GAL", "WAR");
AddBorders("WAR", Land, "PRU", "LVN", "MOS", "UKR", "GAL", "SIL");
AddBorders("DEN", Land, "KIE", "SWE");
AddBorders("DEN", Water, "KIE", "HEL", "NTH", "SKA", "BAL", "SWE");
AddBorders("NWY", Land, "STP", "FIN", "SWE");
AddBorders("NWY", Water, "BAR", "NWG", "NTH", "SKA", "SWE");
Water("NWY").AddBorder(Coast("STP", "nc"));
AddBorders("SWE", Land, "NWY", "FIN", "DEN");
AddBorders("SWE", Water, "FIN", "BOT", "BAL", "DEN", "SKA", "NWY");
AddBorders("ANK", Land, "ARM", "SMY", "CON");
AddBorders("ANK", Water, "CON", "BLA", "ARM");
AddBorders("ARM", Land, "SEV", "SYR", "SMY", "ANK");
AddBorders("ARM", Water, "ANK", "BLA", "SEV");
AddBorders("CON", Land, "BUL", "ANK", "SMY");
AddBorders("CON", Water, "BLA", "ANK", "SMY", "AEG");
Water("CON").AddBorder(Coast("BUL", "ec"));
Water("CON").AddBorder(Coast("BUL", "sc"));
AddBorders("SMY", Land, "CON", "ANK", "ARM", "SYR");
AddBorders("SMY", Water, "SYR", "EAS", "AEG", "CON");
AddBorders("SYR", Land, "SMY", "ARM");
AddBorders("SYR", Water, "EAS", "SMY");
AddBorders("BAR", Water, "NWG", "NWY");
Water("BAR").AddBorder(Coast("STP", "nc"));
AddBorders("ENC", Water, "LON", "NTH", "BEL", "PIC", "BRE", "MAO", "IRS", "WAL");
AddBorders("HEL", Water, "NTH", "DEN", "BAL", "KIE", "HOL");
AddBorders("IRS", Water, "NAO", "LVP", "WAL", "ENC", "MAO");
AddBorders("MAO", Water, "NAO", "IRS", "ENC", "BRE", "GAS", "POR", "NAF");
Water("MAO").AddBorder(Coast("SPA", "nc"));
Water("MAO").AddBorder(Coast("SPA", "sc"));
AddBorders("NAO", Water, "NWG", "CLY", "LVP", "IRS", "MAO");
AddBorders("NTH", Water, "NWG", "NWY", "SKA", "DEN", "HEL", "HOL", "BEL", "ENC", "LON", "YOR", "EDI");
AddBorders("NWG", Water, "BAR", "NWY", "NTH", "EDI", "CLY", "NAO");
AddBorders("SKA", Water, "NWY", "SWE", "BAL", "DEN", "NTH");
AddBorders("BAL", Water, "BOT", "LVN", "PRU", "BER", "KIE", "HEL", "DEN", "SWE");
AddBorders("BOT", Water, "LVN", "BAL", "SWE", "FIN");
Water("BOT").AddBorder(Coast("STP", "wc"));
AddBorders("ADR", Water, "IOS", "APU", "VEN", "TRI", "ALB");
AddBorders("AEG", Water, "CON", "SMY", "EAS", "IOS", "GRE");
Water("AEG").AddBorder(Coast("BUL", "sc"));
AddBorders("BLA", Water, "RUM", "SEV", "ARM", "ANK", "CON");
Water("BLA").AddBorder(Coast("BUL", "ec"));
AddBorders("EAS", Water, "IOS", "AEG", "SMY", "SYR");
AddBorders("LYO", Water, "MAR", "PIE", "TUS", "TYS", "WES");
Water("LYO").AddBorder(Coast("SPA", "sc"));
AddBorders("IOS", Water, "TUN", "TYS", "NAP", "APU", "ADR", "ALB", "GRE", "AEG");
AddBorders("TYS", Water, "LYO", "TUS", "ROM", "NAP", "IOS", "TUN", "WES");
AddBorders("WES", Water, "LYO", "TYS", "TUN", "NAF", "MAO");
Water("WES").AddBorder(Coast("SPA", "sc"));
#endregion
List<string> powers =
[
"Austria",
"England",
"France",
"Germany",
"Italy",
"Russia",
"Turkey",
];
return new(MapType.Classical, provinces, powers);
});
}

View File

@ -1,16 +0,0 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
[JsonConverter(typeof(JsonStringEnumConverter<MapType>))]
public enum MapType {
/// <summary>
/// A minimal test map.
/// </summary>
Test,
/// <summary>
/// The standard Diplomacy map.
/// </summary>
Classical,
}

View File

@ -20,7 +20,4 @@ public static class ModelExtensions
{
return $"{coord.season.Timeline}-{coord.province.Abbreviations[0]}@{coord.season.Turn}";
}
public static World WithNewSeason(this World world, Season season, out Season future)
=> world.Update(timelines: world.Timelines.WithNewSeason(season, out future));
}
}

View File

@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Orders;
@ -6,23 +6,20 @@ namespace MultiversalDiplomacy.Model;
public class OrderHistory
{
public List<UnitOrder> Orders { get; }
public List<UnitOrder> Orders;
/// <summary>
/// Map from unit designation to dislodge outcome.
/// </summary>
public Dictionary<string, bool> IsDislodgedOutcomes { get; }
public Dictionary<Unit, bool> IsDislodgedOutcomes;
/// <summary>
/// Map from designation of the ordered unit to move outcome.
/// </summary>
public Dictionary<string, bool> DoesMoveOutcomes { get; }
public Dictionary<MoveOrder, bool> DoesMoveOutcomes;
public OrderHistory()
: this(new(), new(), new())
{}
[JsonConstructor]
public OrderHistory(
List<UnitOrder> orders,
Dictionary<string, bool> isDislodgedOutcomes,
Dictionary<string, bool> doesMoveOutcomes)
Dictionary<Unit, bool> isDislodgedOutcomes,
Dictionary<MoveOrder, bool> doesMoveOutcomes)
{
this.Orders = new(orders);
this.IsDislodgedOutcomes = new(isDislodgedOutcomes);

View File

@ -0,0 +1,22 @@
namespace MultiversalDiplomacy.Model;
/// <summary>
/// One of the rival nations vying for control of the map.
/// </summary>
public class Power
{
/// <summary>
/// The power's name.
/// </summary>
public string Name { get; }
public Power(string name)
{
this.Name = name;
}
public override string ToString()
{
return this.Name;
}
}

View File

@ -37,7 +37,7 @@ public class Province
this.Abbreviations = abbreviations;
this.IsSupplyCenter = isSupply;
this.IsTimeCenter = isTime;
this.LocationList = [];
this.LocationList = new List<Location>();
}
public override string ToString()
@ -49,26 +49,26 @@ public class Province
/// Create a new province with no supply center.
/// </summary>
public static Province Empty(string name, params string[] abbreviations)
=> new(name, abbreviations, isSupply: false, isTime: false);
=> new Province(name, abbreviations, isSupply: false, isTime: false);
/// <summary>
/// Create a new province with a supply center.
/// </summary>
public static Province Supply(string name, params string[] abbreviations)
=> new(name, abbreviations, isSupply: true, isTime: false);
=> new Province(name, abbreviations, isSupply: true, isTime: false);
/// <summary>
/// Create a new province with a time center.
/// </summary>
public static Province Time(string name, params string[] abbreviations)
=> new(name, abbreviations, isSupply: true, isTime: true);
=> new Province(name, abbreviations, isSupply: true, isTime: true);
/// <summary>
/// Create a new land location in this province.
/// </summary>
public Province AddLandLocation()
{
Location location = new(this, "land", "l", LocationType.Land);
Location location = new Location(this, name: null, abbreviation: null, LocationType.Land);
this.LocationList.Add(location);
return this;
}
@ -78,7 +78,19 @@ public class Province
/// </summary>
public Province AddOceanLocation()
{
Location location = new(this, "water", "w", LocationType.Water);
Location location = new Location(this, name: null, abbreviation: null, LocationType.Water);
this.LocationList.Add(location);
return this;
}
/// <summary>
/// Create a new coastal location. Coastal locations must have names to disambiguate them
/// from the single land location in coastal provinces.
/// </summary>
public Province AddCoastLocation()
{
// Use a default name for provinces with only one coastal location
Location location = new Location(this, "coast", "c", LocationType.Water);
this.LocationList.Add(location);
return this;
}
@ -89,7 +101,7 @@ public class Province
/// </summary>
public Province AddCoastLocation(string name, string abbreviation)
{
Location location = new(this, name, abbreviation, LocationType.Water);
Location location = new Location(this, name, abbreviation, LocationType.Water);
this.LocationList.Add(location);
return this;
}

View File

@ -1,77 +1,185 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Represents a multiversal coordinate at which a state of the map exists.
/// Represents a state of the map produced by a set of move orders on a previous season.
/// </summary>
[JsonConverter(typeof(SeasonJsonConverter))]
public struct Season(string timeline, int turn)
public class Season
{
/// <summary>
/// The root season of every game. This is defined to avoid any confusion about what the first turn or timeline
/// should be or what season to use to key into a fresh <see cref="Timelines"/>.
/// A shared counter for handing out new timeline numbers.
/// </summary>
public static readonly Season First = new(Timelines.IntToString(0), 0);
private class TimelineFactory
{
private int nextTimeline = 0;
public int NextTimeline() => nextTimeline++;
}
/// <summary>
/// The timeline to which this season belongs.
/// The first turn number.
/// </summary>
public string Timeline { get; } = timeline;
public const int FIRST_TURN = 0;
/// <summary>
/// The season immediately preceding this season.
/// If this season is an alternate timeline root, the past is from the origin timeline.
/// The initial season does not have a past.
/// </summary>
public Season? Past { get; }
/// <summary>
/// The current turn, beginning at 0. Each season (spring and fall) is one turn.
/// Phases that only occur after the fall phase occur when Turn % 2 == 1.
/// The current year is (Turn / 2) + 1901.
/// </summary>
public int Turn { get; } = turn;
public int Turn { get; }
/// <summary>
/// The multiversal designation of this season.
/// The timeline to which this season belongs.
/// </summary>
[JsonIgnore]
public readonly string Key => $"{this.Timeline}{this.Turn}";
public int Timeline { get; }
/// <summary>
/// Create a new season from a tuple coordinate.
/// The season's spatial location as a turn-timeline tuple.
/// </summary>
public Season((string timeline, int turn) coord) : this(coord.timeline, coord.turn) { }
public (int Turn, int Timeline) Coord => (this.Turn, this.Timeline);
/// <summary>
/// Create a new season from a combined string designation.
/// The shared timeline number generator.
/// </summary>
/// <param name="designation"></param>
public Season(string designation) : this(SplitKey(designation)) { }
private TimelineFactory Timelines { get; }
/// <summary>
/// Extract the timeline and turn components of a season designation.
/// Future seasons created directly from this season.
/// </summary>
/// <param name="seasonKey">A timeline-turn season designation.</param>
/// <returns>The timeline and turn components.</returns>
/// <exception cref="FormatException"></exception>
public static (string timeline, int turn) SplitKey(string seasonKey)
public IEnumerable<Season> Futures => this.FutureList;
private List<Season> FutureList { get; }
private Season(Season? past, int turn, int timeline, TimelineFactory factory)
{
int i = 1;
for (; !char.IsAsciiDigit(seasonKey[i]) && i < seasonKey.Length; i++);
return int.TryParse(seasonKey.AsSpan(i), out int turn)
? (seasonKey[..i], turn)
: throw new FormatException($"Could not parse turn from {seasonKey}");
this.Past = past;
this.Turn = turn;
this.Timeline = timeline;
this.Timelines = factory;
this.FutureList = new();
if (past != null)
{
past.FutureList.Add(this);
}
}
public override readonly string ToString() => Key;
public override string ToString()
{
return $"{this.Timeline}@{this.Turn}";
}
/// <remarks>
/// Seasons are essentially 2D points, so they are equal when their components are equal.
/// </remarks>
public override readonly bool Equals([NotNullWhen(true)] object? obj)
=> obj is Season season
&& Timeline == season.Timeline
&& Turn == season.Turn;
/// <summary>
/// Create a root season at the beginning of time.
/// </summary>
public static Season MakeRoot()
{
TimelineFactory factory = new TimelineFactory();
return new Season(
past: null,
turn: FIRST_TURN,
timeline: factory.NextTimeline(),
factory: factory);
}
public static bool operator ==(Season one, Season two) => one.Equals(two);
/// <summary>
/// Create a season immediately after this one in the same timeline.
/// </summary>
public Season MakeNext()
=> new Season(this, this.Turn + 1, this.Timeline, this.Timelines);
public static bool operator !=(Season one, Season two) => !(one == two);
/// <summary>
/// Create a season immediately after this one in a new timeline.
/// </summary>
public Season MakeFork()
=> new Season(this, this.Turn + 1, this.Timelines.NextTimeline(), this.Timelines);
public override readonly int GetHashCode() => (Timeline, Turn).GetHashCode();
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season TimelineRoot()
=> this.Past != null && this.Timeline == this.Past.Timeline
? this.Past.TimelineRoot()
: this;
/// <summary>
/// Returns whether this season is in an adjacent timeline to another season.
/// Seasons are considered to be in adjacent timelines if they are in the same timeline,
/// one is in a timeline that branched from the other's timeline, or both are in timelines
/// that branched from the same point.
/// </summary>
public bool InAdjacentTimeline(Season other)
{
// Timelines are adjacent to themselves. Early out in that case.
if (this.Timeline == other.Timeline) return true;
// If the timelines aren't identical, one of them isn't the initial trunk.
// They can still be adjacent if one of them branched off of the other, or
// if they both branched off of the same point.
Season thisRoot = this.TimelineRoot();
Season otherRoot = other.TimelineRoot();
return // One branched off the other
thisRoot.Past?.Timeline == other.Timeline
|| otherRoot.Past?.Timeline == this.Timeline
// Both branched off of the same point
|| thisRoot.Past == otherRoot.Past;
}
/// <summary>
/// Returns all seasons that are adjacent to this season.
/// </summary>
public IEnumerable<Season> GetAdjacentSeasons()
{
List<Season> adjacents = new();
// The immediate past and all immediate futures are adjacent.
if (this.Past != null) adjacents.Add(this.Past);
adjacents.AddRange(this.FutureList);
// Find all adjacent timelines by finding all timelines that branched off of this season's
// timeline, i.e. all futures of this season's past that have different timelines. Also
// include any timelines that branched off of the timeline this timeline branched off from.
List<Season> adjacentTimelineRoots = new();
Season? current;
for (current = this;
current?.Past?.Timeline != null && current.Past.Timeline == current.Timeline;
current = current.Past)
{
adjacentTimelineRoots.AddRange(
current.FutureList.Where(s => s.Timeline != current.Timeline));
}
// At the end of the for loop, if this season is part of the first timeline, then current
// is the root season (current.past == null); if this season is in a branched timeline,
// then current is the branch timeline's root season (current.past.timeline !=
// current.timeline). There are co-branches if this season is in a branched timeline, since
// the first timeline by definition cannot have co-branches.
if (current?.Past != null)
{
IEnumerable<Season> cobranchRoots = current.Past.FutureList
.Where(s => s.Timeline != current.Timeline && s.Timeline != current.Past.Timeline);
adjacentTimelineRoots.AddRange(cobranchRoots);
}
// Walk up all alternate timelines to find seasons within one turn of this season.
foreach (Season timelineRoot in adjacentTimelineRoots)
{
for (Season? branchSeason = timelineRoot;
branchSeason != null && branchSeason.Turn <= this.Turn + 1;
branchSeason = branchSeason.FutureList
.FirstOrDefault(s => s!.Timeline == branchSeason.Timeline, null))
{
if (branchSeason.Turn >= this.Turn - 1) adjacents.Add(branchSeason);
}
}
return adjacents;
}
}

View File

@ -1,16 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Serializes a <see cref="Season"/> as its combined designation.
/// </summary>
internal class SeasonJsonConverter : JsonConverter<Season>
{
public override Season Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, Season value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Key);
}

View File

@ -1,152 +0,0 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Tracks the relations between seasons.
/// </summary>
public class Timelines(int next, Dictionary<string, Season?> pasts)
{
private static readonly char[] Letters = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z',
];
/// <summary>
/// Convert a string timeline identifier to its serial number.
/// </summary>
/// <param name="timeline">Timeline identifier.</param>
/// <returns>Integer.</returns>
public static int StringToInt(string timeline)
{
int result = Array.IndexOf(Letters, timeline[0]);
for (int i = 1; i < timeline.Length; i++) {
// The result is incremented by one because timeline designations are not a true base26 system.
// The "ones digit" maps a-z 0-25, but the "tens digit" maps a to 1, so "10" (26) is "aa" and not "a0"
result = (result + 1) * 26;
result += Array.IndexOf(Letters, timeline[i]);
}
return result;
}
/// <summary>
/// Convert a timeline serial number to its string identifier.
/// </summary>
/// <param name="serial">Integer.</param>
/// <returns>Timeline identifier.</returns>
public static string IntToString(int serial) {
static int downshift(int i ) => (i - (i % 26)) / 26;
IEnumerable<char> result = [Letters[serial % 26]];
for (int remainder = downshift(serial); remainder > 0; remainder = downshift(remainder) - 1) {
// We subtract 1 after downshifting for the same reason we add 1 above after upshifting.
result = result.Prepend(Letters[(remainder % 26 + 25) % 26]);
}
return new string(result.ToArray());
}
/// <summary>
/// Extract the timeline and turn components of a season designation.
/// </summary>
/// <param name="seasonKey">A timeline-turn season designation.</param>
/// <returns>The timeline and turn components.</returns>
/// <exception cref="FormatException"></exception>
public static (string timeline, int turn) SplitKey(string seasonKey)
{
int i = 1;
for (; !char.IsAsciiDigit(seasonKey[i]) && i < seasonKey.Length; i++);
return int.TryParse(seasonKey.AsSpan(i), out int turn)
? (seasonKey[..i], turn)
: throw new FormatException($"Could not parse turn from {seasonKey}");
}
/// <summary>
/// The next timeline to be created.
/// </summary>
public int Next { get; private set; } = next;
/// <summary>
/// Map of season designations to their parent seasons.
/// The set of keys here is the set of all seasons in the multiverse.
/// </summary>
public Dictionary<string, Season?> Pasts { get; } = pasts;
/// <summary>
/// Create a new multiverse with an initial season.
/// </summary>
public static Timelines Create()
=> new(StringToInt(Season.First.Timeline) + 1, new() { {Season.First.Key, null} });
/// <summary>
/// Create a continuation of a season if it has no futures, otherwise create a fork.
/// </summary>
public Timelines WithNewSeason(Season past, out Season future)
{
int next;
(next, future) = GetFutureKeys(past).Any()
? (Next + 1, new Season(IntToString(Next), past.Turn + 1))
: (Next, new Season(past.Timeline, past.Turn + 1));
return new Timelines(next, new(Pasts.Append(new KeyValuePair<string, Season?>(future.Key, past))));
}
/// <summary>
/// Create a continuation of a season if it has no futures, otherwise create a fork.
/// </summary>
public Timelines WithNewSeason(string past, out Season future) => WithNewSeason(new Season(past), out future);
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="season">A season.</param>
/// <returns>The immediate futures of the season.</returns>
public IEnumerable<string> GetFutureKeys(Season season)
=> Pasts.Where(kvp => kvp.Value is Season future && future == season).Select(kvp => kvp.Key);
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="season">A season.</param>
/// <returns>The immediate futures of the season.</returns>
public IEnumerable<Season> GetFutures(Season season) => GetFutureKeys(season).Select(key => new Season(key));
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season GetTimelineRoot(Season season)
{
return Pasts[season.Key] is Season past && season.Timeline == past.Timeline
? GetTimelineRoot(past)
: season;
}
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season GetTimelineRoot(string season) => GetTimelineRoot(new Season(season));
/// <summary>
/// Returns whether a season is in an adjacent timeline to another season.
/// Seasons are considered to be in adjacent timelines if they are in the same timeline,
/// one is in a timeline that branched from the other's timeline, or both are in timelines
/// that branched from the same point.
/// </summary>
public bool InAdjacentTimeline(Season one, Season two)
{
// Timelines are adjacent to themselves. Early out in that case.
if (one == two) return true;
// If the timelines aren't identical, one of them isn't the initial trunk.
// They can still be adjacent if one of them branched off of the other, or
// if they both branched off of the same point.
Season rootOne = GetTimelineRoot(one);
Season rootTwo = GetTimelineRoot(two);
bool oneForked = Pasts[rootOne.Key] is Season originOne && originOne.Timeline == two.Timeline;
bool twoForked = Pasts[rootTwo.Key] is Season originTwo && originTwo.Timeline == one.Timeline;
bool bothForked = Pasts[rootOne.Key] == Pasts[rootTwo.Key];
return oneForked || twoForked || bothForked;
}
}

View File

@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
@ -10,12 +8,17 @@ public class Unit
/// <summary>
/// The previous iteration of a unit. This is null if the unit was just built.
/// </summary>
public string? Past { get; }
public Unit? Past { get; }
/// <summary>
/// The location on the map where the unit is.
/// </summary>
public string Location { get; }
public Location Location { get; }
/// <summary>
/// The province where the unit is.
/// </summary>
public Province Province => this.Location.Province;
/// <summary>
/// The season in time when the unit is.
@ -25,7 +28,7 @@ public class Unit
/// <summary>
/// The allegiance of the unit.
/// </summary>
public string Power { get; }
public Power Power { get; }
/// <summary>
/// The type of unit.
@ -33,12 +36,11 @@ public class Unit
public UnitType Type { get; }
/// <summary>
/// A unique designation for this unit.
/// The unit's spatiotemporal location as a province-season tuple.
/// </summary>
[JsonIgnore]
public string Key => $"{Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
public (Province province, Season season) Point => (this.Province, this.Season);
public Unit(string? past, string location, Season season, string power, UnitType type)
private Unit(Unit? past, Location location, Season season, Power power, UnitType type)
{
this.Past = past;
this.Location = location;
@ -48,18 +50,20 @@ public class Unit
}
public override string ToString()
=> $"{Power[0]} {Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
{
return $"{this.Power.Name[0]} {this.Type.ToShort()} {(this.Province, this.Season).ToShort()}";
}
/// <summary>
/// Create a new unit. No validation is performed; the adjudicator should only call this
/// method after accepting a build order.
/// </summary>
public static Unit Build(string location, Season season, string power, UnitType type)
=> new(past: null, location, season, power, type);
public static Unit Build(Location location, Season season, Power power, UnitType type)
=> new Unit(past: null, location, season, power, type);
/// <summary>
/// Advance this unit's timeline to a new location and season.
/// </summary>
public Unit Next(string location, Season season)
=> new(past: this.Key, location, season, this.Power, this.Type);
public Unit Next(Location location, Season season)
=> new Unit(past: this, location, season, this.Power, this.Type);
}

View File

@ -1,11 +1,8 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// The type of a unit.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<UnitType>))]
public enum UnitType
{
/// <summary>

View File

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Model;
@ -8,96 +9,66 @@ namespace MultiversalDiplomacy.Model;
/// </summary>
public class World
{
public static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// The map variant of the game.
/// </summary>
[JsonIgnore]
public Map Map { get; }
/// <summary>
/// The map variant of the game.
/// </summary>
/// <remarks>
/// While this is serialized to JSON, deserialization uses it to populate <see cref="Map"/>
/// </remarks>
public MapType MapType => this.Map.Type;
/// <summary>
/// The game map.
/// </summary>
[JsonIgnore]
public IReadOnlyCollection<Province> Provinces => this.Map.Provinces;
public ReadOnlyCollection<Province> Provinces { get; }
/// <summary>
/// The game powers.
/// </summary>
[JsonIgnore]
public IReadOnlyCollection<string> Powers => this.Map.Powers;
public ReadOnlyCollection<Power> Powers { get; }
/// <summary>
/// The state of the multiverse.
/// </summary>
public ReadOnlyCollection<Season> Seasons { get; }
/// <summary>
/// The first season of the game.
/// </summary>
public Season RootSeason { get; }
/// <summary>
/// All units in the multiverse.
/// </summary>
public List<Unit> Units { get; }
public ReadOnlyCollection<Unit> Units { get; }
/// <summary>
/// All retreating units in the multiverse.
/// </summary>
public List<RetreatingUnit> RetreatingUnits { get; }
public ReadOnlyCollection<RetreatingUnit> RetreatingUnits { get; }
/// <summary>
/// Orders given to units in each season.
/// </summary>
public Dictionary<string, OrderHistory> OrderHistory { get; }
/// <summary>
/// The shared timeline number generator.
/// </summary>
public Timelines Timelines { get; }
public ReadOnlyDictionary<Season, OrderHistory> OrderHistory { get; }
/// <summary>
/// Immutable game options.
/// </summary>
public Options Options { get; }
[JsonConstructor]
public World(
MapType mapType,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
Timelines timelines,
Options options)
{
this.Map = Map.FromType(mapType);
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
this.Timelines = timelines;
this.Options = options;
}
/// <summary>
/// Create a new World, providing all state data.
/// </summary>
private World(
Map map,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
Timelines timelines,
ReadOnlyCollection<Province> provinces,
ReadOnlyCollection<Power> powers,
ReadOnlyCollection<Season> seasons,
Season rootSeason,
ReadOnlyCollection<Unit> units,
ReadOnlyCollection<RetreatingUnit> retreatingUnits,
ReadOnlyDictionary<Season, OrderHistory> orderHistory,
Options options)
{
this.Map = map;
this.Provinces = provinces;
this.Powers = powers;
this.Seasons = seasons;
this.RootSeason = rootSeason;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
this.Timelines = timelines;
this.Options = options;
}
@ -106,17 +77,21 @@ public class World
/// </summary>
private World(
World previous,
List<Unit>? units = null,
List<RetreatingUnit>? retreatingUnits = null,
Dictionary<string, OrderHistory>? orderHistory = null,
Timelines? timelines = null,
ReadOnlyCollection<Province>? provinces = null,
ReadOnlyCollection<Power>? powers = null,
ReadOnlyCollection<Season>? seasons = null,
ReadOnlyCollection<Unit>? units = null,
ReadOnlyCollection<RetreatingUnit>? retreatingUnits = null,
ReadOnlyDictionary<Season, OrderHistory>? orderHistory = null,
Options? options = null)
: this(
previous.Map,
provinces ?? previous.Provinces,
powers ?? previous.Powers,
seasons ?? previous.Seasons,
previous.RootSeason, // Can't change the root season
units ?? previous.Units,
retreatingUnits ?? previous.RetreatingUnits,
orderHistory ?? previous.OrderHistory,
timelines ?? previous.Timelines,
options ?? previous.Options)
{
}
@ -124,14 +99,17 @@ public class World
/// <summary>
/// Create a new world with specified provinces and powers and an initial season.
/// </summary>
public static World WithMap(Map map)
public static World WithMap(IEnumerable<Province> provinces, IEnumerable<Power> powers)
{
Season root = Season.MakeRoot();
return new World(
map,
new([]),
new([]),
new(new Dictionary<string, OrderHistory>()),
Timelines.Create(),
new(provinces.ToList()),
new(powers.ToList()),
new(new List<Season> { root }),
root,
new(new List<Unit>()),
new(new List<RetreatingUnit>()),
new(new Dictionary<Season, OrderHistory>()),
new Options());
}
@ -139,15 +117,18 @@ public class World
/// Create a new world with the standard Diplomacy provinces and powers.
/// </summary>
public static World WithStandardMap()
=> WithMap(Map.Classical);
=> WithMap(StandardProvinces, StandardPowers);
public World Update(
IEnumerable<Season>? seasons = null,
IEnumerable<Unit>? units = null,
IEnumerable<RetreatingUnit>? retreats = null,
IEnumerable<KeyValuePair<string, OrderHistory>>? orders = null,
Timelines? timelines = null)
=> new(
IEnumerable<KeyValuePair<Season, OrderHistory>>? orders = null)
=> new World(
previous: this,
seasons: seasons == null
? this.Seasons
: new(seasons.ToList()),
units: units == null
? this.Units
: new(units.ToList()),
@ -156,8 +137,7 @@ public class World
: new(retreats.ToList()),
orderHistory: orders == null
? this.OrderHistory
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)),
timelines: timelines ?? this.Timelines);
: 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
@ -169,7 +149,7 @@ public class World
IEnumerable<Unit> units = unitSpecs.Select(spec =>
{
string[] splits = spec.Split(' ', 4);
string power = Map.GetPower(splits[0]);
Power power = this.GetPower(splits[0]);
UnitType type = splits[1] switch
{
"A" => UnitType.Army,
@ -177,11 +157,11 @@ public class World
_ => throw new ApplicationException($"Unknown unit type {splits[1]}")
};
Location location = type == UnitType.Army
? Map.GetLand(splits[2])
? this.GetLand(splits[2])
: splits.Length == 3
? Map.GetWater(splits[2])
: Map.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location.Key, Season.First, power, type);
? this.GetWater(splits[2])
: this.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location, this.RootSeason, power, type);
return unit;
});
return this.Update(units: units);
@ -221,23 +201,559 @@ public class World
/// <summary>
/// A standard Diplomacy game setup.
/// </summary>
public static World Standard => WithStandardMap().AddStandardUnits();
public static World Standard => World
.WithStandardMap()
.AddStandardUnits();
/// <summary>
/// Get a province by name. Throws if the province is not found.
/// </summary>
private Province GetProvince(string provinceName)
=> GetProvince(provinceName, this.Provinces);
/// <summary>
/// Get a province by name. Throws if the province is not found.
/// </summary>
private static Province GetProvince(string provinceName, IEnumerable<Province> provinces)
{
string provinceNameUpper = provinceName.ToUpperInvariant();
Province? foundProvince = provinces.SingleOrDefault(
p => p!.Name.ToUpperInvariant() == provinceNameUpper
|| p.Abbreviations.Any(a => a.ToUpperInvariant() == provinceNameUpper),
null);
if (foundProvince == null) throw new KeyNotFoundException(
$"Province {provinceName} not found");
return foundProvince;
}
/// <summary>
/// Get the location in a province matching a predicate. Throws if there is not exactly one
/// such location.
/// </summary>
private Location GetLocation(string provinceName, Func<Location, bool> predicate)
{
Location? foundLocation = GetProvince(provinceName).Locations.SingleOrDefault(
l => l != null && predicate(l), null);
if (foundLocation == null) throw new KeyNotFoundException(
$"No such location in {provinceName}");
return foundLocation;
}
/// <summary>
/// Get the sole land location of a province.
/// </summary>
public Location GetLand(string provinceName)
=> GetLocation(provinceName, l => l.Type == LocationType.Land);
/// <summary>
/// Get the sole water location of a province, optionally specifying a named coast.
/// </summary>
public Location GetWater(string provinceName, string? coastName = null)
=> coastName == null
? GetLocation(provinceName, l => l.Type == LocationType.Water)
: GetLocation(provinceName, l => l.Name == coastName || l.Abbreviation == coastName);
/// <summary>
/// Get a season by coordinate. Throws if the season is not found.
/// </summary>
public Season GetSeason(int turn, int timeline)
{
Season? foundSeason = this.Seasons.SingleOrDefault(
s => s!.Turn == turn && s.Timeline == timeline,
null);
if (foundSeason == null) throw new KeyNotFoundException(
$"Season {turn}:{timeline} not found");
return foundSeason;
}
/// <summary>
/// Get a power by name. Throws if there is not exactly one such power.
/// </summary>
public Power GetPower(string powerName)
{
Power? foundPower = this.Powers.SingleOrDefault(
p => p!.Name == powerName || p.Name.StartsWith(powerName),
null);
if (foundPower == null) throw new KeyNotFoundException(
$"Power {powerName} not found");
return foundPower;
}
/// <summary>
/// Returns a unit in a province. Throws if there are duplicate units.
/// </summary>
public Unit GetUnitAt(string provinceName, Season? season = null)
public Unit GetUnitAt(string provinceName, (int turn, int timeline)? seasonCoord = null)
{
Province province = Map.GetProvince(provinceName);
season ??= Season.First;
Province province = GetProvince(provinceName);
seasonCoord ??= (this.RootSeason.Turn, this.RootSeason.Timeline);
Season season = GetSeason(seasonCoord.Value.turn, seasonCoord.Value.timeline);
Unit? foundUnit = this.Units.SingleOrDefault(
u => Map.GetLocation(u!).Province == province && u!.Season == season,
null)
?? throw new KeyNotFoundException($"Unit at {province} at {season} not found");
u => u!.Province == province && u.Season == season,
null);
if (foundUnit == null) throw new KeyNotFoundException(
$"Unit at {province} at {season} not found");
return foundUnit;
}
public Unit GetUnitByKey(string designation)
=> Units.SingleOrDefault(u => u!.Key == designation, null)
?? throw new KeyNotFoundException($"Unit {designation} not found");
}
/// <summary>
/// The standard Diplomacy provinces.
/// </summary>
public static ReadOnlyCollection<Province> StandardProvinces
{
get
{
// Define the provinces of the standard world map.
List<Province> standardProvinces = new List<Province>
{
Province.Empty("North Africa", "NAF")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Tunis", "TUN")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Bohemia", "BOH")
.AddLandLocation(),
Province.Supply("Budapest", "BUD")
.AddLandLocation(),
Province.Empty("Galacia", "GAL")
.AddLandLocation(),
Province.Supply("Trieste", "TRI")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Tyrolia", "TYR")
.AddLandLocation(),
Province.Time("Vienna", "VIE")
.AddLandLocation(),
Province.Empty("Albania", "ALB")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Bulgaria", "BUL")
.AddLandLocation()
.AddCoastLocation("east coast", "ec")
.AddCoastLocation("south coast", "sc"),
Province.Supply("Greece", "GRE")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Rumania", "RUM", "RMA")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Serbia", "SER")
.AddLandLocation(),
Province.Empty("Clyde", "CLY")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Edinburgh", "EDI")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Liverpool", "LVP", "LPL")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("London", "LON")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Wales", "WAL")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Yorkshire", "YOR")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Brest", "BRE")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Burgundy", "BUR")
.AddLandLocation(),
Province.Empty("Gascony", "GAS")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Marseilles", "MAR")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("Paris", "PAR")
.AddLandLocation(),
Province.Empty("Picardy", "PIC")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("Berlin", "BER")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Kiel", "KIE")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Munich", "MUN")
.AddLandLocation(),
Province.Empty("Prussia", "PRU")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Ruhr", "RUH", "RHR")
.AddLandLocation(),
Province.Empty("Silesia", "SIL")
.AddLandLocation(),
Province.Supply("Spain", "SPA")
.AddLandLocation()
.AddCoastLocation("north coast", "nc")
.AddCoastLocation("south coast", "sc"),
Province.Supply("Portugal", "POR")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Apulia", "APU")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Naples", "NAP")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Piedmont", "PIE")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("Rome", "ROM", "RME")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Tuscany", "TUS")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Venice", "VEN")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Belgium", "BEL")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Holland", "HOL")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Finland", "FIN")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Livonia", "LVN", "LVA")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("Moscow", "MOS")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Sevastopol", "SEV")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Saint Petersburg", "STP")
.AddLandLocation()
.AddCoastLocation("north coast", "nc")
.AddCoastLocation("west coast", "wc"),
Province.Empty("Ukraine", "UKR")
.AddLandLocation(),
Province.Supply("Warsaw", "WAR")
.AddLandLocation(),
Province.Supply("Denmark", "DEN")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Norway", "NWY")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Sweden", "SWE")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Ankara", "ANK")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Armenia", "ARM")
.AddLandLocation()
.AddCoastLocation(),
Province.Time("Constantinople", "CON")
.AddLandLocation()
.AddCoastLocation(),
Province.Supply("Smyrna", "SMY")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Syria", "SYR")
.AddLandLocation()
.AddCoastLocation(),
Province.Empty("Barents Sea", "BAR")
.AddOceanLocation(),
Province.Empty("English Channel", "ENC", "ECH")
.AddOceanLocation(),
Province.Empty("Heligoland Bight", "HEL", "HGB")
.AddOceanLocation(),
Province.Empty("Irish Sea", "IRS", "IRI")
.AddOceanLocation(),
Province.Empty("Mid-Atlantic Ocean", "MAO", "MID")
.AddOceanLocation(),
Province.Empty("North Atlantic Ocean", "NAO", "NAT")
.AddOceanLocation(),
Province.Empty("North Sea", "NTH", "NTS")
.AddOceanLocation(),
Province.Empty("Norwegian Sea", "NWS", "NWG")
.AddOceanLocation(),
Province.Empty("Skagerrak", "SKA", "SKG")
.AddOceanLocation(),
Province.Empty("Baltic Sea", "BAL")
.AddOceanLocation(),
Province.Empty("Guld of Bothnia", "GOB", "BOT")
.AddOceanLocation(),
Province.Empty("Adriatic Sea", "ADS", "ADR")
.AddOceanLocation(),
Province.Empty("Aegean Sea", "AEG")
.AddOceanLocation(),
Province.Empty("Black Sea", "BLA")
.AddOceanLocation(),
Province.Empty("Eastern Mediterranean Sea", "EMS", "EAS")
.AddOceanLocation(),
Province.Empty("Gulf of Lyons", "GOL", "LYO")
.AddOceanLocation(),
Province.Empty("Ionian Sea", "IOS", "ION", "INS")
.AddOceanLocation(),
Province.Empty("Tyrrhenian Sea", "TYS", "TYN")
.AddOceanLocation(),
Province.Empty("Western Mediterranean Sea", "WMS", "WES")
.AddOceanLocation(),
};
// Declare some helpers for border definitions
Location Land(string provinceName) => GetProvince(provinceName, standardProvinces)
.Locations.Single(l => l.Type == LocationType.Land);
Location Water(string provinceName) => GetProvince(provinceName, standardProvinces)
.Locations.Single(l => l.Type == LocationType.Water);
Location Coast(string provinceName, string coastName)
=> GetProvince(provinceName, standardProvinces)
.Locations.Single(l => l.Name == coastName || l.Abbreviation == coastName);
static void AddBordersTo(Location location, Func<string, Location> LocationType, params string[] borders)
{
foreach (string bordering in borders)
{
location.AddBorder(LocationType(bordering));
}
}
void AddBorders(string provinceName, Func<string, Location> LocationType, params string[] borders)
=> AddBordersTo(LocationType(provinceName), LocationType, borders);
AddBorders("NAF", Land, "TUN");
AddBorders("NAF", Water, "MAO", "WES", "TUN");
AddBorders("TUN", Land, "NAF");
AddBorders("TUN", Water, "NAF", "WES", "TYS", "ION");
AddBorders("BOH", Land, "MUN", "SIL", "GAL", "VIE", "TYR");
AddBorders("BUD", Land, "VIE", "GAL", "RUM", "SER", "TRI");
AddBorders("GAL", Land, "BOH", "SIL", "WAR", "UKR", "RUM", "BUD", "VIE");
AddBorders("TRI", Land, "TYR", "VIE", "BUD", "SER", "ALB");
AddBorders("TRI", Water, "ALB", "ADR", "VEN");
AddBorders("TYR", Land, "MUN", "BOH", "VIE", "TRI", "VEN", "PIE");
AddBorders("VIE", Land, "TYR", "BOH", "GAL", "BUD", "TRI");
AddBorders("ALB", Land, "TRI", "SER", "GRE");
AddBorders("ALB", Water, "TRI", "ADR", "ION", "GRE");
AddBorders("BUL", Land, "GRE", "SER", "RUM", "CON");
AddBordersTo(Coast("BUL", "ec"), Water, "BLA", "CON");
AddBordersTo(Coast("BUL", "sc"), Water, "CON", "AEG", "GRE");
AddBorders("GRE", Land, "ALB", "SER", "BUL");
AddBorders("GRE", Water, "ALB", "ION", "AEG");
Water("GRE").AddBorder(Coast("BUL", "sc"));
AddBorders("RUM", Land, "BUL", "SER", "BUD", "GAL", "UKR", "SEV");
AddBorders("RUM", Water, "SEV", "BLA");
Water("RUM").AddBorder(Coast("BUL", "ec"));
AddBorders("SER", Land, "BUD", "RUM", "BUL", "GRE", "ALB", "TRI");
AddBorders("CLY", Land, "EDI", "LVP");
AddBorders("CLY", Water, "LVP", "NAO", "NWG", "EDI");
AddBorders("EDI", Land, "YOR", "LVP", "CLY");
AddBorders("EDI", Water, "CLY", "NWG", "NTH", "YOR");
AddBorders("LVP", Land, "CLY", "EDI", "YOR", "WAL");
AddBorders("LVP", Water, "WAL", "IRS", "NAO", "CLY");
AddBorders("LON", Land, "WAL", "YOR");
AddBorders("LON", Water, "WAL", "ENC", "NTH", "YOR");
AddBorders("WAL", Land, "LVP", "YOR", "LON");
AddBorders("WAL", Water, "LON", "ENC", "IRS", "LVP");
AddBorders("YOR", Land, "LON", "WAL", "LVP", "EDI");
AddBorders("YOR", Water, "EDI", "NTH", "LON");
AddBorders("BRE", Land, "PIC", "PAR", "GAS");
AddBorders("BRE", Water, "GAS", "MAO", "ENC", "PIC");
AddBorders("BUR", Land, "BEL", "RUH", "MUN", "MAR", "GAS", "PAR", "PIC");
AddBorders("GAS", Land, "BRE", "PAR", "BUR", "MAR", "SPA");
AddBorders("GAS", Water, "MAO", "BRE");
Water("GAS").AddBorder(Coast("SPA", "nc"));
AddBorders("MAR", Land, "SPA", "GAS", "BUR", "PIE");
AddBorders("MAR", Water, "LYO", "PIE");
Water("MAR").AddBorder(Coast("SPA", "sc"));
AddBorders("PAR", Land, "PIC", "BUR", "GAS", "BRE");
AddBorders("PIC", Land, "BEL", "BUR", "PAR", "BRE");
AddBorders("PIC", Water, "BRE", "ENC", "BEL");
AddBorders("BER", Land, "PRU", "SIL", "MUN", "KIE");
AddBorders("BER", Water, "KIE", "BAL", "PRU");
AddBorders("KIE", Land, "BER", "MUN", "RUH", "HOL", "DEN");
AddBorders("KIE", Water, "HOL", "HEL", "DEN", "BAL", "BER");
AddBorders("MUN", Land, "BUR", "RUH", "KIE", "BER", "SIL", "BOH", "TYR");
AddBorders("PRU", Land, "LVN", "WAR", "SIL", "BER");
AddBorders("PRU", Water, "BER", "BAL", "LVN");
AddBorders("RUH", Land, "KIE", "MUN", "BUR", "BEL", "HOL");
AddBorders("SIL", Land, "PRU", "WAR", "GAL", "BOH", "MUN", "BER");
AddBorders("SPA", Land, "POR", "GAS", "MAR");
AddBordersTo(Coast("SPA", "nc"), Water, "POR", "MAO", "GAS");
AddBordersTo(Coast("SPA", "sc"), Water, "POR", "MAO", "WES", "LYO", "MAR");
AddBorders("POR", Land, "SPA");
AddBorders("POR", Water, "MAO");
Water("POR").AddBorder(Coast("SPA", "nc"));
Water("POR").AddBorder(Coast("SPA", "sc"));
AddBorders("APU", Land, "NAP", "ROM", "VEN");
AddBorders("APU", Water, "VEN", "ADR", "IOS", "NAP");
AddBorders("NAP", Land, "ROM", "APU");
AddBorders("NAP", Water, "APU", "IOS", "TYS", "ROM");
AddBorders("PIE", Land, "MAR", "TYR", "VEN", "TUS");
AddBorders("PIE", Water, "TUS", "LYO", "MAR");
AddBorders("ROM", Land, "TUS", "VEN", "APU", "NAP");
AddBorders("ROM", Water, "NAP", "TYS", "TUS");
AddBorders("TUS", Land, "PIE", "VEN", "ROM");
AddBorders("TUS", Water, "ROM", "TYS", "LYO", "PIE");
AddBorders("VEN", Land, "APU", "ROM", "TUS", "PIE", "TYR", "TRI");
AddBorders("VEN", Water, "TRI", "ADR", "APU");
AddBorders("BEL", Land, "HOL", "RUH", "BUR", "PIC");
AddBorders("BEL", Water, "PIC", "ENC", "NTH", "HOL");
AddBorders("HOL", Land, "BEL", "RUH", "KIE");
AddBorders("HOL", Water, "NTH", "HEL");
AddBorders("FIN", Land, "SWE", "NWY", "STP");
AddBorders("FIN", Water, "SWE", "BOT");
Water("FIN").AddBorder(Coast("STP", "wc"));
AddBorders("LVN", Land, "STP", "MOS", "WAR", "PRU");
AddBorders("LVN", Water, "PRU", "BAL", "BOT");
Water("LVN").AddBorder(Coast("STP", "wc"));
AddBorders("MOS", Land, "SEV", "UKR", "WAR", "LVN", "STP");
AddBorders("SEV", Land, "RUM", "UKR", "MOS", "ARM");
AddBorders("SEV", Water, "ARM", "BLA", "RUM");
AddBorders("STP", Land, "MOS", "LVN", "FIN");
AddBordersTo(Coast("STP", "nc"), Water, "BAR", "NWY");
AddBordersTo(Coast("STP", "wc"), Water, "LVN", "BOT", "FIN");
AddBorders("UKR", Land, "MOS", "SEV", "RUM", "GAL", "WAR");
AddBorders("WAR", Land, "PRU", "LVN", "MOS", "UKR", "GAL", "SIL");
AddBorders("DEN", Land, "KIE", "SWE");
AddBorders("DEN", Water, "KIE", "HEL", "NTH", "SKA", "BAL", "SWE");
AddBorders("NWY", Land, "STP", "FIN", "SWE");
AddBorders("NWY", Water, "BAR", "NWG", "NTH", "SKA", "SWE");
Water("NWY").AddBorder(Coast("STP", "nc"));
AddBorders("SWE", Land, "NWY", "FIN", "DEN");
AddBorders("SWE", Water, "FIN", "BOT", "BAL", "DEN", "SKA", "NWY");
AddBorders("ANK", Land, "ARM", "SMY", "CON");
AddBorders("ANK", Water, "CON", "BLA", "ARM");
AddBorders("ARM", Land, "SEV", "SYR", "SMY", "ANK");
AddBorders("ARM", Water, "ANK", "BLA", "SEV");
AddBorders("CON", Land, "BUL", "ANK", "SMY");
AddBorders("CON", Water, "BLA", "ANK", "SMY", "AEG");
Water("CON").AddBorder(Coast("BUL", "ec"));
Water("CON").AddBorder(Coast("BUL", "sc"));
AddBorders("SMY", Land, "CON", "ANK", "ARM", "SYR");
AddBorders("SMY", Water, "SYR", "EAS", "AEG", "CON");
AddBorders("SYR", Land, "SMY", "ARM");
AddBorders("SYR", Water, "EAS", "SMY");
AddBorders("BAR", Water, "NWG", "NWY");
Water("BAR").AddBorder(Coast("STP", "nc"));
AddBorders("ENC", Water, "LON", "NTH", "BEL", "PIC", "BRE", "MAO", "IRS", "WAL");
AddBorders("HEL", Water, "NTH", "DEN", "BAL", "KIE", "HOL");
AddBorders("IRS", Water, "NAO", "LVP", "WAL", "ENC", "MAO");
AddBorders("MAO", Water, "NAO", "IRS", "ENC", "BRE", "GAS", "POR", "NAF");
Water("MAO").AddBorder(Coast("SPA", "nc"));
Water("MAO").AddBorder(Coast("SPA", "sc"));
AddBorders("NAO", Water, "NWG", "CLY", "LVP", "IRS", "MAO");
AddBorders("NTH", Water, "NWG", "NWY", "SKA", "DEN", "HEL", "HOL", "BEL", "ENC", "LON", "YOR", "EDI");
AddBorders("NWG", Water, "BAR", "NWY", "NTH", "EDI", "CLY", "NAO");
AddBorders("SKA", Water, "NWY", "SWE", "BAL", "DEN", "NTH");
AddBorders("BAL", Water, "BOT", "LVN", "PRU", "BER", "KIE", "HEL", "DEN", "SWE");
AddBorders("BOT", Water, "LVN", "BAL", "SWE", "FIN");
Water("BOT").AddBorder(Coast("STP", "wc"));
AddBorders("ADR", Water, "IOS", "APU", "VEN", "TRI", "ALB");
AddBorders("AEG", Water, "CON", "SMY", "EAS", "IOS", "GRE");
Water("AEG").AddBorder(Coast("BUL", "sc"));
AddBorders("BLA", Water, "RUM", "SEV", "ARM", "ANK", "CON");
Water("BLA").AddBorder(Coast("BUL", "ec"));
AddBorders("EAS", Water, "IOS", "AEG", "SMY", "SYR");
AddBorders("LYO", Water, "MAR", "PIE", "TUS", "TYS", "WES");
Water("LYO").AddBorder(Coast("SPA", "sc"));
AddBorders("IOS", Water, "TUN", "TYS", "NAP", "APU", "ADR", "ALB", "GRE", "AEG");
AddBorders("TYS", Water, "LYO", "TUS", "ROM", "NAP", "IOS", "TUN", "WES");
AddBorders("WES", Water, "LYO", "TYS", "TUN", "NAF", "MAO");
Water("WES").AddBorder(Coast("SPA", "sc"));
return new(standardProvinces);
}
}
/// <summary>
/// The standard Diplomacy powers.
/// </summary>
public static ReadOnlyCollection<Power> StandardPowers
{
get => new(new List<Power>
{
new Power("Austria"),
new Power("England"),
new Power("France"),
new Power("Germany"),
new Power("Italy"),
new Power("Russia"),
new Power("Turkey"),
});
}
}

View File

@ -7,10 +7,4 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>MultiversalDiplomacyTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@ -17,7 +17,7 @@ public class BuildOrder : Order
/// </summary>
public UnitType Type { get; }
public BuildOrder(string power, Location location, UnitType type)
public BuildOrder(Power power, Location location, UnitType type)
: base (power)
{
this.Location = location;

View File

@ -27,7 +27,7 @@ public class ConvoyOrder : UnitOrder
/// </summary>
public Province Province => this.Location.Province;
public ConvoyOrder(string power, Unit unit, Unit target, Season season, Location location)
public ConvoyOrder(Power power, Unit unit, Unit target, Season season, Location location)
: base (power, unit)
{
this.Target = target;
@ -37,6 +37,6 @@ public class ConvoyOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} con {this.Target} -> {(this.Province, this.Season).ToShort()}";
return $"{this.Unit} C {this.Target} -> {(this.Province, this.Season).ToShort()}";
}
}

View File

@ -7,6 +7,6 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class DisbandOrder : UnitOrder
{
public DisbandOrder(string power, Unit unit)
public DisbandOrder(Power power, Unit unit)
: base (power, unit) {}
}

View File

@ -7,7 +7,7 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class HoldOrder : UnitOrder
{
public HoldOrder(string power, Unit unit)
public HoldOrder(Power power, Unit unit)
: base (power, unit) {}
public override string ToString()

View File

@ -1,7 +1,5 @@
using MultiversalDiplomacy.Model;
using static MultiversalDiplomacy.Model.Location;
namespace MultiversalDiplomacy.Orders;
/// <summary>
@ -17,9 +15,19 @@ public class MoveOrder : UnitOrder
/// <summary>
/// The destination location to which the unit should move.
/// </summary>
public string Location { get; }
public Location Location { get; }
public MoveOrder(string power, Unit unit, Season season, string location)
/// <summary>
/// The destination province to which the unit should move.
/// </summary>
public Province Province => this.Location.Province;
/// <summary>
/// The destination's spatiotemporal location as a province-season tuple.
/// </summary>
public (Province province, Season season) Point => (this.Province, this.Season);
public MoveOrder(Power power, Unit unit, Season season, Location location)
: base (power, unit)
{
this.Season = season;
@ -28,6 +36,23 @@ public class MoveOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} -> {Season.Timeline}-{SplitKey(Location).province}@{Season.Turn}";
return $"{this.Unit} -> {(this.Province, this.Season).ToShort()}";
}
/// <summary>
/// Returns whether another move order is in a head-to-head battle with this order.
/// </summary>
public bool IsOpposing(MoveOrder other)
=> this.Season == other.Unit.Season
&& other.Season == this.Unit.Season
&& this.Province == other.Unit.Province
&& other.Province == this.Unit.Province;
/// <summary>
/// Returns whether another move order has the same destination as this order.
/// </summary>
public bool IsCompeting(MoveOrder other)
=> this != other
&& this.Season == other.Season
&& this.Province == other.Province;
}

View File

@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Orders;
@ -7,24 +5,14 @@ namespace MultiversalDiplomacy.Orders;
/// <summary>
/// A submitted action by a power.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(BuildOrder), typeDiscriminator: "move")]
[JsonDerivedType(typeof(ConvoyOrder), typeDiscriminator: "convoy")]
[JsonDerivedType(typeof(DisbandOrder), typeDiscriminator: "disband")]
[JsonDerivedType(typeof(HoldOrder), typeDiscriminator: "hold")]
[JsonDerivedType(typeof(MoveOrder), typeDiscriminator: "move")]
[JsonDerivedType(typeof(RetreatOrder), typeDiscriminator: "retreat")]
[JsonDerivedType(typeof(SupportHoldOrder), typeDiscriminator: "supportHold")]
[JsonDerivedType(typeof(SupportMoveOrder), typeDiscriminator: "supportMove")]
[JsonDerivedType(typeof(SustainOrder), typeDiscriminator: "sustain")]
public abstract class Order
{
/// <summary>
/// The power that submitted this order.
/// </summary>
public string Power { get; }
public Power Power { get; }
public Order(string power)
public Order(Power power)
{
this.Power = power;
}

View File

@ -12,7 +12,7 @@ public class RetreatOrder : UnitOrder
/// </summary>
public Location Location { get; }
public RetreatOrder(string power, Unit unit, Location location)
public RetreatOrder(Power power, Unit unit, Location location)
: base (power, unit)
{
this.Location = location;

View File

@ -7,13 +7,13 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class SupportHoldOrder : SupportOrder
{
public SupportHoldOrder(string power, Unit unit, Unit target)
public SupportHoldOrder(Power power, Unit unit, Unit target)
: base (power, unit, target)
{
}
public override string ToString()
{
return $"{this.Unit} sup {this.Target}";
return $"{this.Unit} S {this.Target}";
}
}

View File

@ -27,7 +27,7 @@ public class SupportMoveOrder : SupportOrder
/// </summary>
public (Province province, Season season) Point => (this.Province, this.Season);
public SupportMoveOrder(string power, Unit unit, Unit target, Season season, Location location)
public SupportMoveOrder(Power power, Unit unit, Unit target, Season season, Location location)
: base(power, unit, target)
{
this.Season = season;
@ -36,6 +36,11 @@ public class SupportMoveOrder : SupportOrder
public override string ToString()
{
return $"{this.Unit} sup {this.Target} -> {(this.Province, this.Season).ToShort()}";
return $"{this.Unit} S {this.Target} -> {(this.Province, this.Season).ToShort()}";
}
public bool IsSupportFor(MoveOrder move)
=> this.Target == move.Unit
&& this.Season == move.Season
&& this.Location == move.Location;
}

View File

@ -12,7 +12,7 @@ public abstract class SupportOrder : UnitOrder
/// </summary>
public Unit Target { get; }
public SupportOrder(string power, Unit unit, Unit target)
public SupportOrder(Power power, Unit unit, Unit target)
: base (power, unit)
{
this.Target = target;

View File

@ -17,7 +17,7 @@ public class SustainOrder : Order
/// </summary>
public int Timeline { get; }
public SustainOrder(string power, Location timeCenter, int timeline)
public SustainOrder(Power power, Location timeCenter, int timeline)
: base (power)
{
this.TimeCenter = timeCenter;

View File

@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Orders;
@ -7,14 +5,6 @@ namespace MultiversalDiplomacy.Orders;
/// <summary>
/// An order given to a specific unit.
/// </summary>
[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(ConvoyOrder), typeDiscriminator: "convoy")]
[JsonDerivedType(typeof(DisbandOrder), typeDiscriminator: "disband")]
[JsonDerivedType(typeof(HoldOrder), typeDiscriminator: "hold")]
[JsonDerivedType(typeof(MoveOrder), typeDiscriminator: "move")]
[JsonDerivedType(typeof(RetreatOrder), typeDiscriminator: "retreat")]
[JsonDerivedType(typeof(SupportHoldOrder), typeDiscriminator: "supportHold")]
[JsonDerivedType(typeof(SupportMoveOrder), typeDiscriminator: "supportMove")]
public abstract class UnitOrder : Order
{
/// <summary>
@ -22,8 +12,16 @@ public abstract class UnitOrder : Order
/// </summary>
public Unit Unit { get; }
public UnitOrder(string power, Unit unit) : base(power)
public UnitOrder(Power power, Unit unit) : base(power)
{
this.Unit = unit;
}
/// <summary>
/// Returns whether a move order is moving into this order's unit's province.
/// </summary>
public bool IsIncoming(MoveOrder other)
=> this != other
&& other.Season == this.Unit.Season
&& other.Province == this.Unit.Province;
}

View File

@ -1,10 +0,0 @@
namespace System;
public static class StringExtensions
{
public static bool EqualsAnyCase(this string str, string? other)
=> str.Equals(other, StringComparison.InvariantCultureIgnoreCase);
public static bool StartsWithAnyCase(this string str, string other)
=> str.StartsWith(other, StringComparison.InvariantCultureIgnoreCase);
}

View File

@ -14,12 +14,12 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// Hold to move into the future, then move back into the past.
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").Holds().GetReference(out var mun0)
.Execute()
[("a", 1)]
[(1, 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").MovesTo("Tyr", season: s0).GetReference(out var mun1);
@ -33,25 +33,25 @@ public class TimeTravelTest
// Confirm that there are now four seasons: three in the main timeline and one in a fork.
Assert.That(
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline == s0.Timeline).Count(),
world.Seasons.Where(s => s.Timeline == s0.Timeline).Count(),
Is.EqualTo(3),
"Failed to advance main timeline after last unit left");
Assert.That(
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline != s0.Timeline).Count(),
world.Seasons.Where(s => s.Timeline != s0.Timeline).Count(),
Is.EqualTo(1),
"Failed to fork timeline when unit moved in");
// Confirm that there is a unit in Tyr b1 originating from Mun a1
Season fork = new("b1");
Unit originalUnit = world.GetUnitAt("Mun", s0);
Unit aMun0 = world.GetUnitAt("Mun", s1);
Unit aTyr = world.GetUnitAt("Tyr", fork);
Assert.That(aTyr.Past, Is.EqualTo(mun1.Order.Unit.Key));
Assert.That(world.GetUnitByKey(aTyr.Past!).Past, Is.EqualTo(mun0.Order.Unit.Key));
// Confirm that there is a unit in Tyr 1:1 originating from Mun 1:0
Season fork = world.GetSeason(1, 1);
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 b1 originating from Mun a0
Unit aMun1 = world.GetUnitAt("Mun", fork);
Assert.That(aMun1.Past, Is.EqualTo(originalUnit.Key));
// 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));
}
[Test]
@ -60,7 +60,7 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// Fail to dislodge on the first turn, then support the move so it succeeds.
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun0)
@ -75,7 +75,7 @@ public class TimeTravelTest
Assert.That(tyr0, Is.NotDislodged);
setup.UpdateWorld();
setup[("a", 1)]
setup[(1, 0)]
["Germany"]
.Army("Mun").Supports.Army("Mun", season: s0).MoveTo("Tyr").GetReference(out var mun1)
["Austria"]
@ -91,17 +91,17 @@ public class TimeTravelTest
// Confirm that an alternate future is created.
World world = setup.UpdateWorld();
Season fork = new("b1");
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Season fork = world.GetSeason(1, 1);
Unit tyr1 = world.GetUnitAt("Tyr", fork.Coord);
Assert.That(
tyr1.Past,
Is.EqualTo(mun0.Order.Unit.Key),
"Expected A Mun a0 to advance to Tyr b1");
Is.EqualTo(mun0.Order.Unit),
"Expected A Mun 0:0 to advance to Tyr 1:1");
Assert.That(
world.RetreatingUnits.Count,
Is.EqualTo(1),
"Expected A Tyr a0 to be in retreat");
Assert.That(world.RetreatingUnits.First().Unit.Key, Is.EqualTo(tyr0.Order.Unit.Key));
"Expected A Tyr 0:0 to be in retreat");
Assert.That(world.RetreatingUnits.First().Unit, Is.EqualTo(tyr0.Order.Unit));
}
[Test]
@ -110,14 +110,14 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// Hold to create a future, then attempt to attack in the past.
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").Holds()
["Austria"]
.Army("Tyr").Holds().GetReference(out var tyr0)
.Execute()
[("a", 1)]
[(1, 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").MovesTo("Tyr", season: s0).GetReference(out var mun1)
@ -128,7 +128,7 @@ public class TimeTravelTest
Assert.That(mun1, Is.Valid);
setup.AdjudicateOrders();
Assert.That(mun1, Is.Repelled);
// The order to Tyr a0 should have been pulled into the adjudication set, so the reference
// The order to Tyr 0:0 should have been pulled into the adjudication set, so the reference
// should not throw when accessing it.
Assert.That(tyr0, Is.NotDislodged);
@ -136,12 +136,12 @@ public class TimeTravelTest
// change the past and therefore did not create a new timeline.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(s0).Count(),
s0.Futures.Count(),
Is.EqualTo(1),
"A failed move incorrectly forked the timeline");
Assert.That(world.Timelines.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(world.Timelines.GetFutures(s2).Count(), Is.EqualTo(0));
Assert.That(s1.Futures.Count(), Is.EqualTo(1));
Season s2 = world.GetSeason(s1.Turn + 1, s1.Timeline);
Assert.That(s2.Futures.Count(), Is.EqualTo(0));
}
[Test]
@ -150,7 +150,7 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// Move, then support the past move even though it succeeded already.
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun0)
@ -162,7 +162,7 @@ public class TimeTravelTest
Assert.That(mun0, Is.Victorious);
setup.UpdateWorld();
setup[("a", 1)]
setup[(1, 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Tyr").Holds()
@ -178,12 +178,12 @@ public class TimeTravelTest
// ...since it succeeded anyway, no fork is created.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(s0).Count(),
s0.Futures.Count(),
Is.EqualTo(1),
"A superfluous support incorrectly forked the timeline");
Assert.That(world.Timelines.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(world.Timelines.GetFutures(s2).Count(), Is.EqualTo(0));
Assert.That(s1.Futures.Count(), Is.EqualTo(1));
Season s2 = world.GetSeason(s1.Turn + 1, s1.Timeline);
Assert.That(s2.Futures.Count(), Is.EqualTo(0));
}
[Test]
@ -192,51 +192,51 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// London creates two timelines by moving into the past.
setup[("a", 0)]
.GetReference(out var a0)
setup[(0, 0)]
.GetReference(out var s0_0)
["England"].Army("Lon").Holds()
["Austria"].Army("Tyr").Holds()
["Germany"].Army("Mun").Holds()
.Execute()
[("a", 1)]
["England"].Army("Lon").MovesTo("Yor", a0)
[(1, 0)]
["England"].Army("Lon").MovesTo("Yor", s0_0)
.Execute()
// Head seasons: a2 b1
// Head seasons: 2:0 1:1
// Both contain copies of the armies in Mun and Tyr.
// Now Germany dislodges Austria in Tyr by supporting the move across timelines.
[("a", 2)]
.GetReference(out var a2)
["Germany"].Army("Mun").MovesTo("Tyr").GetReference(out var mun_a2)
["Austria"].Army("Tyr").Holds().GetReference(out var tyr_a2)
[("b", 1)]
.GetReference(out var b1)
["Germany"].Army("Mun").Supports.Army("Mun", a2).MoveTo("Tyr").GetReference(out var mun_b1)
[(2, 0)]
.GetReference(out var s2_0)
["Germany"].Army("Mun").MovesTo("Tyr").GetReference(out var mun2_0)
["Austria"].Army("Tyr").Holds().GetReference(out var tyr2_0)
[(1, 1)]
.GetReference(out var s1_1)
["Germany"].Army("Mun").Supports.Army("Mun", s2_0).MoveTo("Tyr").GetReference(out var mun1_1)
["Austria"].Army("Tyr").Holds();
// The attack against Tyr a2 succeeds.
// The attack against Tyr 2:0 succeeds.
setup.ValidateOrders();
Assert.That(mun_a2, Is.Valid);
Assert.That(tyr_a2, Is.Valid);
Assert.That(mun_b1, Is.Valid);
Assert.That(mun2_0, Is.Valid);
Assert.That(tyr2_0, Is.Valid);
Assert.That(mun1_1, Is.Valid);
setup.AdjudicateOrders();
Assert.That(mun_a2, Is.Victorious);
Assert.That(tyr_a2, Is.Dislodged);
Assert.That(mun_b1, Is.NotCut);
Assert.That(mun2_0, Is.Victorious);
Assert.That(tyr2_0, Is.Dislodged);
Assert.That(mun1_1, Is.NotCut);
// Since both seasons were at the head of their timelines, there should be no forking.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(a2).Count(),
s2_0.Futures.Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Assert.That(
world.Timelines.GetFutures(b1).Count(),
s1_1.Futures.Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Season a3 = new(a2.Timeline, a2.Turn + 1);
Assert.That(world.Timelines.GetFutures(a3).Count(), Is.EqualTo(0));
Season b2 = new(b1.Timeline, b1.Turn + 1);
Assert.That(world.Timelines.GetFutures(b2).Count(), Is.EqualTo(0));
Season s3_0 = world.GetSeason(s2_0.Turn + 1, s2_0.Timeline);
Assert.That(s3_0.Futures.Count(), Is.EqualTo(0));
Season s2_1 = world.GetSeason(s1_1.Turn + 1, s1_1.Timeline);
Assert.That(s2_1.Futures.Count(), Is.EqualTo(0));
}
[Test]
@ -245,63 +245,63 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// As above, only now London creates three timelines.
setup[("a", 0)]
.GetReference(out var a0)
setup[(0, 0)]
.GetReference(out var s0_0)
["England"].Army("Lon").Holds()
["Austria"].Army("Boh").Holds()
["Germany"].Army("Mun").Holds()
.Execute()
[("a", 1)]
["England"].Army("Lon").MovesTo("Yor", a0)
[(1, 0)]
["England"].Army("Lon").MovesTo("Yor", s0_0)
.Execute()
// Head seasons: a2, b1
[("a", 2)]
[("b", 1)]
["England"].Army("Yor").MovesTo("Edi", a0)
// Head seasons: 2:0 1:1
[(2, 0)]
[(1, 1)]
["England"].Army("Yor").MovesTo("Edi", s0_0)
.Execute()
// Head seasons: a3, b2, c1
// Head seasons: 3:0 2:1 1:2
// All three of these contain copies of the armies in Mun and Tyr.
// As above, Germany dislodges Austria in Tyr a3 by supporting the move from b2.
[("a", 3)]
.GetReference(out var a3)
// As above, Germany dislodges Austria in Tyr 3:0 by supporting the move from 2:1.
[(3, 0)]
.GetReference(out var s3_0)
["Germany"].Army("Mun").MovesTo("Tyr")
["Austria"].Army("Tyr").Holds()
[("b", 2)]
.GetReference(out Season b2)
["Germany"].Army("Mun").Supports.Army("Mun", a3).MoveTo("Tyr").GetReference(out var mun_b2)
[(2, 1)]
.GetReference(out Season s2_1)
["Germany"].Army("Mun").Supports.Army("Mun", s3_0).MoveTo("Tyr").GetReference(out var mun2_1)
["Austria"].Army("Tyr").Holds()
[("c", 1)]
[(1, 2)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").Holds()
.Execute()
// Head seasons: a4, b3, c2
// Now Austria cuts the support in b2 by attacking from c2.
[("a", 4)]
// Head seasons: 4:0 3:1 2:2
// Now Austria cuts the support in 2:1 by attacking from 2:2.
[(4, 0)]
["Germany"].Army("Tyr").Holds()
[("b", 3)]
[(3, 1)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").Holds()
[("c", 2)]
[(2, 2)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").MovesTo("Mun", b2).GetReference(out var tyr_c2);
["Austria"].Army("Tyr").MovesTo("Mun", s2_1).GetReference(out var tyr2_2);
// The attack on Mun b2 is repelled, but the support is cut.
// The attack on Mun 2:1 is repelled, but the support is cut.
setup.ValidateOrders();
Assert.That(tyr_c2, Is.Valid);
Assert.That(tyr2_2, Is.Valid);
setup.AdjudicateOrders();
Assert.That(tyr_c2, Is.Repelled);
Assert.That(mun_b2, Is.NotDislodged);
Assert.That(mun_b2, Is.Cut);
Assert.That(tyr2_2, Is.Repelled);
Assert.That(mun2_1, Is.NotDislodged);
Assert.That(mun2_1, Is.Cut);
// Though the support was cut, the timeline doesn't fork because the outcome of a battle
// wasn't changed in this timeline.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(a3).Count(),
s3_0.Futures.Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
Assert.That(
world.Timelines.GetFutures(b2).Count(),
s2_1.Futures.Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
}

View File

@ -6,10 +6,10 @@ namespace MultiversalDiplomacyTests;
public class MapTests
{
static IEnumerable<Location> LocationClosure(Location location)
IEnumerable<Location> LocationClosure(Location location)
{
IEnumerable<Location> visited = [];
IEnumerable<Location> toVisit = [location];
IEnumerable<Location> visited = new List<Location>();
IEnumerable<Location> toVisit = new List<Location>() { location };
while (toVisit.Any())
{
@ -50,7 +50,7 @@ public class MapTests
[Test]
public void LandAndSeaBorders()
{
Map map = Map.Classical;
World map = World.WithStandardMap();
Assert.That(
map.GetLand("NAF").Adjacents.Count(),
Is.EqualTo(1),

View File

@ -168,23 +168,25 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Assert.That(updated.Seasons.Count, Is.EqualTo(2));
Season future = updated.Seasons.Single(s => s != updated.RootSeason);
Assert.That(future.Past, Is.EqualTo(updated.RootSeason));
Assert.That(future.Futures, Is.Empty);
Assert.That(future.Timeline, Is.EqualTo(updated.RootSeason.Timeline));
Assert.That(future.Turn, Is.EqualTo(Season.FIRST_TURN + 1));
// Confirm the unit was created
Assert.That(updated.Units.Count, Is.EqualTo(2));
Unit second = updated.Units.Single(u => u.Past != null);
Assert.That(second.Location, Is.EqualTo(mun.Order.Unit.Location));
Assert.That(second.Season.Timeline, Is.EqualTo(mun.Order.Unit.Season.Timeline));
// Confirm that the unit's season exists
CollectionAssert.Contains(updated.Timelines.Pasts.Keys, second.Season.Key, "Season was not added");
CollectionAssert.DoesNotContain(updated.Timelines.Pasts.Values, second.Season.Key, "Season should not have a future");
}
[Test]
public void Update_DoubleHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").Holds().GetReference(out var mun1);
@ -197,20 +199,20 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(updated.Timelines.Pasts[s2.Key], Is.EqualTo(s1));
Assert.That(updated.Timelines.GetFutures(s2), Is.Empty);
Season s2 = updated.GetSeason(1, 0);
Assert.That(s2.Past, Is.EqualTo(s1));
Assert.That(s2.Futures, Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
// Confirm the unit was created in the future
Unit u2 = updated.GetUnitAt("Mun", s2);
Unit u2 = updated.GetUnitAt("Mun", s2.Coord);
Assert.That(updated.Units.Count, Is.EqualTo(2));
Assert.That(u2.Key, Is.Not.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2, Is.Not.EqualTo(mun1.Order.Unit));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit));
Assert.That(u2.Season, Is.EqualTo(s2));
setup[("a", 1)]
setup[(1, 0)]
["Germany"]
.Army("Mun").Holds().GetReference(out var mun2);
@ -225,16 +227,16 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(mun2.Order.Unit.Key));
Season s3 = updated.GetSeason(s2.Turn + 1, s2.Timeline);
Unit u3 = updated.GetUnitAt("Mun", s3.Coord);
Assert.That(u3.Past, Is.EqualTo(mun2.Order.Unit));
}
[Test]
public void Update_DoubleMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
setup[("a", 0)]
setup[(0, 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun1);
@ -247,20 +249,20 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(updated.Timelines.Pasts[s2.Key], Is.EqualTo(s1));
Assert.That(updated.Timelines.GetFutures(s2), Is.Empty);
Season s2 = updated.GetSeason(s1.Turn + 1, s1.Timeline);
Assert.That(s2.Past, Is.EqualTo(s1));
Assert.That(s2.Futures, Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
// Confirm the unit was created in the future
Unit u2 = updated.GetUnitAt("Tyr", s2);
Unit u2 = updated.GetUnitAt("Tyr", s2.Coord);
Assert.That(updated.Units.Count, Is.EqualTo(2));
Assert.That(u2.Key, Is.Not.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2, Is.Not.EqualTo(mun1.Order.Unit));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit));
Assert.That(u2.Season, Is.EqualTo(s2));
setup[("a", 1)]
setup[(1, 0)]
["Germany"]
.Army("Tyr").MovesTo("Mun").GetReference(out var tyr2);
@ -275,8 +277,8 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(u2.Key));
Season s3 = updated.GetSeason(s2.Turn + 1, s2.Timeline);
Unit u3 = updated.GetUnitAt("Mun", s3.Coord);
Assert.That(u3.Past, Is.EqualTo(u2));
}
}

View File

@ -58,7 +58,7 @@ public abstract class OrderReference
if (this.Order is UnitOrder unitOrder)
{
var replacementOrder = this.Builder.ValidationResults.Where(
v => v.Order is UnitOrder uo && uo != unitOrder && uo.Unit.Key == unitOrder.Unit.Key);
v => v.Order is UnitOrder uo && uo != unitOrder && uo.Unit == unitOrder.Unit);
if (replacementOrder.Any())
{
return replacementOrder.Single();
@ -108,7 +108,8 @@ public abstract class OrderReference
DefendStrength defend => defend.Order == this.Order,
PreventStrength prevent => prevent.Order == this.Order,
HoldStrength hold => this.Order is UnitOrder unitOrder
&& hold.Province == Builder.World.Map.GetLocation(unitOrder.Unit).Province,
? hold.Province == unitOrder.Unit.Province
: false,
_ => false,
}).ToList();
return adjudications;
@ -142,7 +143,7 @@ public abstract class OrderReference
if (this.Order is UnitOrder unitOrder)
{
var retreat = this.Builder.World.RetreatingUnits.Where(
ru => ru.Unit.Key == unitOrder.Unit.Key);
ru => ru.Unit == unitOrder.Unit);
if (retreat.Any())
{
return retreat.Single();

View File

@ -0,0 +1,65 @@
using MultiversalDiplomacy.Model;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
public class SeasonTests
{
[Test]
public void TimelineForking()
{
Season a0 = Season.MakeRoot();
Season a1 = a0.MakeNext();
Season a2 = a1.MakeNext();
Season a3 = a2.MakeNext();
Season b1 = a1.MakeFork();
Season b2 = b1.MakeNext();
Season c1 = a1.MakeFork();
Season d1 = a2.MakeFork();
Assert.That(a0.Timeline, Is.EqualTo(0), "Unexpected trunk timeline number");
Assert.That(a1.Timeline, Is.EqualTo(0), "Unexpected trunk timeline number");
Assert.That(a2.Timeline, Is.EqualTo(0), "Unexpected trunk timeline number");
Assert.That(a3.Timeline, Is.EqualTo(0), "Unexpected trunk timeline number");
Assert.That(b1.Timeline, Is.EqualTo(1), "Unexpected first alt number");
Assert.That(b2.Timeline, Is.EqualTo(1), "Unexpected first alt number");
Assert.That(c1.Timeline, Is.EqualTo(2), "Unexpected second alt number");
Assert.That(d1.Timeline, Is.EqualTo(3), "Unexpected third alt number");
Assert.That(a0.Turn, Is.EqualTo(Season.FIRST_TURN + 0), "Unexpected first turn number");
Assert.That(a1.Turn, Is.EqualTo(Season.FIRST_TURN + 1), "Unexpected next turn number");
Assert.That(a2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected next turn number");
Assert.That(a3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected next turn number");
Assert.That(b1.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected fork turn number");
Assert.That(b2.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected fork turn number");
Assert.That(c1.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected fork turn number");
Assert.That(d1.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected fork turn number");
Assert.That(a0.TimelineRoot(), Is.EqualTo(a0), "Expected timeline root to be reflexive");
Assert.That(a3.TimelineRoot(), Is.EqualTo(a0), "Expected trunk timeline to have root");
Assert.That(b1.TimelineRoot(), Is.EqualTo(b1), "Expected alt timeline root to be reflexive");
Assert.That(b2.TimelineRoot(), Is.EqualTo(b1), "Expected alt timeline to root at first fork");
Assert.That(c1.TimelineRoot(), Is.EqualTo(c1), "Expected alt timeline root to be reflexive");
Assert.That(d1.TimelineRoot(), Is.EqualTo(d1), "Expected alt timeline root to be reflexive");
Assert.That(b2.InAdjacentTimeline(a3), Is.True, "Expected alts to be adjacent to origin");
Assert.That(b2.InAdjacentTimeline(c1), Is.True, "Expected alts with common origin to be adjacent");
Assert.That(b2.InAdjacentTimeline(d1), Is.False, "Expected alts from different origins not to be adjacent");
}
[Test]
public void LookupTest()
{
World world = World.WithStandardMap();
Season s2 = world.RootSeason.MakeNext();
Season s3 = s2.MakeNext();
Season s4 = s2.MakeFork();
World updated = world.Update(seasons: world.Seasons.Append(s2).Append(s3).Append(s4));
Assert.That(updated.GetSeason(Season.FIRST_TURN, 0), Is.EqualTo(updated.RootSeason));
Assert.That(updated.GetSeason(Season.FIRST_TURN + 1, 0), Is.EqualTo(s2));
Assert.That(updated.GetSeason(Season.FIRST_TURN + 2, 0), Is.EqualTo(s3));
Assert.That(updated.GetSeason(Season.FIRST_TURN + 2, 1), Is.EqualTo(s4));
}
}

View File

@ -1,145 +0,0 @@
using System.Text.Json;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
public class SerializationTest
{
private JsonSerializerOptions Options = new(World.JsonOptions) {
WriteIndented = true,
};
[Test]
public void SerializeRoundTrip_Timelines()
{
Timelines one = Timelines.Create();
string serial1 = JsonSerializer.Serialize(one, Options);
Timelines two = JsonSerializer.Deserialize<Timelines>(serial1, Options)
?? throw new AssertionException("Failed to deserialize");
Assert.That(two.Next, Is.EqualTo(one.Next), "Failed to reserialize next timeline");
Assert.That(two.Pasts, Is.EquivalentTo(one.Pasts), "Failed to reserialize pasts");
Timelines three = two
.WithNewSeason(Season.First, out var a1)
.WithNewSeason(a1, out var a2)
.WithNewSeason(a1, out var b2);
string serial2 = JsonSerializer.Serialize(three, Options);
Timelines four = JsonSerializer.Deserialize<Timelines>(serial2, Options)
?? throw new AssertionException("Failed to deserialize");
Assert.That(four.Next, Is.EqualTo(three.Next), "Failed to reserialize next timeline");
Assert.That(four.Pasts, Is.EquivalentTo(three.Pasts), "Failed to reserialize pasts");
}
[Test]
public void SerializeRoundTrip_NewGame()
{
World world = World.WithStandardMap();
JsonElement document = JsonSerializer.SerializeToDocument(world, Options).RootElement;
Assert.That(
document.EnumerateObject().Select(prop => prop.Name),
Is.EquivalentTo(new List<string> {
"mapType",
"units",
"retreatingUnits",
"orderHistory",
"options",
"timelines",
}));
string serialized = JsonSerializer.Serialize(world, Options);
World? deserialized = JsonSerializer.Deserialize<World>(serialized, Options);
Assert.That(deserialized, Is.Not.Null, "Failed to deserialize");
Assert.That(deserialized!.Map, Is.Not.Null, "Failed to deserialize map");
Assert.That(deserialized!.Units, Is.Not.Null, "Failed to deserialize units");
Assert.That(deserialized!.RetreatingUnits, Is.Not.Null, "Failed to deserialize retreats");
Assert.That(deserialized!.OrderHistory, Is.Not.Null, "Failed to deserialize history");
Assert.That(deserialized!.Timelines, Is.Not.Null, "Failed to deserialize timelines");
Assert.That(deserialized!.Timelines.Pasts, Is.Not.Null, "Failed to deserialize timeline pasts");
Assert.That(deserialized!.Timelines.Next, Is.EqualTo(world.Timelines.Next));
Assert.That(deserialized!.Options, Is.Not.Null, "Failed to deserialize options");
}
[Test]
public void SerializeRoundTrip_MDATC_3_A_2()
{
// Set up MDATC 3.A.2
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
setup[("a", 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun0)
["Austria"]
.Army("Tyr").Holds().GetReference(out var tyr0);
setup.ValidateOrders();
Assert.That(mun0, Is.Valid);
Assert.That(tyr0, Is.Valid);
setup.AdjudicateOrders();
Assert.That(mun0, Is.Repelled);
Assert.That(tyr0, Is.NotDislodged);
setup.UpdateWorld();
Assert.That(setup.World.OrderHistory[s0.Key].Orders.Count, Is.GreaterThan(0), "Missing orders");
Assert.That(setup.World.OrderHistory[s0.Key].DoesMoveOutcomes.Count, Is.GreaterThan(0), "Missing moves");
Assert.That(setup.World.OrderHistory[s0.Key].IsDislodgedOutcomes.Count, Is.GreaterThan(0), "Missing dislodges");
// Serialize and deserialize the world
string serialized = JsonSerializer.Serialize(setup.World, Options);
World reserialized = JsonSerializer.Deserialize<World>(serialized, Options)
?? throw new AssertionException("Failed to reserialize world");
Assert.Multiple(() => {
Assert.That(reserialized.OrderHistory[s0.Key].Orders.Count, Is.GreaterThan(0), "Missing orders");
Assert.That(reserialized.OrderHistory[s0.Key].DoesMoveOutcomes.Count, Is.GreaterThan(0), "Missing moves");
Assert.That(reserialized.OrderHistory[s0.Key].IsDislodgedOutcomes.Count, Is.GreaterThan(0), "Missing dislodges");
Assert.That(reserialized.Timelines.Pasts, Is.Not.Empty, "Missing timeline history");
});
// Resume the test case
setup = new(reserialized, MovementPhaseAdjudicator.Instance);
setup[("a", 1)]
["Germany"]
.Army("Mun").Supports.Army("Mun", season: s0).MoveTo("Tyr").GetReference(out var mun1)
["Austria"]
.Army("Tyr").Holds();
setup.ValidateOrders();
Assert.That(mun1, Is.Valid);
var adjudications = setup.AdjudicateOrders();
Assert.That(mun1, Is.NotCut);
AttackStrength mun0attack = adjudications.OfType<AttackStrength>().Single();
Assert.That(mun0attack.Supports, Is.Not.Empty, "Support not tracked");
DoesMove mun0move = adjudications.OfType<DoesMove>().Single(move => move.Order.Unit.Key == mun0.Order.Unit.Key);
Assert.That(mun0move.Outcome, Is.True);
IsDislodged tyr0dislodge = adjudications.OfType<IsDislodged>().Single(dis => dis.Order.Unit.Key == tyr0.Order.Unit.Key);
Assert.That(tyr0dislodge.Outcome, Is.True);
// Confirm that an alternate future is created.
World world = setup.UpdateWorld();
Season fork = new("b1");
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Assert.That(
tyr1.Past,
Is.EqualTo(mun0.Order.Unit.Key),
"Expected A Mun a0 to advance to Tyr b1");
Assert.That(
world.RetreatingUnits.Count,
Is.EqualTo(1),
"Expected A Tyr a0 to be in retreat");
Assert.That(world.RetreatingUnits.First().Unit.Key, Is.EqualTo(tyr0.Order.Unit.Key));
}
}

View File

@ -3,7 +3,6 @@ using System.Collections.ObjectModel;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
@ -20,7 +19,7 @@ public class TestCaseBuilder
/// <summary>
/// Choose a new season to define orders for.
/// </summary>
public ISeasonContext this[(string timeline, int turn) seasonCoord] { get; }
public ISeasonContext this[(int turn, int timeline) seasonCoord] { get; }
/// <summary>
/// Get the context for defining the orders for a power.
@ -41,7 +40,7 @@ public class TestCaseBuilder
/// <summary>
/// Choose a new season to define orders for.
/// </summary>
public ISeasonContext this[(string timeline, int turn) seasonCoord] { get; }
public ISeasonContext this[(int turn, int timeline) seasonCoord] { get; }
/// <summary>
/// Get the context for defining the orders for another power.
@ -189,7 +188,7 @@ public class TestCaseBuilder
/// <summary>
/// Choose a new season to define orders for.
/// </summary>
public ISeasonContext this[(string timeline, int turn) seasonCoord] { get; }
public ISeasonContext this[(int turn, int timeline) seasonCoord] { get; }
/// <summary>
/// Get the context for defining the orders for another power.
@ -235,13 +234,13 @@ public class TestCaseBuilder
/// <summary>
/// Get the context for defining the orders for a power. Defaults to the root season.
/// </summary>
public IPowerContext this[string powerName] => this[("a", 0)][powerName];
public IPowerContext this[string powerName] => this[(0, 0)][powerName];
/// <summary>
/// Get the context for defining the orders for a season.
/// </summary>
public ISeasonContext this[(string timeline, int turn) seasonCoord]
=> new SeasonContext(this, new(seasonCoord));
public ISeasonContext this[(int turn, int timeline) seasonCoord]
=> new SeasonContext(this, this.World.GetSeason(seasonCoord.turn, seasonCoord.timeline));
/// <summary>
/// Get a unit matching a description. If no such unit exists, one is created and added to the
@ -255,7 +254,7 @@ public class TestCaseBuilder
/// of this type.
/// </param>
private Unit GetOrBuildUnit(
string power,
Power power,
Location location,
Season season,
UnitType type)
@ -263,7 +262,7 @@ public class TestCaseBuilder
foreach (Unit unit in this.World.Units)
{
if (unit.Power == power
&& World.Map.GetLocation(unit).Province == location.Province
&& unit.Province == location.Province
&& unit.Season == season)
{
return unit;
@ -271,7 +270,7 @@ public class TestCaseBuilder
}
// Not found
Unit newUnit = Unit.Build(location.Key, season, power, type);
Unit newUnit = Unit.Build(location, season, power, type);
this.World = this.World.Update(units: this.World.Units.Append(newUnit));
return newUnit;
}
@ -328,11 +327,11 @@ public class TestCaseBuilder
this.Season = season;
}
public ISeasonContext this[(string timeline, int turn) seasonCoord]
=> this.Builder[(seasonCoord.timeline, seasonCoord.turn)];
public ISeasonContext this[(int turn, int timeline) seasonCoord]
=> this.Builder[(seasonCoord.turn, seasonCoord.timeline)];
public IPowerContext this[string powerName]
=> new PowerContext(this, this.Builder.World.Map.GetPower(powerName));
=> new PowerContext(this, this.Builder.World.GetPower(powerName));
public ISeasonContext GetReference(out Season season)
{
@ -345,18 +344,16 @@ public class TestCaseBuilder
{
public TestCaseBuilder Builder;
public SeasonContext SeasonContext;
public string Power;
public Power Power;
public PowerContext(SeasonContext seasonContext, string power)
public PowerContext(SeasonContext seasonContext, Power Power)
{
Assert.That(power, Is.AnyOf([.. seasonContext.Builder.World.Map.Powers]), "Invalid power");
this.Builder = seasonContext.Builder;
this.SeasonContext = seasonContext;
this.Power = power;
this.Power = Power;
}
public ISeasonContext this[(string timeline, int turn) seasonCoord]
public ISeasonContext this[(int turn, int timeline) seasonCoord]
=> this.SeasonContext[seasonCoord];
public IPowerContext this[string powerName]
@ -364,10 +361,10 @@ public class TestCaseBuilder
public IUnitContext Army(string provinceName, string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetLand(provinceName);
Unit unit = this.Builder.GetOrBuildUnit(
power, location, this.SeasonContext.Season, UnitType.Army);
return new UnitContext(this, unit);
@ -375,10 +372,10 @@ public class TestCaseBuilder
public IUnitContext Fleet(string provinceName, string? coast = null, string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetWater(provinceName, coast);
Unit unit = this.Builder.GetOrBuildUnit(
power, location, this.SeasonContext.Season, UnitType.Fleet);
return new UnitContext(this, unit);
@ -416,14 +413,14 @@ public class TestCaseBuilder
string? coast = null)
{
Location destination = this.Unit.Type == UnitType.Army
? this.Builder.World.Map.GetLand(provinceName)
: this.Builder.World.Map.GetWater(provinceName, coast);
? 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,
destSeason,
destination.Key);
destination);
this.Builder.OrderList.Add(moveOrder);
return new OrderDefinedContext<MoveOrder>(this, moveOrder);
}
@ -452,10 +449,10 @@ public class TestCaseBuilder
public IConvoyDestinationContext Army(string provinceName, string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetLand(provinceName);
Unit unit = this.Builder.GetOrBuildUnit(
power, location, this.SeasonContext.Season, UnitType.Army);
return new ConvoyDestinationContext(this, unit);
@ -466,10 +463,10 @@ public class TestCaseBuilder
string? coast = null,
string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetWater(provinceName, coast);
Unit unit = this.Builder.GetOrBuildUnit(
power, location, this.SeasonContext.Season, UnitType.Fleet);
return new ConvoyDestinationContext(this, unit);
@ -495,7 +492,7 @@ public class TestCaseBuilder
public IOrderDefinedContext<ConvoyOrder> To(string provinceName)
{
Location location = this.Builder.World.Map.GetLand(provinceName);
Location location = this.Builder.World.GetLand(provinceName);
ConvoyOrder order = new ConvoyOrder(
this.PowerContext.Power,
this.UnitContext.Unit,
@ -527,10 +524,10 @@ public class TestCaseBuilder
Season? season = null,
string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetLand(provinceName);
Season destSeason = season ?? this.SeasonContext.Season;
Unit unit = this.Builder.GetOrBuildUnit(
power, location, destSeason, UnitType.Army);
@ -542,10 +539,10 @@ public class TestCaseBuilder
string? coast = null,
string? powerName = null)
{
string power = powerName == null
Power power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);
: this.Builder.World.GetPower(powerName);
Location location = this.Builder.World.GetWater(provinceName, coast);
Unit unit = this.Builder.GetOrBuildUnit(
power, location, this.SeasonContext.Season, UnitType.Fleet);
return new SupportTypeContext(this, unit);
@ -585,8 +582,8 @@ public class TestCaseBuilder
string? coast = null)
{
Location destination = this.Target.Type == UnitType.Army
? this.Builder.World.Map.GetLand(provinceName)
: this.Builder.World.Map.GetWater(provinceName, coast);
? this.Builder.World.GetLand(provinceName)
: this.Builder.World.GetWater(provinceName, coast);
Season targetDestSeason = season ?? this.Target.Season;
SupportMoveOrder order = new SupportMoveOrder(
this.PowerContext.Power,
@ -626,7 +623,7 @@ public class TestCaseBuilder
return this.Builder;
}
public ISeasonContext this[(string timeline, int turn) seasonCoord]
public ISeasonContext this[(int turn, int timeline) seasonCoord]
=> this.SeasonContext[seasonCoord];
public IPowerContext this[string powerName]

View File

@ -14,7 +14,7 @@ class TestCaseBuilderTest
{
TestCaseBuilder setup = new(World.WithStandardMap());
Assert.That(setup.World.Powers.Count, Is.EqualTo(7), "Unexpected power count");
Assert.That(setup.World.Powers.Count(), Is.EqualTo(7), "Unexpected power count");
Assert.That(setup.World.Units, Is.Empty, "Expected no units to be created yet");
setup
@ -28,19 +28,19 @@ class TestCaseBuilderTest
Assert.That(setup.World.Units, Is.Not.Empty, "Expected units to be created");
Unit armyLON = setup.World.GetUnitAt("London");
Assert.That(armyLON.Power, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(armyLON.Power.Name, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(armyLON.Type, Is.EqualTo(UnitType.Army), "Unit created with wrong type");
Unit fleetIRI = setup.World.GetUnitAt("Irish Sea");
Assert.That(fleetIRI.Power, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(fleetIRI.Power.Name, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(fleetIRI.Type, Is.EqualTo(UnitType.Fleet), "Unit created with wrong type");
Unit fleetSTP = setup.World.GetUnitAt("Saint Petersburg");
Assert.That(fleetSTP.Power, Is.EqualTo("Russia"), "Unit created with wrong power");
Assert.That(fleetSTP.Power.Name, Is.EqualTo("Russia"), "Unit created with wrong power");
Assert.That(fleetSTP.Type, Is.EqualTo(UnitType.Fleet), "Unit created with wrong type");
Assert.That(
fleetSTP.Location,
Is.EqualTo(setup.World.Map.GetWater("STP", "wc").Key),
Is.EqualTo(setup.World.GetWater("STP", "wc")),
"Unit created on wrong coast");
}
@ -49,7 +49,7 @@ class TestCaseBuilderTest
{
TestCaseBuilder setup = new(World.WithStandardMap());
Assert.That(setup.World.Powers.Count, Is.EqualTo(7), "Unexpected power count");
Assert.That(setup.World.Powers.Count(), Is.EqualTo(7), "Unexpected power count");
Assert.That(setup.World.Units, Is.Empty, "Expected no units to be created yet");
Assert.That(setup.Orders, Is.Empty, "Expected no orders to be created yet");
@ -68,13 +68,13 @@ class TestCaseBuilderTest
List<UnitOrder> orders = setup.Orders.OfType<UnitOrder>().ToList();
Func<UnitOrder, bool> OrderForProvince(string name)
=> order => setup.World.Map.GetLocation(order.Unit).Province.Name == name;
=> order => order.Unit.Province.Name == name;
UnitOrder orderBer = orders.Single(OrderForProvince("Berlin"));
Assert.That(orderBer, Is.InstanceOf<MoveOrder>(), "Unexpected order type");
Assert.That(
(orderBer as MoveOrder)?.Location,
Is.EqualTo(setup.World.Map.GetLand("Kiel").Key),
Is.EqualTo(setup.World.GetLand("Kiel")),
"Unexpected move order destination");
UnitOrder orderPru = orders.Single(OrderForProvince("Prussia"));
@ -88,7 +88,7 @@ class TestCaseBuilderTest
"Unexpected convoy order target");
Assert.That(
(orderNth as ConvoyOrder)?.Location,
Is.EqualTo(setup.World.Map.GetLand("Holland")),
Is.EqualTo(setup.World.GetLand("Holland")),
"Unexpected convoy order destination");
UnitOrder orderKie = orders.Single(OrderForProvince("Kiel"));
@ -99,7 +99,7 @@ class TestCaseBuilderTest
"Unexpected convoy order target");
Assert.That(
(orderKie as SupportMoveOrder)?.Location,
Is.EqualTo(setup.World.Map.GetLand("Holland")),
Is.EqualTo(setup.World.GetLand("Holland")),
"Unexpected convoy order destination");
UnitOrder orderMun = orders.Single(OrderForProvince("Munich"));
@ -124,11 +124,11 @@ class TestCaseBuilderTest
Assert.That(orderMun, Is.Not.Null, "Expected order reference");
Assert.That(
orderMun.Order.Power,
Is.EqualTo("Germany"),
Is.EqualTo(setup.World.GetPower("Germany")),
"Wrong power");
Assert.That(
orderMun.Order.Unit.Location,
Is.EqualTo(setup.World.Map.GetLand("Mun").Key),
Is.EqualTo(setup.World.GetLand("Mun")),
"Wrong unit");
Assert.That(

View File

@ -1,103 +0,0 @@
using MultiversalDiplomacy.Model;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
public class TimelinesTest
{
[TestCase(0, "a")]
[TestCase(1, "b")]
[TestCase(25, "z")]
[TestCase(26, "aa")]
[TestCase(27, "ab")]
[TestCase(51, "az")]
[TestCase(52, "ba")]
[TestCase(53, "bb")]
[TestCase(77, "bz")]
[TestCase(78, "ca")]
public void RoundTripTimelineKeys(int number, string designation)
{
Assert.That(Timelines.IntToString(number), Is.EqualTo(designation), "Incorrect string");
Assert.That(Timelines.StringToInt(designation), Is.EqualTo(number), "Incorrect number");
}
[TestCase("a0", "a", 0)]
[TestCase("a1", "a", 1)]
[TestCase("a10", "a", 10)]
[TestCase("aa2", "aa", 2)]
[TestCase("aa22", "aa", 22)]
public void SeasonKeySplit(string key, string timeline, int turn)
{
Assert.That(Timelines.SplitKey(key), Is.EqualTo((timeline, turn)), "Failed to split key");
}
[Test]
public void NoSharedFactoryState()
{
Timelines one = Timelines.Create()
.WithNewSeason(Season.First, out var s1)
.WithNewSeason(Season.First, out var s2)
.WithNewSeason(Season.First, out var s3);
Timelines two = Timelines.Create()
.WithNewSeason(Season.First, out var s4)
.WithNewSeason(Season.First, out var s5);
Assert.That(s1.Timeline, Is.EqualTo("a"));
Assert.That(s2.Timeline, Is.EqualTo("b"));
Assert.That(s3.Timeline, Is.EqualTo("c"));
Assert.That(s4.Timeline, Is.EqualTo("a"), "Unexpected first timeline");
Assert.That(s5.Timeline, Is.EqualTo("b"), "Unexpected second timeline");
}
[Test]
public void TimelineForking()
{
Timelines timelines = Timelines.Create()
.WithNewSeason(Season.First, out var a1)
.WithNewSeason(a1, out var a2)
.WithNewSeason(a2, out var a3)
.WithNewSeason(a1, out var b2)
.WithNewSeason(b2, out var b3)
.WithNewSeason(a1, out var c2)
.WithNewSeason(a2, out var d3);
Season a0 = Season.First;
Assert.That(
timelines.Pasts.Keys,
Is.EquivalentTo(new List<string> { "a0", "a1", "a2", "a3", "b2", "b3", "c2", "d3" }),
"Unexpected seasons");
Assert.That(a1.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a2.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a3.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(b2.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(b3.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(c2.Timeline, Is.EqualTo("c"), "Unexpected second alt");
Assert.That(d3.Timeline, Is.EqualTo("d"), "Unexpected third alt");
Assert.That(a1.Turn, Is.EqualTo(Season.First.Turn + 1), "Unexpected a1 turn number");
Assert.That(a2.Turn, Is.EqualTo(Season.First.Turn + 2), "Unexpected a2 turn number");
Assert.That(a3.Turn, Is.EqualTo(Season.First.Turn + 3), "Unexpected a3 turn number");
Assert.That(b2.Turn, Is.EqualTo(Season.First.Turn + 2), "Unexpected b2 turn number");
Assert.That(b3.Turn, Is.EqualTo(Season.First.Turn + 3), "Unexpected b3 turn number");
Assert.That(c2.Turn, Is.EqualTo(Season.First.Turn + 2), "Unexpected c2 turn number");
Assert.That(d3.Turn, Is.EqualTo(Season.First.Turn + 3), "Unexpected d3 turn number");
Assert.That(timelines.GetTimelineRoot(a0), Is.EqualTo(a0), "Expected timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(a3), Is.EqualTo(a0), "Expected trunk timeline to have root");
Assert.That(timelines.GetTimelineRoot(b2), Is.EqualTo(b2), "Expected alt timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(b3), Is.EqualTo(b2), "Expected alt timeline to root at first fork");
Assert.That(timelines.GetTimelineRoot(c2), Is.EqualTo(c2), "Expected alt timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(d3), Is.EqualTo(d3), "Expected alt timeline root to be reflexive");
Assert.That(timelines.InAdjacentTimeline(b3, a3), Is.True, "Expected alts to be adjacent to origin");
Assert.That(timelines.InAdjacentTimeline(b3, c2), Is.True, "Expected alts with common origin to be adjacent");
Assert.That(timelines.InAdjacentTimeline(b3, d3), Is.False, "Expected alts from different origins not to be adjacent");
Assert.That(timelines.GetFutures(a0), Is.EquivalentTo(new List<Season> { a1 }), "Unexpected futures");
Assert.That(timelines.GetFutures(a1), Is.EquivalentTo(new List<Season> { a2, b2, c2 }), "Unexpected futures");
Assert.That(timelines.GetFutures(a2), Is.EquivalentTo(new List<Season> { a3, d3 }), "Unexpected futures");
Assert.That(timelines.GetFutures(b2), Is.EquivalentTo(new List<Season> { b3 }), "Unexpected futures");
}
}

View File

@ -10,28 +10,29 @@ public class UnitTests
public void MovementTest()
{
World world = World.WithStandardMap();
Location Mun = world.Map.GetLand("Mun"),
Boh = world.Map.GetLand("Boh"),
Tyr = world.Map.GetLand("Tyr");
Season a0 = Season.First;
Unit u1 = Unit.Build(Mun.Key, a0, "Austria", UnitType.Army);
Location Mun = world.GetLand("Mun"),
Boh = world.GetLand("Boh"),
Tyr = world.GetLand("Tyr");
Power pw1 = world.GetPower("Austria");
Season s1 = world.RootSeason;
Unit u1 = Unit.Build(Mun, s1, pw1, UnitType.Army);
world = world.WithNewSeason(a0, out Season a1);
Unit u2 = u1.Next(Boh.Key, a1);
Season s2 = s1.MakeNext();
Unit u2 = u1.Next(Boh, s2);
_ = world.WithNewSeason(a1, out Season a2);
Unit u3 = u2.Next(Tyr.Key, a2);
Season s3 = s2.MakeNext();
Unit u3 = u2.Next(Tyr, s3);
Assert.That(u3.Past, Is.EqualTo(u2.Key), "Missing unit past");
Assert.That(u2.Past, Is.EqualTo(u1.Key), "Missing unit past");
Assert.That(u3.Past, Is.EqualTo(u2), "Missing unit past");
Assert.That(u2.Past, Is.EqualTo(u1), "Missing unit past");
Assert.That(u1.Past, Is.Null, "Unexpected unit past");
Assert.That(u1.Season, Is.EqualTo(a0), "Unexpected unit season");
Assert.That(u2.Season, Is.EqualTo(a1), "Unexpected unit season");
Assert.That(u3.Season, Is.EqualTo(a2), "Unexpected unit season");
Assert.That(u1.Season, Is.EqualTo(s1), "Unexpected unit season");
Assert.That(u2.Season, Is.EqualTo(s2), "Unexpected unit season");
Assert.That(u3.Season, Is.EqualTo(s3), "Unexpected unit season");
Assert.That(u1.Location, Is.EqualTo(Mun.Key), "Unexpected unit location");
Assert.That(u2.Location, Is.EqualTo(Boh.Key), "Unexpected unit location");
Assert.That(u3.Location, Is.EqualTo(Tyr.Key), "Unexpected unit location");
Assert.That(u1.Location, Is.EqualTo(Mun), "Unexpected unit location");
Assert.That(u2.Location, Is.EqualTo(Boh), "Unexpected unit location");
Assert.That(u3.Location, Is.EqualTo(Tyr), "Unexpected unit location");
}
}

View File

@ -4,11 +4,9 @@ In lieu of a systematic overview of the architecture, here are a few scattered n
## Provinces and Locations
The data model here is based on the data model of [godip](https://github.com/zond/godip). In particular, godip handles the distinction between army and fleet movement by distinguishing between Provicnces and SubProvinces, which 5dplomacy calls Locations. The graph edges that define valid paths are drawn between Locations, but occupation by a unit and being a supply center are properties of the Province as a whole. This makes it easy to represent the different paths available to armies or fleets: the land and sea graphs are unconnected and only interact at the Province level. This also provides a way to distinguish the connectivity of multiple coasts within a province.
The data model here is based on the data model of [godip](https://github.com/zond/godip). In particular, godip handles the distinction between army and fleet movement by distinguishing between Provicnces and SubProvinces, which 5dplomacy calls Locations. The graph edges that define valid paths are drawn between Locations, but a Province's supply center or occupation by a unit are at the Province level. This makes it easy to represent the different paths available to armies or fleets, since each is essentially moving through a distinct graph from the other, while still interacting at the Province level. It also provides a way to distinguish the connectivity of multiple coasts within a province.
As a consequence of the unconnected land and sea graphs, there is no special significance to unit type in movement, since the inability of fleets to move to land locations is ensured by the lack of edges from land locations to sea locations. The primary difference between unit types becomes "can convoy" and "can move by convoy", as well as how the units are represented by clients.
Internally, land locations are named "land" or "l" and water locations are called "water" or "w". For example, SPA has three locations: SPA/nc, SPA/sc, and SPA/l. This provides a uniform way to handle unit location, because locations in orders without coast specifications can easily be inferred from the map and the unit type. For example, "A Mun - Tyr" can easily be inferred to mean "A Mun/l - Tyr/l" because A Mun is located in the "land" location in Mun and the "land" location in Tyr is the only connected one.
As a consequence of the unconnected land and sea graphs, there is no special significance to unit type in movement, since the inability of fleets to move to land locations is ensured by the lack of edges from land locations to sea locations. Unit type is still relevant to convoy orders and how clients represent the units.
## Timeline notation
@ -40,13 +38,3 @@ The core adjudication algorithm is intended to be a pure function. That is, adju
> [!WARNING]
> This is not complete and the adjudicator is still stateful.
## Game options
In order to support different decisions about how adjudication or the rules of multiversal _Diplomacy_ are implemented, the `Options` object is a grab-bag of settings that can be used to tune the adjudicator. The following options are supported:
- `implicitMainTimeline`: Whether orders to units with no timeline designation should be interpreted as orders for the first timeline. (This may be the default behavior to support adjudication of classical _Diplomacy_ games.)
- `enableOpenConvoys`: Whether the open convoy order can be used.
- `enableJumpAssists`: Whether the jump assist order can be used.
- `victoryCondition`: The victory condition to use for the game. `"elimination"` means a player is eliminated if they are eliminated in a single timeline and the last player standing wins. `"majority"` means a player wins if they control the majority of supply centers across all timelines. `"unique"` means a player wins if they control 18 unique supply centers by name across all timelines.
- `adjacency`: The rule to use for determining province adjacency. `"strict"` means provinces are adjacent if they are within one timeline of each other, within one turn of each other, and geographically adjacent. `"anyTimeline"` follows `"strict"` but all timelines are considered adjacent to each other.

View File

@ -43,7 +43,7 @@ The standard _Diplomacy_ rules require that a convoy order include the convoyed
### Jump assist
Outside of convoys, a unit may only move one province at a time. Multiversal time travel also allows units to move back or forward by one turn and/or across by one timeline. Since time moves forward by one turn per turn, this makes it difficult to go further back into the past. The _jump assist_ order provides a way for units to intervene deeper into the past. A unit may be ordered to give a jump assist to another unit's move. For each successful jump assist given, a unit may move one more turn or timeline. Jump assists do not grant units the ability to move further geographically than they could otherwise, nor do they provide additional support to an attack.
Outside of convoys, a unit may only move one province at a time. Multiversal time travel also allows units to move back or forward by one turn and/or across by one timeline. Since time moves forward by one turn per turn, this makes it difficult to go further back into the past. The _jump assist_ order provides a way for units to intervene deeper into the past. A unit may be ordered to give a jump assist to another unit's move. For each successful jump assist given, a unit may move one more turn or timeline. Jump assists do not grant units the ability to move further geographically than they could otherwise.
> [!WARNING]
> Jump assists are a speculative feature and have not been implemented yet.

View File

@ -10,7 +10,7 @@
in rec {
devShell = pkgs.mkShell {
DOTNET_CLI_TELEMETRY_OPTOUT = 1;
NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc pkgs.icu ];
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