-
Notifications
You must be signed in to change notification settings - Fork 43
/
gameState.coffee
1571 lines (1371 loc) · 55 KB
/
gameState.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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?
# The PlayerState class
# ---------------------
# 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
#### Informational methods
#
# 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
#### Methods that modify the PlayerState
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)
# The State class
# ---------------
# 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 = []
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.
# - `log
setUpWithOptions: (ais, options) ->
if ais.length == 0
throw new Error("There has to be at least one player.")
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
#### Informational methods
# 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
#### Playing a turn
#
# `doPlay` performs the next step of the game, which is a particular phase
# of a particular player's turn. If the phase is...
#
# - 'start': resolve duration effects, then go to action phase
# - 'action': play and resolve some number of actions, then go to
# treasure phase
# - 'treasure': play and resolve some number of treasures, then go to
# buy phase
# - 'buy': buy some number of cards, then go to cleanup phase
# - 'cleanup': resolve cleanup effects, discard everything, draw 5 cards,
# and go to the start phase of the next player's turn.
#
# 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