diff --git a/MultiversalDiplomacy/Map/Map.cs b/MultiversalDiplomacy/Map/Map.cs new file mode 100644 index 0000000..dc70b58 --- /dev/null +++ b/MultiversalDiplomacy/Map/Map.cs @@ -0,0 +1,35 @@ +using MultiversalDiplomacy.Model; + +namespace MultiversalDiplomacy.Map; + +/// +/// A collection of provinces. Provides shortcut functions for referencing provinces. +/// +public abstract class Map +{ + public abstract IEnumerable Provinces { get; } + + /// + /// Returns the sole army-accessible location of a province. + /// + public Location Land(string provinceName) + => Provinces + .Single(p => p.Name == provinceName || p.Abbreviations.Contains(provinceName)) + .Locations.Single(l => l.Type == LocationType.Land); + + /// + /// Returns the sole fleet-accessible location of a province. + /// + public Location Water(string provinceName) + => Provinces + .Single(p => p.Name == provinceName || p.Abbreviations.Contains(provinceName)) + .Locations.Single(l => l.Type == LocationType.Water); + + /// + /// Returns the specified fleet-accessible location of a province with distinct coasts. + /// + public Location Coast(string provinceName, string coastName) + => Provinces + .Single(p => p.Name == provinceName || p.Abbreviations.Contains(provinceName)) + .Locations.Single(l => l.Name == coastName || l.Abbreviation == coastName); +} \ No newline at end of file diff --git a/MultiversalDiplomacy/Map/StandardMap.cs b/MultiversalDiplomacy/Map/StandardMap.cs new file mode 100644 index 0000000..e2f8a18 --- /dev/null +++ b/MultiversalDiplomacy/Map/StandardMap.cs @@ -0,0 +1,311 @@ +using System.Reflection; + +using MultiversalDiplomacy.Model; + +namespace MultiversalDiplomacy.Map; + +/// +/// The standard Diplomacy map. +/// +public class StandardMap : Map +{ + public override IEnumerable Provinces { get; } + + public static StandardMap Instance { get; } = new StandardMap(); + + private StandardMap() + { + this.Provinces = new List() + { + Province.Empty("North Africa", "NAF") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Tunis", "TUN") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Bohemia", "BOH") + .AddLandLocation(), + Province.Supply("Budapest", "BUD") + .AddLandLocation(), + Province.Empty("Galacia", "GAL") + .AddLandLocation(), + Province.Supply("Trieste", "TRI") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Tyrolia", "TYR") + .AddLandLocation(), + Province.Time("Vienna", "VIE") + .AddLandLocation(), + Province.Empty("Albania", "ALB") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Bulgaria", "BUL") + .AddLandLocation() + .AddCoastLocation("east coast", "ec") + .AddCoastLocation("south coast", "sc"), + Province.Supply("Greece", "GRE") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Rumania", "RUM", "RMA") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Serbia", "SER") + .AddLandLocation(), + Province.Empty("Clyde", "CLY") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Edinburgh", "EDI") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Liverpool", "LVP", "LPL") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("London", "LON") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Wales", "WAL") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Yorkshire", "YOR") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Brest", "BRE") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Burgundy", "BUR") + .AddLandLocation(), + Province.Empty("Gascony", "GAS") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Marseilles", "MAR") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("Paris", "PAR") + .AddLandLocation(), + Province.Empty("Picardy", "PIC") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("Berlin", "BER") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Kiel", "KIE") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Munich", "MUN") + .AddLandLocation(), + Province.Empty("Prussia", "PRU") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Ruhr", "RUH", "RHR") + .AddLandLocation(), + Province.Empty("Silesia", "SIL") + .AddLandLocation(), + Province.Supply("Spain", "SPA") + .AddLandLocation() + .AddCoastLocation("north coast", "nc") + .AddCoastLocation("south coast", "sc"), + Province.Supply("Portugal", "POR") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Apulia", "APU") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Naples", "NAP") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Piedmont", "PIE") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("Rome", "ROM", "RME") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Tuscany", "TUS") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Venice", "VEN") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Belgium", "BEL") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Holland", "HOL") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Finland", "FIN") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Livonia", "LVN", "LVA") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("Moscow", "MOS") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Sevastopol", "SEV") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Saint Petersburg", "STP") + .AddLandLocation() + .AddCoastLocation("north coast", "nc") + .AddCoastLocation("west coast", "wc"), + Province.Empty("Ukraine", "UKR") + .AddLandLocation(), + Province.Supply("Warsaw", "WAR") + .AddLandLocation(), + Province.Supply("Denmark", "DEN") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Norway", "NWY") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Sweden", "SWE") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Ankara", "ANK") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Armenia", "ARM") + .AddLandLocation() + .AddCoastLocation(), + Province.Time("Constantinople", "CON") + .AddLandLocation() + .AddCoastLocation(), + Province.Supply("Smyrna", "SMY") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Syria", "SYR") + .AddLandLocation() + .AddCoastLocation(), + Province.Empty("Barents Sea", "BAR") + .AddOceanLocation(), + Province.Empty("English Channel", "ENC", "ECH") + .AddOceanLocation(), + Province.Empty("Heligoland Bight", "HEL", "HGB") + .AddOceanLocation(), + Province.Empty("Irish Sea", "IRS", "IRI") + .AddOceanLocation(), + Province.Empty("Mid-Atlantic Ocean", "MAO", "MID") + .AddOceanLocation(), + Province.Empty("North Atlantic Ocean", "NAO", "NAT") + .AddOceanLocation(), + Province.Empty("North Sea", "NTH", "NTS") + .AddOceanLocation(), + Province.Empty("Norwegian Sea", "NWS", "NWG") + .AddOceanLocation(), + Province.Empty("Skagerrak", "SKA", "SKG") + .AddOceanLocation(), + Province.Empty("Baltic Sea", "BAL") + .AddOceanLocation(), + Province.Empty("Guld of Bothnia", "GOB", "BOT") + .AddOceanLocation(), + Province.Empty("Adriatic Sea", "ADS", "ADR") + .AddOceanLocation(), + Province.Empty("Aegean Sea", "AEG") + .AddOceanLocation(), + Province.Empty("Black Sea", "BLA") + .AddOceanLocation(), + Province.Empty("Eastern Mediterranean Sea", "EMS", "EAS") + .AddOceanLocation(), + Province.Empty("Gulf of Lyons", "GOL", "LYO") + .AddOceanLocation(), + Province.Empty("Ionian Sea", "IOS", "ION", "INS") + .AddOceanLocation(), + Province.Empty("Tyrrhenian Sea", "TYS", "TYN") + .AddOceanLocation(), + Province.Empty("Western Mediterranean Sea", "WMS", "WES") + .AddOceanLocation(), + }; + + Land("NAF").AddBorder(Land("TUN")); + Water("NAF").AddBorder(Water("MAO")); + Water("NAF").AddBorder(Water("WES")); + Water("NAF").AddBorder(Water("TUN")); + + Land("TUN").AddBorder(Land("NAF")); + Water("TUN").AddBorder(Water("NAF")); + Water("TUN").AddBorder(Water("WES")); + Water("TUN").AddBorder(Water("TYS")); + Water("TUN").AddBorder(Water("ION")); + + Land("BOH").AddBorder(Land("MUN")); + Land("BOH").AddBorder(Land("SIL")); + Land("BOH").AddBorder(Land("GAL")); + Land("BOH").AddBorder(Land("VIE")); + Land("BOH").AddBorder(Land("TYR")); + + Land("BUD").AddBorder(Land("VIE")); + Land("BUD").AddBorder(Land("GAL")); + Land("BUD").AddBorder(Land("RUM")); + Land("BUD").AddBorder(Land("SER")); + Land("BUD").AddBorder(Land("TRI")); + + Land("GAL").AddBorder(Land("BOH")); + Land("GAL").AddBorder(Land("SIL")); + Land("GAL").AddBorder(Land("WAR")); + Land("GAL").AddBorder(Land("UKR")); + Land("GAL").AddBorder(Land("RUM")); + Land("GAL").AddBorder(Land("BUD")); + Land("GAL").AddBorder(Land("VIE")); + + Land("TRI").AddBorder(Land("VEN")); + Land("TRI").AddBorder(Land("TYR")); + Land("TRI").AddBorder(Land("VIE")); + Land("TRI").AddBorder(Land("BUD")); + Land("TRI").AddBorder(Land("SER")); + Land("TRI").AddBorder(Land("ALB")); + Water("TRI").AddBorder(Water("ALB")); + Water("TRI").AddBorder(Water("ADR")); + Water("TRI").AddBorder(Water("VEN")); + + Land("TYR").AddBorder(Land("MUN")); + Land("TYR").AddBorder(Land("BOH")); + Land("TYR").AddBorder(Land("VIE")); + Land("TYR").AddBorder(Land("TRI")); + Land("TYR").AddBorder(Land("VEN")); + Land("TYR").AddBorder(Land("PIE")); + + Land("VIE").AddBorder(Land("TYR")); + Land("VIE").AddBorder(Land("BOH")); + Land("VIE").AddBorder(Land("GAL")); + Land("VIE").AddBorder(Land("BUD")); + Land("VIE").AddBorder(Land("TRI")); + + Land("ALB").AddBorder(Land("TRI")); + Land("ALB").AddBorder(Land("SER")); + Land("ALB").AddBorder(Land("GRE")); + Water("ALB").AddBorder(Water("TRI")); + Water("ALB").AddBorder(Water("ADR")); + Water("ALB").AddBorder(Water("ION")); + Water("ALB").AddBorder(Water("GRE")); + + Land("BUL").AddBorder(Land("GRE")); + Land("BUL").AddBorder(Land("SER")); + Land("BUL").AddBorder(Land("RUM")); + Land("BUL").AddBorder(Land("CON")); + Coast("BUL", "ec").AddBorder(Water("BLA")); + Coast("BUL", "ec").AddBorder(Water("CON")); + Coast("BUL", "sc").AddBorder(Water("CON")); + Coast("BUL", "sc").AddBorder(Water("AEG")); + Coast("BUL", "sc").AddBorder(Water("GRE")); + + Land("GRE").AddBorder(Land("ALB")); + Land("GRE").AddBorder(Land("SER")); + Land("GRE").AddBorder(Land("BUL")); + Water("GRE").AddBorder(Water("ALB")); + Water("GRE").AddBorder(Water("ION")); + Water("GRE").AddBorder(Water("AEG")); + Water("GRE").AddBorder(Coast("BUL", "sc")); + + // TODO + + Water("IOS").AddBorder(Water("TUN")); + Water("IOS").AddBorder(Water("TYS")); + Water("IOS").AddBorder(Water("NAP")); + Water("IOS").AddBorder(Water("APU")); + Water("IOS").AddBorder(Water("ADR")); + Water("IOS").AddBorder(Water("ALB")); + Water("IOS").AddBorder(Water("GRE")); + Water("IOS").AddBorder(Water("AEG")); + + // TODO + } +} diff --git a/MultiversalDiplomacy/Model/Location.cs b/MultiversalDiplomacy/Model/Location.cs new file mode 100644 index 0000000..a0ecbf9 --- /dev/null +++ b/MultiversalDiplomacy/Model/Location.cs @@ -0,0 +1,52 @@ +namespace MultiversalDiplomacy.Model; + +/// +/// Represents a locus of connectivity in a province. Land-locked and ocean/sea provinces +/// have one location. Coastal provinces have a land location and some number of named +/// water locations. +/// +public class Location +{ + /// + /// The province to which this location belongs. + public Province Province { get; } + + /// + /// The location's full human-readable name. + /// + public string? Name { get; } + + /// + /// The location's shorthand abbreviation. + /// + public string? Abbreviation { get; } + + /// + /// The location's type. + /// + public LocationType Type { get; } + + /// + /// The locations that border this location. + /// + public IEnumerable Adjacents => this.AdjacentList; + private List AdjacentList { get; set; } + + public Location(Province province, string? name, string? abbreviation, LocationType type) + { + this.Province = province; + this.Name = name; + this.Abbreviation = abbreviation; + this.Type = type; + this.AdjacentList = new List(); + } + + /// + /// Set another location as bordering this location. + /// + public void AddBorder(Location other) + { + if (!this.AdjacentList.Contains(other)) this.AdjacentList.Add(other); + if (!other.AdjacentList.Contains(this)) other.AdjacentList.Add(this); + } +} diff --git a/MultiversalDiplomacy/Model/LocationType.cs b/MultiversalDiplomacy/Model/LocationType.cs new file mode 100644 index 0000000..b05ca48 --- /dev/null +++ b/MultiversalDiplomacy/Model/LocationType.cs @@ -0,0 +1,17 @@ +namespace MultiversalDiplomacy.Model; + +/// +/// The type of a location in a province. +/// +public enum LocationType +{ + /// + /// An army-accessible location. + /// + Land = 0, + + /// + /// A fleet-accessible location. + /// + Water = 1, +} diff --git a/MultiversalDiplomacy/Model/Province.cs b/MultiversalDiplomacy/Model/Province.cs new file mode 100644 index 0000000..eb08ef5 --- /dev/null +++ b/MultiversalDiplomacy/Model/Province.cs @@ -0,0 +1,103 @@ +namespace MultiversalDiplomacy.Model; + +/// +/// Represents a single province as it exists across all timelines. +/// +public class Province +{ + /// + /// The province's full human-readable name. + /// + public string Name { get; } + + /// + /// The province's shorthand abbreviation. + /// + public string[] Abbreviations { get; } + + /// + /// Whether the province contains a supply center. + /// + public bool IsSupplyCenter { get; } + + /// + /// Whether the province contains a time center. + /// + public bool IsTimeCenter { get; } + + /// + /// The occupiable locations in this province. Only one location can be occupied at a time. + /// + public IEnumerable Locations => LocationList; + private List LocationList { get; set; } + + public Province(string name, string[] abbreviations, bool isSupply, bool isTime) + { + this.Name = name; + this.Abbreviations = abbreviations; + this.IsSupplyCenter = isSupply; + this.IsTimeCenter = isTime; + this.LocationList = new List(); + } + + /// + /// Create a new province with no supply center. + /// + public static Province Empty(string name, params string[] abbreviations) + => new Province(name, abbreviations, isSupply: false, isTime: false); + + /// + /// Create a new province with a supply center. + /// + public static Province Supply(string name, params string[] abbreviations) + => new Province(name, abbreviations, isSupply: true, isTime: false); + + /// + /// Create a new province with a time center. + /// + public static Province Time(string name, params string[] abbreviations) + => new Province(name, abbreviations, isSupply: true, isTime: true); + + /// + /// Create a new land location in this province. + /// + public Province AddLandLocation() + { + Location location = new Location(this, name: null, abbreviation: null, LocationType.Land); + this.LocationList.Add(location); + return this; + } + + /// + /// Create a new ocean location. + /// + public Province AddOceanLocation() + { + Location location = new Location(this, name: null, abbreviation: null, LocationType.Water); + this.LocationList.Add(location); + return this; + } + + /// + /// Create a new coastal location. Coastal locations must have names to disambiguate them + /// from the single land location in coastal provinces. + /// + public Province AddCoastLocation() + { + // Use a default name for provinces with only one coastal location + Location location = new Location(this, "coast", "c", LocationType.Water); + this.LocationList.Add(location); + return this; + } + + /// + /// Create a new coastal location. Coastal locations must have names to disambiguate them + /// from the single land location in coastal provinces. + /// + public Province AddCoastLocation(string name, string abbreviation) + { + Location location = new Location(this, name, abbreviation, LocationType.Water); + this.LocationList.Add(location); + return this; + } +} diff --git a/MultiversalDiplomacyTests/MapTests.cs b/MultiversalDiplomacyTests/MapTests.cs new file mode 100644 index 0000000..21e1e5b --- /dev/null +++ b/MultiversalDiplomacyTests/MapTests.cs @@ -0,0 +1,58 @@ +using MultiversalDiplomacy.Map; +using MultiversalDiplomacy.Model; + +using NUnit.Framework; + +namespace MultiversalDiplomacyTests; + +public class MapTests +{ + IEnumerable LocationClosure(Location location) + { + IEnumerable visited = new List(); + IEnumerable toVisit = new List() { location }; + + while (toVisit.Any()) + { + Location next = toVisit.First(); + toVisit = toVisit.Skip(1); + visited = visited.Append(next); + foreach (Location other in next.Adjacents) + { + if (!visited.Contains(other)) toVisit = toVisit.Append(other); + } + } + + return visited; + } + + [Test] + public void MapCreation() + { + Province left = Province.Empty("Left", "Lef") + .AddLandLocation(); + Province center = Province.Empty("Center", "Cen") + .AddLandLocation(); + Province right = Province.Empty("Right", "Rig") + .AddLandLocation(); + + Location leftL = left.Locations.First(); + Location centerL = center.Locations.First(); + Location rightL = right.Locations.First(); + centerL.AddBorder(leftL); + rightL.AddBorder(centerL); + + IEnumerable closure = LocationClosure(leftL); + Assert.That(closure.Contains(leftL), Is.True, "Expected Left in closure"); + Assert.That(closure.Contains(centerL), Is.True, "Expected Center in closure"); + Assert.That(closure.Contains(rightL), Is.True, "Expected Right in closure"); + } + + [Test] + public void LandAndSeaBorders() + { + Map map = StandardMap.Instance; + Assert.That(map.Land("NAF").Adjacents.Count(), Is.EqualTo(1), "Expected 1 bordering land province"); + Assert.That(map.Water("NAF").Adjacents.Count(), Is.EqualTo(3), "Expected 3 bordering sea provinces"); + } +} \ No newline at end of file diff --git a/MultiversalDiplomacyTests/MultiversalDiplomacyTests.csproj b/MultiversalDiplomacyTests/MultiversalDiplomacyTests.csproj new file mode 100644 index 0000000..76ffa91 --- /dev/null +++ b/MultiversalDiplomacyTests/MultiversalDiplomacyTests.csproj @@ -0,0 +1,21 @@ + + + + net6.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 5e59945..2ed5549 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,36 @@ Neat. VS Code doesn't seamlessly work with nix, so to get the extensions working } } ``` + +## Comprehending all of time and space + +Now for the data model. The state of the world can be described in three layers, so to speak. + +1. The board is the same across time and multiverse. Since it consists of (i) provinces and (ii) borders connecting two provinces, we're going to model it like a graph. However, a simple graph won't work because provinces are differently accessible to armies and fleets, and coasts are part of the same province while having distinct connectivity. Following the data model described in [godip](https://github.com/zond/godip), we will model this by subdividing provinces and making connections between those subdivisions. This will effectively create multiple distinct graphs for fleets and armies within the map, with some nodes grouped for control purposes into provinces. Since the map itself does not change across time, we can make this "layer" completely immutable and shared between turns and timelines. +2. Since we need to preserve the state of the past in order to time travel effectively, we won't be mutating a board state. Instead, we'll use orders submitted for each turn to append a new copy of the board state. This can't be represented by a simple list, since we can have more than one timeline branch off of a particular turn, so instead we'll use something like a directed graph and create turns pointing to their immediate past. +3. Given the map of the board and the state of all timelines, all units have a spatial location on the board and a temporal location in one of the turns. As the game progresses and timelines are extended, we'll create copies of the unit in each turn it appears in. + +This is going to get complicated, and we're looking forward to implementing the [Diplomacy Adjudicator Test Cases](http://web.inter.nl.net/users/L.B.Kruijswijk/), so let's also create a test project: + +``` +5dplomacy:5dplomacy$ dotnet new nunit --name MultiversalDiplomacyTests --output MultiversalDiplomacyTests +``` + +I think dotnet will fetch NUnit when it needs it, but to get it into our environment so VS Code recognizes it, we add it to the nix shell: + +``` +packages = [ pkgs.dotnet-sdk pkgs.dotnetPackages.NUnit3 ]; +``` + +After writing some basic tests, we can run them with: + +``` +5dplomacy:5dplomacy$ dotnet test MultiversalDiplomacyTests/ +[...] +Starting test execution, please wait... +A total of 1 test files matched the specified pattern. + +Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 29 ms - 5dplomacy/MultiversalDiplomacyTests/bin/Debug/net6.0/MultiversalDiplomacyTests.dll (net6.0) +``` + +Neat. diff --git a/flake.nix b/flake.nix index 85905d5..a267b54 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ PS1="5dplomacy:\W$ " ''; DOTNET_CLI_TELEMETRY_OPTOUT = 1; - packages = [ pkgs.dotnet-sdk ]; + packages = [ pkgs.dotnet-sdk pkgs.dotnetPackages.NUnit3 ]; }; } );