Compare commits

...

105 Commits

Author SHA1 Message Date
Tim Van Baak ddf951c17e Add GPLv3 license 2024-09-06 17:24:33 +00:00
Tim Van Baak 9fc298adda Remove in-code DATC A tests in favor of scripts 2024-09-06 16:42:35 +00:00
Tim Van Baak 7c0cdb0a21 Add the rest of DATC 6.A to the script tests 2024-09-06 16:17:55 +00:00
Tim Van Baak 5e2d495fa5 Update script tests 2024-09-06 16:04:25 +00:00
Tim Van Baak 7773c571e3 Add hold-order assertion for unparseable orders 2024-09-06 16:03:43 +00:00
Tim Van Baak 4096e4d517 Implement coastal accessibility checks for support-move orders 2024-09-06 15:19:58 +00:00
Tim Van Baak aaf2e78730 Implement coastal accessibility checks for move orders 2024-09-06 15:09:30 +00:00
Tim Van Baak 864a933ba0 Implement dislodge/hold assertions 2024-09-05 23:16:22 +00:00
Tim Van Baak c6f10868ae Implement support assertions 2024-09-05 23:11:29 +00:00
Tim Van Baak 5b32786904 Implement support-move parsing 2024-09-05 05:27:48 +00:00
Tim Van Baak ae5eb22010 Implement support-hold parsing 2024-09-05 05:22:07 +00:00
Tim Van Baak 26f7cee070 Add unit tests for move location disambiguation
Some of the coastal tests fail because the coast accessibility check isn't implemented yet
2024-09-05 05:11:12 +00:00
Tim Van Baak 80f340c0b2 Implement assert moves/no-move 2024-09-03 04:25:37 +00:00
Tim Van Baak e9c9999268 Implement assert has-past 2024-09-03 03:49:38 +00:00
Tim Van Baak 4fee854c4c Refactor script handlers to return a result type
This moves the point of strictness from the handler to the driver, which makes more sense and keeps it in one place. Drivers choose to be strict when a script result is a failure but still gives a continuation handler. The CLI driver prints an error and continues, while the test driver fails if it wasn't expecting the failure.
2024-09-03 03:20:59 +00:00
Tim Van Baak 569c9021e6 Disable some broken tests for now 2024-09-02 19:52:27 +00:00
Tim Van Baak 3984b814ca Implement assert order-valid 2024-09-01 04:54:28 +00:00
Tim Van Baak f18147f666 Enable suppressing adjudicator output in tests 2024-08-28 21:27:35 +00:00
Tim Van Baak 9f52c78b40 Enable repl output to /dev/null 2024-08-28 21:10:41 +00:00
Tim Van Baak 7b890046b6 Add assertion stubs and unit tests 2024-08-28 19:14:19 +00:00
Tim Van Baak 720ccc4329 Add standard repl helper 2024-08-28 15:09:57 +00:00
Tim Van Baak d2a46aa02d Implement dummy assertions 2024-08-28 15:01:27 +00:00
Tim Van Baak f02e71d4f9 Implement repl adjudication 2024-08-28 14:41:23 +00:00
Tim Van Baak f77cc60185 Factor out common subject parsing logic 2024-08-28 14:39:21 +00:00
Tim Van Baak 14a493d95c Update design.md with note about order interpretation 2024-08-28 14:34:42 +00:00
Tim Van Baak 44f2c25a2c Add unit test for submitting orders in the repl 2024-08-28 00:46:32 +00:00
Tim Van Baak 43a2517a95 Fix unit declaration commands 2024-08-28 00:45:38 +00:00
Tim Van Baak 512c91d2de Add unit test for testing the repl 2024-08-27 04:18:36 +00:00
Tim Van Baak 416f2aa919 Rename to OrderParser 2024-08-27 03:23:28 +00:00
Tim Van Baak 4f276df6c1 Basic order parsing and a unit test 2024-08-27 02:43:12 +00:00
Tim Van Baak 24e80af7ef Add test cases for support-move 2024-08-26 17:47:52 +00:00
Tim Van Baak e25191548e Set version to 0.0.2
Some internal milestones may qualify as 0.x, 1.0 will be reserved for when a minimum viable game can be played
2024-08-26 16:47:00 +00:00
Tim Van Baak 33aecf876a Add test cases for support-hold 2024-08-26 16:32:33 +00:00
Tim Van Baak b4f8f621ca Add test cases for move order 2024-08-26 15:39:42 +00:00
Tim Van Baak ffe164975b Remove power from UnitSpec and add hold regex tests 2024-08-26 04:57:30 +00:00
Tim Van Baak ebeb178984 Move some parsing code into OrderRegex 2024-08-25 04:26:10 +00:00
Tim Van Baak 93b106da1e Move province and power regexes to Map 2024-08-25 03:55:14 +00:00
Tim Van Baak 973f8ea0d7 Add Timelines.All shortcut 2024-08-25 03:36:31 +00:00
Tim Van Baak 868138b988 Disable script tests for now 2024-08-25 03:17:46 +00:00
Tim Van Baak 55dfe0ca99 Early-out on comments 2024-08-21 09:47:13 -07:00
Tim Van Baak e9c4d3d2d3 Re-spec validation handler for all adjudication steps 2024-08-21 09:11:39 -07:00
Tim Van Baak 32a7ddd3b5 Documentation updates 2024-08-21 14:39:03 +00:00
Tim Van Baak 5167978f8c Update some log statements 2024-08-21 14:27:48 +00:00
Tim Van Baak aaf3320cf8 Add a handler for asserting against orders 2024-08-21 14:25:25 +00:00
Tim Van Baak 2745d12d29 Implement regex order parsing in game script handler 2024-08-21 13:46:02 +00:00
Tim Van Baak 8e976433c8 Replace ad-hoc setup parsing with regex 2024-08-21 13:35:28 +00:00
Tim Van Baak bfafb66603 Add test case for provinces with spaces 2024-08-20 14:43:02 +00:00
Tim Van Baak 1689d2e9b1 Add order parsing regex 2024-08-20 14:39:49 +00:00
Tim Van Baak ea366220eb Better error message for script failure 2024-08-18 13:32:36 -07:00
Tim Van Baak f9f8ea2b5a Add script-based test framework 2024-08-17 21:24:59 -07:00
Tim Van Baak b2461b3736 Add strict mode to setup handler 2024-08-17 21:18:35 -07:00
Tim Van Baak 92506ac6ed Typo in default units 2024-08-16 16:03:37 -07:00
Tim Van Baak 114379de59 Add basic game setup 2024-08-16 16:00:02 -07:00
Tim Van Baak 7ad6f3a3d3 Add a repl script handling framework 2024-08-16 14:39:13 -07:00
Tim Van Baak 6bb6c0695f Add CLI verbs 2024-08-16 13:33:13 -07:00
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
Tim Van Baak 9fd63f4317 Update and refactor documentation
The README will become an entry point for using the project. Notes on design decisions and explanations of the rules are split off into their own documents.
2024-08-09 13:18:49 -07:00
81 changed files with 8869 additions and 1580 deletions

22
LICENSE Normal file
View File

@ -0,0 +1,22 @@
5dplomacy: A Diplomacy adjudicator with multiversal time travel
Copyright (C) 2024 Tim Van Baak
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program, under docs/GPL3.txt. If not, see
<https://www.gnu.org/licenses/>
The Diplomacy Adjudicator Test Cases are copyright (C) 2001-2024 Lucas B. Kruijswijk.
_Diplomacy_, a game of international intrigue, is trademarked by Avalon Hill Game Company and copyright (C) 1976 Avalon Hill. Avalon Hill belongs to Hasbro.
_5D Chess with Multiversal Time Travel_ was created by Conor Peterson and is copyright (C) 2020 Thunkspace, LLC.

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">

13
Makefile Normal file
View File

@ -0,0 +1,13 @@
.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)
repl: ## execute the repl
dotnet run --project MultiversalDiplomacy repl

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

@ -0,0 +1,15 @@
using CommandLine;
namespace MultiversalDiplomacy.CommandLine;
[Verb("adjudicate", HelpText = "Adjudicate a Multiversal Diplomacy game state.")]
public class AdjudicateOptions
{
[Value(0, HelpText = "Input file describing the game state to adjudicate, or - to read from stdin.")]
public string? InputFile { get; set; }
public static void Execute(AdjudicateOptions args)
{
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,15 @@
using CommandLine;
namespace MultiversalDiplomacy.CommandLine;
[Verb("image", HelpText = "Generate an image of a game state.")]
public class ImageOptions
{
[Value(0, HelpText = "Input file describing the game state to visualize, or - to read from stdin.")]
public string? InputFile { get; set; }
public static void Execute(ImageOptions args)
{
throw new NotImplementedException();
}
}

View File

@ -0,0 +1,92 @@
using CommandLine;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacy.CommandLine;
[Verb("repl", HelpText = "Begin an interactive 5dplomacy session.")]
public class ReplOptions
{
[Option('i', "input", HelpText = "Begin the repl session by executing the commands in this file.")]
public string? InputFile { get; set; }
[Option('o', "output", HelpText = "Echo the repl session to this file. Specify a directory to autogenerate a filename.")]
public string? OutputFile { get; set; }
public static void Execute(ReplOptions args)
{
IEnumerable<string>? inputFileLines = null;
if (args.InputFile is not null) {
var fullPath = Path.GetFullPath(args.InputFile);
inputFileLines = File.ReadAllLines(fullPath);
Console.WriteLine($"Reading from {fullPath}");
}
// Create a writer to the output file, if specified.
StreamWriter? outputWriter = null;
if (args.OutputFile is not null)
{
string fullPath = Path.GetFullPath(args.OutputFile);
string outputPath = Directory.Exists(fullPath)
? Path.Combine(fullPath, $"{DateTime.UtcNow:yyyyMMddHHmmss}.log")
: fullPath;
Console.WriteLine($"Echoing to {outputPath}");
outputWriter = File.CreateText(outputPath);
}
IEnumerable<string?> GetInputs()
{
foreach (string line in inputFileLines ?? [])
{
var trimmed = line.Trim();
// File inputs weren't echoed to the terminal so they need to be echoed here
Console.WriteLine($"{trimmed}");
yield return trimmed;
}
string? input;
do
{
input = Console.ReadLine();
yield return input;
}
while (input is not null);
// The last null is returned because an EOF means we should quit the repl.
}
IScriptHandler? handler = new ReplScriptHandler(Console.WriteLine);
Console.Write(handler.Prompt);
foreach (string? nextInput in GetInputs())
{
// Handle quitting directly.
if (nextInput is null || nextInput == "quit" || nextInput == "exit")
{
break;
}
string input = nextInput.Trim();
outputWriter?.WriteLine(input);
outputWriter?.Flush();
// Delegate all other command parsing to the handler.
var result = handler.HandleInput(input);
// Report errors if they occured.
if (!result.Success)
{
Console.WriteLine($"Error: {result.Message}");
}
// Quit if the handler didn't continue processing.
if (result.NextHandler is null)
{
break;
}
// Otherwise prompt for the next command.
Console.Write(handler.Prompt);
}
Console.WriteLine("exiting");
}
}

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,21 @@ 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]);
}
/// <summary>
/// Whether a name is the name or abbreviation of this location.
/// </summary>
public bool Is(string name)
=> name.EqualsAnyCase(Name) || name.EqualsAnyCase(Abbreviation);
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,576 @@
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>
/// A regex that matches any of the power names for this variant.
/// </summary>
public string PowerRegex => $"({string.Join("|", Powers)})";
/// <summary>
/// A regex that matches any of the province names or abbreviations for this variant.
/// </summary>
public string ProvinceRegex => $"({string.Join("|", Provinces.SelectMany(p => p.AllNames))})";
/// <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(province => province!.Is(provinceName), 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

