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 @@ + + + +