This program implements a rules engine for Tamerlane, the card game server. It provides a working version of the game Bid Whist, as well as an example implementation of a realistically complex rules engine.
This is a literate program which describes the full implementation of a rules engine for Tamerlane. In it is contained all the source code required to compile and run a web server which implements Tamerlane's API contract.
This code is written using the Literate program, which takes a literate source document and renders the document you are reading, as well as the source files needed to compile the program. The source files are available at sourcehut.
The language that I've implemented this software in is Janet,
a lightweight lisp. To thoroughly understand the implementation it
might be good to know Janet, or at least a language like Clojure or
Scheme. To be able to follow along and understand what you need to
implement in order to write your own rules engine (which is the
purpose of this document), you don't need to know Janet. You just need
to understand some basic Lisp control forms like let
and defn
, and
to understand how to read an s-expression.
You should be able to read this document front-to-back, like a book. I've tried to focus on the concepts that are central to the function of the Tamerlane rules engine concept. Therefore, the bulk of this document is concerned with writing the business logic that defines the rules to the game Bid Whist. Also important is understanding the specifics of the endpoints and HTTP methods that are expected by Tamerlane.
Other implementation details are handled in appendices at the end of the document. I've tried to make the code as readable and effective as possible, but it should be less crucial to this document's purpose as a guide for writing a games engine for Tamerlane.
The game of Bid Whist is, as its name suggests, a descendent of the old English game of Whist, and a cousin of the game Bridge.
A full copy of the rules are available at Pagat. I've tried to reproduce them faithfully and completely; one notable exception is that I've opted to allow the auction to go around until all players have passed instead of having it go exactly once around the table.
Here's a simplified sequence diagram illustrating how a player interacts with the Tamerlane server and a Tamerlane rules engine. Simply put, the server will initialize a game based on the results of an initial state call to the rules engine, and thereafter it will loop on getting player input, using that input to calculate the next game state, and exposing player prompts based on that state.
The rules of the game, therefore, are realized in the next state step.
+---------+ +---------+ +-------------+ | Player | | Server | | RulesEngine | +---------+ +---------+ +-------------+ | | | | start game | | |---------------------->| | | | | | | get initial state | | |------------------------->| | | | | player action | | |---------------------->| | | | | | | current state | | |------------------------->| | | | ----------------------\ | | |-| (whist/next params) | | | | |---------------------| | | | | | next state | | |<-------------------------| | | | | prompt for input | | |<----------------------| | | | |
The most important function that we need to implement when building a
rules engine is next
. Ultimately, we'll expose an API endpoint that
accepts a POST request and passes the body to next
. The input to our
function describes the state of the entire session, and the return
value of this function will be to describe what happens next.
(defn next
```
Rules engine for Bid Whist.
```
[{:state state
:players players
:action action}]
{Return Next State, 7}
)
Used in section 8
The three components of the input are the state
, the players
, and
an action
.
Every next state describes a change after some single action.
The state
consists of the state of the game. This includes things
like the cards on the table and the scores for the players, but it
also will include arbitrary metadata that we read and write.
The players
are a list of the players, their metadata, and their
current hands.
The most basic and important attribute of the game state is the
phase
. You can populate this however you like, though it must be
present.
Here we'll match on the game phase to call one of the five main game modules. Each one has the same return signature.
As we shall see, the return value of next
is always a list with two
elements:
(match (state :phase) "deal" (deal/evaluate-phase state players) "bid" (bid/evaluate-phase state players action) "discard" (discard/evaluate-phase state players action) "begin_play" (beginplay/evaluate-phase state players action) "play" (play/evaluate-phase state players action))
Used in section 5
We've broken up our Bid Whist game by those phases. Each phase has a
single module which exposes a single evaluate-phase
function. Our main
whist
module simply looks at the phase of the incoming game state
and delegates to the appropriate module.
(import game/deal)
(import game/bid)
(import game/discard)
(import game/beginplay)
(import game/play)
{`next` Function Signature, 5}
We'll start with the deal. We see that we only need the game state and the players, as it's the first thing that happens in the game.
There's very little logic in the deal, so it's a good place to start. We simply pull out the first player and then return.
players
is a list of objects representing each player. (player :id)
is a string that's guaranteed to be unique among the players. Here
we'll simply grab the first player's id.
Finally we see the return value of our functions.
We simply return a tuple of two elements. The first is the new game state; the second is an array of events to be emitted.
First the state.
We begin by merging some values into the state we were passed in. In general this is good practice, because whatever we return will become the new state. So if we clobber something useful, it be lost.
The new state we're merging in has two attributes: phase
, which
we've already covered, and meta
. In fact, meta
has no semantics to
the Tamerlane system. We'll be using it for our own purposes. The game
won't display it; it will simply include it in the next game state it
sends over. Thus, it's an easy way for us to keep track of any game
variables we like.
In this case, we'll need to keep track of two variables: high_bid
,
which is empty for now; and not_passed
, which is a set containing
all the players. We'll use them both in the bidding phase.
Next are the events.
Each element in the events array is, fittingly, a call to the events
module. There are three types of event. First, the draw
event:
(defn- all-draw [players] (map |(events/draw ($0 :id) 12) players))
Used in section 17
(defn draw [player count] {:event "draw" :player player :count count})
Used in section 68
all-draw
emits one event for each player. We end up with four
structs corresponding to the four players.
As we will see, every event has an event
attribute, which identifies
a specific side effect understood by the Tamerlane server. Every type
of event has its own set of attributes. Together they constitute the Tamerlane API.
The draw
event tells the server to draw a certain number of cards
into the specified player's hand.
Notice that the hands are not part of the state, and we haven't seen the deck anywhere either; these are controlled by the server. When the server processes one of these events, it will remove the first 12 cards from the deck and append them to the hand of the specified player.
The next type of event is add-decoration
.
(defn add-decoration [player name value] {:event "add_decoration" :name name :player player :value value})
Used in section 68
decoration is a generic way of displaying information associated
with a player. In this case we set the bid_action
decoration for
the first player to bidding
. The name, bid_action
, is for our use
only; the server will display bidding
next to the icon for that
player.
Finally, we include a prompt.
(defn pick1 [name player choices] {:event "prompt_select" :name name :player player :count 1 :from choices})
Used in section 68
Most state changes (except for the first, as we've seen!) are in response to actions. And all actions are in response to prompts. This is the first kind of prompt we've seen: a select prompt, where the player is presented with a simple list of choices and they have to choose one.
When they do that, the game server will make a next
call to our
rules server, and the value of their selection will be the action
.
In this case, the choices are a list of possible bids. Thus the bid phase begins.
We've now performed everything we need to complete the deal: we've set up our state, populated the players' hands, set a player decoration, and prompted for the first player action.
The "Bid" in "Bid Whist" is an auction for the right to name trumps. The players, starting to the left of the dealer, take turns bidding on which team will undertake a contract to win the highest number of tricks. Whichever player wins the auction becomes the declarer and announces what the trump suit will be.
Our bid function is structured quite similarly to the deal
function. One important difference, however, is that now we actually
handle action
. Referring to {whist.janet,
8
}
, we always pattern match
on action
in the incoming request body; however, it doesn't matter
in the deal. Intuitively, this makes sense; the deal is the first
thing that happens and there's no player input necessary to evaluate it.
The auction, and the rest of the game to boot, are different. In
{Deal Events,
13
}
we prompted the first player for their opening bid;
therefore, we expect that this incoming bid action is the next input
that the server will receive.
[NOTE]: Of course, in production, we should expect that we will be receiving many requests from different ongoing games at once. That doesn't pose any difficulty as our game rules server is stateless. Every request will contain everything needed to evaluate the next state of the game.
(defn evaluate-phase
```
The players bid in an auction to name trump.
The highest bid is a number of tricks to take above six with a named
suit or no-trumps.
Requires:
- `high_bid`
- `not_passed`: The players that haven't yet passed.
```
[state players action]
(if (= action :null) (error {:error "action required"}))
{Handle the Bid Action, 19}
)
Used in section 26
The first thing we do is check for the existence of the action input. Generally speaking, Tamerlane is designed not to require too much error handling and input validation from rules engines; it should generally try to Do The Right Thing. That said, it might help with development to add some basic error and input validation.
In the bidding phase, as in most other complex game phases that we
might want to represent, we need to be able to handle any input and
determine whether the phase is over, or whether it should continue by
returning a state with the same phase
attribute.
In this case, we can reason that two things need to be true for the auction to be over:
[NOTE]: In a system where we were responsible for the error handling and validation, we might want to validate that the high bidder and the one person left in the set of not-passed players are the same player. In this system, that constraint would only be violated if the Tamerlane input system had a bug in it, and that's not our responsibility.
This gives us the basic structure of the bid phase. We will determine if we've reached that condition, and if so, end the auction. Otherwise we'll continue.
(let [new-meta (new-meta state action) {:high_bid {:player high-bidder :bid high-bid}} new-meta events (initial-events state action high-bid) not-passed (update-not-passed state action)] # The set of players still in the auction is now up to date. We'll # use it to determine whether the action is over. (if (and (not (nil? high-bidder)) (= 1 (length not-passed))) # If someone has bid and all but one have passed, the auction is over. {End the Auction, 25} # Otherwise, continue the bidding. {Continue the Auction, 24} ))
Used in section 18
The new-meta
function takes the existing state and the action and
computes the new metadata entry from it.
(defn- new-meta [{:meta meta} {:value last-bid :player last-bidder}] (let [{:bid previous-high-bid :player previous-high-bidder} (meta :high_bid) # Record the high bid (whether it's a new bid or the existing high bid). [high-bid high-bidder] (case last-bid "pass" [previous-high-bid previous-high-bidder] [last-bid last-bidder])] @{:high_bid @{:player high-bidder :bid high-bid}}))
Used in section 26
Conceptually, it's pretty straightforward: we assume the presence of a
high_bid
attribute in the input state metadata. If the input action
was a pass, the new high bid is the same as the old high
bid. Otherwise, the new bid is the new high bid. If we had to actually
validate that the input was higher, this function would be a little
more complex, but we can assume that no illegal inputs were allowed by
the server.
After we determine the new metadata, we generate the initial array of events that we will emit.
(defn- initial-events [{:meta {:high_bid {:bid previous-high-bid :player previous-high-bidder}}} {:value last-bid :player last-bidder} current-high-bid] (case last-bid "pass" @[(events/add-decoration last-bidder "bid_action" "passed")] (array/concat (case previous-high-bidder nil @[] @[(events/clear-decoration previous-high-bidder "bid_action")]) (events/add-decoration last-bidder "bid_action" "declarer") (events/add-decoration last-bidder "bid" (bids/to-text current-high-bid)))))
Used in section 26
As we can see, these all have to do with managing the player decorations that will be displayed by the game interface.
The only new event type here is add-decoration
.
(defn clear-decoration [player name] {:event "clear_decoration" :name name :player player})
Used in section 68
There's very little to this---even less than to {Events: Add Decoration,
15
}
---but one point
that is hopefully apparent is the purpose of
the name
field on the add-decoration
struct: it allows us to refer
back to this value when specifying what to clear.
The last piece of bookkeeping before we can determine whether to end or continue the action is to manage the set of players still in the auction.
If (as will necessarily be the case for at least the first trip around the table) the auction isn't over yet, there are two possible states:
bids/force-bid
call, which doesn't
include the option to pass.
In both cases we include a prompt event. In the Tamerlane system there is no concept of turns---any player who has an active prompt can act and move the game state forward. In Bid Whist it's always exactly one player's turn, but that's not a necessary limitation of the system. We can emit prompts for multiple players at once.
(let [{:player last-bidder} action next-bidder (players/next-player last-bidder players not-passed)] (array/push events (events/add-decoration next-bidder "bid_action" "bidding")) # The auction isn't over; include the set of players still bidding in the metadata. [(merge state {:meta (put new-meta :not_passed not-passed) # State: Bid -> Bid :phase "bid"}) (if (= 1 (length not-passed)) # If no one has bid and all but one have passed, the dealer has to bid. (array/push events (events/pick1 "bid" next-bidder (bids/force-bid))) # Otherwise, move to the next bidder. (array/push events (events/pick1 "bid" next-bidder (bids/available-bids high-bid))))])
Used in section 19
If the auction isn't still going, it must be over. In that case we simply return an updated state that advances to the next phase---the discard phase---making sure to include the new metadata with the winning bid in it.
Finally, we prompt the high bidder to complete their bid. If they won with a suit bid, that means they'll call the suit. If they won with a notrumps bid, they'll call Uptown or Downtown.
This is all we need to run the entire bidding phase. We've realized it as a recursive function, where the base case is moving to the next phase, and the recursive case is returning a state in the same phase.
After the bidder names their full contract, they pick up the undealt kitty and then discard 6 cards.
(import events) (import bids) (defn- make-full-bid [high-bid second-bid bidder] (merge high-bid second-bid {:player bidder})) (defn evaluate-phase ``` The bidder has named their full contract. They pick up the kitty, and are then prompted to discard 6 cards. Expected metadata: - `high_bid`: The winning bid in the auction. Provides: - `bid`: The full bid for the hand. ``` [{:meta {:high_bid {:bid high-bid}}} players {:player bidder :value second-bid}] (let [full-bid (make-full-bid high-bid second-bid bidder) full-bid-text (string (bids/to-text high-bid) ": " (bids/to-text second-bid bids/second-bids))] # State: Discard -> Begin Play [{:phase "begin_play" :meta {:bid full-bid}} (array/concat (map |(events/clear-decoration ($0 :id) "bid") players) (map |(events/clear-decoration ($0 :id) "bid_action") players) (events/add-decoration bidder "high_bid" full-bid-text) (events/draw bidder 6) (events/prompt-discard bidder 6))]))
This phase is quite straightforward. The only new element is one new event.
(defn prompt-discard [player count] {:event "prompt_discard" :player player :count count})
Used in section 68
This is the second kind of prompt we've seen, after prompt_select
(available to us in this codebase via events/pick1
). The player is
prompted to select count
cards from their hand, which the server
will then discard for them.
The same principles apply here as for drawing cards; the deck and the players' hands are managed by the server. In the next phase, when we receive the game state from the server, whatever cards the player selected will have already been removed from their hand.
Once the declarer has discarded their six cards, they score one trick and lead to the first trick.
With the beginning of the play phase, we need to keep track of two new types of data: stacks and info.
A stack is any collection of one or more cards besides the deck and the players' hands. For any game, the stacks make up the table; any card or pile of cards on the table is part of a stack.
In bid whist, there's only ever one pile of cards on the table: the cards played to the current trick. In the tamerlane system, the stacks state is quite simple: it's an object mapping the name of a stack to a list of card values.
This is part of the game state, and so whatever we return in the state
response will be the content of the stacks in the game. The play phase
begins with a single stack, the trick
, which is empty.
Info is the generic way of recording data that's important to keep track of the game. Unlike the metadata we've been using above, which was specific to our purposes and not designed to be exposed directly to players, there will be some game elements during the play phase that we do expect to be displayed directly (and which we will want to manipulate as state, rather than as side effects): namely, each player will have a count of current tricks and each team will have a score.
(defn- tricks-counter [player-name] (keyword player-name "_tricks")) (defn- init-counters [bidder other-players] (var counters (zipcoll (map |(tricks-counter ($0 :id)) other-players) (map (fn [_] 0) other-players))) # It's the first trick; bidder gets one trick for the discard. (put counters (tricks-counter bidder) 1))
Used in section 33
We structure these counters very similarly to the stacks: as an object mapping the name of the info box to the value it contains.
In this case the names are <player name>_tricks
and the values are 0
for every other player, and 1 for the bidder.
(import events) {Initial Stacks, 31} {Initial Counters, 32} (defn evaluate-phase ``` The bidder has discarded six cards. They may lead any card from their hand. Provides: - `suit`: The led suit of the current trick. ``` [{:meta meta} players {:player bidder :value to-discard}] (let [other-players (filter |(not= ($0 :id) bidder) players)] # State: Begin -> Play [{:phase "play" :meta (merge meta {:suit "undefined"}) :info (init-counters bidder other-players) :stacks (init-stacks)} (array/concat # TODO: when players have names as well as IDs, this will map to names. (map |(events/add-info (tricks-counter ($0 :id)) ($0 :id)) players) (events/prompt-play bidder))]))
Here we return the state--with two new top-level attributes, info
and stacks
---and the events.
The first new event type we can cover is prompt_play
.
(defn prompt-play [player &opt from] {:event "prompt_play" :player player :to "trick" :count 1 :from from})
Used in section 68
This is the last prompt type that we need. It's structured similarly
to prompt_discard
, but it has a little more information in it. In
addition to count
, there's also to
and from
.
to
is required and must be the name of a stack in the game. Unlike
in the case of discards, when a player plays a card it has to go
somewhere; given a to
value, the server will be able to remove the
card from the player's hand and put it on the top of the specified
stack.
Finally, we sometimes need to specify from
. This will be a list of
cards in the player's hand that they can select from. In bid whist,
players must follow suit if they can; therefore, we will sometimes
have to specify which cards in the hand are of the correct suit when
prompting the player.
(defn add-info [id label] {:event "add_info" :id id :label label})
Used in section 68
We would like to be able to display useful labels on our info
boxes. For that reason we can make an add_info
event, which simply
associates an info box ID with a more friendly label (in this case
, we'll be associating an info box like North_tricks
to a label like North
[NOTE] Update this with Player Names.
In the play phase, players take turns playing a single card to a trick. Whoever wins the trick leads to the next one.
This creates a situation similar to the bidding phase: whenever we handle a call in this phase, there are two main cases:
(defn- add-to-stack [stack card] (tuple ;stack card)) (defn evaluate-phase ``` Players play to tricks. In the first trick, the bidder leads; every subsequent trick, the winner of the previous trick leads. Players must follow suit if possible; if not, they can either discard or trump. Expected stacks: - `trick`: The current trick. Expected metadata: - `bid`: The current contract. - `suit`: The led suit of the current trick. ``` [state players {:player player :value @[card-played]}] (let [just-played (with-player card-played player) current-trick (-> state (get-in [:stacks :trick]) (add-to-stack just-played))] (case (length current-trick) 4 (end-trick players state current-trick) (continue-trick players state current-trick just-played))))
Used in section 48
We've structured our program so that the beginning of the play, before someone has led the first card, is the previous phase. That allows us to always assume that someone has played a card in order to enter this function.
Notice that the value of the action is an array of cards, though we
assume that only one card was passed in; since the prompt_play
prompt is able to specify more than one card, an incoming play
action will always refer to a list of cards---whether the player was
prompted for one or more.
Let's first handle the case where the current trick continues.
The main bit of state we need to maintain, in addition to adding the
just-played card to the trick
stack, is the led suit, if any, of
the current trick.
There are a few different possibilities to handle when it comes to determining the current suit.
The simplest is that the suit has already been determined when a
previous player played to the trick. If that's the case, it can't be
changed. On the other hand, if it's "undefined"
(which is what we
initialized it to in the previous phase), we probably need to
set it for the rest of the trick. This value will determine both what
cards can be played by other players, as well as which played card
wins the trick.
(defn- new-suit ``` Determine the led suit in a given trick. - If the suit has already been determined, use that. - If the card isn't a joker, the led suit is the suit of that card. - If the card is a joker : - If the bid is notrumps, the led suit hasn't been determined yet. - Otherwise, the led suit is the trump suit. ``` [current-suit bid card-played] # We need to explicitly set the current suit to `"undefined"`. This # allows us to check for the edge case where the suit has # not been set, even after a card (or two) has been played # - if the first card is a Joker, and it's no trumps. (if (not= current-suit "undefined") current-suit (match [(bid :suit) (card-played :suit)] ["notrumps" "joker"] "undefined" [trumps "joker"] trumps [_ led-suit] led-suit)))
Used in section 48
There's one subtle edge case we need to cover: if the bid was a
no-trumps bid, and if the led card is a joker, the suit remains
undefined. This means we can't assume that the first card is
the one that determines the suit; that's why we have to look for the
string "undefined"
rather than assuming it's always set on the first
lead to the trick.
Once we've determined the suit of the trick, we simply need to add the played card to the trick and prompt the next player to play.
(defn- with-player [card player] (merge-into @{:player player} card)) (defn- continue-trick ``` Handle the first, second or third player playing to a trick. Set the led suit if necessary, update the stack and prompt the next player in sequence. ``` [players {:meta {:bid current-bid :suit current-suit} :info info} current-trick just-played] (let [new-suit (new-suit current-suit current-bid just-played) id-to-prompt (players/next-player (just-played :player) players) hand-to-prompt (-> |(= ($0 :id) id-to-prompt) (find players) (in :hand)) play-prompt (->> (cards/of-suit-or-off new-suit current-bid hand-to-prompt) (events/prompt-play id-to-prompt))] [{:phase "play" :info info :meta {:bid current-bid :suit new-suit} :stacks {:trick current-trick}} [(events/add-decoration (just-played :player) "play_action" (string "played " (cards/to-text just-played))) play-prompt]]))
Used in section 48
Finishing the current trick is more complex, because there are two options again:
Either case begins with determining who wins the trick and updating the trick counters accordingly.
After that, we can decide between the two simply by looking at one of the players' hands and seeing if there are any cards left to play.
(defn- end-trick ``` Handle the last player playing to a trick. Determine which card takes the trick and update: the number of tricks taken; if applicable, the team scores. ``` [players state current-trick] (let [{:meta {:bid current-bid :suit current-suit}} state winning-player (-> current-trick (cards/high-card current-suit current-bid) (in :player)) events (map |(events/clear-decoration ($0 :id) "play_action") players) updated-info (update (state :info) (keyword winning-player "_tricks") inc)] (case (length ((players 0) :hand)) # There are no more cards in the players' hands; resolve the # current hand. (NB: we expect that the game server will be # responsible for updating the hand values on the basis of a # `play` prompt.) 0 (next-hand players events updated-info current-bid) (continue-hand events updated-info current-bid winning-player))))
Used in section 48
The simpler case is that the players have more cards left, and thus we should simply continue the current hand. To do so we just need to clear out any state specific to this trick and prompt the winner to play again.
More complex is if this is the end of the hand, ie, if the players have played all their cards.
There's more bookkeeping in this case. We need to do a few things in order:
We don't actually keep track of the total number of tricks made by each team (though we certainly could); that means that at the end of the end, we need to figure out which player is on which team, get those players' tricks taken, and add them together.
(defn- bidding-team [players bid] ((find |(= ($0 :id) (bid :player)) players) :team)) (defn- non-bidding-team [players bid] ((find |(not= ($0 :id) (bid :player)) players) :team)) (defn- total-tricks [players bid info] (let [bidding-team (bidding-team players bid)] (->> (players/of-team players bidding-team) (map |(keyword $0 "_tricks")) (map |(info $0)) (sum))))
Used in section 48
One interesting wrinkle in Bid Whist is that if the bid was in no-trumps, the value of the hand---positive or negative---is doubled. We can handle that by either returning a multiplier function which either doubles a value, or one that simply returns it.
If the declaring team fails their bid, the negative value of the hand is whatever they bid. If they make their bid, the positive value of the hand is the number of tricks that they make minus 6.
It's quite straightforward to tell if the bidding team has won or lost a hand: they simply need to have taken their bid + 6.
Similarly, a team has won the game if they go above 7 game points; they have lost the game if they go below -7 game points.
(def- score-threshold 7) (defn- won? [val] (>= val score-threshold)) (defn- lost? [val] (<= val (- score-threshold))) (defn- made-bid? ``` A team has made a bid if their combined tricks is greater than or equal to their bid + 6. ``` [total-tricks bid] (>= total-tricks (+ 6 (bid :count))))
Used in section 48
Now that we know the win conditions for a hand and game, we can adjust the scores accordingly.
In both cases, it's only the declarers' scores that need to be adjusted. We only need to pull out the opponents' score from the game info in order to populate the new state with it.
(defn- next-hand [players events info current-bid] (let [total-tricks (total-tricks players current-bid info) score-multiplier (adjustment-for-bid current-bid) bidding-team-keyword (-> players (bidding-team current-bid) (keyword)) bidders-score (in info bidding-team-keyword) bidders-score (if (made-bid? total-tricks current-bid) (+ bidders-score (-> total-tricks (tricks-value) (score-multiplier))) (- bidders-score (-> current-bid (in :count) (score-multiplier) ))) opponent-team-keyword (-> players (non-bidding-team current-bid) (keyword)) opponents-score (in info opponent-team-keyword)] (if (or (won? bidders-score) (lost? bidders-score)) (array/push events (events/end-game players bidding-team-keyword bidders-score opponent-team-keyword opponents-score))) # State: Play->Deal [@{:phase "deal" :info {opponent-team-keyword opponents-score bidding-team-keyword bidders-score}} events]))
Used in section 48
If the bidders have won or lost, the game is over. In the Tamerlane system, we simply end a game by emitting an event; it's not part of the game state.
Different card games have different concepts of ending a game; in some games (such as this one), teams win and lose a game together; in some, each player plays for themselves; in yet others, players might be on fixed or shifting teams and still score differently.
To accomodate that degree of flexibility, we end a game in the Tamerlane system by emitting an event that contains a total ranking of all the players by score. In a game like Bid Whist, where players are on fixed teams, both players on a team will end the game with the same score. Thus, out of four players, two will be in first place and two will be in second place.
The specific format of the scores
value is a struct mapping each
player ID to their final score.
(defn end-game [players team1 score1 team2 score2] (let [players1 (players/of-team players (string team1)) players2 (players/of-team players (string team2))] {:event "end_game" :scores (zipcoll (array/concat players1 players2) [score1 score1 score2 score2])}))
Used in section 68
Having covered all the possible outcomes of a play state transition, we can wrap up all the components into a single module.
(import cards) (import players) (import events) # Play utility logic {New Suit, 38} {Total Tricks, 43} {Bid Values, 44} {Made Bid?, 45} # Possible branches of a play state transition {Continue the Trick, 39} {Continue the Hand, 41} {End the Hand, 46} {End the Trick, 40} {Main Play Function, 36}
We have implemented the entirety of our /next
API, which contains
all of rules for our game.
In order to have a working rules engine, we need to expose two more endpoints: one to return some basic configuration values that will be the case for all Bid Whist sessions, and one to return the initial game state for a single session.
Our config
function is called by the game server to get the
configuration values which are independent from session to session. In
particular, that includes:
Finally, we need to expose an init
function that returns the
starting state for a specific session. This function takes the list of
player IDs in the game and returns an object with players
and
state
in it.
(defn- make-player [id team] {:id id :team team}) (defn init "Create an initial game state." [fst snd thd fth] {:players [(make-player fst "north_south") (make-player snd "east_west") (make-player thd "north_south") (make-player fth "east_west")] :state {:phase "deal" :info {:north_south 0 :east_west 0}}})
Used in section 52
In the Tamerlane application, game rules engines are installed by specifying a callback URL for the server to hit. Each call that the server makes to the rules engine takes the form of an JSON-formatted HTTP request, and expects a JSON-formatted response.
[NOTE]: The protocol around specific status codes and port numbers is still very much in development.
In Janet, the standard web framework is Joy; we'll define an extremely simple API using that library.
(import json) (import init) (import whist) (use joy) (route :get "/api/v1/whist/config" :config) (route :get "/api/v1/whist/init" :init) (route :post "/api/v1/whist/next" :next) {Config Handler, 53} {Init Handler, 54} {Next Handler, 55} (def app (app {:layout layout :csrf-token false})) (defn main [& args] (server app 9001))
Our config endpoint needs to do very little with the request, as there are no arguments provided by the Tamerlane server.
Our init endpoint is only slightly more complex; the Tamerlane server
has a single argument to /init
, which is a list of players. Those
are sent under the query parameter players
as a comma-separated
string. For instance, a sample call might be
GET http://localhost:9001/api/v1/whist/init?players=North,East,South,West
We can handle it simply by splitting on the commas.
Finally, we need to expose the next
business logic itself.
We'd like to be able to do some basic input validation and to return an error response if we get bad input (if only to make it easier to develop our application), so we have some very simple parameter validation associated with this endpoint.
The output of our next
function is always a two-tuple of state and
events. In fact, the expected response format in the API is an object
with a state
attribute and an events
attribute, so we need to
match on the two-tuple that comes from our business logic and return
the response that the Tamerlane server expects.
(def- params (params :next (validates [:action] :required true) (permit [:action :state :players]))) (defn next [request] (def handler (fiber/new (fn [req] (whist/next (params req))) :e)) (match (resume handler request) {:error error-msg} @{:status 422 :body (json/encode {:error error-msg}) :headers @{"Content-Type" "application/json"}} [state events] (application/json {:state state :events events})))
Used in section 52
We now have a fully working Tamerlane rules engine, which models a complete version of the game of Bid Whist. If we run our web server, then we can point the Tamerlane server at its URL and play a game.
We can hardcode the list of possible bids and define a few functions
to expose them. They are written in the select
format (ie, in the
format used to define the choices for a select
prompt). It's a
simple format, a list of pairs, where the first element is the value
that will be sent to the rules engine if that choice is selected, and
the second element is the string to display when exposing the prompt
to the player.
This kind of general format gives us a maximum of flexibility as
game-developers; in this case, the value of a selection is an object
containing count
and direction
attributes, but this value will be
passed transparently back to us from the server, so we can make it
whatever we like (as long as it can be serialized and deserialized
from JSON!).
(def- bids [[{:count 3 :direction "up"} "3 Uptown"] [{:count 3 :direction "down"} "3 Downtown"] [{:count 3 :suit "no_trumps"} "3 No-Trumps"] [{:count 4 :direction "up"} "4 Uptown"] [{:count 4 :direction "down"} "4 Downtown"] [{:count 4 :suit "no_trumps"} "4 No-Trumps"] [{:count 5 :direction "up"} "5 Uptown"] [{:count 5 :direction "down"} "5 Downtown"] [{:count 5 :suit "no_trumps"} "5 No-Trumps"] [{:count 6 :direction "up"} "6 Uptown"] [{:count 6 :direction "down"} "6 Downtown"] [{:count 6 :suit "no_trumps"} "6 No-Trumps"] [{:count 7 :direction "up"} "7 Uptown"] [{:count 7 :direction "down"} "7 Downtown"] [{:count 7 :suit "no_trumps"} "7 No-Trumps"]])
Used in section 60
For each of the two types of bids in the auction--suited bids and no-trumps bids---there's a second call that the declarer makes when they have won the auction. For suited bids, the declarer calls the suit. For no-trumps bids, the declarer calls the direction.
(def- direction [[{:direction "up"} "Uptown"] [{:direction "down"} "Downtown"]]) (def- suit [[{:suit "hearts"} "Hearts"] [{:suit "spades"} "Spades"] [{:suit "diamonds"} "Diamonds"] [{:suit "clubs"} "Clubs"]]) (def second-bids (array/concat @[] direction suit)) (defn- no-trumps? [bid] (= (bid :suit) "no_trumps")) (defn second-bid [bid] (if (no-trumps? bid) direction suit))
Used in section 60
The bulk of the game logic that we need to implement in the bids module is determining what bids are available for the next bidder given what the current high bid in the auction is.
The logic is slightly complex, because Uptown and Downtown bids are equivalent in terms of the auction. That means that if the current high bid is a suited bid, the next available bid is the no-trumps bid of the same number. If the high bid is a no-trumps bid, the next available bid is the Uptown bid of the next number.
Finally, if three out of four players pass, then the fourth player
has to bid; so in that case, the Pass
option isn't available to them.
(defn- no-trumps? [bid] (= (bid :suit) "no_trumps")) (defn available-bids [&opt high-bid] (case high-bid nil (array ;bids ["pass" "Pass"]) (if-let [minimum-bid (if (no-trumps? high-bid) {:count (inc (high-bid :count)) :direction "up"} {:count (high-bid :count) :suit "no_trumps"}) minimum-bid-ind (find-row-index minimum-bid bids)] (array/push (array/slice bids minimum-bid-ind) ["pass" "Pass"]) @[["pass" "Pass"]]))) (defn force-bid "The bidder can't pass; they can only select an actual bid." [] (array ;bids))
Used in section 60
{All Bids, 57} {Second Bids, 58} (defn- find-row-index [bid source] (find-index |(= ($0 0) bid) source)) (defn to-text [bid &opt source] (default source bids) (if-let [ind (find-row-index (freeze bid) source) row (source ind)] (row 1) (error (string "Not found: " (string/format "%q" bid) " in " (string/format "%q" source))))) {Available Bids, 59}
In Tamerlane, cards are represented in data as simple objects. They
have two attributes, suit
and rank
.
The definition of which cards are contained in any deck type is
currently stored on the Tamerlane server, and deck types are specified
by name. For instance, Bid Whist uses the 52JJ
deck, which is the
normal 52-card Anglo-French deck with two Jokers.
Our card logic is a crucial element of the game. This module encodes the logic determining what can be legally played to a given trick, as well as how to determine which card wins a given trick.
The card ranks are encoded as integers in "standard" order, with Aces represented with 1 up to Kings at 13.
(defn to-text [{:suit suit :rank rank}] (case suit "joker" (case rank 2 "Big Joker" 1 "Little Joker") (let [suits {"diamonds" "♦" "spades" "♠" "clubs" "♣" "hearts" "♥"} ranks {1 "Ace" 2 "2" 3 "3" 4 "4" 5 "5" 6 "6" 7 "7" 8 "8" 9 "9" 10 "10" 11 "J" 12 "Q" 13 "K"}] (string (suits suit) (ranks rank)))))
Used in section 66
The rules of following suit in Bid Whist are fairly simple:
Our job is only somewhat complicated by the variable role of the Joker. In a suited contract, Jokers behave as though they were members of the trump suit. On the other hand, in a no-trumps contract, Jokers are members of their own suit.
(defn- of-suit "Return any cards in stack that match the led suit." [led-suit stack] (let [without-jokers |(= led-suit ($0 :suit))] (filter without-jokers stack))) (defn- of-suit-or-jokers "Return any cards in stack that match the led suit, or are jokers." [led-suit stack] (let [with-jokers |(or (= led-suit ($0 :suit)) (= "joker" ($0 :suit)))] (filter with-jokers stack))) (defn of-suit-or-off "Return any cards in stack that match the led suit, otherwise return all cards." [led-suit current-bid stack] (let [on-suit (if (= led-suit (current-bid :suit)) # If the led suit is trumps, then look for all cards # of that suit or jokers. (of-suit-or-jokers led-suit stack) # Otherwise, look just for cards of that suit. (of-suit led-suit stack))] (if (> (length on-suit) 0) on-suit stack)))
Used in section 66
We can be a bit clever here by checking to see if the led suit is the
same as the suit of the contract, since "notrumps"
is not the suit
of any card.
The other crucial element of our card logic is the function which actually determines who takes a trick. In our play phase, we have decorated each card with the player who played it. Thus, in this module, we determine the highest card in the trick, and then back in the play we can pull the winning player off of it.
(defn- of-suit-or-trumps "Return any cards in stack that match the led suit, or are trumps." [led-suit trumps stack] (let [with-jokers-and-trumps |(or (= led-suit ($0 :suit)) (= trumps ($0 :suit)) (= "joker" ($0 :suit)))] (filter with-jokers-and-trumps stack))) (defn high-card "Trick resolution for all Bid Whist games." [stack led-suit bid] (let [f (make-compare-enable-fn bid)] (->> stack # Get a version of the current-trick where # each card has a `:compare` method that's # aware of the current bid. (map f) (of-suit-or-trumps led-suit (bid :suit)) # Get the highest card according to each # one's `:compare`. (extreme compare>))))
Used in section 66
In this program, possibly the most idiomatic way to find the winning
card is to take advantage of some specific features of the Janet
language. In particular we're taking advantage of Janet's OO features
by defining some prototype cards with custom compare
methods,
and then setting the right prototype for the cards we are comparing
according to the contract they're being evaluated under.
This arguably has more to do with the specifics of object-oriented programming in Janet than anything having to do with the Tamerlane system, so feel free to skip it if it's not of particular interest.
(defn- uptown [a b] (match [a b] # `1` is the rank of the Ace. @[1 1] 0 @[1 _] 1 @[_ 1] -1 @[x y] (compare x y))) (defn- downtown [a b] (match [a b] # `1` is the rank of the Ace. @[1 1] 0 @[1 _] 1 @[_ 1] -1 @[x y] (- (compare x y)))) (defn- uptown-card [trumps] @{:compare (fn [self other] (match [(self :suit) (other :suit)] @["joker" "joker"] (compare (self :rank) (other :rank)) @["joker" _] 1 @[_ "joker"] -1 @[trumps trumps] (uptown (self :rank) (other :rank)) @[trumps _] 1 @[_ trumps] -1 @[_ _] (uptown (self :rank) (other :rank))))}) (defn- downtown-card [trumps] @{:compare (fn [self other] (match [(self :suit) (other :suit)] @["joker" "joker"] (compare (self :rank) (other :rank)) @["joker" _] 1 @[_ "joker"] -1 @[trumps trumps] (downtown (self :rank) (other :rank)) @[trumps _] 1 @[_ trumps] -1 @[_ _] (downtown (self :rank) (other :rank))) )}) (def- notrumps-card @{:compare (fn [self other] (match [(self :suit) (other :suit)] @["joker" "joker"] (compare (self :rank) (other :rank)) @["joker" _] -1 @[_ "joker"] 1 @[_ _] (uptown (self :rank) (other :rank))))}) (def- notrumps-downtown-card @{:compare (fn [self other] (match [(self :suit) (other :suit)] @["joker" "joker"] (compare (self :rank) (other :rank)) @["joker" _] -1 @[_ "joker"] 1 @[_ _] (downtown (self :rank) (other :rank))))}) (defn- make-compare-enable-fn [current-bid] (let [proto (match [(current-bid :suit) (current-bid :direction)] @["notrumps" "up"] notrumps-card @["notrumps" "down"] notrumps-downtown-card @[trumps "up"] (uptown-card trumps) @[trumps "down"] (downtown-card trumps))] (fn [card] (table/setproto card proto))))
Used in section 66
{Card to Text, 62} {Of Suit or Off, 63} {Enable Card Comparison, 65} {High Card, 64}
We need a little bit of business logic when it comes to handling players. In particular, we need a way to easily select the "next" player around the table, while optionally filtering out certain ones (in the case of the auction, we want to filter out players who have already passed).
In Janet we can do this using fibers.
(defn next-player [id players &opt out-of] (var ind (find-index |(and (= ($0 :id) id)) players)) (let [f (fiber/new (fn [] (while true (do (set ind (mod (inc ind) (length players))) (yield ind)))))] (var found nil) (while (not found) (let [new-ind (resume f) new-id ((players new-ind) :id)] (if (or (nil? out-of) (in out-of (keyword new-id))) (set found new-id)))) found)) (defn of-team [players team] (->> players (filter |(= ($0 :team) team)) (map |($0 :id))))
(import players) # Hand side effects {Events: Draw, 14} # Game Side Effects {Events: End Game, 47} # Decorations {Events: Add Decoration, 15} {Events: Clear Decoration, 22} # Prompts {Events: Pick 1, 16} {Events: Prompt Play, 34} {Events: Prompt Discard, 28} # State labels {Events: Add Info, 35}