5dplomacy/MultiversalDiplomacy/Model/OrderParser.cs

553 lines
21 KiB
C#
Raw Normal View History

2024-08-25 04:26:10 +00:00
using System.Diagnostics.CodeAnalysis;
2024-08-20 14:39:49 +00:00
using System.Text.RegularExpressions;
2024-08-25 04:26:10 +00:00
using MultiversalDiplomacy.Orders;
2024-08-20 14:39:49 +00:00
namespace MultiversalDiplomacy.Model;
2024-08-26 16:32:33 +00:00
/// <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>
2024-08-27 03:23:28 +00:00
public class OrderParser(World world)
2024-08-20 14:39:49 +00:00
{
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 ]+)\\))";
2024-08-25 03:50:08 +00:00
public string FullLocation => $"(?:{Timeline}-)?{world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?(?:@{Turn})?";
2024-08-20 14:39:49 +00:00
public string UnitSpec => $"(?:{Type} )?{FullLocation}";
2024-08-20 14:39:49 +00:00
public const string HoldVerb = "(h|hold|holds)";
2024-09-07 02:45:13 +00:00
public const string MoveVerb = "(-|(?:->)|(?:=>)|(?:to)|(?:attack(?:s)?)|(?:move(?:s)?(?: to)?))";
2024-08-20 14:39:49 +00:00
2024-08-26 16:32:33 +00:00
public const string SupportVerb = "(s|support|supports)";
public const string ViaConvoy = "(convoy|via convoy|by convoy)";
2024-09-07 02:45:13 +00:00
public const string ConvoyVerb = "(c|convoy|convoys)";
2024-08-28 00:45:38 +00:00
public Regex UnitDeclaration = new(
$"^{world.Map.PowerRegex} {Type} {world.Map.ProvinceRegex}(?:{SlashLocation}|{ParenLocation})?$",
RegexOptions.IgnoreCase);
2024-08-27 03:23:28 +00:00
public static (
string power,
2024-08-28 00:45:38 +00:00
string type,
string province,
string location)
ParseUnitDeclaration(Match match) => (
2024-08-27 03:23:28 +00:00
match.Groups[1].Value,
2024-08-28 00:45:38 +00:00
match.Groups[2].Value,
match.Groups[3].Value,
match.Groups[4].Value.Length > 0
? match.Groups[4].Value
: match.Groups[5].Value);
2024-08-27 03:23:28 +00:00
2024-08-26 15:39:42 +00:00
public Regex Hold => new(
$"^{UnitSpec} {HoldVerb}$",
RegexOptions.IgnoreCase);
2024-08-20 14:39:49 +00:00
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);
2024-08-26 15:39:42 +00:00
public Regex Move => new(
$"^{UnitSpec} {MoveVerb} {FullLocation}(?: {ViaConvoy})?$",
RegexOptions.IgnoreCase);
2024-08-20 14:39:49 +00:00
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,
2024-08-26 15:39:42 +00:00
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,
2024-08-26 15:39:42 +00:00
match.Groups[10].Length > 0
? match.Groups[10].Value
: match.Groups[11].Value,
match.Groups[12].Value,
match.Groups[13].Value);
2024-08-25 04:26:10 +00:00
2024-08-26 16:32:33 +00:00
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);
2024-08-26 17:47:52 +00:00
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) => (
2024-09-07 02:45:13 +00:00
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 Regex Convoy => new(
$"{UnitSpec} {ConvoyVerb} {UnitSpec} {MoveVerb} {FullLocation}$",
RegexOptions.IgnoreCase);
public static (
string type,
string timeline,
string province,
string location,
string turn,
string convoyVerb,
string targetType,
string targetTimeline,
string targetProvince,
string targetLocation,
string targetTurn,
string moveVerb,
string destTimeline,
string destProvince,
string destLocation,
string destTurn)
ParseConvoy(Match match) => (
2024-08-26 17:47:52 +00:00
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);
2024-08-25 04:26:10 +00:00
public static bool TryParseUnit(World world, string unitSpec, [NotNullWhen(true)] out Unit? newUnit)
{
newUnit = null;
2024-08-28 00:45:38 +00:00
OrderParser re = new(world);
2024-08-25 04:26:10 +00:00
2024-08-28 00:45:38 +00:00
Match match = re.UnitDeclaration.Match(unitSpec);
if (!match.Success) return false;
var unit = ParseUnitDeclaration(match);
2024-08-25 04:26:10 +00:00
2024-08-28 00:45:38 +00:00
string power = world.Map.Powers.First(p => p.EqualsAnyCase(unit.power));
2024-08-25 04:26:10 +00:00
2024-08-28 00:45:38 +00:00
string typeName = Enum.GetNames<UnitType>().First(name => name.StartsWithAnyCase(unit.type));
2024-08-25 04:26:10 +00:00
UnitType type = Enum.Parse<UnitType>(typeName);
2024-08-28 00:45:38 +00:00
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;
2024-08-25 04:26:10 +00:00
newUnit = Unit.Build(location.Key, Season.First, power, type);
return true;
}
2024-09-05 05:22:07 +00:00
public static bool TryParseOrder(World world, string power, string command, [NotNullWhen(true)] out Order? order)
{
2024-08-25 04:26:10 +00:00
order = null;
2024-08-27 03:23:28 +00:00
OrderParser re = new(world);
2024-08-27 02:43:12 +00:00
2024-09-07 03:01:44 +00:00
if (re.Hold.Match(command) is Match holdMatch && holdMatch.Success)
{
2024-08-27 02:43:12 +00:00
return TryParseHoldOrder(world, power, holdMatch, out order);
2024-09-07 03:01:44 +00:00
}
else if (re.Move.Match(command) is Match moveMatch && moveMatch.Success)
{
2024-08-27 02:43:12 +00:00
return TryParseMoveOrder(world, power, moveMatch, out order);
2024-09-07 03:01:44 +00:00
}
else if (re.SupportHold.Match(command) is Match sholdMatch && sholdMatch.Success)
{
return TryParseSupportHoldOrder(world, power, sholdMatch, out order);
2024-09-07 03:01:44 +00:00
}
else if (re.SupportMove.Match(command) is Match smoveMatch && smoveMatch.Success)
{
return TryParseSupportMoveOrder(world, power, smoveMatch, out order);
2024-09-07 03:01:44 +00:00
}
else if (re.Convoy.Match(command) is Match convoyMatch && convoyMatch.Success)
{
return TryParseConvoyOrder(world, power, convoyMatch, out order);
}
else
{
2024-09-01 04:54:28 +00:00
return false;
2024-08-25 04:26:10 +00:00
}
2024-08-27 02:43:12 +00:00
}
public static bool TryParseOrderSubject(
World world,
string parsedTimeline,
string parsedTurn,
string parsedProvince,
[NotNullWhen(true)] out Unit? subject)
2024-08-27 02:43:12 +00:00
{
subject = null;
string timeline = parsedTimeline.Length > 0
? parsedTimeline
2024-08-27 02:43:12 +00:00
// 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)
2024-08-27 02:43:12 +00:00
// 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));
2024-08-27 02:43:12 +00:00
// 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
2024-08-27 02:43:12 +00:00
=> 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;
}
2024-08-27 02:43:12 +00:00
order = new HoldOrder(power, subject);
return true;
}
public static bool TryParseMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
2024-08-27 02:43:12 +00:00
{
order = null;
var move = ParseMove(match);
if (!TryParseOrderSubject(world, move.timeline, move.turn, move.province, out Unit? subject)) {
return false;
}
2024-08-27 02:43:12 +00:00
string destTimeline = move.destTimeline.Length > 0
? move.destTimeline
// If the destination is unspecified, use the unit's
: subject.Season.Timeline;
int destTurn = move.destTurn.Length > 0
? int.Parse(move.destTurn)
// If the destination is unspecified, use the unit's
: subject.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(move.destProvince));
string? destLocationKey = null;
2024-08-25 04:26:10 +00:00
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
2024-08-27 02:43:12 +00:00
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => subject.Type == UnitType.Army,
LocationType.Water => subject.Type == UnitType.Fleet,
_ => false,
});
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
2024-08-27 02:43:12 +00:00
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
2024-08-27 02:43:12 +00:00
if (destLocationKey is null) {
var matchingLocations = unitLocations.Where(loc => loc.Is(move.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
2024-08-27 02:43:12 +00:00
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(subject.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
2024-08-27 02:43:12 +00:00
}
// If the order is still ambiguous, fail per DATC 4.B.1.
if (destLocationKey is null) return false;
2024-08-27 02:43:12 +00:00
order = new MoveOrder(power, subject, new(destTimeline, destTurn), destLocationKey);
return true;
2024-08-25 04:26:10 +00:00
}
public static bool TryParseSupportHoldOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var support = ParseSupportHold(match);
2024-09-05 05:22:07 +00:00
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
order = new SupportHoldOrder(power, subject, target);
return true;
}
public static bool TryParseSupportMoveOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var support = ParseSupportMove(match);
2024-09-05 05:27:48 +00:00
if (!TryParseOrderSubject(world, support.timeline, support.turn, support.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, support.targetTimeline, support.targetTurn, support.targetProvince, out Unit? target))
{
return false;
}
string destTimeline = support.destTimeline.Length > 0
? support.destTimeline
// If the destination is unspecified, use the target's
: target.Season.Timeline;
int destTurn = support.destTurn.Length > 0
? int.Parse(support.destTurn)
// If the destination is unspecified, use the unit's
: target.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(support.destProvince));
string? destLocationKey = null;
2024-09-05 05:27:48 +00:00
// DATC 4.B specifies how to interpret orders with missing or incorrect locations. These issues arise because
// of provinces with multiple locations of the same type, i.e. two-coast provinces in Classical. In general,
// DATC's only concern is to disambiguate the order, failing the order only when it is ineluctably ambiguous
// (4.B.1) or explicitly incorrect (4.B.3). Irrelevant or nonexistent locations can be ignored.
// If there is only one possible location for the moving unit, that location is used. The idea of land and
// water locations is an implementation detail of 5dplomacy and not part of the Diplomacy rules, so they will
// usually be omitted, and so moving an army to any land province or a fleet to a non-multi-coast province is
// naturally unambiguous even without the location.
2024-09-05 05:27:48 +00:00
var unitLocations = destProvince.Locations.Where(loc => loc.Type switch {
LocationType.Land => target.Type == UnitType.Army,
LocationType.Water => target.Type == UnitType.Fleet,
_ => false,
});
if (!unitLocations.Any()) return false; // If *no* locations match, the move is illegal
if (unitLocations.Count() == 1) destLocationKey ??= unitLocations.Single().Key;
2024-09-05 05:27:48 +00:00
// If more than one location is possible for the unit, the order must be disambiguated by the dest location
// or the physical realities of which coast is accessible. DATC 4.B.3 makes an order illegal if the location
// is specified but it isn't an accessible coast, so successfully specifying a location takes precedence over
// there being one accessible coast.
2024-09-05 05:27:48 +00:00
if (destLocationKey is null) {
var matchingLocations = unitLocations.Where(loc => loc.Is(support.destLocation));
if (matchingLocations.Any()) destLocationKey ??= matchingLocations.Single().Key;
}
2024-09-05 05:27:48 +00:00
// If the order location didn't disambiguate the coasts, either because it's missing or it's nonsense, the
// order can be disambiguated by there being one accessible coast from the order source.
if (destLocationKey is null) {
Location source = world.Map.GetLocation(target.Location);
var accessibleLocations = destProvince.Locations.Where(loc => loc.Adjacents.Contains(source));
if (accessibleLocations.Count() == 1) destLocationKey ??= accessibleLocations.Single().Key;
2024-09-05 05:27:48 +00:00
}
// If the order is still ambiguous, fail per DATC 4.B.1. This also satisfies 4.B.4, which prefers for
// programmatic adjudicators with order validation to require the coasts instead of interpreting the ambiguous
// support by referring to the move order it supports.
if (destLocationKey is null) return false;
2024-09-05 05:27:48 +00:00
var destLocation = world.Map.GetLocation(destLocationKey);
order = new SupportMoveOrder(power, subject, target, new(destTimeline, destTurn), destLocation);
return true;
}
2024-09-07 03:01:44 +00:00
public static bool TryParseConvoyOrder(
World world,
string power,
Match match,
[NotNullWhen(true)] out Order? order)
{
order = null;
var convoy = ParseConvoy(match);
if (!TryParseOrderSubject(world, convoy.timeline, convoy.turn, convoy.province, out Unit? subject)) {
return false;
}
if (!TryParseOrderSubject(
world, convoy.targetTimeline, convoy.targetTurn, convoy.targetProvince, out Unit? target))
{
return false;
}
string destTimeline = convoy.destTimeline.Length > 0
? convoy.destTimeline
// If the destination is unspecified, use the target's
: target.Season.Timeline;
int destTurn = convoy.destTurn.Length > 0
? int.Parse(convoy.destTurn)
// If the destination is unspecified, use the unit's
: target.Season.Turn;
var destProvince = world.Map.Provinces.Single(province => province.Is(convoy.destProvince));
// Only armies can be convoyed, which means the destination location can only be land.
var landLocations = destProvince.Locations.Where(loc => loc.Type == LocationType.Land);
if (!landLocations.Any()) return false; // Can't convoy to water
string destLocationKey = landLocations.First().Key;
var destLocation = world.Map.GetLocation(destLocationKey);
order = new ConvoyOrder(power, subject, target, new(destTimeline, destTurn), destLocation);
return true;
}
2024-08-27 02:43:12 +00:00
}