Compare commits

..

No commits in common. "25d903d91aaac638292ca11bd5db7bdc13e571d3" and "161e0a1ddbad36e195417d26f63fb482041ab139" have entirely different histories.

34 changed files with 439 additions and 479 deletions

View File

@ -60,9 +60,9 @@ public class MovementDecisions
{
case MoveOrder move:
AdvanceTimeline.Ensure(
move.Season.Key,
() => new(move.Season, world.OrderHistory[move.Season.Key].Orders));
AdvanceTimeline[move.Season.Key].Orders.Add(move);
move.Season,
() => new(world.Seasons[move.Season], world.OrderHistory[move.Season].Orders));
AdvanceTimeline[move.Season].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.Key);
=> (move.Province, move.Season);
// 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
&& other.Season == me.Unit.Season
&& world.Seasons[other.Season] == me.Unit.Season
&& other.Province == world.Map.GetLocation(me.Unit).Province;
bool AreOpposing(MoveOrder one, MoveOrder two)
=> one.Season == two.Unit.Season
&& two.Season == one.Unit.Season
=> one.Season == two.Unit.Season.Key
&& two.Season == one.Unit.Season.Key
&& 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, move.Season));
HoldStrength.Ensure(MovePoint(move), () => new(move.Province, world.Seasons[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.Timelines.GetFutures(order.Unit.Season).Any(),
order => !world.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),
order => !(order.Location.Key == order.Unit.Location && order.Season == order.Unit.Season.Key),
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 - order.Season.Turn) <= 1
&& Math.Abs(order.Unit.Season.Turn - world.Seasons[order.Season].Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season));
&& world.InAdjacentTimeline(order.Unit.Season, world.Seasons[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.Timelines.InAdjacentTimeline(order.Unit.Season, order.Target.Season),
&& world.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.Timelines.InAdjacentTimeline(order.Unit.Season, order.Season),
&& world.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.Timelines.GetFutures(unit.Season).Any())
.Where(unit => !world.GetFutures(unit.Season).Any())
.ToList();
HashSet<Unit> orderedUnits = validOrders.Select(order => order.Unit).ToHashSet();
List<Unit> unorderedUnits = allOrderableUnits
@ -315,7 +315,6 @@ 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");
@ -325,8 +324,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
if (advanceTimeline.Outcome == true)
{
// A timeline that doesn't have a future yet simply continues. Otherwise, it forks.
newTimelines = newTimelines.WithNewSeason(advanceTimeline.Season, out var newFuture);
createdFutures[advanceTimeline.Season] = newFuture;
createdFutures[advanceTimeline.Season] = world.ContinueOrFork(advanceTimeline.Season);
}
}
@ -336,8 +334,8 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
foreach (DoesMove doesMove in moves.Values)
{
logger.Log(2, "{0} = {1}", doesMove, doesMove.Outcome?.ToString() ?? "?");
Season moveSeason = doesMove.Order.Season;
if (doesMove.Outcome == true && createdFutures.TryGetValue(moveSeason, out Season future))
Season moveSeason = world.Seasons[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);
@ -418,10 +416,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,
timelines: newTimelines);
orders: updatedHistory);
logger.Log(0, "Completed update");
@ -486,7 +484,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
bool progress = false;
// A season at the head of a timeline always advances.
if (!world.Timelines.GetFutures(decision.Season).Any())
if (!world.GetFutures(decision.Season).Any())
{
progress |= LoggedUpdate(decision, true, depth, "A timeline head always advances");
return progress;
@ -495,8 +493,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
&& !world.OrderHistory[order.Season.Key].DoesMoveOutcomes.ContainsKey(order.Unit.Key));
.Where(order => order.Season == decision.Season.Key
&& !world.OrderHistory[order.Season].DoesMoveOutcomes.ContainsKey(order.Unit.Key));
foreach (MoveOrder moveOrder in newIncomingMoves)
{
DoesMove doesMove = decisions.DoesMove[moveOrder];
@ -637,9 +635,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 - decision.Order.Season.Turn) <= 1
&& Math.Abs(decision.Order.Unit.Season.Turn - world.Seasons[decision.Order.Season].Turn) <= 1
// Timeline adjacency
&& world.Timelines.InAdjacentTimeline(decision.Order.Unit.Season, decision.Order.Season))
&& world.InAdjacentTimeline(decision.Order.Unit.Season, world.Seasons[decision.Order.Season]))
{
bool update = LoggedUpdate(decision, true, depth, "Adjacent move");
return progress | update;
@ -776,7 +774,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.Key)].Order;
UnitOrder? destOrder = decisions.HoldStrength[(decision.Order.Province, decision.Order.Season)].Order;
DoesMove? destMoveAway = destOrder is MoveOrder moveAway
? decisions.DoesMove[moveAway]
: null;
@ -795,7 +793,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// Is failing to move away
|| destMoveAway.Outcome == false))
{
string destPower = destOrder.Unit.Power;
Power destPower = destOrder.Unit.Power;
if (decision.Order.Unit.Power == destPower)
{
// Cannot dislodge own unit.
@ -825,7 +823,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.
string destPower = destMoveAway.Order.Unit.Power;
Power destPower = destMoveAway.Order.Unit.Power;
int min = 1;
int max = 1;
foreach (SupportMoveOrder support in decision.Supports)
@ -955,7 +953,7 @@ public class MovementPhaseAdjudicator : IPhaseAdjudicator
// strength.
NumericAdjudicationDecision defense = decision.OpposingMove != null
? decisions.DefendStrength[decision.OpposingMove]
: decisions.HoldStrength[(decision.Order.Province, decision.Order.Season.Key)];
: decisions.HoldStrength[(decision.Order.Province, decision.Order.Season)];
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, order.Season);
=> ConvoyPathExists(world, order.Unit, order.Location, world.Seasons[order.Season]);
private static bool ConvoyPathExists(
World world,
@ -111,36 +111,35 @@ 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 (pasts[season.Key] is Season immediatePast) adjacents.Add(immediatePast);
adjacents.AddRange(world.Timelines.GetFutures(season));
if (season.Past != null) adjacents.Add(world.Seasons[season.Past]);
adjacents.AddRange(world.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 = season;
for (;
pasts[current?.Key!] is Season currentPast && currentPast.Timeline == current?.Timeline;
current = pasts[current?.Key!])
Season? current;
for (current = season;
current?.Past != null && world.Seasons[current.Past].Timeline == current.Timeline;
current = world.Seasons[current.Past])
{
adjacentTimelineRoots.AddRange(
world.Timelines.GetFutures(current.Value).Where(s => s.Timeline != current?.Timeline));
world.GetFutures(current).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). Check for co-branches if this season is in a branched timeline, since
// current.timeline). There are co-branches if this season is in a branched timeline, since
// the first timeline by definition cannot have co-branches.
if (pasts[current?.Key!] is Season rootPast)
if (current?.Past != null && world.Seasons[current.Past] is Season past)
{
IEnumerable<Season> cobranchRoots = world.Timelines
.GetFutures(rootPast)
.Where(s => s.Timeline != current?.Timeline && s.Timeline != rootPast.Timeline);
IEnumerable<Season> cobranchRoots = world
.GetFutures(past)
.Where(s => s.Timeline != current.Timeline && s.Timeline != past.Timeline);
adjacentTimelineRoots.AddRange(cobranchRoots);
}
@ -148,13 +147,11 @@ public static class PathFinder
foreach (Season timelineRoot in adjacentTimelineRoots)
{
for (Season? branchSeason = timelineRoot;
branchSeason is Season branch && branch.Turn <= season.Turn + 1;
branchSeason = world.Timelines
.GetFutures(branchSeason!.Value)
.Cast<Season?>()
.FirstOrDefault(s => s?.Timeline == branchSeason?.Timeline, null))
branchSeason != null && branchSeason.Turn <= season.Turn + 1;
branchSeason = world.GetFutures(branchSeason)
.FirstOrDefault(s => s!.Timeline == branchSeason.Timeline, null))
{
if (branchSeason?.Turn >= season.Turn - 1) adjacents.Add(branchSeason.Value);
if (branchSeason.Turn >= season.Turn - 1) adjacents.Add(branchSeason);
}
}

View File

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

View File

@ -21,6 +21,9 @@ public static class ModelExtensions
return $"{coord.season.Timeline}-{coord.province.Abbreviations[0]}@{coord.season.Turn}";
}
public static World WithNewSeason(this World world, Season season, out Season future)
=> world.Update(timelines: world.Timelines.WithNewSeason(season, out future));
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));
}
}

