Compare commits

...

49 Commits

Author SHA1 Message Date
Tim Van Baak eaafdeb5a9 Eliminate sources of Unit reference equality 2024-08-16 20:13:15 +00:00
Tim Van Baak 9a64609605 Update adjudication logging 2024-08-15 22:20:03 -07:00
Tim Van Baak 889e9d173b Reduce MoveOrder.Location to string 2024-08-15 22:01:38 -07:00
Tim Van Baak f21b1e500c Eliminate MoveOrder.Province 2024-08-15 21:48:41 -07:00
Tim Van Baak ff9e6196ad Add Timelines serialization test 2024-08-15 16:40:23 -07:00
Tim Van Baak a02e8121eb Rename test 2024-08-15 16:37:35 -07:00
Tim Van Baak 3d664208b5 Add Season.First, replacing FIRST_TURN 2024-08-15 16:36:50 -07:00
Tim Van Baak 25d903d91a Refactor Season into a value struct
This keeps the rich features of a Season type without requiring constant string parsing (as much) or going through World to do lookups to get the objects. Since seasons now have value equality instead of reference equality, it's easier to get access to whem when needed. They're still, fundamentally, sugar over a tuple.
2024-08-15 13:51:41 -07:00
Tim Van Baak 566d29e539 Add function to extract information from season keys 2024-08-15 08:51:17 -07:00
Tim Van Baak 4b2712e4bc Reduce Power to string 2024-08-15 07:54:53 -07:00
Tim Van Baak bfdf2d5636 Reduce Unit.Power to string 2024-08-15 07:37:05 -07:00
Tim Van Baak 2e6e6c55b8 Reduce Order.Power to string 2024-08-15 07:30:43 -07:00
Tim Van Baak 161e0a1ddb Doc updates 2024-08-15 07:10:44 -07:00
Tim Van Baak abbe929122 Designation -> Key 2024-08-15 06:52:08 -07:00
Tim Van Baak 601ce2d297 HoldStrength uses season string 2024-08-14 22:40:40 -07:00
Tim Van Baak 64f48064fc AdvanceTimeline key to string 2024-08-14 22:32:48 -07:00
Tim Van Baak 9185534f70 Reduce MoveOrder.Season to string 2024-08-14 22:28:56 -07:00
Tim Van Baak f2d3d5a583 Remove GetSeason(string) 2024-08-14 22:03:56 -07:00
Tim Van Baak 868022d34f Convert World.Seasons to a dictionary 2024-08-14 22:00:22 -07:00
Tim Van Baak 2484d4f0fd Get world serialization round trip kinda working 2024-08-14 21:12:58 -07:00
Tim Van Baak a4002a1081 Serialize unit type as string 2024-08-14 18:53:36 -07:00
Tim Van Baak 8f5dc63833 Use unit designations for order history instead of references 2024-08-14 18:49:27 -07:00
Tim Van Baak 5b5320b3e2 Add vsdgb dependency
This isn't enough to get the debugger working but might as well get part of the way there
2024-08-14 15:08:13 -07:00
Tim Van Baak 73d849e117 Working World roundtrip serialization
There are probably still reference equality issues here since Unit still has Season and Power objects. The test case builder also still works on reference equality in some places so the second part of adjudication is broken.
2024-08-14 09:51:18 -07:00
Tim Van Baak 31bd6a45cb Define JSON serialization options on World 2024-08-14 09:16:53 -07:00
Tim Van Baak 5ad57465d8 Remove reference from Unit.Past 2024-08-14 09:06:05 -07:00
Tim Van Baak 885628900b Remove Location reference from Unit 2024-08-14 09:06:05 -07:00
Tim Van Baak e1772ce60b Refactor away Unit.Province 2024-08-14 09:06:05 -07:00
Tim Van Baak abaa7f7a92 Shift usage of Unit.Location to Unit.LocationId
This is in preparation for removing province and location references from Unit
2024-08-14 09:06:04 -07:00
Tim Van Baak 442015b942 Always name locations 2024-08-14 09:06:04 -07:00
Tim Van Baak 228ad53cca Enable basic World serialization 2024-08-14 09:06:04 -07:00
Tim Van Baak f1563b8f5f Delete Season.Coord 2024-08-14 09:06:04 -07:00
Tim Van Baak 345d54f960 Refactor timelines and season creation logic into World 2024-08-14 09:06:04 -07:00
Tim Van Baak 58f877425a Add more JsonIgnores 2024-08-14 09:06:04 -07:00
Tim Van Baak 2f4c8b2a38 Store order history by timeline designation instead of reference 2024-08-14 09:06:04 -07:00
Tim Van Baak ef4e130dbb Add a serialization round trip test
This currently fails because a lot of World still works on references instead of lookups
2024-08-14 09:06:04 -07:00
Tim Van Baak 9606307e12 Update Season ctor 2024-08-14 09:06:04 -07:00
Tim Van Baak 87685ec744 Refactor season futures into World 2024-08-14 09:06:04 -07:00
Tim Van Baak 752a898123 Use a simpler override where available 2024-08-14 09:06:04 -07:00
Tim Van Baak 400263ea0b Rename PastId back to Past 2024-08-14 09:06:04 -07:00
Tim Van Baak 5e5483367d Remove Season.Past so all lookups go through World 2024-08-14 09:06:04 -07:00
Tim Van Baak 81c9aa4859 Move more timeline logic from Season to World 2024-08-14 09:06:04 -07:00
Tim Van Baak fca8b77a21 Move GetAdjacentSeasons to PathFinder 2024-08-14 09:06:04 -07:00
Tim Van Baak b756959b0a Replace most uses of Season creators to World 2024-08-14 09:06:04 -07:00
Tim Van Baak b887e01334 Eliminate RootSeason field 2024-08-14 09:06:04 -07:00
Tim Van Baak 421e84b559 Update timeline designator usage
Timelines are now identified by strings and come first in timeline-turn tuples.
2024-08-14 09:06:04 -07:00
Tim Van Baak 780ae8b948 Refactor timeline factory to generate string ids
The strings are immediately shimmed back to ints for now
2024-08-14 09:06:03 -07:00
Tim Van Baak 40254b0fca Add Makefile 2024-08-14 09:04:56 -07:00
Tim Van Baak bd8e0da6b6 Refactor province and power information into Map 2024-08-14 09:04:56 -07:00
45 changed files with 1726 additions and 1308 deletions

