Compare commits

...

29 Commits

Author SHA1 Message Date
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
30 changed files with 1109 additions and 125 deletions

View File

@ -59,6 +59,12 @@ public class Location
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 == "land" || this.Name == "water"

View File

@ -37,6 +37,16 @@ public class Map
.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>
@ -47,12 +57,8 @@ public class Map
/// Get a province by name. Throws if the province is not found.
/// </summary>
private static Province GetProvince(string provinceName, IEnumerable<Province> provinces)
=> provinces.SingleOrDefault(
p => p!.Name.Equals(provinceName, StringComparison.InvariantCultureIgnoreCase)
|| p.Abbreviations.Any(
a => a.Equals(provinceName, StringComparison.InvariantCultureIgnoreCase)),
null)
?? throw new KeyNotFoundException($"Province {provinceName} not found");
=> 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

View File

@ -0,0 +1,365 @@
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 {
throw new NotImplementedException();
}
}
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));
// DATC 4.B.6 requires that "irrelevant" locations like army to Spain nc be ignored.
// To satisfy this, any location of the wrong type is categorically ignored, so for an army the
// "north coast" location effectively doesn't exist here.
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => subject.Type == UnitType.Army,
LocationType.Water => subject.Type == UnitType.Fleet,
_ => false,
});
// DATC 4.6.B also requires that unknown coasts be ignored. To satisfy this, an additional filter by name.
// Doing both of these filters means "A - Spain/nc" is as meaningful as "F - Spain/wc".
var matchingLocations = unitLocations.Where(loc => loc.Is(move.destLocation));
// If one location matched, use that location. If the coast is inaccessible to the subject, the order will
// be invalidated by a path check later to satisfy DATC 4.B.3.
string? destLocationKey = matchingLocations.FirstOrDefault(defaultValue: null)?.Key;
if (destLocationKey is null) {
// If no location matched, location was omitted, nonexistent, or the wrong type.
// If one location is accessible, DATC 4.B.2 requires that it be used.
// If more than one location is accessible, DATC 4.B.1 requires the order fail.
// TODO check which locations are accessible per the above
destLocationKey = unitLocations.First().Key;
// 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);
throw new NotImplementedException();
}
public static bool TryParseSupportMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var support = ParseSupportMove(match);
throw new NotImplementedException();
// It is possible to support a move to an inaccessible coast if another coast is accessible to the subject.
// DATC 4.B.4 prefers that automatic adjudicators strictly require matching coasts in supports.
}
}

View File

@ -31,6 +31,11 @@ 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;
@ -45,6 +50,12 @@ 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>

View File

@ -66,11 +66,17 @@ public class Timelines(int next, Dictionary<string, Season?> pasts)
public int Next { get; private set; } = next;
/// <summary>
/// Map of season designations to their parent seasons.
/// The set of keys here is the set of all seasons in the multiverse.
/// 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>

View File

@ -55,7 +55,7 @@ public class World
public Dictionary<string, OrderHistory> OrderHistory { get; }
/// <summary>
/// The shared timeline number generator.
/// The state of the multiverse.
/// </summary>
public Timelines Timelines { get; }

View File

@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<VersionPrefix>0.0.2</VersionPrefix>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@ -15,7 +15,7 @@ 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 string Location { get; }

View File

@ -0,0 +1,60 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
public class AdjudicationQueryScriptHandler(World world, bool strict = false) : IScriptHandler
{
public string Prompt => "valid> ";
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
public bool Strict { get; } = strict;
public IScriptHandler? HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0 || input.StartsWith('#'))
{
return this;
}
var command = args[0];
switch (command)
{
case "---":
Console.WriteLine("Ready for orders");
return new GameScriptHandler(World, Strict);
case "assert" when args.Length == 1:
Console.WriteLine("Usage:");
break;
case "assert":
string assertion = input["assert ".Length..];
Regex prov = new($"{World.Map.ProvinceRegex} (.*)");
Match match = prov.Match(assertion);
if (!match.Success) {
Console.WriteLine($"Could not parse province from \"{assertion}\"");
return Strict ? null : this;
}
// TODO look up order once orders are validated and adjudicated
Console.WriteLine("Order lookup not implemented yet");
return null;
case "status":
throw new NotImplementedException();
default:
Console.WriteLine($"Unrecognized command: \"{command}\"");
if (Strict) return null;
break;
}
return this;
}
}

View File

