Compare commits

..

5 Commits

Author SHA1 Message Date
Tim Van Baak 25d903d91a Refactor Season into a value struct
This keeps the rich features of a Season type without requiring constant string parsing (as much) or going through World to do lookups to get the objects. Since seasons now have value equality instead of reference equality, it's easier to get access to whem when needed. They're still, fundamentally, sugar over a tuple.
2024-08-15 13:51:41 -07:00
Tim Van Baak 566d29e539 Add function to extract information from season keys 2024-08-15 08:51:17 -07:00
Tim Van Baak 4b2712e4bc Reduce Power to string 2024-08-15 07:54:53 -07:00
Tim Van Baak bfdf2d5636 Reduce Unit.Power to string 2024-08-15 07:37:05 -07:00
Tim Van Baak 2e6e6c55b8 Reduce Order.Power to string 2024-08-15 07:30:43 -07:00
34 changed files with 479 additions and 439 deletions

View File

@ -60,9 +60,9 @@ public class MovementDecisions
{
case MoveOrder move:
AdvanceTimeline.Ensure(
move.Season,
() => new(world.Seasons[move.Season], world.OrderHistory[move.Season].Orders));
AdvanceTimeline[move.Season].Orders.Add(move);
move.Season.Key,
() => new(move.Season, world.OrderHistory[move.Season.Key].Orders));
AdvanceTimeline[move.Season.Key].Orders.Add(move);
break;
case SupportHoldOrder supportHold:
@ -94,7 +94,7 @@ public class MovementDecisions
(Province province, string season) UnitPoint(Unit unit)
=> (world.Map.GetLocation(unit.Location).Province, unit.Season.Key);
(Province province, string season) MovePoint(MoveOrder move)
=> (move.Province, move.Season);
=> (move.Province, move.Season.Key);
// Create a hold strength decision with an associated order for every province with a unit.
foreach (UnitOrder order in relevantOrders)
@ -107,12 +107,12 @@ public class MovementDecisions
bool IsIncoming(UnitOrder me, MoveOrder other)
=> me != other
&& world.Seasons[other.Season] == me.Unit.Season
&& other.Season == me.Unit.Season
&& other.Province == world.Map.GetLocation(me.Unit).Province;
bool AreOpposing(MoveOrder one, MoveOrder two)
=> one.Season == two.Unit.Season.Key
&& two.Season == one.Unit.Season.Key
=> one.Season == two.Unit.Season
&& two.Season == one.Unit.Season
&& one.Province == world.Map.GetLocation(two.Unit).Province
&& two.Province == world.Map.GetLocation(one.Unit).Province;
@ -153,7 +153,7 @@ public class MovementDecisions
DoesMove[move] = new(move, opposingMove, competing);
// Ensure a hold strength decision exists for the destination.
HoldStrength.Ensure(MovePoint(move), () => new(move.Province, world.Seasons[move.Season]));
HoldStrength.Ensure(MovePoint(move), () => new(move.Province, move.Season));
}
else if (order is SupportOrder support)
{

View File

@ -50,7 +50,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Invalidate any order given to a unit in the past.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !world.GetFutures(order.Unit.Season).Any(),
order => !world.Timelines.GetFutures(order.Unit.Season).Any(),
ValidationReason.IneligibleForOrder,
ref unitOrders,
ref validationResults);
@ -77,7 +77,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Trivial check: a unit cannot move to where it already is.
AdjudicatorHelpers.InvalidateIfNotMatching(
order => !(order.Location.Key == order.Unit.Location && order.Season == order.Unit.Season.Key),
order => !(order.Location.Key == order.Unit.Location && order.Season == order.Unit.Season),
ValidationReason.DestinationMatchesOrigin,
ref moveOrders,
ref validationResults);
@ -92,9 +92,9 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Map adjacency
world.Map.GetLocation(order.Unit).Adjacents.Contains(order.Location)
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - world.Seasons[order.Season].Turn) <= 1
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& world.InAdjacentTimeline(order.Unit.Season, world.Seasons[order.Season]));
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season));
List<MoveOrder> adjacentMoveOrders = moveOrdersByAdjacency[true].ToList();
List<MoveOrder> nonAdjacentMoveOrders = moveOrdersByAdjacency[false].ToList();
@ -180,7 +180,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Target.Season.Turn) <= 1
// Timeline adjacency
&& world.InAdjacentTimeline(order.Unit.Season, order.Target.Season),
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Target.Season),
ValidationReason.UnreachableSupport,
ref supportHoldOrders,
ref validationResults);
@ -212,7 +212,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Turn adjacency
&& Math.Abs(order.Unit.Season.Turn - order.Season.Turn) <= 1
// Timeline adjacency
&& world.InAdjacentTimeline(order.Unit.Season, order.Season),
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season),
ValidationReason.UnreachableSupport,
ref supportMoveOrders,
ref validationResults);
@ -255,7 +255,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Finally, add implicit hold orders for units without legal orders.
List<Unit> allOrderableUnits = world.Units
.Where(unit => !world.GetFutures(unit.Season).Any())
.Where(unit => !world.Timelines.GetFutures(unit.Season).Any())
.ToList();
HashSet<Unit> orderedUnits = validOrders.Select(order => order.Unit).ToHashSet();
List<Unit> unorderedUnits = allOrderableUnits
@ -315,6 +315,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
Dictionary<Season, Season> createdFutures = [];
List<Unit> createdUnits = [];
List<RetreatingUnit> retreats = [];
Timelines newTimelines = world.Timelines;
// Populate createdFutures with the timeline fork decisions
logger.Log(1, "Processing AdvanceTimeline decisions");
@ -324,7 +325,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (advanceTimeline.Outcome == true)
{
// A timeline that doesn't have a future yet simply continues. Otherwise, it forks.
createdFutures[advanceTimeline.Season] = world.ContinueOrFork(advanceTimeline.Season);
newTimelines = newTimelines.WithNewSeason(advanceTimeline.Season, out var newFuture);
createdFutures[advanceTimeline.Season] = newFuture;
}
}
@ -334,8 +336,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (DoesMove doesMove in moves.Values)
{
logger.Log(2, "{0} = {1}", doesMove, doesMove.Outcome?.ToString() ?? "?");
Season moveSeason = world.Seasons[doesMove.Order.Season];
if (doesMove.Outcome == true && createdFutures.TryGetValue(moveSeason, out Season? future))
Season moveSeason = doesMove.Order.Season;
if (doesMove.Outcome == true && createdFutures.TryGetValue(moveSeason, out Season future))
{
Unit next = doesMove.Order.Unit.Next(doesMove.Order.Location.Key, future);
logger.Log(3, "Advancing unit to {0}", next);
@ -416,10 +418,10 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// TODO provide more structured information about order outcomes
World updated = world.Update(
seasons: world.Seasons.Values.Concat(createdFutures.Values),
units: world.Units.Concat(createdUnits),
retreats: retreats,
orders: updatedHistory);
orders: updatedHistory,
timelines: newTimelines);
logger.Log(0, "Completed update");
@ -484,7 +486,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
bool progress = false;
// A season at the head of a timeline always advances.
if (!world.GetFutures(decision.Season).Any())
if (!world.Timelines.GetFutures(decision.Season).Any())
{
progress |= LoggedUpdate(decision, true, depth, "A timeline head always advances");
return progress;
@ -493,8 +495,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// The season target of a new (i.e. not previously adjudicated) and successful move always advances.
IEnumerable<MoveOrder> newIncomingMoves = decision.Orders
.OfType<MoveOrder>()
.Where(order => order.Season == decision.Season.Key
&& !world.OrderHistory[order.Season].DoesMoveOutcomes.ContainsKey(order.Unit.Key));
.Where(order => order.Season == decision.Season
&& !world.OrderHistory[order.Season.Key].DoesMoveOutcomes.ContainsKey(order.Unit.Key));
foreach (MoveOrder moveOrder in newIncomingMoves)
{
DoesMove doesMove = decisions.DoesMove[moveOrder];
@ -635,9 +637,9 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (// Map adjacency
world.Map.GetLocation(decision.Order.Unit).Adjacents.Contains(decision.Order.Location)
// Turn adjacency
&& Math.Abs(decision.Order.Unit.Season.Turn - world.Seasons[decision.Order.Season].Turn) <= 1
&& Math.Abs(decision.Order.Unit.Season.Turn - decision.Order.Season.Turn) <= 1
// Timeline adjacency
&& world.InAdjacentTimeline(decision.Order.Unit.Season, world.Seasons[decision.Order.Season]))
&& world.Timelines.InAdjacentTimeline(decision.Order.Unit.Season, decision.Order.Season))
{
bool update = LoggedUpdate(decision, true, depth, "Adjacent move");
return progress | update;
@ -774,7 +776,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// If there is a head to head battle, a unit at the destination that isn't moving away, or
// a unit at the destination that will fail to move away, then the attacking unit will have
// to dislodge it.
UnitOrder? destOrder = decisions.HoldStrength[(decision.Order.Province, decision.Order.Season)].Order;
UnitOrder? destOrder = decisions.HoldStrength[(decision.Order.Province, decision.Order.Season.Key)].Order;
DoesMove? destMoveAway = destOrder is MoveOrder moveAway
? decisions.DoesMove[moveAway]
: null;
@ -793,7 +795,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Is failing to move away
|| destMoveAway.Outcome == false))
{
Power destPower = destOrder.Unit.Power;
string destPower = destOrder.Unit.Power;
if (decision.Order.Unit.Power == destPower)
{
// Cannot dislodge own unit.
@ -823,7 +825,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// the case where it doesn't move and the attack strength is mitigated by supports not
// helping to dislodge units of the same power as the support. The maximum tracks the
// case where it does move and the attack strength is unmitigated.
Power destPower = destMoveAway.Order.Unit.Power;
string destPower = destMoveAway.Order.Unit.Power;
int min = 1;
int max = 1;
foreach (SupportMoveOrder support in decision.Supports)
@ -953,7 +955,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// strength.
NumericAdjudicationDecision defense = decision.OpposingMove != null
? decisions.DefendStrength[decision.OpposingMove]
: decisions.HoldStrength[(decision.Order.Province, decision.Order.Season)];
: decisions.HoldStrength[(decision.Order.Province, decision.Order.Season.Key)];
progress |= ResolveDecision(defense, world, decisions, depth + 1);
// If the attack doesn't beat the defense, resolve the move to false.

View File

@ -18,7 +18,7 @@ public static class PathFinder
/// Determines if a convoy path exists for a move order.
/// </summary>
public static bool ConvoyPathExists(World world, MoveOrder order)
=> ConvoyPathExists(world, order.Unit, order.Location, world.Seasons[order.Season]);
=> ConvoyPathExists(world, order.Unit, order.Location, order.Season);
private static bool ConvoyPathExists(
World world,
@ -111,35 +111,36 @@ public static class PathFinder
/// </summary>
public static IEnumerable<Season> GetAdjacentSeasons(World world, Season season)
{
var pasts = world.Timelines.Pasts;
List<Season> adjacents = [];
// The immediate past and all immediate futures are adjacent.
if (season.Past != null) adjacents.Add(world.Seasons[season.Past]);
adjacents.AddRange(world.GetFutures(season));
if (pasts[season.Key] is Season immediatePast) adjacents.Add(immediatePast);
adjacents.AddRange(world.Timelines.GetFutures(season));
// Find all adjacent timelines by finding all timelines that branched off of this season's
// timeline, i.e. all futures of this season's past that have different timelines. Also
// include any timelines that branched off of the timeline this timeline branched off from.
List<Season> adjacentTimelineRoots = [];
Season? current;
for (current = season;
current?.Past != null && world.Seasons[current.Past].Timeline == current.Timeline;
current = world.Seasons[current.Past])
Season? current = season;
for (;
pasts[current?.Key!] is Season currentPast && currentPast.Timeline == current?.Timeline;
current = pasts[current?.Key!])
{
adjacentTimelineRoots.AddRange(
world.GetFutures(current).Where(s => s.Timeline != current.Timeline));
world.Timelines.GetFutures(current.Value).Where(s => s.Timeline != current?.Timeline));
}
// At the end of the for loop, if this season is part of the first timeline, then current
// is the root season (current.past == null); if this season is in a branched timeline,
// then current is the branch timeline's root season (current.past.timeline !=
// current.timeline). There are co-branches if this season is in a branched timeline, since
// current.timeline). Check for co-branches if this season is in a branched timeline, since
// the first timeline by definition cannot have co-branches.
if (current?.Past != null && world.Seasons[current.Past] is Season past)
if (pasts[current?.Key!] is Season rootPast)
{
IEnumerable<Season> cobranchRoots = world
.GetFutures(past)
.Where(s => s.Timeline != current.Timeline && s.Timeline != past.Timeline);
IEnumerable<Season> cobranchRoots = world.Timelines
.GetFutures(rootPast)
.Where(s => s.Timeline != current?.Timeline && s.Timeline != rootPast.Timeline);
adjacentTimelineRoots.AddRange(cobranchRoots);
}
@ -147,11 +148,13 @@ public static class PathFinder
foreach (Season timelineRoot in adjacentTimelineRoots)
{
for (Season? branchSeason = timelineRoot;
branchSeason != null && branchSeason.Turn <= season.Turn + 1;
branchSeason = world.GetFutures(branchSeason)
.FirstOrDefault(s => s!.Timeline == branchSeason.Timeline, null))
branchSeason is Season branch && branch.Turn <= season.Turn + 1;
branchSeason = world.Timelines
.GetFutures(branchSeason!.Value)
.Cast<Season?>()
.FirstOrDefault(s => s?.Timeline == branchSeason?.Timeline, null))
{
if (branchSeason.Turn >= season.Turn - 1) adjacents.Add(branchSeason);
if (branchSeason?.Turn >= season.Turn - 1) adjacents.Add(branchSeason.Value);
}
}

View File

@ -22,11 +22,11 @@ public class Map
/// <summary>
/// The game powers.
/// </summary>
public IReadOnlyCollection<Power> Powers => _Powers.AsReadOnly();
public IReadOnlyCollection<string> Powers => _Powers.AsReadOnly();
private List<Power> _Powers { get; }
private List<string> _Powers { get; }
private Map(MapType type, IEnumerable<Province> provinces, IEnumerable<Power> powers)
private Map(MapType type, IEnumerable<Province> provinces, IEnumerable<string> powers)
{
Type = type;
_Provinces = provinces.ToList();
@ -84,10 +84,10 @@ public class Map
: GetLocation(provinceName, l => l.Name == coastName || l.Abbreviation == coastName);
/// <summary>
/// Get a power by name. Throws if there is not exactly one such power.
/// Get a power by full or partial name. Throws if there is not exactly one such power.
/// </summary>
public Power GetPower(string powerName)
=> Powers.SingleOrDefault(p => p!.Name == powerName || p.Name.StartsWith(powerName), null)
public string GetPower(string powerName)
=> Powers.SingleOrDefault(p => p!.EqualsAnyCase(powerName) || p!.StartsWithAnyCase(powerName), null)
?? throw new KeyNotFoundException($"Power {powerName} not found (powers: {string.Join(", ", Powers)})");
public static Map FromType(MapType type)
@ -110,10 +110,7 @@ public class Map
center.AddBorder(lef.Locations.First());
center.AddBorder(rig.Locations.First());
Power a = new("Alpha");
Power b = new("Beta");
return new(MapType.Test, [lef, cen, rig], [a, b]);
return new(MapType.Test, [lef, cen, rig], ["Alpha", "Beta"]);
});
public static Map Classical => _Classical.Value;
@ -557,15 +554,15 @@ public class Map
Water("WES").AddBorder(Coast("SPA", "sc"));
#endregion
List<Power> powers =
List<string> powers =
[
new("Austria"),
new("England"),
new("France"),
new("Germany"),
new("Italy"),
new("Russia"),
new("Turkey"),
"Austria",
"England",
"France",
"Germany",
"Italy",
"Russia",
"Turkey",
];
return new(MapType.Classical, provinces, powers);

View File

@ -21,9 +21,6 @@ public static class ModelExtensions
return $"{coord.season.Timeline}-{coord.province.Abbreviations[0]}@{coord.season.Turn}";
}
public static World ContinueOrFork(this World world, Season season, out Season future)
{
future = world.ContinueOrFork(season);
return world.Update(seasons: world.Seasons.Values.Append(future));
}
public static World WithNewSeason(this World world, Season season, out Season future)
=> world.Update(timelines: world.Timelines.WithNewSeason(season, out future));
}