View File

@ -236,17 +236,13 @@
<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>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".
<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>
<ul>
<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>
<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>
</ul>
</ul>
@ -258,13 +254,15 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<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 0:0
A Munich hold
Germany:
A a-Munich hold
Germany 1:0
A Munich - Tyrolia 0:0
---
Germany:
A a-Munich - a-Tyrolia@0
</pre>
<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>
<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>
<div class="figures">
<canvas id="canvas-3-A-1-before" width="0" height="0"></canvas>
<script>
@ -311,19 +309,21 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<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 0:0
Austria:
A Tyrolia hold
Germany 0:0
Germany:
A Munich - Tyrolia
Austria 1:0
---
Austria:
A Tyrolia hold
Germany 1:0
A Munich supports A Munich 0:0 - Tyrolia 0:0
Germany:
A Munich supports A a-Munich@0 - Tyrolia
</pre>
<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>
<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>
<div class="figures">
<canvas id="canvas-3-A-2-before" width="0" height="0"></canvas>
<script>
@ -379,19 +379,21 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<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 0:0
Austria:
A Tyrolia hold
Germany 0:0
Germany:
A Munich hold
Austria 1:0
---
Austria:
A Tyrolia hold
Germany 1:0
A Munich - Tyrolia 0:0
Germany:
A Munich - a-Tyrolia@0
</pre>
<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>
<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>
<div class="figures">
<canvas id="canvas-3-A-3-before" width="0" height="0"></canvas>
<script>
@ -441,15 +443,17 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<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 0:0
Germany:
A Munich - Tyrolia
A Bohemia hold
Germany 1:0
---
Germany:
A Tyrolia hold
A Bohemia supports A Munich 0:0 - Tyrolia
A Bohemia supports A a-Munich@0 - Tyrolia
</pre>
<p>Both units in 1:0 continue to 2:0. No alternate timeline is created.</p>
<p>Both units in a1 continue to a2. No alternate timeline is created.</p>
<div class="figures">
<canvas id="canvas-3-A-4-before" width="0" height="0"></canvas>
<script>
@ -501,15 +505,15 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<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 Tyrolia 2:0 hold
A Tyrolia 1:1 hold
Austria:
A a-Tyrolia hold
A b-Tyrolia hold
Germany
A Munich 2:0 - Tyrolia
A Munich 1:1 supports A Munich 2:0 - Tyrolia
A a-Munich - Tyrolia
A b-Munich supports A a-Munich - Tyrolia
</pre>
<p>A Munich 2:0 dislodges A Tyrolia 2:0. No alternate timeline is created.</p>
<p>A a-Munich dislodges A a-Tyrolia. No alternate timeline is created.</p>
<div class="figures">
<canvas id="canvas-3-A-5-before" width="0" height="0"></canvas>
<script>
@ -567,19 +571,11 @@ The order here is for Army Munich in 2:0. The move being supported is for Army M
<p>Following <a href="#3.A.5">3.A.5</a>, a cross-timeline support that previously succeeded is cut.</p>
<pre>
Germany
A Munich 2:0 - Tyrolia
A Munich 1:1 supports A Munich 2:0 - Tyrolia
A a-Tyrolia holds
A b-Munich holds
Austria
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
A b-Tyrolia@2 - b-Munich@1
</pre>
<p>Cutting the support does not change the past or cause a timeline fork.</p>
<div class="figures">

