1#!/usr/bin/env python
2# -*- mode: python; coding: utf-8; -*-
3# ---------------------------------------------------------------------------##
4#
5# Copyright (C) 1998-2003 Markus Franz Xaver Johannes Oberhumer
6# Copyright (C) 2003 Mt. Hood Playing Card Co.
7# Copyright (C) 2005-2009 Skomoroh
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program.  If not, see <http://www.gnu.org/licenses/>.
21#
22# ---------------------------------------------------------------------------##
23
24from pysollib.game import Game
25from pysollib.gamedb import GI, GameInfo, registerGame
26from pysollib.hint import DefaultHint
27from pysollib.layout import Layout
28from pysollib.stack import \
29        BasicRowStack, \
30        InitialDealTalonStack, \
31        InvisibleStack, \
32        Stack, \
33        StackWrapper, \
34        TalonStack, \
35        WasteStack, \
36        WasteTalonStack
37from pysollib.util import ACE, NO_SUIT
38
39# ************************************************************************
40# *
41# ************************************************************************
42
43
44class Montana_Hint(DefaultHint):
45    def computeHints(self):
46        game = self.game
47        RSTEP, RBASE = game.RSTEP, game.RBASE
48        freerows = [s for s in game.s.rows if not s.cards]
49        # for each stack
50        for r in game.s.rows:
51            if not r.cards:
52                continue
53            assert len(r.cards) == 1 and r.cards[-1].face_up
54            c, pile, rpile = r.cards[0], r.cards, []
55            if r.id % RSTEP > 0:
56                left = game.s.rows[r.id - 1]
57            else:
58                left = None
59                if c.rank == RBASE:
60                    # do not move the leftmost card of a row if the
61                    # rank is correct
62                    continue
63            for t in freerows:
64                if self.shallMovePile(r, t, pile, rpile):
65                    # FIXME: this scoring is completely simple
66                    if left and left.cards:
67                        # prefer low-rank left neighbours
68                        score = 40000 + (self.K - left.cards[-1].rank)
69                    else:
70                        score = 50000
71                    self.addHint(score, 1, r, t)
72
73
74# ************************************************************************
75# * Montana
76# ************************************************************************
77
78class Montana_Talon(TalonStack):
79    def canDealCards(self):
80        return self.round != self.max_rounds and not self.game.isGameWon()
81
82    def _inSequence(self, card, suit, rank):
83        return card.suit == suit and card.rank == rank
84
85    def dealCards(self, sound=False):
86        # move cards to the Talon, shuffle and redeal
87        game = self.game
88        decks = game.gameinfo.decks
89        RSTEP, RBASE = game.RSTEP, game.RBASE
90        num_cards = 0
91        assert len(self.cards) == 0
92        rows = game.s.rows
93        # move out-of-sequence cards from the Tableau to the Talon
94        stacks = []
95        gaps = [None] * 4 * decks
96        for g in range(4*decks):
97            i = g * RSTEP
98            r = rows[i]
99            if r.cards and r.cards[-1].rank == RBASE:
100                in_sequence, suit = 1, r.cards[-1].suit
101            else:
102                in_sequence, suit = 0, NO_SUIT
103            for j in range(RSTEP):
104                r = rows[i + j]
105                if in_sequence:
106                    if (not r.cards or
107                            not self._inSequence(r.cards[-1], suit, RBASE+j)):
108                        in_sequence = 0
109                if not in_sequence:
110                    stacks.append(r)
111                    if gaps[g] is None:
112                        gaps[g] = r
113                    if r.cards:
114                        game.moveMove(1, r, self, frames=0)
115                        num_cards = num_cards + 1
116        assert len(self.cards) == num_cards
117        assert len(stacks) == num_cards + len(gaps)
118        if num_cards == 0:          # game already finished
119            return 0
120        if sound:
121            game.startDealSample()
122        # shuffle
123        game.shuffleStackMove(self)
124        # redeal
125        game.nextRoundMove(self)
126        spaces = self.getRedealSpaces(stacks, gaps)
127        for r in stacks:
128            if r not in spaces:
129                self.game.moveMove(1, self, r, frames=4)
130        # done
131        assert len(self.cards) == 0
132        if sound:
133            game.stopSamples()
134        return num_cards
135
136    def getRedealSpaces(self, stacks, gaps):
137        # the spaces are directly after the sorted sequence in each row
138        return gaps
139
140
141class Montana_RowStack(BasicRowStack):
142    def acceptsCards(self, from_stack, cards):
143        if not BasicRowStack.acceptsCards(self, from_stack, cards):
144            return False
145        if self.id % self.game.RSTEP == 0:
146            return cards[0].rank == self.game.RBASE
147        left = self.game.s.rows[self.id - 1]
148        return left.cards and left.cards[-1].suit == cards[0].suit \
149            and left.cards[-1].rank + 1 == cards[0].rank
150
151    def clickHandler(self, event):
152        if not self.cards:
153            return self.quickPlayHandler(event)
154        return BasicRowStack.clickHandler(self, event)
155
156    getBottomImage = Stack._getBlankBottomImage
157
158
159class Montana(Game):
160    Talon_Class = StackWrapper(Montana_Talon, max_rounds=3)
161    RowStack_Class = Montana_RowStack
162    Hint_Class = Montana_Hint
163
164    RLEN, RSTEP, RBASE = 52, 13, 1
165
166    def createGame(self, round_text=True):
167        decks = self.gameinfo.decks
168
169        # create layout
170        l, s = Layout(self, card_x_space=4), self.s
171
172        # set window
173        w, h = l.XM + self.RSTEP*l.XS, l.YM + (4*decks)*l.YS
174        if round_text:
175            h += l.YS
176        self.setSize(w, h)
177
178        # create stacks
179        for k in range(decks):
180            for i in range(4):
181                x, y = l.XM, l.YM + (i+k*4)*l.YS
182                for j in range(self.RSTEP):
183                    s.rows.append(self.RowStack_Class(x, y, self,
184                                  max_accept=1, max_cards=1))
185                    x += l.XS
186        if round_text:
187            x, y = l.XM + (self.RSTEP-1)*l.XS//2, self.height-l.YS
188            s.talon = self.Talon_Class(x, y, self)
189            l.createRoundText(s.talon, 'se')
190        else:
191            # Talon is invisible
192            x, y = self.getInvisibleCoords()
193            s.talon = self.Talon_Class(x, y, self)
194        if self.RBASE:
195            # create an invisible stack to hold the four Aces
196            s.internals.append(InvisibleStack(self))
197
198        # define stack-groups
199        l.defaultStackGroups()
200
201    #
202    # game overrides
203    #
204
205    def startGame(self):
206        frames = 0
207        toprows = len(self.s.talon.cards) * .75
208        for i in range(len(self.s.talon.cards)):
209            c = self.s.talon.cards[-1]
210            if c.rank == ACE:
211                self.s.talon.dealRow(rows=self.s.internals, frames=0)
212            else:
213                if frames == 0 and i >= toprows:
214                    self.startDealSample()
215                    frames = 4
216                self.s.talon.dealRow(rows=(self.s.rows[i],), frames=frames)
217
218    def isGameWon(self):
219        rows = self.s.rows
220        for i in range(0, self.RLEN, self.RSTEP):
221            if not rows[i].cards:
222                return False
223            suit = rows[i].cards[-1].suit
224            for j in range(self.RSTEP - 1):
225                r = rows[i + j]
226                if not r.cards or r.cards[-1].rank != self.RBASE + j \
227                        or r.cards[-1].suit != suit:
228                    return False
229        return True
230
231    def getHighlightPilesStacks(self):
232        return ()
233
234    def getAutoStacks(self, event=None):
235        return (self.sg.dropstacks, (), self.sg.dropstacks)
236
237    shallHighlightMatch = Game._shallHighlightMatch_SS
238
239    def getQuickPlayScore(self, ncards, from_stack, to_stack):
240        if from_stack.cards:
241            if from_stack.id % self.RSTEP == 0 and \
242                    from_stack.cards[-1].rank == self.RBASE:
243                # do not move the leftmost card of a row if the rank is correct
244                return -1
245        return 1
246
247
248# ************************************************************************
249# * Spaces
250# ************************************************************************
251
252class Spaces_Talon(Montana_Talon):
253    def getRedealSpaces(self, stacks, gaps):
254        # use four random spaces, ignore gaps
255        # note: the random.seed is already saved in shuffleStackMove
256        spaces = []
257        while len(spaces) != 4:
258            r = self.game.random.choice(stacks)
259            if r not in spaces:
260                spaces.append(r)
261        return spaces
262
263
264class Spaces(Montana):
265    Talon_Class = StackWrapper(Spaces_Talon, max_rounds=3)
266
267
268# ************************************************************************
269# * Blue Moon
270# ************************************************************************
271
272class BlueMoon(Montana):
273    RLEN, RSTEP, RBASE = 56, 14, 0
274
275    def startGame(self):
276        frames = 0
277        for i in range(self.RLEN):
278            if i == self.RLEN-self.RSTEP:  # last row
279                self.startDealSample()
280                frames = -1
281            if i % self.RSTEP == 0:     # left column
282                continue
283            self.s.talon.dealRow(rows=(self.s.rows[i],), frames=frames)
284        ace_rows = [r for r in self.s.rows
285                    if r.cards and r.cards[-1].rank == ACE]
286        j = 0
287        for r in ace_rows:
288            self.moveMove(1, r, self.s.rows[j])
289            j += self.RSTEP
290
291
292# ************************************************************************
293# * Red Moon
294# ************************************************************************
295
296class RedMoon(BlueMoon):
297    def _shuffleHook(self, cards):
298        # move Aces to top of the Talon (i.e. first cards to be dealt)
299        return self._shuffleHookMoveToTop(
300            cards, lambda c: (c.rank == 0, c.suit))
301
302    def startGame(self):
303        decks = self.gameinfo.decks
304        frames = 0
305        r = self.s.rows
306        self.s.talon.dealRow(rows=(r[::14]), frames=frames)
307        for i in range(4*decks):
308            if i == 4*decks-1:
309                self.startDealSample()
310                frames = 4
311            n = i * 14 + 2
312            self.s.talon.dealRow(rows=r[n:n+12], frames=frames)
313
314
315# ************************************************************************
316# * Galary
317# ************************************************************************
318
319
320class Galary_Hint(Montana_Hint):
321    def shallMovePile(self, from_stack, to_stack, pile, rpile):
322        if from_stack is to_stack or \
323                not to_stack.acceptsCards(from_stack, pile):
324            return False
325        # now check for loops
326        rr = self.ClonedStack(from_stack, stackcards=rpile)
327        if rr.acceptsCards(to_stack, pile):
328            # the pile we are going to move could be moved back -
329            # this is dangerous as we can create endless loops...
330            return False
331        return True
332
333
334class Galary_RowStack(Montana_RowStack):
335    def acceptsCards(self, from_stack, cards):
336        if not BasicRowStack.acceptsCards(self, from_stack, cards):
337            return False
338        if self.id % self.game.RSTEP == 0:
339            return cards[0].rank == self.game.RBASE
340        r = self.game.s.rows
341        left = r[self.id - 1]
342        if left.cards and left.cards[-1].suit == cards[0].suit \
343                and left.cards[-1].rank + 1 == cards[0].rank:
344            return True
345        if self.id < len(r)-1:
346            right = r[self.id + 1]
347            if right.cards and right.cards[-1].suit == cards[0].suit \
348                    and right.cards[-1].rank - 1 == cards[0].rank:
349                return True
350        return False
351
352
353class Galary(RedMoon):
354    RowStack_Class = Galary_RowStack
355    Hint_Class = Galary_Hint
356
357
358# ************************************************************************
359# * Moonlight
360# ************************************************************************
361
362class Moonlight(Montana):
363    RowStack_Class = Galary_RowStack
364    Hint_Class = Galary_Hint
365
366
367# ************************************************************************
368# * Jungle
369# ************************************************************************
370
371class Jungle_RowStack(Montana_RowStack):
372    def acceptsCards(self, from_stack, cards):
373        if not BasicRowStack.acceptsCards(self, from_stack, cards):
374            return False
375        if self.id % self.game.RSTEP == 0:
376            return cards[0].rank == self.game.RBASE
377        left = self.game.s.rows[self.id - 1]
378        return left.cards and left.cards[-1].rank + 1 == cards[0].rank
379
380
381class Jungle(BlueMoon):
382    Talon_Class = StackWrapper(Montana_Talon, max_rounds=2)
383    RowStack_Class = Jungle_RowStack
384    Hint_Class = Galary_Hint
385
386
387# ************************************************************************
388# * Spaces and Aces
389# ************************************************************************
390
391class SpacesAndAces_RowStack(Montana_RowStack):
392    def acceptsCards(self, from_stack, cards):
393        if not BasicRowStack.acceptsCards(self, from_stack, cards):
394            return False
395        if self.id % self.game.RSTEP == 0:
396            return cards[0].rank == self.game.RBASE
397        left = self.game.s.rows[self.id - 1]
398        return left.cards and left.cards[-1].suit == cards[0].suit \
399            and left.cards[-1].rank < cards[0].rank
400
401
402class SpacesAndAces(BlueMoon):
403    Hint_Class = Galary_Hint
404    Talon_Class = InitialDealTalonStack
405    RowStack_Class = SpacesAndAces_RowStack
406
407    def createGame(self):
408        Montana.createGame(self, round_text=False)
409
410    def startGame(self):
411        frames = 0
412        for i in range(self.RLEN):
413            if i == self.RLEN-self.RSTEP:  # last row
414                self.startDealSample()
415                frames = -1
416            if i % self.RSTEP == 0:     # left column
417                continue
418            self.s.talon.dealRow(rows=(self.s.rows[i],), frames=frames)
419
420# ************************************************************************
421# * Paganini
422# ************************************************************************
423
424
425class Paganini_Talon(Montana_Talon):
426    def _inSequence(self, card, suit, rank):
427        card_rank = card.rank
428        if card_rank >= 5:
429            card_rank -= 4
430        return card.suit == suit and card_rank == rank
431
432
433class Paganini_RowStack(Montana_RowStack):
434    def acceptsCards(self, from_stack, cards):
435        if not BasicRowStack.acceptsCards(self, from_stack, cards):
436            return False
437        if self.id % self.game.RSTEP == 0:
438            return cards[0].rank == self.game.RBASE
439        left = self.game.s.rows[self.id - 1]
440        if not left.cards:
441            return False
442        if left.cards[-1].suit != cards[0].suit:
443            return False
444        if left.cards[-1].rank == ACE:
445            return cards[0].rank == 5
446        return left.cards[-1].rank+1 == cards[0].rank
447
448
449class Paganini(BlueMoon):
450    RLEN, RSTEP, RBASE = 40, 10, 0
451
452    Talon_Class = StackWrapper(Paganini_Talon, max_rounds=2)
453    RowStack_Class = Paganini_RowStack
454
455    def isGameWon(self):
456        rows = self.s.rows
457        for i in range(0, self.RLEN, self.RSTEP):
458            if not rows[i].cards:
459                return False
460            suit = rows[i].cards[-1].suit
461            for j in range(self.RSTEP - 1):
462                r = rows[i + j]
463                if not r.cards:
464                    return False
465                card = r.cards[-1]
466                card_rank = card.rank
467                if card_rank >= 5:
468                    card_rank -= 4
469                if card_rank != self.RBASE + j or card.suit != suit:
470                    return False
471        return True
472
473
474# ************************************************************************
475# * Spoilt
476# ************************************************************************
477
478class Spoilt_RowStack(BasicRowStack):
479    def acceptsCards(self, from_stack, cards):
480        # if not BasicRowStack.acceptsCards(self, from_stack, cards):
481        #    return False
482
483        card = cards[0]
484        RSTEP = self.game.RSTEP
485        RBASE = self.game.RBASE
486        row, col = divmod(self.id, RSTEP)
487        # check rank
488        if card.rank == ACE:
489            if col != RSTEP-1:
490                return False
491        else:
492            if card.rank - RBASE != col:
493                return False
494        # check suit
495        suit = None
496        for i in range(row*RSTEP, (row+1)*RSTEP):
497            r = self.game.s.rows[i]
498            if r.cards and r.cards[0].face_up:
499                suit = r.cards[0].suit
500                break
501        if suit is not None:
502            return card.suit == suit
503        for r in self.game.s.rows:      # check other rows
504            if r.cards and r.cards[0].face_up and r.cards[0].suit == card.suit:
505                return False
506        return True
507
508    def canFlipCard(self):
509        return False
510
511
512class Spoilt_Waste(WasteStack):
513
514    def moveMove(self, ncards, to_stack, frames=-1, shadow=-1):
515        assert ncards == 1 and to_stack in self.game.s.rows
516        if to_stack.cards:
517            self._swapPairMove(ncards, to_stack, frames=-1, shadow=0)
518        else:
519            WasteStack.moveMove(self, ncards, to_stack, frames, shadow)
520
521    def _swapPairMove(self, n, other_stack, frames=-1, shadow=-1):
522        game = self.game
523        old_state = game.enterState(game.S_FILL)
524        swap = game.s.internals[0]
525        game.flipMove(other_stack)
526        game.moveMove(n, self, swap, frames=0)
527        game.moveMove(n, other_stack, self, frames=frames, shadow=shadow)
528        game.moveMove(n, swap, other_stack, frames=0)
529        game.leaveState(old_state)
530
531
532class Spoilt(Game):
533    RSTEP, RBASE = 8, 6
534
535    def createGame(self):
536        # create layout
537        l, s = Layout(self), self.s
538
539        # set window
540        self.setSize(l.XM + self.RSTEP*l.XS, l.YM + 5.5*l.YS)
541
542        # create stacks
543        for i in range(4):
544            x, y, = l.XM, l.YM + i*l.YS
545            for j in range(self.RSTEP):
546                s.rows.append(Spoilt_RowStack(x, y, self,
547                              max_accept=1, max_cards=2, min_cards=1))
548                x += l.XS
549        x, y = self.width//2 - l.XS, self.height-l.YS
550        s.talon = WasteTalonStack(x, y, self, max_rounds=1)
551        l.createText(s.talon, 'n')
552        x += l.XS
553        s.waste = Spoilt_Waste(x, y, self, max_cards=1)
554
555        # create an invisible stack
556        s.internals.append(InvisibleStack(self))
557
558        # define stack-groups
559        l.defaultStackGroups()
560
561    def startGame(self):
562        self.startDealSample()
563        for i in range(4):
564            rows = self.s.rows[self.RSTEP*i+1:self.RSTEP*(i+1)]
565            self.s.talon.dealRow(rows=rows, frames=4, flip=False)
566        self.s.talon.dealCards()
567
568    def isGameWon(self):
569        for r in self.s.rows:
570            if not r.cards:
571                return False
572            if not r.cards[0].face_up:
573                return False
574        return True
575
576    def getHighlightPilesStacks(self):
577        return ()
578
579    def getAutoStacks(self, event=None):
580        return (), (), ()
581
582
583# ************************************************************************
584# * Double Montana
585# ************************************************************************
586
587class DoubleMontana(Montana):
588    Talon_Class = InitialDealTalonStack
589    Hint_Class = Galary_Hint
590    RLEN, RSTEP, RBASE = 112, 14, 0
591
592    def createGame(self):
593        Montana.createGame(self, round_text=False)
594
595    def startGame(self):
596        frames = 0
597        for i in range(self.RLEN):
598            if i == self.RLEN-self.RSTEP:  # last row
599                self.startDealSample()
600                frames = -1
601            if i % self.RSTEP == 0:     # left column
602                continue
603            self.s.talon.dealRow(rows=(self.s.rows[i],), frames=frames)
604
605
606class DoubleBlueMoon(DoubleMontana, BlueMoon):
607    Talon_Class = StackWrapper(Montana_Talon, max_rounds=3)
608    RLEN, RSTEP, RBASE = 112, 14, 0
609
610    def createGame(self):
611        BlueMoon.createGame(self, round_text=True)
612    startGame = BlueMoon.startGame
613
614
615class DoubleRedMoon(DoubleMontana, RedMoon):
616    Talon_Class = StackWrapper(Montana_Talon, max_rounds=3)
617    RLEN, RROWS = 112, 8
618    _shuffleHook = RedMoon._shuffleHook
619
620    def createGame(self):
621        RedMoon.createGame(self, round_text=True)
622    startGame = RedMoon.startGame
623
624
625# ************************************************************************
626# * House of Commons
627# * Pretzel
628# ************************************************************************
629
630
631class HouseOfCommons(Montana):
632    Talon_Class = StackWrapper(Montana_Talon, max_rounds=2)
633    RLEN, RSTEP, RBASE = 40, 10, 1
634
635    def createGame(self):
636        Montana.createGame(self, round_text=True)
637
638
639class Pretzel(Montana):
640    Talon_Class = InitialDealTalonStack
641    RLEN, RSTEP, RBASE = 20, 5, 1
642
643    def createGame(self):
644        Montana.createGame(self, round_text=False)
645
646
647# register the game
648registerGame(GameInfo(53, Montana, "Montana",
649                      GI.GT_MONTANA | GI.GT_OPEN, 1, 2, GI.SL_MOSTLY_SKILL,
650                      si={"ncards": 48}, altnames="Gaps"))
651registerGame(GameInfo(116, Spaces, "Spaces",
652                      GI.GT_MONTANA | GI.GT_OPEN, 1, 2, GI.SL_MOSTLY_SKILL,
653                      si={"ncards": 48}, altnames="Addiction"))
654registerGame(GameInfo(63, BlueMoon, "Blue Moon",
655                      GI.GT_MONTANA | GI.GT_OPEN, 1, 2, GI.SL_MOSTLY_SKILL,
656                      altnames=("Rangoon",)))
657registerGame(GameInfo(117, RedMoon, "Red Moon",
658                      GI.GT_MONTANA | GI.GT_OPEN, 1, 2, GI.SL_MOSTLY_SKILL))
659registerGame(GameInfo(275, Galary, "Galary",
660                      GI.GT_MONTANA | GI.GT_OPEN | GI.GT_ORIGINAL, 1, 2,
661                      GI.SL_MOSTLY_SKILL))
662registerGame(GameInfo(276, Moonlight, "Moonlight",
663                      GI.GT_MONTANA | GI.GT_OPEN, 1, 2, GI.SL_MOSTLY_SKILL,
664                      si={"ncards": 48}, altnames="Free Parking"))
665registerGame(GameInfo(380, Jungle, "Jungle",
666                      GI.GT_MONTANA | GI.GT_OPEN, 1, 1, GI.SL_MOSTLY_SKILL))
667registerGame(GameInfo(381, SpacesAndAces, "Spaces and Aces",
668                      GI.GT_MONTANA | GI.GT_OPEN, 1, 0, GI.SL_MOSTLY_SKILL))
669registerGame(GameInfo(706, Paganini, "Paganini",
670                      GI.GT_MONTANA | GI.GT_OPEN, 1, 1, GI.SL_MOSTLY_SKILL,
671                      ranks=(0, 5, 6, 7, 8, 9, 10, 11, 12),
672                      altnames=('Long Trip',)))
673registerGame(GameInfo(736, Spoilt, "Spoilt",
674                      GI.GT_MONTANA, 1, 0, GI.SL_MOSTLY_LUCK,
675                      ranks=(0, 6, 7, 8, 9, 10, 11, 12)))
676registerGame(GameInfo(759, DoubleMontana, "Double Montana",
677                      GI.GT_MONTANA | GI.GT_OPEN, 2, 0, GI.SL_MOSTLY_SKILL))
678registerGame(GameInfo(770, DoubleBlueMoon, "Double Blue Moon",
679                      GI.GT_MONTANA | GI.GT_OPEN, 2, 2, GI.SL_MOSTLY_SKILL))
680registerGame(GameInfo(771, DoubleRedMoon, "Double Red Moon",
681                      GI.GT_MONTANA | GI.GT_OPEN, 2, 2, GI.SL_MOSTLY_SKILL))
682registerGame(GameInfo(794, HouseOfCommons, "House of Commons",
683                      GI.GT_MONTANA | GI.GT_OPEN, 1, 1, GI.SL_MOSTLY_SKILL,
684                      ranks=(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), si={"ncards": 36}))
685registerGame(GameInfo(795, Pretzel, "Pretzel",
686                      GI.GT_MONTANA | GI.GT_OPEN, 1, 0, GI.SL_MOSTLY_SKILL,
687                      ranks=(0, 1, 2, 3, 4), si={"ncards": 16}))
688