Merge pull request #310 from pgruderman/bullfight

Bullfight
This commit is contained in:
Jeff Atwood
2021-07-26 10:00:31 -07:00
committed by GitHub
18 changed files with 890 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31321.278
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Game", "Game.csproj", "{8F7C450E-5F3A-45BA-9DB9-329744214931}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8F7C450E-5F3A-45BA-9DB9-329744214931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F7C450E-5F3A-45BA-9DB9-329744214931}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F7C450E-5F3A-45BA-9DB9-329744214931}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F7C450E-5F3A-45BA-9DB9-329744214931}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C5BFC749-C7D8-4981-A7D4-1D401901A890}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,24 @@
namespace Game
{
/// <summary>
/// Enumerates the different actions that the player can take on each round
/// of the fight.
/// </summary>
public enum Action
{
/// <summary>
/// Dodge the bull.
/// </summary>
Dodge,
/// <summary>
/// Kill the bull.
/// </summary>
Kill,
/// <summary>
/// Freeze in place and don't do anything.
/// </summary>
Panic
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Game
{
/// <summary>
/// Enumerates the different possible outcomes of the player's action.
/// </summary>
public enum ActionResult
{
/// <summary>
/// The fight continues.
/// </summary>
FightContinues,
/// <summary>
/// The player fled from the ring.
/// </summary>
PlayerFlees,
/// <summary>
/// The bull has gored the player.
/// </summary>
BullGoresPlayer,
/// <summary>
/// The bull killed the player.
/// </summary>
BullKillsPlayer,
/// <summary>
/// The player killed the bull.
/// </summary>
PlayerKillsBull,
/// <summary>
/// The player attempted to kill the bull and both survived.
/// </summary>
Draw
}
}

View File

@@ -0,0 +1,220 @@
using System;
using System.Collections.Generic;
namespace Game
{
/// <summary>
/// Provides a method for simulating a bull fight.
/// </summary>
public static class BullFight
{
/// <summary>
/// Begins a new fight.
/// </summary>
/// <param name="mediator">
/// Object used to communicate with the player.
/// </param>
/// <returns>
/// The sequence of events that take place during the fight.
/// </returns>
/// <remarks>
/// After receiving each event, the caller must invoke the appropriate
/// mediator method to inform this coroutine what to do next. Failure
/// to do so will result in an exception.
/// </remarks>
public static IEnumerable<Events.Event> Begin(Mediator mediator)
{
var random = new Random();
var result = ActionResult.FightContinues;
var bullQuality = GetBullQuality();
var toreadorePerformance = GetHelpQuality(bullQuality);
var picadorePerformance = GetHelpQuality(bullQuality);
var bullStrength = 6 - (int)bullQuality;
var assistanceLevel = (12 - (int)toreadorePerformance - (int)picadorePerformance) * 0.1;
var bravery = 1.0;
var style = 1.0;
var passNumber = 0;
yield return new Events.MatchStarted(
bullQuality,
toreadorePerformance,
picadorePerformance,
GetHumanCasualties(toreadorePerformance),
GetHumanCasualties(picadorePerformance),
GetHorseCasualties(picadorePerformance));
while (result == ActionResult.FightContinues)
{
yield return new Events.BullCharging(++passNumber);
var (action, riskLevel) = mediator.GetInput<(Action, RiskLevel)>();
result = action switch
{
Action.Dodge => TryDodge(riskLevel),
Action.Kill => TryKill(riskLevel),
_ => Panic()
};
var first = true;
while (result == ActionResult.BullGoresPlayer)
{
yield return new Events.PlayerGored(action == Action.Panic, first);
first = false;
result = TrySurvive();
if (result == ActionResult.FightContinues)
{
yield return new Events.PlayerSurvived();
var runFromRing = mediator.GetInput<bool>();
if (runFromRing)
result = Flee();
else
result = IgnoreInjury(action);
}
}
}
yield return new Events.MatchCompleted(
result,
bravery == 2,
GetReward());
Quality GetBullQuality() =>
(Quality)random.Next(1, 6);
Quality GetHelpQuality(Quality bullQuality) =>
((3.0 / (int)bullQuality) * random.NextDouble()) switch
{
< 0.37 => Quality.Superb,
< 0.50 => Quality.Good,
< 0.63 => Quality.Fair,
< 0.87 => Quality.Poor,
_ => Quality.Awful
};
int GetHumanCasualties(Quality performance) =>
performance switch
{
Quality.Poor => random.Next(0, 2),
Quality.Awful => random.Next(1, 3),
_ => 0
};
int GetHorseCasualties(Quality performance) =>
performance switch
{
// NOTE: The code for displaying a single horse casuality
// following a poor picadore peformance was unreachable
// in the original BASIC version. I've assumed this was
// a bug.
Quality.Poor => 1,
Quality.Awful => random.Next(1, 3),
_ => 0
};
ActionResult TryDodge(RiskLevel riskLevel)
{
var difficultyModifier = riskLevel switch
{
RiskLevel.High => 3.0,
RiskLevel.Medium => 2.0,
_ => 0.5
};
var outcome = (bullStrength + (difficultyModifier / 10)) * random.NextDouble() /
((assistanceLevel + (passNumber / 10.0)) * 5);
if (outcome < 0.51)
{
style += difficultyModifier;
return ActionResult.FightContinues;
}
else
return ActionResult.BullGoresPlayer;
}
ActionResult TryKill(RiskLevel riskLevel)
{
var luck = bullStrength * 10 * random.NextDouble() / (assistanceLevel * 5 * passNumber);
return ((riskLevel == RiskLevel.High && luck > 0.2) || luck > 0.8) ?
ActionResult.BullGoresPlayer : ActionResult.PlayerKillsBull;
}
ActionResult Panic() =>
ActionResult.BullGoresPlayer;
ActionResult TrySurvive()
{
if (random.Next(2) == 0)
{
bravery = 1.5;
return ActionResult.BullKillsPlayer;
}
else
return ActionResult.FightContinues;
}
ActionResult Flee()
{
bravery = 0.0;
return ActionResult.PlayerFlees;
}
ActionResult IgnoreInjury(Action action)
{
if (random.Next(2) == 0)
{
bravery = 2.0;
return action == Action.Dodge ? ActionResult.FightContinues : ActionResult.Draw;
}
else
return ActionResult.BullGoresPlayer;
}
Reward GetReward()
{
var score = CalculateScore();
if (score * random.NextDouble() < 2.4)
return Reward.Nothing;
else
if (score * random.NextDouble() < 4.9)
return Reward.OneEar;
else
if (score * random.NextDouble() < 7.4)
return Reward.TwoEars;
else
return Reward.CarriedFromRing;
}
double CalculateScore()
{
var score = 4.5;
// Style
score += style / 6;
// Assisstance
score -= assistanceLevel * 2.5;
// Courage
score += 4 * bravery;
// Kill bonus
score += (result == ActionResult.PlayerKillsBull) ? 4 : 2;
// Match length
score -= Math.Pow(passNumber, 2) / 120;
// Difficulty
score -= (int)bullQuality;
return score;
}
}
}
}

View File

@@ -0,0 +1,134 @@
using System;
namespace Game
{
/// <summary>
/// Contains functions for getting input from the user.
/// </summary>
public static class Controller
{
/// <summary>
/// Handles the initial interaction with the player.
/// </summary>
public static void StartGame()
{
View.ShowBanner();
View.PromptShowInstructions();
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
if (input.ToUpperInvariant() != "NO")
View.ShowInstructions();
View.ShowSeparator();
}
/// <summary>
/// Gets the player's action for the current round.
/// </summary>
/// <param name="passNumber">
/// The current pass number.
/// </param>
public static (Action action, RiskLevel riskLevel) GetPlayerIntention(int passNumber)
{
if (passNumber < 3)
View.PromptKillBull();
else
View.PromptKillBullBrief();
var attemptToKill = GetYesOrNo();
if (attemptToKill)
{
View.PromptKillMethod();
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
return input switch
{
"4" => (Action.Kill, RiskLevel.High),
"5" => (Action.Kill, RiskLevel.Low),
_ => (Action.Panic, default(RiskLevel))
};
}
else
{
if (passNumber < 2)
View.PromptCapeMove();
else
View.PromptCapeMoveBrief();
var action = Action.Panic;
var riskLevel = default(RiskLevel);
while (action == Action.Panic)
{
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
(action, riskLevel) = input switch
{
"0" => (Action.Dodge, RiskLevel.High),
"1" => (Action.Dodge, RiskLevel.Medium),
"2" => (Action.Dodge, RiskLevel.Low),
_ => (Action.Panic, default(RiskLevel))
};
if (action == Action.Panic)
View.PromptDontPanic();
}
return (action, riskLevel);
}
}
/// <summary>
/// Gets the player's intention to flee (or not).
/// </summary>
/// <returns>
/// True if the player flees; otherwise, false.
/// </returns>
public static bool GetPlayerRunsFromRing()
{
View.PromptRunFromRing();
var playerFlees = GetYesOrNo();
if (!playerFlees)
View.ShowPlayerFoolhardy();
return playerFlees;
}
/// <summary>
/// Gets a yes or no response from the player.
/// </summary>
/// <returns>
/// True if the user answered yes; otherwise, false.
/// </returns>
public static bool GetYesOrNo()
{
while (true)
{
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
switch (input.ToUpperInvariant())
{
case "YES":
return true;
case "NO":
return false;
default:
Console.WriteLine("INCORRECT ANSWER - - PLEASE TYPE 'YES' OR 'NO'.");
break;
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace Game.Events
{
/// <summary>
/// Indicates that the bull is charing the player.
/// </summary>
public sealed record BullCharging(int PassNumber) : Event;
}

View File

@@ -0,0 +1,7 @@
namespace Game.Events
{
/// <summary>
/// Common base class for all events in the game.
/// </summary>
public abstract record Event();
}

View File

@@ -0,0 +1,7 @@
namespace Game.Events
{
/// <summary>
/// Indicates that the fight has completed.
/// </summary>
public sealed record MatchCompleted(ActionResult Result, bool ExtremeBravery, Reward Reward) : Event;
}

View File

@@ -0,0 +1,13 @@
namespace Game.Events
{
/// <summary>
/// Indicates that a new match has started.
/// </summary>
public sealed record MatchStarted(
Quality BullQuality,
Quality ToreadorePerformance,
Quality PicadorePerformance,
int ToreadoresKilled,
int PicadoresKilled,
int HorsesKilled) : Event;
}

View File

@@ -0,0 +1,7 @@
namespace Game.Events
{
/// <summary>
/// Indicates that the player has been gored by the bull.
/// </summary>
public sealed record PlayerGored(bool Panicked, bool FirstGoring) : Event;
}

View File

@@ -0,0 +1,7 @@
namespace Game.Events
{
/// <summary>
/// Indicates that the player has survived being gored by the bull.
/// </summary>
public sealed record PlayerSurvived() : Event;
}

View File

@@ -0,0 +1,48 @@
using System.Diagnostics;
namespace Game
{
/// <summary>
/// Facilitates sending messages between the two game loops.
/// </summary>
/// <remarks>
/// This class serves as a little piece of glue in between the main program
/// loop and the bull fight coroutine. When the main program calls one of
/// its methods, the mediator creates the appropriate input data that the
/// bull fight coroutine later retrieves with <see cref="GetInput{T}"/>.
/// </remarks>
public class Mediator
{
private object? m_input;
public void Dodge(RiskLevel riskLevel) =>
m_input = (Action.Dodge, riskLevel);
public void Kill(RiskLevel riskLevel) =>
m_input = (Action.Kill, riskLevel);
public void Panic() =>
m_input = (Action.Panic, default(RiskLevel));
public void RunFromRing() =>
m_input = true;
public void ContinueFighting() =>
m_input = false;
/// <summary>
/// Gets the next input from the user.
/// </summary>
/// <typeparam name="T">
/// The type of input to receive.
/// </typeparam>
public T GetInput<T>()
{
Debug.Assert(m_input is not null, "No input received");
Debug.Assert(m_input.GetType() == typeof(T), "Invalid input received");
var result = (T)m_input;
m_input = null;
return result;
}
}
}

View File

@@ -0,0 +1,54 @@
namespace Game
{
class Program
{
static void Main()
{
Controller.StartGame();
var mediator = new Mediator();
foreach (var evt in BullFight.Begin(mediator))
{
switch (evt)
{
case Events.MatchStarted matchStarted:
View.ShowStartingConditions(matchStarted);
break;
case Events.BullCharging bullCharging:
View.ShowStartOfPass(bullCharging.PassNumber);
var (action, riskLevel) = Controller.GetPlayerIntention(bullCharging.PassNumber);
switch (action)
{
case Action.Dodge:
mediator.Dodge(riskLevel);
break;
case Action.Kill:
mediator.Kill(riskLevel);
break;
case Action.Panic:
mediator.Panic();
break;
}
break;
case Events.PlayerGored playerGored:
View.ShowPlayerGored(playerGored.Panicked, playerGored.FirstGoring);
break;
case Events.PlayerSurvived:
View.ShowPlayerSurvives();
if (Controller.GetPlayerRunsFromRing())
mediator.RunFromRing();
else
mediator.ContinueFighting();
break;
case Events.MatchCompleted matchCompleted:
View.ShowFinalResult(matchCompleted.Result, matchCompleted.ExtremeBravery, matchCompleted.Reward);
break;
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
namespace Game
{
/// <summary>
/// Enumerates the different levels of quality in the game.
/// </summary>
/// <remarks>
/// Quality applies both to the bull and to the help received from the
/// toreadores and picadores. Note that the ordinal values are significant
/// (these are used in various calculations).
/// </remarks>
public enum Quality
{
Superb = 1,
Good = 2,
Fair = 3,
Poor = 4,
Awful = 5
}
}

View File

@@ -0,0 +1,13 @@
namespace Game
{
/// <summary>
/// Enumerates the different things the player can be awarded.
/// </summary>
public enum Reward
{
Nothing,
OneEar,
TwoEars,
CarriedFromRing
}
}

View File

@@ -0,0 +1,12 @@
namespace Game
{
/// <summary>
/// Enumerates the different levels of risk for manoeuvres in the game.
/// </summary>
public enum RiskLevel
{
Low,
Medium,
High
}
}

View File

@@ -0,0 +1,242 @@
using System;
namespace Game
{
/// <summary>
/// Contains functions for displaying information to the user.
/// </summary>
public static class View
{
private static readonly string[] QualityString = { "SUPERB", "GOOD", "FAIR", "POOR", "AWFUL" };
public static void ShowBanner()
{
Console.WriteLine(" BULL");
Console.WriteLine(" CREATIVE COMPUTING MORRISTOWN, NEW JERSEY");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine();
}
public static void ShowInstructions()
{
Console.WriteLine("HELLO, ALL YOU BLOODLOVERS AND AFICIONADOS.");
Console.WriteLine("HERE IS YOUR BIG CHANCE TO KILL A BULL.");
Console.WriteLine();
Console.WriteLine("ON EACH PASS OF THE BULL, YOU MAY TRY");
Console.WriteLine("0 - VERONICA (DANGEROUS INSIDE MOVE OF THE CAPE)");
Console.WriteLine("1 - LESS DANGEROUS OUTSIDE MOVE OF THE CAPE");
Console.WriteLine("2 - ORDINARY SWIRL OF THE CAPE.");
Console.WriteLine();
Console.WriteLine("INSTEAD OF THE ABOVE, YOU MAY TRY TO KILL THE BULL");
Console.WriteLine("ON ANY TURN: 4 (OVER THE HORNS), 5 (IN THE CHEST).");
Console.WriteLine("BUT IF I WERE YOU,");
Console.WriteLine("I WOULDN'T TRY IT BEFORE THE SEVENTH PASS.");
Console.WriteLine();
Console.WriteLine("THE CROWD WILL DETERMINE WHAT AWARD YOU DESERVE");
Console.WriteLine("(POSTHUMOUSLY IF NECESSARY).");
Console.WriteLine("THE BRAVER YOU ARE, THE BETTER THE AWARD YOU RECEIVE.");
Console.WriteLine();
Console.WriteLine("THE BETTER THE JOB THE PICADORES AND TOREADORES DO,");
Console.WriteLine("THE BETTER YOUR CHANCES ARE.");
}
public static void ShowSeparator()
{
Console.WriteLine();
Console.WriteLine();
}
public static void ShowStartingConditions(Events.MatchStarted matchStarted)
{
ShowBullQuality();
ShowHelpQuality("TOREADORES", matchStarted.ToreadorePerformance, matchStarted.ToreadoresKilled, 0);
ShowHelpQuality("PICADORES", matchStarted.PicadorePerformance, matchStarted.PicadoresKilled, matchStarted.HorsesKilled);
void ShowBullQuality()
{
Console.WriteLine($"YOU HAVE DRAWN A {QualityString[(int)matchStarted.BullQuality - 1]} BULL.");
if (matchStarted.BullQuality > Quality.Poor)
{
Console.WriteLine("YOU'RE LUCKY");
}
else
if (matchStarted.BullQuality < Quality.Good)
{
Console.WriteLine("GOOD LUCK. YOU'LL NEED IT.");
Console.WriteLine();
}
Console.WriteLine();
}
static void ShowHelpQuality(string helperName, Quality helpQuality, int helpersKilled, int horsesKilled)
{
Console.WriteLine($"THE {helperName} DID A {QualityString[(int)helpQuality - 1]} JOB.");
// NOTE: The code below makes some *strong* assumptions about
// how the casualty numbers were generated. It is written
// this way to preserve the behaviour of the original BASIC
// version, but it would make more sense ignore the helpQuality
// parameter and just use the provided numbers to decide what
// to display.
switch (helpQuality)
{
case Quality.Poor:
if (horsesKilled > 0)
Console.WriteLine($"ONE OF THE HORSES OF THE {helperName} WAS KILLED.");
if (helpersKilled > 0)
Console.WriteLine($"ONE OF THE {helperName} WAS KILLED.");
else
Console.WriteLine($"NO {helperName} WERE KILLED.");
break;
case Quality.Awful:
if (horsesKilled > 0)
Console.WriteLine($" {horsesKilled} OF THE HORSES OF THE {helperName} KILLED.");
Console.WriteLine($" {helpersKilled} OF THE {helperName} KILLED.");
break;
}
}
}
public static void ShowStartOfPass(int passNumber)
{
Console.WriteLine();
Console.WriteLine();
Console.WriteLine($"PASS NUMBER {passNumber}");
}
public static void ShowPlayerGored(bool playerPanicked, bool firstGoring)
{
Console.WriteLine((playerPanicked, firstGoring) switch
{
(true, true) => "YOU PANICKED. THE BULL GORED YOU.",
(false, true) => "THE BULL HAS GORED YOU!",
(_, false) => "YOU ARE GORED AGAIN!"
});
}
public static void ShowPlayerSurvives()
{
Console.WriteLine("YOU ARE STILL ALIVE.");
Console.WriteLine();
}
public static void ShowPlayerFoolhardy()
{
Console.WriteLine("YOU ARE BRAVE. STUPID, BUT BRAVE.");
}
public static void ShowFinalResult(ActionResult result, bool extremeBravery, Reward reward)
{
Console.WriteLine();
Console.WriteLine();
Console.WriteLine();
switch (result)
{
case ActionResult.PlayerFlees:
Console.WriteLine("COWARD");
break;
case ActionResult.BullKillsPlayer:
Console.WriteLine("YOU ARE DEAD.");
break;
case ActionResult.PlayerKillsBull:
Console.WriteLine("YOU KILLED THE BULL!");
break;
}
if (result == ActionResult.PlayerFlees)
{
Console.WriteLine("THE CROWD BOOS FOR TEN MINUTES. IF YOU EVER DARE TO SHOW");
Console.WriteLine("YOUR FACE IN A RING AGAIN, THEY SWEAR THEY WILL KILL YOU--");
Console.WriteLine("UNLESS THE BULL DOES FIRST.");
}
else
{
if (extremeBravery)
Console.WriteLine("THE CROWD CHEERS WILDLY!");
else
if (result == ActionResult.PlayerKillsBull)
{
Console.WriteLine("THE CROWD CHEERS!");
Console.WriteLine();
}
Console.WriteLine("THE CROWD AWARDS YOU");
switch (reward)
{
case Reward.Nothing:
Console.WriteLine("NOTHING AT ALL.");
break;
case Reward.OneEar:
Console.WriteLine("ONE EAR OF THE BULL.");
break;
case Reward.TwoEars:
Console.WriteLine("BOTH EARS OF THE BULL!");
Console.WriteLine("OLE!");
break;
default:
Console.WriteLine("OLE! YOU ARE 'MUY HOMBRE'!! OLE! OLE!");
break;
}
}
Console.WriteLine();
Console.WriteLine("ADIOS");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine();
}
public static void PromptShowInstructions()
{
Console.Write("DO YOU WANT INSTRUCTIONS? ");
}
public static void PromptKillBull()
{
Console.WriteLine("THE BULL IS CHARGING AT YOU! YOU ARE THE MATADOR--");
Console.Write("DO YOU WANT TO KILL THE BULL? ");
}
public static void PromptKillBullBrief()
{
Console.Write("HERE COMES THE BULL. TRY FOR A KILL? ");
}
public static void PromptKillMethod()
{
Console.WriteLine();
Console.WriteLine("IT IS THE MOMENT OF TRUTH.");
Console.WriteLine();
Console.Write("HOW DO YOU TRY TO KILL THE BULL? ");
}
public static void PromptCapeMove()
{
Console.Write("WHAT MOVE DO YOU MAKE WITH THE CAPE? ");
}
public static void PromptCapeMoveBrief()
{
Console.Write("CAPE MOVE? ");
}
public static void PromptDontPanic()
{
Console.WriteLine("DON'T PANIC, YOU IDIOT! PUT DOWN A CORRECT NUMBER");
Console.Write("? ");
}
public static void PromptRunFromRing()
{
Console.Write("DO YOU RUN FROM THE RING? ");
}
}
}