View File

@ -1,22 +0,0 @@
namespace MultiversalDiplomacy.Model;
/// <summary>
/// One of the rival nations vying for control of the map.
/// </summary>
public class Power
{
/// <summary>
/// The power's name.
/// </summary>
public string Name { get; }
public Power(string name)
{
this.Name = name;
}
public override string ToString()
{
return this.Name;
}
}

View File

@ -1,11 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Represents a state of the map produced by a set of move orders on a previous season.
/// Represents a multiversal coordinate at which a state of the map exists.
/// </summary>
public class Season(string? past, int turn, string timeline)
[JsonConverter(typeof(SeasonJsonConverter))]
public struct Season(string timeline, int turn)
{
/// <summary>
/// The first turn number. This is defined to reduce confusion about whether the first turn is 0 or 1.
@ -13,11 +15,9 @@ public class Season(string? past, int turn, string timeline)
public const int FIRST_TURN = 0;
/// <summary>
/// The designation of the season immediately preceding this season.
/// If this season is an alternate timeline root, the past is from the origin timeline.
/// The initial season does not have a past.
/// The timeline to which this season belongs.
/// </summary>
public string? Past { get; } = past;
public string Timeline { get; } = timeline;
/// <summary>
/// The current turn, beginning at 0. Each season (spring and fall) is one turn.
@ -26,16 +26,51 @@ public class Season(string? past, int turn, string timeline)
/// </summary>
public int Turn { get; } = turn;
/// <summary>
/// The timeline to which this season belongs.
/// </summary>
public string Timeline { get; } = timeline;
/// <summary>
/// The multiversal designation of this season.
/// </summary>
[JsonIgnore]
public string Key => $"{this.Timeline}{this.Turn}";
public readonly string Key => $"{this.Timeline}{this.Turn}";
public override string ToString() => Key;
/// <summary>
/// Create a new season from a tuple coordinate.
/// </summary>
public Season((string timeline, int turn) coord) : this(coord.timeline, coord.turn) { }
/// <summary>
/// Create a new season from a combined string designation.
/// </summary>
/// <param name="designation"></param>
public Season(string designation) : this(SplitKey(designation)) { }
/// <summary>
/// Extract the timeline and turn components of a season designation.
/// </summary>
/// <param name="seasonKey">A timeline-turn season designation.</param>
/// <returns>The timeline and turn components.</returns>
/// <exception cref="FormatException"></exception>
public static (string timeline, int turn) SplitKey(string seasonKey)
{
int i = 1;
for (; !char.IsAsciiDigit(seasonKey[i]) && i < seasonKey.Length; i++);
return int.TryParse(seasonKey.AsSpan(i), out int turn)
? (seasonKey[..i], turn)
: throw new FormatException($"Could not parse turn from {seasonKey}");
}
public override readonly string ToString() => Key;
/// <remarks>
/// Seasons are essentially 2D points, so they are equal when their components are equal.
/// </remarks>
public override readonly bool Equals([NotNullWhen(true)] object? obj)
=> obj is Season season
&& Timeline == season.Timeline
&& Turn == season.Turn;
public static bool operator ==(Season one, Season two) => one.Equals(two);
public static bool operator !=(Season one, Season two) => !(one == two);
public override readonly int GetHashCode() => (Timeline, Turn).GetHashCode();
}

