在Haskell中,如何用语言的方式表示纸牌游戏的卡牌效果?

5

我有一个简单的单人纸牌游戏:

data Player = Player {
    _hand :: [Card],
    _deck :: [Card],
    _board :: [Card]}
$(makeLenses ''Player)

一些卡牌具有效果。例如,“Erk”是一张具有以下效果的卡牌:

Flip a coin. If heads, shuffle your deck.

我已经按照以下方式实施了它:
shuffleDeck :: (MonadRandom m, Functor m) => Player -> m Player
shuffleDeck = deck shuffleM

randomCoin :: (MonadRandom m) => m Coin
randomCoin = getRandom

flipCoin :: (MonadRandom m) => m a -> m a -> m a
flipCoin head tail = randomCoin >>= branch where
    branch Head = head
    branch Tail = tail

-- Flip a coin. If heads, shuffle your deck.
erk :: (MonadRandom m, Functor m) => Player -> m Player
erk player = flipCoin (deck shuffleM player) (return player)

虽然这样做确实可以完成任务,但我发现强制耦合到Random库有问题。如果我以后有一张卡片依赖于另一个单子,那么我将不得不重写到目前为止定义的每一张卡片的定义(使它们具有相同的类型)。我更喜欢一种完全独立于Random(和其他任何东西)描述游戏逻辑的方法。就像这样:

erk :: CardAction
erk = do
    coin <- flipCoin
    case coin of
        Head -> shuffleDeck
        Tail -> doNothing

后来,我可以编写一个名为runGame的函数来进行连接。

runGame :: (RandomGen g) => g -> CardAction -> Player -> Player

我不确定那会有帮助。对于这个模式,正确的语言处理方式是什么?


1
MonadRandom 不是一个单子(monad),而是一个支持随机数生成的单子类型类(type class for monads)。您可以在广泛的单子变换堆栈中使用 erk 而不进行更改。 - András Kovács
2个回答

5
这是mtl库旨在解决的工程问题之一。看起来你已经在使用它,但还没有意识到其全部潜力。
这个想法是使用类型类使单子变换器堆栈更易于处理。普通单子变换器堆栈的问题是,当你编写函数时必须知道所有你正在使用的变换器,并且更改变换器堆栈会改变lifts的工作方式。mtl通过为每个变换器定义一个类型类来解决了这个问题。这允许您编写具有对每个所需变换器的类约束的函数,但可以在包括至少那些变换器的任何变换器堆栈上工作。
这意味着你可以自由地编写带有不同约束集的函数,然后在你的游戏单子中使用它们,只要你的游戏单子具有至少这些功能。
例如,你可以有
erk  :: MonadRandom m => ...
incr :: MonadState GameState m => ...
err  :: MonadError GameError m => ...
lots :: (MonadRandom m, MonadState GameState m) => ...

并定义您的游戏a类型以支持所有这些功能:

type Game a = forall g. RandT g (StateT GameState (ErrorT GameError IO)) a

Game中,你可以将这些类型互换使用,因为Game属于所有这些类型类。此外,如果你想要添加更多功能,你只需更改Game的定义,而无需更改其他任何内容。

需要记住一个重要限制:您只能访问每个transformer的一个实例。这意味着您在整个堆栈中只能有一个StateT和一个ErrorT。这就是为什么StateT使用自定义GameState类型的原因:您可以将可能想要存储在整个游戏中的不同东西都放入该类型中,以便您只需要一个StateT。(GameError也为ErrorT执行相同操作。)

对于像这样的代码,当您定义函数时,只需直接使用Game类型即可:

flipCoin :: Game a -> Game a -> Game a
flipCoin a b = ...

由于getRandom是对m本身进行类型多态化的,只要Game内部至少有一个RandT(或等价物),它就可以正常工作。
因此,为了回答你的问题,你可以依靠现有的mtl类型类来处理这个问题。所有的基本操作,如getRandom,都是对它们的monad进行多态化,所以它们将与最终得到的任何堆栈一起工作。只需将所有转换器包装成自己的类型(Game),就可以完成所有设置。

嗨!很有趣,谢谢。但是我没有预料到的一件事是:如果我以后想让人工智能控制玩家的行为怎么办?也就是说 - 假设我定义了一个依赖于玩家选择的卡牌效果,如 (MonadIO m) => Player -> m Player。这不会使得卡牌在没有人类在控制台上输入 IO 的情况下无法使用吗? - MaiaVictor
@Viclib:如果卡片的定义内部使用了类似于getLine的东西,那么是的,它将与控制台IO绑定。你可以将AI作为一个外部程序,以与玩家完全相同的方式进行通信,但这很麻烦且限制性强。我可能可以考虑一些更好的组织代码的方法,但这需要一些时间,而且在评论中讨论不太合适。我建议你就这个具体问题提出另一个问题。 - Tikhon Jelvis
好的。谢谢!再次感谢,我认为两个答案都是正确的,但是SO不允许我签署两个。我想我应该在Meta上开一个问题。 - MaiaVictor

3
这似乎是使用operational包的好例子。它允许您使用GADT定义一组操作及其返回类型作为monad,并且您可以轻松构建解释器函数,例如您建议的runGame函数。例如:
{-# LANGUAGE GADTs #-}

import Control.Monad.Operational
import System.Random

data Player = Player {
    _hand :: [Card],
    _deck :: [Card],
    _board :: [Card]}

data Coin = Head | Tail

data CardOp a where
    FlipCoin    :: CardOp Coin
    ShuffleDeck :: CardOp ()

type CardAction = Program CardOp

flipCoin :: CardAction Coin
flipCoin = singleton FlipCoin

shuffleDeck :: CardAction ()
shuffleDeck = singleton ShuffleDeck

erk :: CardAction ()
erk = do
    coin <- flipCoin
    case coin of
        Head -> shuffleDeck
        Tail -> return ()

runGame :: RandomGen g => g -> CardAction a -> Player -> Player
runGame = step where
    step g action player = case view action of
        Return _ -> player
        FlipCoin :>>= continue ->
            let (heads, g') = random g 
                coin = if heads then Head else Tail
            in  step g' (continue coin) player
        ...etc...

不过,你可能还希望考虑将所有的卡片行为描述为一个简单的 ADT,而不需要使用 do-syntax。即:

data CardAction
    = CoinFlip CardAction CardAction
    | ShuffleDeck
    | DoNothing

erk :: CardAction
erk = CoinFlip ShuffleDeck DoNothing

您可以轻松地为ADT编写一个解释器,并作为奖励,您还可以自动生成卡片规则文本等内容。


这是一个很棒的答案!我仍在努力寻找这种方法与自由单子之间的关系。 - MaiaVictor
自由单子本质上与操作单子同构。我个人认为操作包更容易解释和使用。 - shang

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接