{c,transferCard,transferCardToTop} = require './cards' if exports?
This “indecisive import” pattern is messy but it gets the job done, and it’s explained at the bottom of this documentation.
{c,transferCard,transferCardToTop} = require './cards' if exports?
A PlayerState stores the part of the game state that is specific to each player, plus what AI is making the decisions.
class PlayerState
At the start of the game, the State should .initialize() each PlayerState, which assigns its AI and sets up its starting state. Before then, it is an empty object.
initialize: (ai, logFunc) ->
These attributes of the PlayerState are okay for card effects and AI strategies to refer to.
Often, you will want to find out something
about the player whose turn it is, who will appear as state.current
.
For example, if you want to know how many actions the current player
has, you can look up state.current.actions
.
@actions = 1
@buys = 1
@coins = 0
@potions = 0
@coinTokens = 0
@coinTokensSpendThisTurn = 0
@multipliedDurations = []
@chips = 0
@hand = []
@discard = [c.Copper, c.Copper, c.Copper, c.Copper, c.Copper,
c.Copper, c.Copper, c.Estate, c.Estate, c.Estate]
A mat is a place where cards can store inter-turn state for a player. It can correspond to a physical mat, like the Island or Pirate Ship Mat or just a place to set things aside for cards like Haven.
@mats = {}
If you want to ask what’s in a player’s draw pile, be sure to only do
it to a hypothetical PlayerState that you retrieve with
state.hypothetical(ai)
. Then the draw pile will contain a random
guess, as opposed to the actual hidden information.
@draw = []
@inPlay = []
@duration = []
@setAside = []
@gainedThisTurn = []
@turnsTaken = 0
To stack various card effects, we’ll have to keep track of the location of the card we’re playing and the card we’re gaining. For example, if you have two Feasts in hand and you Throne Room a Feast, you don’t trash both Feasts — you trash one and then do nothing, based on the fact that that particular Feast is already in the trash.
@playLocation = 'inPlay'
@gainLocation = 'discard'
The actionStack
is not a physical location for cards to be in; it’s
a computational list of what actions are in play but not yet resolved.
This becomes particularly important with King’s Courts.
@actionStack = []
Set the properties passed in from the State.
@ai = ai
@logFunc = logFunc
To start the game, the player starts with the 10 starting cards in the discard pile, then shuffles them and draws 5.
this.drawCards(5)
this
The methods here ask about general properties of a player’s deck,
discard pile, and so on. A number of similar methods appear on the State
class defined below, which deal with information that is not so
player-specific, such as the cards in the supply.
As an example:
Most AI code will start with a reference to the State, called state
.
If you want to check the number of cards in the current player’s deck,
you would ask the player object state.current
:
state.current.numCardsInDeck()
If you want to check how many piles are currently empty, you would ask the state object itself:
state.numEmptyPiles()
getDeck()
returns all the cards in the player’s deck, even those in
strange places such as the Island mat.
getDeck: () ->
result = [].concat(@draw, @discard, @hand, @inPlay, @duration, @setAside)
for own name, contents of @mats when contents?
If contents is a card or an array containing cards, add it to the list
if contents.hasOwnProperty('playEffect') || contents[0]?.hasOwnProperty('playEffect')
result = result.concat(contents)
result
getCurrentAction()
returns the action being resolved that is on the
top of the stack.
getCurrentAction: () ->
@actionStack[@actionStack.length - 1]
getMultiplier()
gets the value of the multipier that is currently being
played: 1 in most cases, 2 after playing Throne Room, 3 after playing
King’s Court.
getMultiplier: () ->
action = this.getCurrentAction()
if action?
return action.getMultiplier()
else
return 1
countInDeck(card)
counts the number of copies of a card in the deck.
The card may be specified either by name or as a card object.
countInDeck: (card) ->
count = 0
for card2 in this.getDeck()
if card.toString() == card2.toString()
count++
count
numCardsInDeck()
returns the size of the player’s deck.
numCardsInDeck: () -> this.getDeck().length
Aliases for numCardsInDeck
that you might use intuitively.
countCardsInDeck: this.numCardsInDeck
cardsInDeck: this.numCardsInDeck
countCardTypeInDeck(type)
counts the number of cards of a given type
in the deck. Curse is not a type for these purposes, it’s a card.
countCardTypeInDeck: (type) ->
typeChecker = 'is'+type
count = 0
for card in this.getDeck()
if card[typeChecker]
count++
count
numCardTypeInDeck: this.countCardTypeInDeck
getVP()
returns the number of VP the player would have if the game
ended now.
getVP: (state) ->
total = @chips
for card in this.getDeck()
total += card.getVP(this)
total
countVP: this.getVP
getTotalMoney()
adds up the total money in the player’s deck,
including both Treasure and +$x, +y Actions cards.
getTotalMoney: () ->
total = 0
for card in this.getDeck()
if card.isTreasure or card.actions >= 1
total += card.coins
total += card.coinTokens
total
totalMoney: this.getTotalMoney
getAvailableMoney()
counts the money the player might have upon playing
all treasure in hand. Banks, Ventures, and such are counted inaccurately
so far.
getAvailableMoney: () ->
this.coins + this.getTreasureInHand()
availableMoney: this.getAvailableMoney
getTreasureInHand()
adds up the value of the treasure in the player’s
hand. Banks and Ventures and such will be inaccurate.
A getMoneyInHand(state)
method that counted playable action cards would
be great, but I’m skipping it for now because it’s difficult to get right.
getTreasureInHand: () ->
total = 0
for card in this.hand
if card.isTreasure
total += card.coins
total
treasureInHand: this.getTreasureInHand
countPlayableTerminals: (state) ->
if (@actions>0)
@actions + ( (Math.max (card.getActions(state) - 1), 0 for card in this.hand).reduce (s,t) -> s + t)
else 0
numPlayableTerminals: this.countPlayableTerminals
playableTerminals: this.countPlayableTerminals
countInHand(card)
counts the number of copies of a card in hand.
countInHand: (card) ->
countStr(@hand, card)
countInDiscard(card)
counts the number of copies of a card in the discard
pile.
countInDiscard: (card) ->
countStr(@discard, card)
countInPlay(card)
counts the number of copies of a card in play. Don’t use this
for evaluating effects that stack, because you may also need
to take Throne Rooms and King’s Courts into account.
countInPlay: (card) ->
countStr(@inPlay, card)
numActionCardsInDeck()
is the number of action cards in the player’s
entire deck.
numActionCardsInDeck: () ->
this.countCardTypeInDeck('Action')
getActionDensity()
returns a fractional value, between 0.0 and 1.0,
representing the proportion of actions in the deck.
getActionDensity: () ->
this.numActionCardsInDeck() / this.getDeck().length
menagerieDraws()
is the number of cards the player would draw upon
playing a Menagerie: either 1 or 3.
TODO: allow for a hypothetical version where it’s okay to have another Menagerie.
menagerieDraws: () ->
seen = {}
cardsToDraw = 3
for card in @hand
if seen[card.name]?
cardsToDraw = 1
break
seen[card.name] = true
cardsToDraw
shantyTownDraws()
is the number of cards the player draws upon
playing a Shanty town: either 0 or 2.
Set hypothetical
to true
if deciding whether to play a Shanty Town
(because it won’t be in your hand anymore when you do).
shantyTownDraws: (hypothetical = false) ->
cardsToDraw = 2
skippedShanty = false
for card in @hand
if card.isAction
if hypothetical and not skippedShanty
skippedShanty = true
else
cardsToDraw = 0
break
cardsToDraw
actionBalance()
is a complex method meant to be used by AIs in
deciding whether they want +actions or +cards, for example.
If the actionBalance is less than 0, you want +actions, because otherwise you will have dead action cards in hand or risk drawing them dead. If it is greater than 0, you want +cards, because you have a surplus of actions and need action cards to spend them on.
actionBalance: () ->
balance = @actions
for card in @hand
if card.isAction
balance += card.actions
balance--
Estimate the risk of drawing an action card dead.
TODO: do something better when there are variable card-drawers.
if card.actions == 0
balance -= card.cards * this.getActionDensity()
balance
deckActionBalance()
is a measure of action balance across the entire
deck.
deckActionBalance: () ->
balance = 0
for card in this.getDeck()
if card.isAction
balance += card.actions
balance--
return balance / this.numCardsInDeck()
What is the trashing power of this hand?
trashingInHand: () ->
trash = 0
for card in this.hand
Count actions that simply trash a constant number of cards from hand.
trash += card.trash
Add other trashers, including the trash-on-gain power of Watchtower.
trash += 2 if card is c.Steward
trash += 2 if card is c['Trading Post']
trash += 4 if card is c.Chapel
trash += 1 if card is c.Masquerade
trash += 2 if card is c.Ambassador
trash += 1 if card is c.Watchtower
return trash
numUniqueCardsInPlay: () ->
unique = []
cards = @inPlay.concat(@duration)
for card in cards
if card not in unique
unique.push(card)
return unique.length
countUniqueCardsInPlay: this.numUniqueCardsInPlay
uniqueCardsInPlay: this.numUniqueCardsInPlay
drawCards: (nCards) ->
drawn = this.getCardsFromDeck(nCards)
Array::push.apply @hand, drawn
this.log("#{@ai} draws #{drawn.length} cards: #{drawn}.")
return drawn
getCardsFromDeck
is a sub-method of many things that need to happen
with the game. It takes nCards
cards off the deck, and then
returns them so you can do something with them.
Code that calls getCardsFromDeck
is responsible for making sure the cards aren’t just “dropped on the
floor” after that, so to speak.
getCardsFromDeck: (nCards) ->
if @draw.length < nCards
diff = nCards - @draw.length
drawn = @draw.slice(0)
@draw = []
if @discard.length > 0
this.shuffle()
return drawn.concat(this.getCardsFromDeck(diff))
else
return drawn
else
drawn = @draw[0...nCards]
@draw = @draw[nCards...]
return drawn
dig
is a function to draw and reveal cards from the deck until
certain ones are found. The cards to be found are defined by digFunc,
which takes (state, card) and returns true if card is one that we’re
trying find. For example, Venture’s and Adventurer’s would be
digFunc: (state, card) -> card.isTreasure
nCards is the number of cards we’re looking for; usually 1, but Golem and Adventurer look for 2 cards.
By default, discard the revealed and set aside cards, but Scrying Pool digs for a card that is not an action, then draws up all the revealed actions as well; discardSetAside allows a card calling dig to do something with setAside other than discarding.
dig: (state, digFunc, nCards=1, discardSetAside=true) ->
foundCards = [] # These are the cards you're looking for
revealedCards = [] # All the cards drawn and revealed from the deck
while foundCards.length < nCards
drawn = this.getCardsFromDeck(1)
break if drawn.length == 0
card = drawn[0]
revealedCards.push(card)
if digFunc(state, card)
foundCards.push(card)
else
this.setAside.push(card)
if revealedCards.length == 0
this.log("...#{this.ai} has no cards to draw.")
else
this.log("...#{this.ai} reveals #{revealedCards}.")
if discardSetAside
if this.setAside.length > 0
this.log("...#{this.ai} discards #{this.setAside}.")
this.discard = this.discard.concat(this.setAside)
state.handleDiscards(this, this.setAside)
this.setAside = []
foundCards
discardFromDeck: (nCards) ->
throw new Error("discardFromDeck is done by the state now")
doDiscard: (card) ->
throw new Error("doDiscard is done by the state now")
doTrash: (card) ->
throw new Error("doTrash is done by the state now")
doPutOnDeck: (card) ->
throw new Error("doPutOnDeck is done by the state now")
shuffle: () ->
this.log("(#{@ai} shuffles.)")
if @draw.length > 0
throw new Error("Shuffling while there are cards left to draw")
shuffle(@discard)
@draw = @discard
@discard = []
TODO: add an AI decision for Stashes
Most PlayerStates are created by copying an existing one.
copy: () ->
other = new PlayerState()
other.actions = @actions
other.buys = @buys
other.coins = @coins
other.potions = @potions
other.coinTokens = @coinTokens
other.multipliedDurations = @multipliedDurations.slice(0)
Clone mat contents, deep-copying arrays of cards
other.mats = {}
for own name, contents of @mats
if contents instanceof Array
contents = contents.concat()
other.mats[name] = contents
other.chips = @chips
other.hand = @hand.slice(0)
other.draw = @draw.slice(0)
other.discard = @discard.slice(0)
other.inPlay = @inPlay.slice(0)
other.duration = @duration.slice(0)
other.setAside = @setAside.slice(0)
other.gainedThisTurn = @gainedThisTurn.slice(0)
other.playLocation = @playLocation
other.gainLocation = @gainLocation
other.actionStack = @actionStack.slice(0)
other.actionsPlayed = @actionsPlayed
other.ai = @ai
other.logFunc = @logFunc
other.turnsTaken = @turnsTaken
other.coinTokensSpendThisTurn = @coinTokensSpendThisTurn
other
Games can provide output using the log
function.
log: (obj) ->
if this.logFunc?
this.logFunc(obj)
else
if console?
console.log(obj)
A State instance stores the complete state of the game at a point in time.
Almost all operations work by changing the game state. This means that if AI code wants to evaluate potential decisions, it should do them using a copy of the state (often one with hidden information in it).
class State
basicSupply: [c.Curse, c.Copper, c.Silver, c.Gold,
c.Estate, c.Duchy, c.Province]
extraSupply: [c.Potion, c.Platinum, c.Colony]
AIs can get at the c
object that stores information about cards
by looking up state.cardInfo
.
cardInfo: c
Set up the state at the start of the game. Takes these arguments:
ais
: a list of AI objects that will make the decisions, one per player.
This sets the number of players in the game.tableau
: the list of non-basic cards in the supply. Colony, Platinum,
and Potion have to be listed explicitly. initialize: (ais, tableau, logFunc) ->
this.logFunc = logFunc
@players = (new PlayerState().initialize(ai, this.logFunc) for ai in ais)
@players = []
playerNum = 0
for ai in ais
playerNum += 1 if ai.name[2] == ‘:’ ai.name = ai.name[3…] ai.name = “P#{playerNum}:#{ai.name}”
player = new PlayerState().initialize(ai, this.logFunc)
@players.push(player)
@nPlayers = @players.length
@current = @players[0]
@supply = this.makeSupply(tableau)
Cards like Tournament or Black Market may put cards in a special supply
@specialSupply = {}
@trash = []
A map of Card to state object that allows cards to define lasting state.
@cardState = {}
A list of objects which have a “modify” method that takes a card and returns a modification to its cost. Objects must also have a “source” property that specifies which card caused the cost modification.
@costModifiers = []
@copperValue = 1
@phase = 'start'
@extraturn = false
@cache = {}
The depth
indicates how deep into hypothetical situations we are. A depth of 0
indicates the state of the actual game.
@depth = 0
this.log("Tableau: #{tableau}")
Let cards in the tableau know the game is starting so they can perform any necessary initialization
for card in tableau
card.startGameEffect(this)
totalCards
tracks the total number of cards that are in the game. If it changes,
we screwed up.
@totalCards = this.countTotalCards()
return this
setUpWithOptions
is the function I’d like to use as the primary way of setting up
a new game, doing the work of choosing a set of kingdom and extra cards (what I call
the tableau) with the cards they require plus random cards, and handling options.
It takes two arguments, ais
and options
. ais
is the list of AI objects, and
options
is an object with these keys and values:
randomizeOrder
: whether to shuffle the player order. Defaults to true.colonies
: whether to add Colonies and Platinums to the tableau. Defaults
to false-ish: it can be set to true by a strategy that requires Colony if
left undefined. setUpWithOptions: (ais, options) ->
tableau = []
if options.require?
for card in options.require
tableau.push(c[card])
for ai in ais
if ai.requires?
for card in ai.requires
card = c[card]
if card in [c.Colony, c.Platinum]
if not options.colonies?
options.colonies = true
else if options.colonies is false
throw new Error("This setup forbids Colonies, but #{ai} requires them")
else if card not in tableau and card not in this.basicSupply\
and card not in this.extraSupply and not card.isPrize
tableau.push(card)
if tableau.length > 10
throw new Error("These strategies require too many different cards to play against each other.")
index = 0
moreCards = c.allCards.slice(0)
shuffle(moreCards)
while tableau.length < 10
card = c[moreCards[index]]
if not (card in tableau or card in this.basicSupply or card in this.extraSupply or card.isPrize)
tableau.push(card)
index++
if options.colonies
tableau.push(c.Colony)
tableau.push(c.Platinum)
for card in tableau
if card.costPotion > 0
if c.Potion not in tableau
tableau.push(c.Potion)
if options.randomizeOrder
shuffle(ais)
return this.initialize(ais, tableau, options.log ? console.log)
Given the tableau (the set of non-basic cards in play), construct the appropriate supply for the number of players.
makeSupply: (tableau) ->
allCards = this.basicSupply.concat(tableau)
supply = {}
for card in allCards
if c[card].startingSupply(this) > 0
card = c[card] ? card
supply[card] = card.startingSupply(this)
supply
These methods are referred to by some card effects, but can also be useful in crafting a strategy.
emptyPiles()
determines which supply piles are empty.
emptyPiles: () ->
piles = []
for key, value of @supply
if value == 0
piles.push(key)
piles
numEmptyPiles()
simply returns the number of empty piles.
numEmptyPiles: () ->
this.emptyPiles().length
filledPiles()
determines which supply piles are not empty.
filledPiles: () ->
piles = []
for key, value of @supply
if value > 0
piles.push(key)
piles
gameIsOver()
returns whether the game is over.
gameIsOver: () ->
The game can only end after a player has taken a full turn. Check that
by making sure the phase is 'start'
.
return false if @phase != 'start'
Check all the conditions in which empty piles can end the game. Add a fake game-ending condition, too, which is a stalemate the SillyAI sometimes ends up in.
emptyPiles = this.emptyPiles()
if emptyPiles.length >= this.totalPilesToEndGame() \
or (@nPlayers < 5 and emptyPiles.length >= 3) \
or 'Province' in emptyPiles \
or 'Colony' in emptyPiles \
or ('Curse' in emptyPiles and 'Copper' in emptyPiles and @current.turnsTaken >= 100)
this.log("Empty piles: #{emptyPiles}")
for [playerName, vp, turns] in this.getFinalStatus()
this.log("#{playerName} took #{turns} turns and scored #{vp} points.")
return true
return false
getFinalStatus()
is a useful thing to call when gameIsOver()
is true.
It returns a list of triples of [player name, score, turns taken].
getFinalStatus: () ->
([player.ai.toString(), player.getVP(this), player.turnsTaken] for player in @players)
getWinners()
returns a list (usually of length 1) of the names of players
that won the game, or would win if it were over now.
getWinners: () ->
scores = this.getFinalStatus()
best = []
bestScore = -Infinity
for [player, score, turns] in scores
Modify the score by subtracting a fraction of turnsTaken.
modScore = score - turns/100
if modScore == bestScore
best.push(player)
if modScore > bestScore
best = [player]
bestScore = modScore
best
countInSupply()
returns the number of copies of a card that remain
in the supply. It can take in either a card object or card name.
If the card has never been in the supply, it returns 0,
so it is safe to refer to state.countInSupply('Colony')
even in
a non-Colony game. This does not count as an empty pile, of course.
countInSupply: (card) ->
@supply[card] ? 0
totalPilesToEndGame()
returns the number of empty piles that triggers
the end of the game, which is almost always 3.
totalPilesToEndGame: () ->
switch @nPlayers
when 1, 2, 3, 4 then 3
else 4
As a useful heuristic, gainsToEndGame()
returns the minimum number of
buys/gains that would have to be used by an opponent who is determined to
end the game. A low number means the game is probably ending soon.
gainsToEndGame: () ->
if @cache.gainsToEndGame?
return @cache.gainsToEndGame
counts = (count for card, count of @supply)
numericSort(counts)
First, add up the smallest 3 (or 4) piles.
piles = this.totalPilesToEndGame()
minimum = 0
for count in counts[...piles]
minimum += count
Then compare this to the number of Provinces or possibly Colonies remaining, and see which one is smallest.
minimum = Math.min(minimum, @supply['Province'])
if @supply['Colony']?
minimum = Math.min(minimum, @supply['Colony'])
Cache the result; apparently it’s expensive to compute.
@cache.gainsToEndGame = minimum
minimum
smugglerChoices
determines the set of cards that could be gained with a
Smuggler.
smugglerChoices: () ->
choices = [null]
prevPlayer = @players[@nPlayers - 1]
for card in prevPlayer.gainedThisTurn
[coins, potions] = card.getCost(this)
if potions == 0 and coins <= 6
choices.push(card)
choices
countTotalCards
counts the number of cards that exist anywhere.
countTotalCards: () ->
total = 0
for player in @players
total += player.numCardsInDeck()
for card, count of @supply
total += count
for card, count of @specialSupply
total += count
total += @trash.length
total
buyCausesToLose: (player, state, card) ->
if not card? || @supply[card] > 1 || state.gainsToEndGame() > 1
return false
Check to see if the player would be in the lead after buying this card
maxOpponentScore = -Infinity
for status in this.getFinalStatus()
[name, score, turns] = status
if name == player.ai.toString()
myScore = score + card.getVP(player)
else if score > maxOpponentScore
maxOpponentScore = score
if myScore > maxOpponentScore
return false
One level of recursion is enough for first
if (this.depth==0)
[hypState, hypMy] = state.hypothetical(player.ai)
else
return false
try to buy this card C&P from below
[coinCost, potionCost] = card.getCost(this)
hypMy.coins -= coinCost
hypMy.potions -= potionCost
hypMy.buys -= 1
hypState.gainCard(hypMy, card, 'discard', true)
card.onBuy(hypState)
for i in [hypMy.inPlay.length-1...-1]
cardInPlay = hypMy.inPlay[i]
if cardInPlay?
cardInPlay.buyInPlayEffect(hypState, card)
goonses = hypMy.countInPlay('Goons')
if goonses > 0
this.log("...gaining #{goonses} VP.")
hypMy.chips += goonses
C&P until here
finish buyPhase
hypState.doBuyPhase()
find out if game ended and who if we have won it
hypState.phase = 'start'
if not hypState.gameIsOver()
return false
if ( hypMy.ai.toString() in hypState.getWinners() )
return false
state.log("Buying #{card} will cause #{player.ai} to lose the game")
return true
doPlay
performs the next step of the game, which is a particular phase
of a particular player’s turn. If the phase is…
To play the entire game, iterate doPlay()
until gameIsOver()
. Putting
this in a single loop would be a bad idea because it would make Web
browsers freeze up. Browser-facing code should return control after each
call to doPlay()
.
doPlay: () ->
switch @phase
when 'start'
if not @extraturn
@current.turnsTaken += 1
this.log("\n== #{@current.ai}'s turn #{@current.turnsTaken} ==")
this.doDurationPhase()
@phase = 'action'
else
this.log("\n== #{@current.ai}'s turn #{@current.turnsTaken}+ ==")
this.doDurationPhase()
@phase = 'action'
when 'action'
this.doActionPhase()
@phase = 'treasure'
when 'treasure'
this.doTreasurePhase()
@phase = 'buy'
when 'buy'
this.doBuyPhase()
@phase = 'cleanup'
when 'cleanup'
this.doCleanupPhase()
if not @extraturn
this.rotatePlayer()
else
@phase = 'start'
@current.duration
contains all cards that are in play with duration
effects. At the start of the turn, check all of these cards and run their
onDuration
method.
doDurationPhase: () ->
Clear out the list of cards gained. (We clear it here because this information is actually used by Smugglers.)
@current.gainedThisTurn = []
iterate backwards because cards might move
for i in [@current.duration.length-1...-1]
card = @current.duration[i]
this.log("#{@current.ai} resolves the duration effect of #{card}.")
card.onDuration(this)
@current.multipliedDurations
contains virtual copies of cards, which
exist because a multiplier was played on a Duration card.
for card in @current.multipliedDurations
this.log("#{@current.ai} resolves the duration effect of #{card} again.")
card.onDuration(this)
@current.multipliedDurations = []
Perform the action phase. Ask the AI repeatedly which action to play,
until there are no more action cards to play or there are no
actions remaining to play them with, or the AI chooses null
, indicating
that it doesn’t want to play an action.
doActionPhase: () ->
while @current.actions > 0
validActions = [null]
Determine the set of unique actions that may be played.
for card in @current.hand
if card.isAction and card not in validActions
validActions.push(card)
Ask the AI for its choice.
action = @current.ai.chooseAction(this, validActions)
return if action is null
Remove the action from the hand and put it in the play area.
if action not in @current.hand
this.warn("#{@current.ai} chose an invalid action.")
return
this.playAction(action)
The current player plays an action from the hand, and performs the effect of the action.
playAction: (action) ->
this.log("#{@current.ai} plays #{action}.")
Subtract 1 from the action count and perform the action.
@current.hand.remove(action)
@current.inPlay.push(action)
@current.playLocation = 'inPlay'
@current.actions -= 1
this.resolveAction(action)
Another event that causes actions to be played, such as Throne Room,
should skip straight to resolveAction
.
resolveAction: (action) ->
@current.actionStack.push(action)
@current.actionsPlayed += 1
action.onPlay(this)
@current.actionStack.pop()
The “treasure phase” is a concept introduced in Prosperity. After playing
actions, you play any number of treasures in some order. This loop
repeats until the AI chooses null
, either because there are no treasures
left to play or because it does not want to play any more treasures.
doTreasurePhase: () ->
loop
validTreasures = [null]
Determine the set of unique treasures that may be played.
for card in @current.hand
if card.isTreasure and card not in validTreasures
validTreasures.push(card)
Ask the AI for its choice.
treasure = @current.ai.chooseTreasure(this, validTreasures)
break if treasure is null
this.log("#{@current.ai} plays #{treasure}.")
Remove the treasure from the hand and put it in the play area.
if treasure not in @current.hand
this.warn("#{@current.ai} chose an invalid treasure")
break
this.playTreasure(treasure)
while (ctd = this.getCoinTokenDecision()) > 0
@current.coins += ctd
@current.coinTokens -= ctd
getCoinTokenDecision: () ->
ct = @current.ai.spendCoinTokens(this, @current)
if (ct > @current.coinTokens)
this.log("#{@current.ai} wants to spend more Coin Tokens as it possesses (#{ct}/#{@current.coinTokens})")
ct = @current.coinTokens
else
if (ct > 0)
this.log("#{@current.ai} spends #{ct} Coin Token#{if ct > 1 then "s" else ""}")
@current.coinTokensSpendThisTurn = ct
return ct
playTreasure: (treasure) ->
@current.hand.remove(treasure)
@current.inPlay.push(treasure)
@current.playLocation = 'inPlay'
treasure.onPlay(this)
getSingleBuyDecision
determines what single card (or none) the AI
wants to buy in the current state.
getSingleBuyDecision: () ->
buyable = [null]
checkSuicide = (this.depth == 0 and this.gainsToEndGame() <= 2)
for cardname, count of @supply
Because the supply must reference cards by their names, we use
c[cardname]
to get the actual object for the card.
card = c[cardname]
Determine whether each card can be bought in the current state.
if card.mayBeBought(this) and count > 0
[coinCost, potionCost] = card.getCost(this)
if coinCost <= @current.coins and potionCost <= @current.potions
buyable.push(card)
Don’t allow cards that will lose us the game
Note that this just cares for the buyPhase, gains by other means (Workshop) are not covered
if checkSuicide
buyable = (card for card in buyable when (not this.buyCausesToLose(@current, this, card)))
Ask the AI for its choice.
this.log("Coins: #{@current.coins}, Potions: #{@current.potions}, Buys: #{@current.buys}")
this.log("Coin Tokens left: #{@current.coinTokens}")
choice = @current.ai.chooseGain(this, buyable)
return choice
doBuyPhase
steps through the buy phase, asking the AI to choose
a card to buy until it has no buys left or chooses to buy nothing.
Setting hypothetical
to true will skip gaining the cards and simply
return the card list.
doBuyPhase: () ->
while @current.buys > 0
choice = this.getSingleBuyDecision()
return if choice is null
this.log("#{@current.ai} buys #{choice}.")
Update money and buys.
[coinCost, potionCost] = choice.getCost(this)
@current.coins -= coinCost
@current.potions -= potionCost
@current.buys -= 1
Gain the card and deal with the effects.
this.gainCard(@current, choice, 'discard', true)
choice.onBuy(this)
Handle cards such as Talisman that respond to cards being bought.
for i in [@current.inPlay.length-1...-1]
cardInPlay = @current.inPlay[i]
If a Mandarin put cards back on the deck, this card may not be there anymore. This showed up in a fascinating interaction among Talisman, Quarry, Border Village, and Mandarin.
if cardInPlay?
cardInPlay.buyInPlayEffect(this, choice)
Handle all the things that happen at the end of the turn.
doCleanupPhase: () ->
Clean up Walled Villages first
actionCardsInPlay = 0
for card in @current.inPlay
if card.isAction
actionCardsInPlay += 1
if actionCardsInPlay <= 2
while c['Walled Village'] in @current.inPlay
transferCardToTop(c['Walled Village'], @current.inPlay, @current.draw)
this.log("#{@current.ai} returns a Walled Village to the top of the deck.")
@extraturn = not @extraturn and (c['Outpost'] in @current.inPlay)
Discard old duration cards.
@current.discard = @current.discard.concat @current.duration
@current.duration = []
If there are cards set aside at this point, it probably means something went wrong in performing an action. But clean them up anyway.
if @current.setAside.length > 0
this.warn(["Cards were set aside at the end of turn", @current.setAside])
@current.discard = @current.discard.concat @current.setAside
@current.setAside = []
Check which multiplier cards ended up in multipliedDurations
, which means
they should be cleaned up as if they were duration cards. Remove them once
they’re dealt with. Disregard the other cards there for now.
for i in [@current.multipliedDurations.length-1...-1]
card = @current.multipliedDurations[i]
if card.isMultiplier
this.log("#{@current.ai} puts a #{card} in the duration area.")
@current.inPlay.remove(card)
@current.duration.push(card)
@current.multipliedDurations.splice(i, 1)
Handle effects of cleaning up the card, which may involve moving it somewhere else. We do this before removing cards from play because cards such as Scheme and Herbalist need to consider cards in play.
cardsToCleanup = @current.inPlay.concat().reverse()
for i in [cardsToCleanup.length-1...-1]
card = cardsToCleanup[i]
card.onCleanup(this)
Clean up cards in play.
while @current.inPlay.length > 0
card = @current.inPlay[0]
@current.inPlay = @current.inPlay[1...]
Put duration cards by default in the duration area, and other cards in play in the discard pile.
if card.isDuration
@current.duration.push(card)
else
@current.discard.push(card)
Discard the remaining cards in hand.
@current.discard = @current.discard.concat(@current.hand)
@current.hand = []
Reset things for the next turn.
@current.actions = 1
@current.buys = 1
@current.coins = 0
@current.potions = 0
@current.actionsPlayed = 0
@copperValue = 1
@costModifiers = []
Announce extra turn
if @extraturn
this.log("#{@current.ai} takes an extra turn from Outpost.")
Finally, draw the next hand of three/five cards.
if not (c.Outpost in @current.duration)
@current.drawCards(5)
else
@current.drawCards(3)
Make sure we didn’t drop cards on the floor.
if this.countTotalCards() != @totalCards
throw new Error("The game started with #{@totalCards} cards; now there are #{this.countTotalCards()}")
The player list is implemented so that the current player is always first in the list; the list rotates after every turn.
For convenience, the attribute @current
always points to the current
player.
rotatePlayer: () ->
@players = @players[1...@nPlayers].concat [@players[0]]
@current = @players[0]
@phase = 'start'
gainCard
performs the effects of a player gaining a card.
This is one of many events that affects a particular player, and
also has some effect on the overall state (in that the supply is
decreased). In this function and others like it, the player
argument
is the appropriate PlayerState object to affect. This must, of course,
be one of the objects in the @players
array.
gainCard: (player, card, gainLocation='discard', suppressMessage=false) ->
if this.depth == 0
delete @cache.gainsToEndGame
if @supply[card] > 0 or @specialSupply[card] > 0
for i in [player.hand.length-1...-1]
reactCard = player.hand[i]
if reactCard? and reactCard.isReaction and reactCard.reactReplacingGain?
card = reactCard.reactReplacingGain(this, player, card)
Keep track of the card gained, for Smugglers.
if player is @current
player.gainedThisTurn.push(card)
suppressMessage
is true when this happens as the direct result of a
buy. Nobody wants to read “X buys Y. X gains Y.” all the time.
if not suppressMessage
this.log("#{player.ai} gains #{card}.")
Determine what list the card is being gained in, and add it to the front of that list.
location = player[gainLocation]
location.unshift(card)
Remove the card from the supply
if @supply[card] > 0
@supply[card] -= 1
gainSource = 'supply'
else
@specialSupply[card] -= 1
gainSource = 'specialSupply'
Delegate to handleGainCard
to deal with reactions.
this.handleGainCard(player, card, gainLocation, gainSource)
else
this.log("There is no #{card} to gain.")
handleGainCard
deals with the reactions that result from gaining a card.
A card effect such as Thief needs to call this explicitly after gaining a
card from someplace that is not the supply or the prize list.
handleGainCard: (player, card, gainLocation='discard', gainSource='supply') ->
Remember where the card was gained, so that reactions can find it.
player.gainLocation = gainLocation
for own supplyCard, quantity of @supply
c[supplyCard].globalGainEffect(this, player, card, gainSource)
for own supplyCard, quantity of @specialSupply
c[supplyCard].globalGainEffect(this, player, card, gainSource)
Handle cards such as Royal Seal that respond to gains while they are in play.
for i in [player.inPlay.length-1...-1]
cardInPlay = player.inPlay[i]
cardInPlay.gainInPlayEffect(this, card)
Handle cards such as Watchtower that react to gains as a Reaction card.
for i in [player.hand.length-1...-1]
reactCard = player.hand[i]
if reactCard.isReaction
reactCard.reactToGain(this, player, card)
for opp in this.players[1...]
for i in [opp.hand.length-1...-1]
reactCard = opp.hand[i]
if reactCard.isReaction
reactCard.reactToOpponentGain(this, opp, player, card)
Handle the card’s own effects of being gained.
card.onGain(this, player)
Effects of an action could cause players to reveal their hand. So far, nothing happens as a result, but in the future, AIs might be able to take advantage of the information.
revealHand: (player) ->
this.log("#{player.ai} reveals the hand (#{player.hand}).")
drawCards
causes the player to draw num
cards.
This currently passes through directly to the PlayerState, without passing any information from the global state. An improved version would pass the state in case the player shuffles, and has Stash in the deck, and wants to use information from the state to decide where to put the Stash.
The drawn cards will be returned.
drawCards: (player, num) ->
player.drawCards(num)
discardFromDeck
puts num cards from the top of the deck directly
in the discard pile. It returns the set of cards, for the benefit of
actions that do something based on which cards were discarded.
discardFromDeck: (player, nCards) ->
drawn = player.getCardsFromDeck(nCards)
player.discard = player.discard.concat(drawn)
this.log("#{player.ai} draws and discards #{drawn.length} cards (#{drawn}).")
this.handleDiscards(player, drawn)
return drawn
doDiscard
causes the player to discard a particular card.
doDiscard: (player, card) ->
if card not in player.hand
this.warn("#{player.ai} has no #{card} to discard")
return
this.log("#{player.ai} discards #{card}.")
player.hand.remove(card)
player.discard.push(card)
this.handleDiscards(player, [card])
handleDiscards
looks through a list of cards and triggers their discard
reactions.
handleDiscards: (player, cards) ->
for card in cards
if card.isReaction
card.reactToDiscard(this, player)
doTrash
causes the player to trash a particular card.
doTrash: (player, card) ->
if card not in player.hand
this.warn("#{player.ai} has no #{card} to trash")
return
this.log("#{player.ai} trashes #{card}.")
player.hand.remove(card)
card.onTrash(this, player)
@trash.push(card)
doPutOnDeck
puts a particular card from the player’s hand on top of
the player’s draw pile.
doPutOnDeck: (player, card) ->
if card not in player.hand
this.warn("#{player.ai} has no #{card} to put on deck.")
return
this.log("#{player.ai} puts #{card} on deck.")
player.hand.remove(card)
player.draw.unshift(card)
getCardsFromDeck
is superficially similar to drawCards
, but it does
not put the cards into the hand. Any code that calls it needs to determine
what happens to those cards (otherwise they’ll be dropped on the floor!)
This is useful for effects that say “draw n cards, do something based on them, and discard them”.
getCardsFromDeck: (player, num) ->
player.getCardsFromDeck(num)
allowDiscard
allows a player to discard 0 through num
cards.
added typeFunc to only allow discards of certain type of cards.
allowDiscard: (player, num, typeFunc = (card) -> true) ->
discarded = []
while discarded.length < num
In allowDiscard
, valid discards are the entire hand, plus null
to stop discarding.
validDiscards = ( card for card in player.hand when typeFunc(card?) ).slice(0)
validDiscards.push(null)
choice = player.ai.chooseDiscard(this, validDiscards)
return discarded if choice is null
discarded.push(choice)
this.doDiscard(player, choice)
return discarded
requireDiscard
requires the player to discard exactly num
cards,
except that it stops if the player has 0 cards in hand.
requireDiscard: (player, num, typeFunc = (card) -> true) ->
discarded = []
while discarded.length < num
validDiscards = ( card for card in player.hand when typeFunc(card) ).slice(0)
return discarded if validDiscards.length == 0
choice = player.ai.chooseDiscard(this, validDiscards)
discarded.push(choice)
this.doDiscard(player, choice)
return discarded
allowTrash
and requireTrash
are similar to allowDiscard
and
requireDiscard
.
allowTrash: (player, num) ->
trashed = []
while trashed.length < num
valid = player.hand.slice(0)
valid.push(null)
choice = player.ai.chooseTrash(this, valid)
return trashed if choice is null
trashed.push(choice)
this.doTrash(player, choice)
return trashed
requireTrash: (player, num) ->
trashed = []
while trashed.length < num
valid = player.hand.slice(0)
return trashed if valid.length == 0
choice = player.ai.chooseTrash(this, valid)
trashed.push(choice)
this.doTrash(player, choice)
return trashed
gainOneOf
gives the player a choice of cards to gain. Include
null
if gaining nothing is an option.
gainOneOf: (player, options, location='discard') ->
choice = player.ai.chooseGain(this, options)
return null if choice is null
this.gainCard(player, choice, location)
return choice
attackOpponents
takes in a function of one argument, and applies
it to all players except the one whose turn it is.
The function should take in the PlayerState of the player to attack,
and alter it somehow. This function can also involve the global state:
it doesn’t need to be passed in because it’s already in scope in the place
where the action is defined. See Militia
in cards.coffee
for an
example.
attackOpponents: (effect) ->
for opp in @players[1...]
this.attackPlayer(opp, effect)
attackPlayer
does the work of attacking a particular player, including
handling their reactions to attacks.
attackPlayer: (player, effect) ->
attackEvent gets passed to each reactToAttack method. Any card may block the attack by setting attackEvent.blocked to true
attackEvent = {}
Reaction cards in the hand can react to the attack
reactionCards = (card for card in player.hand when card.isReaction)
for card in reactionCards
card.reactToAttack(this, player, attackEvent)
for card in player.duration
card.durationReactToAttack(this, player, attackEvent)
Apply the attack’s effect unless it’s been blocked by a card such as Moat or Lighthouse
effect(player) unless attackEvent.blocked
copy()
makes a copy of this state that can be safely mutated
without affecting the original state.
Ideally, the AI would be passed a copy of the state, with unknown information randomized, when it is asked to make a decision. This would allow it to try simulating the effects of various plays without actually breaking the game. But this isn’t implemented yet, so make this a TODO.
copy: () ->
newSupply = {}
for key, value of @supply
newSupply[key] = value
newSpecialSupply = {}
for key, value of @specialSupply
newSpecialSupply[key] = value
newState = new State()
If something overrode the log function, make sure that’s preserved.
newState.logFunc = @logFunc
newPlayers = []
for player in @players
playerCopy = player.copy()
playerCopy.logFunc = (obj) ->
newPlayers.push(playerCopy)
Copy card-specific state
newCardState = {}
for card, state of @cardState
If the card state has a copy method, call it, otherwise just shallow copy the state
if state.copy?
Objects with a copy method
newCardState[card] = state.copy?()
else if typeof state == 'object'
Objects with no copy method
newCardState[card] = copy = {}
copy[k] = v for k, v of state
else
Simple types
newCardState[card] = state
newState.players = newPlayers
newState.supply = newSupply
newState.specialSupply = newSpecialSupply
newState.cardState = newCardState
newState.trash = @trash.slice(0)
newState.current = newPlayers[0]
newState.nPlayers = @nPlayers
newState.costModifiers = @costModifiers.concat()
newState.copperValue = @copperValue
newState.phase = @phase
newState.cache = {}
newState
hypothetical(ai)
returns a State and PlayerState that an AI can do
whatever it wants to without affecting the real state:
An AI that wants to test a hypothesis should do this: [state, my] = state.hypothetical(this)
hypothetical: (ai) ->
state = this.copy()
Rotate through players until this AI is the current player.
counter = 0
while state.players[0].ai isnt ai
counter++
if counter > state.nPlayers
throw new Error("Can't find this AI in the player list")
state.players = state.players[1...].concat([state.players[0]])
state.depth = this.depth + 1
my = null
for player in state.players
if player.ai isnt ai
player.ai = ai.copy()
We don’t know what’s in their hand or their deck, so shuffle them together randomly, preserving the number of cards.
handSize = player.hand.length
combined = player.hand.concat(player.draw)
shuffle(combined)
player.hand = combined[...handSize]
player.draw = combined[handSize...]
else
shuffle(player.draw)
my = player
[state, my]
Functions for comparing, used for sorting
Rob says: this is pretty AI-specific. It’s also an unnecessarily complex operation, even given caching. The choices are already in order in the actionPriority; they need to be filtered, not sorted.
compareByActionPriority: (state, my, x, y) ->
my.ai.cacheActionPriority(state,my)
my.ai.choiceToValue('cachedAction', state, x) - my.ai.choiceToValue('cachedAction', state, y)
compareByCoinCost: (state, my, x, y) ->
x.getCost(state)[0] - y.getCost(state)[0]
Games can provide output using the log
function.
log: (obj) ->
Only log things that actually happen.
if @depth == 0
if this.logFunc?
this.logFunc(obj)
else
if console?
console.log(obj)
A warning has a similar effect to a log message, but indicates that something has gone wrong with the gameplay.
warn: (obj) ->
if console?
console.warn("WARNING: ", obj)
Define some possible tableaux to play the game with. None of these are actually legal tableaux, but that gives strategies more room to play.
this.tableaux = {
moneyOnly: []
moneyOnlyColony: ['Platinum', 'Colony']
all: c.allCards
}
How to remove something from a JavaScript array. Modifies the list and returns the 0 or 1 removed elements.
Array.prototype.remove = (elt) ->
idx = this.lastIndexOf(elt)
if idx != -1
this.splice(idx, 1)
else
[]
Define the additional tableau of everything but Platinum/Colony. If there’s a better way to remove items from a JS array, I’d like to know what it is.
noColony = this.tableaux.all.slice(0)
noColony.remove('Platinum')
noColony.remove('Colony')
this.tableaux.noColony = noColony
This customized clone function will not make unnecessary copies of cards and AIs. However, it doesn’t seem to work.
cloneDominionObject = (obj) ->
if not obj? or typeof obj isnt 'object'
return obj
if (obj.gainPriority?) or (obj.costInCoins?)
return obj
newInstance = new obj.constructor()
for own key, value of obj
newInstance[key] = cloneDominionObject(value)
newInstance
General function to randomly shuffle a list.
shuffle = (v) ->
i = v.length
while i
j = parseInt(Math.random() * i)
i -= 1
temp = v[i]
v[i] = v[j]
v[j] = temp
v
Count the number of times a value appears in a list, coercing everything to its string value.
countStr = (list, elt) ->
count = 0
for member in list
if member.toString() == elt.toString()
count++
count
Sort by numeric value. You’d think this would be in the standard library.
numericSort = (array) ->
array.sort( (a, b) -> (a-b) )
When modifying built-in methods of core types, we need to play nice with other libraries. For instance, our Array#toString method modifies the behavior in a way that breaks the CoffeeScript compiler.
Modifies built-in methods of core Javascript types in a way that’s reversible
modifyCoreTypes = ->
Make Array#toString output more readable
Array::_originalToString ||= Array::toString
Array::toString = ->
'[' + this.join(', ') + ']'
Reverses modifications to core Javascript types
restoreCoreTypes = ->
Array::toString = Array::_originalToString if Array::_originalToString?
delete Array::_originalToString
useCoreTypeMods takes an object and the name of a method. It then wraps that method so that it correctly uses and restores our core type modifications. The modifications are visible within the method body and any child method calls, but they are cleaned up when leaving the method
useCoreTypeMods = (object, method) ->
originalMethod = "_original_#{method}"
unless object[originalMethod]?
object[originalMethod] = object[method]
object[method] = ->
try
modifyCoreTypes()
this[originalMethod](arguments...)
finally
restoreCoreTypes()
Use our core type modifications within the State object. These three methods are the ones called by external functions to set up and play a game.
useCoreTypeMods(State::, 'setUpWithOptions')
useCoreTypeMods(State::, 'gameIsOver')
useCoreTypeMods(State::, 'doPlay')
Export the State and PlayerState classes for other modules to use.
this.State = State
this.PlayerState = PlayerState
Recall that this code begins with:
{c} = require './cards' if exports?
This means “get the variable named c
from the module ./cards
. Unless
there’s no module system. In that case, don’t.”
Here’s why that is useful. When the code
is running inside node.js, it will use node.js’s import system. This
uses the predefined function require
, which doesn’t exist in a
Web browser’s JS environment.
When running in a web browser, there is no sane way for one module to import another. Instead, the typical practice — which we will use too — is to just load a bunch of JavaScript files into the same global namespace.
In that case, the variable c
already exists without any additional effort
from us. We’re polluting the global namespace and defeating some of the
point of modules, but that’s how most JavaScript in the wild works anyway.