diff --git a/00_Common/BASIC_Tests/InputTest.bas b/00_Common/BASIC_Tests/InputTest.bas
new file mode 100644
index 00000000..bc57e8bd
--- /dev/null
+++ b/00_Common/BASIC_Tests/InputTest.bas
@@ -0,0 +1,5 @@
+10 INPUT "Enter 3 numbers";A,B,C
+20 PRINT "You entered: ";A;B;C
+30 PRINT "--------------------------"
+40 GOTO 10
+
diff --git a/00_Common/BASIC_Tests/OutputTest.bas b/00_Common/BASIC_Tests/OutputTest.bas
new file mode 100644
index 00000000..b857401f
--- /dev/null
+++ b/00_Common/BASIC_Tests/OutputTest.bas
@@ -0,0 +1,4 @@
+10 A=1: B=-2: C=0.7: D=123456789: E=-0.0000000001
+20 PRINT "|";A;"|";B;"|";C;"|";D;"|";E;"|"
+
+
diff --git a/00_Common/BASIC_Tests/RndTest.bas b/00_Common/BASIC_Tests/RndTest.bas
new file mode 100644
index 00000000..f4702f19
--- /dev/null
+++ b/00_Common/BASIC_Tests/RndTest.bas
@@ -0,0 +1,6 @@
+10 PRINT "1: ";RND(1);RND(1);RND(0);RND(0);RND(1)
+20 PRINT "2: ";RND(-2);RND(1);RND(1);RND(1)
+30 PRINT "3: ";RND(-5);RND(1);RND(1);RND(1)
+40 PRINT "4: ";RND(-2);RND(1);RND(1);RND(1)
+
+
diff --git a/00_Common/README.md b/00_Common/README.md
new file mode 100644
index 00000000..5b207e05
--- /dev/null
+++ b/00_Common/README.md
@@ -0,0 +1,185 @@
+# Common Library
+
+## Purpose
+
+The primary purpose of this library is to implement common behaviours of the BASIC interpreter that impact gameplay, to
+free coders porting the games to concentrate on the explicit game logic.
+
+The behaviours implemented by this library are:
+
+* Complex interactions involved in text input.
+* Formatting of number in text output.
+* Behaviour of the BASIC `RND(float)` PRNG function.
+
+A secondary purpose is to provide common services that, with dependency injection, would allow a ported game to be
+driven programmatically to permit full-game acceptance tests to be written.
+
+The library is **NOT** intended to be:
+
+* a repository for common game logic that is implemented in the BASIC code of the games; or
+* a DSL allowing the BASIC code to be compiled in a different language environment with minimal changes. This implies
+ that implementations of the above behaviours should use method names, etc, that are idiomatic of the specific
+ language's normal routines for that behaviour.
+
+## Text Input
+
+The behaviour of the BASIC interpreter when accepting text input from the user is a major element of the original
+gameplay experience that is seen to be valuable to maintain. This behaviour is complex and non-trivial to implement, and
+is better implemented once for other developers to use so they can concentrate on the explicit game logic.
+
+The text input/output behaviour can be investigated using a basic program such as:
+
+**`BASIC_Tests/InputTest.bas`**
+
+```basic
+10 INPUT "Enter 3 numbers";A,B,C
+20 PRINT "You entered: ";A;B;C
+30 PRINT "--------------------------"
+40 GOTO 10
+```
+
+The following transcript shows the use of this program, and some interesting behaviours of the BASIC interpreter INPUT
+routine. There are some other behaviours which can be seen in the unit tests for the C# library implementation.
+
+```dos
+Enter 3 numbers? -1,2,3.141 <-- multiple numbers are separated by commas
+You entered: -1 2 3.141
+--------------------------
+Enter 3 numbers? 1 <-- ... or entered on separate lines
+?? 2
+?? 3
+You entered: 1 2 3
+--------------------------
+Enter 3 numbers? 1,2 <-- ... or both
+?? 3
+You entered: 1 2 3
+--------------------------
+Enter 3 numbers? 1,-2,3,4 <-- Extra input is ignore with a warning
+!EXTRA INPUT IGNORED
+You entered: 1 -2 3
+--------------------------
+Enter 3 numbers? 5 , 6.7, -8 ,10 <-- Whitespace around values is ignored
+!EXTRA INPUT IGNORED
+You entered: 5 6.7 -8
+--------------------------
+Enter 3 numbers? abcd,e,f <-- Non-numeric entries must be retried
+!NUMBER EXPECTED - RETRY INPUT LINE
+? 1,2,abc <-- A single non-numeric invalidates the whole line
+!NUMBER EXPECTED - RETRY INPUT LINE
+? 1de,2.3f,10k,abcde <-- ... except for trailing non-digit chars and extra input
+!EXTRA INPUT IGNORED
+You entered: 1 2.3 10
+--------------------------
+Enter 3 numbers? 1,"2,3",4 <-- Double-quotes enclose a single parsed value
+You entered: 1 2 4
+--------------------------
+Enter 3 numbers? 1,2,"3 <-- An unmatched double-quote crashes the interpreter
+vintbas.exe: Mismatched inputbuf in inputVars
+CallStack (from HasCallStack):
+ error, called at src\Language\VintageBasic\Interpreter.hs:436:21 in main:Language.VintageBasic.Interpreter
+```
+
+I propose to ignore this last behaviour - the interpreter crash - and instead treat the end of the input line as the end
+of a quoted value. There are some additional behaviours to those shown above which can be seen in the unit tests for
+the C# implementation of the library.
+
+Note also that BASIC numeric variables store a single-precision floating point value, so numeric input functions should
+return a value of that type.
+
+### Implementation Notes
+
+The usage of the `INPUT` command in the BASIC code of the games was analysed, with the variables used designated as
+`num`, for a numeric variable (eg. `M1`), or `str`, for a string variable (eg. `C$`). The result of this usage analysis
+across all game programs is:
+
+Variable number and type|Count
+---|---
+str|137
+str str|2
+num|187
+num num|27
+num num num|7
+num num num num|1
+num num num num num num num num num num|1
+
+The usage count is interesting, but not important. What is important is the variable type and number for each usage.
+Implementers if the above behaviours do not need to cater for mixed variable types in their input routines (although the
+BASIC interpreter does support this). Input routines also need to cater only for 1 or 2 string values, and 1, 2, 3, 4,
+or 10 numeric values.
+
+## Numbers in Text Output
+
+As seen in the transcript above, the BASIC interpreter has some particular rules for formatting numbers in text output.
+Negative numbers are printed with a leading negative sign (`-`) and a trailing space. Positive numbers are printed also
+with the trailing space, but with a leading space in place of the sign character.
+
+Additional formatting rules can be seen by running this program:
+
+**`BASIC_Tests/OutputTest.bas`**
+
+```basic
+10 A=1: B=-2: C=0.7: D=123456789: E=-0.0000000001
+20 PRINT "|";A;"|";B;"|";C;"|";D;"|";E;"|"
+```
+
+The output is:
+
+```dos
+| 1 |-2 | .7 | 1.2345679E+8 |-1.E-10 |
+```
+
+This clearly shows the leading and trailing spaces, but also shows that:
+
+* numbers without an integer component are printed without a leading zero to the left of the decimal place;
+* numbers above and below a certain magnitude are printed in scientific notation.
+
+
+### Implementation Notes
+
+
+I think the important piece of this number formatting behaviour, in terms of its impact on replicating the text output
+of the original games, is the leading and trailing spaces. This should be the minimum behaviour supported for numeric
+output. Additional formatting behaviours may be investigated and supported by library implementers as they choose.
+
+## The BASIC `RND(float)` function
+
+The [Vintage BASIC documentation](http://vintage-basic.net/downloads/Vintage_BASIC_Users_Guide.html) describes the
+behaviour of the `RND(float)` function:
+
+> Psuedorandom number generator. The behavior is different depending on the value passed. If the value is positive, the
+> result will be a new random value between 0 and 1 (including 0 but not 1). If the value is zero, the result will be a
+> repeat of the last random number generated. If the value is negative, it will be rounded down to the nearest integer
+> and used to reseed the random number generator. Pseudorandom sequences can be repeated by reseeding with the same
+> number.
+
+This behaviour can be shown by the program:
+
+**`BASIC_Tests/RndTest.bas`**
+
+```basic
+10 PRINT "1: ";RND(1);RND(1);RND(0);RND(0);RND(1)
+20 PRINT "2: ";RND(-2);RND(1);RND(1);RND(1)
+30 PRINT "3: ";RND(-5);RND(1);RND(1);RND(1)
+40 PRINT "4: ";RND(-2);RND(1);RND(1);RND(1)
+```
+
+The output of this program is:
+
+```dos
+1: .97369444 .44256502 .44256502 .44256502 .28549057 <-- Repeated values due to RND(0)
+2: .4986506 4.4510484E-2 .96231 .35997057
+3: .8113741 .13687313 6.1034977E-2 .7874807
+4: .4986506 4.4510484E-2 .96231 .35997057 <-- Same sequence as line 2 due to same seed
+```
+
+
+### Implementation Notes
+
+
+While the BASIC `RND(x)` function always returns a number between 0 (inclusive) and 1 (exclusive) for positive non-zero
+values of `x`, game porters would find it convenient for the library to include functions returning a random float or
+integer in a range from an inclusive minimum to an exclusive maximum.
+
+As one of the games, "Football", makes use of `RND(0)` with different scaling applied than the previous use of `RND(1)`,
+a common library implementation should always generate a value between 0 and 1, and scale that for a function with a
+range, so that a call to the equivalent of the `RND(0)` function can return the previous value between 0 and 1.
diff --git a/00_Common/dotnet/Directory.Build.props b/00_Common/dotnet/Directory.Build.props
new file mode 100644
index 00000000..f5a054dc
--- /dev/null
+++ b/00_Common/dotnet/Directory.Build.props
@@ -0,0 +1,8 @@
+
+
+
+ enable
+ 10.0
+
+
+
diff --git a/00_Common/dotnet/Games.Common.Test/Games.Common.Test.csproj b/00_Common/dotnet/Games.Common.Test/Games.Common.Test.csproj
new file mode 100644
index 00000000..c90ed518
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/Games.Common.Test.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net6.0
+ false
+
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
diff --git a/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/NumberFormatTests.cs b/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/NumberFormatTests.cs
new file mode 100644
index 00000000..670a7366
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/NumberFormatTests.cs
@@ -0,0 +1,79 @@
+using System;
+using System.IO;
+using FluentAssertions;
+using Xunit;
+
+namespace Games.Common.IO.TextIOTests;
+
+public class NumberFormatTests
+{
+ [Theory]
+ [MemberData(nameof(WriteFloatTestCases))]
+ public void Write_Float_FormatsNumberSameAsBasic(float value, string basicString)
+ {
+ var outputWriter = new StringWriter();
+ var io = new TextIO(new StringReader(""), outputWriter);
+
+ io.Write(value);
+
+ outputWriter.ToString().Should().BeEquivalentTo(basicString);
+ }
+
+ [Theory]
+ [MemberData(nameof(WriteFloatTestCases))]
+ public void WriteLine_Float_FormatsNumberSameAsBasic(float value, string basicString)
+ {
+ var outputWriter = new StringWriter();
+ var io = new TextIO(new StringReader(""), outputWriter);
+
+ io.WriteLine(value);
+
+ outputWriter.ToString().Should().BeEquivalentTo(basicString + Environment.NewLine);
+ }
+
+ public static TheoryData WriteFloatTestCases()
+ => new()
+ {
+ { 1000F, " 1000 " },
+ { 3.1415927F, " 3.1415927 " },
+ { 1F, " 1 " },
+ { 0F, " 0 " },
+ { -1F, "-1 " },
+ { -3.1415927F, "-3.1415927 " },
+ { -1000F, "-1000 " },
+ };
+
+ [Theory]
+ [MemberData(nameof(WriteIntTestCases))]
+ public void Write_Int_FormatsNumberSameAsBasic(int value, string basicString)
+ {
+ var outputWriter = new StringWriter();
+ var io = new TextIO(new StringReader(""), outputWriter);
+
+ io.Write(value);
+
+ outputWriter.ToString().Should().BeEquivalentTo(basicString);
+ }
+
+ [Theory]
+ [MemberData(nameof(WriteIntTestCases))]
+ public void WriteLine_Int_FormatsNumberSameAsBasic(int value, string basicString)
+ {
+ var outputWriter = new StringWriter();
+ var io = new TextIO(new StringReader(""), outputWriter);
+
+ io.WriteLine(value);
+
+ outputWriter.ToString().Should().BeEquivalentTo(basicString + Environment.NewLine);
+ }
+
+ public static TheoryData WriteIntTestCases()
+ => new()
+ {
+ { 1000, " 1000 " },
+ { 1, " 1 " },
+ { 0, " 0 " },
+ { -1, "-1 " },
+ { -1000, "-1000 " },
+ };
+}
diff --git a/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/ReadMethodTests.cs b/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/ReadMethodTests.cs
new file mode 100644
index 00000000..119bacd0
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/IO/TextIOTests/ReadMethodTests.cs
@@ -0,0 +1,165 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Xunit;
+
+using TwoStrings = System.ValueTuple;
+using TwoNumbers = System.ValueTuple;
+using ThreeNumbers = System.ValueTuple;
+using FourNumbers = System.ValueTuple;
+
+using static System.Environment;
+using static Games.Common.IO.Strings;
+
+namespace Games.Common.IO.TextIOTests;
+
+public class ReadMethodTests
+{
+ [Theory]
+ [MemberData(nameof(ReadStringTestCases))]
+ [MemberData(nameof(Read2StringsTestCases))]
+ [MemberData(nameof(ReadNumberTestCases))]
+ [MemberData(nameof(Read2NumbersTestCases))]
+ [MemberData(nameof(Read3NumbersTestCases))]
+ [MemberData(nameof(Read4NumbersTestCases))]
+ [MemberData(nameof(ReadNumbersTestCases))]
+ public void ReadingValuesHasExpectedPromptsAndResults(
+ Func read,
+ string input,
+ string expectedOutput,
+ T expectedResult)
+ {
+ var inputReader = new StringReader(input + Environment.NewLine);
+ var outputWriter = new StringWriter();
+ var io = new TextIO(inputReader, outputWriter);
+
+ var result = read.Invoke(io);
+ var output = outputWriter.ToString();
+
+ using var _ = new AssertionScope();
+ output.Should().Be(expectedOutput);
+ result.Should().BeEquivalentTo(expectedResult);
+ }
+
+ [Fact]
+ public void ReadNumbers_ArrayEmpty_ThrowsArgumentException()
+ {
+ var io = new TextIO(new StringReader(""), new StringWriter());
+
+ Action readNumbers = () => io.ReadNumbers("foo", Array.Empty());
+
+ readNumbers.Should().Throw()
+ .WithMessage("'values' must have a non-zero length.*")
+ .WithParameterName("values");
+ }
+
+ public static TheoryData, string, string, string> ReadStringTestCases()
+ {
+ static Func ReadString(string prompt) => io => io.ReadString(prompt);
+
+ return new()
+ {
+ { ReadString("Name"), "", "Name? ", "" },
+ { ReadString("prompt"), " foo ,bar", $"prompt? {ExtraInput}{NewLine}", "foo" }
+ };
+ }
+
+ public static TheoryData, string, string, TwoStrings> Read2StringsTestCases()
+ {
+ static Func Read2Strings(string prompt) => io => io.Read2Strings(prompt);
+
+ return new()
+ {
+ { Read2Strings("2 strings"), ",", "2 strings? ", ("", "") },
+ {
+ Read2Strings("Input please"),
+ $"{NewLine}x,y",
+ $"Input please? ?? {ExtraInput}{NewLine}",
+ ("", "x")
+ }
+ };
+ }
+
+ public static TheoryData, string, string, float> ReadNumberTestCases()
+ {
+ static Func ReadNumber(string prompt) => io => io.ReadNumber(prompt);
+
+ return new()
+ {
+ { ReadNumber("Age"), $"{NewLine}42,", $"Age? {NumberExpected}{NewLine}? {ExtraInput}{NewLine}", 42 },
+ { ReadNumber("Guess"), "3,4,5", $"Guess? {ExtraInput}{NewLine}", 3 }
+ };
+ }
+
+ public static TheoryData, string, string, TwoNumbers> Read2NumbersTestCases()
+ {
+ static Func Read2Numbers(string prompt) => io => io.Read2Numbers(prompt);
+
+ return new()
+ {
+ { Read2Numbers("Point"), "3,4,5", $"Point? {ExtraInput}{NewLine}", (3, 4) },
+ {
+ Read2Numbers("Foo"),
+ $"x,4,5{NewLine}4,5,x",
+ $"Foo? {NumberExpected}{NewLine}? {ExtraInput}{NewLine}",
+ (4, 5)
+ }
+ };
+ }
+
+ public static TheoryData, string, string, ThreeNumbers> Read3NumbersTestCases()
+ {
+ static Func Read3Numbers(string prompt) => io => io.Read3Numbers(prompt);
+
+ return new()
+ {
+ { Read3Numbers("Point"), "3.2, 4.3, 5.4, 6.5", $"Point? {ExtraInput}{NewLine}", (3.2F, 4.3F, 5.4F) },
+ {
+ Read3Numbers("Bar"),
+ $"x,4,5{NewLine}4,5,x{NewLine}6,7,8,y",
+ $"Bar? {NumberExpected}{NewLine}? {NumberExpected}{NewLine}? {ExtraInput}{NewLine}",
+ (6, 7, 8)
+ }
+ };
+ }
+
+ public static TheoryData, string, string, FourNumbers> Read4NumbersTestCases()
+ {
+ static Func Read4Numbers(string prompt) => io => io.Read4Numbers(prompt);
+
+ return new()
+ {
+ { Read4Numbers("Point"), "3,4,5,6,7", $"Point? {ExtraInput}{NewLine}", (3, 4, 5, 6) },
+ {
+ Read4Numbers("Baz"),
+ $"x,4,5,6{NewLine} 4, 5 , 6,7 ,x",
+ $"Baz? {NumberExpected}{NewLine}? {ExtraInput}{NewLine}",
+ (4, 5, 6, 7)
+ }
+ };
+ }
+
+ public static TheoryData>, string, string, float[]> ReadNumbersTestCases()
+ {
+ static Func> ReadNumbers(string prompt) =>
+ io =>
+ {
+ var numbers = new float[6];
+ io.ReadNumbers(prompt, numbers);
+ return numbers;
+ };
+
+ return new()
+ {
+ { ReadNumbers("Primes"), "2, 3, 5, 7, 11, 13", $"Primes? ", new float[] { 2, 3, 5, 7, 11, 13 } },
+ {
+ ReadNumbers("Qux"),
+ $"42{NewLine}3.141, 2.718{NewLine}3.0e8, 6.02e23{NewLine}9.11E-28",
+ $"Qux? ?? ?? ?? ",
+ new[] { 42, 3.141F, 2.718F, 3.0e8F, 6.02e23F, 9.11E-28F }
+ }
+ };
+ }
+}
diff --git a/00_Common/dotnet/Games.Common.Test/IO/TokenReaderTests.cs b/00_Common/dotnet/Games.Common.Test/IO/TokenReaderTests.cs
new file mode 100644
index 00000000..d4d8458e
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/IO/TokenReaderTests.cs
@@ -0,0 +1,106 @@
+using System;
+using System.IO;
+using System.Linq;
+using FluentAssertions;
+using FluentAssertions.Execution;
+using Xunit;
+
+using static System.Environment;
+using static Games.Common.IO.Strings;
+
+namespace Games.Common.IO;
+
+public class TokenReaderTests
+{
+ private readonly StringWriter _outputWriter;
+
+ public TokenReaderTests()
+ {
+ _outputWriter = new StringWriter();
+ }
+
+ [Fact]
+ public void ReadTokens_QuantityNeededZero_ThrowsArgumentException()
+ {
+ var sut = TokenReader.ForStrings(new TextIO(new StringReader(""), _outputWriter));
+
+ Action readTokens = () => sut.ReadTokens("", 0);
+
+ readTokens.Should().Throw()
+ .WithMessage("'quantityNeeded' must be greater than zero.*")
+ .WithParameterName("quantityNeeded");
+ }
+
+
+ [Theory]
+ [MemberData(nameof(ReadTokensTestCases))]
+ public void ReadTokens_ReadingValuesHasExpectedPromptsAndResults(
+ string prompt,
+ uint tokenCount,
+ string input,
+ string expectedOutput,
+ string[] expectedResult)
+ {
+ var sut = TokenReader.ForStrings(new TextIO(new StringReader(input + NewLine), _outputWriter));
+
+ var result = sut.ReadTokens(prompt, tokenCount);
+ var output = _outputWriter.ToString();
+
+ using var _ = new AssertionScope();
+ output.Should().Be(expectedOutput);
+ result.Select(t => t.String).Should().BeEquivalentTo(expectedResult);
+ }
+
+ [Theory]
+ [MemberData(nameof(ReadNumericTokensTestCases))]
+ public void ReadTokens_Numeric_ReadingValuesHasExpectedPromptsAndResults(
+ string prompt,
+ uint tokenCount,
+ string input,
+ string expectedOutput,
+ float[] expectedResult)
+ {
+ var sut = TokenReader.ForNumbers(new TextIO(new StringReader(input + NewLine), _outputWriter));
+
+ var result = sut.ReadTokens(prompt, tokenCount);
+ var output = _outputWriter.ToString();
+
+ using var _ = new AssertionScope();
+ output.Should().Be(expectedOutput);
+ result.Select(t => t.Number).Should().BeEquivalentTo(expectedResult);
+ }
+
+ public static TheoryData ReadTokensTestCases()
+ {
+ return new()
+ {
+ { "Name", 1, "Bill", "Name? ", new[] { "Bill" } },
+ { "Names", 2, " Bill , Bloggs ", "Names? ", new[] { "Bill", "Bloggs" } },
+ { "Names", 2, $" Bill{NewLine}Bloggs ", "Names? ?? ", new[] { "Bill", "Bloggs" } },
+ {
+ "Foo",
+ 6,
+ $"1,2{NewLine}\" a,b \"{NewLine},\"\"c,d{NewLine}d\"x,e,f",
+ $"Foo? ?? ?? ?? {ExtraInput}{NewLine}",
+ new[] { "1", "2", " a,b ", "", "", "d\"x" }
+ }
+ };
+ }
+
+ public static TheoryData ReadNumericTokensTestCases()
+ {
+ return new()
+ {
+ { "Age", 1, "23", "Age? ", new[] { 23F } },
+ { "Constants", 2, " 3.141 , 2.71 ", "Constants? ", new[] { 3.141F, 2.71F } },
+ { "Answer", 1, $"Forty-two{NewLine}42 ", $"Answer? {NumberExpected}{NewLine}? ", new[] { 42F } },
+ {
+ "Foo",
+ 6,
+ $"1,2{NewLine}\" a,b \"{NewLine}3, 4 {NewLine}5.6,7,a, b",
+ $"Foo? ?? {NumberExpected}{NewLine}? ?? {ExtraInput}{NewLine}",
+ new[] { 1, 2, 3, 4, 5.6F, 7 }
+ }
+ };
+ }
+}
diff --git a/00_Common/dotnet/Games.Common.Test/IO/TokenTests.cs b/00_Common/dotnet/Games.Common.Test/IO/TokenTests.cs
new file mode 100644
index 00000000..e91922d8
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/IO/TokenTests.cs
@@ -0,0 +1,42 @@
+using FluentAssertions;
+using Xunit;
+
+namespace Games.Common.IO;
+
+public class TokenTests
+{
+ [Theory]
+ [MemberData(nameof(TokenTestCases))]
+ public void Ctor_PopulatesProperties(string value, bool isNumber, float number)
+ {
+ var expected = new { String = value, IsNumber = isNumber, Number = number };
+
+ var token = new Token(value);
+
+ token.Should().BeEquivalentTo(expected);
+ }
+
+ public static TheoryData TokenTestCases() => new()
+ {
+ { "", false, float.NaN },
+ { "abcde", false, float.NaN },
+ { "123 ", true, 123 },
+ { "+42 ", true, 42 },
+ { "-42 ", true, -42 },
+ { "+3.14159 ", true, 3.14159F },
+ { "-3.14159 ", true, -3.14159F },
+ { " 123", false, float.NaN },
+ { "1.2e4", true, 12000 },
+ { "2.3e-5", true, 0.000023F },
+ { "1e100", true, float.MaxValue },
+ { "-1E100", true, float.MinValue },
+ { "1E-100", true, 0 },
+ { "-1e-100", true, 0 },
+ { "100abc", true, 100 },
+ { "1,2,3", true, 1 },
+ { "42,a,b", true, 42 },
+ { "1.2.3", true, 1.2F },
+ { "12e.5", false, float.NaN },
+ { "12e0.5", true, 12 }
+ };
+}
diff --git a/00_Common/dotnet/Games.Common.Test/IO/TokenizerTests.cs b/00_Common/dotnet/Games.Common.Test/IO/TokenizerTests.cs
new file mode 100644
index 00000000..30e54b0f
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.Test/IO/TokenizerTests.cs
@@ -0,0 +1,33 @@
+using System.Linq;
+using FluentAssertions;
+using Xunit;
+
+namespace Games.Common.IO;
+
+public class TokenizerTests
+{
+ [Theory]
+ [MemberData(nameof(TokenizerTestCases))]
+ public void ParseTokens_SplitsStringIntoExpectedTokens(string input, string[] expected)
+ {
+ var result = Tokenizer.ParseTokens(input);
+
+ result.Select(t => t.ToString()).Should().BeEquivalentTo(expected);
+ }
+
+ public static TheoryData TokenizerTestCases() => new()
+ {
+ { "", new[] { "" } },
+ { "aBc", new[] { "aBc" } },
+ { " Foo ", new[] { "Foo" } },
+ { " \" Foo \" ", new[] { " Foo " } },
+ { " \" Foo ", new[] { " Foo " } },
+ { "\"\"abc", new[] { "" } },
+ { "a\"\"bc", new[] { "a\"\"bc" } },
+ { "\"\"", new[] { "" } },
+ { ",", new[] { "", "" } },
+ { " foo ,bar", new[] { "foo", "bar" } },
+ { "\"a\"bc,de", new[] { "a" } },
+ { "a\"b,\" c,d\", f ,,g", new[] { "a\"b", " c,d", "f", "", "g" } }
+ };
+}
diff --git a/00_Common/dotnet/Games.Common.sln b/00_Common/dotnet/Games.Common.sln
new file mode 100644
index 00000000..e0f51d4b
--- /dev/null
+++ b/00_Common/dotnet/Games.Common.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 16
+VisualStudioVersion = 16.0.30114.105
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Games.Common", "Games.Common\Games.Common.csproj", "{005F2A3E-4E45-418B-8D19-E735B3BD4535}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Games.Common.Test", "Games.Common.Test\Games.Common.Test.csproj", "{8369DA66-0414-4A14-B5BE-73B0159498A2}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {005F2A3E-4E45-418B-8D19-E735B3BD4535}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {005F2A3E-4E45-418B-8D19-E735B3BD4535}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {005F2A3E-4E45-418B-8D19-E735B3BD4535}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {005F2A3E-4E45-418B-8D19-E735B3BD4535}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8369DA66-0414-4A14-B5BE-73B0159498A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8369DA66-0414-4A14-B5BE-73B0159498A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8369DA66-0414-4A14-B5BE-73B0159498A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8369DA66-0414-4A14-B5BE-73B0159498A2}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/00_Common/dotnet/Games.Common/Games.Common.csproj b/00_Common/dotnet/Games.Common/Games.Common.csproj
new file mode 100644
index 00000000..d4c395e8
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/Games.Common.csproj
@@ -0,0 +1,7 @@
+
+
+
+ netstandard2.1
+
+
+
diff --git a/00_Common/dotnet/Games.Common/IO/ConsoleIO.cs b/00_Common/dotnet/Games.Common/IO/ConsoleIO.cs
new file mode 100644
index 00000000..a24238bf
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/ConsoleIO.cs
@@ -0,0 +1,15 @@
+using System;
+
+namespace Games.Common.IO;
+
+///
+/// An implementation of with input begin read for STDIN and output being written to
+/// STDOUT.
+///
+public sealed class ConsoleIO : TextIO
+{
+ public ConsoleIO()
+ : base(Console.In, Console.Out)
+ {
+ }
+}
diff --git a/00_Common/dotnet/Games.Common/IO/IReadWrite.cs b/00_Common/dotnet/Games.Common/IO/IReadWrite.cs
new file mode 100644
index 00000000..27bd6986
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/IReadWrite.cs
@@ -0,0 +1,90 @@
+using System;
+using System.IO;
+
+namespace Games.Common.IO;
+
+///
+/// Provides for input and output of strings and numbers.
+///
+public interface IReadWrite
+{
+ ///
+ /// Reads a value from input.
+ ///
+ /// The text to display to prompt for the value.
+ /// A , being the value entered.
+ float ReadNumber(string prompt);
+
+ ///
+ /// Reads 2 values from input.
+ ///
+ /// The text to display to prompt for the values.
+ /// A , being the values entered.
+ (float, float) Read2Numbers(string prompt);
+
+ ///
+ /// Reads 3 values from input.
+ ///
+ /// The text to display to prompt for the values.
+ /// A , being the values entered.
+ (float, float, float) Read3Numbers(string prompt);
+
+ ///
+ /// Reads 4 values from input.
+ ///
+ /// The text to display to prompt for the values.
+ /// A , being the values entered.
+ (float, float, float, float) Read4Numbers(string prompt);
+
+ ///
+ /// Read numbers from input to fill an array.
+ ///
+ /// The text to display to prompt for the values.
+ /// A to be filled with values from input.
+ void ReadNumbers(string prompt, float[] values);
+
+ ///
+ /// Reads a value from input.
+ ///
+ /// The text to display to prompt for the value.
+ /// A , being the value entered.
+ string ReadString(string prompt);
+
+ ///
+ /// Reads 2 values from input.
+ ///
+ /// The text to display to prompt for the values.
+ /// A , being the values entered.
+ (string, string) Read2Strings(string prompt);
+
+ ///
+ /// Writes a to output.
+ ///
+ /// The to be written.
+ void Write(string message);
+
+ ///
+ /// Writes a to output, followed by a new-line.
+ ///
+ /// The to be written.
+ void WriteLine(string message = "");
+
+ ///
+ /// Writes a to output, formatted per the BASIC interpreter, with leading and trailing spaces.
+ ///
+ /// The to be written.
+ void Write(float value);
+
+ ///
+ /// Writes a to output, formatted per the BASIC interpreter, with leading and trailing spaces,
+ /// followed by a new-line.
+ ///
+ /// The to be written.
+ void WriteLine(float value);
+
+ ///
+ /// Writes the contents of a to output.
+ ///
+ /// The to be written.
+ void Write(Stream stream);
+}
diff --git a/00_Common/dotnet/Games.Common/IO/Strings.cs b/00_Common/dotnet/Games.Common/IO/Strings.cs
new file mode 100644
index 00000000..cf7d0f43
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/Strings.cs
@@ -0,0 +1,7 @@
+namespace Games.Common.IO;
+
+internal static class Strings
+{
+ internal const string NumberExpected = "!Number expected - retry input line";
+ internal const string ExtraInput = "!Extra input ignored";
+}
diff --git a/00_Common/dotnet/Games.Common/IO/TextIO.cs b/00_Common/dotnet/Games.Common/IO/TextIO.cs
new file mode 100644
index 00000000..d5e9f9f6
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/TextIO.cs
@@ -0,0 +1,108 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Games.Common.IO;
+
+///
+///
+/// Implements with input read from a and output written to a
+/// .
+///
+///
+/// This implementation reproduces the Vintage BASIC input experience, prompting multiple times when partial input
+/// supplied, rejecting non-numeric input as needed, warning about extra input being ignored, etc.
+///
+public class TextIO : IReadWrite
+{
+ private readonly TextReader _input;
+ private readonly TextWriter _output;
+ private readonly TokenReader _stringTokenReader;
+ private readonly TokenReader _numberTokenReader;
+
+ public TextIO(TextReader input, TextWriter output)
+ {
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _output = output ?? throw new ArgumentNullException(nameof(output));
+ _stringTokenReader = TokenReader.ForStrings(this);
+ _numberTokenReader = TokenReader.ForNumbers(this);
+ }
+
+ public float ReadNumber(string prompt) => ReadNumbers(prompt, 1)[0];
+
+ public (float, float) Read2Numbers(string prompt)
+ {
+ var numbers = ReadNumbers(prompt, 2);
+ return (numbers[0], numbers[1]);
+ }
+
+ public (float, float, float) Read3Numbers(string prompt)
+ {
+ var numbers = ReadNumbers(prompt, 3);
+ return (numbers[0], numbers[1], numbers[2]);
+ }
+
+ public (float, float, float, float) Read4Numbers(string prompt)
+ {
+ var numbers = ReadNumbers(prompt, 4);
+ return (numbers[0], numbers[1], numbers[2], numbers[3]);
+ }
+
+ public void ReadNumbers(string prompt, float[] values)
+ {
+ if (values.Length == 0)
+ {
+ throw new ArgumentException($"'{nameof(values)}' must have a non-zero length.", nameof(values));
+ }
+
+ var numbers = _numberTokenReader.ReadTokens(prompt, (uint)values.Length).Select(t => t.Number).ToArray();
+ numbers.CopyTo(values.AsSpan());
+ }
+
+ private IReadOnlyList ReadNumbers(string prompt, uint quantity) =>
+ (quantity > 0)
+ ? _numberTokenReader.ReadTokens(prompt, quantity).Select(t => t.Number).ToList()
+ : throw new ArgumentOutOfRangeException(
+ nameof(quantity),
+ $"'{nameof(quantity)}' must be greater than zero.");
+
+ public string ReadString(string prompt)
+ {
+ return ReadStrings(prompt, 1)[0];
+ }
+
+ public (string, string) Read2Strings(string prompt)
+ {
+ var values = ReadStrings(prompt, 2);
+ return (values[0], values[1]);
+ }
+
+ private IReadOnlyList ReadStrings(string prompt, uint quantityRequired) =>
+ _stringTokenReader.ReadTokens(prompt, quantityRequired).Select(t => t.String).ToList();
+
+ internal string ReadLine(string prompt)
+ {
+ Write(prompt + "? ");
+ return _input.ReadLine();
+ }
+
+ public void Write(string value) => _output.Write(value);
+
+ public void WriteLine(string value = "") => _output.WriteLine(value);
+
+ public void Write(float value) => _output.Write(GetString(value));
+
+ public void WriteLine(float value) => _output.WriteLine(GetString(value));
+
+ public void Write(Stream stream)
+ {
+ using var reader = new StreamReader(stream);
+ while (!reader.EndOfStream)
+ {
+ _output.WriteLine(reader.ReadLine());
+ }
+ }
+
+ private string GetString(float value) => value < 0 ? $"{value} " : $" {value} ";
+}
diff --git a/00_Common/dotnet/Games.Common/IO/Token.cs b/00_Common/dotnet/Games.Common/IO/Token.cs
new file mode 100644
index 00000000..dc432e0c
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/Token.cs
@@ -0,0 +1,59 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Games.Common.IO;
+
+internal class Token
+{
+ private static readonly Regex _numberPattern = new(@"^[+\-]?\d*(\.\d*)?([eE][+\-]?\d*)?");
+
+ internal Token(string value)
+ {
+ String = value;
+
+ var match = _numberPattern.Match(String);
+
+ IsNumber = float.TryParse(match.Value, out var number);
+ Number = (IsNumber, number) switch
+ {
+ (false, _) => float.NaN,
+ (true, float.PositiveInfinity) => float.MaxValue,
+ (true, float.NegativeInfinity) => float.MinValue,
+ (true, _) => number
+ };
+ }
+
+ public string String { get; }
+ public bool IsNumber { get; }
+ public float Number { get; }
+
+ public override string ToString() => String;
+
+ internal class Builder
+ {
+ private readonly StringBuilder _builder = new();
+ private bool _isQuoted;
+ private int _trailingWhiteSpaceCount;
+
+ public Builder Append(char character)
+ {
+ _builder.Append(character);
+
+ _trailingWhiteSpaceCount = char.IsWhiteSpace(character) ? _trailingWhiteSpaceCount + 1 : 0;
+
+ return this;
+ }
+
+ public Builder SetIsQuoted()
+ {
+ _isQuoted = true;
+ return this;
+ }
+
+ public Token Build()
+ {
+ if (!_isQuoted) { _builder.Length -= _trailingWhiteSpaceCount; }
+ return new Token(_builder.ToString());
+ }
+ }
+}
diff --git a/00_Common/dotnet/Games.Common/IO/TokenReader.cs b/00_Common/dotnet/Games.Common/IO/TokenReader.cs
new file mode 100644
index 00000000..04e7ad41
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/TokenReader.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using static Games.Common.IO.Strings;
+
+namespace Games.Common.IO;
+
+///
+/// Reads from input and assembles a given number of values, or tokens, possibly over a number of input lines.
+///
+internal class TokenReader
+{
+ private readonly TextIO _io;
+ private readonly Predicate _isTokenValid;
+
+ private TokenReader(TextIO io, Predicate isTokenValid)
+ {
+ _io = io;
+ _isTokenValid = isTokenValid ?? (t => true);
+ }
+
+ ///
+ /// Creates a which reads string tokens.
+ ///
+ /// A instance.
+ /// The new instance.
+ public static TokenReader ForStrings(TextIO io) => new(io, t => true);
+
+ ///
+ /// Creates a which reads tokens and validates that they can be parsed as numbers.
+ ///
+ /// A instance.
+ /// The new instance.
+ public static TokenReader ForNumbers(TextIO io) => new(io, t => t.IsNumber);
+
+ ///
+ /// Reads valid tokens from one or more input lines and builds a list with the required quantity.
+ ///
+ /// The string used to prompt the user for input.
+ /// The number of tokens required.
+ /// The sequence of tokens read.
+ public IEnumerable ReadTokens(string prompt, uint quantityNeeded)
+ {
+ if (quantityNeeded == 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(quantityNeeded),
+ $"'{nameof(quantityNeeded)}' must be greater than zero.");
+ }
+
+ var tokens = new List();
+
+ while (tokens.Count < quantityNeeded)
+ {
+ tokens.AddRange(ReadValidTokens(prompt, quantityNeeded - (uint)tokens.Count));
+ prompt = "?";
+ }
+
+ return tokens;
+ }
+
+ ///
+ /// Reads a line of tokens, up to , and rejects the line if any are invalid.
+ ///
+ /// The string used to prompt the user for input.
+ /// The maximum number of tokens to read.
+ /// The sequence of tokens read.
+ private IEnumerable ReadValidTokens(string prompt, uint maxCount)
+ {
+ while (true)
+ {
+ var tokensValid = true;
+ var tokens = new List();
+ foreach (var token in ReadLineOfTokens(prompt, maxCount))
+ {
+ if (!_isTokenValid(token))
+ {
+ _io.WriteLine(NumberExpected);
+ tokensValid = false;
+ prompt = "";
+ break;
+ }
+
+ tokens.Add(token);
+ }
+
+ if (tokensValid) { return tokens; }
+ }
+ }
+
+ ///
+ /// Lazily reads up to tokens from an input line.
+ ///
+ /// The string used to prompt the user for input.
+ /// The maximum number of tokens to read.
+ ///
+ private IEnumerable ReadLineOfTokens(string prompt, uint maxCount)
+ {
+ var tokenCount = 0;
+
+ foreach (var token in Tokenizer.ParseTokens(_io.ReadLine(prompt)))
+ {
+ if (++tokenCount > maxCount)
+ {
+ _io.WriteLine(ExtraInput);
+ break;
+ }
+
+ yield return token;
+ }
+ }
+}
diff --git a/00_Common/dotnet/Games.Common/IO/Tokenizer.cs b/00_Common/dotnet/Games.Common/IO/Tokenizer.cs
new file mode 100644
index 00000000..dc6a4d79
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/IO/Tokenizer.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+
+namespace Games.Common.IO;
+
+///
+/// A simple state machine which parses tokens from a line of input.
+///
+internal class Tokenizer
+{
+ private const char Quote = '"';
+ private const char Separator = ',';
+
+ private readonly Queue _characters;
+
+ private Tokenizer(string input) => _characters = new Queue(input);
+
+ public static IEnumerable ParseTokens(string input)
+ {
+ if (input is null) { throw new ArgumentNullException(nameof(input)); }
+
+ return new Tokenizer(input).ParseTokens();
+ }
+
+ private IEnumerable ParseTokens()
+ {
+ while (true)
+ {
+ var (token, isLastToken) = Consume(_characters);
+ yield return token;
+
+ if (isLastToken) { break; }
+ }
+ }
+
+ public (Token, bool) Consume(Queue characters)
+ {
+ var tokenBuilder = new Token.Builder();
+ var state = ITokenizerState.LookForStartOfToken;
+
+ while (characters.TryDequeue(out var character))
+ {
+ (state, tokenBuilder) = state.Consume(character, tokenBuilder);
+ if (state is AtEndOfTokenState) { return (tokenBuilder.Build(), false); }
+ }
+
+ return (tokenBuilder.Build(), true);
+ }
+
+ private interface ITokenizerState
+ {
+ public static ITokenizerState LookForStartOfToken { get; } = new LookForStartOfTokenState();
+
+ (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder);
+ }
+
+ private struct LookForStartOfTokenState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ character switch
+ {
+ Separator => (new AtEndOfTokenState(), tokenBuilder),
+ Quote => (new InQuotedTokenState(), tokenBuilder.SetIsQuoted()),
+ _ when char.IsWhiteSpace(character) => (this, tokenBuilder),
+ _ => (new InTokenState(), tokenBuilder.Append(character))
+ };
+ }
+
+ private struct InTokenState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ character == Separator
+ ? (new AtEndOfTokenState(), tokenBuilder)
+ : (this, tokenBuilder.Append(character));
+ }
+
+ private struct InQuotedTokenState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ character == Quote
+ ? (new ExpectSeparatorState(), tokenBuilder)
+ : (this, tokenBuilder.Append(character));
+ }
+
+ private struct ExpectSeparatorState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ character == Separator
+ ? (new AtEndOfTokenState(), tokenBuilder)
+ : (new IgnoreRestOfLineState(), tokenBuilder);
+ }
+
+ private struct IgnoreRestOfLineState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ (this, tokenBuilder);
+ }
+
+ private struct AtEndOfTokenState : ITokenizerState
+ {
+ public (ITokenizerState, Token.Builder) Consume(char character, Token.Builder tokenBuilder) =>
+ throw new InvalidOperationException();
+ }
+}
diff --git a/00_Common/dotnet/Games.Common/Randomness/IRandom.cs b/00_Common/dotnet/Games.Common/Randomness/IRandom.cs
new file mode 100644
index 00000000..9db4c12f
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/Randomness/IRandom.cs
@@ -0,0 +1,25 @@
+namespace Games.Common.Randomness;
+
+///
+/// Provides access to a random number generator
+///
+public interface IRandom
+{
+ ///
+ /// Gets a random such that 0 <= n < 1.
+ ///
+ /// The random number.
+ float NextFloat();
+
+ ///
+ /// Gets the returned by the previous call to .
+ ///
+ /// The previous random number.
+ float PreviousFloat();
+
+ ///
+ /// Reseeds the random number generator.
+ ///
+ /// The seed.
+ void Reseed(int seed);
+}
diff --git a/00_Common/dotnet/Games.Common/Randomness/IRandomExtensions.cs b/00_Common/dotnet/Games.Common/Randomness/IRandomExtensions.cs
new file mode 100644
index 00000000..bfdd0b49
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/Randomness/IRandomExtensions.cs
@@ -0,0 +1,91 @@
+using System;
+
+namespace Games.Common.Randomness;
+
+///
+/// Provides extension methods to providing random numbers in a given range.
+///
+///
+public static class IRandomExtensions
+{
+ ///
+ /// Gets a random such that 0 <= n < exclusiveMaximum.
+ ///
+ /// The random number.
+ public static float NextFloat(this IRandom random, float exclusiveMaximum) =>
+ Scale(random.NextFloat(), exclusiveMaximum);
+
+ ///
+ /// Gets a random such that inclusiveMinimum <= n < exclusiveMaximum.
+ ///
+ /// The random number.
+ public static float NextFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum) =>
+ Scale(random.NextFloat(), inclusiveMinimum, exclusiveMaximum);
+
+ ///
+ /// Gets a random such that 0 <= n < exclusiveMaximum.
+ ///
+ /// The random number.
+ public static int Next(this IRandom random, int exclusiveMaximum) => ToInt(random.NextFloat(exclusiveMaximum));
+
+ ///
+ /// Gets a random such that inclusiveMinimum <= n < exclusiveMaximum.
+ ///
+ /// The random number.
+ public static int Next(this IRandom random, int inclusiveMinimum, int exclusiveMaximum) =>
+ ToInt(random.NextFloat(inclusiveMinimum, exclusiveMaximum));
+
+ ///
+ /// Gets the previous unscaled (between 0 and 1) scaled to a new range:
+ /// 0 <= x < .
+ ///
+ /// The random number.
+ public static float PreviousFloat(this IRandom random, float exclusiveMaximum) =>
+ Scale(random.PreviousFloat(), exclusiveMaximum);
+
+ ///
+ /// Gets the previous unscaled (between 0 and 1) scaled to a new range:
+ /// <= n < .
+ ///
+ /// The random number.
+ public static float PreviousFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum) =>
+ Scale(random.PreviousFloat(), inclusiveMinimum, exclusiveMaximum);
+
+ ///
+ /// Gets the previous unscaled (between 0 and 1) scaled to an in a new
+ /// range: 0 <= n < .
+ ///
+ /// The random number.
+ public static int Previous(this IRandom random, int exclusiveMaximum) =>
+ ToInt(random.PreviousFloat(exclusiveMaximum));
+
+ ///
+ /// Gets the previous unscaled (between 0 and 1) scaled to an in a new
+ /// range: <= n < .
+ /// The random number.
+ public static int Previous(this IRandom random, int inclusiveMinimum, int exclusiveMaximum) =>
+ ToInt(random.PreviousFloat(inclusiveMinimum, exclusiveMaximum));
+
+ private static float Scale(float zeroToOne, float exclusiveMaximum)
+ {
+ if (exclusiveMaximum <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(exclusiveMaximum), "Must be greater than 0.");
+ }
+
+ return Scale(zeroToOne, 0, exclusiveMaximum);
+ }
+
+ private static float Scale(float zeroToOne, float inclusiveMinimum, float exclusiveMaximum)
+ {
+ if (exclusiveMaximum <= inclusiveMinimum)
+ {
+ throw new ArgumentOutOfRangeException(nameof(exclusiveMaximum), "Must be greater than inclusiveMinimum.");
+ }
+
+ var range = exclusiveMaximum - inclusiveMinimum;
+ return zeroToOne * range + inclusiveMinimum;
+ }
+
+ private static int ToInt(float value) => (int)Math.Floor(value);
+}
\ No newline at end of file
diff --git a/00_Common/dotnet/Games.Common/Randomness/RandomNumberGenerator.cs b/00_Common/dotnet/Games.Common/Randomness/RandomNumberGenerator.cs
new file mode 100644
index 00000000..d46f72e7
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/Randomness/RandomNumberGenerator.cs
@@ -0,0 +1,22 @@
+using System;
+
+namespace Games.Common.Randomness;
+
+///
+public class RandomNumberGenerator : IRandom
+{
+ private Random _random;
+ private float _previous;
+
+ public RandomNumberGenerator()
+ {
+ // The BASIC RNG is seeded based on time with a 1 second resolution
+ _random = new Random((int)(DateTime.UtcNow.Ticks / TimeSpan.TicksPerSecond));
+ }
+
+ public float NextFloat() => _previous = (float)_random.NextDouble();
+
+ public float PreviousFloat() => _previous;
+
+ public void Reseed(int seed) => _random = new Random(seed);
+}
diff --git a/00_Common/dotnet/Games.Common/_InternalsVisibleTo.cs b/00_Common/dotnet/Games.Common/_InternalsVisibleTo.cs
new file mode 100644
index 00000000..2ffc2ca3
--- /dev/null
+++ b/00_Common/dotnet/Games.Common/_InternalsVisibleTo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly:InternalsVisibleTo("Games.Common.Test")]
\ No newline at end of file
diff --git a/00_Common/dotnet/README.md b/00_Common/dotnet/README.md
new file mode 100644
index 00000000..c44f210b
--- /dev/null
+++ b/00_Common/dotnet/README.md
@@ -0,0 +1,181 @@
+# Games.Common
+
+This is the common library for C# and VB.Net ports of the games.
+
+## Overview
+
+### Game Input/Output
+
+* `TextIO` is the main class which manages text input and output for a game. It take a `TextReader` and a `TextWriter` in
+its constructor so it can be wired up in unit tests to test gameplay scenarios.
+* `ConsoleIO` derives from `TextIO` and binds it to `System.Console.In` and `System.Console.Out`.
+* `IReadWrite` is an interface implemented by `TextIO` which may be useful in some test scenarios.
+
+```csharp
+public interface IReadWrite
+{
+ // Reads a float value from input.
+ float ReadNumber(string prompt);
+
+ // Reads 2 float values from input.
+ (float, float) Read2Numbers(string prompt);
+
+ // Reads 3 float values from input.
+ (float, float, float) Read3Numbers(string prompt);
+
+ // Reads 4 float values from input.
+ (float, float, float, float) Read4Numbers(string prompt);
+
+ // Read numbers from input to fill an array.
+ void ReadNumbers(string prompt, float[] values);
+
+ // Reads a string value from input.
+ string ReadString(string prompt);
+
+ // Reads 2 string values from input.
+ (string, string) Read2Strings(string prompt);
+
+ // Writes a string to output.
+ void Write(string message);
+
+ // Writes a string to output, followed by a new-line.
+ void WriteLine(string message = "");
+
+ // Writes a float to output, formatted per the BASIC interpreter, with leading and trailing spaces.
+ void Write(float value);
+
+ // Writes a float to output, formatted per the BASIC interpreter, with leading and trailing spaces,
+ // followed by a new-line.
+ void WriteLine(float value);
+
+ // Writes the contents of a Stream to output.
+ void Write(Stream stream);}
+```
+
+### Random Number Generation
+
+* `IRandom` is an interface that provides basic methods that parallel the 3 uses of BASIC's `RND(float)` function.
+* `RandomNumberGenerator` is an implementation of `IRandom` built around `System.Random`.
+* `IRandomExtensions` provides convenience extension methods for obtaining random numbers as `int` and also within a
+ given range.
+
+```csharp
+public interface IRandom
+{
+ // Like RND(1), gets a random float such that 0 <= n < 1.
+ float NextFloat();
+
+ // Like RND(0), Gets the float returned by the previous call to NextFloat.
+ float PreviousFloat();
+
+ // Like RND(-x), Reseeds the random number generator.
+ void Reseed(int seed);
+}
+```
+
+Extension methods on `IRandom`:
+
+```csharp
+// Gets a random float such that 0 <= n < exclusiveMaximum.
+float NextFloat(this IRandom random, float exclusiveMaximum);
+
+// Gets a random float such that inclusiveMinimum <= n < exclusiveMaximum.
+float NextFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum);
+
+// Gets a random int such that 0 <= n < exclusiveMaximum.
+int Next(this IRandom random, int exclusiveMaximum);
+
+// Gets a random int such that inclusiveMinimum <= n < exclusiveMaximum.
+int Next(this IRandom random, int inclusiveMinimum, int exclusiveMaximum);
+
+// Gets the previous unscaled float (between 0 and 1) scaled to a new range:
+// 0 <= x < exclusiveMaximum.
+float PreviousFloat(this IRandom random, float exclusiveMaximum);
+
+// Gets the previous unscaled float (between 0 and 1) scaled to a new range:
+// inclusiveMinimum <= n < exclusiveMaximum.
+float PreviousFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum);
+
+// Gets the previous unscaled float (between 0 and 1) scaled to an int in a new range:
+// 0 <= n < exclusiveMaximum.
+int Previous(this IRandom random, int exclusiveMaximum);
+
+// Gets the previous unscaled float (between 0 and 1) scaled to an int in a new range:
+// inclusiveMinimum <= n < exclusiveMaximum.
+int Previous(this IRandom random, int inclusiveMinimum, int exclusiveMaximum);
+```
+
+## C\# Usage
+
+### Add Project Reference
+
+Add the `Games.Common` project as a reference to the game project. For example, here's the reference from the C\# port
+of `86_Target`:
+
+```xml
+
+
+
+```
+
+### C# Game Input/Output usage
+
+A game can be encapsulated in a class which takes a `TextIO` instance in it's constructor:
+
+```csharp
+public class Game
+{
+ private readonly TextIO _io;
+
+ public Game(TextIO io) => _io = io;
+
+ public void Play()
+ {
+ var name = _io.ReadString("What is your name");
+ var (cats, dogs) = _io.Read2Number($"Hello, {name}, how many pets do you have (cats, dogs)");
+ _io.WriteLine($"So, {cats + dogs} pets in total, huh?");
+ }
+}
+```
+
+Then the entry point of the game program would look something like:
+
+```csharp
+var game = new Game(new ConsoleIO());
+game.Play();
+```
+
+### C# Random Number Generator usage
+
+```csharp
+var io = new ConsoleIO();
+var rng = new RandomNumberGenerator();
+io.WriteLine(rng.NextFloat()); // 0.1234, for example
+io.WriteLine(rng.NextFloat()); // 0.6, for example
+io.WriteLine(rng.PreviousFloat()); // 0.6, repeats previous
+io.WriteLine(rng.PreviousFloat(0, 10)); // 6, repeats previous value, but scaled to new range
+```
+
+### C# Unit Test usage
+
+`TextIO` can be initialised with a `StringReader` and `StringWriter` to enable testing. For example, given the `Game`
+class above:
+
+```csharp
+var reader = new StringReader("Joe Bloggs\r\n4\n\r5");
+var writer = new StringWriter();
+var game = new Game(new TextIO(reader, writer))
+
+game.Play();
+
+writer.ToString().Should().BeEquivalentTo(
+ "What is your name? Hello, Joe Bloggs, how many pets do you have (cats, dogs)? ?? So, 9 pets in total, huh?");
+```
+
+Note the lack of line breaks in the expected output, because during game play the line breaks come from the text input.
+
+Of course, `IReadWrite` can also be mocked for simple test scenarios.
+
+## VB.Net Usage
+
+*To be provided*
diff --git a/86_Target/csharp/FiringRange.cs b/86_Target/csharp/FiringRange.cs
index 368dc453..b8a6399c 100644
--- a/86_Target/csharp/FiringRange.cs
+++ b/86_Target/csharp/FiringRange.cs
@@ -1,25 +1,23 @@
-using System;
+using Games.Common.Randomness;
namespace Target
{
internal class FiringRange
{
- private readonly Random random;
+ private readonly IRandom _random;
+ private Point _targetPosition;
- public FiringRange()
+ public FiringRange(IRandom random)
{
- random = new Random();
- NextTarget();
+ _random = random;
}
- public Point TargetPosition { get; private set; }
-
- public void NextTarget() => TargetPosition = random.NextPosition();
+ public Point NextTarget() => _targetPosition = _random.NextPosition();
public Explosion Fire(Angle angleFromX, Angle angleFromZ, float distance)
{
var explosionPosition = new Point(angleFromX, angleFromZ, distance);
- var targetOffset = explosionPosition - TargetPosition;
+ var targetOffset = explosionPosition - _targetPosition;
return new (explosionPosition, targetOffset);
}
}
diff --git a/86_Target/csharp/Game.cs b/86_Target/csharp/Game.cs
index 2cef015a..4bb4f9b2 100644
--- a/86_Target/csharp/Game.cs
+++ b/86_Target/csharp/Game.cs
@@ -1,39 +1,41 @@
using System;
+using Games.Common.IO;
namespace Target
{
internal class Game
{
+ private readonly IReadWrite _io;
private readonly FiringRange _firingRange;
private int _shotCount;
- private Game(FiringRange firingRange)
+ public Game(IReadWrite io, FiringRange firingRange)
{
+ _io = io;
_firingRange = firingRange;
}
- public static void Play(FiringRange firingRange) => new Game(firingRange).Play();
-
- private void Play()
+ public void Play()
{
- var target = _firingRange.TargetPosition;
- Console.WriteLine(target.GetBearing());
- Console.WriteLine($"Target sighted: approximate coordinates: {target}");
+ _shotCount = 0;
+ var target = _firingRange.NextTarget();
+ _io.WriteLine(target.GetBearing());
+ _io.WriteLine($"Target sighted: approximate coordinates: {target}");
while (true)
{
- Console.WriteLine($" Estimated distance: {target.EstimateDistance()}");
- Console.WriteLine();
+ _io.WriteLine($" Estimated distance: {target.EstimateDistance()}");
+ _io.WriteLine();
var explosion = Shoot();
if (explosion.IsTooClose)
{
- Console.WriteLine("You blew yourself up!!");
+ _io.WriteLine("You blew yourself up!!");
return;
}
- Console.WriteLine(explosion.GetBearing());
+ _io.WriteLine(explosion.GetBearing());
if (explosion.IsHit)
{
@@ -47,31 +49,32 @@ namespace Target
private Explosion Shoot()
{
- var input = Input.ReadNumbers("Input angle deviation from X, angle deviation from Z, distance", 3);
+ var (xDeviation, zDeviation, distance) = _io.Read3Numbers(
+ "Input angle deviation from X, angle deviation from Z, distance");
_shotCount++;
- Console.WriteLine();
+ _io.WriteLine();
- return _firingRange.Fire(Angle.InDegrees(input[0]), Angle.InDegrees(input[1]), input[2]);
+ return _firingRange.Fire(Angle.InDegrees(xDeviation), Angle.InDegrees(zDeviation), distance);
}
private void ReportHit(float distance)
{
- Console.WriteLine();
- Console.WriteLine($" * * * HIT * * * Target is non-functional");
- Console.WriteLine();
- Console.WriteLine($"Distance of explosion from target was {distance} kilometers.");
- Console.WriteLine();
- Console.WriteLine($"Mission accomplished in {_shotCount} shots.");
+ _io.WriteLine();
+ _io.WriteLine($" * * * HIT * * * Target is non-functional");
+ _io.WriteLine();
+ _io.WriteLine($"Distance of explosion from target was {distance} kilometers.");
+ _io.WriteLine();
+ _io.WriteLine($"Mission accomplished in {_shotCount} shots.");
}
private void ReportMiss(Explosion explosion)
{
ReportMiss(explosion.FromTarget);
- Console.WriteLine($"Approx position of explosion: {explosion.Position}");
- Console.WriteLine($" Distance from target = {explosion.DistanceToTarget}");
- Console.WriteLine();
- Console.WriteLine();
- Console.WriteLine();
+ _io.WriteLine($"Approx position of explosion: {explosion.Position}");
+ _io.WriteLine($" Distance from target = {explosion.DistanceToTarget}");
+ _io.WriteLine();
+ _io.WriteLine();
+ _io.WriteLine();
}
private void ReportMiss(Offset targetOffset)
@@ -82,7 +85,7 @@ namespace Target
}
private void ReportMiss(float delta, string positiveText, string negativeText) =>
- Console.WriteLine(delta >= 0 ? GetOffsetText(positiveText, delta) : GetOffsetText(negativeText, -delta));
+ _io.WriteLine(delta >= 0 ? GetOffsetText(positiveText, delta) : GetOffsetText(negativeText, -delta));
private static string GetOffsetText(string text, float distance) => $"Shot {text} target {distance} kilometers.";
}
diff --git a/86_Target/csharp/Input.cs b/86_Target/csharp/Input.cs
deleted file mode 100644
index 3cfce2c0..00000000
--- a/86_Target/csharp/Input.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using System;
-using System.Collections.Generic;
-
-namespace Target
-{
- // Provides input methods which emulate the BASIC interpreter's keyboard input routines
- internal static class Input
- {
- internal static void Prompt(string text = "") => Console.Write($"{text}? ");
-
- internal static List ReadNumbers(string prompt, int requiredCount)
- {
- var numbers = new List();
-
- while (!TryReadNumbers(prompt, requiredCount, numbers))
- {
- numbers.Clear();
- prompt = "";
- }
-
- return numbers;
- }
-
- private static bool TryReadNumbers(string prompt, int requiredCount, List numbers)
- {
- Prompt(prompt);
- var inputValues = ReadStrings();
-
- foreach (var value in inputValues)
- {
- if (numbers.Count == requiredCount)
- {
- Console.WriteLine("!Extra input ingored");
- break;
- }
-
- if (!TryParseNumber(value, out var number))
- {
- return false;
- }
-
- numbers.Add(number);
- }
-
- return numbers.Count == requiredCount || TryReadNumbers("?", requiredCount, numbers);
- }
-
- private static string[] ReadStrings() => Console.ReadLine().Split(',', StringSplitOptions.TrimEntries);
-
- private static bool TryParseNumber(string text, out float number)
- {
- if (float.TryParse(text, out number)) { return true; }
-
- Console.WriteLine("!Number expected - retry input line");
- number = default;
- return false;
- }
- }
-}
diff --git a/86_Target/csharp/Program.cs b/86_Target/csharp/Program.cs
index b861f0e6..cafa559a 100644
--- a/86_Target/csharp/Program.cs
+++ b/86_Target/csharp/Program.cs
@@ -1,39 +1,43 @@
using System;
using System.Reflection;
+using Games.Common.IO;
+using Games.Common.Randomness;
namespace Target
{
class Program
{
- static void Main(string[] args)
+ static void Main()
{
- DisplayTitleAndInstructions();
+ var io = new ConsoleIO();
+ var game = new Game(io, new FiringRange(new RandomNumberGenerator()));
- var firingRange = new FiringRange();
+ Play(game, io, () => true);
+ }
- while (true)
+ public static void Play(Game game, TextIO io, Func playAgain)
+ {
+ DisplayTitleAndInstructions(io);
+
+ while (playAgain())
{
- Game.Play(firingRange);
+ game.Play();
- Console.WriteLine();
- Console.WriteLine();
- Console.WriteLine();
- Console.WriteLine();
- Console.WriteLine();
- Console.WriteLine("Next target...");
- Console.WriteLine();
-
- firingRange.NextTarget();
+ io.WriteLine();
+ io.WriteLine();
+ io.WriteLine();
+ io.WriteLine();
+ io.WriteLine();
+ io.WriteLine("Next target...");
+ io.WriteLine();
}
}
- private static void DisplayTitleAndInstructions()
+ private static void DisplayTitleAndInstructions(TextIO io)
{
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("Target.Strings.TitleAndInstructions.txt");
- using var stdout = Console.OpenStandardOutput();
-
- stream.CopyTo(stdout);
+ io.Write(stream);
}
}
}
diff --git a/86_Target/csharp/RandomExtensions.cs b/86_Target/csharp/RandomExtensions.cs
index 45443c26..a76b0279 100644
--- a/86_Target/csharp/RandomExtensions.cs
+++ b/86_Target/csharp/RandomExtensions.cs
@@ -1,12 +1,10 @@
-using System;
+using Games.Common.Randomness;
namespace Target
{
internal static class RandomExtensions
{
- public static float NextFloat(this Random rnd) => (float)rnd.NextDouble();
-
- public static Point NextPosition(this Random rnd) => new (
+ public static Point NextPosition(this IRandom rnd) => new (
Angle.InRotations(rnd.NextFloat()),
Angle.InRotations(rnd.NextFloat()),
100000 * rnd.NextFloat() + rnd.NextFloat());
diff --git a/86_Target/csharp/Target.csproj b/86_Target/csharp/Target.csproj
index 2df33b67..ea8ff526 100644
--- a/86_Target/csharp/Target.csproj
+++ b/86_Target/csharp/Target.csproj
@@ -9,4 +9,8 @@
+
+
+
+