From d84cfa80cf128b05637212eed6c355b7b9f5d425 Mon Sep 17 00:00:00 2001 From: Favyen Bastani Date: Fri, 18 Sep 2020 15:35:10 -0400 Subject: [PATCH 1/4] Support flushes on first play in trick. Only requirement on play is now that all cards should be in same normalized suit. If a tractor in the flush is beaten by another player, then the smallest beaten tractor is played, and all of the cards in the original failed attempt are stored as failed_flush in the state (and the player view). UI is not updated yet, but can render failed_flush. --- server/model.py | 78 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/server/model.py b/server/model.py index 4114327..badc3c7 100644 --- a/server/model.py +++ b/server/model.py @@ -246,6 +246,9 @@ def __init__(self, num_players, trump_rank, bottom_player, deck_name=None): # list of cards that each player has placed on the board for the current trick self.board = [[] for i in range(num_players)] + # the full list of cards in a failed flush for the current trick, if any + self.failed_flush = [] + # history of all declarations # to get the most recent declaration, use self.declaration self.declarations = [] @@ -405,6 +408,7 @@ def is_starting_trick(self): def clear_board(self): for i in range(len(self.board)): self.board[i] = [] + self.failed_flush = [] def get_trick_points(self): points = 0 @@ -449,7 +453,8 @@ def get_player_view(self, player): 'bottom_size': BOTTOM_SIZE[self.num_players], 'player_points': self.player_points, 'attacking_players': self.attacking_players, - 'bottom_player': self.bottom_player + 'bottom_player': self.bottom_player, + 'failed_flush': [card.dict for card in self.failed_flush], } if player is not None: @@ -487,35 +492,75 @@ def get_suit_tractors_from_cards(self, cards, trick_suit): suit_tractors = cards_to_tractors(suit_cards, trick_suit, self.trump_card) return suit_tractors + def is_flush_tractor_beaten(self, player, trick_suit, tractor): + ''' + Determines whether another player has cards in their hand matching the tractor's suit + that form a tractor beating the provided tractor. + ''' + for i, hand in enumerate(self.player_hands): + if i == player: + continue + hand_suit_tractors = self.get_suit_tractors_from_cards(self.player_hands[i], trick_suit) + for t in hand_suit_tractors: + # to beat tractor, t must have at least the same rank/length, while having higher power (card value) + if t.rank < tractor.rank or t.length < tractor.length: + continue + if t.power <= tractor.power: + continue + return True + + return False + def is_play_valid(self, player, cards): ''' This function checks if player's cards, aka play, is valid. In order for a play to be valid, the play must follow suit and form of the trick's first play. Otherwise, the appropriate form is calculated and used to determine whether or not the play is valid given the trick's first play. + It returns a tuple (out_cards, valid). If the play is valid, out_cards is the list of played cards, + and valid=True; out_cards may not match cards for a flush, in the case that some tractor in the + flush was not high enough. If the play is not valid, out_cards=None and valid=False. + Args: player: int cards: Card [] Returns: - bool + (Card [], bool) ''' play_card_count = len(cards) # number of cards must be nonzero if play_card_count == 0: - return False + return None, False - # TODO(workitem0028): once flushing feature is added, then multiple tractors is allowed if player wants to flush - # for now, first play must be one tractor if player == self.trick_first_player: # need the first card's suit in order to accurately transform cards to tractors if board is empty - return len(cards_to_tractors(cards, cards[0].suit, self.trump_card)) == 1 + trick_suit = cards[0].get_normalized_suit(self.trump_card) + tractors = cards_to_tractors(cards, trick_suit, self.trump_card) + # first, all tractors must be the same suit + for tractor in tractors[1:]: + if tractor.suit_type != tractors[0].suit_type: + return None, False + # if there is only one tractor, then at this point it's valid + if len(tractors) == 1: + return cards, True + # otherwise, this is a flush + # for flush plays, other players must not have tractors in their hand in the suit that + # beat any components of the flush. + # otherwise, the player is forced to play the weakest tractor that was beaten. + # in both cases, the play is valid + beaten_tractors = [tractor for tractor in tractors if self.is_flush_tractor_beaten(player, trick_suit, tractor)] + if len(beaten_tractors) == 0: + return cards, True + smallest_beaten_tractor = min(beaten_tractors) + cards = [card for l in smallest_beaten_tractor.orig_cards for card in l] + return cards, True first_play = self.board[self.trick_first_player] trick_card_count = len(first_play) # number of cards played must match number of cards in trick if play_card_count != trick_card_count: - return False + return None, False # grab trick tractor and player hand trick suit tractor rank and length data trick_card = first_play[0] @@ -526,7 +571,7 @@ def is_play_valid(self, player, cards): # if hand doesn't have any trick suit cards then player can play cards of any suit as long as # play_card_count equals trick_card_count (case already handled above) if not hand_suit_tractors: - return True + return cards, True play_suit_cards = [card for card in cards if card.get_normalized_suit(self.trump_card) == trick_suit] play_suit_tractors = cards_to_tractors(play_suit_cards, trick_suit, self.trump_card) @@ -543,17 +588,17 @@ def is_play_valid(self, player, cards): play_idx = find_matching_data_index(play_data_array, trick_data_array[0]) if play_idx is None: - return False + return None, False play_min_data = get_min_data(trick_data_array[0], play_data_array[play_idx]) if play_min_data < hand_min_data: - return False + return None, False trick_data_array = update_data_array(trick_data_array, hand_min_data) hand_data_array = update_data_array(hand_data_array, hand_min_data) play_data_array = update_data_array(play_data_array, hand_min_data) - return True + return cards, True class RoundListener(object): def round_started(self, r): @@ -709,11 +754,14 @@ def play(self, player, cards): self.state.trick_first_player = player # checks if play is invalid - if not self.state.is_play_valid(player, cards): + play_cards, valid = self.state.is_play_valid(player, cards) + if not valid: raise RoundException("Invalid play") - self.state.board[player] = cards - self.state.remove_cards_from_hand(player, cards) + if len(play_cards) != len(cards): + self.state.failed_flush = cards + self.state.board[player] = play_cards + self.state.remove_cards_from_hand(player, play_cards) # if all players have played, then we need to figure out who won to update the turn # otherwise, we can just increment it @@ -725,7 +773,7 @@ def play(self, player, cards): else: self.state.increment_turn() - self._fire(lambda listener: listener.player_played(self, player, cards)) + self._fire(lambda listener: listener.player_played(self, player, play_cards)) def set_bottom(self, player, cards): ''' From c8116018eedd55db9383c196c0e8e569d48de86b Mon Sep 17 00:00:00 2001 From: Favyen Bastani Date: Fri, 18 Sep 2020 16:33:20 -0400 Subject: [PATCH 2/4] Add a few flush validity test cases. --- server/model_test.py | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/server/model_test.py b/server/model_test.py index c381685..1d0cd23 100644 --- a/server/model_test.py +++ b/server/model_test.py @@ -422,8 +422,6 @@ def testSuitTractorsFromHandJokerTrump(self, suit_name, trick_card, suit_tractor ['different suits 1 pair + 1 single', [Card('d', '2'), Card('d', '2'), Card('s', '5')]], - ['same suits 2 nonconsecutive pairs', [Card('h', '2'), Card('h', '2'), Card('h', '6'), Card('h', '6')]], - ['different suits 2 consecutive pairs + single', [Card('d', '4'), Card('d', '4'), Card('d', '5'), Card('d', '5'), Card('c', '5')]], @@ -435,7 +433,7 @@ def testSuitTractorsFromHandJokerTrump(self, suit_name, trick_card, suit_tractor ]) def testInvalidFirstPlays(self, name, play): - self.assertFalse(self.round_state.is_play_valid(self.first_player, play)) + self.assertFalse(self.round_state.is_play_valid(self.first_player, play)[1]) @parameterized.expand([ ['1 single', [Card('h', '2')]], @@ -462,8 +460,8 @@ def testValidFirstPlays(self, name, play): def testFollowSuitValidity(self, name, first_play, invalid_play, valid_play): self.round_state.board[0] = first_play - self.assertFalse(self.round_state.is_play_valid(self.second_player, invalid_play)) - self.assertTrue(self.round_state.is_play_valid(self.second_player, valid_play)) + self.assertFalse(self.round_state.is_play_valid(self.second_player, invalid_play)[1]) + self.assertTrue(self.round_state.is_play_valid(self.second_player, valid_play)[1]) @parameterized.expand(model_test_data.follow_suit_validity_custom_hand_test_data) @@ -472,9 +470,25 @@ def testFollowSuitValidityWithCustomHand(self, name, first_play, hand, invalid_p self.round_state.board[0] = first_play self.round_state.player_hands[self.third_player] = hand if invalid_play is not None: - self.assertFalse(self.round_state.is_play_valid(self.third_player, invalid_play)) + self.assertFalse(self.round_state.is_play_valid(self.third_player, invalid_play)[1]) if valid_play is not None: - self.assertTrue(self.round_state.is_play_valid(self.third_player, valid_play)) + self.assertTrue(self.round_state.is_play_valid(self.third_player, valid_play)[1]) + + @parameterized.expand([ + ['two singles neither beaten', [Card('h', 'Q'), Card('h', 'A')], None], + + ['two singles one beaten', [Card('s', 'Q'), Card('s', 'A')], [Card('s', 'Q')]], + + ['two doubles neither beaten', [Card('s', 'Q'), Card('s', 'Q'), Card('s', 'A'), Card('s', 'A')], None], + ]) + + def testFlushValidity(self, name, play, expect): + cards, valid = self.round_state.is_play_valid(self.first_player, play) + self.assertTrue(valid) + if expect is None: + self.assertEqual(cards, play) + else: + self.assertEqual(cards, expect) @parameterized.expand([ ['no play', [], []], From 98fe6892239437c0d01e334748efbadac05af707 Mon Sep 17 00:00:00 2001 From: Favyen Bastani Date: Sat, 19 Sep 2020 11:17:48 -0400 Subject: [PATCH 3/4] display failed flush on the front-end. i did it probably hacky/ugly way... maybe someone can improve. --- server/model.py | 1 + web/css/styles.css | 25 ++++++++++++++++++++---- web/index.html | 48 ++++++++++++++++++++++++++++++++-------------- web/js/app.js | 11 ++++++++--- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/server/model.py b/server/model.py index badc3c7..4ca3109 100644 --- a/server/model.py +++ b/server/model.py @@ -454,6 +454,7 @@ def get_player_view(self, player): 'player_points': self.player_points, 'attacking_players': self.attacking_players, 'bottom_player': self.bottom_player, + 'trick_first_player': self.trick_first_player, 'failed_flush': [card.dict for card in self.failed_flush], } diff --git a/web/css/styles.css b/web/css/styles.css index 64cde71..ef16c07 100644 --- a/web/css/styles.css +++ b/web/css/styles.css @@ -128,10 +128,27 @@ h1, h2, h3 { line-height: 1.2; } +.playerCards { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + width: 100%; +} + +.playerCards .annotationLabel { + padding-left: 5px; + padding-right: 5px; +} + .playingCards .card.trump { background-color: #ffec72; } +.playingCards .card.annotation { + background-color: lightgray; +} + .inHand .card { transition: 0.2s; } @@ -249,8 +266,8 @@ h1, h2, h3 { text-rendering: optimizeLegibility; color: #546cca; letter-spacing: .05em; - text-shadow: - 4px 4px 0px #d5d5d5, + text-shadow: + 4px 4px 0px #d5d5d5, 7px 7px 0px rgba(0, 0, 0, 0.2); -webkit-mask-image: -webkit-gradient(linear, left 55%, left bottom, from(rgba(0,0,0,1)), to(rgba(0,0,0,0))); } @@ -270,7 +287,7 @@ a:visited { .gameTable { border-collapse: collapse; - margin-left: auto; + margin-left: auto; margin-right: auto; margin-top: 20px; width: 50%; @@ -308,7 +325,7 @@ a:visited { .nameBox { margin-bottom: 15px; } - + /* Style the input fields */ input, select { vertical-align: middle; diff --git a/web/index.html b/web/index.html index 7209d77..ccf9bed 100644 --- a/web/index.html +++ b/web/index.html @@ -63,7 +63,7 @@

80 POINTS

{{ game.occupied }}/{{ game.total }} - + @@ -100,18 +100,38 @@

80 POINTS

{{ player.name }}

-
-
    - -
    -
  • - - -
  • -
    -
    -
+
+ +
+
    + +
    +
  • + + +
  • +
    +
    +
+
@@ -137,7 +157,7 @@

Defenders Win

- +