diff --git a/MultiversalDiplomacy/Model/Season.cs b/MultiversalDiplomacy/Model/Season.cs
index cbbc7d8..3f9a1ce 100644
--- a/MultiversalDiplomacy/Model/Season.cs
+++ b/MultiversalDiplomacy/Model/Season.cs
@@ -5,16 +5,6 @@ namespace MultiversalDiplomacy.Model;
///
public class Season
{
- ///
- /// A shared counter for handing out new timeline numbers.
- ///
- private class TimelineFactory
- {
- private int nextTimeline = 0;
-
- public int NextTimeline() => nextTimeline++;
- }
-
///
/// The first turn number.
///
@@ -83,7 +73,7 @@ public class Season
return new Season(
past: null,
turn: FIRST_TURN,
- timeline: factory.NextTimeline(),
+ timeline: TimelineFactory.StringToInt(factory.NextTimeline()),
factory: factory);
}
@@ -97,7 +87,7 @@ public class Season
/// Create a season immediately after this one in a new timeline.
///
public Season MakeFork()
- => new Season(this, this.Turn + 1, this.Timelines.NextTimeline(), this.Timelines);
+ => new Season(this, this.Turn + 1, TimelineFactory.StringToInt(Timelines.NextTimeline()), this.Timelines);
///
/// Returns the first season in this season's timeline. The first season is the
diff --git a/MultiversalDiplomacy/Model/TimelineFactory.cs b/MultiversalDiplomacy/Model/TimelineFactory.cs
new file mode 100644
index 0000000..1921842
--- /dev/null
+++ b/MultiversalDiplomacy/Model/TimelineFactory.cs
@@ -0,0 +1,50 @@
+namespace MultiversalDiplomacy.Model;
+
+///
+/// A shared counter for handing out new timeline designations.
+///
+internal class TimelineFactory
+{
+ 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',
+ ];
+
+ ///
+ /// Convert a string timeline identifier to its serial number.
+ ///
+ /// Timeline identifier.
+ /// Integer.
+ 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;
+ }
+
+ ///
+ /// Convert a timeline serial number to its string identifier.
+ ///
+ /// Integer.
+ /// Timeline identifier.
+ public static string IntToString(int designation) {
+ static int downshift(int i ) => (i - (i % 26)) / 26;
+ IEnumerable 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());
+ }
+
+ private int nextTimeline = 0;
+
+ public string NextTimeline() => IntToString(nextTimeline++);
+}
diff --git a/MultiversalDiplomacy/MultiversalDiplomacy.csproj b/MultiversalDiplomacy/MultiversalDiplomacy.csproj
index 91b464a..76512ea 100644
--- a/MultiversalDiplomacy/MultiversalDiplomacy.csproj
+++ b/MultiversalDiplomacy/MultiversalDiplomacy.csproj
@@ -7,4 +7,10 @@
enable
+
+
+ <_Parameter1>MultiversalDiplomacyTests
+
+
+
diff --git a/MultiversalDiplomacyTests/Model/TimelineFactoryTest.cs b/MultiversalDiplomacyTests/Model/TimelineFactoryTest.cs
new file mode 100644
index 0000000..a804653
--- /dev/null
+++ b/MultiversalDiplomacyTests/Model/TimelineFactoryTest.cs
@@ -0,0 +1,38 @@
+using MultiversalDiplomacy.Model;
+
+using NUnit.Framework;
+
+namespace MultiversalDiplomacyTests.Model;
+
+public class TimelineFactoryTest
+{
+ [TestCase(0, "a")]
+ [TestCase(1, "b")]
+ [TestCase(25, "z")]
+ [TestCase(26, "aa")]
+ [TestCase(27, "ab")]
+ [TestCase(51, "az")]
+ [TestCase(52, "ba")]
+ [TestCase(53, "bb")]
+ [TestCase(77, "bz")]
+ [TestCase(78, "ca")]
+ public void RoundTripTimelineDesignations(int number, string designation)
+ {
+ Assert.That(TimelineFactory.IntToString(number), Is.EqualTo(designation), "Incorrect string");
+ Assert.That(TimelineFactory.StringToInt(designation), Is.EqualTo(number), "Incorrect number");
+ }
+
+ [Test]
+ public void NoSharedFactoryState()
+ {
+ TimelineFactory one = new();
+ TimelineFactory two = new();
+
+ Assert.That(one.NextTimeline(), Is.EqualTo("a"));
+ Assert.That(one.NextTimeline(), Is.EqualTo("b"));
+ Assert.That(one.NextTimeline(), Is.EqualTo("c"));
+
+ Assert.That(two.NextTimeline(), Is.EqualTo("a"));
+ Assert.That(two.NextTimeline(), Is.EqualTo("b"));
+ }
+}