10
Makefile Normal file
View File

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

View File

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

View File

@ -12,37 +12,41 @@ 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, order.Target, order.Location, order.Season);
=> ConvoyPathExists(world, world.Map.GetLocation(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, order.Unit, order.Location, order.Season);
=> ConvoyPathExists(
world,
world.Map.GetLocation(order.Unit),
world.Map.GetLocation(order.Location),
order.Season);
private static bool ConvoyPathExists(
World world,
Unit movingUnit,
Location unitLocation,
Season unitSeason)
Location destLocation,
Season destSeason)
{
// 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<(Location location, Season season), Unit> fleets = world.Units
IDictionary<(string 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 (movingUnit.Location.Type != LocationType.Land) return false;
IEnumerable<Location> originCoasts = movingUnit.Province.Locations
if (unitLocation.Type != LocationType.Land) return false;
IEnumerable<Location> originCoasts = unitLocation.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!originCoasts.Any()) return false;
// Verify that the destination is a coastal province.
if (unitLocation.Type != LocationType.Land) return false;
IEnumerable<Location> destCoasts = unitLocation.Province.Locations
if (destLocation.Type != LocationType.Land) return false;
IEnumerable<Location> destCoasts = destLocation.Province.Locations
.Where(location => location.Type == LocationType.Water);
if (!destCoasts.Any()) return false;
@ -50,7 +54,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, unitSeason)));
originCoasts.Select(location => (location, destSeason)));
HashSet<(Location, Season)> visited = new();
// Begin pathfinding.
@ -60,16 +64,16 @@ public static class PathFinder
(Location currentLocation, Season currentSeason) = toVisit.Dequeue();
visited.Add((currentLocation, currentSeason));
var adjacents = GetAdjacentPoints(currentLocation, currentSeason);
var adjacents = GetAdjacentPoints(world, currentLocation, currentSeason);
foreach ((Location adjLocation, Season adjSeason) in adjacents)
{
// If the destination is adjacent, then a path exists.
if (destCoasts.Contains(adjLocation) && unitSeason == adjSeason) return true;
if (destCoasts.Contains(adjLocation) && destSeason == 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, adjSeason))
&& fleets.ContainsKey((adjLocation.Key, adjSeason))
&& !visited.Contains((adjLocation, adjSeason)))
{
toVisit.Enqueue((adjLocation, adjSeason));
@ -81,11 +85,11 @@ public static class PathFinder
return false;
}
private static List<(Location, Season)> GetAdjacentPoints(Location location, Season season)
private static List<(Location, Season)> GetAdjacentPoints(World world, Location location, Season season)
{
List<(Location, Season)> adjacentPoints = new();
List<(Location, Season)> adjacentPoints = [];
List<Location> adjacentLocations = location.Adjacents.ToList();
List<Season> adjacentSeasons = season.GetAdjacentSeasons().ToList();
List<Season> adjacentSeasons = GetAdjacentSeasons(world, season).ToList();
foreach (Location adjacentLocation in adjacentLocations)
{
@ -105,4 +109,59 @@ 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,3 +1,5 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
@ -9,17 +11,21 @@ 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.
@ -29,10 +35,16 @@ public class Location
/// <summary>
/// The locations that border this location.
/// </summary>
[JsonIgnore]
public IEnumerable<Location> Adjacents => this.AdjacentList;
private List<Location> AdjacentList { get; set; }
public Location(Province province, string? name, string? abbreviation, LocationType type)
/// <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)
{
this.Province = province;
this.Name = name;
@ -41,9 +53,15 @@ 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 == null
return this.Name == "land" || this.Name == "water"
? $"{this.Province.Name} ({this.Type})"
: $"{this.Province.Name} ({this.Type}:{this.Name}]";
}

View File

@ -0,0 +1,570 @@
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

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

View File

@ -1,22 +0,0 @@
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 = new List<Location>();
this.LocationList = [];
}
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 Province(name, abbreviations, isSupply: false, isTime: false);
=> new(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 Province(name, abbreviations, isSupply: true, isTime: false);
=> new(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 Province(name, abbreviations, isSupply: true, isTime: true);
=> new(name, abbreviations, isSupply: true, isTime: true);
/// <summary>
/// Create a new land location in this province.
/// </summary>
public Province AddLandLocation()
{
Location location = new Location(this, name: null, abbreviation: null, LocationType.Land);
Location location = new(this, "land", "l", LocationType.Land);
this.LocationList.Add(location);
return this;
}
@ -78,19 +78,7 @@ public class Province
/// </summary>
public Province AddOceanLocation()
{
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);
Location location = new(this, "water", "w", LocationType.Water);
this.LocationList.Add(location);
return this;
}
@ -101,7 +89,7 @@ public class Province
/// </summary>
public Province AddCoastLocation(string name, string abbreviation)
{
Location location = new Location(this, name, abbreviation, LocationType.Water);
Location location = new(this, name, abbreviation, LocationType.Water);
this.LocationList.Add(location);
return this;
}

View File

@ -1,185 +1,77 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Represents a state of the map produced by a set of move orders on a previous season.
/// Represents a multiversal coordinate at which a state of the map exists.
/// </summary>
public class Season
[JsonConverter(typeof(SeasonJsonConverter))]
public struct Season(string timeline, int turn)
{
/// <summary>
/// A shared counter for handing out new timeline numbers.
/// 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"/>.
/// </summary>
private class TimelineFactory
{
private int nextTimeline = 0;
public int NextTimeline() => nextTimeline++;
}
public static readonly Season First = new(Timelines.IntToString(0), 0);
/// <summary>
/// The first turn number.
/// The timeline to which this season belongs.
/// </summary>
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; }
public string Timeline { get; } = timeline;
/// <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; }
public int Turn { get; } = turn;
/// <summary>
/// The timeline to which this season belongs.
/// The multiversal designation of this season.
/// </summary>
public int Timeline { get; }
[JsonIgnore]
public readonly string Key => $"{this.Timeline}{this.Turn}";
/// <summary>
/// The season's spatial location as a turn-timeline tuple.
/// Create a new season from a tuple coordinate.
/// </summary>
public (int Turn, int Timeline) Coord => (this.Turn, this.Timeline);
public Season((string timeline, int turn) coord) : this(coord.timeline, coord.turn) { }
/// <summary>
/// The shared timeline number generator.
/// Create a new season from a combined string designation.
/// </summary>
private TimelineFactory Timelines { get; }
/// <param name="designation"></param>
public Season(string designation) : this(SplitKey(designation)) { }
/// <summary>
/// Future seasons created directly from this season.
/// Extract the timeline and turn components of a season designation.
/// </summary>
public IEnumerable<Season> Futures => this.FutureList;
private List<Season> FutureList { get; }
private Season(Season? past, int turn, int timeline, TimelineFactory factory)
/// <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)
{
this.Past = past;
this.Turn = turn;
this.Timeline = timeline;
this.Timelines = factory;
this.FutureList = new();
if (past != null)
{
past.FutureList.Add(this);
}
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}");
}
public override string ToString()
{
return $"{this.Timeline}@{this.Turn}";
}
public override readonly string ToString() => Key;
/// <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);
}
/// <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 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.Equals(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 static bool operator !=(Season one, Season two) => !(one == two);
/// <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;
}
public override readonly int GetHashCode() => (Timeline, Turn).GetHashCode();
}