@ -0,0 +1,451 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// This class defines the regular expressions that are used to build up larger expressions for matching orders
/// and other script inputs. It also provides helper functions to extract the captured order elements as tuples,
/// which function as the structured intermediate representation between raw user input and full Order objects.
/// </summary>
public class OrderParser(World world)
{
public const string Type = "(A|F|Army|Fleet)";
public const string Timeline = "([A-Za-z]+)";
public const string Turn = "([0-9]+)";
public const string SlashLocation = "(?:/([A-Za-z]+))";
public const string ParenLocation = "(?:\\(([A-Za-z ]+)\\))";
public string FullLocation => $"(?:{Timeline}-)?{world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?(?:@{Turn})?";
public string UnitSpec => $"(?:{Type} )?{FullLocation}";
public const string HoldVerb = "(h|hold|holds)";
public const string MoveVerb = "(-|(?:->)|(?:=>)|(?:attack(?:s)?)|(?:move(?:s)?(?: to)?))";
public const string SupportVerb = "(s|support|supports)";
public const string ViaConvoy = "(convoy|via convoy|by convoy)";
public Regex UnitDeclaration = new(
$"^{world.Map.PowerRegex} {Type} {world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?$",
RegexOptions.IgnoreCase);
public static (
string power,
string type,
string province,
string location)
ParseUnitDeclaration(Match match) => (
match.Groups[1].Value,
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Value.Length > 0
? match.Groups[4].Value
: match.Groups[5].Value);
public Regex Hold => new(
$"^{UnitSpec} {HoldVerb}$",
RegexOptions.IgnoreCase);
public static (
string type,
string timeline,
string province,
string location,
string turn,
string holdVerb)
ParseHold(Match match) => (
match.Groups[1].Value,
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Length > 0
? match.Groups[4].Value
: match.Groups[5].Value,
match.Groups[6].Value,
match.Groups[7].Value);
public Regex Move => new(
$"^{UnitSpec} {MoveVerb} {FullLocation}(?: {ViaConvoy})?$",
RegexOptions.IgnoreCase);
public static (
string type,
string timeline,
string province,
string location,
string turn,
string moveVerb,
string destTimeline,
string destProvince,
string destLocation,
string destTurn,
string viaConvoy)
ParseMove(Match match) => (
match.Groups[1].Value,
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Length > 0
? match.Groups[4].Value
: match.Groups[5].Value,
match.Groups[6].Value,
match.Groups[7].Value,
match.Groups[8].Value,
match.Groups[9].Value,
match.Groups[10].Length > 0
? match.Groups[10].Value
: match.Groups[11].Value,
match.Groups[12].Value,
match.Groups[13].Value);
public Regex SupportHold => new(
$"^{UnitSpec} {SupportVerb} {UnitSpec}$",
RegexOptions.IgnoreCase);
public static (
string type,
string timeline,
string province,
string location,
string turn,
string supportVerb,
string targetType,
string targetTimeline,
string targetProvince,
string targetLocation,
string targetTurn)
ParseSupportHold(Match match) => (
match.Groups[1].Value,
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Length > 0
? match.Groups[4].Value
: match.Groups[5].Value,
match.Groups[6].Value,
match.Groups[7].Value,
match.Groups[8].Value,
match.Groups[9].Value,
match.Groups[10].Value,
match.Groups[11].Length > 0
? match.Groups[11].Value
: match.Groups[12].Value,
match.Groups[13].Value);
public Regex SupportMove => new(
$"{UnitSpec} {SupportVerb} {UnitSpec} {MoveVerb} {FullLocation}$",
RegexOptions.IgnoreCase);
public static (
string type,
string timeline,
string province,
string location,
string turn,
string supportVerb,
string targetType,
string targetTimeline,
string targetProvince,
string targetLocation,
string targetTurn,
string moveVerb,
string destTimeline,
string destProvince,
string destLocation,
string destTurn)
ParseSupportMove(Match match) => (
match.Groups[1].Value,
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Length > 0
? match.Groups[4].Value
: match.Groups[5].Value,
match.Groups[6].Value,
match.Groups[7].Value,
match.Groups[8].Value,
match.Groups[9].Value,
match.Groups[10].Value,
match.Groups[11].Length > 0
? match.Groups[11].Value
: match.Groups[12].Value,
match.Groups[13].Value,
match.Groups[14].Value,
match.Groups[15].Value,
match.Groups[16].Value,
match.Groups[17].Length > 0
? match.Groups[17].Value
: match.Groups[18].Value,
match.Groups[19].Value);
public static bool TryParseUnit(World world, string unitSpec, [NotNullWhen(true)] out Unit? newUnit)
{
newUnit = null;
OrderParser re = new(world);
Match match = re.UnitDeclaration.Match(unitSpec);
if (!match.Success) return false;
var unit = ParseUnitDeclaration(match);
string power = world.Map.Powers.First(p => p.EqualsAnyCase(unit.power));
string typeName = Enum.GetNames<UnitType>().First(name => name.StartsWithAnyCase(unit.type));
UnitType type = Enum.Parse<UnitType>(typeName);
Province province = world.Map.Provinces.First(prov => prov.Is(unit.province));
Location? location;
if (unit.location.Length > 0) {
location = province.Locations.FirstOrDefault(loc => loc!.Is(unit.location), null);
} else {
location = type switch {
UnitType.Army => province.Locations.FirstOrDefault(loc => loc.Type == LocationType.Land),
UnitType.Fleet => province.Locations.FirstOrDefault(loc => loc.Type == LocationType.Water),
_ => null,
};
}
if (location is null) return false;
newUnit = Unit.Build(location.Key, Season.First, power, type);
return true;
}
public static bool TryParseOrder(World world, string power, string command, [NotNullWhen(true)] out Order? order)
{
order = null;
OrderParser re = new(world);
if (re.Hold.Match(command) is Match holdMatch && holdMatch.Success) {
return TryParseHoldOrder(world, power, holdMatch, out order);
} else if (re.Move.Match(command) is Match moveMatch && moveMatch.Success) {
return TryParseMoveOrder(world, power, moveMatch, out order);
} else if (re.SupportHold.Match(command) is Match sholdMatch && sholdMatch.Success) {
return TryParseSupportHoldOrder(world, power, sholdMatch, out order);
} else if (re.SupportMove.Match(command) is Match smoveMatch && smoveMatch.Success) {
return TryParseSupportMoveOrder(world, power, smoveMatch, out order);
} else {
return false;
}
}
public static bool TryParseOrderSubject(
World world,
string parsedTimeline,
string parsedTurn,
string parsedProvince,
[NotNullWhen(true)] out Unit? subject)
{
subject = null;
string timeline = parsedTimeline.Length > 0
? parsedTimeline
// If timeline is unspecified, use the root timeline
: Season.First.Timeline;
var seasonsInTimeline = world.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return false;
int turn = parsedTurn.Length > 0
? int.Parse(parsedTurn)
// If turn is unspecified, use the latest turn in the timeline
: seasonsInTimeline.Max(season => season.Turn);
Province province = world.Map.Provinces.Single(province => province.Is(parsedProvince));
// Because only one unit can be in a province at a time, the province is sufficient to identify the subject
// and the location is ignored. This also satisfies DATC 4.B.5, which requires that a wrong coast for the
// subject be ignored.
subject = world.Units.FirstOrDefault(unit
=> world.Map.GetLocation(unit!.Location).ProvinceName == province.Name
&& unit!.Season.Timeline == timeline
&& unit!.Season.Turn == turn,
null);
return subject is not null;
}
public static bool TryParseHoldOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var hold = ParseHold(match);
if (!TryParseOrderSubject(world, hold.timeline, hold.turn, hold.province, out Unit? subject)) {
return false;
}
order = new HoldOrder(power, subject);
return true;
}
public static bool TryParseMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var move = ParseMove(match);
if (!TryParseOrderSubject(world, move.timeline, move.turn, move.province, out Unit? subject)) {
return false;
}
string destTimeline = move.destTimeline.Length > 0
? move.destTimeline
// If the destination is unspecified, use the unit's
: subject.Season.Timeline;
int destTurn = move.destTurn.Length > 0
? int.Parse(move.destTurn)
// If the destination is unspecified, use the unit's
: subject.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(move.destProvince));
string? destLocationKey = null;
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => subject.Type == UnitType.Army,
LocationType.Water => subject.Type == UnitType.Fleet,
_ => false,
});
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
if (destLocationKey is null) {
var matchingLocations = unitLocations.Where(loc => loc.Is(move.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(subject.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
}
// If the order is still ambiguous, fail per DATC 4.B.1.
if (destLocationKey is null) return false;
order = new MoveOrder(power, subject, new(destTimeline, destTurn), destLocationKey);
return true;
}
public static bool TryParseSupportHoldOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var support = ParseSupportHold(match);
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
order = new SupportHoldOrder(power, subject, target);
return true;
}
public static bool TryParseSupportMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var support = ParseSupportMove(match);
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
string destTimeline = support.destTimeline.Length > 0
? support.destTimeline
// If the destination is unspecified, use the target's
: target.Season.Timeline;
int destTurn = support.destTurn.Length > 0
? int.Parse(support.destTurn)
// If the destination is unspecified, use the unit's
: target.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(support.destProvince));
string? destLocationKey = null;
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => target.Type == UnitType.Army,
LocationType.Water => target.Type == UnitType.Fleet,
_ => false,
});
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
if (destLocationKey is null) {
var matchingLocations = unitLocations.Where(loc => loc.Is(support.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(target.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
}
// If the order is still ambiguous, fail per DATC 4.B.1. This also satisfies 4.B.4, which prefers for
// programmatic adjudicators with order validation to require the coasts instead of interpreting the ambiguous
// support by referring to the move order it supports.
if (destLocationKey is null) return false;
var destLocation = world.Map.GetLocation(destLocationKey);
order = new SupportMoveOrder(power, subject, target, new(destTimeline, destTurn), destLocation);
return true;
}
}

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

@ -31,13 +31,18 @@ public class Province
public IEnumerable<Location> Locations => LocationList;
private List<Location> LocationList { get; set; }
/// <summary>
/// The province's name and abbreviations as a single enumeration.
/// </summary>
public IEnumerable<string> AllNames => Abbreviations.Append(Name);
public Province(string name, string[] abbreviations, bool isSupply, bool isTime)
{
this.Name = name;
this.Abbreviations = abbreviations;
this.IsSupplyCenter = isSupply;
this.IsTimeCenter = isTime;
this.LocationList = new List<Location>();
this.LocationList = [];
}
public override string ToString()
@ -45,30 +50,36 @@ public class Province
return this.Name;
}
/// <summary>
/// Whether a name is the name or abbreviation of this province.
/// </summary>
public bool Is(string name)
=> name.EqualsAnyCase(Name) || Abbreviations.Any(name.EqualsAnyCase);
/// <summary>
/// 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 +89,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 +100,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,158 @@
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. Every season has an entry, so
/// the set of keys is the set of existing seasons.
/// </summary>
public Dictionary<string, Season?> Pasts { get; } = pasts;
/// <summary>
/// All seasons in the multiverse.
/// </summary>
[JsonIgnore]
public IEnumerable<Season> Seasons => Pasts.Keys.Select(key => new Season(key));
/// <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 state of the multiverse.
/// </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);
@ -174,7 +194,7 @@ public class World
{
return this.AddUnits(
"Austria A Bud",
"Austria A Vir",
"Austria A Vie",
"Austria F Tri",
"England A Lvp",
"England F Edi",
@ -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

@ -1,10 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionPrefix>0.0.2</VersionPrefix>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>MultiversalDiplomacyTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
</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>
@ -13,21 +15,11 @@ public class MoveOrder : UnitOrder
public Season Season { get; }
/// <summary>
/// The destination location to which the unit should move.
/// The destination province/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

@ -1,12 +1,32 @@
using System;
using CommandLine;
namespace MultiversalDiplomacy
using MultiversalDiplomacy.CommandLine;
namespace MultiversalDiplomacy;
internal class Program
{
internal class Program
[Verb("stab", HelpText = "stab")]
private class StabOptions
{
static void Main(string[] args)
{
Console.WriteLine("stab");
}
public static void Execute(StabOptions _)
=> Console.WriteLine("stab");
}
static void Main(string[] args)
{
var parser = Parser.Default;
var parseResult = parser.ParseArguments(
args,
typeof(AdjudicateOptions),
typeof(ImageOptions),
typeof(ReplOptions),
typeof(StabOptions));
parseResult
.WithParsed<AdjudicateOptions>(AdjudicateOptions.Execute)
.WithParsed<ImageOptions>(ImageOptions.Execute)
.WithParsed<ReplOptions>(ReplOptions.Execute)
.WithParsed<StabOptions>(StabOptions.Execute);
}
}

View File

@ -0,0 +1,276 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Script;
public class AdjudicationQueryScriptHandler(
Action<string> WriteLine,
List<OrderValidation> validations,
List<AdjudicationDecision> adjudications,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "valid> ";
public List<OrderValidation> Validations { get; } = validations;
public List<AdjudicationDecision> Adjudications { get; } = adjudications;
public World World { get; private set; } = world;
public ScriptResult HandleInput(string input)
{
var args = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0 || input.StartsWith('#'))
{
return ScriptResult.Succeed(this);
}
var command = args[0];
switch (command)
{
case "---":
WriteLine("Ready for orders");
return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator));
case "assert" when args.Length == 1:
WriteLine("Usage:");
break;
case "assert":
return EvaluateAssertion(args[1]);
case "status":
throw new NotImplementedException();
default:
return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
}
return ScriptResult.Succeed(this);
}
private ScriptResult EvaluateAssertion(string assertion)
{
var args = assertion.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
OrderParser re = new(World);
Regex prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
Match match;
string timeline;
IEnumerable<Season> seasonsInTimeline;
int turn;
Season season;
Province province;
switch (args[0])
{
case "true":
return ScriptResult.Succeed(this);
case "false":
return ScriptResult.Fail("assert false", this);
case "hold-order":
// The hold-order assertion primarily serves to verify that a unit's order was illegal in cases where
// a written non-hold order was rejected before order validation and replaced with a hold order.
match = prov.Match(args[1]);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingHolds = Validations.Where(val
=> val.Valid
&& val.Order is HoldOrder hold
&& hold.Unit.Season == season
&& World.Map.GetLocation(hold.Unit.Location).ProvinceName == province.Name);
if (!matchingHolds.Any()) return ScriptResult.Fail("No matching holds");
return ScriptResult.Succeed(this);
case "order-valid":
case "order-invalid":
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matching = Validations.Where(val
=> val.Order is UnitOrder order
&& order.Unit.Season == season
&& World.Map.GetLocation(order.Unit.Location).ProvinceName == province.Name);
if (!matching.Any()) return ScriptResult.Fail("No matching validations");
if (args[0] == "order-valid" && !matching.First().Valid) {
return ScriptResult.Fail($"Order \"{matching.First().Order} is invalid");
}
if (args[0] == "order-invalid" && matching.First().Valid) {
return ScriptResult.Fail($"Order \"{matching.First().Order} is valid");
}
return ScriptResult.Succeed(this);
case "has-past":
Regex hasPast = new($"^([a-z]+[0-9]+)>([a-z]+[0-9]+)$");
match = hasPast.Match(args[1]);
if (!match.Success) return ScriptResult.Fail("Expected format s1>s2", this);
Season future = new(match.Groups[1].Value);
if (!World.Timelines.Pasts.TryGetValue(future.Key, out Season? actual)) {
return ScriptResult.Fail($"No such season \"{future}\"");
}
Season expected = new(match.Groups[2].Value);
if (actual != expected) return ScriptResult.Fail(
$"Expected past of {future} to be {expected}, but it was {actual}");
return ScriptResult.Succeed(this);
case "not-dislodged":
case "dislodged":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingDislodges = Adjudications.Where(adj
=> adj is IsDislodged dislodge
&& dislodge.Order.Unit.Season == season
&& World.Map.GetLocation(dislodge.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingDislodges.Any()) return ScriptResult.Fail("No matching dislodge decisions");
var isDislodged = matchingDislodges.Cast<IsDislodged>().First();
if (args[0] == "not-dislodged" && isDislodged.Outcome != false) {
return ScriptResult.Fail($"Adjudication {isDislodged} is true");
}
if (args[0] == "dislodged" && isDislodged.Outcome != true) {
return ScriptResult.Fail($"Adjudication {isDislodged} is false");
}
return ScriptResult.Succeed(this);
case "moves":
case "no-move":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingMoves = Adjudications.Where(adj
=> adj is DoesMove moves
&& moves.Order.Unit.Season == season
&& World.Map.GetLocation(moves.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingMoves.Any()) return ScriptResult.Fail("No matching movement decisions");
var doesMove = matchingMoves.Cast<DoesMove>().First();
if (args[0] == "moves" && doesMove.Outcome != true) {
return ScriptResult.Fail($"Adjudication {doesMove} is false");
}
if (args[0] == "no-move" && doesMove.Outcome != false) {
return ScriptResult.Fail($"Adjudication {doesMove} is true");
}
return ScriptResult.Succeed(this);
case "support-given":
case "support-cut":
re = new(World);
prov = new($"^{re.FullLocation}$", RegexOptions.IgnoreCase);
match = prov.Match(args[1]);
if (!match.Success) return ScriptResult.Fail($"Could not parse province from \"{args[1]}\"", this);
timeline = match.Groups[1].Length > 0
? match.Groups[1].Value
: Season.First.Timeline;
seasonsInTimeline = World.Timelines.Seasons.Where(season => season.Timeline == timeline);
if (!seasonsInTimeline.Any()) return ScriptResult.Fail($"No seasons in timeline {timeline}", this);
turn = match.Groups[4].Length > 0
? int.Parse(match.Groups[4].Value)
// If turn is unspecified, use the second-latest turn in the timeline,
// since we want to assert against the subjects of the orders just adjudicated,
// and adjudication created a new set of seasons.
: seasonsInTimeline.Max(season => season.Turn) - 1;
season = new(timeline, turn);
province = World.Map.Provinces.Single(province => province.Is(match.Groups[2].Value));
var matchingSupports = Adjudications.Where(adj
=> adj is GivesSupport sup
&& sup.Order.Unit.Season == season
&& World.Map.GetLocation(sup.Order.Unit.Location).ProvinceName == province.Name);
if (!matchingSupports.Any()) return ScriptResult.Fail("No matching support decisions");
var supports = matchingSupports.Cast<GivesSupport>().First();
if (args[0] == "support-given" && supports.Outcome != true) {
return ScriptResult.Fail($"Adjudication {supports} is false");
}
if (args[0] == "support-cut" && supports.Outcome != false) {
return ScriptResult.Fail($"Adjudication {supports} is true");
}
return ScriptResult.Succeed(this);
default:
return ScriptResult.Fail($"Unknown assertion \"{args[0]}\"", this);
}
}
}

View File

@ -0,0 +1,90 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Script;
public class GameScriptHandler(
Action<string> WriteLine,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "orders> ";
public World World { get; private set; } = world;
private string? CurrentPower { get; set; } = null;
public List<Order> Orders { get; } = [];
public ScriptResult HandleInput(string input)
{
if (input == "") {
CurrentPower = null;
return ScriptResult.Succeed(this);
}
if (input.StartsWith('#')) return ScriptResult.Succeed(this);
// "---" submits the orders and allows queries about the outcome
if (input == "---") {
WriteLine("Submitting orders for adjudication");
var validation = adjudicator.ValidateOrders(World, Orders);
var validOrders = validation
.Where(v => v.Valid)
.Select(v => v.Order)
.ToList();
var adjudication = adjudicator.AdjudicateOrders(World, validOrders);
var newWorld = adjudicator.UpdateWorld(World, adjudication);
return ScriptResult.Succeed(new AdjudicationQueryScriptHandler(
WriteLine, validation, adjudication, newWorld, adjudicator));
}
// "===" submits the orders and moves immediately to taking the next set of orders
// i.e. it's "---" twice
if (input == "===") {
WriteLine("Submitting orders for adjudication");
var validation = adjudicator.ValidateOrders(World, Orders);
var validOrders = validation
.Where(v => v.Valid)
.Select(v => v.Order)
.ToList();
var adjudication = adjudicator.AdjudicateOrders(World, validOrders);
World = adjudicator.UpdateWorld(World, adjudication);
WriteLine("Ready for orders");
return ScriptResult.Succeed(this);
}
// A block of orders for a single power beginning with "{name}:"
if (World.Powers.FirstOrDefault(p => input.EqualsAnyCase($"{p}:"), null) is string power) {
CurrentPower = power;
return ScriptResult.Succeed(this);
}
// If it's not a comment, submit, or order block, assume it's an order.
string orderPower;
string orderText;
if (CurrentPower is not null) {
// In a block of orders from a power, the power was specified at the top and each line is an order.
orderPower = CurrentPower;
orderText = input;
} else {
// Outside a power block, the power is prefixed to each order.
Regex re = new($"^{World.Map.PowerRegex}(?:[:])? (.*)$", RegexOptions.IgnoreCase);
var match = re.Match(input);
if (!match.Success) return ScriptResult.Fail($"Could not determine ordering power in \"{input}\"", this);
orderPower = match.Groups[1].Value;
orderText = match.Groups[2].Value;
}
if (OrderParser.TryParseOrder(World, orderPower, orderText, out Order? order)) {
WriteLine($"Parsed {orderPower} order: {order}");
Orders.Add(order);
return ScriptResult.Succeed(this);
}
return ScriptResult.Fail($"Failed to parse \"{orderText}\"", this);
}
}

View File

@ -0,0 +1,18 @@
namespace MultiversalDiplomacy.Script;
/// <summary>
/// A handler that interprets and executes 5dp script commands. Script handlers may create additional script handlers
/// and delegate handling to them, allowing a sort of recursive parsing of script commands.
/// </summary>
public interface IScriptHandler
{
/// <summary>
/// When used interactively, the prompt that should be displayed.
/// </summary>
public string Prompt { get; }
/// <summary>
/// Process a line of input.
/// </summary>
public ScriptResult HandleInput(string input);
}

View File

@ -0,0 +1,64 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
/// <summary>
/// A script handler for the interactive repl.
/// </summary>
public class ReplScriptHandler(Action<string> WriteLine) : IScriptHandler
{
public string Prompt => "5dp> ";
public ScriptResult HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0 || input.StartsWith('#'))
{
return ScriptResult.Succeed(this);
}
var command = args[0];
switch (command)
{
case "help":
case "?":
WriteLine("Commands:");
WriteLine(" help, ?: print this message");
WriteLine(" map <variant>: start a new game of the given variant");
WriteLine(" stab: stab");
break;
case "stab":
WriteLine("stab");
break;
case "map" when args.Length == 1:
WriteLine("Usage:");
WriteLine(" map <variant>");
WriteLine("Available variants:");
WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
break;
case "map" when args.Length > 1:
string mapType = args[1].Trim();
if (!Enum.TryParse(mapType, ignoreCase: true, out MapType map)) {
WriteLine($"Unknown variant \"{mapType}\"");
WriteLine("Available variants:");
WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
break;
}
World world = World.WithMap(Map.FromType(map));
WriteLine($"Created a new {map} game");
return ScriptResult.Succeed(new SetupScriptHandler(
WriteLine,
world,
MovementPhaseAdjudicator.Instance));
default:
return ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
}
return ScriptResult.Succeed(this);
}
}