View File

@ -0,0 +1,22 @@
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,13 +1,11 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
namespace MultiversalDiplomacy.Model;
/// <summary>
/// Represents a multiversal coordinate at which a state of the map exists.
/// Represents a state of the map produced by a set of move orders on a previous season.
/// </summary>
[JsonConverter(typeof(SeasonJsonConverter))]
public struct Season(string timeline, int turn)
public class Season(string? past, int turn, string timeline)
{
/// <summary>
/// The first turn number. This is defined to reduce confusion about whether the first turn is 0 or 1.
@ -15,9 +13,11 @@ public struct Season(string timeline, int turn)
public const int FIRST_TURN = 0;
/// <summary>
/// The timeline to which this season belongs.
/// 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.
/// </summary>
public string Timeline { get; } = timeline;
public string? Past { get; } = past;
/// <summary>
/// The current turn, beginning at 0. Each season (spring and fall) is one turn.
@ -26,51 +26,16 @@ public struct Season(string timeline, int turn)
/// </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 readonly string Key => $"{this.Timeline}{this.Turn}";
public string Key => $"{this.Timeline}{this.Turn}";
/// <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();
public override string ToString() => Key;
}

View File

@ -1,16 +0,0 @@
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

@ -0,0 +1,55 @@
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

@ -0,0 +1,13 @@
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

@ -1,156 +0,0 @@
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 string Power { get; }
public Power 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, string power, UnitType type)
public Unit(string? past, string location, Season season, Power power, UnitType type)
{
this.Past = past;
this.Location = location;
@ -48,13 +48,13 @@ public class Unit
}
public override string ToString()
=> $"{Power[0]} {Type.ToShort()} {Season.Timeline}-{Location}@{Season.Turn}";
=> $"{Power.Name[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, string power, UnitType type)
public static Unit Build(string location, Season season, Power power, UnitType type)
=> new(past: null, location, season, power, type);
/// <summary>

View File

@ -37,7 +37,18 @@ public class World
/// The game powers.
/// </summary>
[JsonIgnore]
public IReadOnlyCollection<string> Powers => this.Map.Powers;
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"];
/// <summary>
/// All units in the multiverse.
@ -57,7 +68,7 @@ public class World
/// <summary>
/// The shared timeline number generator.
/// </summary>
public Timelines Timelines { get; }
public TimelineFactory Timelines { get; }
/// <summary>
/// Immutable game options.
@ -67,13 +78,15 @@ public class World
[JsonConstructor]
public World(
MapType mapType,
Dictionary<string, Season> seasons,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
Timelines timelines,
TimelineFactory timelines,
Options options)
{
this.Map = Map.FromType(mapType);
this.Seasons = seasons;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
@ -87,13 +100,15 @@ public class World
/// </summary>
private World(
Map map,
Dictionary<string, Season> seasons,
List<Unit> units,
List<RetreatingUnit> retreatingUnits,
Dictionary<string, OrderHistory> orderHistory,
Timelines timelines,
TimelineFactory timelines,
Options options)
{
this.Map = map;
this.Seasons = seasons;
this.Units = units;
this.RetreatingUnits = retreatingUnits;
this.OrderHistory = orderHistory;
@ -106,17 +121,18 @@ 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,
timelines ?? previous.Timelines,
previous.Timelines,
options ?? previous.Options)
{
}
@ -126,12 +142,15 @@ 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.Create(),
timelines,
new Options());
}
@ -142,12 +161,15 @@ 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,
Timelines? timelines = null)
IEnumerable<KeyValuePair<string, OrderHistory>>? orders = null)
=> new(
previous: this,
seasons: seasons == null
? this.Seasons
: new(seasons.ToDictionary(season => season.Key)),
units: units == null
? this.Units
: new(units.ToList()),
@ -156,8 +178,7 @@ public class World
: new(retreats.ToList()),
orderHistory: orders == null
? this.OrderHistory
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)),
timelines: timelines ?? this.Timelines);
: new(orders.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)));
/// <summary>
/// Create a new world with new units created from unit specs. Units specs are in the format
@ -169,7 +190,7 @@ public class World
IEnumerable<Unit> units = unitSpecs.Select(spec =>
{
string[] splits = spec.Split(' ', 4);
string power = Map.GetPower(splits[0]);
Power power = Map.GetPower(splits[0]);
UnitType type = splits[1] switch
{
"A" => UnitType.Army,
@ -181,7 +202,7 @@ public class World
: splits.Length == 3
? Map.GetWater(splits[2])
: Map.GetWater(splits[2], splits[3]);
Unit unit = Unit.Build(location.Key, new("a0"), power, type);
Unit unit = Unit.Build(location.Key, this.RootSeason, power, type);
return unit;
});
return this.Update(units: units);
@ -218,18 +239,85 @@ 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 ??= new("a0");
season ??= RootSeason;
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(string power, Location location, UnitType type)
public BuildOrder(Power 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(string power, Unit unit, Unit target, Season season, Location location)
public ConvoyOrder(Power 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(string power, Unit unit)
public DisbandOrder(Power power, Unit unit)
: base (power, unit) {}
}

View File

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

View File

@ -9,8 +9,9 @@ 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 Season Season { get; }
public string Season { get; }
/// <summary>
/// The destination location to which the unit should move.
@ -22,7 +23,7 @@ public class MoveOrder : UnitOrder
/// </summary>
public Province Province => this.Location.Province;
public MoveOrder(string power, Unit unit, Season season, Location location)
public MoveOrder(Power power, Unit unit, string season, Location location)
: base (power, unit)
{
this.Season = season;
@ -31,7 +32,7 @@ public class MoveOrder : UnitOrder
public override string ToString()
{
return $"{this.Unit} -> {Season.Timeline}-{Province}@{Season.Turn}";
return $"{this.Unit} -> {this.Season} {this.Province}";
}
/// <summary>

View File

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

View File

@ -12,7 +12,7 @@ public class RetreatOrder : UnitOrder
/// </summary>
public Location Location { get; }
public RetreatOrder(string power, Unit unit, Location location)
public RetreatOrder(Power 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(string power, Unit unit, Unit target)
public SupportHoldOrder(Power 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(string power, Unit unit, Unit target, Season season, Location location)
public SupportMoveOrder(Power 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 == move.Season
&& this.Season.Key == move.Season
&& this.Location == move.Location;
}

View File

@ -12,7 +12,7 @@ public abstract class SupportOrder : UnitOrder
/// </summary>
public Unit Target { get; }
public SupportOrder(string power, Unit unit, Unit target)
public SupportOrder(Power 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(string power, Location timeCenter, int timeline)
public SustainOrder(Power 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(string power, Unit unit) : base(power)
public UnitOrder(Power power, Unit unit) : base(power)
{
this.Unit = unit;
}

View File

@ -1,10 +0,0 @@
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.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline == s0.Timeline).Count(),
world.Seasons.Values.Where(s => s.Timeline == s0.Timeline).Count(),
Is.EqualTo(3),
"Failed to advance main timeline after last unit left");
Assert.That(
world.Timelines.Pasts.Keys.Select(key => new Season(key)).Where(s => s.Timeline != s0.Timeline).Count(),
world.Seasons.Values.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 = new("b1");
Season fork = world.Seasons["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 = new("b1");
Season fork = world.Seasons["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.Timelines.GetFutures(s0).Count(),
world.GetFutures(s0).Count(),
Is.EqualTo(1),
"A failed move incorrectly forked the timeline");
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));
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));
}
[Test]
@ -178,12 +178,12 @@ public class TimeTravelTest
// ...since it succeeded anyway, no fork is created.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(s0).Count(),
world.GetFutures(s0).Count(),
Is.EqualTo(1),
"A superfluous support incorrectly forked the timeline");
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));
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));
}
[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.Timelines.GetFutures(a2).Count(),
world.GetFutures(a2).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
Assert.That(
world.Timelines.GetFutures(b1).Count(),
world.GetFutures(b1).Count(),
Is.EqualTo(1),
"A cross-timeline support incorrectly forked the head of the timeline");
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));
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));
}
[Test]
@ -297,11 +297,11 @@ public class TimeTravelTest
// wasn't changed in this timeline.
World world = setup.UpdateWorld();
Assert.That(
world.Timelines.GetFutures(a3).Count(),
world.GetFutures(a3).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
Assert.That(
world.Timelines.GetFutures(b2).Count(),
world.GetFutures(b2).Count(),
Is.EqualTo(1),
"A cross-timeline support cut incorrectly forked the timeline");
}

View File

@ -18,86 +18,21 @@ public class TimelineFactoryTest
[TestCase(78, "ca")]
public void RoundTripTimelineKeys(int number, string designation)
{
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");
Assert.That(TimelineFactory.IntToString(number), Is.EqualTo(designation), "Incorrect string");
Assert.That(TimelineFactory.StringToInt(designation), Is.EqualTo(number), "Incorrect number");
}
[Test]
public void NoSharedFactoryState()
{
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);
TimelineFactory one = new();
TimelineFactory two = new();
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(one.NextTimeline(), Is.EqualTo("a"));
Assert.That(one.NextTimeline(), Is.EqualTo("b"));
Assert.That(one.NextTimeline(), Is.EqualTo("c"));
[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");
Assert.That(two.NextTimeline(), Is.EqualTo("a"));
Assert.That(two.NextTimeline(), Is.EqualTo("b"));
}
}

View File

@ -168,16 +168,18 @@ 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]
@ -197,9 +199,9 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
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);
Season s2 = updated.Seasons["a1"];
Assert.That(s2.Past, Is.EqualTo(s1.ToString()));
Assert.That(updated.GetFutures(s2), Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
@ -225,7 +227,7 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = new(s2.Timeline, s2.Turn + 1);
Season s3 = updated.GetSeason(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(mun2.Order.Unit.Key));
}
@ -247,9 +249,9 @@ public class MovementAdjudicatorTest
World updated = setup.UpdateWorld();
// Confirm the future was created
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);
Season s2 = updated.GetSeason(s1.Timeline, s1.Turn + 1);
Assert.That(s2.Past, Is.EqualTo(s1.ToString()));
Assert.That(updated.GetFutures(s2), Is.Empty);
Assert.That(s2.Timeline, Is.EqualTo(s1.Timeline));
Assert.That(s2.Turn, Is.EqualTo(s1.Turn + 1));
@ -275,7 +277,7 @@ public class MovementAdjudicatorTest
// Update the world again
updated = setup.UpdateWorld();
Season s3 = new(s2.Timeline, s2.Turn + 1);
Season s3 = updated.GetSeason(s2.Timeline, s2.Turn + 1);
Unit u3 = updated.GetUnitAt("Mun", s3);
Assert.That(u3.Past, Is.EqualTo(u2.Key));
}

View File

@ -0,0 +1,62 @@
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("Serialization doesn't fully work yet");
Assert.Ignore();
// Resume the test case
setup = new(reserialized, MovementPhaseAdjudicator.Instance);
setup[("a", 1)]
["Germany"]
.Army("Mun").Supports.Army("Mun", season: new("a0")).MoveTo("Tyr").GetReference(out var mun1)
.Army("Mun").Supports.Army("Mun", season: reserialized.Seasons["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 = new("b1");
Season fork = world.Seasons["b1"];
Unit tyr1 = world.GetUnitAt("Tyr", fork);
Assert.That(
tyr1.Past,

View File

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

View File

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