-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcards.py
2064 lines (1718 loc) · 91.1 KB
/
cards.py
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
# CardList extends the list class to add a few methods for dealing with
# a list of cards.
import pickle
#import msgpack as pickle
import random
import time
from typing import List
MAXINT = 2**31 - 1
LOGGING_ENABLED = False
def get_card_by_name(name):
for subclass in Card.__subclasses__():
if subclass.name == name:
return subclass
return None
class Cards(list):
def __init__(self, cards=None, randseed=None):
super().__init__()
self.randseed = randseed
# If cards is a string, then it is a list of cards to parse and add to the deck
if isinstance(cards, str):
for line in cards.split('\n'):
# Lines are in the format of "quantity cardname"
if line:
quantity, cardname = line.split(' ', 1)
self.add_cards_by_name(cardname, quantity)
elif isinstance(cards, List):
self.extend(cards)
def add_cards_by_name(self, cardname, quantity=1):
# Find subclasses of Card that have a name that matches the card name and add each one to the deck
card_class = get_card_by_name(cardname)
if not card_class:
print ("Warning: Card not found: " + cardname)
else:
for i in range(int(quantity)):
next_card = card_class()
next_card.uid = len(self)
self.append(next_card)
def shuffle(self):
# Shuffle the deck with a fixed seed
if not self.randseed is None:
random.seed(self.randseed)
random.shuffle(self)
def draw(self, quant=1):
if quant == 1:
return self.pop()
else:
return [self.pop() for i in range(quant)]
def find_and_remove(self, name, quantity=1) -> List['Card']:
# Return up to X instances of cards with the given name.
# If there are not enough cards, return what you can.
retval = []
for card in list(self):
if card.name == name:
retval.append(card)
self.remove(card)
if len(retval) == quantity:
break
self.shuffle()
return retval
def find(self, name, quantity=1) -> List['Card']:
# Return up to X instances of cards with the given name.
# If there are not enough cards, return what you can.
retval = []
for card in list(self):
if card.name == name:
retval.append(card)
if len(retval) == quantity:
break
return retval
def reveal_cards_until(self, name):
# Reveal cards until the given card is found.
# Return the list of revealed cards.
revealed_cards = []
revealed_card = None
while True:
if len(self) == 0:
break
card = self.draw()
revealed_cards.append(card)
if card.name == name:
revealed_card = card
break
return revealed_cards, revealed_card
def reveal_cards_until_not(self, name):
# Reveal cards until something OTHER than the given card is found.
# Return the list of revealed cards.
revealed_cards = []
revealed_card = None
while True:
if len(self) == 0:
break
card = self.draw()
revealed_cards.append(card)
if not card.name == name:
revealed_card = card
break
return revealed_cards, revealed_card
def count_cards(self, name, in_top=0) -> int:
count = 0
peek_cnt = 0
for card in reversed(self):
if card.name == name or card.cardtype == name:
count += 1
peek_cnt += 1
if in_top == peek_cnt:
break
return count
def put_on_bottom(self, card):
# If card is a list, put each card on the bottom of the deck
if isinstance(card, list):
for c in card:
self.put_on_bottom(c)
else:
self.insert(0, card)
def get_card(self, card_ref, player:'Player'=None, can_play=False, can_alt_play=False, can_activate=False) -> 'Card':
if (can_play or can_alt_play or can_activate) and not isinstance(player, Player):
raise Exception("Player object must be passed to get_card if play/activate filter is set.")
if isinstance(card_ref, Card):
card_ref = card_ref.uid
if isinstance(card_ref, int):
# Return the first instance where the card uid matches the card_ref
for c in self:
if (c.uid == card_ref
and (not can_play or c.can_play(player))
and (not can_alt_play or c.can_alt_play(player))
and (not can_activate or c.can_activate(player))):
return c
#print(f'Unable to find card "{card_ref}"')
return None
if isinstance(card_ref, str):
for c in self:
if (c.name == card_ref
and (not can_play or c.can_play(player))
and (not can_alt_play or c.can_alt_play(player))
and (not can_activate or c.can_activate(player))):
return c
#print(f'Unable to find card "{card_ref}"')
return None
return None
class Player:
land_drops:int = 0
lands:int = 0
colorless_lands:int = 0
mana_pool:int = 0
colorless_mana_pool:int = 0
persistent_mana_pool:int = 0 # This is mana that is from on-demand sources like Elvish Spirit Guide, and Lotus Petal. Don't spend it except as a last resort.
persistent_colorless_mana_pool:int = 0 # This is non-green mana that is from on-demand sources Simian Spirit Guide. Don't spend it except as a last resort, but spend it before regular persistent mana.
current_turn:int = 0
creature_died_this_turn:bool = False
life_total:int = 20
opponent_lifetotal:int = 20
is_pruned:bool = False # Marks a player state as pruned, meaning that it should not be evaluated for exhaustive search anymore.
pickledump = None
can_cast_wurm_now:bool = False
def __init__(self, decklist, randseed=None):
if randseed is None:
randseed = time.time()
self.randseed = randseed
self.deck:Cards = Cards(decklist, randseed)
self.hand:Cards = Cards()
self.graveyard:Cards = Cards()
self.table:Cards = Cards()
self.exile:Cards = Cards()
self.log:List[str] = [""]
self.childstates:List['Player'] = []
# If we don't have any Panglacial Wurms in the deck, we can shortcut some costly checks.
self.panglacial_in_deck:bool = self.deck.count_cards('Panglacial Wurm') > 0
def draw(self, quantity=1):
self.debug_log(f" Draw {quantity} card(s)")
for i in range(quantity):
self.hand.append(self.deck.draw())
def mulligan(self, mulligan_to=6):
# Only permit a mulligan on turn 0 before any actions are taken.
assert self.current_turn == 0, "Cannot mulligan after turn 0"
self.debug_log(f" Mulligan to {mulligan_to} cards")
# Return all cards in hand to the deck
self.deck.extend(self.hand)
# Shuffle
self.deck.shuffle()
# Draw new hand
self.draw(mulligan_to)
# TODO: Implement London Mulligan rules instead of this variety.
# Run upkeep and everything for the new hand
self.debug_log(f"Beginning turn {self.current_turn} after mulligan: " + self.short_str())
# Untap all mana sources and immediately tap them all for mana
self.mana_pool = self.lands
self.colorless_mana_pool = self.colorless_lands
# Reset flags and counts
self.creature_died_this_turn = False
self.land_drops = 1
self.can_cast_wurm_now = False
# Upkeep for permanents on table and cards in hand
for card in self.table:
card.do_upkeep(self)
for card in self.hand:
card.do_upkeep(self)
for card in self.exile:
card.do_upkeep(self)
def has_mana(self, total_cost, colorless_cost=0):
colored_cost = total_cost - colorless_cost
colored_mana_available = self.mana_pool + self.persistent_mana_pool
colorless_mana_available = self.colorless_mana_pool + self.persistent_colorless_mana_pool
return ((colored_cost <= colored_mana_available)
and (total_cost <= colored_mana_available + colorless_mana_available))
def adjust_mana_pool(self, total_cost, colorless_cost=0):
#debuglog = f"\n Adjusting mana pool for {total_cost} ({colorless_cost}) for card {debug_card_ref}"
starting_total_mana = self.mana_pool + self.colorless_mana_pool + self.persistent_mana_pool + self.persistent_colorless_mana_pool
#debuglog += f'\n {starting_total_mana} starting mana: {self.mana_pool} ({self.colorless_mana_pool}) [{self.persistent_mana_pool} ({self.persistent_colorless_mana_pool})]'
remaining_colored_cost = total_cost - colorless_cost
remaining_colorless_cost = colorless_cost
### Stage 1: Colored costs
## 1) Spend temporary colored mana on our colored costs first
## 2) Spend persistent colored mana on our remaining colored costs last
### Stage 2: Colorless costs
## 1) Spend temporary colorless mana on our colorless costs first
## 2) Spend temporary colored mana on our remaining colorless costs next
## 3) Spend persistent colorless mana on our remaining colorless costs next
## 4) Spend persistent colored mana on our remaining colorless costs last
### Stage 1: Colored costs
## Step 1.1) Spend temporary colored mana on our colored costs first
colored_usage = min(self.mana_pool, remaining_colored_cost)
self.mana_pool -= colored_usage
remaining_colored_cost -= colored_usage
#debuglog += f'\n Spent {colored_usage} colored mana on colored costs, {remaining_colored_cost} remaining'
## Step 1.2) Spend persistent colored mana on our remaining colored costs last
colored_usage = min(self.persistent_mana_pool, remaining_colored_cost)
self.persistent_mana_pool -= colored_usage
remaining_colored_cost -= colored_usage
#debuglog += f'\n Spent {colored_usage} persistent colored mana on colored costs, {remaining_colored_cost} remaining'
### Stage 2: Colorless costs
## Step 2.1) Spend temporary colorless mana on our colorless costs first
colorless_usage = min(self.colorless_mana_pool, remaining_colorless_cost)
self.colorless_mana_pool -= colorless_usage
remaining_colorless_cost -= colorless_usage
#debuglog += f'\n Spent {colorless_usage} colorless mana on colorless costs, {remaining_colorless_cost} remaining'
## Step 2.2) Spend temporary colored mana on our remaining colorless costs next
colored_usage = min(self.mana_pool, remaining_colorless_cost)
self.mana_pool -= colored_usage
remaining_colorless_cost -= colored_usage
#debuglog += f'\n Spent {colored_usage} colored mana on colorless costs, {remaining_colorless_cost} remaining'
## Step 2.3) Spend persistent colorless mana on our remaining colorless costs next
colorless_usage = min(self.persistent_colorless_mana_pool, remaining_colorless_cost)
self.persistent_colorless_mana_pool -= colorless_usage
remaining_colorless_cost -= colorless_usage
#debuglog += f'\n Spent {colorless_usage} persistent colorless mana on colorless costs, {remaining_colorless_cost} remaining'
## Step 2.4) Spend persistent colored mana on our remaining colorless costs last
colored_usage = min(self.persistent_mana_pool, remaining_colorless_cost)
self.persistent_mana_pool -= colored_usage
remaining_colorless_cost -= colored_usage
#debuglog += f'\n Spent {colored_usage} persistent colored mana on colorless costs, {remaining_colorless_cost} remaining'
# Check that we haven't overspent
assert self.mana_pool >= 0
assert self.colorless_mana_pool >= 0
assert self.persistent_mana_pool >= 0
assert self.persistent_colorless_mana_pool >= 0
# Ensure that we spent the proper amount
ending_total_mana = self.mana_pool + self.colorless_mana_pool + self.persistent_mana_pool + self.persistent_colorless_mana_pool
#debuglog += f'\n {ending_total_mana} ending mana: {self.mana_pool} ({self.colorless_mana_pool}) [{self.persistent_mana_pool} ({self.persistent_colorless_mana_pool})]'
assert starting_total_mana - total_cost == ending_total_mana, f"ERROR: Spent {starting_total_mana - ending_total_mana} mana, but should have spent {total_cost} ({colorless_cost})."
def can_play(self, card) -> bool:
card = self.hand.get_card(card, player=self, can_play=True)
return card is not None
def play(self, card_ref):
card = self.hand.get_card(card_ref, player=self, can_play=True)
if not card:
raise Exception(f'ERROR: Cannot retrieve playable card {card_ref} from hand')
self.debug_log(f" Play: {card} ({card_ref})")
self.hand.remove(card)
self.adjust_mana_pool(card.cost, card.colorless_cost)
card.play(self)
def can_alt_play(self, card_ref) -> bool:
card = self.hand.get_card(card_ref, player=self, can_alt_play=True)
return card is not None
def alt_play(self, card_ref):
card = self.hand.get_card(card_ref, player=self, can_alt_play=True)
if not card:
raise Exception(f' ERROR: Cannot alt play card {card_ref} from hand')
self.debug_log(f" Alt play: {card}")
self.hand.remove(card)
self.adjust_mana_pool(card.alt_cost, card.colorless_alt_cost)
card.alt_play(self)
def can_activate(self, card_ref) -> bool:
card = self.table.get_card(card_ref, player=self, can_activate=True)
return card is not None
def activate(self, card_ref):
card = self.table.get_card(card_ref, player=self, can_activate=True)
if not card:
raise Exception(f' ERROR: Cannot activate card {card_ref} from table')
self.debug_log(f" Activate: {card}")
self.adjust_mana_pool(card.activation_cost, card.colorless_activation_cost)
card.activate(self)
def panglacial_potential(self, additional_cost) -> bool:
if not self.panglacial_in_deck:
return False
# Check if we have a Panglacial Wurm in our deck, and have enough mana to cast it.
return (self.has_mana(PanglacialWurm.cost + additional_cost, PanglacialWurm.colorless_cost)
and self.deck.count("Panglacial Wurm") > 0)
def check_panglacial(self):
if self.panglacial_potential(0):
self.can_cast_wurm_now = True
def has_spellmastery(self):
# If there are two or more instant and sorcery cards in your graveyard, you have spellmastery
return self.graveyard.count_cards('Instant') + self.graveyard.count_cards('Sorcery') >= 2
def has_delirium(self):
# If there are four or more card types among cards in your graveyard, you have delirium
card_types = []
for card in self.graveyard:
if card.cardtype not in card_types:
card_types.append(card.cardtype)
return len(card_types) >= 4
def trigger_landfall(self, landcount=1):
# If there are cards on the table that trigger landfall, then trigger them.
for card in self.table:
# If the card has a landfall ability, then trigger it.
if hasattr(card, 'do_landfall'):
for i in range(landcount):
card.do_landfall(self)
def start_game(self) -> 'Player':
# If it's the first turn, shuffle up and draw 7 cards
if self.current_turn == 0:
self.deck.shuffle()
self.draw(7)
def start_turn(self) -> 'Player':
# Increment turn count
self.current_turn += 1
self.debug_log(f"Beginning turn {self.current_turn}: " + self.short_str())
# Untap all mana
self.mana_pool = self.lands
self.colorless_mana_pool = self.colorless_lands
# Reset flags and counts
self.creature_died_this_turn = False
self.land_drops = 1
self.can_cast_wurm_now = False
# Upkeep for permanents on table and cards in hand
for card in self.table:
card.do_upkeep(self)
for card in self.hand:
card.do_upkeep(self)
for card in self.exile:
card.do_upkeep(self)
# Draw a card for turn if it's not the first turn
if self.current_turn > 1:
self.draw()
return self
def check_win(self) -> bool:
return self.opponent_lifetotal <= 0
def step_next_actions(self) -> List['Player']:
if self.is_pruned:
return []
if len(self.childstates) == 0:
next_states = []
# Return a list of game states that are possible from the current state
# This is used to generate a tree of possible game states
if self.check_win():
self.debug_log("!!!You are a win!!!")
self.childstates = [self]
return self.childstates
# Check if we can cast a Panglacial Wurm
# Note that we're technically checking this after the resolution of whatever spell
# did the searching, but because the check that set this flag looked at the amount
# of mana that was available at the time of the search, we can cast the Panglacial
# Wurm now without any loss of gameplay integrity.
if self.can_cast_wurm_now:
# Make a copy of the current state
new_state = self.copy()
# Retrieve the wurm from within the deck
wurm = new_state.deck.find_and_remove("Panglacial Wurm", 1)
# Add the wurm to our hand
new_state.hand.append(wurm)
# Cast the wurm
new_state.play(wurm[0])
new_state.can_cast_wurm_now = False
# Add the new state to the list of child states
next_states.append(new_state)
# If we don't take advantage of it now, we've lost the opportunity for later.
self.can_cast_wurm_now = False
# No-brainer decisions:
# * If we have a Lotus Cobra we can play, then play it before we play any lands
# * If we have a land in our hand, play it
# * If we can cast Land Grant for free, then do so
# * If we can attack with a creature, then do so
# Otherwise, loop through all other cards in hand and activations (if any) and evaluate them.
# Landfall triggers take priority, so we want to play things with landfall triggers (like Lotus Cobra and Spelunking) first
# This is not a perfect system (I.E., what if we untap with 1 land and have an Elvish Spirit Guide + Forest + Lotus in hand)
# but it's a good enough approximation for now.
# TODO: Fix our system of Elvish Spirit Guide mana by adding it to a persistent mana pool that is always spent LAST and carried from turn to turn.
# Instant mana sources like Elvish Spirit Guide, Simian Spirit Guide, and Lotus Petal should be played first.
# This is because they can be used to pay for other cards that we play this turn.
if self.can_alt_play('Elvish Spirit Guide'):
copy = self.copy()
copy.alt_play('Elvish Spirit Guide')
next_states.append(copy)
elif self.can_alt_play('Simian Spirit Guide'):
copy = self.copy()
copy.alt_play('Simian Spirit Guide')
next_states.append(copy)
# Next, cards with landfall triggers should be played next.
elif self.can_play('Lotus Cobra'):
copy = self.copy()
copy.play('Lotus Cobra')
next_states.append(copy)
# Land drops take next priority -- always do those first UNLESS we have a Lotus Cobra in hand that we can cast
# If we can drop a land, and we have 1 or more lands in hand, then play them.
elif self.land_drops > 0 and self.hand.count_cards('Forest') > 0:
copy = self.copy()
for cnt in range(min(self.hand.count_cards('Forest'), self.land_drops)):
copy.play('Forest')
next_states.append(copy)
# Otherwise, if we can play Land Grant for its alternate cost, do that.
elif self.can_alt_play('Land Grant'):
copy = self.copy()
copy.alt_play('Land Grant')
next_states.append(copy)
# Check to see if we can attack with any creatures
# Can we attack with Chancellor?
elif self.can_activate('Chancellor of the Tangle'):
# Find the first copy of Chancellor in our new table that can be activated.
copy = self.copy()
copy.activate('Chancellor of the Tangle')
next_states.append(copy)
# Can we attack with Panglacial Wurm?
elif self.panglacial_in_deck and self.can_activate('Panglacial Wurm'):
copy = self.copy()
copy.activate('Panglacial Wurm')
next_states.append(copy)
# NOTE: If one wants to make saccing Steve a no-brainer, then uncomment the following lines.
# Leaving this commented will increase branching permutations, but may be worth it
# for selectively saving the activation for things like Caravan Vigil or Panglacial Wurm.
#elif self.can_activate('Sakura-Tribe Elder'):
# copy = self.copy()
# copy.activate('Sakura-Tribe Elder')
# next_states.append(copy)
else:
# Get a list of every unique card name in the hand
unique_hand_cards = []
for card in self.hand:
if card not in unique_hand_cards and not card.skip_playing_this_turn:
unique_hand_cards.append(card)
# For every card, if it is flagged to consider not playing it, then create a branch where we don't play it.
for card in self.hand:
# TODO: Fix this
if False and card.consider_not_playing and not card.skip_playing_this_turn:
copy = self.copy()
copy_card = copy.hand.get_card(card.uid)
copy_card.skip_playing_this_turn = True
next_states.append(copy)
# For every unique card, if we can play that card, then play it. Don't branch more than once for each card name.
for card in unique_hand_cards:
can_altplay = self.can_alt_play(card.name)
# Only play it for regular if the card doesn't prefer to be alt played
if self.can_play(card.name) and not (can_altplay and card.prefer_alt):
copy = self.copy()
copy.play(card.name)
next_states.append(copy)
if can_altplay:
copy = self.copy()
copy.alt_play(card.name)
next_states.append(copy)
# However, for cards already on the field, we can activate multiples of the same card
# Attempt to activate every card on the table
for card in self.table:
if self.can_activate(card):
copy = self.copy()
copy_card = copy.table.get_card(card.uid)
copy.activate(copy_card)
next_states.append(copy)
# Always consider the option of just passing the turn.
# Note that this will increase branching permutations and may be of questionable value.
# TODO: Evaluate the baseline to see if this measurably increases win rate or not.
# Just because we CAN do something on our turn, is there ever any benefit to NOT doing it on our turn?
# Or should we attempt to always use every resource available to us?
# NOTE: Examples of cards that may benefit from this are:
# * Simian Spirit Guide
# * Elvish Spirit Guide
# * Possibly Caravan Vigil...?
#copy = self.copy()
#copy.start_turn()
#next_states.append(copy)
self.childstates = next_states
# If after all that, child states is still empty, then go to the next turn.
if len(self.childstates) == 0:
copy = self.copy()
copy.start_turn()
self.childstates.append(copy)
return self.childstates
def copy(self) -> 'Player':
# Serialize self into a string
ser = self.serialize()
# Deserialize the string into a new Player object
copy = Player.deserialize(ser)
return copy
def serialize(self):
# Cache the pickle dump so that we don't do this any more frequently than we have to.
if self.pickledump is None:
# Serialize self by using pickle
pickledump = pickle.dumps(self)
# Cache the result for later in case we need to make multiple copies (quite likely)
self.pickledump = pickledump
return self.pickledump
@staticmethod
def deserialize(ser) -> 'Player':
# Deserialize the pickle into a new Player object
return pickle.loads(ser)
def dumplog(self):
print('\n'.join(self.log))
def __str__(self) -> str:
# Print out the player's current state
s = f"Turn [{self.current_turn}] - Opponent Life: {self.opponent_lifetotal}\n"
s += f" {self.deck.count_cards('Forest')} Forests in library\n"
s += f" Lands: {self.mana_pool} / {self.lands} ({self.land_drops} drops avail.)\n"
s += f" Colorless: {self.colorless_mana_pool} / {self.colorless_lands}\n"
s += f" Hand: {len(self.hand)} cards\n"
# Display the hand sorted alphabetically
for card in sorted(self.hand, key=lambda x: x.name):
s += f" {card}[{card.uid}]\n"
s += f" Table: {len(self.table)} cards\n"
for card in sorted(self.table, key=lambda x: x.name):
s += f" {card}[{card.uid}]\n"
s += f" Graveyard: {len(self.graveyard)} cards\n"
for card in sorted(self.graveyard, key=lambda x: x.name):
s += f" {card}[{card.uid}]\n"
s += f" Library: {len(self.deck)} cards\n"
s += f" Exile: {len(self.exile)} cards\n"
for card in sorted(self.exile, key=lambda x: x.name):
s += f" {card}[{card.uid}]\n"
return s
def short_str(self) -> str:
# Print out the player's current state in a very short format
# Current turn, number of lands left in our deck, number of cards in hand, mana in our pool, lands on the field, opponent's life total, and the last log entry
return f"{self.current_turn}) LID: {self.deck.count_cards('Forest')} H: {len(self.hand)} Mana: {self.mana_pool}/{self.lands} ({self.colorless_mana_pool}/{self.colorless_lands}) [{self.hand.count_cards('Forest')}] OLife: {self.opponent_lifetotal} '{self.log[-1]}'"
# Methods to support testing
def debug_force_get_card_in_hand(self, card_name, quant=1) -> 'Card':
# Ensure that the player has the given card in their hand. If they don't, then retrieve on from the deck.
while self.hand.count_cards(card_name) < quant:
assert self.deck.count_cards(card_name) > 0, f"ERROR: Cannot find required {quant} {card_name} in deck"
card = self.deck.find_and_remove(card_name)[0]
self.hand.append(card)
return self.hand.find(card_name)[0]
def debug_log(self, msg):
if LOGGING_ENABLED:
self.log.append(msg)
# Define generic Card class that has a cost, name, and ability function
class Card:
name:str = 'card'
cost:int = 0
colorless_cost:int = 0 # Colorless portion of the cost
alt_cost:int = MAXINT
colorless_alt_cost:int = 0 # Colorless portion of the alternate cost
activation_cost:int = MAXINT # Assume all activations are colorless
colorless_activation_cost:int = 0 # Colorless portion of the activation cost
cardtype:str = 'None'
prefer_alt:bool = False # If the alternate cost is available, don't evaluate the regular cost. This is useful for cards like Caravan Vigil and Land Grant.
deck_max_quant:int = 4 # How many of these cards can we play in our deck?
consider_not_playing:bool = False # Set to True if this is a card that we can potentially gain advantage by saving to a future turn -- even if we can play it. Example would be cards that add mana, like Elvish Spirit Guide.
skip_playing_this_turn:bool = False # Flag to mark when this card should be skipped and saved for a future turn
power:int = None
toughness:int = None
uid:int = -1
def __str__(self):
return self.name
def long_str(self, controller: Player):
return self.name + f" [{self.cost}] Can play: {self.can_play(controller)} / {self.can_alt_play(controller)} Can activate: {self.can_activate(controller)}"
def play(self, controller: Player):
self.resolve(controller)
def resolve(self, controller: Player):
# If it's a permanent, put it on the table
if self.is_permanent():
controller.table.append(self)
else:
# Otherwise it goes into the graveyard
controller.graveyard.append(self)
def can_play(self, controller: Player) -> bool:
return controller.has_mana(self.cost, self.colorless_cost)
def alt_play(self, controller: Player):
self.resolve(controller)
def can_alt_play(self, controller: Player) -> bool:
return controller.has_mana(self.alt_cost, self.colorless_alt_cost)
def activate(self, controller: Player):
pass
def can_activate(self, controller: Player) -> bool:
return controller.has_mana(self.activation_cost, self.colorless_activation_cost)
def is_permanent(self) -> bool:
return not (self.cardtype == 'Instant' or self.cardtype == 'Sorcery')
def do_upkeep(self, controller: Player):
# Reset our skip-playing flag so that we always consider each card fresh on each turn
self.skip_playing_this_turn = False
pass
# Forest is a card that costs 0 and has an ability that increases a player's land count by 1
# ASSUMPTION: We always tap every land for mana immediately.
# Adding a land to the battlefield untapped is to increase the controller's land count
# (how much mana is available after untap) and also increases the amount available in a
# player's mana pool.
# Note that this causes some tricky assumptions when it comes to Wild Growth (which only
# immediately adds a mana if there was an untapped forest when it was played), but we are
# able to work around it alright I think.
class Forest (Card):
name = 'Forest'
cost:int = 0
cardtype = 'Land'
deck_max_quant:int = 10 # No limit on lands to play in our deck
def can_play(self, controller: Player) -> bool:
return controller.land_drops > 0
def play(self, controller: Player):
controller.lands += 1
controller.mana_pool += 1 # Assume that every land is immediately tapped for mana when it's played.
controller.land_drops -= 1
super().play(controller)
controller.trigger_landfall()
# Lay of the Land is a card that costs 1 and has an ability that searches the deck for a land and puts it into the player's hand
class LayOfTheLand (Card):
name = 'Lay of the Land'
cost:int = 1
cardtype = 'Sorcery'
def can_play(self, controller: Player) -> bool:
return super().can_play(controller) and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.cost))
def play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().play(controller)
# Caravan Vigil is a card that costs 1 and has an ability that says: Search your library for a basic land card, reveal it, put it into your hand, then shuffle your library. You may put that card onto the battlefield instead of putting it into your hand if a creature died this turn.
# NOTE: The morbid mode of the card is done as an alternate play, so that we can more easily track its effect on the game.
class CaravanVigil (Card):
name = 'Caravan Vigil'
cost:int = 1
alt_cost:int = 1
cardtype = 'Sorcery'
prefer_alt:bool = True
consider_not_playing:bool = True # If we have Sakura-Tribe Elder, then we can get a big benefit by holding onto this card until Morbid is active.
def __init__(self):
self.mana_value = 1
pass
def can_play(self, controller: Player) -> bool:
# NOTE: If there is a Sakura-Tribe Elder on the battlefield or morbid is active, then don't play this card the regular way -- wait for the better one.
return super().can_play(controller) and (controller.table.count_cards('Sakura-Tribe Elder') == 0) and (not controller.creature_died_this_turn) and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.cost))
def play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().play(controller)
def can_alt_play(self, controller: Player) -> bool:
return super().can_alt_play(controller) and controller.deck.count_cards('Forest') > 0 and controller.creature_died_this_turn
def alt_play(self, controller: Player) -> bool:
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
# Add the land to the battlefield
controller.table.extend(cards)
controller.lands += len(cards)
# The land comes into play untapped
controller.mana_pool += len(cards)
super().alt_play(controller)
controller.trigger_landfall(len(cards))
# Traverse the Ulvenwald is a card that costs 1 and has an ability that says: Search your library for a basic land card, reveal it, put it into your hand, then shuffle your library. Delirium - If there are four or more card types among cards in your graveyard, instead search your library for a creature or land card, reveal it, put it into your hand, then shuffle your library.
# We will implement the delirium mode as an alternate play, so that we can more easily track its effect on the game.
# NOTE: Currently I don't think there is much (any) self-mill, so probably unlikely to get Delirium. Don't bother with this right now.
"""
class TraverseTheUlvenwald (Card):
name = 'Traverse the Ulvenwald'
cost:int = 1
colorless_cost:int = 0
alt_cost:int = 1
colorless_alt_cost:int = 0
cardtype = 'Sorcery'
prefer_alt:bool = True
def can_play(self, controller: Player) -> bool:
return super().can_play(controller) and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.cost))
def play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().play(controller)
def can_alt_play(self, controller: Player) -> bool:
return super().can_alt_play(controller) and controller.has_delirium()
def alt_play(self, controller: Player):
card_priorities = ['Street Wraith',
# TODO: Which land or creature do we want to find?
# Maybe Street Wraith so that we can turn this into a cycler...?
# Maybe our highest CMC creature so that we can cast it eventually?
# Maybe the creature with the highest CMC that's <= our current mana pool?
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().alt_play(controller)
"""
# Sakura-Tribe Elder is a creature that costs 2 and has an ability that says: Sacrifice Sakura-Tribe Elder: Search your library for a basic land card, put that card onto the battlefield tapped, then shuffle.
class SakuraTribeElder (Card):
name = 'Sakura-Tribe Elder'
cost:int = 2
colorless_cost:int = 1 # Colorless portion of the cost
cardtype = 'Creature'
activation_cost:int = 0
power:int = 1
toughness:int = 1
def can_activate(self, controller: Player) -> bool:
return (self in controller.table
and (
controller.deck.count_cards('Forest') > 0
or controller.panglacial_potential(self.activation_cost))
)
def activate(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
# Add a tapped forest
controller.table.extend(cards)
controller.lands += len(cards)
# Destroy self
if not self in controller.table:
raise Exception("Sakura-Tribe Elder is not on the battlefield")
controller.table.remove(self)
controller.graveyard.append(self)
# Mark that a creature died this turn
controller.creature_died_this_turn = True
# Trigger landfall
controller.trigger_landfall(len(cards))
# Arboreal Grazer is a creature that costs 1 that says "When Arboreal Grazer enters the battlefield, you may put a land card from your hand onto the battlefield tapped."
class ArborealGrazer (Card):
name = 'Arboreal Grazer'
cost:int = 1
cardtype = 'Creature'
power:int = 0
toughness:int = 3
def play(self, controller: Player):
super().play(controller)
# Put a land into play tapped
# NOTE: Cannot be used for MDFCs
cards = controller.hand.find_and_remove('Forest', 1)
controller.table.extend(cards)
controller.lands += len(cards)
controller.trigger_landfall(len(cards))
# Only let us play this card if we have more forests in hand than land drops
def can_play(self, controller: Player) -> bool:
return super().can_play(controller) and controller.hand.count_cards('Forest') > controller.land_drops
# Krosan Wayfarer is a creature that costs 1 that says "Sacrifice Krosan Wayfarer: You may put a land card from your hand onto the battlefield."
class KrosanWayfarer (Card):
name = 'Krosan Wayfarer'
cost:int = 1
cardtype = 'Creature'
activation_cost:int = 0
power:int = 1
toughness:int = 1
def play(self, controller: Player):
super().play(controller)
def can_play(self, controller: Player) -> bool:
return super().can_play(controller)
def can_activate(self, controller: Player) -> bool:
return (super().can_activate(controller)
and self in controller.table
and controller.hand.count_cards('Forest') > controller.land_drops)
def activate(self, controller: Player):
super().activate(controller)
# Put a land into play untapped
cards = controller.hand.find_and_remove('Forest', 1)
controller.table.extend(cards)
controller.lands += len(cards)
controller.mana_pool += len(cards)
# Immediately sacrifice this
controller.table.remove(self)
controller.graveyard.append(self)
controller.trigger_landfall(len(cards))
# Skyshroud Ranger is a creature that costs 1 that says "T: You may put a land card from your hand onto the battlefield. Activate only as a sorcery."
# Sakura-Tribe Scout is a near-identical card.
# NOTE: If this card sees play, then can also add the 2-mana big brothers, Scaled Herbalist, Llanowar Scout, and .
class SkyshroudRanger (Card):
name = 'Skyshroud Ranger'
cost:int = 1
cardtype = 'Creature'
activation_cost:int = 0
power:int = 1
toughness:int = 1
deck_max_quant:int = 8 # Because Sakura-Tribe Scout is a functional duplicate, we can play 8 of these in our deck.
def __init__(self):
self.is_tapped = False
def play(self, controller: Player):
# Represent summoning sickness by coming into play tapped.
self.is_tapped = True
super().play(controller)
def can_activate(self, controller: Player) -> bool:
return (not self.is_tapped
and controller.hand.count_cards('Forest') > 0
and self in controller.table)
def activate(self, controller: Player):
# Put a land into play untapped
# NOTE: Cannot be used for MDFCs
cards = controller.hand.find_and_remove('Forest', 1)
controller.table.extend(cards)
controller.lands += len(cards)
controller.trigger_landfall(len(cards))
controller.mana_pool += len(cards)
self.is_tapped = True
# Reclaim the Wastes is a card that costs 1 and when played, searches the deck for a land and puts it into the player's hand.
# It has an alternate cost of 4 that searches for 2 lands instead.
class ReclaimTheWastes (Card):
name = 'Reclaim the Wastes'
cost:int = 1
alt_cost:int = 4
colorless_alt_cost:int = 3 # Colorless portion of the alternate cost
cardtype = 'Sorcery'
def __init__(self):
pass
def can_play(self, controller: Player) -> bool:
return super().can_play(controller) and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.cost))
def play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().play(controller)
def can_alt_play (self, controller: Player) -> bool:
return super().can_alt_play(controller) and controller.deck.count_cards('Forest') > 1
def alt_play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 2)
controller.check_panglacial()
controller.hand.extend(cards)
super().alt_play(controller)
# Land Grant is a card that costs 2 and has an ability that searches the deck for a land and puts it into the player's hand
# However, if the player has no lands in hand, it costs 0.
# Each mode is implemented as a separate cost.
class LandGrant(Card):
name = 'Land Grant'
cost:int = 2
colorless_cost:int = 1 # Colorless portion of the cost
alt_cost:int = 0
cardtype = 'Sorcery'
prefer_alt = True
def can_play(self, controller: Player) -> bool:
return super().can_play(controller) and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.cost))
def play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().play(controller)
def can_alt_play(self, controller: Player) -> bool:
return (super().can_alt_play(controller)
and (controller.deck.count_cards('Forest') > 0 or controller.panglacial_potential(self.alt_cost))
and controller.hand.count_cards('Forest') == 0)
def alt_play(self, controller: Player):
cards = controller.deck.find_and_remove('Forest', 1)
controller.check_panglacial()
controller.hand.extend(cards)
super().alt_play(controller)
# Goblin Charbelcher is a card that costs 4. It has an Activation ability that costs 3, and when activated, removes cards from the top of the library until a land is reached. It reduces the enemy life total by the number of cards revealed this way and puts them all onto the bottom of the library.
# NOTE: If we permit the game to activate Belcher prior to removing all lands from the deck, it will be possible to win much faster. HOWEVER, this is also "cheating" in that the game knows the contents of the deck and can therefore make a decision that the player cannot.