Compare commits
29 Commits
92506ac6ed
...
f02e71d4f9
Author | SHA1 | Date |
---|---|---|
Tim Van Baak | f02e71d4f9 | |
Tim Van Baak | f77cc60185 | |
Tim Van Baak | 14a493d95c | |
Tim Van Baak | 44f2c25a2c | |
Tim Van Baak | 43a2517a95 | |
Tim Van Baak | 512c91d2de | |
Tim Van Baak | 416f2aa919 | |
Tim Van Baak | 4f276df6c1 | |
Tim Van Baak | 24e80af7ef | |
Tim Van Baak | e25191548e | |
Tim Van Baak | 33aecf876a | |
Tim Van Baak | b4f8f621ca | |
Tim Van Baak | ffe164975b | |
Tim Van Baak | ebeb178984 | |
Tim Van Baak | 93b106da1e | |
Tim Van Baak | 973f8ea0d7 | |
Tim Van Baak | 868138b988 | |
Tim Van Baak | 55dfe0ca99 | |
Tim Van Baak | e9c4d3d2d3 | |
Tim Van Baak | 32a7ddd3b5 | |
Tim Van Baak | 5167978f8c | |
Tim Van Baak | aaf3320cf8 | |
Tim Van Baak | 2745d12d29 | |
Tim Van Baak | 8e976433c8 | |
Tim Van Baak | bfafb66603 | |
Tim Van Baak | 1689d2e9b1 | |
Tim Van Baak | ea366220eb | |
Tim Van Baak | f9f8ea2b5a | |
Tim Van Baak | b2461b3736 |
|
@ -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"
|
||||
|
|
|
@ -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,11 +57,7 @@ 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)
|
||||
=> provinces.SingleOrDefault(province => province!.Is(provinceName), null)
|
||||
?? throw new KeyNotFoundException($"Province {provinceName} not found");
|
||||
|
||||
/// <summary>
|
||||
|
|
|
@ -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.
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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; }
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
using MultiversalDiplomacy.Adjudicate;
|
||||
using MultiversalDiplomacy.Adjudicate.Decision;
|
||||
using MultiversalDiplomacy.Model;
|
||||
using MultiversalDiplomacy.Orders;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace MultiversalDiplomacyTests;
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -18,4 +18,10 @@
|
|||
<PackageReference Include="coverlet.collector" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Scripts/**/*.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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}\"");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
# DATC test scripts
|
||||
|
||||
These test scripts are copied from DATC v3.1.
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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`.
|
||||
|
|
Loading…
Reference in New Issue