dovin-0.2: mtgtc/MTGTC.hs
{-# LANGUAGE TemplateHaskell #-}
-- https://arxiv.org/abs/1904.09828
module MTGTC where
import Dovin.V2
import Dovin.Prelude
import Dovin.Monad
import Data.List (intercalate)
import Control.Lens
import qualified Data.List
import qualified Data.Set
import qualified Data.Ord
import qualified Data.Map as M
import Data.List.Split (splitOneOf)
alice = Opponent
bob = Active
-- Indicates that a card's color text has been changed by Glamerdye
colorHacked = "color-hacked"
phasing = "phasing"
phasedOut = "phasedOut"
shroud = "shroud"
aetherborn = "aetherborn"
basilisk = "basilisk"
cephalid = "cephalid"
demon = "demon"
elf = "elf"
faerie = "fairie"
giant = "giant"
harpy = "harpy"
illusion = "illusion"
juggernaut = "juggernaut"
kavu = "kavu"
leviathan = "leviathan"
myr = "myr"
noggle = "noggle"
orc = "orc"
pegasus = "pegasus"
rhino = "rhino"
sliver = "sliver"
lyurgoyf = "lyurgoyf"
rat = "rat"
tapeTypes =
[ aetherborn
, basilisk
, cephalid
, demon
, elf
, faerie
, giant
, harpy
, illusion
, juggernaut
, kavu
, leviathan
, myr
, noggle
, orc
, pegasus
, rhino
, sliver
, assassin
, lyurgoyf
, rat
]
unicodeMappings = M.fromList
[ (aetherborn, "1 ")
, (basilisk, "1⃗ ")
, (cephalid, "1⃖ ")
, (demon, "1⃗₁")
, (elf, "1⃖₁")
, (faerie, "b ")
, (giant, "b⃗ ")
, (harpy, "b⃖ ")
, (illusion, "b⃗₁")
, (juggernaut, "b⃖₁")
, (kavu, "b₂")
, (leviathan, "b₃")
, (myr, "c ")
, (noggle, "c⃗ ")
, (orc, "c⃖ ")
, (pegasus, "c⃗₁")
, (rhino, "c⃖₁")
, (sliver, "c₂")
, (assassin, "!!")
, (lyurgoyf, "L ")
, (rat, "R ")
]
assassin = "assassin"
assemblyWorker = "assembly-worker"
white = "white"
blue = "blue"
black = "black"
red = "red"
green = "green"
allColors = [white, blue, black, red, green]
extractColors = Data.Set.toList . Data.Set.intersection (Data.Set.fromList [white, blue, black, red, green]) . view cardAttributes
castWithWildEvocation = castNoRestrictions ""
castNoRestrictions mana name = do
card <- requireCard name mempty
spendMana mana
modifyCard (location . _2) (const Stack) name
modifying
stack
((:) name)
when
(hasAttribute sorcery card || hasAttribute instant card) $
modifying
(counters . at storm . non 0)
(+ 1)
draw :: CardName -> GameMonad ()
draw expectedName = do
active <- view envActor
s <- use $ deck . at active . non mempty
case s of
[] -> throwError $ "deck is empty, expecting " <> expectedName
(name:cs) -> do
unless (name == expectedName) $
throwError $ "unexpected top of deck: expected "
<> expectedName
<> ", got "
<> name
moveTo Hand expectedName
modifying
(deck . at alice . non mempty)
(drop 1)
mill :: CardName -> GameMonad ()
mill expectedName = do
active <- view envActor
s <- use $ deck . at active . non mempty
case s of
[] -> throwError $ "deck is empty, expecting " <> expectedName
(name:cs) -> do
unless (name == expectedName) $
throwError $ "unexpected top of deck: expected "
<> expectedName
<> ", got "
<> name
moveTo Graveyard expectedName
modifying
(deck . at active . non mempty)
(drop 1)
drawTop = do
active <- view envActor
s <- use $ deck . at active . non mempty
case s of
[] -> throwError $ "deck is empty"
(name:cs) -> do
moveTo Hand name
modifying
(deck . at active . non mempty)
(drop 1)
phaseCards owner = do
forCards (matchAttribute phasing <> matchController owner) $ \cn -> do
c <- requireCard cn mempty
if hasAttribute phasedOut c then
loseAttribute phasedOut cn
else
gainAttribute phasedOut cn
findM :: (a -> GameMonad ()) -> [a] -> GameMonad a
findM f [] = throwError "No matching element"
findM f (x:xs) = (f x >> return x) `catchError` (\_ -> findM f xs)
-- Technically the card should never hit the graveyard, but that's not relevant
-- in this proof. Instead, move from graveyard to bottom of deck.
wheelOfSunAndMoon name = do
active <- view envActor
validate (matchLocation (active, Graveyard)) name
moveTo Deck name
modifying
(deck . at active . non mempty)
(\xs -> xs ++ [name])
lookupSingleCard :: CardMatcher -> GameMonad Card
lookupSingleCard matcher = do
matchingCs <- lookupCards matcher
case matchingCs of
[] -> throwError $ "No matches: " <> show matcher
[x] -> return x
xs -> throwError $ "Ambigious match: " <> (intercalate ", " . map (view cardName) $ xs)
lookupCards :: CardMatcher -> GameMonad [Card]
lookupCards matcher = do
cs <- allCards
return $ filter (applyMatcher matcher) cs
whenNotHalted m = do
halted <- gameFinished
unless halted m
whenState f m = do
result <- (f >> pure True) `catchError` (const $ pure False)
when result m
data State = Q1 | Q2 deriving (Show, Eq)
data Rule = Rule
{ _ruleState :: State
, _ruleNumber :: Int
, _ruleTrigger :: CardAttribute
, _ruleAttributes :: [CardAttribute]
}
makeLenses ''Rule
mkRule s n trigger attributes = Rule
{ _ruleState = s
, _ruleNumber = n
, _ruleTrigger = trigger
, _ruleAttributes = attributes
}
instance Show Rule where
show = triggeringCreature
triggeringCreature rule =
let baseCreature = if tapped `elem` view ruleAttributes rule then
"Xathrid Necromancer"
else
"Rotlung Reanimator"
in
baseCreature <> " " <> show (view ruleState rule) <> " " <> show (view ruleNumber rule)
rules =
[ mkRule Q1 1 aetherborn [sliver, white]
, mkRule Q1 2 basilisk [green, elf]
, mkRule Q1 3 cephalid [sliver, white]
, mkRule Q1 4 demon [green, aetherborn]
, mkRule Q1 5 elf [white, demon]
, mkRule Q1 6 faerie [green, harpy]
, mkRule Q1 7 giant [green, juggernaut]
, mkRule Q1 8 harpy [white, faerie]
, mkRule Q1 9 illusion [green, faerie]
, mkRule Q1 10 juggernaut [white, illusion]
, mkRule Q1 11 kavu [white, leviathan, tapped]
, mkRule Q1 12 leviathan [white, illusion, tapped]
, mkRule Q1 13 myr [white, basilisk, tapped]
, mkRule Q1 14 noggle [green, orc]
, mkRule Q1 15 orc [white, pegasus]
, mkRule Q1 16 pegasus [green, rhino, tapped]
, mkRule Q1 17 rhino [assassin, blue]
, mkRule Q1 18 sliver [green, cephalid]
, mkRule Q2 1 aetherborn [green, cephalid]
, mkRule Q2 2 basilisk [green, cephalid]
, mkRule Q2 3 cephalid [white, basilisk]
, mkRule Q2 4 demon [green, elf]
, mkRule Q2 5 elf [white, aetherborn]
, mkRule Q2 6 faerie [green, kavu, tapped]
, mkRule Q2 7 giant [green, harpy]
, mkRule Q2 8 harpy [white, giant]
, mkRule Q2 9 illusion [green, juggernaut]
, mkRule Q2 10 juggernaut [white, giant]
, mkRule Q2 11 kavu [green, faerie, tapped]
, mkRule Q2 12 leviathan [green, juggernaut]
, mkRule Q2 13 myr [green, orc]
, mkRule Q2 14 noggle [green, orc]
, mkRule Q2 15 orc [white, noggle]
, mkRule Q2 16 pegasus [green, sliver]
, mkRule Q2 17 rhino [white, sliver, tapped]
, mkRule Q2 18 sliver [white, myr]
]
mappings = M.fromList
. map (\x -> (head x, x))
. Data.List.delete rat
. Data.List.delete lyurgoyf
. Data.List.delete assassin
$ tapeTypes
charToType :: Char -> GameMonad CardAttribute
charToType c = do
case M.lookup c mappings of
Nothing -> throwError ("Unknown tape symbol: " <> [c])
Just x -> return x
encodeTape :: String -> GameMonad ()
encodeTape xs = do
let [ls, (c:_), rs] = splitOneOf "[]" xs
forM_ (zip (reverse ls) [3..]) $ \(l, s) -> do
t <- charToType l
withAttributes [token, green, t] $ addCreature (s, s) ("Initial L" <> show s)
t <- charToType c
withAttributes [token, t] $ addCreature (2, 2) ("Initial C")
forM_ (zip rs [3..]) $ \(l, s) -> do
t <- charToType l
withAttributes [token, white, t] $ addCreature (s, s) ("Initial R" <> show s)
let n = length ls + 2
withAttributes [green, lyurgoyf, token] $ addCreature (n, n) "Lyurgoyf Left Tape"
let n = length rs + 2
withAttributes [white, rat, token] $ addCreature (n, n) "Rat Right Tape"
setup :: String -> GameMonad ()
setup tape = do
transitionToForced Untap
as alice $ do
withLocation Hand $ do
addSorcery "Infest"
withLocation Deck $ do
addInstant "Cleansing Beam"
addSorcery "Coalition Victory"
withAttribute black $ addCreature (3, 3) "Soul Snuffers"
assign
(deck . at alice)
(Just ["Cleansing Beam", "Coalition Victory", "Soul Snuffers"])
withLocation Play $ do
addArtifact "Mesmeric Orb"
addAura "Illusory Gains"
withAttributes allColors $ addLand "Island"
withAttribute green $ addEnchantment "Choke"
-- TODO: This should be (2, 2), but then dread of night/infest kills it?
withAttributes [red, green, white, black, assemblyWorker] $ addCreature (5, 5) "Fungus Sliver"
withEffect
matchInPlay
(pure $ matchAttribute creature <> matchAttribute assemblyWorker)
(pure . setAttribute shroud)
$ addEnchantment "Steely Resolve"
as bob $ do
withLocation Play $ do
encodeTape tape
-- "[p]fr"
--encodeTape "" 's' "sr"
--encodeTape "crrffafafaffaffaaaaaaafaaaaaf" 'f' "amamamc"
addEnchantment "Wild Evocation"
withEffect
matchInPlay
(const $ matchAttribute creature <> matchAttribute black)
(pure . over cardStrengthModifier (mkStrength (-1, -1) <>))
$ withAttribute colorHacked
$ addEnchantment "Dread of Night 1"
withEffect
matchInPlay
(const $ matchAttribute creature <> matchAttribute black)
(pure . over cardStrengthModifier (mkStrength (-1, -1) <>))
$ withAttribute colorHacked
$ addEnchantment "Dread of Night 2"
withEffect
matchInPlay
(const $ matchAttribute creature <> matchAttribute rat)
(pure . over cardStrengthModifier (mkStrength (1, 1) <>))
$ addEnchantment "Shared Triumph 1"
withEffect
matchInPlay
(const $ matchAttribute creature <> matchAttribute lyurgoyf)
(pure . over cardStrengthModifier (mkStrength (1, 1) <>))
$ addEnchantment "Shared Triumph 2"
-- TODO: setup this color pallete from black
forM_ rules $ \rule -> do
let name = triggeringCreature rule
let extraAttributes = if view ruleState rule == Q2 then
[phasedOut]
else
[]
withAttributes [red, green, black, white]
$ withAttributes (phasing:extraAttributes)
$ addCreature (2, 2) name
withEffect
matchInPlay
(matchLocation . view cardLocation)
(pure . setAttribute hexproof)
$ addEnchantment "Privileged Position"
stepCompute :: Int -> GameMonad ()
stepCompute n = do
runLoop n
runLoop n = do
turn1 n
turn2 n
whenState (validate (matchLocation (alice, Hand)) "Coalition Victory") $ do
turn3 n
whenNotHalted $ do
turn4 n
turnStep c n l = step ("Cycle " <> show c <> ", Turn " <> show n <> ": " <> l)
turn1 n = do
turnStep n 1 "Alice: Untap" $ do
transitionToForced Untap
phaseCards alice
deadToken <- turnStep n 1 "Upkeep: Infest" $ do
transitionTo Upkeep
as bob $ do
trigger "Force cast" "Wild Evocation" >> resolveTop
as alice $ do
_ <- lookupSingleCard (matchLocation (alice, Hand) <> matchName "Infest")
castWithWildEvocation "Infest" >> resolveTop
wheelOfSunAndMoon "Infest"
forCards (matchInPlay <> matchAttribute creature) $
modifyCard cardStrengthModifier (mkStrength (-2, -2) <>)
lookupSingleCard (matchInPlay <> matchAttribute creature <> matchLesserPower 1)
deadToken' <-
if (hasAttribute rat deadToken) then
do
-- Handle infinite tape on right side
-- TODO: Trigger and resolve things, don't just create creatures
as bob $ do
-- TODO: Illusory gains, though shouldn't be relevant
withLocation Play $
withAttributes [rat, white, token] $ addCreature (2, 2) ("Rat " <> show n)
as alice $ do
withLocation Play $
withAttributes [cephalid, black, token] $ addCreature (2, 2) ("Cephalid " <> show n)
requireCard ("Cephalid " <> show n) (matchLesserPower 1)
else if (hasAttribute lyurgoyf deadToken) then
do
-- Handle infinite tape on right side
-- TODO: Trigger and resolve things, don't just create creatures
as bob $ do
-- TODO: Illusory gains, though shouldn't be relevant
withLocation Play $
withAttributes [lyurgoyf, green, token] $ addCreature (2, 2) ("Lyurgoyf " <> show n)
as alice $ do
withLocation Play $
withAttributes [cephalid, black, token] $ addCreature (2, 2) ("Cephalid " <> show n)
requireCard ("Cephalid " <> show n) (matchLesserPower 1)
else
return deadToken
let matchingRules =
filter
(\rule -> hasAttribute (view ruleTrigger rule) deadToken')
rules
when (null matchingRules) $
throwError ("Unknown card died: " <> formatCard deadToken)
rule <- findM
(validate (missingAttribute phasedOut) . triggeringCreature)
matchingRules
let c = triggeringCreature rule
turnStep n 1 ("Action: " <> show rule) $ do
as bob $ do
trigger (c <> " Trigger") c >> resolveTop
withLocation Play
$ withAttributes (token : view ruleAttributes rule)
$ addCreature (2, 2) ("Token " <> show n)
turnStep n 1 "Illusory Gains" $ do
as alice $ do
trigger "Steal" "Illusory Gains" >> resolveTop
-- TODO: Better tracking of what Illusory Gains is attached to
forCards (matchController alice <> matchAttributes [creature, token] <> matchInPlay) $
move (alice, Play) (bob, Play)
move (bob, Play) (alice, Play) ("Token " <> show n)
turnStep n 1 "Draw" $ do
transitionTo DrawStep
as alice $ draw "Cleansing Beam"
-- TODO: Validate alice can't do anything
turnStep n 1 "EoT: Undo infest -2/-2" $ do
forCards (matchInPlay <> matchAttribute creature) $
modifyCard cardStrengthModifier (const $ mkStrength (0, 0))
turnStep n 1 "Bob: Untap and Phase" $ do
phaseCards bob
turn2 n = do
turnStep n 2 "Alice Untap" $ do
transitionToForced Untap
phaseCards alice
as alice $ do
tappedCard <- lookupCards (matchController alice <> matchAttribute tapped)
case tappedCard of
[] -> return ()
[c] -> do
let cn = view cardName c
untap cn
-- No priority in untap phase, to this doesn't technically go on stack until Upkeep
trigger "Mill" "Mesmeric Orb"
cs -> throwError $ "Too many tapped cards: " <> intercalate ", " (map (view cardName) cs)
turnStep n 2 "Upkeep: Cleansing Beam" $ do
transitionTo Upkeep
as bob $ do
trigger "Force cast" "Wild Evocation" >> resolveTop
as alice $ do
_ <- lookupSingleCard
( matchLocation (alice, Hand)
<> matchName "Cleansing Beam"
)
castWithWildEvocation "Cleansing Beam" >> resolveTop
wheelOfSunAndMoon "Cleansing Beam"
card <- lookupSingleCard
( matchInPlay
<> matchAttribute creature
<> missingAttribute shroud
<> missingAttribute hexproof
)
target (view cardName card)
forCards (
matchAttribute creature
<> (foldl (\b a -> matchAttribute a `matchOr` b) (invert mempty) (extractColors card))
) $ \cn -> do
-- TODO: Check for vigor
modifyCard cardPlusOneCounters (+ 2) cn
turnStep n 2 "Mill from Mesmeric Orb (if triggered)" $ do
s <- use $ stack
as alice $ do
when (not . null $ s) $ do
resolve "Mill"
mill "Coalition Victory"
wheelOfSunAndMoon "Coalition Victory"
turnStep n 2 "Alice Draw" $ do
transitionTo DrawStep
as alice $ drawTop
turnStep n 2 "Bob: Untap and Phase" $ do
phaseCards bob
turn3 n = do
turnStep n 3 "Upkeep: Coalition Victory" $ do
transitionToForced Untap
phaseCards alice
transitionTo Upkeep
as bob $ do
trigger "Force cast" "Wild Evocation" >> resolveTop
as alice $ do
_ <- lookupSingleCard
( matchLocation (alice, Hand)
<> matchName "Coalition Victory"
)
castWithWildEvocation "Coalition Victory" >> resolveTop
matches <-
sequence
. map (\(c, t) -> (not . null) <$> lookupCards (matchController alice <> matchAttributes [c, t]))
$ [(x, y) | x <- allColors, y <- [creature, land]]
if (Prelude.all id matches) then
throwError "won game"
else
wheelOfSunAndMoon "Coalition Victory"
whenNotHalted $ do
turnStep n 3 "Alice Draw" $ do
transitionTo DrawStep
as alice $ draw "Soul Snuffers"
turnStep n 3 "Bob: Untap and Phase" $ do
phaseCards bob
turn4 n = do
turnStep n 4 "Upkeep: Soul Snuffers" $ do
transitionToForced Untap
phaseCards alice
transitionTo Upkeep
as bob $ do
trigger "Force cast" "Wild Evocation" >> resolveTop
as alice $ do
_ <- lookupSingleCard
( matchLocation (alice, Hand)
<> matchName "Soul Snuffers"
)
castWithWildEvocation "Soul Snuffers" >> resolveTop
withStateBasedActions $ do
forCards (matchInPlay <> matchAttribute creature) $
modifyCard cardMinusOneCounters (+ 1)
wheelOfSunAndMoon "Soul Snuffers"
turnStep n 4 "Alice Draw" $ do
transitionTo DrawStep
as alice $ draw "Infest"
turnStep n 4 "Bob: Untap and Phase" $ do
phaseCards bob
matchAny = foldl (\b a -> a `matchOr` b) (invert mempty)
tapeFormatter :: Formatter
tapeFormatter board =
let f matcher = Data.List.sortBy (Data.Ord.comparing tapePosition)
. filter (applyMatcher $ matcher <> matchAny (map matchAttribute tapeTypes))
$ cs in
let leftCs = f (matchAttribute green <> invert (matchToughness 2)) in
let rightCs = f (matchAttribute white <> invert (matchToughness 2)) in
let centerCs = f (matchToughness 2) in
let tapeValid = length centerCs == 1
&& contiguous (map (view cardToughness) leftCs)
&& contiguous (map (view cardToughness) rightCs)
&& (null leftCs || minimum (map (view cardToughness) leftCs) == 3)
&& (null rightCs || minimum (map (view cardToughness) rightCs) == 3)
in
let tapeWithHead = map extractSymbol (reverse leftCs) <> "[" <> map extractSymbol centerCs <> "]" <> map extractSymbol rightCs in
if tapeValid then
tapeWithHead
else
""
where
cs = let Right value = execMonad board allCards in value
tapePosition c = view cardPower c
stateFormatter :: Formatter
stateFormatter board =
let expr = requireCard "Rotlung Reanimator Q1 1" mempty in
let Right value = execMonad board expr in
if hasAttribute phasedOut value then
"Q2: "
else
"Q1: "
tapeFormatter2 :: Formatter
tapeFormatter2 board =
let f matcher = Data.List.sortBy (Data.Ord.comparing tapePosition)
. filter (applyMatcher $ matcher <> matchAny (map matchAttribute tapeTypes))
$ cs in
let leftCs = f (matchAttribute green <> invert (matchToughness 2)) in
let rightCs = f (matchAttribute white <> invert (matchToughness 2)) in
let centerCs = f (matchToughness 2) in
let tapeValid = length centerCs == 1
&& contiguous (map (view cardToughness) leftCs)
&& contiguous (map (view cardToughness) rightCs)
in
let tapeWithHead =
formatSymbols (reverse leftCs) <> ">" <>
formatSymbols centerCs <> " " <>
formatSymbols rightCs in
if tapeValid then
tapeWithHead
else
"\ntape invalid"
where
cs = let Right value = execMonad board allCards in value
tapePosition c = view cardPower c
formatSymbols = intercalate " " . map extractSymbol2
extractSymbol c = if hasAttribute assassin c then 'H' else head . head . Data.Set.toList $ (Data.Set.fromList tapeTypes) `Data.Set.intersection` (view cardAttributes c)
extractSymbol2 :: Card -> String
extractSymbol2 c =
case M.lookup relevantAttribute unicodeMappings of
Nothing -> "? "
Just x -> x
where
relevantAttribute =
head
. Data.Set.toList
$ (Data.Set.fromList tapeTypes)
`Data.Set.intersection`
(view cardAttributes c)
matchOwner :: Player -> CardMatcher
matchOwner x = CardMatcher ("owner " <> show x) $
(==) x . fst . view cardLocation
contiguous xs = Prelude.all (\(x, y) -> y - x == 1) $ zip xs (tail xs)
deckFormatter actor board = "\n deck: " <> intercalate "" (map (\cn -> "\n " <> cn) (view (deck . at actor . non mempty) board))
formatter step = case view stepNumber step of
_ -> tapeFormatter
-- <> cardFormatter "tape (bob)" (matchAny (map matchAttribute tapeTypes) <> matchOwner bob)
-- <> cardFormatter "tape (alice)" (matchAny (map matchAttribute tapeTypes) <> matchOwner alice)
-- <> deckFormatter alice