@ -0,0 +1,96 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacy.Script;
public class GameScriptHandler(World world, bool strict = false) : IScriptHandler
{
public string Prompt => "orders> ";
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
public bool Strict { get; } = strict;
private string? CurrentPower { get; set; } = null;
public List<Order> Orders { get; } = [];
public IScriptHandler? HandleInput(string input)
{
if (input == "") {
CurrentPower = null;
return this;
}
if (input.StartsWith('#')) return this;
// "---" submits the orders and allows queries about the outcome
if (input == "---") {
Console.WriteLine("Submitting orders for adjudication");
var adjudicator = MovementPhaseAdjudicator.Instance;
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 new AdjudicationQueryScriptHandler(newWorld, Strict);
}
// "===" submits the orders and moves immediately to taking the next set of orders
// i.e. it's "---" twice
if (input == "===") {
Console.WriteLine("Submitting orders for adjudication");
var adjudicator = MovementPhaseAdjudicator.Instance;
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);
Console.WriteLine("Ready for orders");
return 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 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) {
Console.WriteLine($"Could not determine ordering power in \"{input}\"");
return Strict ? null : this;
}
orderPower = match.Groups[1].Value;
orderText = match.Groups[2].Value;
}
if (OrderParser.TryParseOrder(World, orderPower, orderText, out Order? order)) {
Console.WriteLine($"Parsed {orderPower} order: {order}");
Orders.Add(order);
return this;
}
Console.WriteLine($"Failed to parse \"{orderText}\"");
return Strict ? null : this;
}
}

View File

@ -12,7 +12,7 @@ public class ReplScriptHandler : IScriptHandler
public IScriptHandler? HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0)
if (args.Length == 0 || input.StartsWith('#'))
{
return this;
}
@ -42,7 +42,7 @@ public class ReplScriptHandler : IScriptHandler
case "map" when args.Length > 1:
string mapType = args[1].Trim();
if (!Enum.TryParse(mapType, ignoreCase: true, out MapType map)) {
Console.WriteLine($"Unknown variant {mapType}");
Console.WriteLine($"Unknown variant \"{mapType}\"");
Console.WriteLine("Available variants:");
Console.WriteLine($" {string.Join(", ", Enum.GetNames<MapType>().Select(s => s.ToLowerInvariant()))}");
break;
@ -52,10 +52,7 @@ public class ReplScriptHandler : IScriptHandler
return new SetupScriptHandler(world);
default:
// noop on comments that begin with #
if (!command.StartsWith('#')) {
Console.WriteLine($"Unrecognized command: {command}");
}
Console.WriteLine($"Unrecognized command: \"{command}\"");
break;
}

View File

