Last active
December 8, 2020 18:15
-
-
Save thquinn/ac4b94c896e91efc2943076f9c00615e to your computer and use it in GitHub Desktop.
Runs simplified goldfish games of Penny Dreadful Near-Death Experience Combo.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Runs simplified goldfish games of Penny Dreadful Near-Death Experience Combo. Simplifications include: | |
// - no interaction from the opponent, obviously | |
// - doesn't simulate cards besides combo pieces and lands | |
// - no maximum hand size | |
// - Lost Auramancers doesn't actually remove NDE from the deck | |
// - generally poor decision making | |
// - lots of other stuff (see inline comments) | |
using System; | |
using System.Collections.Generic; | |
using System.Diagnostics; | |
using System.Linq; | |
namespace NearWinMonteCarlo { | |
class Program { | |
public static Dictionary<Card, string> CARD_NAMES = new Dictionary<Card, string>() { | |
{ Card.FetidHeath, "Fetid Heath" }, | |
{ Card.HeartlessAct, "Heartless Act" }, | |
{ Card.LostAuramancers, "Lost Auramancers" }, | |
{ Card.MischievousPoltergeist, "Mischievous Poltergeist" }, | |
{ Card.NearDeathExperience, "Near-Death Experience" }, | |
{ Card.OrzhovBasilica, "Orzhov Basilica" }, | |
{ Card.Other, "a non-combo spell" }, | |
{ Card.PelakkaCaverns, "Pelakka Caverns" }, | |
{ Card.Plains, "Plains" }, | |
{ Card.Swamp, "Swamp" }, | |
{ Card.TempleOfSilence, "Temple of Silence" }, | |
{ Card.WallOfBlood, "Wall of Blood" }, | |
}; | |
static int TRIALS = 1; | |
static int LOG_LEVEL = TRIALS == 1 ? 3 : 0; | |
// 0 = no per-trial logging | |
// 1 = combo pieces played | |
// 2 = cards drawn and lands played | |
// 3 = scries, lands picked up by Orzhov Basilica | |
// 4 = debug | |
static void Main(string[] args) { | |
Dictionary<Card, int> build = new Dictionary<Card, int>(); | |
// combo pieces | |
build[Card.HeartlessAct] = 4; | |
build[Card.LostAuramancers] = 4; | |
build[Card.MischievousPoltergeist] = 4; | |
build[Card.NearDeathExperience] = 4; | |
build[Card.WallOfBlood] = 4; | |
// lands | |
build[Card.FetidHeath] = 4; | |
build[Card.OrzhovBasilica] = 4; | |
build[Card.PelakkaCaverns] = 4; | |
build[Card.Plains] = 6; | |
build[Card.Swamp] = 2; | |
build[Card.TempleOfSilence] = 4; | |
while (true) { | |
Stopwatch watch = new Stopwatch(); | |
watch.Start(); | |
float averageTurnWon = Run(build, TRIALS); | |
watch.Stop(); | |
Console.WriteLine("{0} trials complete in {1} ms. Average win turn: {2}.", TRIALS, watch.ElapsedMilliseconds, averageTurnWon.ToString("N2")); | |
Console.ReadLine(); | |
} | |
} | |
static float Run(Dictionary<Card, int> build, int trials) { | |
Card[] deck = new Card[60]; | |
int i = 0; | |
foreach (var kvp in build) { | |
for (int j = 0; j < kvp.Value; j++) { | |
deck[i] = kvp.Key; | |
i++; | |
} | |
} | |
while (i < 60) { | |
deck[i] = Card.Other; | |
i++; | |
} | |
int total = 0; | |
for (i = 0; i < trials; i++) { | |
Log(1, "TRIAL {0}", i + 1); | |
int turns = RunTrial(deck); | |
if (turns <= 5) { | |
throw new Exception("Unexpected win before turn 6."); | |
} | |
total += turns; | |
} | |
return total / (float)trials; | |
} | |
static int RunTrial(Card[] deck) { | |
State state = new State(deck); | |
while (true) { | |
Log(1, "Turn {0}:", state.turn); | |
if (state.IsWon()) { | |
break; | |
} | |
state.UpdateAuramancer(); | |
if (state.turn > 1) { | |
state.Draw(); | |
} | |
// Calculate all mana generation possibilities for all potential land plays. | |
List<ManaPossibility> manaPossibilities = state.GetAllManaPossibilities(); | |
// Execute the best possible plan with the best possible land play. | |
if (TurnPlanLeechAndHeartlessAct(state, manaPossibilities)) { | |
Log(1, "Played a leech and Heartless Act on Lost Auramancers, fetching Near-Death Experience."); | |
} else if (TurnPlanLeechAndNDE(state, manaPossibilities)) { | |
Log(1, "Played a leech and Near-Death Experience."); | |
} else if (TurnPlanLeechAuramancersAndHeartlessAct(state, manaPossibilities)) { | |
Log(1, "Played a leech, Lost Auramancers, and Heartless Act on Lost Auramancers, fetching Near-Death Experience."); | |
} else if (TurnPlanAuramancersAndHeartlessAct(state, manaPossibilities)) { | |
Log(1, "Played Lost Auramancers and Heartless Act on Lost Auramancers, fetching Near-Death Experience."); | |
} else if (TurnPlanNDE(state, manaPossibilities)) { | |
Log(1, "Played Near-Death Experience."); | |
} else if (TurnPlanAuramancers(state, manaPossibilities)) { | |
Log(1, "Played Lost Auramancers."); | |
} else if (TurnPlanLeech(state, manaPossibilities)) { | |
Log(1, "Played a leech."); | |
} else if (TurnPlanHeartlessAct(state, manaPossibilities)) { | |
Log(1, "Played Heartless Act on Lost Auramancers, fetching Near-Death Experience."); | |
} else if (manaPossibilities.Count > 0) { | |
state.PlayLand(manaPossibilities[manaPossibilities.Count - 1].landPlayed); | |
} | |
// Ping self. | |
if (state.IsWon()) { | |
Log(1, "Leeched down to 1 life."); | |
} | |
// Advance turn. | |
state.turn++; | |
} | |
Log(1, "Win!\n"); | |
return state.turn; | |
} | |
// Turn plans. | |
#region turn plans | |
static bool TurnPlanLeechAndHeartlessAct(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayLeech() || !state.ShouldPlayHeartlessAct()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 0, 2, 3); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.leechInPlay = true; | |
state.ndeInPlay = true; | |
return true; | |
} | |
static bool TurnPlanLeechAndNDE(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayLeech() || !state.ShouldPlayNDE()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 3, 1, 4); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.leechInPlay = true; | |
state.ndeInPlay = true; | |
return true; | |
} | |
static bool TurnPlanLeechAuramancersAndHeartlessAct(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayLeech() || !state.ShouldPlayAuramancers() || !state.heartlessActInHand) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 2, 2, 5); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.leechInPlay = true; | |
state.ndeInPlay = true; | |
return true; | |
} | |
static bool TurnPlanAuramancersAndHeartlessAct(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayAuramancers() || !state.heartlessActInHand) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 2, 1, 3); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.ndeInPlay = true; | |
return true; | |
} | |
static bool TurnPlanNDE(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayNDE()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 3, 0, 2); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.ndeInPlay = true; | |
return true; | |
} | |
static bool TurnPlanAuramancers(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayAuramancers()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 2, 0, 2); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.auramancersTimer = 3; | |
return true; | |
} | |
static bool TurnPlanLeech(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayLeech()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 0, 1, 2); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.leechInPlay = true; | |
return true; | |
} | |
static bool TurnPlanHeartlessAct(State state, List<ManaPossibility> manaPossibilities) { | |
if (!state.ShouldPlayHeartlessAct()) { | |
return false; | |
} | |
int possibilityIndex = GetLastIndexWithMana(manaPossibilities, 0, 1, 1); | |
if (possibilityIndex == -1) { | |
return false; | |
} | |
state.PlayLand(manaPossibilities[possibilityIndex].landPlayed); | |
state.ndeInPlay = true; | |
return true; | |
} | |
#endregion | |
static int GetLastIndexWithMana(List<ManaPossibility> manaPossibilities, int white, int black, int colorless) { | |
for (int i = manaPossibilities.Count - 1; i >= 0; i--) { | |
if (manaPossibilities[i].HasAtLeast(white, black, colorless)) { | |
Log(4, "Found compatible mana possibility: {0}W{1}B.", manaPossibilities[i].white, manaPossibilities[i].black); | |
return i; | |
} | |
} | |
return -1; | |
} | |
public static void Log(int level, string format, params object[] tokens) { | |
if (level > LOG_LEVEL) return; | |
Console.WriteLine(string.Format(format, tokens)); | |
} | |
} | |
class State { | |
public int turn; | |
Card[] deck; | |
int deckIndex; | |
int[] landsInHand, landsInPlay; | |
public bool leechInHand, ndeInHand, auramancersInHand, heartlessActInHand; | |
public bool leechInPlay, ndeInPlay; | |
public int auramancersTimer; | |
public State(Card[] deck) { | |
this.deck = deck; | |
turn = 1; | |
landsInHand = new int[6]; | |
landsInPlay = new int[6]; | |
OpeningHand(7); | |
// TODO: More advanced mulligan logic, inc. London mulligans. | |
int landCount = landsInHand.Sum(); | |
if (landCount < 2 || landCount > 5) { | |
Program.Log(2, "Took a mulligan."); | |
deckIndex = 0; | |
landsInHand = new int[6]; | |
landsInPlay = new int[6]; | |
leechInHand = false; | |
ndeInHand = false; | |
auramancersInHand = false; | |
heartlessActInHand = false; | |
OpeningHand(6); | |
} | |
} | |
public void OpeningHand(int num) { | |
deck.Shuffle(); | |
for (int i = 0; i < num; i++) { | |
Draw(); | |
} | |
} | |
public bool IsWon() { | |
return leechInPlay & ndeInPlay; | |
} | |
public void UpdateAuramancer() { | |
if (auramancersTimer > 0) { | |
auramancersTimer--; | |
if (auramancersTimer == 0) { | |
ndeInPlay = true; | |
Program.Log(1, "Lost Auramancers vanished, fetching Near-Death Experience."); | |
} | |
} | |
} | |
public void Draw() { | |
Card card = deck[deckIndex++]; | |
switch (card) { | |
case Card.None: | |
throw new Exception("Deck contains None card."); | |
case Card.Other: | |
break; | |
case Card.HeartlessAct: | |
heartlessActInHand = true; | |
break; | |
case Card.LostAuramancers: | |
auramancersInHand = true; | |
break; | |
case Card.MischievousPoltergeist: | |
case Card.WallOfBlood: | |
leechInHand = true; | |
break; | |
case Card.NearDeathExperience: | |
ndeInHand = true; | |
break; | |
default: | |
landsInHand[(int)card]++; | |
break; | |
} | |
Program.Log(2, "Drew {0}.", Program.CARD_NAMES[card]); | |
} | |
public List<ManaPossibility> GetAllManaPossibilities() { | |
List<ManaPossibility> currentPossibilities = new List<ManaPossibility>(); | |
AddManaPossibilities(currentPossibilities, Card.None); | |
List<ManaPossibility> output = new List<ManaPossibility>(); | |
// Possibility order prioritizes playing tapped lands unless playing an untapped land would advance the combo more. | |
for (int i = 0; i < 6; i++) { | |
if (landsInHand[i] > 0) { | |
Card land = (Card)i; | |
if (land == Card.OrzhovBasilica && NumLands(Card.Plains) == 0 && NumLands(Card.Swamp) == 0 && NumLands(Card.FetidHeath) == 0 && NumLands(Card.TempleOfSilence) == 0 && NumLands(Card.PelakkaCaverns) == 0) { | |
// Don't bother playing Orzhov Basilica unless there's a non-Basilica land in play. | |
continue; | |
} | |
if (i < 3) { | |
// Calculate new possibilities with untapped land. | |
landsInPlay[i]++; | |
AddManaPossibilities(output, land); | |
landsInPlay[i]--; | |
} else { | |
// Copy current possibilities with tapped land. | |
output.AddRange(currentPossibilities.Select(p => new ManaPossibility(p, land))); | |
} | |
} | |
} | |
return output.Count > 0 ? output : currentPossibilities; | |
} | |
public void AddManaPossibilities(List<ManaPossibility> manaPossibilities, Card land) { | |
int baseWhite = NumLands(Card.Plains) + NumLands(Card.OrzhovBasilica); | |
int baseBlack = NumLands(Card.Swamp) + NumLands(Card.PelakkaCaverns) + NumLands(Card.OrzhovBasilica); | |
int flex = NumLands(Card.TempleOfSilence); | |
if (baseWhite > 0 || baseBlack > 0 || flex > 0) { | |
// only count Fetid Heaths if we can produce colored mana | |
// this implementation underestimates the fixing power of Fetid Heath (e.g. W => BB) | |
flex += NumLands(Card.FetidHeath); | |
} | |
if (flex == 0) { | |
manaPossibilities.Add(new ManaPossibility(baseWhite, baseBlack, land)); | |
} else { | |
for (int i = 0; i <= flex; i++) { | |
manaPossibilities.Add(new ManaPossibility(baseWhite + i, baseBlack + flex - i, land)); | |
} | |
} | |
} | |
public void PlayLand(Card land) { | |
if (land == Card.None) { | |
return; | |
} | |
landsInHand[(int)land]--; | |
landsInPlay[(int)land]++; | |
Program.Log(2, "Played {0}.", Program.CARD_NAMES[land]); | |
if (land == Card.TempleOfSilence) { | |
// Scry 1. (For simplicity's sake, the card is skipped rather than being put on the bottom of the deck.) | |
Card next = deck[deckIndex]; | |
bool bottom = false; | |
switch (next) { | |
case Card.None: | |
throw new Exception("Deck contains None card."); | |
case Card.Other: | |
bottom = true; | |
break; | |
case Card.HeartlessAct: | |
bottom = heartlessActInHand || ndeInPlay; | |
break; | |
case Card.LostAuramancers: | |
bottom = auramancersInHand || auramancersTimer > 0 || ndeInPlay; | |
break; | |
case Card.MischievousPoltergeist: | |
case Card.WallOfBlood: | |
bottom = leechInHand || leechInPlay; | |
break; | |
case Card.NearDeathExperience: | |
bottom = ndeInHand || ndeInPlay; | |
break; | |
default: | |
// TODO: More advanced land scry decisions. | |
bottom = landsInHand.Sum() + landsInPlay.Sum() >= 5; | |
break; | |
} | |
if (bottom) { | |
deckIndex++; | |
Program.Log(3, "Scried {0} to the bottom.", Program.CARD_NAMES[next]); | |
} else { | |
Program.Log(3, "Scried {0} to the top.", Program.CARD_NAMES[next]); | |
} | |
} else if (land == Card.OrzhovBasilica) { | |
// Choose which land to pick up. | |
if (LandIsInPlay(Card.FetidHeath)) { | |
PickUpLand(Card.FetidHeath); | |
} else if (landsInPlay[(int)Card.Plains] > landsInPlay[(int)Card.Swamp]) { | |
PickUpLand(Card.Plains); | |
} else if (landsInPlay[(int)Card.Plains] < landsInPlay[(int)Card.Swamp]) { | |
PickUpLand(Card.Swamp); | |
} else if (LandIsInPlay(Card.Plains)) { | |
PickUpLand(Card.Plains); | |
} else if (LandIsInPlay(Card.TempleOfSilence)) { | |
PickUpLand(Card.TempleOfSilence); | |
} else if (LandIsInPlay(Card.PelakkaCaverns)) { | |
PickUpLand(Card.PelakkaCaverns); | |
} else { | |
throw new Exception(string.Format("Couldn't decide which land to pick up: {0}", string.Join(", ", landsInPlay))); | |
} | |
} | |
Program.Log(4, "Lands in play: {0}.", string.Join(", ", landsInPlay)); | |
} | |
public bool LandIsInPlay(Card land) { | |
return landsInPlay[(int)land] > 0; | |
} | |
public int NumLands(Card land) { | |
return landsInPlay[(int)land]; | |
} | |
public void PickUpLand(Card land) { | |
landsInHand[(int)land]++; | |
landsInPlay[(int)land]--; | |
Program.Log(3, "Picked up {0}.", Program.CARD_NAMES[land]); | |
} | |
public bool ShouldPlayNDE() { | |
return ndeInHand && !ndeInPlay; | |
} | |
public bool ShouldPlayAuramancers() { | |
return auramancersInHand && auramancersTimer == 0 && !ndeInPlay; | |
} | |
public bool ShouldPlayLeech() { | |
return leechInHand && !leechInPlay; | |
} | |
public bool ShouldPlayHeartlessAct() { | |
return heartlessActInHand && auramancersTimer > 0 && !ndeInPlay; | |
} | |
} | |
enum Card : int { | |
// lands | |
FetidHeath = 2, | |
OrzhovBasilica = 4, | |
PelakkaCaverns = 5, | |
Plains = 0, | |
Swamp = 1, | |
TempleOfSilence = 3, | |
// combo pieces | |
HeartlessAct = 6, | |
LostAuramancers = 7, | |
MischievousPoltergeist = 8, | |
NearDeathExperience = 9, | |
WallOfBlood = 10, | |
// other | |
Other = 11, | |
None = 12 | |
} | |
struct ManaPossibility { | |
public int white, black, total; | |
public Card landPlayed; | |
public ManaPossibility(int white, int black, Card landPlayed) { | |
this.white = white; | |
this.black = black; | |
total = white + black; | |
this.landPlayed = landPlayed; | |
} | |
public ManaPossibility(ManaPossibility other, Card landPlayed) { | |
white = other.white; | |
black = other.black; | |
total = other.total; | |
this.landPlayed = landPlayed; | |
} | |
public bool HasAtLeast(int white, int black, int colorless) { | |
return this.white >= white && this.black >= black && (total >= (white + black + colorless)); | |
} | |
} | |
public static class ArrayExtensions { | |
private static Random random = new Random(); | |
public static T[] Shuffle<T>(this T[] array) { | |
int n = array.Length; | |
for (int i = 0; i < n; i++) { | |
int r = i + random.Next(n - i); | |
T t = array[r]; | |
array[r] = array[i]; | |
array[i] = t; | |
} | |
return array; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment