7 Startups - part 3 - An interpreter for the rules
This post is part of the "7 Startups" serie:
In the previous episode, I implemented the game rules, but did not test them. I also had some reservations about some code I wrote, but predicted it would be mostly right, even without tests. Today's episode is about pretty printing and operational !
Minor modifications since last time
- I refactored the
getCardFunding
andgetCardVictory
functions so that they are now pure. I toyed with the idea of having a monad morphism (I learned today it was called like that to integrateReader GameState
actions in theMonadState GameState
functions, but this was not warranted as the functions are so simple. - I refactored neighborhood relationship so that it encodes more invariants. A player now must have a left and right neighbor. They might be invalid though.
- I refactored the type of the interfaces between the game rules and the players, so that you can't pass empty lists where they are forbidden. I was later told this type already existed in semigroups.
Why pretty printing ?
I hinted heavily last time that there would be a dedicated pretty printer. An example of such an implementation is in the ansi-wl-pprint package. It introduces functions and combinators that let you easily create a Doc
value that will look neat on your screen.
Unfortunately, in order to properly support all text-based backends (IRC, XMPP, email, console) it doesn't seem to be possible to reuse an existing printer. For example, the color set between all these backends is quite distinct, and some are even capable of printing pictures. I tried to engineer one that would be at the same time flexible, easy to use and good-looking an all backends. Time will tell if this was a success.
I will not give a dissertation on the subject, and have copied the interface from other pretty printing libraries. I will just give some implementation details here.
Basic pretty printing types
Speaking of stealing from other pretty printers, I really should have looked at their code too ! Here are my basic types:
newtype PrettyDoc = PrettyDoc { getDoc :: Seq PrettyElement }
deriving (Eq, Monoid)
data PrettyElement = RawText T.Text
| NewLine
| Space
| ...
So you basically have all "elements" in PrettyElement
, and they can be appended in a monoidal fashion in a PrettyDoc
, which is just a newtype
for Seq PrettyElement
. This is a very inelegant decision, and I will be sure to refactor it for the next episode ! Looking at another implementation, it is clear that a single type was required, and that the Monoidal structure could be achieved by adding Empty
and Cat
constructors. There is a reason I wrote my type like this though, and it is related to how I intended to solve the problem of backends with poor or no support for multiline messages, but this will featured in another episode !
Specific design choices
I decided to directly encode the game entities as part of the pretty printing types. That should be obvious from the list of elements. A VictoryPoint
, a Neighbor
or even a CardType
are directly representable, so that the backends can optimize their rendering.
Other than that, the code is pretty boring.
A pretty-pretty printer ?
My first backend will be the console, as it will not have any networking or concurrency problems to solve. I used the aforementioned ansi-wl-pprint package, and wrote a pretty instance for PrettyElement
and PrettyDoc
. This leads to strange code such as print (PP.pretty (pe something))
.
Implementing the GameMonad
During the last episode, I wrote all the rules in an abstract monad that is an instance of GameMonad
, meaning it featured a few functions for interacting with the players. I took a typeclass approach so that I could start writing the rules without worrying about the actual implementation of this abstract monad.
Now that the rules are written, it is time to give them a try. In order to do so, I ditched the typeclass, and expressed it in terms of ProgramT
, from the operational package. It only takes a few steps to refactor :
The instructions GADT
You must start by writing all the operations that must be supported as a GADT.
We previously had :
type NonInteractive m = (MonadState GameState m,
Monad m,
MonadError Message m,
Functor m,
Applicative m)
class NonInteractive m => GameMonad m where
playerDecision :: Age -> Turn -> PlayerId -> [Card] -> GameState -> m (PlayerAction, Exchange)
askCard :: Age -> PlayerId -> [Card] -> GameState -> Message -> m Card
tellPlayer :: PlayerId -> Message -> m ()
generalMessage :: Message -> m ()
And now have :
data GameInstr a where
PlayerDecision :: Age -> Turn -> PlayerId -> NonEmpty Card -> GameInstr (PlayerAction, Exchange)
AskCard :: Age -> PlayerId -> NonEmpty Card -> Message -> GameInstr Card
TellPlayer :: PlayerId -> Message -> GameInstr ()
GeneralMessage :: Message -> GameInstr ()
ActionsRecap :: M.Map PlayerId (PlayerAction, Exchange) -> GameInstr ()
ThrowError :: Message -> GameInstr a
CatchError :: GameMonad a -> (Message -> GameMonad a) -> GameInstr a
So ... there have been some choices going on here. First of all, we need to support all the features we previously had, namely MonadState
, MonadError
and four game-specific functions. You can spot these four functions quite easily (along with a new one, which will be covered in a minute). We get MonadState
and MonadError
in the following way :
type GameMonad = ProgramT GameInstr (State GameState)
instance MonadError PrettyDoc (ProgramT GameInstr (State GameState)) where
= singleton . ThrowError
throwError = singleton (CatchError a handler) catchError a handler
I decided to use the monad transformer ProgramT
over a base State GameState
monad, but encode the error part with the provided instructions. It would have been easier to encode the state part that way, except I don't know how to write an instance for ProgramT
(see this post comment).
The interaction functions no longer have a GameState
in their types, because the interpreter will have access to the state when decoding this instruction, so it is not necessary to pass it here too.
Mechanically refactor all mentions of GameMonad
Now all you have to do is to replace all type signatures that looked like :
GameMonad m => a -> b -> m c
Into :
-> b -> GameMonad c a
Write an interpreter
I decided to write a generic interpreter, that takes a group of functions in some monad m
, a base GameState
, and gives you a function that computes any GameMonad a
expression in the m
monad. The implementation is pretty obvious, and not very interesting, but it should be easy to write backends now.
Perhaps of interest is the fact that the game state is explicitly passed as a parameter all over the place, so it can be passed to the backends at the interpreter level.
A pure backend
The easiest backend to write is a pure one, with no real player interaction. I could have used Identity
as the base monad, but instead opted for State StdGen
. That way, I can easily have the "players" play random cards, which will help with testing.
The implementation is also nothing special, but made me write a lot of code to support it. In particular, the allowableActions
function is pretty tricky, and is not entirely satisfying. Given a game state, a player name and a list of cards in his hands, it gives a list of all the non obviously stupid legal actions that are available. It does so in the most direct way, enumerating all possible combinations of resources, neighbor resources, exchanges, etc. that would work. Then it removes all duplicates, and the actions that are obviously too expensive.
Fortunately, all this code will also be used by the other backends.
So ... are there bugs yet ?
I wrote a simple test that checks for errors. Theoretically, the pure backend should always result in games that end well (we should get a Right ...
instead of a Left rr
. So I wrote a simple property-based test that gets an arbitrary seed and number of players (between 3 and 7), runs a pure game and checks its result.
And there were runtime errors !
- The
Monoid
instance forAddMap
had an infinite loop. - The
allowableActions
function sometimes returned no valid actions. I forgot to always add the possibility to drop a card ...
To prevent the second case from happening again, I wrote the "prepend drop actions" before the big case statement, and modified the type of the askCardSafe
function so that it can't accept an empty list. This means that if I introduce another bug in allowableActions
, I should get a Left ...
instead of a runtime exception.
There also was a "rule" bug, due to the fact that I had not understood a rule correctly. Basically, I use a fictional 7Th round to emulate the efficiency capability, but there should be no "hand rotation" before that turn. I fixed it wrong once, and then properly. However, I did not discover nor fix this bug because of tests.
The console backend
Before writing the console backend I needed a bit of code for pretty printing stuff. Once this was done, the backend was quickly written.
The opponents still play randomly, which explains the kind of results depicted below, but it is a genuine pleasure to finally play !
I also realized when using the console backends that the messaging functions, while generic, would probably not work well on all backends. I decided to include more specialized functions, such as ActionsRecap
, which can be passed a map of all the actions the players undertook in a turn. The current version also lacks a way of getting the results of the poacher wars between the ages, but that should be trivial to add.
Next time
Next time should get more interesting, as I will try to write an interesting backend. It will be a bit harder to design because I want players using distinct backends to be able to participate in the same game.