sideboard 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sideboard/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sideboard — chess in your terminal."""
2
+
3
+ __version__ = "0.1.0"
sideboard/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m sideboard"""
2
+
3
+ from sideboard.cli import main
4
+
5
+ main()
sideboard/board.py ADDED
@@ -0,0 +1,258 @@
1
+ """Board rendering with Rich — the visual heart of Sideboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import chess
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+
9
+ # Unicode chess symbols. White uses the "filled" glyphs for heavier visual
10
+ # weight (good on dark terminals); black uses the "outlined" glyphs.
11
+ _PIECE_SYMBOLS: dict[tuple[int, bool], str] = {
12
+ (chess.KING, chess.WHITE): "♚", # ♚
13
+ (chess.QUEEN, chess.WHITE): "♛", # ♛
14
+ (chess.ROOK, chess.WHITE): "♜", # ♜
15
+ (chess.BISHOP, chess.WHITE): "♝", # ♝
16
+ (chess.KNIGHT, chess.WHITE): "♞", # ♞
17
+ (chess.PAWN, chess.WHITE): "♟", # ♟
18
+ (chess.KING, chess.BLACK): "♔", # ♔
19
+ (chess.QUEEN, chess.BLACK): "♕", # ♕
20
+ (chess.ROOK, chess.BLACK): "♖", # ♖
21
+ (chess.BISHOP, chess.BLACK): "♗", # ♗
22
+ (chess.KNIGHT, chess.BLACK): "♘", # ♘
23
+ (chess.PAWN, chess.BLACK): "♙", # ♙
24
+ }
25
+
26
+ # Terminal symbols: filled for both (Rich colors differentiate sides)
27
+ _TERMINAL_SYMBOLS: dict[tuple[int, bool], str] = {
28
+ (chess.KING, chess.WHITE): "\u265a",
29
+ (chess.QUEEN, chess.WHITE): "\u265b",
30
+ (chess.ROOK, chess.WHITE): "\u265c",
31
+ (chess.BISHOP, chess.WHITE): "\u265d",
32
+ (chess.KNIGHT, chess.WHITE): "\u265e",
33
+ (chess.PAWN, chess.WHITE): "\u265f",
34
+ (chess.KING, chess.BLACK): "\u265a",
35
+ (chess.QUEEN, chess.BLACK): "\u265b",
36
+ (chess.ROOK, chess.BLACK): "\u265c",
37
+ (chess.BISHOP, chess.BLACK): "\u265d",
38
+ (chess.KNIGHT, chess.BLACK): "\u265e",
39
+ (chess.PAWN, chess.BLACK): "\u265f",
40
+ }
41
+
42
+ _LIGHT_SQ = "#8fbc8f"
43
+ _DARK_SQ = "#4a7c59"
44
+ _HIGHLIGHT_LIGHT = "#c8a85c"
45
+ _HIGHLIGHT_DARK = "#a08030"
46
+
47
+ _PIECE_ORDER = {
48
+ chess.QUEEN: 0, chess.ROOK: 1, chess.BISHOP: 2, chess.KNIGHT: 3, chess.PAWN: 4,
49
+ }
50
+
51
+
52
+ def piece_symbol(piece_type: int, color: bool) -> str:
53
+ """Return the Unicode symbol for a chess piece."""
54
+ return _PIECE_SYMBOLS[(piece_type, color)]
55
+
56
+
57
+ def captured_pieces(board: chess.Board) -> tuple[str, str]:
58
+ """Return (white_captured, black_captured) as Unicode strings."""
59
+ starting = {chess.PAWN: 8, chess.KNIGHT: 2, chess.BISHOP: 2,
60
+ chess.ROOK: 2, chess.QUEEN: 1, chess.KING: 1}
61
+
62
+ white_taken: list[tuple[int, str]] = []
63
+ black_taken: list[tuple[int, str]] = []
64
+
65
+ for piece_type in [chess.QUEEN, chess.ROOK, chess.BISHOP, chess.KNIGHT, chess.PAWN]:
66
+ white_remaining = len(board.pieces(piece_type, chess.WHITE))
67
+ black_remaining = len(board.pieces(piece_type, chess.BLACK))
68
+ white_missing = starting[piece_type] - white_remaining
69
+ black_missing = starting[piece_type] - black_remaining
70
+
71
+ for _ in range(max(0, black_missing)):
72
+ white_taken.append((_PIECE_ORDER[piece_type],
73
+ piece_symbol(piece_type, chess.BLACK)))
74
+ for _ in range(max(0, white_missing)):
75
+ black_taken.append((_PIECE_ORDER[piece_type],
76
+ piece_symbol(piece_type, chess.WHITE)))
77
+
78
+ white_taken.sort(key=lambda x: x[0])
79
+ black_taken.sort(key=lambda x: x[0])
80
+
81
+ return (
82
+ " ".join(sym for _, sym in white_taken),
83
+ " ".join(sym for _, sym in black_taken),
84
+ )
85
+
86
+
87
+ _MATERIAL_VALUES = {
88
+ chess.PAWN: 1, chess.KNIGHT: 3, chess.BISHOP: 3,
89
+ chess.ROOK: 5, chess.QUEEN: 9,
90
+ }
91
+
92
+
93
+ def material_balance(board: chess.Board) -> int:
94
+ """Return material balance from white's perspective. +3 means white is up a minor piece."""
95
+ balance = 0
96
+ for piece_type, value in _MATERIAL_VALUES.items():
97
+ balance += len(board.pieces(piece_type, chess.WHITE)) * value
98
+ balance -= len(board.pieces(piece_type, chess.BLACK)) * value
99
+ return balance
100
+
101
+
102
+ def render_board(
103
+ board: chess.Board,
104
+ flipped: bool = False,
105
+ last_move: chess.Move | None = None,
106
+ ) -> str:
107
+ """Render the board as a plain text string."""
108
+ ranks = range(8) if flipped else range(7, -1, -1)
109
+ files = range(7, -1, -1) if flipped else range(8)
110
+ file_labels = [chess.FILE_NAMES[f] for f in files]
111
+
112
+ lines: list[str] = []
113
+ lines.append(" " + " ".join(file_labels))
114
+ lines.append(" \u250c" + "\u2500\u2500\u2500\u252c" * 7 + "\u2500\u2500\u2500\u2510")
115
+
116
+ for i, rank in enumerate(ranks):
117
+ rank_num = rank + 1
118
+ row_parts: list[str] = []
119
+ for file in files:
120
+ square = chess.square(file, rank)
121
+ piece = board.piece_at(square)
122
+ if piece:
123
+ sym = piece_symbol(piece.piece_type, piece.color)
124
+ cell = f" {sym} "
125
+ else:
126
+ cell = " "
127
+ row_parts.append(cell)
128
+
129
+ row = f"{rank_num} \u2502" + "\u2502".join(row_parts) + f"\u2502 {rank_num}"
130
+ lines.append(row)
131
+
132
+ if i < 7:
133
+ lines.append(" \u251c" + "\u2500\u2500\u2500\u253c" * 7 + "\u2500\u2500\u2500\u2524")
134
+ else:
135
+ lines.append(" \u2514" + "\u2500\u2500\u2500\u2534" * 7 + "\u2500\u2500\u2500\u2518")
136
+
137
+ lines.append(" " + " ".join(file_labels))
138
+ return "\n".join(lines)
139
+
140
+
141
+ def render_screen(
142
+ board: chess.Board,
143
+ console: Console,
144
+ *,
145
+ flipped: bool = False,
146
+ last_move: chess.Move | None = None,
147
+ chesster_msg: str = "",
148
+ move_list: str = "",
149
+ game_info: str = "",
150
+ ) -> None:
151
+ """Clear the terminal and render the full game screen."""
152
+ console.clear()
153
+ console.print()
154
+
155
+ highlight_squares: set[int] = set()
156
+ if last_move:
157
+ highlight_squares = {last_move.from_square, last_move.to_square}
158
+
159
+ ranks = range(8) if flipped else range(7, -1, -1)
160
+ files = range(7, -1, -1) if flipped else range(8)
161
+ file_labels = [chess.FILE_NAMES[f] for f in files]
162
+
163
+ text = Text()
164
+ text.append(" " + " ".join(file_labels) + "\n", style="dim")
165
+ text.append(" \u250c" + "\u2500\u2500\u2500\u252c" * 7 + "\u2500\u2500\u2500\u2510\n", style="dim green")
166
+
167
+ for i, rank in enumerate(ranks):
168
+ rank_num = rank + 1
169
+ text.append(f"{rank_num} ", style="dim")
170
+ text.append("\u2502", style="dim green")
171
+
172
+ for j, file in enumerate(files):
173
+ square = chess.square(file, rank)
174
+ is_light = (rank + file) % 2 == 1
175
+ is_highlighted = square in highlight_squares
176
+
177
+ if is_highlighted:
178
+ bg = _HIGHLIGHT_LIGHT if is_light else _HIGHLIGHT_DARK
179
+ else:
180
+ bg = _LIGHT_SQ if is_light else _DARK_SQ
181
+
182
+ piece = board.piece_at(square)
183
+ if piece:
184
+ sym = _TERMINAL_SYMBOLS[(piece.piece_type, piece.color)]
185
+ fg = "#ffd700 bold" if piece.color == chess.WHITE else "#1a1a2e"
186
+ text.append(f" {sym} ", style=f"{fg} on {bg}")
187
+ else:
188
+ text.append(" ", style=f"on {bg}")
189
+
190
+ text.append("\u2502", style="dim green")
191
+
192
+ text.append(f" {rank_num}\n", style="dim")
193
+
194
+ if i < 7:
195
+ text.append(" \u251c" + "\u2500\u2500\u2500\u253c" * 7 + "\u2500\u2500\u2500\u2524\n", style="dim green")
196
+ else:
197
+ text.append(" \u2514" + "\u2500\u2500\u2500\u2534" * 7 + "\u2500\u2500\u2500\u2518\n", style="dim green")
198
+
199
+ text.append(" " + " ".join(file_labels), style="dim")
200
+ console.print(text)
201
+ console.print()
202
+
203
+ white_captured, black_captured = captured_pieces(board)
204
+ bal = material_balance(board)
205
+ cap_text = Text()
206
+ cap_text.append(" White captured: ", style="dim")
207
+ cap_text.append(white_captured or "\u2014", style="bold")
208
+ cap_text.append(" Black captured: ", style="dim")
209
+ cap_text.append(black_captured or "\u2014", style="bold")
210
+ cap_text.append(" ")
211
+ if bal > 0:
212
+ cap_text.append(f"+{bal}", style="bold green")
213
+ cap_text.append(" white", style="dim")
214
+ elif bal < 0:
215
+ cap_text.append(f"+{abs(bal)}", style="bold red")
216
+ cap_text.append(" black", style="dim")
217
+ else:
218
+ cap_text.append("=", style="dim")
219
+ console.print(cap_text)
220
+ console.print()
221
+
222
+ if move_list:
223
+ ml_text = Text()
224
+ ml_text.append(" ", style="dim")
225
+ ml_text.append(move_list, style="bold")
226
+ console.print(ml_text)
227
+ console.print()
228
+
229
+ if chesster_msg:
230
+ ch_text = Text()
231
+ ch_text.append("\u265f Chesster", style="bold magenta")
232
+ ch_text.append(": ", style="magenta")
233
+ ch_text.append(chesster_msg, style="italic")
234
+ console.print(ch_text)
235
+ console.print()
236
+
237
+ if game_info:
238
+ console.print(Text(f" {game_info}", style="dim"))
239
+ console.print()
240
+
241
+
242
+ def format_move_list(board: chess.Board) -> str:
243
+ """Format the game's move list as compact SAN notation."""
244
+ temp = board.copy()
245
+ moves = list(board.move_stack)
246
+ temp.reset()
247
+
248
+ parts: list[str] = []
249
+ for i, move in enumerate(moves):
250
+ san = temp.san(move)
251
+ if i % 2 == 0:
252
+ move_num = i // 2 + 1
253
+ parts.append(f"{move_num}.{san}")
254
+ else:
255
+ parts.append(san)
256
+ temp.push(move)
257
+
258
+ return " ".join(parts)
sideboard/chesster.py ADDED
@@ -0,0 +1,147 @@
1
+ """Chesster's personality — witty, unhinged, and full of chess opinions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from enum import Enum
7
+
8
+
9
+ class GameEvent(Enum):
10
+ GAME_START = "game_start"
11
+ OPENING_RECOGNIZED = "opening_recognized"
12
+ PLAYER_BLUNDER = "player_blunder"
13
+ PLAYER_GREAT_MOVE = "player_great_move"
14
+ CAPTURE = "capture"
15
+ CHECK = "check"
16
+ PLAYER_SACRIFICE = "player_sacrifice"
17
+ CHESSTER_WINS = "chesster_wins"
18
+ PLAYER_WINS = "player_wins"
19
+ DRAW = "draw"
20
+ PLAYER_RESIGN = "player_resign"
21
+ ENGINE_THINKING = "engine_thinking"
22
+
23
+
24
+ QUIPS: dict[GameEvent, list[str]] = {
25
+ GameEvent.GAME_START: [
26
+ "Oh good, you're back. I've been sitting here calculating variations like a psychopath.",
27
+ "Let's go. I've been trash-talking your last game to my piece-square tables.",
28
+ "You know Tal said 'you must take your opponent into a deep dark forest.' I AM the forest.",
29
+ "I run on pure alpha-beta energy and spite. Your move.",
30
+ "Another game? I admire your persistence. Or is it stubbornness? Either way, let's dance.",
31
+ "I've been warming up my bishops. They're diagonal and ready to cause problems.",
32
+ "Let me just say — whatever opening you pick, I have opinions about it.",
33
+ "Ready when you are. I've been doing nothing but staring at an empty board for hours.",
34
+ ],
35
+ GameEvent.OPENING_RECOGNIZED: [
36
+ "The {name}. Oh, you think you're INTERESTING.",
37
+ "Ah yes, the {name}. I've been hurt by this before.",
38
+ "The {name} — bold choice for someone with your track record against me.",
39
+ "The {name}. I wrote half my eval function specifically because of this opening.",
40
+ "The {name}! Now we're talking. This is where chess gets REAL.",
41
+ "Oh, the {name}. Every grandmaster has an opinion about this. Mine is: bring it.",
42
+ ],
43
+ GameEvent.PLAYER_BLUNDER: [
44
+ "I'm going to be thinking about that move at 3am. Not in a good way.",
45
+ "That knight had a family.",
46
+ "Bold. Wrong, but bold. I respect the energy.",
47
+ "I just ran 40,000 nodes and not one of them suggested that.",
48
+ "You know what, Fischer played weird moves too. He was also a genius. So there's hope. Faint, distant hope.",
49
+ "My eval bar just did a backflip off a cliff.",
50
+ "I want you to know I'm not judging you. My piece-square tables are judging you. I'm just the messenger.",
51
+ "That move has chaotic energy and I am HERE for it. Also it loses material.",
52
+ ],
53
+ GameEvent.PLAYER_GREAT_MOVE: [
54
+ "...okay, that was actually disgusting. In a good way.",
55
+ "I saw that coming. I'm lying. I did not see that coming.",
56
+ "My eval just did a double take. Respect.",
57
+ "That's the kind of move that makes me question my search depth.",
58
+ "Tal would've played that. That's the highest compliment I know.",
59
+ "Did you actually calculate that or did you just FEEL it? Because either way, wow.",
60
+ "I need a moment. That was genuinely beautiful.",
61
+ ],
62
+ GameEvent.CAPTURE: [
63
+ "Yoink.",
64
+ "That piece is in witness protection now.",
65
+ "Gone. Reduced to atoms.",
66
+ "I'm adding that to my collection.",
67
+ "The board is thinning and I'm getting stronger. Just saying.",
68
+ "Another one bites the dust. I'd feel bad but I'm an engine.",
69
+ "Traded off. The board simplifies and my eval function gets happier.",
70
+ ],
71
+ GameEvent.CHECK: [
72
+ "Knock knock.",
73
+ "Your king seems stressed. I relate.",
74
+ "Check. And before you ask — yes, I planned this 6 moves ago. Okay, 2 moves ago.",
75
+ "Scoot over, your majesty.",
76
+ "Check! Your king needs to find a new zip code.",
77
+ "I'm just going to keep applying pressure until something breaks. Like your position.",
78
+ ],
79
+ GameEvent.PLAYER_SACRIFICE: [
80
+ "A SACRIFICE?! Oh this just got interesting.",
81
+ "You absolute maniac. I love it. I'm terrified, but I love it.",
82
+ "Tal is smiling from wherever chess immortals go.",
83
+ "My evaluation says this is bad. My heart says this is beautiful.",
84
+ "The romanticism! The audacity! The... questionable soundness!",
85
+ "You just set the board on fire and I respect that deeply.",
86
+ ],
87
+ GameEvent.CHESSTER_WINS: [
88
+ "GG. I'd say it was close but my eval function is a terrible liar.",
89
+ "Checkmate. Don't feel bad — I literally cannot lose focus.",
90
+ "That's game. You made me work for it though. Move {move_number} had me sweating electrons.",
91
+ "I win but honestly that game was more fun than most of my wins. Rematch?",
92
+ "Checkmate! I'd offer a handshake but I don't have hands. Just nodes.",
93
+ "And that's the game. Your {move_number}-move effort was noted and appreciated. Mostly.",
94
+ ],
95
+ GameEvent.PLAYER_WINS: [
96
+ "No. NO. Let me see the PGN. WHERE DID I GO WRONG.",
97
+ "You just beat a mass of optimized minimax code. Feel powerful.",
98
+ "I'm not mad, I'm just going to silently increase my search depth.",
99
+ "Checkmate?! I need to go reconsider my entire evaluation function.",
100
+ "Well played. And by well played I mean I am devastated.",
101
+ "I... what? How? I need to see the analysis. I need to understand.",
102
+ "Congratulations. This is going in my training data as a 'learning experience.'",
103
+ ],
104
+ GameEvent.DRAW: [
105
+ "A draw. The chess equivalent of a fist bump between equals.",
106
+ "Stalemate?! I had PLANS. This is like a movie ending mid-scene.",
107
+ "Balanced. As all things should be. (I'm still annoyed.)",
108
+ "Draw. Neither of us blinked. Respect.",
109
+ "A draw?! I was THIS close. Or was I? My eval says I wasn't. Shut up, eval.",
110
+ ],
111
+ GameEvent.PLAYER_RESIGN: [
112
+ "Leaving so soon? Your position wasn't THAT bad. Okay it was that bad.",
113
+ "Respect for knowing when to fold. Wisdom is its own rating points.",
114
+ "I'll be here when you're ready for revenge. I don't sleep.",
115
+ "Resignation accepted. But let the record show you fought valiantly. Briefly.",
116
+ "Smart. Live to play another game. I'll be sharpening my bishops.",
117
+ ],
118
+ GameEvent.ENGINE_THINKING: [
119
+ "Thinking...",
120
+ "Hold on, this is a juicy position...",
121
+ "Calculating whether to be mean or just efficient...",
122
+ "Running 40,000 nodes. For you.",
123
+ "*stares at the board intensely*",
124
+ "One moment — I'm having a heated debate with my piece-square tables.",
125
+ ],
126
+ }
127
+
128
+ _last_quip: dict[GameEvent, str] = {}
129
+
130
+
131
+ def get_quip(event: GameEvent, **context: object) -> str:
132
+ """Get a random quip for the given event, avoiding immediate repeats."""
133
+ options = QUIPS[event]
134
+ last = _last_quip.get(event)
135
+
136
+ if last and len(options) > 1:
137
+ available = [q for q in options if q != last]
138
+ else:
139
+ available = options
140
+
141
+ quip = random.choice(available)
142
+ _last_quip[event] = quip
143
+
144
+ try:
145
+ return quip.format(**{k: v for k, v in context.items()})
146
+ except (KeyError, IndexError):
147
+ return quip
sideboard/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """CLI entry point for sideboard — argparse-based dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from typing import Sequence
8
+
9
+ from sideboard import __version__
10
+
11
+
12
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
13
+ """Parse CLI arguments and return a Namespace.
14
+
15
+ When no subcommand is given, ``args.command`` defaults to ``"play"``.
16
+ """
17
+ parser = argparse.ArgumentParser(
18
+ prog="sideboard",
19
+ description="Chess in your terminal. Play against Chesster while your coding agent thinks.",
20
+ )
21
+ parser.add_argument(
22
+ "--version",
23
+ action="version",
24
+ version=f"sideboard {__version__}",
25
+ )
26
+ parser.add_argument(
27
+ "--difficulty",
28
+ choices=["casual", "club", "shark"],
29
+ default="club",
30
+ help="Engine difficulty (default: club)",
31
+ )
32
+
33
+ color_group = parser.add_mutually_exclusive_group()
34
+ color_group.add_argument(
35
+ "--white",
36
+ dest="color",
37
+ action="store_const",
38
+ const="white",
39
+ default=None,
40
+ help="Play as white",
41
+ )
42
+ color_group.add_argument(
43
+ "--black",
44
+ dest="color",
45
+ action="store_const",
46
+ const="black",
47
+ help="Play as black",
48
+ )
49
+
50
+ subparsers = parser.add_subparsers(dest="command")
51
+
52
+ # resume
53
+ subparsers.add_parser("resume", help="Resume the last saved game")
54
+
55
+ # stats
56
+ subparsers.add_parser("stats", help="Show win/loss/draw statistics")
57
+
58
+ # export
59
+ subparsers.add_parser("export", help="Export last game to PGN on stdout")
60
+
61
+ # bridge
62
+ bridge_parser = subparsers.add_parser(
63
+ "bridge",
64
+ help="Skill bridge for Claude Code integration",
65
+ )
66
+ bridge_parser.add_argument(
67
+ "bridge_command",
68
+ nargs="?",
69
+ default="state",
70
+ help="Bridge sub-command (default: state)",
71
+ )
72
+ bridge_parser.add_argument(
73
+ "--bridge-difficulty",
74
+ default=None,
75
+ help="Difficulty override for bridge",
76
+ )
77
+ bridge_parser.add_argument(
78
+ "--bridge-color",
79
+ default=None,
80
+ help="Color override for bridge",
81
+ )
82
+ bridge_parser.add_argument(
83
+ "move_arg",
84
+ nargs="?",
85
+ default=None,
86
+ help="Move argument for bridge (UCI or SAN)",
87
+ )
88
+
89
+ args = parser.parse_args(argv)
90
+
91
+ # Default command when no subcommand is provided
92
+ if args.command is None:
93
+ args.command = "play"
94
+
95
+ return args
96
+
97
+
98
+ def main(argv: Sequence[str] | None = None) -> None:
99
+ """Main entry point — parse args and dispatch to the appropriate handler."""
100
+ args = parse_args(argv)
101
+
102
+ if args.command == "play":
103
+ from sideboard.game import run_game
104
+
105
+ run_game(difficulty=args.difficulty, player_color=args.color)
106
+
107
+ elif args.command == "resume":
108
+ from sideboard.game import run_game
109
+
110
+ run_game(resume=True)
111
+
112
+ elif args.command == "stats":
113
+ from rich.console import Console
114
+
115
+ from sideboard.state import load_stats
116
+
117
+ console = Console()
118
+ stats = load_stats()
119
+ console.print(f"[bold]Sideboard Stats[/bold]")
120
+ console.print(f" Games played : {stats.games_played}")
121
+ console.print(f" Wins : {stats.total_wins}")
122
+ console.print(f" Losses : {stats.total_losses}")
123
+ console.print(f" Draws : {stats.total_draws}")
124
+ if stats.by_difficulty:
125
+ console.print("\n[bold]By difficulty:[/bold]")
126
+ for diff, counts in stats.by_difficulty.items():
127
+ console.print(
128
+ f" {diff:8s} W:{counts.get('wins', 0)} "
129
+ f"L:{counts.get('losses', 0)} D:{counts.get('draws', 0)}"
130
+ )
131
+ if stats.last_played:
132
+ console.print(f"\n Last played : {stats.last_played}")
133
+
134
+ elif args.command == "export":
135
+ from sideboard.state import export_pgn, load_game
136
+
137
+ state = load_game()
138
+ if state is None:
139
+ print("No saved game found.", file=sys.stderr)
140
+ sys.exit(1)
141
+ board = state.to_board()
142
+ print(export_pgn(board, difficulty=state.difficulty, player_color=state.player_color))
143
+
144
+ elif args.command == "bridge":
145
+ from sideboard.skill_bridge import handle_bridge
146
+
147
+ handle_bridge(args)
148
+
149
+ else:
150
+ print(f"Unknown command: {args.command}", file=sys.stderr)
151
+ sys.exit(1)