Merge pull request #722 from drewjcooper/csharp-07-basketball

C# 07 basketball
This commit is contained in:
Jeff Atwood
2022-04-15 09:15:47 -07:00
committed by GitHub
24 changed files with 643 additions and 1 deletions

View File

@@ -14,7 +14,7 @@ Both teams use the same defense, but you may call it:
- Enter (7): Zone
- Enter (7.5): None
To change defense, type “0” as your next shot.
To change defense, type "0" as your next shot.
Note: The game is biased slightly in favor of Dartmouth. The average probability of a Dartmouth shot being good is 62.95% compared to a probability of 61.85% for their opponent. (This makes the sample run slightly remarkable in that Cornell won by a score of 45 to 42 Hooray for the Big Red!)
@@ -33,3 +33,19 @@ http://www.vintage-basic.net/games.html
(please note any difficulties or challenges in porting here)
##### Original bugs
###### Initial defense selection
If a number <6 is entered for the starting defense then the original code prompts again until a value >=6 is entered,
but then skips the opponent selection center jump.
The C# port does not reproduce this behavior. It does prompt for a correct value, but will then go to opponent selection
followed by the center jump.
###### Unvalidated defense selection
The original code does not validate the value entered for the defense beyond checking that it is >=6. A large enough
defense value will guarantee that all shots are good, and the game gets rather predictable.
This bug is preserved in the C# port.

View File

