Merge pull request #386 from daves561/bombs-away-csharp

Bombs Away in C#
This commit is contained in:
Jeff Atwood
2022-01-03 10:35:01 -08:00
committed by GitHub
15 changed files with 733 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.32014.148
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BombsAwayConsole", "BombsAwayConsole\BombsAwayConsole.csproj", "{D80015FA-423C-4A16-AA2B-16669245AD59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BombsAwayGame", "BombsAwayGame\BombsAwayGame.csproj", "{F57AEC18-FEE9-4F08-9F20-DFC56EFE6BFC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D80015FA-423C-4A16-AA2B-16669245AD59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D80015FA-423C-4A16-AA2B-16669245AD59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D80015FA-423C-4A16-AA2B-16669245AD59}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D80015FA-423C-4A16-AA2B-16669245AD59}.Release|Any CPU.Build.0 = Release|Any CPU
{F57AEC18-FEE9-4F08-9F20-DFC56EFE6BFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F57AEC18-FEE9-4F08-9F20-DFC56EFE6BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F57AEC18-FEE9-4F08-9F20-DFC56EFE6BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F57AEC18-FEE9-4F08-9F20-DFC56EFE6BFC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {39B2ECFB-037D-4335-BBD2-64892E953DD4}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\BombsAwayGame\BombsAwayGame.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,134 @@
namespace BombsAwayConsole;
/// <summary>
/// Implements <see cref="BombsAwayGame.IUserInterface"/> by writing to and reading from <see cref="Console"/>.
/// </summary>
internal class ConsoleUserInterface : BombsAwayGame.IUserInterface
{
/// <summary>
/// Write message to console.
/// </summary>
/// <param name="message">Message to display.</param>
public void Output(string message)
{
Console.WriteLine(message);
}
/// <summary>
/// Write choices with affixed indexes, allowing the user to choose by index.
/// </summary>
/// <param name="message">Message to display.</param>
/// <param name="choices">Choices to display.</param>
/// <returns>Choice that user picked.</returns>
public int Choose(string message, IList<string> choices)
{
IEnumerable<string> choicesWithIndexes = choices.Select((choice, index) => $"{choice}({index + 1})");
string choiceText = string.Join(", ", choicesWithIndexes);
Output($"{message} -- {choiceText}");
ISet<ConsoleKey> allowedKeys = ConsoleKeysFromList(choices);
ConsoleKey? choice;
do
{
choice = ReadChoice(allowedKeys);
if (choice is null)
{
Output("TRY AGAIN...");
}
}
while (choice is null);
return ListIndexFromConsoleKey(choice.Value);
}
/// <summary>
/// Convert the given list to its <see cref="ConsoleKey"/> equivalents. This generates keys that map
/// the first element to <see cref="ConsoleKey.D1"/>, the second element to <see cref="ConsoleKey.D2"/>,
/// and so on, up to the last element of the list.
/// </summary>
/// <param name="list">List whose elements will be converted to <see cref="ConsoleKey"/> equivalents.</param>
/// <returns><see cref="ConsoleKey"/> equivalents from <paramref name="list"/>.</returns>
private ISet<ConsoleKey> ConsoleKeysFromList(IList<string> list)
{
IEnumerable<int> indexes = Enumerable.Range((int)ConsoleKey.D1, list.Count);
return new HashSet<ConsoleKey>(indexes.Cast<ConsoleKey>());
}
/// <summary>
/// Convert the given console key to its list index equivalent. This assumes the key was generated from
/// <see cref="ConsoleKeysFromList(IList{string})"/>
/// </summary>
/// <param name="key">Key to convert to its list index equivalent.</param>
/// <returns>List index equivalent of key.</returns>
private int ListIndexFromConsoleKey(ConsoleKey key)
{
return key - ConsoleKey.D1;
}
/// <summary>
/// Read a key from the console and return it if it is in the given allowed keys.
/// </summary>
/// <param name="allowedKeys">Allowed keys.</param>
/// <returns>Key read from <see cref="Console"/>, if it is in <paramref name="allowedKeys"/>; null otherwise./></returns>
private ConsoleKey? ReadChoice(ISet<ConsoleKey> allowedKeys)
{
ConsoleKeyInfo keyInfo = ReadKey();
return allowedKeys.Contains(keyInfo.Key) ? keyInfo.Key : null;
}
/// <summary>
/// Read key from <see cref="Console"/>.
/// </summary>
/// <returns>Key read from <see cref="Console"/>.</returns>
private ConsoleKeyInfo ReadKey()
{
ConsoleKeyInfo result = Console.ReadKey(intercept: false);
// Write a blank line to the console so the displayed key is on its own line.
Console.WriteLine();
return result;
}
/// <summary>
/// Allow user to choose 'Y' or 'N' from <see cref="Console"/>.
/// </summary>
/// <param name="message">Message to display.</param>
/// <returns>True if user chose 'Y', false if user chose 'N'.</returns>
public bool ChooseYesOrNo(string message)
{
Output(message);
ConsoleKey? choice;
do
{
choice = ReadChoice(new HashSet<ConsoleKey>(new[] { ConsoleKey.Y, ConsoleKey.N }));
if (choice is null)
{
Output("ENTER Y OR N");
}
}
while (choice is null);
return choice.Value == ConsoleKey.Y;
}
/// <summary>
/// Get integer by reading a line from <see cref="Console"/>.
/// </summary>
/// <returns>Integer read from <see cref="Console"/>.</returns>
public int InputInteger()
{
bool resultIsValid;
int result;
do
{
string? integerText = Console.ReadLine();
resultIsValid = int.TryParse(integerText, out result);
if (!resultIsValid)
{
Output("PLEASE ENTER A NUMBER");
}
}
while (!resultIsValid);
return result;
}
}

View File

@@ -0,0 +1,26 @@
using BombsAwayConsole;
using BombsAwayGame;
/// Create and play <see cref="Game"/>s using a <see cref="ConsoleUserInterface"/>.
PlayGameWhileUserWantsTo(new ConsoleUserInterface());
void PlayGameWhileUserWantsTo(ConsoleUserInterface ui)
{
do
{
new Game(ui).Play();
}
while (UserWantsToPlayAgain(ui));
}
bool UserWantsToPlayAgain(IUserInterface ui)
{
bool result = ui.ChooseYesOrNo("ANOTHER MISSION (Y OR N)?");
if (!result)
{
Console.WriteLine("CHICKEN !!!");
}
return result;
}

View File

@@ -0,0 +1,22 @@
namespace BombsAwayGame;
/// <summary>
/// Allies protagonist. Can fly missions in a Liberator, B-29, B-17, or Lancaster.
/// </summary>
internal class AlliesSide : MissionSide
{
public AlliesSide(IUserInterface ui)
: base(ui)
{
}
protected override string ChooseMissionMessage => "AIRCRAFT";
protected override IList<Mission> AllMissions => new Mission[]
{
new("LIBERATOR", "YOU'VE GOT 2 TONS OF BOMBS FLYING FOR PLOESTI."),
new("B-29", "YOU'RE DUMPING THE A-BOMB ON HIROSHIMA."),
new("B-17", "YOU'RE CHASING THE BISMARK IN THE NORTH SEA."),
new("LANCASTER", "YOU'RE BUSTING A GERMAN HEAVY WATER PLANT IN THE RUHR.")
};
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace BombsAwayGame;
/// <summary>
/// Represents enemy artillery.
/// </summary>
/// <param name="Name">Name of artillery type.</param>
/// <param name="Accuracy">Accuracy of artillery. This is the `T` variable in the original BASIC.</param>
internal record class EnemyArtillery(string Name, int Accuracy);

View File

@@ -0,0 +1,58 @@
namespace BombsAwayGame;
/// <summary>
/// Plays the Bombs Away game using a supplied <see cref="IUserInterface"/>.
/// </summary>
public class Game
{
private readonly IUserInterface _ui;
/// <summary>
/// Create game instance using the given UI.
/// </summary>
/// <param name="ui">UI to use for game.</param>
public Game(IUserInterface ui)
{
_ui = ui;
}
/// <summary>
/// Play game. Choose a side and play the side's logic.
/// </summary>
public void Play()
{
_ui.Output("YOU ARE A PILOT IN A WORLD WAR II BOMBER.");
Side side = ChooseSide();
side.Play();
}
/// <summary>
/// Represents a <see cref="Side"/>.
/// </summary>
/// <param name="Name">Name of side.</param>
/// <param name="CreateSide">Create instance of side that this descriptor represents.</param>
private record class SideDescriptor(string Name, Func<Side> CreateSide);
/// <summary>
/// Choose side and return a new instance of that side.
/// </summary>
/// <returns>New instance of side that was chosen.</returns>
private Side ChooseSide()
{
SideDescriptor[] sides = AllSideDescriptors;
string[] sideNames = sides.Select(a => a.Name).ToArray();
int index = _ui.Choose("WHAT SIDE", sideNames);
return sides[index].CreateSide();
}
/// <summary>
/// All side descriptors.
/// </summary>
private SideDescriptor[] AllSideDescriptors => new SideDescriptor[]
{
new("ITALY", () => new ItalySide(_ui)),
new("ALLIES", () => new AlliesSide(_ui)),
new("JAPAN", () => new JapanSide(_ui)),
new("GERMANY", () => new GermanySide(_ui)),
};
}

View File

@@ -0,0 +1,21 @@
namespace BombsAwayGame;
/// <summary>
/// Germany protagonist. Can fly missions to Russia, England, and France.
/// </summary>
internal class GermanySide : MissionSide
{
public GermanySide(IUserInterface ui)
: base(ui)
{
}
protected override string ChooseMissionMessage => "A NAZI, EH? OH WELL. ARE YOU GOING FOR";
protected override IList<Mission> AllMissions => new Mission[]
{
new("RUSSIA", "YOU'RE NEARING STALINGRAD."),
new("ENGLAND", "NEARING LONDON. BE CAREFUL, THEY'VE GOT RADAR."),
new("FRANCE", "NEARING VERSAILLES. DUCK SOUP. THEY'RE NEARLY DEFENSELESS.")
};
}

View File

@@ -0,0 +1,38 @@
namespace BombsAwayGame;
/// <summary>
/// Represents an interface for supplying data to the game.
/// </summary>
/// <remarks>
/// Abstracting the UI allows us to concentrate its concerns in one part of our code and to change UI behavior
/// without creating any risk of changing the game logic. It also allows us to supply an automated UI for tests.
/// </remarks>
public interface IUserInterface
{
/// <summary>
/// Display the given message.
/// </summary>
/// <param name="message">Message to display.</param>
void Output(string message);
/// <summary>
/// Choose an item from the given choices.
/// </summary>
/// <param name="message">Message to display.</param>
/// <param name="choices">Choices to choose from.</param>
/// <returns>Index of choice in <paramref name="choices"/> that user chose.</returns>
int Choose(string message, IList<string> choices);
/// <summary>
/// Allow user to choose Yes or No.
/// </summary>
/// <param name="message">Message to display.</param>
/// <returns>True if user chose Yes, false if user chose No.</returns>
bool ChooseYesOrNo(string message);
/// <summary>
/// Get integer from user.
/// </summary>
/// <returns>Integer supplied by user.</returns>
int InputInteger();
}

View File

@@ -0,0 +1,21 @@
namespace BombsAwayGame;
/// <summary>
/// Italy protagonist. Can fly missions to Albania, Greece, and North Africa.
/// </summary>
internal class ItalySide : MissionSide
{
public ItalySide(IUserInterface ui)
: base(ui)
{
}
protected override string ChooseMissionMessage => "YOUR TARGET";
protected override IList<Mission> AllMissions => new Mission[]
{
new("ALBANIA", "SHOULD BE EASY -- YOU'RE FLYING A NAZI-MADE PLANE."),
new("GREECE", "BE CAREFUL!!!"),
new("NORTH AFRICA", "YOU'RE GOING FOR THE OIL, EH?")
};
}

View File

@@ -0,0 +1,38 @@
namespace BombsAwayGame;
/// <summary>
/// Japan protagonist. Flies a kamikaze mission, which has a different logic from <see cref="MissionSide"/>s.
/// </summary>
internal class JapanSide : Side
{
public JapanSide(IUserInterface ui)
: base(ui)
{
}
/// <summary>
/// Perform a kamikaze mission. If first kamikaze mission, it will succeed 65% of the time. If it's not
/// first kamikaze mission, perform an enemy counterattack.
/// </summary>
public override void Play()
{
UI.Output("YOU'RE FLYING A KAMIKAZE MISSION OVER THE USS LEXINGTON.");
bool isFirstMission = UI.ChooseYesOrNo("YOUR FIRST KAMIKAZE MISSION(Y OR N)?");
if (!isFirstMission)
{
// LINE 207 of original BASIC: hitRatePercent is initialized to 0,
// but R, the type of artillery, is not initialized at all. Setting
// R = 1, which is to say EnemyArtillery = Guns, gives the same result.
EnemyCounterattack(Guns, hitRatePercent: 0);
}
else if (RandomFrac() > 0.65)
{
MissionSucceeded();
}
else
{
MissionFailed();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace BombsAwayGame;
/// <summary>
/// Represents a mission that can be flown by a <see cref="MissionSide"/>.
/// </summary>
/// <param name="Name">Name of mission.</param>
/// <param name="Description">Description of mission.</param>
internal record class Mission(string Name, string Description);

View File

@@ -0,0 +1,208 @@
namespace BombsAwayGame;
/// <summary>
/// Represents a protagonist that chooses a standard (non-kamikaze) mission.
/// </summary>
internal abstract class MissionSide : Side
{
/// <summary>
/// Create instance using the given UI.
/// </summary>
/// <param name="ui">UI to use.</param>
public MissionSide(IUserInterface ui)
: base(ui)
{
}
/// <summary>
/// Reasonable upper bound for missions flown previously.
/// </summary>
private const int MaxMissionCount = 160;
/// <summary>
/// Choose a mission and attempt it. If attempt fails, perform an enemy counterattack.
/// </summary>
public override void Play()
{
Mission mission = ChooseMission();
UI.Output(mission.Description);
int missionCount = MissionCountFromUI();
CommentOnMissionCount(missionCount);
AttemptMission(missionCount);
}
/// <summary>
/// Choose a mission.
/// </summary>
/// <returns>Mission chosen.</returns>
private Mission ChooseMission()
{
IList<Mission> missions = AllMissions;
string[] missionNames = missions.Select(a => a.Name).ToArray();
int index = UI.Choose(ChooseMissionMessage, missionNames);
return missions[index];
}
/// <summary>
/// Message to display when choosing a mission.
/// </summary>
protected abstract string ChooseMissionMessage { get; }
/// <summary>
/// All aviailable missions to choose from.
/// </summary>
protected abstract IList<Mission> AllMissions { get; }
/// <summary>
/// Get mission count from UI. If mission count exceeds a reasonable maximum, ask UI again.
/// </summary>
/// <returns>Mission count from UI.</returns>
private int MissionCountFromUI()
{
const string HowManyMissions = "HOW MANY MISSIONS HAVE YOU FLOWN?";
string inputMessage = HowManyMissions;
bool resultIsValid;
int result;
do
{
UI.Output(inputMessage);
result = UI.InputInteger();
if (result < 0)
{
UI.Output($"NUMBER OF MISSIONS CAN'T BE NEGATIVE.");
resultIsValid = false;
}
else if (result > MaxMissionCount)
{
resultIsValid = false;
UI.Output($"MISSIONS, NOT MILES...{MaxMissionCount} MISSIONS IS HIGH EVEN FOR OLD-TIMERS.");
inputMessage = "NOW THEN, " + HowManyMissions;
}
else
{
resultIsValid = true;
}
}
while (!resultIsValid);
return result;
}
/// <summary>
/// Display a message about the given mission count, if it is unusually high or low.
/// </summary>
/// <param name="missionCount">Mission count to comment on.</param>
private void CommentOnMissionCount(int missionCount)
{
if (missionCount >= 100)
{
UI.Output("THAT'S PUSHING THE ODDS!");
}
else if (missionCount < 25)
{
UI.Output("FRESH OUT OF TRAINING, EH?");
}
}
/// <summary>
/// Attempt mission.
/// </summary>
/// <param name="missionCount">Number of missions previously flown. Higher mission counts will yield a higher probability of success.</param>
private void AttemptMission(int missionCount)
{
if (missionCount < RandomInteger(0, MaxMissionCount))
{
MissedTarget();
}
else
{
MissionSucceeded();
}
}
/// <summary>
/// Display message indicating that target was missed. Choose enemy artillery and perform a counterattack.
/// </summary>
private void MissedTarget()
{
UI.Output("MISSED TARGET BY " + (2 + RandomInteger(0, 30)) + " MILES!");
UI.Output("NOW YOU'RE REALLY IN FOR IT !!");
// Choose enemy and counterattack.
EnemyArtillery enemyArtillery = ChooseEnemyArtillery();
if (enemyArtillery == Missiles)
{
EnemyCounterattack(enemyArtillery, hitRatePercent: 0);
}
else
{
int hitRatePercent = EnemyHitRatePercentFromUI();
if (hitRatePercent < MinEnemyHitRatePercent)
{
UI.Output("YOU LIE, BUT YOU'LL PAY...");
MissionFailed();
}
else
{
EnemyCounterattack(enemyArtillery, hitRatePercent);
}
}
}
/// <summary>
/// Choose enemy artillery from UI.
/// </summary>
/// <returns>Artillery chosen.</returns>
private EnemyArtillery ChooseEnemyArtillery()
{
EnemyArtillery[] artilleries = new EnemyArtillery[] { Guns, Missiles, Both };
string[] artilleryNames = artilleries.Select(a => a.Name).ToArray();
int index = UI.Choose("DOES THE ENEMY HAVE", artilleryNames);
return artilleries[index];
}
/// <summary>
/// Minimum allowed hit rate percent.
/// </summary>
private const int MinEnemyHitRatePercent = 10;
/// <summary>
/// Maximum allowed hit rate percent.
/// </summary>
private const int MaxEnemyHitRatePercent = 50;
/// <summary>
/// Get the enemy hit rate percent from UI. Value must be between zero and <see cref="MaxEnemyHitRatePercent"/>.
/// If value is less than <see cref="MinEnemyHitRatePercent"/>, mission fails automatically because the user is
/// assumed to be untruthful.
/// </summary>
/// <returns>Enemy hit rate percent from UI.</returns>
private int EnemyHitRatePercentFromUI()
{
UI.Output($"WHAT'S THE PERCENT HIT RATE OF ENEMY GUNNERS ({MinEnemyHitRatePercent} TO {MaxEnemyHitRatePercent})");
bool resultIsValid;
int result;
do
{
result = UI.InputInteger();
// Let them enter a number below the stated minimum, as they will be caught and punished.
if (0 <= result && result <= MaxEnemyHitRatePercent)
{
resultIsValid = true;
}
else
{
resultIsValid = false;
UI.Output($"NUMBER MUST BE FROM {MinEnemyHitRatePercent} TO {MaxEnemyHitRatePercent}");
}
}
while (!resultIsValid);
return result;
}
}

View File

@@ -0,0 +1,97 @@
namespace BombsAwayGame;
/// <summary>
/// Represents a protagonist in the game.
/// </summary>
internal abstract class Side
{
/// <summary>
/// Create instance using the given UI.
/// </summary>
/// <param name="ui">UI to use.</param>
public Side(IUserInterface ui)
{
UI = ui;
}
/// <summary>
/// Play this side.
/// </summary>
public abstract void Play();
/// <summary>
/// User interface supplied to ctor.
/// </summary>
protected IUserInterface UI { get; }
/// <summary>
/// Random-number generator for this play-through.
/// </summary>
private readonly Random _random = new();
/// <summary>
/// Gets a random floating-point number greater than or equal to zero, and less than one.
/// </summary>
/// <returns>Random floating-point number greater than or equal to zero, and less than one.</returns>
protected double RandomFrac() => _random.NextDouble();
/// <summary>
/// Gets a random integer in a range.
/// </summary>
/// <param name="minValue">The inclusive lower bound of the number returned.</param>
/// <param name="maxValue">The exclusive upper bound of the number returned.</param>
/// <returns>Random integer in a range.</returns>
protected int RandomInteger(int minValue, int maxValue) => _random.Next(minValue: minValue, maxValue: maxValue);
/// <summary>
/// Display messages indicating the mission succeeded.
/// </summary>
protected void MissionSucceeded()
{
UI.Output("DIRECT HIT!!!! " + RandomInteger(0, 100) + " KILLED.");
UI.Output("MISSION SUCCESSFUL.");
}
/// <summary>
/// Gets the Guns type of enemy artillery.
/// </summary>
protected EnemyArtillery Guns { get; } = new("GUNS", 0);
/// <summary>
/// Gets the Missiles type of enemy artillery.
/// </summary>
protected EnemyArtillery Missiles { get; } = new("MISSILES", 35);
/// <summary>
/// Gets the Both Guns and Missiles type of enemy artillery.
/// </summary>
protected EnemyArtillery Both { get; } = new("BOTH", 35);
/// <summary>
/// Perform enemy counterattack using the given artillery and hit rate percent.
/// </summary>
/// <param name="artillery">Enemy artillery to use.</param>
/// <param name="hitRatePercent">Hit rate percent for enemy.</param>
protected void EnemyCounterattack(EnemyArtillery artillery, int hitRatePercent)
{
if (hitRatePercent + artillery.Accuracy > RandomInteger(0, 100))
{
MissionFailed();
}
else
{
UI.Output("YOU MADE IT THROUGH TREMENDOUS FLAK!!");
}
}
/// <summary>
/// Display messages indicating the mission failed.
/// </summary>
protected void MissionFailed()
{
UI.Output("* * * * BOOM * * * *");
UI.Output("YOU HAVE BEEN SHOT DOWN.....");
UI.Output("DEARLY BELOVED, WE ARE GATHERED HERE TODAY TO PAY OUR");
UI.Output("LAST TRIBUTE...");
}
}