Merge pull request #619 from drewjcooper/dotnet-common-library

.Net common library
This commit is contained in:
Jeff Atwood
2022-03-09 11:40:32 -06:00
committed by GitHub
31 changed files with 1575 additions and 116 deletions

View File

@@ -0,0 +1,5 @@
10 INPUT "Enter 3 numbers";A,B,C
20 PRINT "You entered: ";A;B;C
30 PRINT "--------------------------"
40 GOTO 10

View File

@@ -0,0 +1,4 @@
10 A=1: B=-2: C=0.7: D=123456789: E=-0.0000000001
20 PRINT "|";A;"|";B;"|";C;"|";D;"|";E;"|"

View File

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

185
00_Common/README.md Normal file
View File

@@ -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.
<!-- markdownlint-disable MD024 -->
### Implementation Notes
<!-- markdownlint-enable MD024 -->
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
```
<!-- markdownlint-disable MD024 -->
### Implementation Notes
<!-- markdownlint-enable MD024 -->
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.

View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<LangVersion>10.0</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Games.Common\Games.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -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<float, string> 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<int, string> WriteIntTestCases()
=> new()
{
{ 1000, " 1000 " },
{ 1, " 1 " },
{ 0, " 0 " },
{ -1, "-1 " },
{ -1000, "-1000 " },
};
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.IO;
using FluentAssertions;
using FluentAssertions.Execution;
using Xunit;
using TwoStrings = System.ValueTuple<string, string>;
using TwoNumbers = System.ValueTuple<float, float>;
using ThreeNumbers = System.ValueTuple<float, float, float>;
using FourNumbers = System.ValueTuple<float, float, float, float>;
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<T>(
Func<IReadWrite, T> 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<float>());
readNumbers.Should().Throw<ArgumentException>()
.WithMessage("'values' must have a non-zero length.*")
.WithParameterName("values");
}
public static TheoryData<Func<IReadWrite, string>, string, string, string> ReadStringTestCases()
{
static Func<IReadWrite, string> ReadString(string prompt) => io => io.ReadString(prompt);
return new()
{
{ ReadString("Name"), "", "Name? ", "" },
{ ReadString("prompt"), " foo ,bar", $"prompt? {ExtraInput}{NewLine}", "foo" }
};
}
public static TheoryData<Func<IReadWrite, TwoStrings>, string, string, TwoStrings> Read2StringsTestCases()
{
static Func<IReadWrite, TwoStrings> 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<Func<IReadWrite, float>, string, string, float> ReadNumberTestCases()
{
static Func<IReadWrite, float> 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<Func<IReadWrite, TwoNumbers>, string, string, TwoNumbers> Read2NumbersTestCases()
{
static Func<IReadWrite, TwoNumbers> 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<Func<IReadWrite, ThreeNumbers>, string, string, ThreeNumbers> Read3NumbersTestCases()
{
static Func<IReadWrite, ThreeNumbers> 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<Func<IReadWrite, FourNumbers>, string, string, FourNumbers> Read4NumbersTestCases()
{
static Func<IReadWrite, FourNumbers> 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<Func<IReadWrite, IReadOnlyList<float>>, string, string, float[]> ReadNumbersTestCases()
{
static Func<IReadWrite, IReadOnlyList<float>> 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 }
}
};
}
}

View File

@@ -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<ArgumentOutOfRangeException>()
.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<string, uint, string, string, string[]> 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<string, uint, string, string, float[]> 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 }
}
};
}
}

View File

@@ -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<string, bool, float> 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 }
};
}

View File

@@ -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<string, string[]> 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" } }
};
}

View File

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

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using System;
namespace Games.Common.IO;
/// <summary>
/// An implementation of <see cref="IReadWrite" /> with input begin read for STDIN and output being written to
/// STDOUT.
/// </summary>
public sealed class ConsoleIO : TextIO
{
public ConsoleIO()
: base(Console.In, Console.Out)
{
}
}

View File

@@ -0,0 +1,90 @@
using System;
using System.IO;
namespace Games.Common.IO;
/// <summary>
/// Provides for input and output of strings and numbers.
/// </summary>
public interface IReadWrite
{
/// <summary>
/// Reads a <see cref="float" /> value from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the value.</param>
/// <returns>A <see cref="float" />, being the value entered.</returns>
float ReadNumber(string prompt);
/// <summary>
/// Reads 2 <see cref="float" /> values from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the values.</param>
/// <returns>A <see cref="ValueTuple{float, float}" />, being the values entered.</returns>
(float, float) Read2Numbers(string prompt);
/// <summary>
/// Reads 3 <see cref="float" /> values from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the values.</param>
/// <returns>A <see cref="ValueTuple{float, float, float}" />, being the values entered.</returns>
(float, float, float) Read3Numbers(string prompt);
/// <summary>
/// Reads 4 <see cref="float" /> values from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the values.</param>
/// <returns>A <see cref="ValueTuple{float, float, float, float}" />, being the values entered.</returns>
(float, float, float, float) Read4Numbers(string prompt);
/// <summary>
/// Read numbers from input to fill an array.
/// </summary>
/// <param name="prompt">The text to display to prompt for the values.</param>
/// <param name="values">A <see cref="float[]" /> to be filled with values from input.</param>
void ReadNumbers(string prompt, float[] values);
/// <summary>
/// Reads a <see cref="string" /> value from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the value.</param>
/// <returns>A <see cref="string" />, being the value entered.</returns>
string ReadString(string prompt);
/// <summary>
/// Reads 2 <see cref="string" /> values from input.
/// </summary>
/// <param name="prompt">The text to display to prompt for the values.</param>
/// <returns>A <see cref="ValueTuple{string, string}" />, being the values entered.</returns>
(string, string) Read2Strings(string prompt);
/// <summary>
/// Writes a <see cref="string" /> to output.
/// </summary>
/// <param name="message">The <see cref="string" /> to be written.</param>
void Write(string message);
/// <summary>
/// Writes a <see cref="string" /> to output, followed by a new-line.
/// </summary>
/// <param name="message">The <see cref="string" /> to be written.</param>
void WriteLine(string message = "");
/// <summary>
/// Writes a <see cref="float" /> to output, formatted per the BASIC interpreter, with leading and trailing spaces.
/// </summary>
/// <param name="value">The <see cref="float" /> to be written.</param>
void Write(float value);
/// <summary>
/// Writes a <see cref="float" /> to output, formatted per the BASIC interpreter, with leading and trailing spaces,
/// followed by a new-line.
/// </summary>
/// <param name="value">The <see cref="float" /> to be written.</param>
void WriteLine(float value);
/// <summary>
/// Writes the contents of a <see cref="Stream" /> to output.
/// </summary>
/// <param name="stream">The <see cref="Stream" /> to be written.</param>
void Write(Stream stream);
}

View File

@@ -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";
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Games.Common.IO;
/// <inheritdoc />
/// <summary>
/// Implements <see cref="IReadWrite" /> with input read from a <see cref="TextReader" /> and output written to a
/// <see cref="TextWriter" />.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<float> 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<string> 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} ";
}

View File

@@ -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());
}
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Linq;
using static Games.Common.IO.Strings;
namespace Games.Common.IO;
/// <summary>
/// Reads from input and assembles a given number of values, or tokens, possibly over a number of input lines.
/// </summary>
internal class TokenReader
{
private readonly TextIO _io;
private readonly Predicate<Token> _isTokenValid;
private TokenReader(TextIO io, Predicate<Token> isTokenValid)
{
_io = io;
_isTokenValid = isTokenValid ?? (t => true);
}
/// <summary>
/// Creates a <see cref="TokenReader" /> which reads string tokens.
/// </summary>
/// <param name="io">A <see cref="TextIO" /> instance.</param>
/// <returns>The new <see cref="TokenReader" /> instance.</returns>
public static TokenReader ForStrings(TextIO io) => new(io, t => true);
/// <summary>
/// Creates a <see cref="TokenReader" /> which reads tokens and validates that they can be parsed as numbers.
/// </summary>
/// <param name="io">A <see cref="TextIO" /> instance.</param>
/// <returns>The new <see cref="TokenReader" /> instance.</returns>
public static TokenReader ForNumbers(TextIO io) => new(io, t => t.IsNumber);
/// <summary>
/// Reads valid tokens from one or more input lines and builds a list with the required quantity.
/// </summary>
/// <param name="prompt">The string used to prompt the user for input.</param>
/// <param name="quantityNeeded">The number of tokens required.</param>
/// <returns>The sequence of tokens read.</returns>
public IEnumerable<Token> ReadTokens(string prompt, uint quantityNeeded)
{
if (quantityNeeded == 0)
{
throw new ArgumentOutOfRangeException(
nameof(quantityNeeded),
$"'{nameof(quantityNeeded)}' must be greater than zero.");
}
var tokens = new List<Token>();
while (tokens.Count < quantityNeeded)
{
tokens.AddRange(ReadValidTokens(prompt, quantityNeeded - (uint)tokens.Count));
prompt = "?";
}
return tokens;
}
/// <summary>
/// Reads a line of tokens, up to <paramref name="maxCount" />, and rejects the line if any are invalid.
/// </summary>
/// <param name="prompt">The string used to prompt the user for input.</param>
/// <param name="maxCount">The maximum number of tokens to read.</param>
/// <returns>The sequence of tokens read.</returns>
private IEnumerable<Token> ReadValidTokens(string prompt, uint maxCount)
{
while (true)
{
var tokensValid = true;
var tokens = new List<Token>();
foreach (var token in ReadLineOfTokens(prompt, maxCount))
{
if (!_isTokenValid(token))
{
_io.WriteLine(NumberExpected);
tokensValid = false;
prompt = "";
break;
}
tokens.Add(token);
}
if (tokensValid) { return tokens; }
}
}
/// <summary>
/// Lazily reads up to <paramref name="maxCount" /> tokens from an input line.
/// </summary>
/// <param name="prompt">The string used to prompt the user for input.</param>
/// <param name="maxCount">The maximum number of tokens to read.</param>
/// <returns></returns>
private IEnumerable<Token> 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;
}
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
namespace Games.Common.IO;
/// <summary>
/// A simple state machine which parses tokens from a line of input.
/// </summary>
internal class Tokenizer
{
private const char Quote = '"';
private const char Separator = ',';
private readonly Queue<char> _characters;
private Tokenizer(string input) => _characters = new Queue<char>(input);
public static IEnumerable<Token> ParseTokens(string input)
{
if (input is null) { throw new ArgumentNullException(nameof(input)); }
return new Tokenizer(input).ParseTokens();
}
private IEnumerable<Token> ParseTokens()
{
while (true)
{
var (token, isLastToken) = Consume(_characters);
yield return token;
if (isLastToken) { break; }
}
}
public (Token, bool) Consume(Queue<char> 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();
}
}

View File

@@ -0,0 +1,25 @@
namespace Games.Common.Randomness;
/// <summary>
/// Provides access to a random number generator
/// </summary>
public interface IRandom
{
/// <summary>
/// Gets a random <see cref="float" /> such that 0 &lt;= n &lt; 1.
/// </summary>
/// <returns>The random number.</returns>
float NextFloat();
/// <summary>
/// Gets the <see cref="float" /> returned by the previous call to <see cref="NextFloat" />.
/// </summary>
/// <returns>The previous random number.</returns>
float PreviousFloat();
/// <summary>
/// Reseeds the random number generator.
/// </summary>
/// <param name="seed">The seed.</param>
void Reseed(int seed);
}

View File

@@ -0,0 +1,91 @@
using System;
namespace Games.Common.Randomness;
/// <summary>
/// Provides extension methods to <see cref="IRandom" /> providing random numbers in a given range.
/// </summary>
/// <value></value>
public static class IRandomExtensions
{
/// <summary>
/// Gets a random <see cref="float" /> such that 0 &lt;= n &lt; exclusiveMaximum.
/// </summary>
/// <returns>The random number.</returns>
public static float NextFloat(this IRandom random, float exclusiveMaximum) =>
Scale(random.NextFloat(), exclusiveMaximum);
/// <summary>
/// Gets a random <see cref="float" /> such that inclusiveMinimum &lt;= n &lt; exclusiveMaximum.
/// </summary>
/// <returns>The random number.</returns>
public static float NextFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum) =>
Scale(random.NextFloat(), inclusiveMinimum, exclusiveMaximum);
/// <summary>
/// Gets a random <see cref="int" /> such that 0 &lt;= n &lt; exclusiveMaximum.
/// </summary>
/// <returns>The random number.</returns>
public static int Next(this IRandom random, int exclusiveMaximum) => ToInt(random.NextFloat(exclusiveMaximum));
/// <summary>
/// Gets a random <see cref="int" /> such that inclusiveMinimum &lt;= n &lt; exclusiveMaximum.
/// </summary>
/// <returns>The random number.</returns>
public static int Next(this IRandom random, int inclusiveMinimum, int exclusiveMaximum) =>
ToInt(random.NextFloat(inclusiveMinimum, exclusiveMaximum));
/// <summary>
/// Gets the previous unscaled <see cref="float" /> (between 0 and 1) scaled to a new range:
/// 0 &lt;= x &lt; <paramref name="exclusiveMaximum" />.
/// </summary>
/// <returns>The random number.</returns>
public static float PreviousFloat(this IRandom random, float exclusiveMaximum) =>
Scale(random.PreviousFloat(), exclusiveMaximum);
/// <summary>
/// Gets the previous unscaled <see cref="float" /> (between 0 and 1) scaled to a new range:
/// <paramref name="inclusiveMinimum" /> &lt;= n &lt; <paramref name="exclusiveMaximum" />.
/// </summary>
/// <returns>The random number.</returns>
public static float PreviousFloat(this IRandom random, float inclusiveMinimum, float exclusiveMaximum) =>
Scale(random.PreviousFloat(), inclusiveMinimum, exclusiveMaximum);
/// <summary>
/// Gets the previous unscaled <see cref="float" /> (between 0 and 1) scaled to an <see cref="int" /> in a new
/// range: 0 &lt;= n &lt; <paramref name="exclusiveMaximum" />.
/// </summary>
/// <returns>The random number.</returns>
public static int Previous(this IRandom random, int exclusiveMaximum) =>
ToInt(random.PreviousFloat(exclusiveMaximum));
/// <summary>
/// Gets the previous unscaled <see cref="float" /> (between 0 and 1) scaled to an <see cref="int" /> in a new
/// range: <paramref name="inclusiveMinimum" /> &lt;= n &lt; <paramref name="exclusiveMaximum" />.
/// <returns>The random number.</returns>
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);
}

View File

@@ -0,0 +1,22 @@
using System;
namespace Games.Common.Randomness;
/// <inheritdoc />
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);
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("Games.Common.Test")]

181
00_Common/dotnet/README.md Normal file
View File

@@ -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
<ItemGroup>
<ProjectReference Include="..\..\00_Common\dotnet\Games.Common\Games.Common.csproj" />
</ItemGroup>
```
### 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*

View File

@@ -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);
}
}

View File

@@ -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.";
}

View File

@@ -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<float> ReadNumbers(string prompt, int requiredCount)
{
var numbers = new List<float>();
while (!TryReadNumbers(prompt, requiredCount, numbers))
{
numbers.Clear();
prompt = "";
}
return numbers;
}
private static bool TryReadNumbers(string prompt, int requiredCount, List<float> 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;
}
}
}

View File

@@ -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<bool> 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);
}
}
}

View File

@@ -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());

View File

@@ -9,4 +9,8 @@
<EmbeddedResource Include="Strings\TitleAndInstructions.txt" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\00_Common\dotnet\Games.Common\Games.Common.csproj" />
</ItemGroup>
</Project>