View File

@ -0,0 +1,16 @@
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

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

View File

@ -1,8 +1,11 @@
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,6 +1,5 @@
using System.Collections.ObjectModel;
using MultiversalDiplomacy.Orders;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
@ -9,66 +8,96 @@ 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>
public ReadOnlyCollection<Province> Provinces { get; }
[JsonIgnore]
public IReadOnlyCollection<Province> Provinces => this.Map.Provinces;
/// <summary>
/// The game powers.
/// </summary>
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; }
[JsonIgnore]
public IReadOnlyCollection<string> Powers => this.Map.Powers;
/// <summary>
/// All units in the multiverse.
/// </summary>
public ReadOnlyCollection<Unit> Units { get; }
public List<Unit> Units { get; }
/// <summary>
/// All retreating units in the multiverse.
/// </summary>
public ReadOnlyCollection<RetreatingUnit> RetreatingUnits { get; }
public List<RetreatingUnit> RetreatingUnits { get; }
/// <summary>
/// Orders given to units in each season.
/// </summary>
public ReadOnlyDictionary<Season, OrderHistory> OrderHistory { get; }
public Dictionary<string, OrderHistory> OrderHistory { get; }
/// <summary>
/// The shared timeline number generator.
/// </summary>
public Timelines Timelines { 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(
ReadOnlyCollection<Province> provinces,
ReadOnlyCollection<Power> powers,
ReadOnlyCollection<Season> seasons,
Season rootSeason,
ReadOnlyCollection<Unit> units,
ReadOnlyCollection<RetreatingUnit> retreatingUnits,
ReadOnlyDictionary<Season, OrderHistory> orderHistory,
Map map,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
Timelines timelines,
Options options)
{
this.Provinces = provinces;
this.Powers = powers;
this.Seasons = seasons;
this.RootSeason = rootSeason;
this.Map = map;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
this.Timelines = timelines;
this.Options = options;
}
@ -77,21 +106,17 @@ public class World
/// </summary>
private World(
World previous,
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,
List<Unit>? units = null,
List<RetreatingUnit>? retreatingUnits = null,
Dictionary<string, OrderHistory>? orderHistory = null,
Timelines? timelines = null,
Options? options = null)
: this(
provinces ?? previous.Provinces,
powers ?? previous.Powers,
seasons ?? previous.Seasons,
previous.RootSeason, // Can't change the root season
previous.Map,
units ?? previous.Units,
retreatingUnits ?? previous.RetreatingUnits,
orderHistory ?? previous.OrderHistory,
timelines ?? previous.Timelines,
options ?? previous.Options)
{
}
@ -99,17 +124,14 @@ public class World
/// <summary>
/// Create a new world with specified provinces and powers and an initial season.
/// </summary>
public static World WithMap(IEnumerable<Province> provinces, IEnumerable<Power> powers)
public static World WithMap(Map map)
{
Season root = Season.MakeRoot();
return new World(
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>()),
map,
new([]),
new([]),
new(new Dictionary<string, OrderHistory>()),
Timelines.Create(),
new Options());
}
@ -117,18 +139,15 @@ public class World
/// Create a new world with the standard Diplomacy provinces and powers.
/// </summary>
public static World WithStandardMap()
=> WithMap(StandardProvinces, StandardPowers);
=> WithMap(Map.Classical);
public World Update(
IEnumerable<Season>? seasons = null,
IEnumerable<Unit>? units = null,
IEnumerable<RetreatingUnit>? retreats = null,
IEnumerable<KeyValuePair<Season, OrderHistory>>? orders = null)
=> new World(
IEnumerable<KeyValuePair<string, OrderHistory>>? orders = null,
Timelines? timelines = null)
=> new(
previous: this,
seasons: seasons == null
? this.Seasons
: new(seasons.ToList()),
units: units == null
? this.Units
: new(units.ToList()),
@ -137,7 +156,8 @@ public class World
: new(retreats.ToList()),
orderHistory: orders == null
? this.OrderHistory
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)),
timelines: timelines ?? this.Timelines);
/// <summary>
/// Create a new world with new units created from unit specs. Units specs are in the format
@ -149,7 +169,7 @@ public class World
IEnumerable<Unit> units = unitSpecs.Select(spec =>
{
string[] splits = spec.Split(' ', 4);
Power power = this.GetPower(splits[0]);
string power = Map.GetPower(splits[0]);
UnitType type = splits[1] switch
{
"A" => UnitType.Army,
@ -157,11 +177,11 @@ public class World
_ => throw new ApplicationException($"Unknown unit type {splits[1]}")
};
Location location = type == UnitType.Army
? this.GetLand(splits[2])
? Map.GetLand(splits[2])
: splits.Length == 3
? this.GetWater(splits[2])
: this.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location, this.RootSeason, power, type);
? Map.GetWater(splits[2])
: Map.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location.Key, Season.First, power, type);
return unit;
});
return this.Update(units: units);
@ -201,559 +221,23 @@ public class World
/// <summary>
/// A standard Diplomacy game setup.
/// </summary>
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;
}
public static World Standard => WithStandardMap().AddStandardUnits();
/// <summary>
/// Returns a unit in a province. Throws if there are duplicate units.
/// </summary>
public Unit GetUnitAt(string provinceName, (int turn, int timeline)? seasonCoord = null)
public Unit GetUnitAt(string provinceName, Season? season = null)
{
Province province = GetProvince(provinceName);
seasonCoord ??= (this.RootSeason.Turn, this.RootSeason.Timeline);
Season season = GetSeason(seasonCoord.Value.turn, seasonCoord.Value.timeline);
Province province = Map.GetProvince(provinceName);
season ??= Season.First;
Unit? foundUnit = this.Units.SingleOrDefault(
u => u!.Province == province && u.Season == season,
null);
if (foundUnit == null) throw new KeyNotFoundException(
$"Unit at {province} at {season} not found");
u => Map.GetLocation(u!).Province == province && u!.Season == season,
null)
?? throw new KeyNotFoundException($"Unit at {province} at {season} not found");
return foundUnit;
}
/// <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"),
});
}
}
public Unit GetUnitByKey(string designation)
=> Units.SingleOrDefault(u => u!.Key == designation, null)
?? throw new KeyNotFoundException($"Unit {designation} not found");
}

