Compare commits

...

5 Commits

4 changed files with 162 additions and 68 deletions

View File

@ -34,14 +34,22 @@ public class OrderParser(World world)
public const string ViaConvoy = "(convoy|via convoy|by convoy)";
public Regex PowerCommand = new($"^{world.Map.PowerRegex}(?:[:])? (.*)$");
public Regex UnitDeclaration = new(
$"^{world.Map.PowerRegex} {Type} {world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?$",
RegexOptions.IgnoreCase);
public static (
string power,
string command)
ParsePowerCommand(Match match) => (
string type,
string province,
string location)
ParseUnitDeclaration(Match match) => (
match.Groups[1].Value,
match.Groups[2].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}$",
@ -178,27 +186,29 @@ public class OrderParser(World world)
public static bool TryParseUnit(World world, string unitSpec, [NotNullWhen(true)] out Unit? newUnit)
{
newUnit = null;
OrderParser re = new(world);
Regex reUnit = new(
$"^{world.Map.PowerRegex} {Type} {world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?$");
Match match = reUnit.Match(unitSpec);
if (!match.Success) {
return false;
}
Match match = re.UnitDeclaration.Match(unitSpec);
if (!match.Success) return false;
var unit = ParseUnitDeclaration(match);
string power = world.Map.Powers.First(p => p.EqualsAnyCase(match.Groups[1].Value));
string power = world.Map.Powers.First(p => p.EqualsAnyCase(unit.power));
string typeName = Enum.GetNames<UnitType>().First(name => name.StartsWithAnyCase(match.Groups[2].Value));
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.Name.EqualsAnyCase(match.Groups[3].Value)
|| prov.Abbreviations.Any(abv => abv.EqualsAnyCase(match.Groups[3].Value)));
string locationName = match.Groups[4].Length > 0 ? match.Groups[4].Value : match.Groups[5].Value;
Location location = province.Locations.First(loc
=> loc.Name.StartsWithAnyCase(locationName)
|| loc.Abbreviation.StartsWithAnyCase(locationName));
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;
@ -213,73 +223,77 @@ public class OrderParser(World world)
} 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) {
// DATC 4.B.4: coast specification in support orders
throw new NotImplementedException();
return TryParseSupportHoldOrder(world, power, sholdMatch, out order);
} else if (re.SupportMove.Match(command) is Match smoveMatch && smoveMatch.Success) {
throw new NotImplementedException();
return TryParseSupportMoveOrder(world, power, smoveMatch, out order);
} else {
throw new NotImplementedException();
}
}
public static bool TryParseHoldOrder(World world, string power, Match match, [NotNullWhen(true)] out Order? order)
public static bool TryParseOrderSubject(
World world,
string parsedTimeline,
string parsedTurn,
string parsedProvince,
[NotNullWhen(true)] out Unit? subject)
{
order = null;
var hold = ParseHold(match);
string timeline = hold.timeline.Length > 0
? hold.timeline
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 = hold.turn.Length > 0
? int.Parse(hold.turn)
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(hold.province));
Province province = world.Map.Provinces.Single(province => province.Is(parsedProvince));
Unit? subject = world.Units.FirstOrDefault(unit
// 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);
if (subject is null) return false;
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)
public static bool TryParseMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var move = ParseMove(match);
string timeline = move.timeline.Length > 0
? move.timeline
// 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 = move.turn.Length > 0
? int.Parse(move.turn)
// 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(move.province));
// 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.
Unit? subject = world.Units.FirstOrDefault(unit
=> world.Map.GetLocation(unit!.Location).ProvinceName == province.Name
&& unit!.Season.Timeline == timeline
&& unit!.Season.Turn == turn,
null);
if (subject is null) return false;
if (!TryParseOrderSubject(world, move.timeline, move.turn, move.province, out Unit? subject)) {
return false;
}
string destTimeline = move.destTimeline.Length > 0
? move.destTimeline
@ -323,4 +337,29 @@ public class OrderParser(World world)
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

@ -1,5 +1,6 @@
using System.Text.RegularExpressions;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
@ -16,7 +17,9 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
/// </summary>
public bool Strict { get; } = strict;
private string? CurrentPower = null;
private string? CurrentPower { get; set; } = null;
public List<Order> Orders { get; } = [];
public IScriptHandler? HandleInput(string input)
{
@ -29,19 +32,31 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
// "---" submits the orders and allows queries about the outcome
if (input == "---") {
Console.WriteLine("Submitting orders for adjudication");
// TODO submit, validate, and adjudicate orders
// TODO pass validation, adjudication, and prev/next World to next handler
return new AdjudicationQueryScriptHandler(World, Strict);
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");
// TODO submit, validate, and adjudicate orders
// TODO replace World with updated world and return a new handler
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 new GameScriptHandler(World, Strict);
return this;
}
// A block of orders for a single power beginning with "{name}:"
@ -70,7 +85,8 @@ public class GameScriptHandler(World world, bool strict = false) : IScriptHandle
}
if (OrderParser.TryParseOrder(World, orderPower, orderText, out Order? order)) {
Console.WriteLine($"Parsed {orderPower} order \"{orderText}\" but doing anything with it isn't implemented yet");
Console.WriteLine($"Parsed {orderPower} order: {order}");
Orders.Add(order);
return this;
}

View File

@ -1,6 +1,7 @@
using NUnit.Framework;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using MultiversalDiplomacy.Script;
namespace MultiversalDiplomacyTests;
@ -16,7 +17,7 @@ public class ReplTest
repl["""
unit Germany A Munich
unit Austria Army Tyrolia
unit England F London
unit England F Lon
"""].Ready();
Assert.That(repl.Handler, Is.TypeOf<SetupScriptHandler>());
@ -32,4 +33,42 @@ public class ReplTest
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

@ -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.