View File

@ -0,0 +1,36 @@
namespace MultiversalDiplomacy.Script;
/// <summary>
/// The result of an <see cref="IScriptHandler"/> processing a line of input.
/// </summary>
/// <param name="success">Whether processing was successful.</param>
/// <param name="next">The handler to continue script processing with.</param>
/// <param name="message">If processing failed, the error message.</param>
public class ScriptResult(bool success, IScriptHandler? next, string message)
{
/// <summary>
/// Whether processing was successful.
/// </summary>
public bool Success { get; } = success;
/// <summary>
/// The handler to continue script processing with.
/// </summary>
public IScriptHandler? NextHandler { get; } = next;
/// <summary>
/// If processing failed, the error message.
/// </summary>
public string Message { get; } = message;
/// <summary>
/// Mark the processing as successful and continue processing with the next handler.
/// </summary>
public static ScriptResult Succeed(IScriptHandler next) => new(true, next, "");
/// <summary>
/// Mark the processing as a failure and optionally continue with the next handler.
/// </summary>
/// <param name="message">The reason for the processing failure.</param>
public static ScriptResult Fail(string message, IScriptHandler? next = null) => new(false, next, message);
}

View File

@ -0,0 +1,91 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
/// <summary>
/// A script handler for modifying a game before it begins.
/// </summary>
public class SetupScriptHandler(
Action<string> WriteLine,
World world,
IPhaseAdjudicator adjudicator)
: IScriptHandler
{
public string Prompt => "setup> ";
public World World { get; private set; } = world;
public ScriptResult HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0 || input.StartsWith('#'))
{
return ScriptResult.Succeed(this);
}
var command = args[0];
switch (command)
{
case "help":
case "?":
WriteLine("commands:");
WriteLine(" begin: complete setup and start the game (alias: ---)");
WriteLine(" list <type>: list things in a game category");
WriteLine(" option <name> <value>: set a game option");
WriteLine(" unit <power> <type> <province> [location]: add a unit to the game");
WriteLine(" <province> may be \"province/location\"");
break;
case "begin":
case "---":
WriteLine("Starting game");
WriteLine("Ready for orders");
return ScriptResult.Succeed(new GameScriptHandler(WriteLine, World, adjudicator));
case "list" when args.Length == 1:
WriteLine("usage:");
WriteLine(" list powers: the powers in the game");
WriteLine(" list units: units created so far");
break;
case "list" when args[1] == "powers":
WriteLine("Powers:");
foreach (string powerName in World.Powers)
{
WriteLine($" {powerName}");
}
break;
case "list" when args[1] == "units":
WriteLine("Units:");
foreach (Unit unit in World.Units)
{
WriteLine($" {unit}");
}
break;
case "option" when args.Length < 3:
throw new NotImplementedException("There are no supported options yet");
case "unit" when args.Length < 2:
WriteLine("usage: unit [power] [type] [province]</location>");
break;
case "unit":
string unitSpec = input["unit ".Length..];
if (OrderParser.TryParseUnit(World, unitSpec, out Unit? newUnit)) {
World = World.Update(units: World.Units.Append(newUnit));
WriteLine($"Created {newUnit}");
return ScriptResult.Succeed(this);
}
return ScriptResult.Fail($"Could not match unit spec \"{unitSpec}\"", this);
default:
ScriptResult.Fail($"Unrecognized command: \"{command}\"", this);
break;
}
return ScriptResult.Succeed(this);
}
}

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