View File

@ -7,4 +7,10 @@
<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(Power power, Location location, UnitType type)
public BuildOrder(string 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(Power power, Unit unit, Unit target, Season season, Location location)
public ConvoyOrder(string 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} C {this.Target} -> {(this.Province, this.Season).ToShort()}";
return $"{this.Unit} con {this.Target} -> {(this.Province, this.Season).ToShort()}";
}
}

View File

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

View File

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

View File

@ -1,5 +1,7 @@
using MultiversalDiplomacy.Model;
using static MultiversalDiplomacy.Model.Location;
namespace MultiversalDiplomacy.Orders;
/// <summary>
@ -15,19 +17,9 @@ public class MoveOrder : UnitOrder
/// <summary>
/// The destination location to which the unit should move.
/// </summary>
public Location Location { get; }
public string Location { get; }
/// <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)
public MoveOrder(string power, Unit unit, Season season, string location)
: base (power, unit)
{
this.Season = season;
@ -36,23 +28,6 @@ public class MoveOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} -> {(this.Province, this.Season).ToShort()}";
return $"{this.Unit} -> {Season.Timeline}-{SplitKey(Location).province}@{Season.Turn}";
}
/// <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,3 +1,5 @@
using System.Text.Json.Serialization;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Orders;
@ -5,14 +7,24 @@ 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 Power Power { get; }
public string Power { get; }
public Order(Power power)
public Order(string power)
{
this.Power = power;
}

