mirror of
https://github.com/coding-horror/basic-computer-games.git
synced 2025-12-25 04:15:45 -08:00
Merge pull request #619 from drewjcooper/dotnet-common-library
.Net common library
This commit is contained in:
5
00_Common/BASIC_Tests/InputTest.bas
Normal file
5
00_Common/BASIC_Tests/InputTest.bas
Normal 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
|
||||
|
||||
4
00_Common/BASIC_Tests/OutputTest.bas
Normal file
4
00_Common/BASIC_Tests/OutputTest.bas
Normal 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;"|"
|
||||
|
||||
|
||||
6
00_Common/BASIC_Tests/RndTest.bas
Normal file
6
00_Common/BASIC_Tests/RndTest.bas
Normal 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
185
00_Common/README.md
Normal 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.
|
||||
8
00_Common/dotnet/Directory.Build.props
Normal file
8
00_Common/dotnet/Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>10.0</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
26
00_Common/dotnet/Games.Common.Test/Games.Common.Test.csproj
Normal file
26
00_Common/dotnet/Games.Common.Test/Games.Common.Test.csproj
Normal 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>
|
||||
@@ -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 " },
|
||||
};
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
106
00_Common/dotnet/Games.Common.Test/IO/TokenReaderTests.cs
Normal file
106
00_Common/dotnet/Games.Common.Test/IO/TokenReaderTests.cs
Normal 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 }
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
42
00_Common/dotnet/Games.Common.Test/IO/TokenTests.cs
Normal file
42
00_Common/dotnet/Games.Common.Test/IO/TokenTests.cs
Normal 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 }
|
||||
};
|
||||
}
|
||||
33
00_Common/dotnet/Games.Common.Test/IO/TokenizerTests.cs
Normal file
33
00_Common/dotnet/Games.Common.Test/IO/TokenizerTests.cs
Normal 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" } }
|
||||
};
|
||||
}
|
||||
28
00_Common/dotnet/Games.Common.sln
Normal file
28
00_Common/dotnet/Games.Common.sln
Normal 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
|
||||
7
00_Common/dotnet/Games.Common/Games.Common.csproj
Normal file
7
00_Common/dotnet/Games.Common/Games.Common.csproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
15
00_Common/dotnet/Games.Common/IO/ConsoleIO.cs
Normal file
15
00_Common/dotnet/Games.Common/IO/ConsoleIO.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
90
00_Common/dotnet/Games.Common/IO/IReadWrite.cs
Normal file
90
00_Common/dotnet/Games.Common/IO/IReadWrite.cs
Normal 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);
|
||||
}
|
||||
7
00_Common/dotnet/Games.Common/IO/Strings.cs
Normal file
7
00_Common/dotnet/Games.Common/IO/Strings.cs
Normal 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";
|
||||
}
|
||||
108
00_Common/dotnet/Games.Common/IO/TextIO.cs
Normal file
108
00_Common/dotnet/Games.Common/IO/TextIO.cs
Normal 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} ";
|
||||
}
|
||||
59
00_Common/dotnet/Games.Common/IO/Token.cs
Normal file
59
00_Common/dotnet/Games.Common/IO/Token.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
112
00_Common/dotnet/Games.Common/IO/TokenReader.cs
Normal file
112
00_Common/dotnet/Games.Common/IO/TokenReader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
104
00_Common/dotnet/Games.Common/IO/Tokenizer.cs
Normal file
104
00_Common/dotnet/Games.Common/IO/Tokenizer.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
25
00_Common/dotnet/Games.Common/Randomness/IRandom.cs
Normal file
25
00_Common/dotnet/Games.Common/Randomness/IRandom.cs
Normal 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 <= n < 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);
|
||||
}
|
||||
@@ -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 <= n < 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 <= n < 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 <= n < 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 <= n < 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 <= x < <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" /> <= n < <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 <= n < <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" /> <= n < <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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
3
00_Common/dotnet/Games.Common/_InternalsVisibleTo.cs
Normal file
3
00_Common/dotnet/Games.Common/_InternalsVisibleTo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly:InternalsVisibleTo("Games.Common.Test")]
|
||||
181
00_Common/dotnet/README.md
Normal file
181
00_Common/dotnet/README.md
Normal 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*
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -9,4 +9,8 @@
|
||||
<EmbeddedResource Include="Strings\TitleAndInstructions.txt" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\00_Common\dotnet\Games.Common\Games.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user