vincentlui commited on
Commit
743c074
1 Parent(s): 0e339f1
Files changed (5) hide show
  1. app.py +18 -7
  2. bridge_util.py +104 -0
  3. dds_util.py +1 -17
  4. hand_record.py +9 -10
  5. pbn_util.py +42 -60
app.py CHANGED
@@ -5,7 +5,8 @@ import os
5
  from tempfile import mkdtemp
6
  from timeit import default_timer as timer
7
  from hand_record import create_hand_record_pdf
8
- from pbn_util import validate_pbn, create_single_pbn_string
 
9
 
10
  # Download model and libraries from repo
11
  try:
@@ -45,7 +46,8 @@ def predict(image_path, top_hand_idx):
45
  df = default_df.copy(deep=True)#pd.DataFrame(['♠', '♥', '♦', '♣'], columns=[''])
46
  for hand in hands:
47
  df[hand.direction] = [''.join(c) for c in hand.cards]
48
- except:
 
49
  raise gr.Error('Cannot process image')
50
 
51
  end = timer()
@@ -67,17 +69,15 @@ def save_file(df, cache_dir, files, board_no):
67
  d = mkdtemp()
68
 
69
  try:
70
- pbn_str = create_single_pbn_string(df, board_no=board_no)
71
- validate_pbn(pbn_str)
72
  except Exception as e:
73
  print(e)
74
- gr.Warning(f'Fail to save: {e}')
75
  return files, files, d
76
 
77
  file_name = f'board_{board_no:03d}.pbn'
78
  file_path = os.path.join(d,file_name)
79
- with open(file_path, 'w') as f:
80
- f.write(pbn_str)
81
 
82
  if not file_path in files:
83
  files.append(file_path)
@@ -87,6 +87,13 @@ def create_hand_record(files, event, site):
87
  file_path = create_hand_record_pdf(files, event=event, site=site)
88
  return file_path
89
 
 
 
 
 
 
 
 
90
  with gr.Blocks(css=custom_css) as demo:
91
  gr.Markdown(
92
  """
@@ -111,6 +118,7 @@ with gr.Blocks(css=custom_css) as demo:
111
  total = gr.State(0)
112
  gradio_cache_dir = gr.State()
113
  files = gr.State([])
 
114
  with gr.Tab('Scan Image'):
115
  with gr.Row():
116
  with gr.Column():
@@ -139,6 +147,7 @@ with gr.Blocks(css=custom_css) as demo:
139
  headers=['', 'N', 'E', 'S', 'W'],
140
  interactive=True, column_widths=['8%', '23%','23%','23%','23%'])
141
  b2 = gr.Button('Save')
 
142
  b3 = gr.File(interactive=False, file_count='multiple')
143
 
144
  with gr.Tab('Hand Record'):
@@ -155,5 +164,7 @@ with gr.Blocks(css=custom_css) as demo:
155
  a2.add([a1,b1])
156
  a3.click(predict, [a1, a_top], [b1])
157
  b2.click(save_file, [b1, gradio_cache_dir, files, a_board_no], [b3, tab2_upload_file, gradio_cache_dir])
 
 
158
 
159
  demo.queue().launch()
 
5
  from tempfile import mkdtemp
6
  from timeit import default_timer as timer
7
  from hand_record import create_hand_record_pdf
8
+ from pbn_util import create_pbn_file
9
+ from bridge_util import validate_dataframe, df_info, roll_direction
10
 
11
  # Download model and libraries from repo
12
  try:
 
46
  df = default_df.copy(deep=True)#pd.DataFrame(['♠', '♥', '♦', '♣'], columns=[''])
47
  for hand in hands:
48
  df[hand.direction] = [''.join(c) for c in hand.cards]
49
+ except Exception as e:
50
+ print(e)
51
  raise gr.Error('Cannot process image')
52
 
53
  end = timer()
 
69
  d = mkdtemp()
70
 
71
  try:
72
+ validate_dataframe(df)
 
73
  except Exception as e:
74
  print(e)
75
+ gr.Warning(f'Fail to save pbn. Error in table entries. {e}')
76
  return files, files, d
77
 
78
  file_name = f'board_{board_no:03d}.pbn'
79
  file_path = os.path.join(d,file_name)
80
+ create_pbn_file(df, file_path)
 
81
 
82
  if not file_path in files:
83
  files.append(file_path)
 
87
  file_path = create_hand_record_pdf(files, event=event, site=site)
88
  return file_path
89
 
90
+ def print_df_info(df):
91
+ return df_info(df)
92
+
93
+ def change_direction_in_df(df, top_direction_idx:int, current_top_idx:int):
94
+ roll_idx = current_top_idx - top_direction_idx
95
+ return roll_direction(df, roll_idx), top_direction_idx
96
+
97
  with gr.Blocks(css=custom_css) as demo:
98
  gr.Markdown(
99
  """
 
118
  total = gr.State(0)
119
  gradio_cache_dir = gr.State()
120
  files = gr.State([])
121
+ current_top_idx = gr.State(0)
122
  with gr.Tab('Scan Image'):
123
  with gr.Row():
124
  with gr.Column():
 
147
  headers=['', 'N', 'E', 'S', 'W'],
148
  interactive=True, column_widths=['8%', '23%','23%','23%','23%'])