@ -0,0 +1,8 @@
using MultiversalDiplomacy.Adjudicate;
namespace MultiversalDiplomacyTests;
public static class Adjudicator
{
public static MovementPhaseAdjudicator MovementPhase { get; } = new MovementPhaseAdjudicator(NullLogger.Instance);
}

View File

@ -1,223 +0,0 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
public class DATC_A
{
private World StandardEmpty { get; } = World.WithStandardMap();
[Test]
public void DATC_6_A_1_MoveToAnAreaThatIsNotANeighbor()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup["England"]
.Fleet("North Sea").MovesTo("Picardy").GetReference(out var order);
// Order should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(order, Is.Invalid(ValidationReason.UnreachableDestination));
}
[Test]
public void DATC_6_A_2_MoveArmyToSea()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
// Order should fail.
Assert.That(
() =>
{
setup["England"]
.Army("Liverpool").MovesTo("Irish Sea");
},
Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public void DATC_6_A_3_MoveFleetToLand()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
// Order should fail.
Assert.That(
() =>
{
setup["Germany"]
.Fleet("Kiel").MovesTo("Munich");
},
Throws.TypeOf<KeyNotFoundException>());
}
[Test]
public void DATC_6_A_4_MoveToOwnSector()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup["Germany"]
.Fleet("Kiel").MovesTo("Kiel").GetReference(out var order);
// Program should not crash.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(order, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
}
[Test]
public void DATC_6_A_5_MoveToOwnSectorWithConvoy()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["England"]
.Fleet("North Sea").Convoys.Army("Yorkshire").To("Yorkshire").GetReference(out var orderNth)
.Army("Yorkshire").MovesTo("Yorkshire").GetReference(out var orderYor)
.Army("Liverpool").Supports.Army("Yorkshire").MoveTo("Yorkshire")
["Germany"]
.Fleet("London").MovesTo("Yorkshire").GetReference(out var orderLon)
.Army("Wales").Supports.Fleet("London").MoveTo("Yorkshire");
// The move of the army in Yorkshire is illegal. This makes the support of Liverpool also illegal.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderLon, Is.Valid);
Assert.That(orderNth, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
Assert.That(orderYor, Is.Invalid(ValidationReason.DestinationMatchesOrigin));
var orderYorRepl = orderYor.GetReplacementReference<HoldOrder>();
// Without the support, the Germans have a stronger force. The army in London dislodges the army in Yorkshire.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderLon, Is.Victorious);
Assert.That(orderYorRepl, Is.Dislodged);
}
[Test]
public void DATC_6_A_6_OrderingAUnitOfAnotherCountry()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Germany"]
.Fleet("London", powerName: "England").MovesTo("North Sea").GetReference(out var order);
// Order should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(order, Is.Invalid(ValidationReason.InvalidUnitForPower));
}
[Test]
public void DATC_6_A_7_OnlyArmiesCanBeConvoyed()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["England"]
.Fleet("London").MovesTo("Belgium")
.Fleet("North Sea").Convoys.Army("London").To("Belgium").GetReference(out var order);
// Move from London to Belgium should fail.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(order, Is.Invalid(ValidationReason.InvalidOrderTypeForUnit));
}
[Test]
public void DATC_6_A_8_SupportToHoldYourselfIsNotPossible()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Italy"]
.Army("Venice").MovesTo("Trieste")
.Army("Tyrolia").Supports.Army("Venice").MoveTo("Trieste")
["Austria"]
.Fleet("Trieste").Supports.Fleet("Trieste").Hold().GetReference(out var orderTri);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderTri, Is.Invalid(ValidationReason.NoSelfSupport));
var orderTriRepl = orderTri.GetReplacementReference<HoldOrder>();
// The army in Trieste should be dislodged.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderTriRepl, Is.Dislodged);
}
[Test]
public void DATC_6_A_9_FleetsMustFollowCoastIfNotOnSea()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Italy"]
.Fleet("Rome").MovesTo("Venice").GetReference(out var order);
// Move fails. An army can go from Rome to Venice, but a fleet can not.
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(order, Is.Invalid(ValidationReason.UnreachableDestination));
}
[Test]
public void DATC_6_A_10_SupportOnUnreachableDestinationNotPossible()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Austria"]
.Army("Venice").Holds().GetReference(out var orderVen)
["Italy"]
.Army("Apulia").MovesTo("Venice")
.Fleet("Rome").Supports.Army("Apulia").MoveTo("Venice").GetReference(out var orderRom);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
// The support of Rome is illegal, because Venice can not be reached from Rome by a fleet.
Assert.That(orderRom, Is.Invalid(ValidationReason.UnreachableSupport));
// Venice is not dislodged.
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderVen, Is.NotDislodged);
}
[Test]
public void DATC_6_A_11_SimpleBounce()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Austria"]
.Army("Vienna").MovesTo("Tyrolia").GetReference(out var orderVie)
["Italy"]
.Army("Venice").MovesTo("Tyrolia").GetReference(out var orderVen);
setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderVie, Is.Valid);
Assert.That(orderVen, Is.Valid);
// The two units bounce.
var adjudications = setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderVie, Is.Repelled);
Assert.That(orderVie, Is.NotDislodged);
Assert.That(orderVen, Is.Repelled);
Assert.That(orderVen, Is.NotDislodged);
}
[Test]
public void DATC_6_A_12_BounceOfThreeUnits()
{
TestCaseBuilder setup = new TestCaseBuilder(StandardEmpty);
setup
["Austria"]
.Army("Vienna").MovesTo("Tyrolia").GetReference(out var orderVie)
["Germany"]
.Army("Munich").MovesTo("Tyrolia").GetReference(out var orderMun)
["Italy"]
.Army("Venice").MovesTo("Tyrolia").GetReference(out var orderVen);
var validations = setup.ValidateOrders(MovementPhaseAdjudicator.Instance);
Assert.That(orderVie, Is.Valid);
Assert.That(orderMun, Is.Valid);
Assert.That(orderVen, Is.Valid);
var adjudications = setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
// The three units bounce.
Assert.That(orderVie, Is.Repelled);
Assert.That(orderVie, Is.NotDislodged);
Assert.That(orderMun, Is.Repelled);
Assert.That(orderMun, Is.NotDislodged);
Assert.That(orderVen, Is.Repelled);
Assert.That(orderVen, Is.NotDislodged);
}
}

View File