@ -1,5 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using MultiversalDiplomacy.Model;
namespace MultiversalDiplomacy.Script;
@ -7,16 +5,21 @@ namespace MultiversalDiplomacy.Script;
/// <summary>
/// A script handler for modifying a game before it begins.
/// </summary>
public class SetupScriptHandler(World world) : IScriptHandler
public class SetupScriptHandler(World world, bool strict = false) : IScriptHandler
{
public string Prompt => "5dp> ";
public string Prompt => "setup> ";
public World World { get; private set; } = world;
/// <summary>
/// Whether unsuccessful commands should terminate the script.
/// </summary>
public bool Strict { get; } = strict;
public IScriptHandler? HandleInput(string input)
{
var args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (args.Length == 0)
if (args.Length == 0 || input.StartsWith('#'))
{
return this;
}
@ -27,7 +30,7 @@ public class SetupScriptHandler(World world) : IScriptHandler
case "help":
case "?":
Console.WriteLine("commands:");
Console.WriteLine(" begin: complete setup and start the game");
Console.WriteLine(" begin: complete setup and start the game (alias: ---)");
Console.WriteLine(" list <type>: list things in a game category");
Console.WriteLine(" option <name> <value>: set a game option");
Console.WriteLine(" unit <power> <type> <province> [location]: add a unit to the game");
@ -35,7 +38,10 @@ public class SetupScriptHandler(World world) : IScriptHandler
break;
case "begin":
return null; // TODO
case "---":
Console.WriteLine("Starting game");
Console.WriteLine("Ready for orders");
return new GameScriptHandler(World, Strict);
case "list" when args.Length == 1:
Console.WriteLine("usage:");
@ -60,108 +66,29 @@ public class SetupScriptHandler(World world) : IScriptHandler
break;
case "option" when args.Length < 3:
throw new NotImplementedException();
throw new NotImplementedException("There are no supported options yet");
case "unit" when args.Length < 4:
Console.WriteLine("usage: unit [power] [type] [province] <location>");
case "unit" when args.Length < 2:
Console.WriteLine("usage: unit [power] [type] [province]</location>");
break;
case "unit":
string power = args[1];
string type = args[2];
string province = args[3];
string? location = args.Length >= 5 ? args[4] : null;
if (ParseUnit(power, type, province, location, out Unit? newUnit)) {
string unitSpec = input["unit ".Length..];
if (OrderParser.TryParseUnit(World, unitSpec, out Unit? newUnit)) {
World = World.Update(units: World.Units.Append(newUnit));
Console.WriteLine($"Created {newUnit}");
return this;
}
Console.WriteLine($"Could not match unit spec \"{unitSpec}\"");
if (Strict) return null;
break;
default:
// noop on comments that begin with #
if (!command.StartsWith('#')) {
Console.WriteLine($"Unrecognized command: {command}");
}
Console.WriteLine($"Unrecognized command: \"{command}\"");
if (Strict) return null;
break;
}
return this;
}
private bool ParseUnit(
string powerArg,
string typeArg,
string provinceArg,
string? locationArg,
[NotNullWhen(true)] out Unit? newUnit)
{
newUnit = null;
// Parse the power name by substring matching.
var matchingPowers = World.Powers.Where(p => p.StartsWithAnyCase(powerArg));
if (!matchingPowers.Any())
{
Console.WriteLine($"No power named \"{powerArg}\"");
return false;
}
if (matchingPowers.Count() > 1)
{
Console.WriteLine($"Ambiguous power \"{powerArg}\" between {string.Join(", ", matchingPowers)}");
return false;
}
string power = matchingPowers.First();
// Parse the unit type by substring matching.
var matchingTypes = Enum.GetNames<UnitType>().Where(t => t.StartsWithAnyCase(typeArg));
if (!matchingTypes.Any())
{
Console.WriteLine($"No unit type \"{typeArg}\"");
return false;
}
if (matchingTypes.Count() > 1)
{
Console.WriteLine($"Ambiguous unit type \"{typeArg}\" between {string.Join(", ", matchingTypes)}");
return false;
}
UnitType type = Enum.Parse<UnitType>(matchingTypes.First());
// Parse the province by matching the province name or one of its abbreviations,
// allowing location specifications separated by a forward slash, e.g. spa/nc.
if (provinceArg.Contains('/')) {
var split = provinceArg.Split('/', 2);
locationArg ??= split[1];
provinceArg = split[0];
}
var matchingProvs = World.Provinces.Where(pr
=> pr.Abbreviations.Any(abv => abv.EqualsAnyCase(provinceArg))
|| pr.Name.EqualsAnyCase(provinceArg));
if (!matchingProvs.Any())
{
Console.WriteLine($"No province matches \"{provinceArg}\"");
return false;
}
if (matchingProvs.Count() > 1)
{
Console.WriteLine($"Ambiguous province \"{provinceArg}\" between {string.Join(", ", matchingProvs)}");
return false;
}
Province province = matchingProvs.First();
Location location;
locationArg ??= type == UnitType.Army ? "land" : "water";
var matchingLocs = locationArg is null
? province.Locations.Where(loc
=> type == UnitType.Army && loc.Type == LocationType.Land
|| type == UnitType.Fleet && loc.Type == LocationType.Water)
: province.Locations.Where(loc
=> loc.Name.EqualsAnyCase(locationArg)
|| loc.Abbreviation.EqualsAnyCase(locationArg));
if (!matchingLocs.Any()) {
Console.WriteLine($"Province {province.Name} has no {locationArg} location");
return false;
}
location = matchingLocs.First();
newUnit = Unit.Build(location.Key, Season.First, powerArg, type);
return true;
}
}

View File

@ -1,8 +1,6 @@
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Adjudicate.Decision;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;

View File

@ -33,11 +33,11 @@ public class TimeTravelTest
// Confirm that there are now four seasons: three in the main timeline and one in a fork.
Assert.That(
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline == s0.Timeline).Count(),
world.Timelines.Seasons.Where(s => s.Timeline == s0.Timeline).Count(),
Is.EqualTo(3),
"Failed to advance main timeline after last unit left");
Assert.That(
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline != s0.Timeline).Count(),
world.Timelines.Seasons.Where(s => s.Timeline != s0.Timeline).Count(),
Is.EqualTo(1),
"Failed to fork timeline when unit moved in");

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,219 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
namespace MultiversalDiplomacyTests;
public class RegexTest
{
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"));
}
}

View File

@ -0,0 +1,51 @@
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;
/// <summary>
/// Input a multiline string into the repl. Call <see cref="Ready"/> or <see cref="Closed"/> at the end so the
/// statement is valid.
/// </summary>
public ReplDriver this[string input] => ExecuteAll(input);
public ReplDriver ExecuteAll(string multiline)
{
var lines = multiline.Split('\n', StringSplitOptions.RemoveEmptyEntries | 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}");
Handler = Handler.HandleInput(inputLine);
LastInput = inputLine;
return this;
}
public void Ready()
{
Assert.That(Handler, Is.Not.Null, "Handler is closed");
}
public void Closed()
{
Assert.That(Handler, Is.Null, "Handler is not closed");
}
}