View File

@ -0,0 +1,16 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Serializes a <see cref="Season"/> as its combined designation.
/// </summary>
internal class SeasonJsonConverter : JsonConverter<Season>
{
public override Season Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, Season value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.Key);
}

View File

@ -1,55 +0,0 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// A shared counter for handing out new timeline designations.
/// </summary>
[JsonConverter(typeof(TimelineFactoryJsonConverter))]
public class TimelineFactory(int nextTimeline)
{
private static readonly char[] Letters = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z',
];
/// <summary>
/// Convert a string timeline identifier to its serial number.
/// </summary>
/// <param name="timeline">Timeline identifier.</param>
/// <returns>Integer.</returns>
public static int StringToInt(string timeline)
{
int result = Array.IndexOf(Letters, timeline[0]);
for (int i = 1; i < timeline.Length; i++) {
// The result is incremented by one because timeline designations are not a true base26 system.
// The "ones digit" maps a-z 0-25, but the "tens digit" maps a to 1, so "10" (26) is "aa" and not "a0"
result = (result + 1) * 26;
result += Array.IndexOf(Letters, timeline[i]);
}
return result;
}
/// <summary>
/// Convert a timeline serial number to its string identifier.
/// </summary>
/// <param name="designation">Integer.</param>
/// <returns>Timeline identifier.</returns>
public static string IntToString(int designation) {
static int downshift(int i ) => (i - (i % 26)) / 26;
IEnumerable<char> result = [Letters[designation % 26]];
for (int remainder = downshift(designation); remainder > 0; remainder = downshift(remainder) - 1) {
// We subtract 1 after downshifting for the same reason we add 1 above after upshifting.
//
result = result.Prepend(Letters[(remainder % 26 + 25) % 26]);
}
return new string(result.ToArray());
}
public TimelineFactory() : this(0) { }
public int nextTimeline = nextTimeline;
public string NextTimeline() => IntToString(nextTimeline++);
}

View File

@ -1,13 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
internal class TimelineFactoryJsonConverter : JsonConverter<TimelineFactory>
{
public override TimelineFactory? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetInt32());
public override void Write(Utf8JsonWriter writer, TimelineFactory value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value.nextTimeline);
}

View File