149
  b2 = gr.Button('Save')
150
+ b_info_panel = gr.TextArea(lines=5,show_label=False, interactive=False)
151
  b3 = gr.File(interactive=False, file_count='multiple')
152
 
153
  with gr.Tab('Hand Record'):
 
164
  a2.add([a1,b1])
165
  a3.click(predict, [a1, a_top], [b1])
166
  b2.click(save_file, [b1, gradio_cache_dir, files, a_board_no], [b3, tab2_upload_file, gradio_cache_dir])
167
+ b1.change(print_df_info, b1, b_info_panel)
168
+ a_top.change(change_direction_in_df, [b1, a_top, current_top_idx], [b1, current_top_idx])
169
 
170
  demo.queue().launch()
bridge_util.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bridgebots import Suit, Direction, Card, Rank
2
+ from bridgebots.pbn import from_pbn_deal
3
+ from pbn_util import pbn_deal_string
4
+ from collections import Counter
5
+
6
+ DEALER_LIST = ['N', 'E', 'S', 'W']
7
+ VULNERABILITY_LIST = ["None","NS","EW","All","NS","EW","All","None","EW","All","None","NS","All","None","NS","EW"]
8
+ SUITS = [Suit.SPADES, Suit.HEARTS, Suit.DIAMONDS, Suit.CLUBS]
9
+ RANKS = [r.abbreviation() for r in Rank]
10
+
11
+ def get_vulnerabilty_from_board_number(board_no: int) -> str:
12
+ # Get vulnerability from board number
13
+ return VULNERABILITY_LIST[(board_no-1) % 16]
14
+
15
+ def get_dealer_from_board_number(board_no: int) -> str:
16
+ return DEALER_LIST[(board_no-1) % 4]
17
+
18
+ def validate_dataframe(df) -> None:
19
+ """ validate submitted dataframe before saving pbn """
20
+ # Check column names to be NESW
21
+ missing_direction = [direction.abbreviation() for direction in Direction if not direction.abbreviation() in df.columns]
22
+ assert len(missing_direction) == 0, f"Expect directions in table to be {','.join([d.abbreviation() for d in Direction])}" + \
23
+ f" missing {','.join(missing_direction)}"
24
+
25
+ err_msg = ''
26
+ # Check each direction to have 13 cards
27
+ invalid_num_cards_direction = [direction.name for direction in Direction if len((''.join(df[direction.abbreviation()].tolist()))) != 13]
28
+ if invalid_num_cards_direction:
29
+ err_msg += f'Expect 13 cards in each hand, received invalid number of cards in {",".join(invalid_num_cards_direction)}. '
30
+
31
+ # Check card values in RANKS
32
+ cards = []
33
+ invalid_values = set()
34
+ for direction in Direction:
35
+ for ranks, suit in zip(df[direction.abbreviation()], SUITS):
36
+ for r in ranks:
37
+ try:
38
+ Rank.from_str(r)
39
+ except:
40
+ invalid_values.add(r)
41
+ assert not invalid_values, f'Expect card values in {",".join(RANKS)}, received {invalid_values}'
42
+
43
+ for direction in Direction:
44
+ for ranks, suit in zip(df[direction.abbreviation()], SUITS):
45
+ cards.extend([Card(rank=Rank.from_str(v), suit=suit) for v in ranks])
46
+
47
+ # Check duplicated and missing cards
48
+ duplicated, missing = get_invalid_cards(cards)
49
+
50
+ if len(duplicated) > 0:
51
+ err_msg += f'Duplicated cards: {duplicated}. '
52
+ if len(missing) > 0:
53
+ err_msg += f'Missing cards: {missing}. '
54
+ assert not err_msg, err_msg
55
+
56
+ def df_info(df):
57
+ # missing_direction = [direction.abbreviation() for direction in Direction if not direction.abbreviation() in df.columns]
58
+ num_cards_direction = {direction.name: len((''.join(df[direction.abbreviation()].tolist()))) for direction in Direction}
59
+
60
+ invalid_values = set()
61
+ for direction in Direction:
62
+ for ranks, suit in zip(df[direction.abbreviation()], SUITS):
63
+ for r in ranks:
64
+ try:
65
+ Rank.from_str(r)
66
+ except:
67
+ invalid_values.add(r)
68
+
69
+ if invalid_values:
70
+ return f'Expect card values in {",".join(RANKS)}, received {invalid_values}'
71
+
72
+ cards = []
73
+ for direction in Direction:
74
+ for ranks, suit in zip(df[direction.abbreviation()], SUITS):
75
+ cards.extend([Card(rank=Rank.from_str(v), suit=suit) for v in ranks])
76
+
77
+ # Check duplicated and missing cards
78
+ duplicated, missing = get_invalid_cards(cards)
79
+
80
+ if (not duplicated) and (not missing) and not [True for n in num_cards_direction.values() if n!=13]:
81
+ return f'It looks good!'
82
+
83
+ print_info = f'Number of cards: {num_cards_direction} \n'
84
+ print_info += f'Duplicated cards: {",".join(sorted(map(Card.__repr__,duplicated)))} \n'
85
+ print_info += f'Missing cards: {",".join(sorted(map(Card.__repr__,missing)))}'
86
+ return print_info
87
+
88
+
89
+
90
+
91
+ def get_invalid_cards(cards: list[Card]) -> tuple[set, set]:
92
+ """Get duplicated and missing cards"""
93
+ counter_cards = Counter(cards)
94
+ duplicated = {c for c, cnt in counter_cards.items() if cnt > 1}
95
+ missing = {Card(s,r) for r in Rank for s in Suit if not Card(s,r) in counter_cards.keys()}
96
+
97
+ return duplicated, missing
98
+
99
+ def roll_direction(df, idx):
100
+ new_df = df.copy(deep=True)
101
+ for i, direction in enumerate(DEALER_LIST):
102
+ new_direction = DEALER_LIST[(idx+i)%4]
103
+ new_df[direction] = df[new_direction]
104
+ return new_df
dds_util.py CHANGED
@@ -105,20 +105,4 @@ def get_dd_tricks(deal):
105
 