View File

@ -0,0 +1,74 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacyTests;
public class ReplTest
{
[Test]
public void SetupHandler()
{
SetupScriptHandler setup = new(World.WithStandardMap(), strict: true);
ReplDriver repl = new(setup);
repl["""
unit Germany A Munich
unit Austria Army Tyrolia
unit England F Lon
"""].Ready();
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["""
---
"""].Ready();
Assert.That(repl.Handler, Is.TypeOf<GameScriptHandler>());
}
[Test]
public void SubmitOrders()
{
SetupScriptHandler setup = new(World.WithStandardMap(), strict: true);
ReplDriver repl = new ReplDriver(setup)["""
unit Germany A Mun
unit Austria A Tyr
unit England F Lon
begin
"""];
repl["""
Germany A Mun hold
Austria: Army Tyrolia - Vienna
England:
Lon h
"""].Ready();
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["""
---
"""].Ready();
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));
}
}

View File

@ -0,0 +1,32 @@
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)
{
Assert.Ignore("Script tests postponed until parsing tests are done");
string filename = Path.GetFileName(testScriptPath);
int line = 0;
IScriptHandler? handler = new SetupScriptHandler(World.WithStandardMap(), strict: true);
foreach (string input in File.ReadAllLines(testScriptPath)) {
line++;
handler = handler?.HandleInput(input);
if (handler is null) Assert.Fail($"Script {filename} quit unexpectedly at line {line}: \"{input}\"");
}
}
}

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 North Sea holds

View File

@ -0,0 +1,14 @@
# 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:
A Liverpool - Irish Sea
---
# Order should fail.
assert Liverpool holds

View File

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

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 Army Kiel
---
Germany:
F Kiel - Kiel
---
# Program should not crash.
assert Kiel holds

View File

@ -0,0 +1,31 @@
# 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...").
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 Yorkshire holds
# This makes the support of Liverpool also illegal and without the support, the Germans have a stronger force.
assert North Sea holds
assert Liverpool holds
assert London moves
# The army in London dislodges the army in Yorkshire.
assert Wales supports
assert Yorkshire dislodged

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 London holds

View File

@ -0,0 +1,16 @@
# 6.A.7. TEST CASE, ONLY ARMIES CAN BE CONVOYED
# A fleet cannot be convoyed.
unit England F London
unit England North Sea
---
England:
F London - Belgium
F North Sea Convoys A London - Belgium
---
# Move from London to Belgium should fail.
assert London holds

View File

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

View File

@ -8,4 +8,4 @@ This project was inspired by [Oliver Lugg's proof-of-concept version](https://gi
This project is not ready for end users yet!
I am working in VS Code on NixOS so currently the developer setup is optimized for that. Code is launch from inside a `nix develop` shell so it gets the environment.
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.

View File

@ -6,7 +6,7 @@ In lieu of a systematic overview of the architecture, here are a few scattered n
The data model here is based on the data model of [godip](https://github.com/zond/godip). In particular, godip handles the distinction between army and fleet movement by distinguishing between Provicnces and SubProvinces, which 5dplomacy calls Locations. The graph edges that define valid paths are drawn between Locations, but occupation by a unit and being a supply center are properties of the Province as a whole. This makes it easy to represent the different paths available to armies or fleets: the land and sea graphs are unconnected and only interact at the Province level. This also provides a way to distinguish the connectivity of multiple coasts within a province.
As a consequence of the unconnected land and sea graphs, there is no special significance to unit type in movement, since the inability of fleets to move to land locations is ensured by the lack of edges from land locations to sea locations. The primary difference between unit types becomes "can convoy" and "can move by convoy", as well as how the units are represented by clients.
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.
@ -14,9 +14,6 @@ Internally, land locations are named "land" or "l" and water locations are calle
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.
> [!WARNING]
> The timeline notation is in flux and archaeological layers of previous decisions are scattered around the code.
## 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.
@ -36,10 +33,7 @@ Note that the timeline advance decision depends on the result of previously-adju
## 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. 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.
> [!WARNING]
> This is not complete and the adjudicator is still stateful.
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
@ -50,3 +44,6 @@ In order to support different decisions about how adjudication or the rules of m
- `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.

View File

@ -10,6 +10,8 @@ When the adjudicator is in a more complete state, this section will declare the
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 nationalist in support order): 5dplomacy does not support specifying the nationalirt of the supported unit.
## Variant rules
### Multiversal time travel and timeline forks
@ -53,3 +55,11 @@ Outside of convoys, a unit may only move one province at a time. Multiversal tim
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`.