@ -0,0 +1,156 @@
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Tracks the relations between seasons.
/// </summary>
public class Timelines(int next, Dictionary<string, Season?> pasts)
{
private static readonly char[] Letters = [
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j',
'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
'u', 'v', 'w', 'x', 'y', 'z',
];
/// <summary>
/// Convert a string timeline identifier to its serial number.
/// </summary>
/// <param name="timeline">Timeline identifier.</param>
/// <returns>Integer.</returns>
public static int StringToInt(string timeline)
{
int result = Array.IndexOf(Letters, timeline[0]);
for (int i = 1; i < timeline.Length; i++) {
// The result is incremented by one because timeline designations are not a true base26 system.
// The "ones digit" maps a-z 0-25, but the "tens digit" maps a to 1, so "10" (26) is "aa" and not "a0"
result = (result + 1) * 26;
result += Array.IndexOf(Letters, timeline[i]);
}
return result;
}
/// <summary>
/// Convert a timeline serial number to its string identifier.
/// </summary>
/// <param name="serial">Integer.</param>
/// <returns>Timeline identifier.</returns>
public static string IntToString(int serial) {
static int downshift(int i ) => (i - (i % 26)) / 26;
IEnumerable<char> result = [Letters[serial % 26]];
for (int remainder = downshift(serial); remainder > 0; remainder = downshift(remainder) - 1) {
// We subtract 1 after downshifting for the same reason we add 1 above after upshifting.
result = result.Prepend(Letters[(remainder % 26 + 25) % 26]);
}
return new string(result.ToArray());
}
/// <summary>
/// Extract the timeline and turn components of a season designation.
/// </summary>
/// <param name="seasonKey">A timeline-turn season designation.</param>
/// <returns>The timeline and turn components.</returns>
/// <exception cref="FormatException"></exception>
public static (string timeline, int turn) SplitKey(string seasonKey)
{
int i = 1;
for (; !char.IsAsciiDigit(seasonKey[i]) && i < seasonKey.Length; i++);
return int.TryParse(seasonKey.AsSpan(i), out int turn)
? (seasonKey[..i], turn)
: throw new FormatException($"Could not parse turn from {seasonKey}");
}
/// <summary>
/// The next timeline to be created.
/// </summary>
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.
/// </summary>
public Dictionary<string, Season?> Pasts { get; } = pasts;
/// <summary>
/// Create a new multiverse with an initial season.
/// </summary>
public static Timelines Create()
{
Season first = new(IntToString(0), Season.FIRST_TURN);
return new Timelines(1, new() { {first.Key, null} });
}
/// <summary>
/// Create a continuation of a season if it has no futures, otherwise create a fork.
/// </summary>
public Timelines WithNewSeason(Season past, out Season future)
{
int next;
(next, future) = GetFutureKeys(past).Any()
? (Next + 1, new Season(IntToString(Next), past.Turn + 1))
: (Next, new Season(past.Timeline, past.Turn + 1));
return new Timelines(next, new(Pasts.Append(new KeyValuePair<string, Season?>(future.Key, past))));
}
/// <summary>
/// Create a continuation of a season if it has no futures, otherwise create a fork.
/// </summary>
public Timelines WithNewSeason(string past, out Season future) => WithNewSeason(new Season(past), out future);
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="season">A season.</param>
/// <returns>The immediate futures of the season.</returns>
public IEnumerable<string> GetFutureKeys(Season season)
=> Pasts.Where(kvp => kvp.Value is Season future && future == season).Select(kvp => kvp.Key);
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="season">A season.</param>
/// <returns>The immediate futures of the season.</returns>
public IEnumerable<Season> GetFutures(Season season) => GetFutureKeys(season).Select(key => new Season(key));
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season GetTimelineRoot(Season season)
{
Console.WriteLine($"GetTimelineRoot({season.Key})");
return Pasts[season.Key] is Season past && season.Timeline == past.Timeline
? GetTimelineRoot(past)
: season;
}
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season GetTimelineRoot(string season) => GetTimelineRoot(new Season(season));
/// <summary>
/// Returns whether a season is in an adjacent timeline to another season.
/// Seasons are considered to be in adjacent timelines if they are in the same timeline,
/// one is in a timeline that branched from the other's timeline, or both are in timelines
/// that branched from the same point.
/// </summary>
public bool InAdjacentTimeline(Season one, Season two)
{
// Timelines are adjacent to themselves. Early out in that case.
if (one == two) return true;
// If the timelines aren't identical, one of them isn't the initial trunk.
// They can still be adjacent if one of them branched off of the other, or
// if they both branched off of the same point.
Season rootOne = GetTimelineRoot(one);
Season rootTwo = GetTimelineRoot(two);
bool oneForked = Pasts[rootOne.Key] is Season originOne && originOne.Timeline == two.Timeline;
bool twoForked = Pasts[rootTwo.Key] is Season originTwo && originTwo.Timeline == one.Timeline;
bool bothForked = Pasts[rootOne.Key] == Pasts[rootTwo.Key];
return oneForked || twoForked || bothForked;
}
}

View File

@ -25,7 +25,7 @@ public class Unit
/// <summary>
/// The allegiance of the unit.
/// </summary>
public Power Power { get; }
public string Power { get; }
/// <summary>
/// The type of unit.
@ -38,7 +38,7 @@ public class Unit
[JsonIgnore]
public string Key => $"{Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
public Unit(string? past, string location, Season season, Power power, UnitType type)
public Unit(string? past, string location, Season season, string power, UnitType type)
{
this.Past = past;
this.Location = location;
@ -48,13 +48,13 @@ public class Unit
}
public override string ToString()
=> $"{Power.Name[0]} {Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
=> $"{Power[0]} {Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
/// <summary>
/// Create a new unit. No validation is performed; the adjudicator should only call this
/// method after accepting a build order.
/// </summary>
public static Unit Build(string location, Season season, Power power, UnitType type)
public static Unit Build(string location, Season season, string power, UnitType type)
=> new(past: null, location, season, power, type);
/// <summary>

View File

