Skip to content

Instantly share code, notes, and snippets.

@thquinn
Last active December 8, 2020 18:15
Show Gist options
  • Save thquinn/ac4b94c896e91efc2943076f9c00615e to your computer and use it in GitHub Desktop.
Save thquinn/ac4b94c896e91efc2943076f9c00615e to your computer and use it in GitHub Desktop.
Runs simplified goldfish games of Penny Dreadful Near-Death Experience Combo.
// 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