Reworked Bull Fight in a reactive style

This commit is contained in:
Peter
2021-07-23 20:02:54 -04:00
parent bc628e71da
commit 6c4f019d40
15 changed files with 372 additions and 378 deletions

View File

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

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

@@ -93,10 +93,15 @@ namespace Game
/// <returns>
/// True if the player flees; otherwise, false.
/// </returns>
public static bool PlayerRunsFromRing()
public static bool GetPlayerRunsFromRing()
{
View.PromptRunFromRing();
return GetYesOrNo();
var playerFlees = GetYesOrNo();
if (!playerFlees)
View.ShowPlayerFoolhardy();
return playerFlees;
}
/// <summary>

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

@@ -1,41 +0,0 @@
namespace Game
{
/// <summary>
/// Stores the initial conditions of a match.
/// </summary>
public record MatchConditions
{
/// <summary>
/// Gets the quality of the bull.
/// </summary>
public Quality BullQuality { get; init; }
/// <summary>
/// Gets the quality of help received from the toreadores.
/// </summary>
public Quality ToreadorePerformance { get; init; }
/// <summary>
/// Gets the quality of help received from the picadores.
/// </summary>
public Quality PicadorePerformance { get; init; }
/// <summary>
/// Gets the number of toreadores killed while preparing for the
/// final round.
/// </summary>
public int ToreadoresKilled { get; init; }
/// <summary>
/// Gets the number of picadores killed while preparing for the
/// final round.
/// </summary>
public int PicadoresKilled { get; init; }
/// <summary>
/// Gets the number of horses killed while preparing for the final
/// round.
/// </summary>
public int HorsesKilled { get; init; }
}
}

View File

@@ -1,28 +0,0 @@
namespace Game
{
/// <summary>
/// Stores the current state of the match.
/// </summary>
public record MatchState(MatchConditions Conditions)
{
/// <summary>
/// Gets the number of times the bull has charged.
/// </summary>
public int PassNumber { get; init; }
/// <summary>
/// Measures the player's bravery during the match.
/// </summary>
public double Bravery { get; init; }
/// <summary>
/// Measures how much style the player showed during the match.
/// </summary>
public double Style { get; init; }
/// <summary>
/// Gets the result of the player's last action.
/// </summary>
public ActionResult Result { get; init; }
}
}

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

@@ -1,6 +1,4 @@
using System;
namespace Game
namespace Game
{
class Program
{
@@ -8,49 +6,49 @@ namespace Game
{
Controller.StartGame();
var random = new Random();
var match = Rules.StartMatch(random);
View.ShowStartingConditions(match.Conditions);
while (match.Result == ActionResult.FightContinues)
var mediator = new Mediator();
foreach (var evt in BullFight.Begin(mediator))
{
match = match with { PassNumber = match.PassNumber + 1 };
View.StartOfPass(match.PassNumber);
var (action, riskLevel) = Controller.GetPlayerIntention(match.PassNumber);
match = action switch
switch (evt)
{
Action.Dodge => Rules.TryDodge(random, riskLevel, match),
Action.Kill => Rules.TryKill(random, riskLevel, match),
_ => Rules.Panic(match)
};
case Events.MatchStarted matchStarted:
View.ShowStartingConditions(matchStarted);
break;
var first = true;
while (match.Result == ActionResult.BullGoresPlayer)
{
View.ShowPlayerGored(action == Action.Panic, first);
first = false;
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;
match = Rules.TrySurvive(random, match);
if (match.Result == ActionResult.FightContinues)
{
case Events.PlayerGored playerGored:
View.ShowPlayerGored(playerGored.Panicked, playerGored.FirstGoring);
break;
case Events.PlayerSurvived:
View.ShowPlayerSurvives();
if (Controller.PlayerRunsFromRing())
{
match = Rules.Flee(match);
}
if (Controller.GetPlayerRunsFromRing())
mediator.RunFromRing();
else
{
View.ShowPlayerFoolhardy();
match = Rules.IgnoreInjury(random, action, match);
}
}
mediator.ContinueFighting();
break;
case Events.MatchCompleted matchCompleted:
View.ShowFinalResult(matchCompleted.Result, matchCompleted.ExtremeBravery, matchCompleted.Reward);
break;
}
}
View.ShowFinalResult(match.Result, match.Bravery, Rules.GetReward(random, match));
}
}
}

View File

@@ -1,258 +0,0 @@
using System;
namespace Game
{
/// <summary>
/// Provides functions implementing the rules of the game.
/// </summary>
public static class Rules
{
/// <summary>
/// Gets the state of a new match.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
public static MatchState StartMatch(Random random)
{
var bullQuality = GetBullQuality();
var toreadorePerformance = GetHelpQuality();
var picadorePerformance = GetHelpQuality();
var conditions = new MatchConditions
{
BullQuality = bullQuality,
ToreadorePerformance = toreadorePerformance,
PicadorePerformance = picadorePerformance,
ToreadoresKilled = GetHumanCasualties(toreadorePerformance),
PicadoresKilled = GetHumanCasualties(picadorePerformance),
HorsesKilled = GetHorseCasualties(picadorePerformance)
};
return new MatchState(conditions)
{
Bravery = 1.0,
Style = 1.0
};
Quality GetBullQuality() =>
(Quality)random.Next(1, 6);
Quality GetHelpQuality() =>
((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
};
}
/// <summary>
/// Determines the result when the player attempts to dodge the bull.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
/// <param name="riskLevel">
/// The level of risk in the dodge manoeuvre chosen.
/// </param>
/// <param name="match">
/// The current match state.
/// </param>
/// <returns>
/// The updated match state.
/// </returns>
public static MatchState TryDodge(Random random, RiskLevel riskLevel, MatchState match)
{
var difficultyModifier = riskLevel switch
{
RiskLevel.High => 3.0,
RiskLevel.Medium => 2.0,
_ => 0.5
};
var outcome = (GetBullStrength(match) + (difficultyModifier / 10)) * random.NextDouble() /
((GetAssisstance(match) + (match.PassNumber / 10.0)) * 5);
return outcome < 0.51 ?
match with { Result = ActionResult.FightContinues, Style = match.Style + difficultyModifier } :
match with { Result = ActionResult.BullGoresPlayer };
}
/// <summary>
/// Determines the result when the player attempts to kill the bull.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
/// <param name="riskLevel">
/// The level of risk in the manoeuvre chosen.
/// </param>
/// <param name="match">
/// The current match state.
/// </param>
/// <returns>
/// The updated match state.
/// </returns>
public static MatchState TryKill(Random random, RiskLevel riskLevel, MatchState match)
{
var K = GetBullStrength(match) * 10 * random.NextDouble() / (GetAssisstance(match) * 5 * match.PassNumber);
return ((riskLevel == RiskLevel.High && K > 0.2) || K > 0.8) ?
match with { Result = ActionResult.BullGoresPlayer } :
match with { Result = ActionResult.PlayerKillsBull };
}
/// <summary>
/// Determines if the player survives being gored by the bull.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
/// <param name="match">
/// The current match state.
/// </param>
/// <returns>
/// The updated match state.
/// </returns>
public static MatchState TrySurvive(Random random, MatchState match) =>
(random.Next(2) == 0) ?
match with { Result = ActionResult.BullKillsPlayer, Bravery = 1.5 } :
match with { Result = ActionResult.FightContinues };
/// <summary>
/// Determines the result when the player panics and fails to do anything.
/// </summary>
/// <param name="match">
/// The match state.
/// </param>
public static MatchState Panic(MatchState match) =>
match with { Result = ActionResult.BullGoresPlayer };
/// <summary>
/// Determines the result when the player flees the ring.
/// </summary>
/// <param name="match">
/// The current match state.
/// </param>
/// <returns>
/// The updated match state.
/// </returns>
public static MatchState Flee(MatchState match) =>
match with { Result = ActionResult.PlayerFlees, Bravery = 0.0 };
/// <summary>
/// Determines the result when the player decides to continue fighting
/// following an injury.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
/// <param name="action">
/// The action the player took that lead to the injury.
/// </param>
/// <param name="match">
/// The current match state.
/// </param>
/// <returns>
/// The updated match state.
/// </returns>
public static MatchState IgnoreInjury(Random random, Action action, MatchState match) =>
(random.Next(2) == 0) ?
match with { Result = action == Action.Dodge ? ActionResult.FightContinues : ActionResult.Draw, Bravery = 2.0 } :
match with { Result = ActionResult.BullGoresPlayer };
/// <summary>
/// Gets the player's reward for completing a match.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
/// <param name="match">
/// The final match state.
/// </param>
public static Reward GetReward(Random random, MatchState match)
{
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 += match.Style / 6;
// Assisstance
score -= GetAssisstance(match) * 2.5;
// Courage
score += 4 * match.Bravery;
// Kill bonus
score += (match.Result == ActionResult.PlayerKillsBull) ? 4 : 2;
// Match length
score -= Math.Pow(match.PassNumber, 2) / 120;
// Difficulty
score -= (int)match.Conditions.BullQuality;
return score;
}
}
/// <summary>
/// Calculates the strength of the bull in a match.
/// </summary>
private static double GetBullStrength(MatchState match) =>
6 - (int)match.Conditions.BullQuality;
/// <summary>
/// Gets the amount of assistance received from the toreadores and
/// picadores in a match.
/// </summary>
private static double GetAssisstance(MatchState match) =>
GetPerformanceBonus(match.Conditions.ToreadorePerformance) +
GetPerformanceBonus(match.Conditions.PicadorePerformance);
/// <summary>
/// Gets the amount of assistance rendered by a performance of the
/// given quality.
/// </summary>
private static double GetPerformanceBonus(Quality performance) =>
(6 - (int)performance) * 0.1;
}
}