@ -37,18 +37,7 @@ public class World
/// The game powers.
/// </summary>
[JsonIgnore]
public IReadOnlyCollection<Power> Powers => this.Map.Powers;
/// <summary>
/// Lookup for seasons by designation.
/// </summary>
public Dictionary<string, Season> Seasons { get; }
/// <summary>
/// The first season of the game.
/// </summary>
[JsonIgnore]
public Season RootSeason => Seasons["a0"];
public IReadOnlyCollection<string> Powers => this.Map.Powers;
/// <summary>
/// All units in the multiverse.
@ -68,7 +57,7 @@ public class World
/// <summary>
/// The shared timeline number generator.
/// </summary>
public TimelineFactory Timelines { get; }
public Timelines Timelines { get; }
/// <summary>
/// Immutable game options.
@ -78,15 +67,13 @@ public class World
[JsonConstructor]
public World(
MapType mapType,
Dictionary<string, Season> seasons,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
TimelineFactory timelines,
Timelines timelines,
Options options)
{
this.Map = Map.FromType(mapType);
this.Seasons = seasons;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
@ -100,15 +87,13 @@ public class World
/// </summary>
private World(
Map map,
Dictionary<string, Season> seasons,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
TimelineFactory timelines,
Timelines timelines,
Options options)
{
this.Map = map;
this.Seasons = seasons;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
@ -121,18 +106,17 @@ public class World
/// </summary>
private World(
World previous,
Dictionary<string, Season>? seasons = null,
List<Unit>? units = null,
List<RetreatingUnit>? retreatingUnits = null,
Dictionary<string, OrderHistory>? orderHistory = null,
Timelines? timelines = null,
Options? options = null)
: this(
previous.Map,
seasons ?? previous.Seasons,
units ?? previous.Units,
retreatingUnits ?? previous.RetreatingUnits,
orderHistory ?? previous.OrderHistory,
previous.Timelines,
timelines ?? previous.Timelines,
options ?? previous.Options)
{
}
@ -142,15 +126,12 @@ public class World
/// </summary>
public static World WithMap(Map map)
{
TimelineFactory timelines = new();
Season a0 = new(past: null, Season.FIRST_TURN, timelines.NextTimeline());
return new World(
map,
new() { {a0.Key, a0} },
new([]),
new([]),
new(new Dictionary<string, OrderHistory>()),
timelines,
Timelines.Create(),
new Options());
}
@ -161,15 +142,12 @@ public class World
=> WithMap(Map.Classical);
public World Update(
IEnumerable<Season>? seasons = null,
IEnumerable<Unit>? units = null,
IEnumerable<RetreatingUnit>? retreats = null,
IEnumerable<KeyValuePair<string, OrderHistory>>? orders = null)
IEnumerable<KeyValuePair<string, OrderHistory>>? orders = null,
Timelines? timelines = null)
=> new(
previous: this,
seasons: seasons == null
? this.Seasons
: new(seasons.ToDictionary(season => season.Key)),
units: units == null
? this.Units
: new(units.ToList()),
@ -178,7 +156,8 @@ public class World
: new(retreats.ToList()),
orderHistory: orders == null
? this.OrderHistory
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)),
timelines: timelines ?? this.Timelines);
/// <summary>
/// Create a new world with new units created from unit specs. Units specs are in the format
@ -190,7 +169,7 @@ public class World
IEnumerable<Unit> units = unitSpecs.Select(spec =>
{
string[] splits = spec.Split(' ', 4);
Power power = Map.GetPower(splits[0]);
string power = Map.GetPower(splits[0]);
UnitType type = splits[1] switch
{
"A" => UnitType.Army,
@ -202,7 +181,7 @@ public class World
: splits.Length == 3
? Map.GetWater(splits[2])
: Map.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location.Key, this.RootSeason, power, type);
Unit unit = Unit.Build(location.Key, new("a0"), power, type);
return unit;
});
return this.Update(units: units);
@ -239,85 +218,18 @@ public class World
);
}
/// <summary>
/// Create a continuation of this season if it has no futures, otherwise create a fork.
/// </summary>
public Season ContinueOrFork(Season season)
=> GetFutures(season).Any()
? new(season.Key, season.Turn + 1, Timelines.NextTimeline())
: new(season.Key, season.Turn + 1, season.Timeline);
/// <summary>
/// A standard Diplomacy game setup.
/// </summary>
public static World Standard => WithStandardMap().AddStandardUnits();
/// <summary>
/// Get a season by coordinate. Throws if the season is not found.
/// </summary>
public Season GetSeason(string timeline, int turn)
=> Seasons[$"{timeline}{turn}"];
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="present">A season designation.</param>
/// <returns>The immediate futures of the designated season.</returns>
public IEnumerable<Season> GetFutures(string present)
=> Seasons.Values.Where(future => future.Past == present);
/// <summary>
/// Get all seasons that are immediate futures of a season.
/// </summary>
/// <param name="present">A season.</param>
/// <returns>The immediate futures of the season.</returns>
public IEnumerable<Season> GetFutures(Season present) => GetFutures(present.Key);
/// <summary>
/// Returns the first season in this season's timeline. The first season is the
/// root of the first timeline. The earliest season in each alternate timeline is
/// the root of that timeline.
/// </summary>
public Season GetTimelineRoot(Season season)
{
if (season.Past is null) {
return season;
}
Season past = Seasons[season.Past];
return season.Timeline == past.Timeline
? GetTimelineRoot(past)
: season;
}
/// <summary>
/// Returns whether this season is in an adjacent timeline to another season.
/// Seasons are considered to be in adjacent timelines if they are in the same timeline,
/// one is in a timeline that branched from the other's timeline, or both are in timelines
/// that branched from the same point.
/// </summary>
public bool InAdjacentTimeline(Season one, Season two)
{
// Timelines are adjacent to themselves. Early out in that case.
if (one == two) return true;
// If the timelines aren't identical, one of them isn't the initial trunk.
// They can still be adjacent if one of them branched off of the other, or
// if they both branched off of the same point.
Season rootOne = GetTimelineRoot(one);
Season rootTwo = GetTimelineRoot(two);
bool oneForked = rootOne.Past != null && Seasons[rootOne.Past].Timeline == two.Timeline;
bool twoForked = rootTwo.Past != null && Seasons[rootTwo.Past].Timeline == one.Timeline;
bool bothForked = rootOne.Past == rootTwo.Past;
return oneForked || twoForked || bothForked;
}
/// <summary>
/// Returns a unit in a province. Throws if there are duplicate units.
/// </summary>
public Unit GetUnitAt(string provinceName, Season? season = null)
{
Province province = Map.GetProvince(provinceName);
season ??= RootSeason;
season ??= new("a0");
Unit? foundUnit = this.Units.SingleOrDefault(
u => Map.GetLocation(u!).Province == province && u!.Season == season,
null)

View File

@ -17,7 +17,7 @@ public class BuildOrder : Order
/// </summary>
public UnitType Type { get; }
public BuildOrder(Power power, Location location, UnitType type)
public BuildOrder(string power, Location location, UnitType type)
: base (power)
{
this.Location = location;

View File

@ -27,7 +27,7 @@ public class ConvoyOrder : UnitOrder
/// </summary>
public Province Province => this.Location.Province;
public ConvoyOrder(Power power, Unit unit, Unit target, Season season, Location location)
public ConvoyOrder(string power, Unit unit, Unit target, Season season, Location location)
: base (power, unit)
{
this.Target = target;

View File

@ -7,6 +7,6 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class DisbandOrder : UnitOrder
{
public DisbandOrder(Power power, Unit unit)
public DisbandOrder(string power, Unit unit)
: base (power, unit) {}
}

View File

@ -7,7 +7,7 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class HoldOrder : UnitOrder
{
public HoldOrder(Power power, Unit unit)
public HoldOrder(string power, Unit unit)
: base (power, unit) {}
public override string ToString()

View File

@ -9,9 +9,8 @@ public class MoveOrder : UnitOrder
{
/// <summary>
/// The destination season to which the unit should move.
/// TODO replace this with timeline and turn individually so ToString can do the proper format
/// </summary>
public string Season { get; }
public Season Season { get; }
/// <summary>
/// The destination location to which the unit should move.
@ -23,7 +22,7 @@ public class MoveOrder : UnitOrder
/// </summary>
public Province Province => this.Location.Province;
public MoveOrder(Power power, Unit unit, string season, Location location)
public MoveOrder(string power, Unit unit, Season season, Location location)
: base (power, unit)
{
this.Season = season;
@ -32,7 +31,7 @@ public class MoveOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} -> {this.Season} {this.Province}";
return $"{this.Unit} -> {Season.Timeline}-{Province}@{Season.Turn}";
}
/// <summary>

View File

@ -22,9 +22,9 @@ public abstract class Order
/// <summary>
/// The power that submitted this order.
/// </summary>
public Power Power { get; }
public string Power { get; }
public Order(Power power)
public Order(string power)
{
this.Power = power;
}

View File

@ -12,7 +12,7 @@ public class RetreatOrder : UnitOrder
/// </summary>
public Location Location { get; }
public RetreatOrder(Power power, Unit unit, Location location)
public RetreatOrder(string power, Unit unit, Location location)
: base (power, unit)
{
this.Location = location;

View File

@ -7,7 +7,7 @@ namespace MultiversalDiplomacy.Orders;
/// </summary>
public class SupportHoldOrder : SupportOrder
{
public SupportHoldOrder(Power power, Unit unit, Unit target)
public SupportHoldOrder(string power, Unit unit, Unit target)
: base (power, unit, target)
{
}

View File

@ -27,7 +27,7 @@ public class SupportMoveOrder : SupportOrder
/// </summary>
public (Province province, Season season) Point => (this.Province, this.Season);
public SupportMoveOrder(Power power, Unit unit, Unit target, Season season, Location location)
public SupportMoveOrder(string power, Unit unit, Unit target, Season season, Location location)
: base(power, unit, target)
{
this.Season = season;
@ -41,6 +41,6 @@ public class SupportMoveOrder : SupportOrder
public bool IsSupportFor(MoveOrder move)
=> this.Target == move.Unit
&& this.Season.Key == move.Season
&& this.Season == move.Season
&& this.Location == move.Location;
}

View File

@ -12,7 +12,7 @@ public abstract class SupportOrder : UnitOrder
/// </summary>
public Unit Target { get; }
public SupportOrder(Power power, Unit unit, Unit target)
public SupportOrder(string power, Unit unit, Unit target)
: base (power, unit)
{
this.Target = target;

View File

@ -17,7 +17,7 @@ public class SustainOrder : Order
/// </summary>
public int Timeline { get; }
public SustainOrder(Power power, Location timeCenter, int timeline)
public SustainOrder(string power, Location timeCenter, int timeline)
: base (power)
{
this.TimeCenter = timeCenter;

View File

@ -22,7 +22,7 @@ public abstract class UnitOrder : Order
/// </summary>
public Unit Unit { get; }
public UnitOrder(Power power, Unit unit) : base(power)
public UnitOrder(string power, Unit unit) : base(power)
{
this.Unit = unit;
}

View File

@ -0,0 +1,10 @@
namespace System;
public static class StringExtensions
{
public static bool EqualsAnyCase(this string str, string? other)
=> str.Equals(other, StringComparison.InvariantCultureIgnoreCase);
public static bool StartsWithAnyCase(this string str, string other)
=> str.StartsWith(other, StringComparison.InvariantCultureIgnoreCase);
}

View File

@ -33,16 +33,16 @@ public class TimeTravelTest
// Confirm that there are now four seasons: three in the main timeline and one in a fork.
Assert.That(
world.Seasons.Values.Where(s => s.Timeline == s0.Timeline).Count(),
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline == s0.Timeline).Count(),
Is.EqualTo(3),
"Failed to advance main timeline after last unit left");
Assert.That(
world.Seasons.Values.Where(s => s.Timeline != s0.Timeline).Count(),
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline != s0.Timeline).Count(),
Is.EqualTo(1),
"Failed to fork timeline when unit moved in");
// Confirm that there is a unit in Tyr b1 originating from Mun a1
Season fork = world.Seasons["b1"];
Season fork = new("b1");
Unit originalUnit = world.GetUnitAt("Mun", s0);
Unit aMun0 = world.GetUnitAt("Mun", s1);
Unit aTyr = world.GetUnitAt("Tyr", fork);
@ -91,7 +91,7 @@ public class TimeTravelTest
// Confirm that an alternate future is created.
World world = setup.UpdateWorld();
Season fork = world.Seasons["b1"];
Season fork = new("b1");
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Assert.That(
tyr1.Past,
@ -136,12 +136,12 @@ public class TimeTravelTest
// change the past and therefore did not create a new timeline.
World world = setup.UpdateWorld();
Assert.That(
world.GetFutures(s0).Count(),
world.Timelines.GetFutures(s0).Count(),
Is.EqualTo(1),
"A failed move incorrectly forked the timeline");
Assert.That(world.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = world.GetSeason(s1.Timeline, s1.Turn + 1);
Assert.That(world.GetFutures(s2).Count(), Is.EqualTo(0));
Assert.That(world.Timelines.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(world.Timelines.GetFutures(s2).Count(), Is.EqualTo(0));
}
[Test]
@ -178,12 +178,12 @@ public class TimeTravelTest
// ...since it succeeded anyway, no fork is created.
World world = setup.UpdateWorld();
Assert.That(
world.GetFutures(s0).Count(),
world.Timelines.GetFutures(s0).Count(),
Is.EqualTo(1),
"A superfluous support incorrectly forked the timeline");
Assert.That(world.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = world.GetSeason(s1.Timeline, s1.Turn + 1);
Assert.That(world.GetFutures(s2).Count(), Is.EqualTo(0));
Assert.That(world.Timelines.GetFutures(s1).Count(), Is.EqualTo(1));
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(world.Timelines.GetFutures(s2).Count(), Is.EqualTo(0));
}
[Test]
@ -226,17 +226,17 @@ public class TimeTravelTest
// Since both seasons were at the head of their timelines, there should be no forking.
World world = setup.UpdateWorld();
Assert.That(
world.GetFutures(a2).Count(),
world.Timelines.GetFutures(a2).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Assert.That(
world.GetFutures(b1).Count(),
world.Timelines.GetFutures(b1).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Season a3 = world.GetSeason(a2.Timeline, a2.Turn + 1);
Assert.That(world.GetFutures(a3).Count(), Is.EqualTo(0));
Season b2 = world.GetSeason(b1.Timeline, b1.Turn + 1);
Assert.That(world.GetFutures(b2).Count(), Is.EqualTo(0));
Season a3 = new(a2.Timeline, a2.Turn + 1);
Assert.That(world.Timelines.GetFutures(a3).Count(), Is.EqualTo(0));
Season b2 = new(b1.Timeline, b1.Turn + 1);
Assert.That(world.Timelines.GetFutures(b2).Count(), Is.EqualTo(0));
}
[Test]
@ -297,11 +297,11 @@ public class TimeTravelTest
// wasn't changed in this timeline.
World world = setup.UpdateWorld();
Assert.That(
world.GetFutures(a3).Count(),
world.Timelines.GetFutures(a3).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
Assert.That(
world.GetFutures(b2).Count(),
world.Timelines.GetFutures(b2).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
}

View File

@ -18,21 +18,86 @@ public class TimelineFactoryTest
[TestCase(78, "ca")]
public void RoundTripTimelineKeys(int number, string designation)
{
Assert.That(TimelineFactory.IntToString(number), Is.EqualTo(designation), "Incorrect string");
Assert.That(TimelineFactory.StringToInt(designation), Is.EqualTo(number), "Incorrect number");
Assert.That(Timelines.IntToString(number), Is.EqualTo(designation), "Incorrect string");
Assert.That(Timelines.StringToInt(designation), Is.EqualTo(number), "Incorrect number");
}
[TestCase("a0", "a", 0)]
[TestCase("a1", "a", 1)]
[TestCase("a10", "a", 10)]
[TestCase("aa2", "aa", 2)]
[TestCase("aa22", "aa", 22)]
public void SeasonKeySplit(string key, string timeline, int turn)
{
Assert.That(Timelines.SplitKey(key), Is.EqualTo((timeline, turn)), "Failed to split key");
}
[Test]
public void NoSharedFactoryState()
{
TimelineFactory one = new();
TimelineFactory two = new();
Timelines one = Timelines.Create()
.WithNewSeason(new Season("a0"), out var s1)
.WithNewSeason(new Season("a0"), out var s2)
.WithNewSeason(new Season("a0"), out var s3);
Timelines two = Timelines.Create()
.WithNewSeason(new Season("a0"), out var s4)
.WithNewSeason(new Season("a0"), out var s5);
Assert.That(one.NextTimeline(), Is.EqualTo("a"));
Assert.That(one.NextTimeline(), Is.EqualTo("b"));
Assert.That(one.NextTimeline(), Is.EqualTo("c"));
Assert.That(s1.Timeline, Is.EqualTo("a"));
Assert.That(s2.Timeline, Is.EqualTo("b"));
Assert.That(s3.Timeline, Is.EqualTo("c"));
Assert.That(s4.Timeline, Is.EqualTo("a"), "Unexpected first timeline");
Assert.That(s5.Timeline, Is.EqualTo("b"), "Unexpected second timeline");
}
Assert.That(two.NextTimeline(), Is.EqualTo("a"));
Assert.That(two.NextTimeline(), Is.EqualTo("b"));
[Test]
public void TimelineForking()
{
Timelines timelines = Timelines.Create()
.WithNewSeason("a0", out var a1)
.WithNewSeason(a1, out var a2)
.WithNewSeason(a2, out var a3)
.WithNewSeason(a1, out var b2)
.WithNewSeason(b2, out var b3)
.WithNewSeason(a1, out var c2)
.WithNewSeason(a2, out var d3);
Season a0 = new("a0");
Assert.That(
timelines.Pasts.Keys,
Is.EquivalentTo(new List<string> { "a0", "a1", "a2", "a3", "b2", "b3", "c2", "d3" }),
"Unexpected seasons");
Assert.That(a1.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a2.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a3.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(b2.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(b3.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(c2.Timeline, Is.EqualTo("c"), "Unexpected second alt");
Assert.That(d3.Timeline, Is.EqualTo("d"), "Unexpected third alt");
Assert.That(a1.Turn, Is.EqualTo(Season.FIRST_TURN + 1), "Unexpected a1 turn number");
Assert.That(a2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected a2 turn number");
Assert.That(a3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected a3 turn number");
Assert.That(b2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected b2 turn number");
Assert.That(b3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected b3 turn number");
Assert.That(c2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected c2 turn number");
Assert.That(d3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected d3 turn number");
Assert.That(timelines.GetTimelineRoot(a0), Is.EqualTo(a0), "Expected timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(a3), Is.EqualTo(a0), "Expected trunk timeline to have root");
Assert.That(timelines.GetTimelineRoot(b2), Is.EqualTo(b2), "Expected alt timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(b3), Is.EqualTo(b2), "Expected alt timeline to root at first fork");
Assert.That(timelines.GetTimelineRoot(c2), Is.EqualTo(c2), "Expected alt timeline root to be reflexive");
Assert.That(timelines.GetTimelineRoot(d3), Is.EqualTo(d3), "Expected alt timeline root to be reflexive");
Assert.That(timelines.InAdjacentTimeline(b3, a3), Is.True, "Expected alts to be adjacent to origin");
Assert.That(timelines.InAdjacentTimeline(b3, c2), Is.True, "Expected alts with common origin to be adjacent");
Assert.That(timelines.InAdjacentTimeline(b3, d3), Is.False, "Expected alts from different origins not to be adjacent");
Assert.That(timelines.GetFutures(a0), Is.EquivalentTo(new List<Season> { a1 }), "Unexpected futures");
Assert.That(timelines.GetFutures(a1), Is.EquivalentTo(new List<Season> { a2, b2, c2 }), "Unexpected futures");
Assert.That(timelines.GetFutures(a2), Is.EquivalentTo(new List<Season> { a3, d3 }), "Unexpected futures");
Assert.That(timelines.GetFutures(b2), Is.EquivalentTo(new List<Season> { b3 }), "Unexpected futures");
}
}

View File

@ -168,18 +168,16 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Assert.That(updated.Seasons.Values.Count, Is.EqualTo(2));
Season future = updated.Seasons.Values.Single(s => s != updated.RootSeason);
Assert.That(future.Past, Is.EqualTo(updated.RootSeason.ToString()));
Assert.That(updated.GetFutures(future), Is.Empty);
Assert.That(future.Timeline, Is.EqualTo(updated.RootSeason.Timeline));
Assert.That(future.Turn, Is.EqualTo(Season.FIRST_TURN + 1));
// Confirm the unit was created
Assert.That(updated.Units.Count, Is.EqualTo(2));
Unit second = updated.Units.Single(u => u.Past != null);
Assert.That(second.Location, Is.EqualTo(mun.Order.Unit.Location));
Assert.That(second.Season.Timeline, Is.EqualTo(mun.Order.Unit.Season.Timeline));
// Confirm that the unit's season exists
CollectionAssert.Contains(updated.Timelines.Pasts.Keys, second.Season.Key, "Season was not added");
CollectionAssert.DoesNotContain(updated.Timelines.Pasts.Values, second.Season.Key, "Season should not have a future");
}
[Test]
@ -199,9 +197,9 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = updated.Seasons["a1"];
Assert.That(s2.Past, Is.EqualTo(s1.ToString()));
Assert.That(updated.GetFutures(s2), Is.Empty);
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(updated.Timelines.Pasts[s2.Key], Is.EqualTo(s1));
Assert.That(updated.Timelines.GetFutures(s2), Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
@ -227,7 +225,7 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = updated.GetSeason(s2.Timeline, s2.Turn + 1);
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(mun2.Order.Unit.Key));
}
@ -249,9 +247,9 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
Season s2 = updated.GetSeason(s1.Timeline, s1.Turn + 1);
Assert.That(s2.Past, Is.EqualTo(s1.ToString()));
Assert.That(updated.GetFutures(s2), Is.Empty);
Season s2 = new(s1.Timeline, s1.Turn + 1);
Assert.That(updated.Timelines.Pasts[s2.Key], Is.EqualTo(s1));
Assert.That(updated.Timelines.GetFutures(s2), Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
@ -277,7 +275,7 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = updated.GetSeason(s2.Timeline, s2.Turn + 1);
Season s3 = new(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(u2.Key));
}

View File

@ -1,62 +0,0 @@
using MultiversalDiplomacy.Model;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
public class SeasonTests
{
[Test]
public void TimelineForking()
{
World world = World.WithMap(Map.Test);
Season a0 = world.Seasons["a0"];
world = world
.ContinueOrFork(a0, out Season a1)
.ContinueOrFork(a1, out Season a2)
.ContinueOrFork(a2, out Season a3)
.ContinueOrFork(a1, out Season b2)
.ContinueOrFork(b2, out Season b3)
.ContinueOrFork(a1, out Season c2)
.ContinueOrFork(a2, out Season d3);
Assert.That(
world.Seasons.Values.Select(season => season.ToString()),
Is.EquivalentTo(new List<string> { "a0", "a1", "a2", "a3", "b2", "b3", "c2", "d3" }),
"Unexpected seasons");
Assert.That(a0.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a1.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a2.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(a3.Timeline, Is.EqualTo("a"), "Unexpected trunk timeline");
Assert.That(b2.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(b3.Timeline, Is.EqualTo("b"), "Unexpected first alt");
Assert.That(c2.Timeline, Is.EqualTo("c"), "Unexpected second alt");
Assert.That(d3.Timeline, Is.EqualTo("d"), "Unexpected third alt");
Assert.That(a0.Turn, Is.EqualTo(Season.FIRST_TURN + 0), "Unexpected first turn number");
Assert.That(a1.Turn, Is.EqualTo(Season.FIRST_TURN + 1), "Unexpected next turn number");
Assert.That(a2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected next turn number");
Assert.That(a3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected next turn number");
Assert.That(b2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected fork turn number");
Assert.That(b3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected fork turn number");
Assert.That(c2.Turn, Is.EqualTo(Season.FIRST_TURN + 2), "Unexpected fork turn number");
Assert.That(d3.Turn, Is.EqualTo(Season.FIRST_TURN + 3), "Unexpected fork turn number");
Assert.That(world.GetTimelineRoot(a0), Is.EqualTo(a0), "Expected timeline root to be reflexive");
Assert.That(world.GetTimelineRoot(a3), Is.EqualTo(a0), "Expected trunk timeline to have root");
Assert.That(world.GetTimelineRoot(b2), Is.EqualTo(b2), "Expected alt timeline root to be reflexive");
Assert.That(world.GetTimelineRoot(b3), Is.EqualTo(b2), "Expected alt timeline to root at first fork");
Assert.That(world.GetTimelineRoot(c2), Is.EqualTo(c2), "Expected alt timeline root to be reflexive");
Assert.That(world.GetTimelineRoot(d3), Is.EqualTo(d3), "Expected alt timeline root to be reflexive");
Assert.That(world.InAdjacentTimeline(b3, a3), Is.True, "Expected alts to be adjacent to origin");
Assert.That(world.InAdjacentTimeline(b3, c2), Is.True, "Expected alts with common origin to be adjacent");
Assert.That(world.InAdjacentTimeline(b3, d3), Is.False, "Expected alts from different origins not to be adjacent");
Assert.That(world.GetFutures(a0), Is.EquivalentTo(new List<Season> { a1 }), "Unexpected futures");
Assert.That(world.GetFutures(a1), Is.EquivalentTo(new List<Season> { a2, b2, c2 }), "Unexpected futures");
Assert.That(world.GetFutures(a2), Is.EquivalentTo(new List<Season> { a3, d3 }), "Unexpected futures");
Assert.That(world.GetFutures(b2), Is.EquivalentTo(new List<Season> { b3 }), "Unexpected futures");
}
}

View File

@ -18,31 +18,31 @@ public class SerializationTest
public void SerializeRoundTrip_NewGame()
{
World world = World.WithStandardMap();
string serialized = JsonSerializer.Serialize(world, Options);
World? deserialized = JsonSerializer.Deserialize<World>(serialized, Options);
Assert.That(deserialized, Is.Not.Null, "Failed to deserialize");
Assert.That(deserialized!.Map, Is.Not.Null, "Failed to deserialize map");
Assert.That(deserialized!.Seasons, Is.Not.Null, "Failed to deserialize seasons");
Assert.That(deserialized!.Units, Is.Not.Null, "Failed to deserialize units");
Assert.That(deserialized!.RetreatingUnits, Is.Not.Null, "Failed to deserialize retreats");
Assert.That(deserialized!.OrderHistory, Is.Not.Null, "Failed to deserialize history");
Assert.That(deserialized!.Timelines, Is.Not.Null, "Failed to deserialize timelines");
Assert.That(deserialized!.Options, Is.Not.Null, "Failed to deserialize options");
Assert.That(deserialized.Timelines.nextTimeline, Is.EqualTo(world.Timelines.nextTimeline));
JsonElement document = JsonSerializer.SerializeToDocument(world, Options).RootElement;
Assert.That(
document.EnumerateObject().Select(prop => prop.Name),
Is.EquivalentTo(new List<string> {
"mapType",
"seasons",
"units",
"retreatingUnits",
"orderHistory",
"options",
"timelines",
}));
string serialized = JsonSerializer.Serialize(world, Options);
World? deserialized = JsonSerializer.Deserialize<World>(serialized, Options);
Assert.That(deserialized, Is.Not.Null, "Failed to deserialize");
Assert.That(deserialized!.Map, Is.Not.Null, "Failed to deserialize map");
Assert.That(deserialized!.Units, Is.Not.Null, "Failed to deserialize units");
Assert.That(deserialized!.RetreatingUnits, Is.Not.Null, "Failed to deserialize retreats");
Assert.That(deserialized!.OrderHistory, Is.Not.Null, "Failed to deserialize history");
Assert.That(deserialized!.Timelines, Is.Not.Null, "Failed to deserialize timelines");
Assert.That(deserialized!.Timelines.Pasts, Is.Not.Null, "Failed to deserialize timeline pasts");
Assert.That(deserialized!.Timelines.Next, Is.EqualTo(world.Timelines.Next));
Assert.That(deserialized!.Options, Is.Not.Null, "Failed to deserialize options");
}
[Test]
@ -80,13 +80,13 @@ public class SerializationTest
Assert.That(reserialized.OrderHistory["a0"].IsDislodgedOutcomes.Count, Is.GreaterThan(0), "Missing dislodges");
});
Assert.Ignore();
Assert.Ignore("Serialization doesn't fully work yet");
// Resume the test case
setup = new(reserialized, MovementPhaseAdjudicator.Instance);
setup[("a", 1)]
["Germany"]
.Army("Mun").Supports.Army("Mun", season: reserialized.Seasons["a0"]).MoveTo("Tyr").GetReference(out var mun1)
.Army("Mun").Supports.Army("Mun", season: new("a0")).MoveTo("Tyr").GetReference(out var mun1)
["Austria"]
.Army("Tyr").Holds();
@ -102,7 +102,7 @@ public class SerializationTest
// Confirm that an alternate future is created.
World world = setup.UpdateWorld();
Season fork = world.Seasons["b1"];
Season fork = new("b1");
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Assert.That(
tyr1.Past,

View File

@ -3,6 +3,7 @@ using System.Collections.ObjectModel;
using MultiversalDiplomacy.Adjudicate;
using MultiversalDiplomacy.Model;
using MultiversalDiplomacy.Orders;
using NUnit.Framework;
namespace MultiversalDiplomacyTests;
@ -240,7 +241,7 @@ public class TestCaseBuilder
/// Get the context for defining the orders for a season.
/// </summary>
public ISeasonContext this[(string timeline, int turn) seasonCoord]
=> new SeasonContext(this, this.World.GetSeason(seasonCoord.timeline, seasonCoord.turn));
=> new SeasonContext(this, new(seasonCoord));
/// <summary>
/// Get a unit matching a description. If no such unit exists, one is created and added to the
@ -254,7 +255,7 @@ public class TestCaseBuilder
/// of this type.
/// </param>
private Unit GetOrBuildUnit(
Power power,
string power,
Location location,
Season season,
UnitType type)
@ -344,13 +345,15 @@ public class TestCaseBuilder
{
public TestCaseBuilder Builder;
public SeasonContext SeasonContext;
public Power Power;
public string Power;
public PowerContext(SeasonContext seasonContext, Power Power)
public PowerContext(SeasonContext seasonContext, string power)
{
Assert.That(power, Is.AnyOf([.. seasonContext.Builder.World.Map.Powers]), "Invalid power");
this.Builder = seasonContext.Builder;
this.SeasonContext = seasonContext;
this.Power = Power;
this.Power = power;
}
public ISeasonContext this[(string timeline, int turn) seasonCoord]
@ -361,7 +364,7 @@ public class TestCaseBuilder
public IUnitContext Army(string provinceName, string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
@ -372,7 +375,7 @@ public class TestCaseBuilder
public IUnitContext Fleet(string provinceName, string? coast = null, string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);
@ -419,7 +422,7 @@ public class TestCaseBuilder
MoveOrder moveOrder = new MoveOrder(
this.PowerContext.Power,
this.Unit,
destSeason.Key,
destSeason,
destination);
this.Builder.OrderList.Add(moveOrder);
return new OrderDefinedContext<MoveOrder>(this, moveOrder);
@ -449,7 +452,7 @@ public class TestCaseBuilder
public IConvoyDestinationContext Army(string provinceName, string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
@ -463,7 +466,7 @@ public class TestCaseBuilder
string? coast = null,
string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);
@ -524,7 +527,7 @@ public class TestCaseBuilder
Season? season = null,
string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetLand(provinceName);
@ -539,7 +542,7 @@ public class TestCaseBuilder
string? coast = null,
string? powerName = null)
{
Power power = powerName == null
string power = powerName == null
? this.PowerContext.Power
: this.Builder.World.Map.GetPower(powerName);
Location location = this.Builder.World.Map.GetWater(provinceName, coast);

View File

@ -28,15 +28,15 @@ class TestCaseBuilderTest
Assert.That(setup.World.Units, Is.Not.Empty, "Expected units to be created");
Unit armyLON = setup.World.GetUnitAt("London");
Assert.That(armyLON.Power.Name, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(armyLON.Power, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(armyLON.Type, Is.EqualTo(UnitType.Army), "Unit created with wrong type");
Unit fleetIRI = setup.World.GetUnitAt("Irish Sea");
Assert.That(fleetIRI.Power.Name, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(fleetIRI.Power, Is.EqualTo("England"), "Unit created with wrong power");
Assert.That(fleetIRI.Type, Is.EqualTo(UnitType.Fleet), "Unit created with wrong type");
Unit fleetSTP = setup.World.GetUnitAt("Saint Petersburg");
Assert.That(fleetSTP.Power.Name, Is.EqualTo("Russia"), "Unit created with wrong power");
Assert.That(fleetSTP.Power, Is.EqualTo("Russia"), "Unit created with wrong power");
Assert.That(fleetSTP.Type, Is.EqualTo(UnitType.Fleet), "Unit created with wrong type");
Assert.That(
fleetSTP.Location,
@ -124,7 +124,7 @@ class TestCaseBuilderTest
Assert.That(orderMun, Is.Not.Null, "Expected order reference");
Assert.That(
orderMun.Order.Power,
Is.EqualTo(setup.World.Map.GetPower("Germany")),
Is.EqualTo("Germany"),
"Wrong power");
Assert.That(
orderMun.Order.Unit.Location,

View File

@ -13,14 +13,13 @@ public class UnitTests
Location Mun = world.Map.GetLand("Mun"),
Boh = world.Map.GetLand("Boh"),
Tyr = world.Map.GetLand("Tyr");
Power pw1 = world.Map.GetPower("Austria");
Season a0 = world.RootSeason;
Unit u1 = Unit.Build(Mun.Key, a0, pw1, UnitType.Army);
Season a0 = new("a0");
Unit u1 = Unit.Build(Mun.Key, a0, "Austria", UnitType.Army);
world = world.ContinueOrFork(a0, out Season a1);
world = world.WithNewSeason(a0, out Season a1);
Unit u2 = u1.Next(Boh.Key, a1);
_ = world.ContinueOrFork(a1, out Season a2);
_ = world.WithNewSeason(a1, out Season a2);
Unit u3 = u2.Next(Tyr.Key, a2);
Assert.That(u3.Past, Is.EqualTo(u2.Key), "Missing unit past");