@@ -6,4 +6,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Resources/*.txt" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\00_Common\dotnet\Games.Common\Games.Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
using Basketball.Resources;
using Games.Common.IO;
namespace Basketball;
internal class Clock
{
private readonly IReadWrite _io;
private int time;
public Clock(IReadWrite io) => _io = io;
public bool IsHalfTime => time == 50;
public bool IsFullTime => time >= 100;
public bool TwoMinutesLeft => time == 92;
public void Increment(Scoreboard scoreboard)
{
time += 1;
if (IsHalfTime) { scoreboard.Display(Resource.Formats.EndOfFirstHalf); }
if (TwoMinutesLeft) { _io.Write(Resource.Streams.TwoMinutesLeft); }
}
public void StartOvertime() => time = 93;
}

View File

@@ -0,0 +1,12 @@
namespace Basketball;
internal class Defense
{
private float _value;
public Defense(float value) => Set(value);
public void Set(float value) => _value = value;
public static implicit operator float(Defense defense) => defense._value;
}

View File

@@ -0,0 +1,73 @@
using Basketball.Plays;
using Basketball.Resources;
using Games.Common.IO;
using Games.Common.Randomness;
namespace Basketball;
internal class Game
{
private readonly Clock _clock;
private readonly Scoreboard _scoreboard;
private readonly TextIO _io;
private readonly IRandom _random;
private Game(Clock clock, Scoreboard scoreboard, TextIO io, IRandom random)
{
_clock = clock;
_scoreboard = scoreboard;
_io = io;
_random = random;
}
public static Game Create(TextIO io, IRandom random)
{
io.Write(Resource.Streams.Introduction);
var defense = new Defense(io.ReadDefense("Your starting defense will be"));
var clock = new Clock(io);
io.WriteLine();
var scoreboard = new Scoreboard(
new Team("Dartmouth", new HomeTeamPlay(io, random, clock, defense)),
new Team(io.ReadString("Choose your opponent"), new VisitingTeamPlay(io, random, clock, defense)),
io);
return new Game(clock, scoreboard, io, random);
}
public void Play()
{
var ballContest = new BallContest(0.4f, "{0} controls the tap", _io, _random);
while (true)
{
_io.WriteLine("Center jump");
ballContest.Resolve(_scoreboard);
_io.WriteLine();
while (true)
{
var isFullTime = _scoreboard.Offense.ResolvePlay(_scoreboard);
if (isFullTime && IsGameOver()) { return; }
if (_clock.IsHalfTime) { break; }
}
}
}
private bool IsGameOver()
{
_io.WriteLine();
if (_scoreboard.ScoresAreEqual)
{
_scoreboard.Display(Resource.Formats.EndOfSecondHalf);
_clock.StartOvertime();
return false;
}
_scoreboard.Display(Resource.Formats.EndOfGame);
return true;
}
}

View File

@@ -0,0 +1,8 @@
using Games.Common.Randomness;
namespace Basketball;
internal static class IRandomExtensions
{
internal static Shot NextShot(this IRandom random) => Shot.Get(random.NextFloat(1, 3.5f));
}

View File

@@ -0,0 +1,34 @@
using Games.Common.IO;
namespace Basketball;
internal static class IReadWriteExtensions
{
public static float ReadDefense(this IReadWrite io, string prompt)
{
while (true)
{
var defense = io.ReadNumber(prompt);
if (defense >= 6) { return defense; }
}
}
private static bool TryReadInteger(this IReadWrite io, string prompt, out int intValue)
{
var floatValue = io.ReadNumber(prompt);
intValue = (int)floatValue;
return intValue == floatValue;
}
public static Shot? ReadShot(this IReadWrite io, string prompt)
{
while (true)
{
if (io.TryReadInteger(prompt, out var value) && Shot.TryGet(value, out var shot))
{
return shot;
}
io.Write("Incorrect answer. Retype it. ");
}
}
}

View File

@@ -0,0 +1,9 @@
namespace Basketball;
public class JumpShot : Shot
{
public JumpShot()
: base("Jump shot")
{
}
}

View File

@@ -0,0 +1,28 @@
using Games.Common.IO;
using Games.Common.Randomness;
namespace Basketball.Plays;
internal class BallContest
{
private readonly float _probability;
private readonly string _messageFormat;
private readonly IReadWrite _io;
private readonly IRandom _random;
internal BallContest(float probability, string messageFormat, IReadWrite io, IRandom random)
{
_io = io;
_probability = probability;
_messageFormat = messageFormat;
_random = random;
}
internal bool Resolve(Scoreboard scoreboard)
{
var winner = _random.NextFloat() <= _probability ? scoreboard.Home : scoreboard.Visitors;
scoreboard.Offense = winner;
_io.WriteLine(_messageFormat, winner);
return false;
}
}

View File

@@ -0,0 +1,93 @@
using Games.Common.IO;
using Games.Common.Randomness;
namespace Basketball.Plays;
internal class HomeTeamPlay : Play
{
private readonly TextIO _io;
private readonly IRandom _random;
private readonly Clock _clock;
private readonly Defense _defense;
private readonly BallContest _ballContest;
public HomeTeamPlay(TextIO io, IRandom random, Clock clock, Defense defense)
: base(io, random, clock)
{
_io = io;
_random = random;
_clock = clock;
_defense = defense;
_ballContest = new BallContest(0.5f, "Shot is blocked. Ball controlled by {0}.", _io, _random);
}
internal override bool Resolve(Scoreboard scoreboard)
{
var shot = _io.ReadShot("Your shot");
if (_random.NextFloat() >= 0.5f && _clock.IsFullTime) { return true; }
if (shot is null)
{
_defense.Set(_io.ReadDefense("Your new defensive alignment is"));
_io.WriteLine();
return false;
}
if (shot is JumpShot jumpShot)
{
if (ClockIncrementsToHalfTime(scoreboard)) { return false; }
if (!Resolve(jumpShot, scoreboard)) { return false; }
}
do
{
if (ClockIncrementsToHalfTime(scoreboard)) { return false; }
} while (Resolve(shot, scoreboard));
return false;
}
// The Resolve* methods resolve the probabilistic outcome of the current game state.
// They return true if the Home team should continue the play and attempt a layup, false otherwise.
private bool Resolve(JumpShot shot, Scoreboard scoreboard) =>
Resolve(shot.ToString(), _defense / 8)
.Do(0.341f, () => scoreboard.AddBasket("Shot is good"))
.Or(0.682f, () => ResolveShotOffTarget(scoreboard))
.Or(0.782f, () => _ballContest.Resolve(scoreboard))
.Or(0.843f, () => ResolveFreeThrows(scoreboard, "Shooter is fouled. Two shots."))
.Or(() => scoreboard.Turnover($"Charging foul. {scoreboard.Home} loses ball."));
private bool Resolve(Shot shot, Scoreboard scoreboard) =>
Resolve(shot.ToString(), _defense / 7)
.Do(0.4f, () => scoreboard.AddBasket("Shot is good. Two points."))
.Or(0.7f, () => ResolveShotOffTheRim(scoreboard))
.Or(0.875f, () => ResolveFreeThrows(scoreboard, "Shooter fouled. Two shots."))
.Or(0.925f, () => scoreboard.Turnover($"Shot blocked. {scoreboard.Visitors}'s ball."))
.Or(() => scoreboard.Turnover($"Charging foul. {scoreboard.Home} loses ball."));
private bool ResolveShotOffTarget(Scoreboard scoreboard) =>
Resolve("Shot is off target", 6 / _defense)
.Do(0.45f, () => ResolveHomeRebound(scoreboard, ResolvePossibleSteal))
.Or(() => scoreboard.Turnover($"Rebound to {scoreboard.Visitors}"));
private bool ResolveHomeRebound(Scoreboard scoreboard, Action<Scoreboard> endOfPlayAction) =>
Resolve($"{scoreboard.Home} controls the rebound.")
.Do(0.4f, () => true)
.Or(() => endOfPlayAction.Invoke(scoreboard));
private void ResolvePossibleSteal(Scoreboard scoreboard)
{
if (_defense == 6 && _random.NextFloat() > 0.6f)
{
scoreboard.Turnover();
scoreboard.AddBasket($"Pass stolen by {scoreboard.Visitors} easy layup.");
_io.WriteLine();
}
_io.Write("Ball passed back to you. ");
}
private void ResolveShotOffTheRim(Scoreboard scoreboard) =>
Resolve("Shot is off the rim.")
.Do(2 / 3f, () => scoreboard.Turnover($"{scoreboard.Visitors} controls the rebound."))
.Or(() => ResolveHomeRebound(scoreboard, _ => _io.WriteLine("Ball passed back to you.")));
}

View File

@@ -0,0 +1,40 @@
using Games.Common.IO;
using Games.Common.Randomness;
namespace Basketball.Plays;
internal abstract class Play
{
private readonly IReadWrite _io;
private readonly IRandom _random;
private readonly Clock _clock;
public Play(IReadWrite io, IRandom random, Clock clock)
{
_io = io;
_random = random;
_clock = clock;
}
protected bool ClockIncrementsToHalfTime(Scoreboard scoreboard)
{
_clock.Increment(scoreboard);
return _clock.IsHalfTime;
}
internal abstract bool Resolve(Scoreboard scoreboard);
protected void ResolveFreeThrows(Scoreboard scoreboard, string message) =>
Resolve(message)
.Do(0.49f, () => scoreboard.AddFreeThrows(2, "Shooter makes both shots."))
.Or(0.75f, () => scoreboard.AddFreeThrows(1, "Shooter makes one shot and misses one."))
.Or(() => scoreboard.AddFreeThrows(0, "Both shots missed."));
protected Probably Resolve(string message) => Resolve(message, 1f);
protected Probably Resolve(string message, float defenseFactor)
{
_io.WriteLine(message);
return new Probably(defenseFactor, _random);
}
}

View File

@@ -0,0 +1,81 @@
using Games.Common.IO;
using Games.Common.Randomness;
namespace Basketball.Plays;
internal class VisitingTeamPlay : Play
{
private readonly TextIO _io;
private readonly IRandom _random;
private readonly Defense _defense;
public VisitingTeamPlay(TextIO io, IRandom random, Clock clock, Defense defense)
: base(io, random, clock)
{
_io = io;
_random = random;
_defense = defense;
}
internal override bool Resolve(Scoreboard scoreboard)
{
if (ClockIncrementsToHalfTime(scoreboard)) { return false; }
_io.WriteLine();
var shot = _random.NextShot();
if (shot is JumpShot jumpShot)
{
var continuePlay = Resolve(jumpShot, scoreboard);
_io.WriteLine();
if (!continuePlay) { return false; }
}
while (true)
{
var continuePlay = Resolve(shot, scoreboard);
_io.WriteLine();
if (!continuePlay) { return false; }
}
}
// The Resolve* methods resolve the probabilistic outcome of the current game state.
// They return true if the Visiting team should continue the play and attempt a layup, false otherwise.
private bool Resolve(JumpShot shot, Scoreboard scoreboard) =>
Resolve(shot.ToString(), _defense / 8)
.Do(0.35f, () => scoreboard.AddBasket("Shot is good."))
.Or(0.75f, () => ResolveBadShot(scoreboard, "Shot is off the rim.", _defense * 6))
.Or(0.9f, () => ResolveFreeThrows(scoreboard, "Player fouled. Two shots."))
.Or(() => _io.WriteLine($"Offensive foul. {scoreboard.Home}'s ball."));
private bool Resolve(Shot shot, Scoreboard scoreboard) =>
Resolve(shot.ToString(), _defense / 7)
.Do(0.413f, () => scoreboard.AddBasket("Shot is good."))
.Or(() => ResolveBadShot(scoreboard, "Shot is missed.", 6 / _defense));
private bool ResolveBadShot(Scoreboard scoreboard, string message, float defenseFactor) =>
Resolve(message, defenseFactor)
.Do(0.5f, () => scoreboard.Turnover($"{scoreboard.Home} controls the rebound."))
.Or(() => ResolveVisitorsRebound(scoreboard));
private bool ResolveVisitorsRebound(Scoreboard scoreboard)
{
_io.Write($"{scoreboard.Visitors} controls the rebound.");
if (_defense == 6 && _random.NextFloat() <= 0.25f)
{
_io.WriteLine();
scoreboard.Turnover();
scoreboard.AddBasket($"Ball stolen. Easy lay up for {scoreboard.Home}.");
return false;
}
if (_random.NextFloat() <= 0.5f)
{
_io.WriteLine();
_io.Write($"Pass back to {scoreboard.Visitors} guard.");
return false;
}
return true;
}
}

View File

@@ -0,0 +1,50 @@
using Games.Common.Randomness;
namespace Basketball;
/// <summary>
/// Supports a chain of actions to be performed based on various probabilities. The original game code gets a new
/// random number for each probability check. Evaluating a set of probabilities against a single random number is
/// much simpler, but yield a very different outcome distribution. The purpose of this class is to simplify the code
/// to for the original probabilistic branch decisions.
/// </summary>
internal struct Probably
{
private readonly float _defenseFactor;
private readonly IRandom _random;
private readonly bool? _result;
internal Probably(float defenseFactor, IRandom random, bool? result = null)
{
_defenseFactor = defenseFactor;
_random = random;
_result = result;
}
public Probably Do(float probability, Action action) =>
ShouldResolveAction(probability)
? new Probably(_defenseFactor, _random, Resolve(action) ?? false)
: this;
public Probably Do(float probability, Func<bool> action) =>
ShouldResolveAction(probability)
? new Probably(_defenseFactor, _random, Resolve(action) ?? false)
: this;
public Probably Or(float probability, Action action) => Do(probability, action);
public Probably Or(float probability, Func<bool> action) => Do(probability, action);
public bool Or(Action action) => _result ?? Resolve(action) ?? false;
private bool? Resolve(Action action)
{
action.Invoke();
return _result;
}
private bool? Resolve(Func<bool> action) => action.Invoke();
private readonly bool ShouldResolveAction(float probability) =>
_result is null && _random.NextFloat() <= probability * _defenseFactor;
}

View File

@@ -0,0 +1,7 @@
using Basketball;
using Games.Common.IO;
using Games.Common.Randomness;
var game = Game.Create(new ConsoleIO(), new RandomNumberGenerator());
game.Play();

View File

@@ -0,0 +1,5 @@
***** End of first half *****
Score: {0}: {1} {2}: {3}

View File

@@ -0,0 +1,2 @@
***** End of game *****
Final score: {0}: {1} {2}: {3}

View File

@@ -0,0 +1,7 @@
***** End of second half *****
Score at end of regulation time:
{0}: {1} {2}: {3}
Begin two minute overtime period

View File

@@ -0,0 +1,12 @@
Basketball
Creative Computing Morristown, New Jersey
This is Dartmouth College basketball. You will be Dartmouth
captain and playmaker. Call shots as follows: 1. Long
(30 ft.) jump shot; 2. Short (15 ft.) jump shot; 3. Lay
up; 4. Set shot.
Both teams will use the same defense. Call defense as
follows: 6. Press; 6.5 Man-to-man; 7. Zone; 7.5 None.
To change defense, just type 0 as your next shot.

View File

@@ -0,0 +1,32 @@
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Basketball.Resources;
internal static class Resource
{
internal static class Streams
{
public static Stream Introduction => GetStream();
public static Stream TwoMinutesLeft => GetStream();
}
internal static class Formats
{
public static string EndOfFirstHalf => GetString();
public static string EndOfGame => GetString();
public static string EndOfSecondHalf => GetString();
public static string Score => GetString();
}
private static string GetString([CallerMemberName] string? name = null)
{
using var stream = GetStream(name);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
private static Stream GetStream([CallerMemberName] string? name = null) =>
Assembly.GetExecutingAssembly().GetManifestResourceStream($"Basketball.Resources.{name}.txt")
?? throw new Exception($"Could not find embedded resource stream '{name}'.");
}

View File

@@ -0,0 +1 @@
Score: {1} to {3}

View File

@@ -0,0 +1,3 @@
*** Two minutes left in the game ***

View File

@@ -0,0 +1,48 @@
using Basketball.Resources;
using Games.Common.IO;
namespace Basketball;
internal class Scoreboard
{
private readonly Dictionary<Team, uint> _scores;
private readonly IReadWrite _io;
public Scoreboard(Team home, Team visitors, IReadWrite io)
{
_scores = new() { [home] = 0, [visitors] = 0 };
Home = home;
Visitors = visitors;
Offense = home; // temporary value till first center jump
_io = io;
}
public bool ScoresAreEqual => _scores[Home] == _scores[Visitors];
public Team Offense { get; set; }
public Team Home { get; }
public Team Visitors { get; }
public void AddBasket(string message) => AddScore(2, message);
public void AddFreeThrows(uint count, string message) => AddScore(count, message);
private void AddScore(uint score, string message)
{
if (Offense is null) { throw new InvalidOperationException("Offense must be set before adding to score."); }
_io.WriteLine(message);
_scores[Offense] += score;
Turnover();
Display();
}
public void Turnover(string? message = null)
{
if (message is not null) { _io.WriteLine(message); }
Offense = Offense == Home ? Visitors : Home;
}
public void Display(string? format = null) =>
_io.WriteLine(format ?? Resource.Formats.Score, Home, _scores[Home], Visitors, _scores[Visitors]);
}

View File

@@ -0,0 +1,37 @@
namespace Basketball;
public class Shot
{
private readonly string _name;
public Shot(string name)
{
_name = name;
}
public static bool TryGet(int shotNumber, out Shot? shot)
{
shot = shotNumber switch
{
// Although the game instructions reference two different jump shots,
// the original game code treats them both the same and just prints "Jump shot"
0 => null,
<= 2 => new JumpShot(),
3 => new Shot("Lay up"),
4 => new Shot("Set shot"),
_ => null
};
return shotNumber == 0 || shot is not null;
}
public static Shot Get(float shotNumber) =>
shotNumber switch
{
<= 2 => new JumpShot(),
> 3 => new Shot("Set shot"),
> 2 => new Shot("Lay up"),
_ => throw new Exception("Unexpected value")
};
public override string ToString() => _name;
}

View File

@@ -0,0 +1,10 @@
using Basketball.Plays;
namespace Basketball;
internal record Team(string Name, Play PlayResolver)
{
public override string ToString() => Name;
public bool ResolvePlay(Scoreboard scoreboard) => PlayResolver.Resolve(scoreboard);
}