@ -1,7 +1,8 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using static MultiversalDiplomacyTests.Adjudicator;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
@ -11,15 +12,15 @@ public class TimeTravelTest
[Test]
public void MDATC_3_A_1_MoveIntoOwnPastForksTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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,34 +34,34 @@ 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.Seasons.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.Seasons.Where(s => s.Timeline != s0.Timeline).Count(),
Is.EqualTo(1),
"Failed to fork timeline when unit moved in");
// Confirm that there is a unit in Tyr 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]
public void MDATC_3_A_2_SupportToRepelledPastMoveForksTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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 +76,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,33 +92,33 @@ 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]
public void MDATC_3_A_3_FailedMoveDoesNotForkTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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 +129,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,21 +137,21 @@ 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]
public void MDATC_3_A_4_SuperfluousSupportDoesNotForkTimeline()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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 +163,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,130 +179,130 @@ 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]
public void MDATC_3_A_5_CrossTimelineSupportDoesNotForkHead()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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]
public void MDATC_3_A_6_CuttingCrossTimelineSupportDoesNotFork()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
// 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

@ -1,9 +1,10 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using NUnit.Framework;
using static MultiversalDiplomacyTests.Adjudicator;
namespace MultiversalDiplomacyTests;
public class MovementAdjudicatorTest
@ -11,7 +12,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var order);
@ -24,7 +25,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var order);
@ -37,7 +38,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidConvoy()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Fleet("Nth").Convoys.Army("Hol").To("Lon").GetReference(out var order);
@ -50,7 +51,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidSupportHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Supports.Army("Kie").Hold().GetReference(out var order);
@ -63,7 +64,7 @@ public class MovementAdjudicatorTest
[Test]
public void Validation_ValidSupportMove()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Supports.Army("Kie").MoveTo("Ber").GetReference(out var order);
@ -76,12 +77,12 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Hold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var order);
setup.ValidateOrders();
setup.AdjudicateOrders(MovementPhaseAdjudicator.Instance);
setup.AdjudicateOrders(MovementPhase);
var adjMun = order.Adjudications;
Assert.That(adjMun.All(adj => adj.Resolved), Is.True);
@ -96,7 +97,7 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Move()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var order);
@ -122,7 +123,7 @@ public class MovementAdjudicatorTest
[Test]
public void Adjudication_Support()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var move)
.Army("Boh").Supports.Army("Mun").MoveTo("Tyr").GetReference(out var support);
@ -156,7 +157,7 @@ public class MovementAdjudicatorTest
[Test]
public void Update_SingleHold()
{
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhaseAdjudicator.Instance);
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup["Germany"]
.Army("Mun").Holds().GetReference(out var mun);
@ -168,25 +169,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)]
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").Holds().GetReference(out var mun1);
@ -199,20 +198,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 +226,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)]
TestCaseBuilder setup = new(World.WithStandardMap(), MovementPhase);
setup[("a", 0)]
.GetReference(out Season s1)
["Germany"]
.Army("Mun").MovesTo("Tyr").GetReference(out var mun1);
@ -249,20 +248,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 +276,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

@ -18,4 +18,10 @@
<PackageReference Include="coverlet.collector" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
<Content Include="Scripts/**/*.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -0,0 +1,10 @@
using MultiversalDiplomacy.Adjudicate.Logging;
namespace MultiversalDiplomacyTests;
public class NullLogger : IAdjudicatorLogger
{
public static NullLogger Instance { get; } = new();
public void Log(int contextLevel, string message, params object[] args) {}
}

View File

@ -0,0 +1,335 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacyTests;
public class OrderParserTest
{
private static TestCaseData Test(string order, params string[] expected)
=> new TestCaseData(order, expected).SetName($"{{m}}(\"{order}\")");
static IEnumerable<TestCaseData> HoldRegexMatchesTestCases()
{
// Full specification
yield return Test(
"Army a-Munich/l@0 holds",
"Army", "a", "Munich", "l", "0", "holds");
// Case insensitivity
yield return Test(
"fleet B-lon/C@0 H",
"fleet", "B", "lon", "C", "0", "H");
// All optionals missing
yield return Test(
"ROM h",
"", "", "ROM", "", "", "h");
// No confusion of unit type and timeline
yield return Test(
"A F-STP hold",
"A", "F", "STP", "", "", "hold");
// Province with space in name
yield return Test(
"Fleet North Sea Hold",
"Fleet", "", "North Sea", "", "", "Hold");
// Parenthesis location
yield return Test(
"F Spain(nc) holds",
"F", "", "Spain", "nc", "", "holds");
}
[TestCaseSource(nameof(HoldRegexMatchesTestCases))]
public void HoldRegexMatches(string order, string[] expected)
{
OrderParser re = new(World.WithStandardMap());
var match = re.Hold.Match(order);
Assert.True(match.Success, "Match failed");
var (type, timeline, province, location, turn, holdVerb) = OrderParser.ParseHold(match);
string[] actual = [type, timeline, province, location, turn, holdVerb];
// Use EquivalentTo for more detailed error message
Assert.That(actual, Is.EquivalentTo(expected), "Unexpected parse results");
Assert.That(actual, Is.EqualTo(expected), "Unexpected parse results");
}
static IEnumerable<TestCaseData> MoveRegexMatchesTestCases()
{
// Full specification
yield return Test(
"Army a-Munich/l@0 - a-Tyrolia/l@0",
"Army", "a", "Munich", "l", "0", "-", "a", "Tyrolia", "l", "0", "");
// Case insensitivity
yield return Test(
"fleet B-lon/C@0 - B-enc/W@0",
"fleet", "B", "lon", "C", "0", "-", "B", "enc", "W", "0", "");
// All optionals missing
yield return Test(
"ROM - VIE",
"", "", "ROM", "", "", "-", "", "VIE", "", "", "");
// No confusion of unit type and timeline
yield return Test(
"A F-STP - MOS",
"A", "F", "STP", "", "", "-", "", "MOS", "", "", "");
// No confusion of timeline and hold verb
yield return Test(
"A Mun - h-Tyr",
"A", "", "Mun", "", "", "-", "h", "Tyr", "", "", "");
// No confusion of timeline and support verb
yield return Test(
"A Mun - s-Tyr",
"A", "", "Mun", "", "", "-", "s", "Tyr", "", "", "");
// Elements with spaces
yield return Test(
"Western Mediterranean Sea moves to Gulf of Lyons via convoy",
"", "", "Western Mediterranean Sea", "", "", "moves to", "", "Gulf of Lyons", "", "", "via convoy");
// Parenthesis location
yield return Test(
"F Spain(nc) - Spain(sc)",
"F", "", "Spain", "nc", "", "-", "", "Spain", "sc", "", "");
// Timeline designation spells out a province
yield return Test(
"A tyr-MUN(vie) - mun-TYR/vie",
"A", "tyr", "MUN", "vie", "", "-", "mun", "TYR", "vie", "", "");
}
[TestCaseSource(nameof(MoveRegexMatchesTestCases))]
public void MoveRegexMatches(string order, string[] expected)
{
OrderParser re = new(World.WithStandardMap());
var match = re.Move.Match(order);
Assert.True(match.Success, "Match failed");
var (type, timeline, province, location, turn, moveVerb,
destTimeline, destProvince, destLocation, destTurn, viaConvoy) = OrderParser.ParseMove(match);
string[] actual = [type, timeline, province, location, turn, moveVerb,
destTimeline, destProvince, destLocation, destTurn, viaConvoy];
// Use EquivalentTo for more detailed error message
Assert.That(actual, Is.EquivalentTo(expected), "Unexpected parse results");
Assert.That(actual, Is.EqualTo(expected), "Unexpected parse results");
}
static IEnumerable<TestCaseData> SupportHoldRegexMatchesTestCases()
{
// Full specification
yield return Test(
"Army a-Munich/l@0 s A a-Tyrolia/l@0",
"Army", "a", "Munich", "l", "0", "s", "A", "a", "Tyrolia", "l", "0");
// Case insensitivity
yield return Test(
"fleet B-lon/C@0 SUPPORTS B-enc/W@0",
"fleet", "B", "lon", "C", "0", "SUPPORTS", "", "B", "enc", "W", "0");
// All optionals missing
yield return Test(
"ROM s VIE",
"", "", "ROM", "", "", "s", "", "", "VIE", "", "");
// No confusion of unit type and timeline
yield return Test(
"A F-STP s MOS",
"A", "F", "STP", "", "", "s", "", "", "MOS", "", "");
// No confusion of timeline and support verb
yield return Test(
"A Mun s Tyr",
"A", "", "Mun", "", "", "s", "", "", "Tyr", "", "");
// Elements with spaces
yield return Test(
"Western Mediterranean Sea supports Gulf of Lyons",
"", "", "Western Mediterranean Sea", "", "", "supports", "", "", "Gulf of Lyons", "", "");
// Parenthesis location
yield return Test(
"F Spain(nc) s Spain(sc)",
"F", "", "Spain", "nc", "", "s", "", "", "Spain", "sc", "");
// Timeline designation spells out a province
yield return Test(
"A tyr-MUN(vie) s mun-TYR/vie",
"A", "tyr", "MUN", "vie", "", "s", "", "mun", "TYR", "vie", "");
}
[TestCaseSource(nameof(SupportHoldRegexMatchesTestCases))]
public void SupportHoldRegexMatches(string order, string[] expected)
{
OrderParser re = new(World.WithStandardMap());
var match = re.SupportHold.Match(order);
Assert.True(match.Success, "Match failed");
var (type, timeline, province, location, turn, supportVerb,
targetType, targetTimeline, targetProvince, targetLocation, targetTurn) = OrderParser.ParseSupportHold(match);
string[] actual = [type, timeline, province, location, turn, supportVerb,
targetType, targetTimeline, targetProvince, targetLocation, targetTurn];
// Use EquivalentTo for more detailed error message
Assert.That(actual, Is.EquivalentTo(expected), "Unexpected parse results");
Assert.That(actual, Is.EqualTo(expected), "Unexpected parse results");
}
static IEnumerable<TestCaseData> SupportMoveRegexMatchesTestCases()
{
// Full specification
yield return Test(
"Army a-Munich/l@0 s A a-Tyrolia/l@0 - a-Vienna/l@0",
"Army", "a", "Munich", "l", "0", "s", "A", "a", "Tyrolia", "l", "0", "-", "a", "Vienna", "l", "0");
// Case insensitivity
yield return Test(
"fleet B-lon/C@0 SUPPORTS B-enc/W@0 MOVE TO B-nts/W@0",
"fleet", "B", "lon", "C", "0", "SUPPORTS", "", "B", "enc", "W", "0", "MOVE TO", "B", "nts", "W", "0");
// All optionals missing
yield return Test(
"ROM s VIE - TYR",
"", "", "ROM", "", "", "s", "", "", "VIE", "", "", "-", "", "TYR", "", "");
// No confusion of unit type and timeline
yield return Test(
"A F-STP S MOS - A-UKR",
"A", "F", "STP", "", "", "S", "", "", "MOS", "", "", "-", "A", "UKR", "", "");
// Elements with spaces
yield return Test(
"Western Mediterranean Sea supports Gulf of Lyons move to North Sea",
"", "", "Western Mediterranean Sea", "", "", "supports", "", "", "Gulf of Lyons", "", "", "move to", "", "North Sea", "", "");
}
[TestCaseSource(nameof(SupportMoveRegexMatchesTestCases))]
public void SupportMoveRegexMatches(string order, string[] expected)
{
OrderParser re = new(World.WithStandardMap());
var match = re.SupportMove.Match(order);
Assert.True(match.Success, "Match failed");
var (type, timeline, province, location, turn, supportVerb,
targetType, targetTimeline, targetProvince, targetLocation, targetTurn, moveVerb,
destTimeline, destProvince, destLocation, destTurn) = OrderParser.ParseSupportMove(match);
string[] actual = [type, timeline, province, location, turn, supportVerb,
targetType, targetTimeline, targetProvince, targetLocation, targetTurn, moveVerb,
destTimeline, destProvince, destLocation, destTurn];
// Use EquivalentTo for more detailed error message
Assert.That(actual, Is.EquivalentTo(expected), "Unexpected parse results");
Assert.That(actual, Is.EqualTo(expected), "Unexpected parse results");
}
[Test]
public void OrderParsingTest()
{
World world = World.WithStandardMap().AddUnits("Germany A Mun");
OrderParser re = new(world);
var match = re.Move.Match("A Mun - Tyr");
var success = OrderParser.TryParseMoveOrder(world, "Germany", match, out Order? order);
Assert.That(success, Is.True);
Assert.That(order, Is.TypeOf<MoveOrder>());
MoveOrder move = (MoveOrder)order!;
Assert.That(move.Power, Is.EqualTo("Germany"));
Assert.That(move.Unit.Key, Is.EqualTo("A a-Munich/l@0"));
Assert.That(move.Location, Is.EqualTo("Tyrolia/l"));
Assert.That(move.Season.Key, Is.EqualTo("a0"));
}
[Test]
public void OrderDisambiguation()
{
World world = World.WithStandardMap().AddUnits("Germany A Mun");
OrderParser.TryParseOrder(world, "Germany", "Mun h", out Order? parsed);
Assert.That(parsed?.ToString(), Is.EqualTo("G A a-Munich/l@0 holds"));
}
[Test]
public void UnitTypeDisambiguatesCoastalLocation()
{
World world = World.WithStandardMap().AddUnits("England F Nth", "Germany A Ruhr");
Assert.That(
OrderParser.TryParseOrder(world, "England", "North Sea - Holland", out Order? fleetOrder),
"Failed to parse fleet order");
Assert.That(
OrderParser.TryParseOrder(world, "Germany", "Ruhr - Holland", out Order? armyOrder),
"Failed to parse army order");
Assert.That(fleetOrder, Is.TypeOf<MoveOrder>(), "Unexpected fleet order");
Assert.That(armyOrder, Is.TypeOf<MoveOrder>(), "Unexpected army order");
Location fleetDest = world.Map.GetLocation(((MoveOrder)fleetOrder!).Location);
Location armyDest = world.Map.GetLocation(((MoveOrder)armyOrder!).Location);
Assert.That(fleetDest.ProvinceName, Is.EqualTo(armyDest.ProvinceName));
Assert.That(fleetDest.Type, Is.EqualTo(LocationType.Water), "Unexpected fleet movement location");
Assert.That(armyDest.Type, Is.EqualTo(LocationType.Land), "Unexpected army movement location");
}
[Test]
public void UnitTypeOverrulesNonsenseLocation()
{
World world = World.WithStandardMap().AddUnits("England F Nth", "Germany A Ruhr");
Assert.That(
OrderParser.TryParseOrder(world, "England", "F North Sea - Holland/l", out Order? fleetOrder),
"Failed to parse fleet order");
Assert.That(
OrderParser.TryParseOrder(world, "Germany", "A Ruhr - Holland/w", out Order? armyOrder),
"Failed to parse army order");
Assert.That(fleetOrder, Is.TypeOf<MoveOrder>(), "Unexpected fleet order");
Assert.That(armyOrder, Is.TypeOf<MoveOrder>(), "Unexpected army order");
Location fleetDest = world.Map.GetLocation(((MoveOrder)fleetOrder!).Location);
Location armyDest = world.Map.GetLocation(((MoveOrder)armyOrder!).Location);
Assert.That(fleetDest.ProvinceName, Is.EqualTo(armyDest.ProvinceName));
Assert.That(fleetDest.Type, Is.EqualTo(LocationType.Water), "Unexpected fleet movement location");
Assert.That(armyDest.Type, Is.EqualTo(LocationType.Land), "Unexpected army movement location");
}
[Test]
public void DisambiguateSingleAccessibleCoast()
{
World world = World.WithStandardMap().AddUnits("France F Gascony", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Gascony - Spain", out Order? northOrder),
"Failed to parse north coast order");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles - Spain", out Order? southOrder),
"Failed to parse south coast order");
Assert.That(northOrder, Is.TypeOf<MoveOrder>(), "Unexpected north coast order");
Assert.That(southOrder, Is.TypeOf<MoveOrder>(), "Unexpected south coast order");
Location north = world.Map.GetLocation(((MoveOrder)northOrder!).Location);
Location south = world.Map.GetLocation(((MoveOrder)southOrder!).Location);
Assert.That(north.Name, Is.EqualTo("north coast"), "Unexpected disambiguation");
Assert.That(south.Name, Is.EqualTo("south coast"), "Unexpected disambiguation");
}
[Test]
public void DisambiguateMultipleAccessibleCoasts()
{
World world = World.WithStandardMap().AddUnits("France F Portugal");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Portugal - Spain", out Order? _),
Is.False,
"Should not parse ambiguous coastal move");
}
[Test]
public void DisambiguateSupportToSingleAccessibleCoast()
{
World world = World.WithStandardMap().AddUnits("France F Gascony", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Gascony S Marseilles - Spain", out Order? northOrder),
"Failed to parse north coast order");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles S Gascony - Spain", out Order? southOrder),
"Failed to parse south coast order");
Assert.That(northOrder, Is.TypeOf<SupportMoveOrder>(), "Unexpected north coast order");
Assert.That(southOrder, Is.TypeOf<SupportMoveOrder>(), "Unexpected south coast order");
Location northTarget = ((SupportMoveOrder)northOrder!).Location;
Location southTarget = ((SupportMoveOrder)southOrder!).Location;
Assert.That(northTarget.Name, Is.EqualTo("south coast"), "Unexpected disambiguation");
Assert.That(southTarget.Name, Is.EqualTo("north coast"), "Unexpected disambiguation");
}
[Test]
public void DisambiguateSupportToMultipleAccessibleCoasts()
{
World world = World.WithStandardMap().AddUnits("France F Portugal", "France F Marseilles");
Assert.That(
OrderParser.TryParseOrder(world, "France", "Marseilles S Portugal - Spain", out Order? _),
Is.False,
"Should not parse ambiguous coastal support");
}
}

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

