from __future__ import annotations
import os
import re
import sys
from pathlib import Path
from dds3 import calc_all_tables_pbn
PBN_FILE_MAX = 8192
_STRAIN_ROWS = (("NT", 4), ("S", 0), ("H", 1), ("D", 2), ("C", 3))
_HAND_COLUMNS = (("North", 0), ("South", 2), ("East", 1), ("West", 3))
_DDS_FULL_LINE = 80
_DDS_HAND_OFFSET = 12
_DDS_HAND_LINES = 12
_BIT_MAP_RANK = [
0x0000, 0x0000, 0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020,
0x0040, 0x0080, 0x0100, 0x0200, 0x0400, 0x0800, 0x1000, 0x2000,
]
_CARD_RANK_CHARS = "xx23456789TJQKA-"
_DEAL_TAG_RE = re.compile(r'\[Deal\s*"([^"]*)"', re.IGNORECASE)
def _read_pbn_stream(stream) -> str | None:
data = stream.read(PBN_FILE_MAX - 1)
if not data:
return None
return data if isinstance(data, str) else data.decode("utf-8", errors="replace")
def _read_pbn_file(path: str) -> str | None:
candidates = [Path(path)]
workspace = os.environ.get("BUILD_WORKSPACE_DIRECTORY")
if workspace is not None:
candidates.append(Path(workspace) / path)
for candidate in candidates:
try:
return candidate.read_text(encoding="utf-8", errors="replace")[
: PBN_FILE_MAX - 1
]
except OSError:
continue
return None
def _extract_deal_tag(text: str) -> str | None:
match = _DEAL_TAG_RE.search(text)
return match.group(1) if match else None
def _load_deal(arg: str) -> str:
if arg == "-":
text = _read_pbn_stream(sys.stdin)
if text is None:
raise ValueError("No PBN input on stdin")
source = "stdin"
else:
text = _read_pbn_file(arg)
source = arg if text is not None else None
if source is not None:
deal = _extract_deal_tag(text)
if deal is None:
raise ValueError(f'No [Deal "..."] tag found in {source}')
return deal
if len(arg) >= PBN_FILE_MAX:
raise ValueError(f"PBN deal too long (max {PBN_FILE_MAX - 1} characters)")
return arg
def _print_usage(prog: str) -> None:
print(
f"Usage: {prog} <pbn_deal_or_file>\n"
f" {prog} -h | --help\n"
"\n"
"Calculate double-dummy tricks for all strains and leads.\n"
"\n"
"Arguments:\n"
" <pbn_deal_or_file> DDS PBN deal string, or path to a .pbn file\n"
"\n"
'If stdin is not a terminal, PBN is read from stdin (uses [Deal "..."]).\n'
"\n"
"Examples:\n"
f' {prog} "N:73.QJT.AQ54.T752 QT6.876.KJ9.AQ84 '
f'5.A95432.7632.K6 AKJ9842.K.T8.J93"\n'
f" {prog} hands/example.pbn\n"
f" {prog} < hands/example.pbn\n",
file=sys.stderr,
)
def _is_card(ch: str) -> int:
ch = ch.upper()
ranks = "23456789TJQKA"
return ranks.index(ch) + 2 if ch in ranks else 0
def _convert_pbn(pbn_deal: str) -> list[list[int]]:
remain = [[0] * 4 for _ in range(4)]
bp = 0
while (
bp < 3
and bp < len(pbn_deal)
and pbn_deal[bp] not in "NWESnwes"
):
bp += 1
if bp >= 3 or bp >= len(pbn_deal):
return remain
first = {"N": 0, "E": 1, "S": 2, "W": 3}[pbn_deal[bp].upper()]
bp += 2
hand_rel_first = 0
suit_in_hand = 0
while bp < 80 and bp < len(pbn_deal):
ch = pbn_deal[bp]
card = _is_card(ch)
if card:
if first == 0:
hand = hand_rel_first
elif first == 1:
hand = 1 if hand_rel_first == 0 else 0 if hand_rel_first == 3 else hand_rel_first + 1
elif first == 2:
hand = 2 if hand_rel_first == 0 else 3 if hand_rel_first == 1 else hand_rel_first - 2
else:
hand = 3 if hand_rel_first == 0 else hand_rel_first - 1
remain[hand][suit_in_hand] |= _BIT_MAP_RANK[card] << 2
elif ch == ".":
suit_in_hand += 1
elif ch == " ":
hand_rel_first += 1
suit_in_hand = 0
bp += 1
return remain
def _print_pbn_hand(title: str, pbn_deal: str) -> None:
remain_cards = _convert_pbn(pbn_deal)
text = [[" "] * _DDS_FULL_LINE for _ in range(_DDS_HAND_LINES)]
row_ends = [_DDS_FULL_LINE] * _DDS_HAND_LINES
for h in range(4):
if h == 0:
offset, line = _DDS_HAND_OFFSET, 0
elif h == 1:
offset, line = 2 * _DDS_HAND_OFFSET, 4
elif h == 2:
offset, line = _DDS_HAND_OFFSET, 8
else:
offset, line = 0, 4
for s in range(4):
row = line + s
c = offset
for r in range(14, 1, -1):
if (remain_cards[h][s] >> 2) & _BIT_MAP_RANK[r]:
text[row][c] = _CARD_RANK_CHARS[r]
c += 1
if c == offset:
text[row][c] = "-"
c += 1
if h != 3:
row_ends[row] = c
sys.stdout.write(title)
dash_len = max(0, len(title) - 1)
print("-" * dash_len)
for i in range(_DDS_HAND_LINES):
print("".join(text[i][: row_ends[i]]))
print("\n")
def _print_table(res_table: list[list[int]]) -> None:
print(f"{'':>5} {'North':<5} {'South':<5} {'East':<5} {'West':<5}")
_, nt_strain = _STRAIN_ROWS[0]
print(
f"{'NT':>5} "
f"{res_table[nt_strain][_HAND_COLUMNS[0][1]]:5d} "
f"{res_table[nt_strain][_HAND_COLUMNS[1][1]]:5d} "
f"{res_table[nt_strain][_HAND_COLUMNS[2][1]]:5d} "
f"{res_table[nt_strain][_HAND_COLUMNS[3][1]]:5d}"
)
for label, strain in _STRAIN_ROWS[1:]:
print(
f"{label:>5} "
f"{res_table[strain][_HAND_COLUMNS[0][1]]:5d} "
f"{res_table[strain][_HAND_COLUMNS[1][1]]:5d} "
f"{res_table[strain][_HAND_COLUMNS[2][1]]:5d} "
f"{res_table[strain][_HAND_COLUMNS[3][1]]:5d}"
)
print()
def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv if argv is None else argv)
prog = Path(argv[0]).name
if len(argv) == 2:
if argv[1] in ("-h", "--help"):
_print_usage(prog)
return 0
input_arg = argv[1]
elif len(argv) == 1 and not sys.stdin.isatty():
input_arg = "-"
else:
_print_usage(prog)
return 1
try:
pbn_deal = _load_deal(input_arg)
except ValueError as exc:
print(exc, file=sys.stderr)
return 1
try:
result = calc_all_tables_pbn([pbn_deal])
except (ValueError, RuntimeError) as exc:
print(f"DDS error: {exc}", file=sys.stderr)
return 1
tables = result.get("tables")
if not tables:
print("DDS error: no table returned", file=sys.stderr)
return 1
res_table = tables[0]["res_table"]
_print_pbn_hand("dd_table_for_deal:\n", pbn_deal)
_print_table(res_table)
return 0
if __name__ == "__main__":
raise SystemExit(main())