View File

@ -12,7 +12,7 @@ public class RetreatOrder : UnitOrder
/// </summary>
public Location Location { get; }
public RetreatOrder(Power power, Unit unit, Location location)
public RetreatOrder(string 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(Power power, Unit unit, Unit target)
public SupportHoldOrder(string power, Unit unit, Unit target)
: base (power, unit, target)
{
}
public override string ToString()
{
return $"{this.Unit} S {this.Target}";
return $"{this.Unit} sup {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(Power power, Unit unit, Unit target, Season season, Location location)
public SupportMoveOrder(string power, Unit unit, Unit target, Season season, Location location)
: base(power, unit, target)
{
this.Season = season;
@ -36,11 +36,6 @@ public class SupportMoveOrder : SupportOrder
public override string ToString()
{
return $"{this.Unit} S {this.Target} -> {(this.Province, this.Season).ToShort()}";
return $"{this.Unit} sup {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(Power power, Unit unit, Unit target)
public SupportOrder(string 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(Power power, Location timeCenter, int timeline)
public SustainOrder(string power, Location timeCenter, int timeline)
: base (power)
{
this.TimeCenter = timeCenter;

View File

@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Orders;
@ -5,6 +7,14 @@ 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>
@ -12,16 +22,8 @@ public abstract class UnitOrder : Order
/// </summary>
public Unit Unit { get; }
public UnitOrder(Power power, Unit unit) : base(power)
public UnitOrder(string 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

@ -0,0 +1,10 @@
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[(0, 0)]
setup[("a", 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").Holds().GetReference(out var mun0)
.Execute()
[(1, 0)]
[("a", 1)]
.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.Seasons.Where(s => s.Timeline == s0.Timeline).Count(),
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline == s0.Timeline).Count(),
Is.EqualTo(3),
"Failed to advance main timeline after last unit left");
Assert.That(
world.Seasons.Where(s => s.Timeline != s0.Timeline).Count(),
world.Timelines.Pasts.Keys.Select(key => new Season(key)).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 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 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 Mun 1:1 originating from Mun 0:0
Unit aMun1 = world.GetUnitAt("Mun", fork.Coord);
Assert.That(aMun1.Past, Is.EqualTo(originalUnit));
// 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));
}
[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[(0, 0)]
setup[("a", 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[(1, 0)]
setup[("a", 1)]
["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 = world.GetSeason(1, 1);
Unit tyr1 = world.GetUnitAt("Tyr", fork.Coord);
Season fork = new("b1");
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Assert.That(
tyr1.Past,
Is.EqualTo(mun0.Order.Unit),
"Expected A Mun 0:0 to advance to Tyr 1:1");
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 0:0 to be in retreat");
Assert.That(world.RetreatingUnits.First().Unit, Is.EqualTo(tyr0.Order.Unit));
"Expected A Tyr a0 to be in retreat");
Assert.That(world.RetreatingUnits.First().Unit.Key, Is.EqualTo(tyr0.Order.Unit.Key));
}
[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[(0, 0)]
setup[("a", 0)]
.GetReference(out Season s0)
["Germany"]
.Army("Mun").Holds()
["Austria"]
.Army("Tyr").Holds().GetReference(out var tyr0)
.Execute()
[(1, 0)]
[("a", 1)]
.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 0:0 should have been pulled into the adjudication set, so the reference
// The order to Tyr a0 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(
s0.Futures.Count(),
world.Timelines.GetFutures(s0).Count(),
Is.EqualTo(1),
"A failed move incorrectly forked the timeline");
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));
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));
}
[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[(0, 0)]
setup[("a", 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[(1, 0)]
setup[("a", 1)]
.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(
s0.Futures.Count(),
world.Timelines.GetFutures(s0).Count(),
Is.EqualTo(1),
"A superfluous support incorrectly forked the timeline");
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));
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));
}
[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[(0, 0)]
.GetReference(out var s0_0)
setup[("a", 0)]
.GetReference(out var a0)
["England"].Army("Lon").Holds()
["Austria"].Army("Tyr").Holds()
["Germany"].Army("Mun").Holds()
.Execute()
[(1, 0)]
["England"].Army("Lon").MovesTo("Yor", s0_0)
[("a", 1)]
["England"].Army("Lon").MovesTo("Yor", a0)
.Execute()
// Head seasons: 2:0 1:1
// Head seasons: a2 b1
// Both contain copies of the armies in Mun and Tyr.
// Now Germany dislodges Austria in Tyr by supporting the move across timelines.
[(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)
[("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)
["Austria"].Army("Tyr").Holds();
// The attack against Tyr 2:0 succeeds.
// The attack against Tyr a2 succeeds.
setup.ValidateOrders();
Assert.That(mun2_0, Is.Valid);
Assert.That(tyr2_0, Is.Valid);
Assert.That(mun1_1, Is.Valid);
Assert.That(mun_a2, Is.Valid);
Assert.That(tyr_a2, Is.Valid);
Assert.That(mun_b1, Is.Valid);
setup.AdjudicateOrders();
Assert.That(mun2_0, Is.Victorious);
Assert.That(tyr2_0, Is.Dislodged);
Assert.That(mun1_1, Is.NotCut);
Assert.That(mun_a2, Is.Victorious);
Assert.That(tyr_a2, Is.Dislodged);
Assert.That(mun_b1, Is.NotCut);
// Since both seasons were at the head of their timelines, there should be no forking.
World world = setup.UpdateWorld();
Assert.That(
s2_0.Futures.Count(),
world.Timelines.GetFutures(a2).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Assert.That(
s1_1.Futures.Count(),
world.Timelines.GetFutures(b1).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
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));
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));
}
[Test]
@ -245,63 +245,63 @@ public class TimeTravelTest
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
// As above, only now London creates three timelines.
setup[(0, 0)]
.GetReference(out var s0_0)
setup[("a", 0)]
.GetReference(out var a0)
["England"].Army("Lon").Holds()
["Austria"].Army("Boh").Holds()
["Germany"].Army("Mun").Holds()
.Execute()
[(1, 0)]
["England"].Army("Lon").MovesTo("Yor", s0_0)
[("a", 1)]
["England"].Army("Lon").MovesTo("Yor", a0)
.Execute()
// Head seasons: 2:0 1:1
[(2, 0)]
[(1, 1)]
["England"].Army("Yor").MovesTo("Edi", s0_0)
// Head seasons: a2, b1
[("a", 2)]
[("b", 1)]
["England"].Army("Yor").MovesTo("Edi", a0)
.Execute()
// Head seasons: 3:0 2:1 1:2
// Head seasons: a3, b2, c1
// All three of these contain copies of the armies in Mun and Tyr.
// As above, Germany dislodges Austria in Tyr 3:0 by supporting the move from 2:1.
[(3, 0)]
.GetReference(out var s3_0)
// As above, Germany dislodges Austria in Tyr a3 by supporting the move from b2.
[("a", 3)]
.GetReference(out var a3)
["Germany"].Army("Mun").MovesTo("Tyr")
["Austria"].Army("Tyr").Holds()
[(2, 1)]
.GetReference(out Season s2_1)
["Germany"].Army("Mun").Supports.Army("Mun", s3_0).MoveTo("Tyr").GetReference(out var mun2_1)
[("b", 2)]
.GetReference(out Season b2)
["Germany"].Army("Mun").Supports.Army("Mun", a3).MoveTo("Tyr").GetReference(out var mun_b2)
["Austria"].Army("Tyr").Holds()
[(1, 2)]
[("c", 1)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").Holds()
.Execute()
// Head seasons: 4:0 3:1 2:2
// Now Austria cuts the support in 2:1 by attacking from 2:2.
[(4, 0)]
// Head seasons: a4, b3, c2
// Now Austria cuts the support in b2 by attacking from c2.
[("a", 4)]
["Germany"].Army("Tyr").Holds()
[(3, 1)]
[("b", 3)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").Holds()
[(2, 2)]
[("c", 2)]
["Germany"].Army("Mun").Holds()
["Austria"].Army("Tyr").MovesTo("Mun", s2_1).GetReference(out var tyr2_2);
["Austria"].Army("Tyr").MovesTo("Mun", b2).GetReference(out var tyr_c2);
// The attack on Mun 2:1 is repelled, but the support is cut.
// The attack on Mun b2 is repelled, but the support is cut.
setup.ValidateOrders();
Assert.That(tyr2_2, Is.Valid);
Assert.That(tyr_c2, Is.Valid);
setup.AdjudicateOrders();
Assert.That(tyr2_2, Is.Repelled);
Assert.That(mun2_1, Is.NotDislodged);
Assert.That(mun2_1, Is.Cut);
Assert.That(tyr_c2, Is.Repelled);
Assert.That(mun_b2, Is.NotDislodged);
Assert.That(mun_b2, 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(
s3_0.Futures.Count(),
world.Timelines.GetFutures(a3).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
Assert.That(
s2_1.Futures.Count(),
world.Timelines.GetFutures(b2).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
}

View File

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

View File

@ -168,25 +168,23 @@ 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[(0, 0)]
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").Holds().GetReference(out var mun1);
@ -199,20 +197,20 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = updated.GetSeason(1, 0);
Assert.That(s2.Past, Is.EqualTo(s1));
Assert.That(s2.Futures, Is.Empty);
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);
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.Coord);
Unit u2 = updated.GetUnitAt("Mun", s2);
Assert.That(updated.Units.Count, Is.EqualTo(2));
Assert.That(u2, Is.Not.EqualTo(mun1.Order.Unit));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit));
Assert.That(u2.Key, Is.Not.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Season, Is.EqualTo(s2));
setup[(1, 0)]
setup[("a", 1)]
["Germany"]
.Army("Mun").Holds().GetReference(out var mun2);
@ -227,16 +225,16 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
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));
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(mun2.Order.Unit.Key));
}
[Test]
public void Update_DoubleMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
setup[(0, 0)]
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun1);
@ -249,20 +247,20 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = updated.GetSeason(s1.Turn + 1, s1.Timeline);
Assert.That(s2.Past, Is.EqualTo(s1));
Assert.That(s2.Futures, Is.Empty);
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);
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.Coord);
Unit u2 = updated.GetUnitAt("Tyr", s2);
Assert.That(updated.Units.Count, Is.EqualTo(2));
Assert.That(u2, Is.Not.EqualTo(mun1.Order.Unit));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit));
Assert.That(u2.Key, Is.Not.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Past, Is.EqualTo(mun1.Order.Unit.Key));
Assert.That(u2.Season, Is.EqualTo(s2));
setup[(1, 0)]
setup[("a", 1)]
["Germany"]
.Army("Tyr").MovesTo("Mun").GetReference(out var tyr2);
@ -277,8 +275,8 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = updated.GetSeason(s2.Turn + 1, s2.Timeline);
Unit u3 = updated.GetUnitAt("Mun", s3.Coord);
Assert.That(u3.Past, Is.EqualTo(u2));
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(u2.Key));
}
}

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