@ -0,0 +1,48 @@
using NUnit.Framework;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacyTests;
public class ReplDriver(IScriptHandler initialHandler, bool echo = false)
{
public IScriptHandler? Handler { get; private set; } = initialHandler;
private string? LastInput { get; set; } = "";
/// <summary>
/// Whether to print the inputs as they are executed. This is primarily a debugging aid.
/// </summary>
bool Echo { get; } = echo;
public ReplDriver ExecuteAll(string multiline)
{
var lines = multiline.Split('\n', StringSplitOptions.TrimEntries);
return lines.Aggregate(this, (repl, line) => repl.Execute(line));
}
public ReplDriver Execute(string inputLine)
{
if (Handler is null) throw new AssertionException(
$"Cannot execute \"{inputLine}\", handler quit. Last input was \"{LastInput}\"");
if (Echo) Console.WriteLine($"{Handler.Prompt}{inputLine}");
var result = Handler.HandleInput(inputLine);
if (!result.Success) Assert.Fail($"Script failed at \"{inputLine}\": {result.Message}");
Handler = result.NextHandler;
LastInput = inputLine;
return this;
}
public void AssertFails(string inputLine)
{
if (Handler is null) throw new AssertionException(
$"Cannot execute \"{inputLine}\", handler quit. Last input was \"{LastInput}\"");
if (Echo) Console.WriteLine($"{Handler.Prompt}{inputLine}");
var result = Handler.HandleInput(inputLine);
if (result.Success) Assert.Fail($"Expected \"{inputLine}\" to fail, but it succeeded.");
}
}

View File