View File

@@ -47,22 +47,22 @@ namespace Game
Console.WriteLine();
}
public static void ShowStartingConditions(MatchConditions conditions)
public static void ShowStartingConditions(Events.MatchStarted matchStarted)
{
ShowBullQuality();
ShowHelpQuality("TOREADORES", conditions.ToreadorePerformance, conditions.ToreadoresKilled, 0);
ShowHelpQuality("PICADORES", conditions.PicadorePerformance, conditions.PicadoresKilled, conditions.HorsesKilled);
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)conditions.BullQuality - 1]} BULL.");
Console.WriteLine($"YOU HAVE DRAWN A {QualityString[(int)matchStarted.BullQuality - 1]} BULL.");
if (conditions.BullQuality > Quality.Poor)
if (matchStarted.BullQuality > Quality.Poor)
{
Console.WriteLine("YOU'RE LUCKY");
}
else
if (conditions.BullQuality < Quality.Good)
if (matchStarted.BullQuality < Quality.Good)
{
Console.WriteLine("GOOD LUCK. YOU'LL NEED IT.");
Console.WriteLine();
@@ -86,9 +86,11 @@ namespace Game
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:
@@ -101,7 +103,7 @@ namespace Game
}
}
public static void StartOfPass(int passNumber)
public static void ShowStartOfPass(int passNumber)
{
Console.WriteLine();
Console.WriteLine();
@@ -129,7 +131,7 @@ namespace Game
Console.WriteLine("YOU ARE BRAVE. STUPID, BUT BRAVE.");
}
public static void ShowFinalResult(ActionResult result, double bravery, Reward reward)
public static void ShowFinalResult(ActionResult result, bool extremeBravery, Reward reward)
{
Console.WriteLine();
Console.WriteLine();
@@ -156,7 +158,7 @@ namespace Game
}
else
{
if (bravery == 2) // You were gored by the bull but survived (and did not later die or flee)
if (extremeBravery)
Console.WriteLine("THE CROWD CHEERS WILDLY!");
else
if (result == ActionResult.PlayerKillsBull)