106
  """
107
  End
108
- """
109
-
110
- DD_DIRECTIONS = ['N', 'S', 'E', 'W']
111
- DD_SUITS = ['NT', 'S', 'H', 'D', 'C']
112
- def get_result_table(pbn_deal_string):
113
- try:
114
- dd_tricks_string = get_dd_tricks(pbn_deal_string)
115
- except Exception as e:
116
- print(e)
117
- raise ValueError(e)
118
- s = '[OptimumResultTable "Declarer;Denomination\\2R;Result\\2R"]\n'
119
- for i, char in enumerate(dd_tricks_string):
120
- direction = DD_DIRECTIONS[i//5]
121
- suit = DD_SUITS[i%5]
122
- tricks = int(char, 16)
123
- s += f'{direction} {suit} {tricks}\n'
124
- return s
 
105
 
106
  """
107
  End
108
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hand_record.py CHANGED
@@ -2,7 +2,7 @@ from fpdf import FPDF
2
  import tempfile
3
  import os
4
  import bridgebots
5
- from pbn_util import merge_pbn, parse_pbn, parse_dds_table
6
 
7
  DEALER_LIST = ['N', 'E', 'S', 'W']
8
  VULNERABILITY_LIST = ["None","NS","EW","All","NS","EW","All","None","EW","All","None","NS","All","None","NS","EW"]
@@ -10,8 +10,6 @@ SUITS = [bridgebots.Suit.SPADES, bridgebots.Suit.HEARTS, bridgebots.Suit.DIAMOND
10
  SUIT_SYMBOLS = ['♠','♥','♦','♣']
11
 
12
 
13
- suit_symbols = ['♠','♥','♦','♣']
14
-
15
  class PDF(FPDF):
16
  value_column_span = 7
17
  def __init__(self, event, site, *args, **kwargs):
@@ -89,7 +87,7 @@ class PDF(FPDF):
89
  self.set_font_size(16)
90
  row.cell(str(board_no), colspan=6, rowspan=2, align='C', v_align='C', padding=(2,0,0,0))
91
  self.set_font_size(10)
92
- for i, (suit, values) in enumerate(zip(suit_symbols, get_values(deal, 'N'))):
93
  if i!=0:
94
  row = table.row()
95
  if i == 2:
@@ -98,7 +96,7 @@ class PDF(FPDF):
98
 
99
  # row = table.row()
100
  # row = table.row()
101
- for i, (suit, values1, values2) in enumerate(zip(suit_symbols, get_values(deal, 'W'), get_values(deal, 'E'))):
102
  row = table.row()
103
  row.cell('', colspan=1, rowspan=1)
104
  print_suit_values(self, row, suit, values1)
@@ -109,7 +107,7 @@ class PDF(FPDF):
109
  # row = table.row()
110
  row = table.row()
111
  row.cell('', colspan=6, rowspan=4)
112
- for i, (suit, values) in enumerate(zip(suit_symbols, get_values(deal, 'S'))):
113
  if i!=0:
114
  row = table.row()
115
  print_suit_values(self, row, suit, values)
@@ -131,10 +129,10 @@ class PDF(FPDF):
131
  self.set_font_size(5)
132
  row.cell('NT', align='C')
133
  self.set_font_size(7)
134
- row.cell(suit_symbols[0], align='C')
135
- row.cell(suit_symbols[1], align='C')
136
- row.cell(suit_symbols[2], align='C')
137
- row.cell(suit_symbols[3], align='C')
138
  row = table.row()
139
  for i in range(4):
140
  if i!=0:
@@ -154,6 +152,7 @@ def create_hand_record_pdf(pbn_paths, event, site):
154
  pdf = PDF(event, site)
155
  pdf.print_boards(results)
156
  pdf.output(fn)
 
157
  return fn
158
 
159
 
 
2
  import tempfile
3
  import os
4
  import bridgebots
5
+ from pbn_util import merge_pbn, parse_pbn
6
 
7
  DEALER_LIST = ['N', 'E', 'S', 'W']
8
  VULNERABILITY_LIST = ["None","NS","EW","All","NS","EW","All","None","EW","All","None","NS","All","None","NS","EW"]
 
10
  SUIT_SYMBOLS = ['♠','♥','♦','♣']
11
 
12
 
 
 
13
  class PDF(FPDF):
14
  value_column_span = 7
15
  def __init__(self, event, site, *args, **kwargs):
 
87
  self.set_font_size(16)
88
  row.cell(str(board_no), colspan=6, rowspan=2, align='C', v_align='C', padding=(2,0,0,0))
89
  self.set_font_size(10)
90
+ for i, (suit, values) in enumerate(zip(SUIT_SYMBOLS, get_values(deal, 'N'))):
91
  if i!=0:
92
  row = table.row()
93
  if i == 2:
 
96
 
97
  # row = table.row()
98
  # row = table.row()
99
+ for i, (suit, values1, values2) in enumerate(zip(SUIT_SYMBOLS, get_values(deal, 'W'), get_values(deal, 'E'))):
100
  row = table.row()
101
  row.cell('', colspan=1, rowspan=1)
102
  print_suit_values(self, row, suit, values1)
 
107
  # row = table.row()
108
  row = table.row()
109
  row.cell('', colspan=6, rowspan=4)
110
+ for i, (suit, values) in enumerate(zip(SUIT_SYMBOLS, get_values(deal, 'S'))):
111
  if i!=0:
112
  row = table.row()
113
  print_suit_values(self, row, suit, values)
 
129
  self.set_font_size(5)
130
  row.cell('NT', align='C')
131
  self.set_font_size(7)
132
+ row.cell(SUIT_SYMBOLS[0], align='C')
133
+ row.cell(SUIT_SYMBOLS[1], align='C')
134
+ row.cell(SUIT_SYMBOLS[2], align='C')
135
+ row.cell(SUIT_SYMBOLS[3], align='C')
136
  row = table.row()
137
  for i in range(4):
138
  if i!=0:
 
152
  pdf = PDF(event, site)
153
  pdf.print_boards(results)
154
  pdf.output(fn)
155
+ os.close(fd)
156
  return fn
157
 
158
 
pbn_util.py CHANGED
@@ -1,13 +1,14 @@
1
  from collections import defaultdict
2
  import logging
3
  import bridgebots
 
4
  from pathlib import Path
5
  from datetime import datetime
6
  import pandas as pd
7
  import tempfile
8
- from collections import Counter
9
- from dds_util import get_result_table
10
  from typing import List, Dict
 
11
 
12
 
13
  DEALER_LIST = ['N', 'E', 'S', 'W']
@@ -128,24 +129,25 @@ def parse_pbn(file_path):
128
  return [(deal, board_records) for deal, board_records in records.items()]
129
 
130
 
131
- def create_single_pbn_string(
132
  data: pd.DataFrame,
 
133
  date=datetime.today(),
134
  board_no=1,
135
  event='',
136
  site='',
137
  ) -> str:
 
 
 
 
138
  year = date.strftime("%y")
139
  month = date.strftime("%m")
140
  day = date.strftime("%d")
141
  date_print = day + "." + month + "." + year
142
  dealer = DEALER_LIST[(board_no-1) % 4]
143
  vulnerability=VULNERABILITY_LIST[(board_no-1) % 16]
144
- deal = 'N:'
145
- deal += ' '.join(
146
- ['.'.join(data[col]) for col in data.columns[1:]]
147
- ) # sss.hhh.ddd.ccc sss.hhh.ddd.ccc......
148
- dd_tricks = get_result_table(deal)
149
 
150
  file = ''
151
  file += ("%This PBN was generated by Bridge Hand Scanner\n")
@@ -156,8 +158,12 @@ def create_single_pbn_string(
156
  file += f'[Dealer "{dealer}"]\n'
157
  file += f'[Vulnerable "{vulnerability}"]\n'
158
  file += f'[Deal "{deal}"]\n'
159
- file += dd_tricks
160
- file += '\n'
 
 
 
 
161
  return file
162
 
163
  def merge_pbn(pbn_paths):
@@ -178,55 +184,31 @@ def merge_pbn(pbn_paths):
178
  f.write('\n')
179
  f.write(v)
180
  return fn
181
-
182
-
183
- def validate_pbn(pbn_string):
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  try:
185
- deal, record = parse_single_pbn_record(pbn_string)
186
- except AssertionError:
187
- raise ValueError('Everyone should have 13 cards')
188
  except Exception as e:
189
- raise Exception(e)
190
- hands = deal.hands
191
- duplicated = set()
192
- missing = set()
193
- validation_dict = {}
194
- for suit in bridgebots.Suit:
195
- cards = [hands[direction].suits[suit] for direction in hands]
196
- # assert len(cards) == 13,
197
- cards = [c for c in sum([hands[direction].suits[suit] for direction in hands], [])]
198
- duplicated.update([bridgebots.Card(suit,val) for val,cnt in Counter(cards).items() if cnt >1])
199
-
200
- cards_set = set(cards)
201
- missing.update([bridgebots.Card(suit,r) for r in bridgebots.Rank if not r in cards_set])
202
-
203
- err_msg = ''
204
- if len(duplicated) > 0:
205
- err_msg += f'Duplicated cards: {duplicated}. '
206
- if len(missing) > 0:
207
- err_msg += f'Missing cards: {missing}. '
208
-
209
- for direction in hands:
210
- num_cards = len(hands[direction].cards)
211
- if not num_cards == 13:
212
- err_msg += '{direction.name} has {num_cards} cards. '
213
-
214
- if err_msg:
215
- raise ValueError(err_msg)
216
-
217
- def parse_dds_table(raw_list):
218
- table = []
219
- row = []
220
- tempchar = ''
221
- for x in raw_list:
222
- if len(x) > 0:
223
- tempchar += x[0]
224
- else:
225
- if len(tempchar) > 0:
226
- row.append(tempchar)
227
- tempchar = ''
228
-
229
- if len(row) == 3:
230
- table.append(row)
231
- row = []
232
- return table
 
1
  from collections import defaultdict
2
  import logging
3
  import bridgebots
4
+ from bridgebots import PlayerHand, Rank
5
  from pathlib import Path
6
  from datetime import datetime
7
  import pandas as pd
8
  import tempfile
9
+ from dds_util import get_dd_tricks
 
10
  from typing import List, Dict
11
+ import os
12
 
13
 
14
  DEALER_LIST = ['N', 'E', 'S', 'W']
 
129
  return [(deal, board_records) for deal, board_records in records.items()]
130
 
131
 
132
+ def create_pbn_file(
133
  data: pd.DataFrame,
134
+ save_path = None,
135
  date=datetime.today(),
136
  board_no=1,
137
  event='',
138
  site='',
139
  ) -> str:
140
+ if not save_path:
141
+ fd, save_path = tempfile.mkstemp(suffix='.pbn')
142
+ os.close(fd)
143
+
144
  year = date.strftime("%y")
145
  month = date.strftime("%m")
146
  day = date.strftime("%d")
147
  date_print = day + "." + month + "." + year
148
  dealer = DEALER_LIST[(board_no-1) % 4]
149
  vulnerability=VULNERABILITY_LIST[(board_no-1) % 16]
150
+ deal = pbn_deal_string(data, dealer) # sss.hhh.ddd.ccc sss.hhh.ddd.ccc......
 
 
 
 
151
 
152
  file = ''
153
  file += ("%This PBN was generated by Bridge Hand Scanner\n")
 
158
  file += f'[Dealer "{dealer}"]\n'
159
  file += f'[Vulnerable "{vulnerability}"]\n'
160
  file += f'[Deal "{deal}"]\n'
161
+ file += pbn_optimum_table(deal)
162
+ file += '\n' # End of board
163
+
164
+ with open(save_path, 'w') as f:
165
+ f.write(file)
166
+
167
  return file
168
 
169
  def merge_pbn(pbn_paths):
 
184
  f.write('\n')
185
  f.write(v)
186
  return fn
187
+
188
+ def pbn_deal_string(df, dealer):
189
+ assert dealer in DEALER_LIST, f'Dealer {dealer} is not valid'
190
+ dealer_idx = DEALER_LIST.index(dealer)
191
+ dealer_list_rolled = DEALER_LIST[dealer_idx:] + DEALER_LIST[:dealer_idx]
192
+ deal = f'{dealer}:'
193
+ hand_reprs = []
194
+ for direction in dealer_list_rolled:
195
+ hand = PlayerHand.from_string_lists(*df[direction])
196
+ hand_reprs.append('.'.join([''.join([r.abbreviation() for r in hand.suits[suit]]) for suit in SUITS]))
197
+ deal += ' '.join(hand_reprs)
198
+ return deal
199
+
200
+ DD_DIRECTIONS = ['N', 'S', 'E', 'W']
201
+ DD_SUITS = ['NT', 'S', 'H', 'D', 'C']
202
+ def pbn_optimum_table(pbn_deal_string):
203
  try:
204
+ dd_tricks_string = get_dd_tricks(pbn_deal_string)
 
 
205
  except Exception as e:
206
+ print(e)
207
+ raise ValueError(e)
208
+ s = '[OptimumResultTable "Declarer;Denomination\\2R;Result\\2R"]\n'
209
+ for i, char in enumerate(dd_tricks_string):
210
+ direction = DD_DIRECTIONS[i//5]
211
+ suit = DD_SUITS[i%5]
212
+ tricks = int(char, 16)
213
+ s += f'{direction} {suit} {tricks}\n'
214
+ return s