@ -0,0 +1,276 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacyTests;
public class ReplTest
{
private static ReplDriver StandardRepl() => new(
new SetupScriptHandler(
(msg) => {/* discard */},
World.WithStandardMap(),
Adjudicator.MovementPhase));
[Test]
public void SetupHandler()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Munich
unit Austria Army Tyrolia
unit England F Lon
""");
Assert.That(repl.Handler, Is.TypeOf<SetupScriptHandler>());
SetupScriptHandler handler = (SetupScriptHandler)repl.Handler!;
Assert.That(handler.World.Units.Count, Is.EqualTo(3));
Assert.That(handler.World.GetUnitAt("Mun"), Is.Not.Null);
Assert.That(handler.World.GetUnitAt("Tyr"), Is.Not.Null);
Assert.That(handler.World.GetUnitAt("Lon"), Is.Not.Null);
repl.Execute("---");
Assert.That(repl.Handler, Is.TypeOf<GameScriptHandler>());
}
[Test]
public void SubmitOrders()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Austria A Tyr
unit England F Lon
---
Germany A Mun hold
Austria: Army Tyrolia - Vienna
England:
Lon h
""");
Assert.That(repl.Handler, Is.TypeOf<GameScriptHandler>());
GameScriptHandler handler = (GameScriptHandler)repl.Handler!;
Assert.That(handler.Orders.Count, Is.EqualTo(3));
Assert.That(handler.Orders.Single(o => o.Power == "Germany"), Is.TypeOf<HoldOrder>());
Assert.That(handler.Orders.Single(o => o.Power == "Austria"), Is.TypeOf<MoveOrder>());
Assert.That(handler.Orders.Single(o => o.Power == "England"), Is.TypeOf<HoldOrder>());
Assert.That(handler.World.Timelines.Pasts.Count, Is.EqualTo(1));
World before = handler.World;
repl.Execute("---");
Assert.That(repl.Handler, Is.TypeOf<AdjudicationQueryScriptHandler>());
var newHandler = (AdjudicationQueryScriptHandler)repl.Handler!;
Assert.That(newHandler.World, Is.Not.EqualTo(before));
Assert.That(newHandler.World.Timelines.Pasts.Count, Is.EqualTo(2));
}
[Test]
public void AssertBasic()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Munich
---
---
assert true
""");
repl.AssertFails("assert false");
}
[Test]
public void AssertOrderValidity()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
---
Germany A Mun - Stp
---
""");
// Order should be invalid
repl.Execute("assert order-invalid Mun");
repl.AssertFails("assert order-valid Mun");
repl.ExecuteAll("""
---
Germany A Mun - Tyr
---
""");
// Order should be valid
repl.Execute("assert order-valid Mun");
repl.AssertFails("assert order-invalid Mun");
}
[Test]
public void AssertSeasonPast()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit England F London
---
---
""");
// Expected past
repl.Execute("assert has-past a1>a0");
// Incorrect past
repl.AssertFails("assert has-past a0>a1");
repl.AssertFails("assert has-past a1>a1");
// Missing season
repl.AssertFails("assert has-past a2>a1");
}
[Test]
public void AssertHoldOrder()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
---
""");
repl.AssertFails("Germany A Mun - The Sun");
repl.Execute("---");
// Order is invalid
repl.Execute("assert hold-order Mun");
// order-invalid requires the order be parsable, which this isn't
repl.AssertFails("assert order-invalid Mun");
}
[Test]
public void AssertMovement()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Austria A Tyr
---
Germany Mun - Tyr
---
""");
// Movement fails
repl.Execute("assert no-move Mun");
repl.AssertFails("assert moves Mun");
repl.ExecuteAll("""
---
Germany Mun - Boh
---
""");
// Movement succeeds
repl.Execute("assert moves Mun");
repl.AssertFails("assert no-move Mun");
}
[Test]
public void AssertSupportHold()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Germany A Boh
unit Austria A Tyr
---
Germany Mun s Boh
---
""");
// Support is given
repl.Execute("assert support-given Mun");
repl.AssertFails("assert support-cut Mun");
repl.ExecuteAll("""
---
Germany Mun s Boh
Austria Tyr - Mun
---
""");
// Support is cut
repl.Execute("assert support-cut Mun");
repl.AssertFails("assert support-given Mun");
}
[Test]
public void AssertSupportMove()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Berlin
unit Germany A Bohemia
unit Austria A Tyrolia
---
Germany:
Berlin - Silesia
Bohemia s Berlin - Silesia
---
""");
// Support is given
repl.Execute("assert support-given Boh");
repl.AssertFails("assert support-cut Boh");
repl.ExecuteAll("""
---
Germany:
Silesia - Munich
Bohemia s Silesia - Munich
Austria Tyrolia - Bohemia
---
""");
// Support is cut
repl.AssertFails("assert support-given Boh");
repl.Execute("assert support-cut Boh");
}
[Test]
public void AssertDislodged()
{
var repl = StandardRepl();
repl.ExecuteAll("""
unit Germany A Mun
unit Germany A Boh
unit Austria A Tyr
---
Germany Mun - Tyr
---
""");
// Move repelled
repl.Execute("assert not-dislodged Tyr");
repl.AssertFails("assert dislodged Tyr");
repl.ExecuteAll("""
---
Germany Mun - Tyr
Germany Boh s Mun - Tyr
---
""");
// Move succeeds
repl.Execute("assert dislodged Tyr");
repl.AssertFails("assert not-dislodged Tyr");
}
}

View File

@ -0,0 +1,55 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacyTests;
public class ScriptTests
{
static IEnumerable<TestCaseData> DatcTestCases()
{
foreach (var path in Directory.EnumerateFiles("Scripts/DATC"))
{
yield return new TestCaseData(path)
.SetName($"{{m}}({Path.GetFileNameWithoutExtension(path)})");
}
}
[TestCaseSource(nameof(DatcTestCases))]
public void Test_DATC(string testScriptPath)
{
string filename = Path.GetFileName(testScriptPath);
int line = 0;
bool expectFailure = false;
IScriptHandler handler = new SetupScriptHandler(
(msg) => {/* discard */},
World.WithStandardMap(),
Adjudicator.MovementPhase);
foreach (string input in File.ReadAllLines(testScriptPath)) {
line++;
// Handle test directives
if (input == "#test:skip") {
Assert.Ignore($"Script {filename} skipped at line {line}");
}
if (input == "#test:fails") {
expectFailure = true;
continue;
}
var result = handler.HandleInput(input);
if (expectFailure && result.Success) throw new AssertionException(
$"Script {filename} expected line {line} to fail, but it succeeded");
if (!expectFailure && !result.Success) throw new AssertionException(
$"Script {filename} error at line {line}: {result.Message}");
if (result.NextHandler is null) throw new AssertionException(
$"Script {filename} quit unexpectedly at line {line}: \"{input}\"");
handler = result.NextHandler;
expectFailure = false;
}
}
}

View File

@ -0,0 +1,14 @@
# 6.A.1. TEST CASE, MOVING TO AN AREA THAT IS NOT A NEIGHBOUR
# Check if an illegal move (without convoy) will fail.
unit England F North Sea
---
England:
F North Sea - Picardy
---
# Order should fail.
assert hold-order North Sea

View File

@ -0,0 +1,21 @@
# 6.A.10. TEST CASE, SUPPORT ON UNREACHABLE DESTINATION NOT POSSIBLE
# The destination of the move that is supported must be reachable by the supporting unit.
unit Austria A Venice
unit Italy F Rome
unit Italy A Apulia
---
Austria:
A Venice Hold
Italy:
F Rome Supports A Apulia - Venice
A Apulia - Venice
---
# The support of Rome is illegal, because Venice cannot be reached from Rome by a fleet. Venice is not dislodged.
assert hold-order Rome
assert not-dislodged Venice

View File

@ -0,0 +1,19 @@
# 6.A.11. TEST CASE, SIMPLE BOUNCE
# Two armies bouncing on each other.
unit Austria A Vienna
unit Italy A Venice
---
Austria:
A Vienna - Tyrolia
Italy:
A Venice - Tyrolia
---
# The two units bounce.
assert no-move Vienna
assert no-move Venice

View File

@ -0,0 +1,24 @@
# 6.A.12. TEST CASE, BOUNCE OF THREE UNITS
# If three units move to the same area, the adjudicator should not bounce the first two units and then let the third unit go to the now open area.
unit Austria A Vienna
unit Germany A Munich
unit Italy A Venice
---
Austria:
A Vienna - Tyrolia
Germany:
A Munich - Tyrolia
Italy:
A Venice - Tyrolia
---
# The three units bounce.
assert no-move Vienna
assert no-move Munich
assert no-move Venice

View File

@ -0,0 +1,15 @@
# 6.A.2. TEST CASE, MOVE ARMY TO SEA
# Check if an army could not be moved to open sea.
unit England A Liverpool
---
England:
#test:fails
A Liverpool - Irish Sea
---
# Order should fail.
assert hold-order Liverpool

View File

@ -0,0 +1,15 @@
# 6.A.3. TEST CASE, MOVE FLEET TO LAND
# Check whether a fleet cannot move to land.
unit Germany F Kiel
---
Germany:
#test:fails
F Kiel - Munich
---
# Order should fail.
assert hold-order Kiel

View File

@ -0,0 +1,14 @@
# 6.A.4. TEST CASE, MOVE TO OWN SECTOR
# Moving to the same sector is an illegal move (2023 rulebook, page 7, "An Army can be ordered to move into an adjacent inland or coastal province.").
unit Germany F Kiel
---
Germany:
F Kiel - Kiel
---
# Program should not crash.
assert hold-order Kiel

View File

@ -0,0 +1,34 @@
# 6.A.5. TEST CASE, MOVE TO OWN SECTOR WITH CONVOY
# Moving to the same sector is still illegal with convoy (2023 rulebook, page 7, "Note: An Army can move across water provinces from one coastal province to another...").
# TODO convoy order parsing
#test:skip
unit England F North Sea
unit England A Yorkshire
unit England A Liverpool
unit Germany F London
unit Germany A Wales
---
England:
F North Sea Convoys A Yorkshire - Yorkshire
A Yorkshire - Yorkshire
A Liverpool Supports A Yorkshire - Yorkshire
Germany:
F London - Yorkshire
A Wales Supports F London - Yorkshire
---
# The move of the army in Yorkshire is illegal.
assert hold-order Yorkshire
# This makes the support of Liverpool also illegal and without the support, the Germans have a stronger force.
assert hold-order North Sea
assert hold-order Liverpool
assert moves London
# The army in London dislodges the army in Yorkshire.
assert support-given Wales
assert dislodged Yorkshire

View File

@ -0,0 +1,16 @@
# 6.A.6. TEST CASE, ORDERING A UNIT OF ANOTHER COUNTRY
# Check whether someone cannot order a unit that is not his own unit.
unit England F London
# A German unit is included here so Germany isn't considered dead
unit Germany A Munich
---
Germany:
F London - North Sea
---
# Order should fail.
assert hold-order London

View File

@ -0,0 +1,19 @@
# 6.A.7. TEST CASE, ONLY ARMIES CAN BE CONVOYED
# A fleet cannot be convoyed.
# TODO convoy order parsing
#test:skip
unit England F London
unit England F North Sea
---
England:
F London - Belgium
F North Sea Convoys A London - Belgium
---
# Move from London to Belgium should fail.
assert hold-order London

View File

@ -0,0 +1,20 @@
# 6.A.8. TEST CASE, SUPPORT TO HOLD YOURSELF IS NOT POSSIBLE
# An army cannot get an additional hold power by supporting itself.
unit Italy A Venice
unit Italy A Tyrolia
unit Austria F Trieste
---
Italy:
A Venice - Trieste
A Tyrolia Supports A Venice - Trieste
Austria:
F Trieste Supports F Trieste
---
# The army in Trieste should be dislodged.
assert dislodged Trieste

View File

@ -0,0 +1,14 @@
# 6.A.9. TEST CASE, FLEETS MUST FOLLOW COAST IF NOT ON SEA
# If two provinces are adjacent, that does not mean that a fleet can move between those two provinces. An implementation that only holds one list of adjacent provinces for each province is incorrect.
unit Italy F Rome
---
Italy:
F Rome - Venice
---
# Move fails. An army can go from Rome to Venice, but a fleet cannot.
assert hold-order Rome

View File

@ -0,0 +1,3 @@
# DATC test scripts
These test scripts are copied from DATC v3.1.

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,146 @@
using System.Text.Json;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using NUnit.Framework;
using static MultiversalDiplomacyTests.Adjudicator;
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(), MovementPhase);
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, MovementPhase);
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

@ -2,28 +2,10 @@
_5D Diplomacy with Multiversal Time Travel_ is a _Diplomacy_ variant that adds multiversal time travel in the style of its namesake, _5D Chess with Multiversal Time Travel_.
## Acknowledgements
This project was inspired by [Oliver Lugg's proof-of-concept version](https://github.com/Oliveriver/5d-diplomacy-with-multiverse-time-travel) and based on the adjudication algorithms of Lucas B. Kruijswijk. For more information on the design, see [docs/design.md](./docs/design.md). For more information on the rules of multiversal Diplomacy, see [docs/rules.md](./docs/rules.md).
This project was inspired by [Oliver Lugg's proof-of-concept version](https://github.com/Oliveriver/5d-diplomacy-with-multiverse-time-travel). The implementation is based on the algorithms described by Lucas B. Kruijswijk in the chapter "The Process of Adjudication" found in the [Diplomacy Adjudicator Test Cases](http://web.inter.nl.net/users/L.B.Kruijswijk/#5) as well as ["The Math of Adjudication"](http://uk.diplom.org/pouch/Zine/S2009M/Kruijswijk/DipMath_Chp1.htm). Some of the data model is inspired by that of Martin Bruse's [godip](https://github.com/zond/godip).
## Usage
## Variant rules
This project is not ready for end users yet!
### Multiversal time travel and timeline forks
_Diplomacy_ is played on a single board, on which are placed armies and fleets. Sequential sets of orders modify the positions of these units, changing the board as time progresses. This may be described as something like an "inner" view of a single timeline. Consider instead the view from "above" the timeline, from which each successive state of the game board is comprehended in sequence. From "above", each turn from the beginning of the game to the present can be considered separately. In _5D Diplomacy with Multiversal Time Travel_, units moving to another province may also move to another turn, potentially changing the past.
If the outcome of a battle in the past of a timeline is changed by time travel, then the subsequent future will be different. Since the future of the original outcome is already determined, history forks, and the alternate future proceeds in an alternate timeline.
Just as units in _Diplomacy_ may only move to adjacent spaces, units in _5D Diplomacy with Multiversal Time Travel_ may only move to adjacent times. For the purposes of attacking, supporting, or convoying, turns within one season of each other adjacent. Branching timelines and the timelines they branched off of are adjacent, as well as timelines that branched off of the same turn in the same timeline. A unit cannot move to the province it is currently in, but it can move to the same province in another turn or another timeline.
When a unit changes the outcome of a battle in the past, only the timeline of the battle forks. If an army from one timeline dislodges an army in the past of a second timeline that was supporting a move in a third timeline, an alternate future is created where the army in the second timeline is dislodged. The third timeline does not fork, since the support was given in the original timeline. Similarly, if a unit moves into another timeline and causes a previously-successful move from a third timeline to become a bounce, the destination timeline forks because the outcome of the move changed, but the newly-bounced unit's origin timeline does not fork because the move succeeded in the original timeline.
### Sustaining timelines and time centers
Since there are many ways to create new timelines, the game would rapidly expand beyond all comprehension if this were not counterbalanced in some way. This happens during the _sustain phase_, which occurs after the fall movement and retreat phases and before the winter buid/disband phase.
(TODO)
### Victory conditions
The Great Powers of Europe can only wage multiversal wars because they are lead by extradimensional beings masquerading as human politicians. When a country is eliminated in one timeline, its extradimensional leader is executed, killing them in all timelines.
I am working in VS Code on NixOS so currently the developer setup is optimized for that. VS Code is launched from inside a `nix develop` shell so it gets the environment. The C# debugger fails to launch on NixOS so I run Code through an Ubuntu 22.04 distrobox when I need that.

4139
docs/DATC_v3_1.html Normal file

File diff suppressed because it is too large Load Diff

675
docs/GPL3.txt Normal file
View File

@ -0,0 +1,675 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

49
docs/design.md Normal file
View File

@ -0,0 +1,49 @@
# Architecture
In lieu of a systematic overview of the architecture, here are a few scattered notes on design decisions.
## Provinces and Locations
The data model here is based on the data model of [godip](https://github.com/zond/godip). In particular, godip handles the distinction between army and fleet movement by distinguishing between Provicnces and SubProvinces, which 5dplomacy calls Locations. The graph edges that define valid paths are drawn between Locations, but occupation by a unit and being a supply center are properties of the Province as a whole. This makes it easy to represent the different paths available to armies or fleets: the land and sea graphs are unconnected and only interact at the Province level. This also provides a way to distinguish the connectivity of multiple coasts within a province.
As a consequence of the unconnected land and sea graphs, there is no fundamental difference between army movement and fleet movement, since the inability of armies to move into the ocean is ensured by the lack of edges between land and sea locations. Unit type still remains significant with respect to convoys, since only fleets can convoy and only armies can be convoyed. Unit type is also relevant to the interpretation of orders that do not fully specify location. And, of course, unit type matters to how clients represent the units.
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
In Diplomacy, there is only one board, whose state changes atomically as a function of the previous state and the orders. Thus, there is only ever need to refer to units by the province they instantaneously occupy, e.g. "A MUN -> TYR" to order the army in Munich to move to Tyrolia. 5dplomacy needs to be able to refer to past states of the board as well as alternative timeline states of the board. The timeline of a province is specified by prefixing the timeline designation, e.g. "a-MUN" to refer to Munish in timeline a or "b-TYR" to refer to Tyrolia in timeline b. The turn of a province is specified by a suffix, e.g. "LON@3" to refer to London in turn 3.
## Adjudication algorithm
The adjuciation algorithm is based on the algorithms described by Lucas B. Kruijswijk in the [Diplomacy Adjudicator Test Cases v2.5 §5 "The Process of Adjudication"](https://web.archive.org/web/20230608074055/http://web.inter.nl.net/users/L.B.Kruijswijk/#5) as well as ["The Math of Adjudication"](http://uk.diplom.org/pouch/Zine/S2009M/Kruijswijk/DipMath_Chp1.htm). The approach taken follows the partial information algorithm later described in [DATC v3.0 §5.E](https://webdiplomacy.net/doc/DATC_v3_0.html#5.E). These algorithms are based on the recursive evaluation of binary (move succeeds, unit is dislodged, etc.) and numeric (attack strength, hold strength, etc.) decisions.
In order to support multiversal time travel, 5dplomacy adds an additional binary decision for each relevant timeline: whether the timeline advances. The timeline advance decision is resolved for each timeline-turn as follows:
- The head of a timeline always advances.
- The target of a new (i.e. not previously adjudicated) and successful move always advances.
- A timeline-turn adcanfes if the outcomne of a battle is changed, as follows:
- The outcome of a dislodge decision is changed.
- The outcome of an intra-timeline move decision is changed.
- The outcome of an inter-timeline move into that timeline-turn is changed.
A timeline head advances into a new turn of the same timeline. A turn behind the head advances into a forked timeline.
Note that the timeline advance decision depends on the result of previously-adjudicated decisions, which informs the data model.
## Pure adjudication
The core adjudication algorithm is intended to be a pure function. That is, adjudication begins with all relevant information about the game state and orders, and it computes the result of adjudicating those orders, leaving the inputs unchanged. Data persistence is handled by a higher layer that is responsible for saving the information the adjudicator needs and constructing the input data structure. This is intended to encapsulate the adjudicator logic and decouple it from other concerns that depend on implementation details of the application.
## 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.
> [!WARNING]
> Options are not implemented yet.

65
docs/rules.md Normal file
View File

@ -0,0 +1,65 @@
# Multiversal Diplomacy rules
_Diplomacy_ is played with armies and fleets on a single board. Each set of orders results in the state of the board changing. Suppose that, instead of moving the pieces on the board, a second board were set up according to the resolution of the orders given to the first board. If this continued, the whole evolution of the game board could be observed from start to finish. One could even go back to a board state in the middle of the game and give a different set of orders, creating a new version of history and playing out a different game.
Now suppose that, in addition to moving to another province on the board, units could move to _another board_, changing the outcome of the turn and creating a new sequence of boards as the new history plays itself out. This is _5D Diplomacy with Multiversal Time Travel_. Units may move or support into the past, changing history and creating alternate timelines, or move or support into those alternate timelines.
## DATC compliance
When the adjudicator is in a more complete state, this section will declare the extent of the adjudicator's DATC compliance. If no unit attempts multiversal time travel, the game plays out exactly as _Diplomacy_ does normally.
The MDATC (Multiversal Diplomacy Adjudicator Test Cases) document defines test cases that involve multiversal time travel.
- 4.C.5 (missing nationality in support order), 4.C.6 (wrong nationality in support order): 5dplomacy does not support specifying the nationality of the supported unit.
## Variant rules
### Multiversal time travel and timeline forks
Just as units in _Diplomacy_ may only move to adjacent spaces, units in _5D Diplomacy with Multiversal Time Travel_ may only move to adjacent times. For the purposes of attacking, supporting, or convoying, turns within one season of each other adjacent. Branching timelines and the timelines they branched off of are adjacent, as well as timelines that branched off of the same turn in the same timeline. A unit cannot move to the province it is currently in, but it can move to the same province in another turn or another timeline.
When a unit changes the outcome of a battle in the past, only the timeline of the battle forks. If an army from one timeline dislodges an army in the past of a second timeline that was supporting a move in a third timeline, an alternate future is created where the army in the second timeline is dislodged. The third timeline does not fork, since the support was given in the original timeline. Similarly, if a unit moves into another timeline and causes a previously-successful move from a third timeline to become a bounce, the destination timeline forks because the outcome of the move changed, but the newly-bounced unit's origin timeline does not fork because the move succeeded in the original timeline.
### Sustaining timelines and time centers
Since there are many ways to create new timelines, the game would rapidly expand beyond all comprehension if this were not counterbalanced in some way. This happens during the _sustain phase_, which occurs after the fall movement and retreat phases and before the winter build/disband phase.
The seven capital supply centers are also considered _time centers_. During the sustain phase, each time center chooses a timeline to sustain. Each timeline with at least one time center sustaining it remains in play. All other timelines dissolve into the ether and cease to be playable. A time center's "vote" is split equally among all timelines and each fraction is owned by the owner of that time center in that timeline.
> [!WARNING]
> The sustain phase is a speculative feature and has not been implemented yet.
### Victory conditions
The Great Powers of Europe can only wage multiversal wars because they are lead by extradimensional beings masquerading as human politicians. When a country is eliminated in one timeline, its extradimensional leader is executed, killing them in all timelines.
> [!WARNING]
> Victory conditions have not been implemented yet.
### Open convoys
The standard _Diplomacy_ rules require that a convoy order include the convoyed unit's origin and destination. This is hard to coordinate once there are multiple turns and timelines involved. _5D Diplomacy with Multiversal Time Travel_ thus introduces the concept of an _open convoy_, a nonspecific convoy order that can become part of a convoy later. An open convoy order does not require specifying the origin or destination of the convoyed unit; the unit is simply told to expect guests. Consequently, it is possible for a unit doing an open convoy to be used by a hostile power to convoy in the opposite direction.
> [!WARNING]
> Open convoys are a speculative feature and have not been implemented yet.
### Jump assist
Outside of convoys, a unit may only move one province at a time. Multiversal time travel also allows units to move back or forward by one turn and/or across by one timeline. Since time moves forward by one turn per turn, this makes it difficult to go further back into the past. The _jump assist_ order provides a way for units to intervene deeper into the past. A unit may be ordered to give a jump assist to another unit's move. For each successful jump assist given, a unit may move one more turn or timeline. Jump assists do not grant units the ability to move further geographically than they could otherwise, nor do they provide additional support to an attack.
> [!WARNING]
> Jump assists are a speculative feature and have not been implemented yet.
## Unit designations
In _Diplomacy_, orders refer to provinces, such as "A Mun-Tyr". In _5D Diplomacy with Multiversal Time Travel_, this is insufficient to unambiguously identify a province, since the province exists in multiple timelines across multiple turns. The convention for identifying a multiversal location is `timeline-province@turn`, where `timeline` is the timeline's identifier and `turn` is the turn's identifier, e.g. "b-Mun@3".
(Why this order? Short representations for timelines and turns can be confused for each other, especially for timelines designated with `f` or `s` that might be confused for fall or spring turns. _5D Diplomacy with Multiversal Time Travel_ is already complicated enough, so the timeline and turn are put on either side of the province and delimited with different symbols.)
Some designation elements may be omitted for brevity. Omitted elements are interpreted according to the following rules:
- If the timeline is omitted from the subject of an order, the timeline is the root timeline, "a". If the turn is omitted from the subject of an order, the turn is the latest turn in the timeline.
- If the timeline or turn are unspecified for the destination of a move or the target of a support-hold order, the timeline and turn are those of the ordered unit.
- If the timeline or turn are unspecified for the destination of a support-move order, the timeline and turn are those of the supported unit.
Thus, if timeline "a" is at turn 2 and timeline "b" is at turn 1, `A Munich supports A b-Munich - Tyrolia` is equivalent to `A a-Munich@2 supports A b-Munich@1 - b-Tyrolia@1`.

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