View File

@ -1,65 +0,0 @@
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

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

View File

@ -0,0 +1,103 @@
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,29 +10,28 @@ public class UnitTests
public void MovementTest()
{
World world = World.WithStandardMap();
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);
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);
Season s2 = s1.MakeNext();
Unit u2 = u1.Next(Boh, s2);
world = world.WithNewSeason(a0, out Season a1);
Unit u2 = u1.Next(Boh.Key, a1);
Season s3 = s2.MakeNext();
Unit u3 = u2.Next(Tyr, s3);
_ = world.WithNewSeason(a1, out Season a2);
Unit u3 = u2.Next(Tyr.Key, a2);
Assert.That(u3.Past, Is.EqualTo(u2), "Missing unit past");
Assert.That(u2.Past, Is.EqualTo(u1), "Missing unit past");
Assert.That(u3.Past, Is.EqualTo(u2.Key), "Missing unit past");
Assert.That(u2.Past, Is.EqualTo(u1.Key), "Missing unit past");
Assert.That(u1.Past, Is.Null, "Unexpected unit past");
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.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.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");
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");
}
}

View File

@ -4,9 +4,11 @@ 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 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.
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.
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.
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.
## Timeline notation
@ -38,3 +40,13 @@ 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.
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.
> [!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 ];
NIX_LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc pkgs.icu ];
NIX_LD = builtins.readFile "${pkgs.stdenv.cc}/nix-support/dynamic-linker";
packages = [
pkgs.bashInteractive