Artvv commited on
Commit
7aa16f0
·
verified ·
1 Parent(s): d119afc

Upload src/persistentpoker_bench/web_ui.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. src/persistentpoker_bench/web_ui.py +643 -0
src/persistentpoker_bench/web_ui.py ADDED
@@ -0,0 +1,643 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from html import escape
5
+ from importlib import import_module
6
+ from typing import Any
7
+
8
+ from persistentpoker_bench.cards import cards_to_notation
9
+ from persistentpoker_bench.hand_runner import HandRunnerConfig
10
+ from persistentpoker_bench.interactive import (
11
+ HumanCommand,
12
+ PlaySeatKind,
13
+ PlaySeatSpec,
14
+ PlaySessionConfig,
15
+ parse_play_session_config,
16
+ run_play_session,
17
+ )
18
+ from persistentpoker_bench.live_play import LiveMatchController
19
+ from persistentpoker_bench.replay import (
20
+ build_match_replay,
21
+ replay_hand_choices,
22
+ render_replay_hand_markdown,
23
+ render_replay_summary_markdown,
24
+ )
25
+ from persistentpoker_bench.schemas import WinnerPoolDecision
26
+
27
+
28
+ LIVE_UI_CSS = """
29
+ :root {
30
+ --ppb-bg: #0f1720;
31
+ --ppb-panel: #16222e;
32
+ --ppb-panel-2: #1d2e3d;
33
+ --ppb-border: rgba(255,255,255,0.08);
34
+ --ppb-accent: #f2b84b;
35
+ --ppb-accent-2: #67d4c5;
36
+ --ppb-text: #edf4f8;
37
+ --ppb-muted: #9bb2c3;
38
+ --ppb-danger: #ef6a6a;
39
+ --ppb-card: #fffaf2;
40
+ --ppb-card-text: #1a1f24;
41
+ }
42
+ .gradio-container {
43
+ background:
44
+ radial-gradient(circle at top left, rgba(103,212,197,0.16), transparent 28%),
45
+ radial-gradient(circle at top right, rgba(242,184,75,0.12), transparent 30%),
46
+ linear-gradient(180deg, #0b1117, #111c27 35%, #0d1620 100%);
47
+ }
48
+ .ppb-shell { color: var(--ppb-text); }
49
+ .ppb-status {
50
+ background: linear-gradient(135deg, rgba(103,212,197,0.10), rgba(242,184,75,0.08));
51
+ border: 1px solid var(--ppb-border);
52
+ border-radius: 18px;
53
+ padding: 16px 18px;
54
+ }
55
+ .ppb-sidebar {
56
+ background: linear-gradient(180deg, rgba(22,34,46,0.96), rgba(18,28,38,0.96));
57
+ border: 1px solid var(--ppb-border);
58
+ border-radius: 22px;
59
+ padding: 18px;
60
+ }
61
+ .ppb-sidebar h3 {
62
+ margin: 0 0 10px;
63
+ font-size: 16px;
64
+ }
65
+ .ppb-stack {
66
+ height: 8px;
67
+ border-radius: 999px;
68
+ background: rgba(255,255,255,0.08);
69
+ overflow: hidden;
70
+ margin-top: 10px;
71
+ }
72
+ .ppb-stack > span {
73
+ display: block;
74
+ height: 100%;
75
+ border-radius: 999px;
76
+ background: linear-gradient(90deg, var(--ppb-accent-2), var(--ppb-accent));
77
+ }
78
+ .ppb-table {
79
+ background: linear-gradient(180deg, rgba(8,24,19,0.96), rgba(10,45,33,0.96));
80
+ border: 1px solid rgba(255,255,255,0.08);
81
+ border-radius: 28px;
82
+ padding: 22px;
83
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05), 0 18px 50px rgba(0,0,0,0.28);
84
+ animation: ppb-fade-up 220ms ease-out;
85
+ }
86
+ .ppb-table-header {
87
+ display: flex;
88
+ justify-content: space-between;
89
+ gap: 12px;
90
+ align-items: center;
91
+ margin-bottom: 18px;
92
+ }
93
+ .ppb-badge {
94
+ display: inline-block;
95
+ padding: 6px 10px;
96
+ border-radius: 999px;
97
+ background: rgba(255,255,255,0.08);
98
+ color: var(--ppb-text);
99
+ border: 1px solid rgba(255,255,255,0.06);
100
+ font-size: 12px;
101
+ letter-spacing: 0.02em;
102
+ }
103
+ .ppb-pot {
104
+ font-size: 22px;
105
+ font-weight: 700;
106
+ color: var(--ppb-accent);
107
+ }
108
+ .ppb-card-row {
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ gap: 10px;
112
+ margin: 10px 0 18px;
113
+ }
114
+ .ppb-card {
115
+ width: 52px;
116
+ height: 72px;
117
+ border-radius: 14px;
118
+ background: var(--ppb-card);
119
+ color: var(--ppb-card-text);
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ font-weight: 800;
124
+ border: 1px solid rgba(0,0,0,0.08);
125
+ box-shadow: 0 8px 18px rgba(0,0,0,0.12);
126
+ }
127
+ .ppb-card.hidden {
128
+ background: linear-gradient(135deg, #203244, #2f4a63);
129
+ color: rgba(255,255,255,0.88);
130
+ }
131
+ .ppb-players {
132
+ display: grid;
133
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
134
+ gap: 14px;
135
+ margin-top: 12px;
136
+ }
137
+ .ppb-player {
138
+ background: rgba(10, 20, 28, 0.36);
139
+ border: 1px solid rgba(255,255,255,0.08);
140
+ border-radius: 20px;
141
+ padding: 14px;
142
+ }
143
+ .ppb-player.active {
144
+ border-color: rgba(242,184,75,0.7);
145
+ box-shadow: 0 0 0 1px rgba(242,184,75,0.18), 0 8px 24px rgba(242,184,75,0.12);
146
+ animation: ppb-pulse 2.4s infinite;
147
+ }
148
+ .ppb-player.folded { opacity: 0.55; }
149
+ .ppb-player-top {
150
+ display: flex;
151
+ justify-content: space-between;
152
+ gap: 8px;
153
+ margin-bottom: 10px;
154
+ }
155
+ .ppb-player-name { font-weight: 700; }
156
+ .ppb-meta {
157
+ color: var(--ppb-muted);
158
+ font-size: 13px;
159
+ line-height: 1.5;
160
+ }
161
+ .ppb-log {
162
+ background: var(--ppb-panel);
163
+ border: 1px solid var(--ppb-border);
164
+ border-radius: 18px;
165
+ padding: 14px 16px;
166
+ min-height: 120px;
167
+ }
168
+ .ppb-history-list {
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 10px;
172
+ }
173
+ .ppb-history-item {
174
+ background: rgba(255,255,255,0.04);
175
+ border: 1px solid rgba(255,255,255,0.05);
176
+ border-radius: 14px;
177
+ padding: 10px 12px;
178
+ }
179
+ .ppb-turn-banner {
180
+ margin-bottom: 14px;
181
+ padding: 12px 14px;
182
+ border-radius: 18px;
183
+ background: linear-gradient(135deg, rgba(242,184,75,0.14), rgba(103,212,197,0.12));
184
+ border: 1px solid rgba(255,255,255,0.08);
185
+ }
186
+ @keyframes ppb-fade-up {
187
+ from { transform: translateY(6px); opacity: 0; }
188
+ to { transform: translateY(0); opacity: 1; }
189
+ }
190
+ @keyframes ppb-pulse {
191
+ 0% { box-shadow: 0 0 0 1px rgba(242,184,75,0.18), 0 8px 24px rgba(242,184,75,0.10); }
192
+ 50% { box-shadow: 0 0 0 1px rgba(242,184,75,0.30), 0 12px 32px rgba(242,184,75,0.18); }
193
+ 100% { box-shadow: 0 0 0 1px rgba(242,184,75,0.18), 0 8px 24px rgba(242,184,75,0.10); }
194
+ }
195
+ """
196
+
197
+
198
+ def default_live_play_config_json() -> str:
199
+ return json.dumps(
200
+ {
201
+ "seed": 20260428,
202
+ "hand_count": 2,
203
+ "players": [
204
+ {"name": "You", "kind": "human"},
205
+ {
206
+ "name": "GPT-5.5",
207
+ "kind": "litellm",
208
+ "provider": "openai",
209
+ "model_id": "gpt-5.5",
210
+ "temperature": 0.0,
211
+ "max_tokens": 300,
212
+ },
213
+ {
214
+ "name": "DeepSeek",
215
+ "kind": "litellm",
216
+ "provider": "deepseek",
217
+ "model_id": "deepseek-v4-pro",
218
+ "temperature": 0.0,
219
+ "max_tokens": 300,
220
+ },
221
+ {"name": "CPU1", "kind": "passive_bot"},
222
+ ],
223
+ },
224
+ indent=2,
225
+ sort_keys=True,
226
+ )
227
+
228
+
229
+ def generate_demo_replay_payload(*, seed: int, hand_count: int) -> dict[str, Any]:
230
+ session_config = PlaySessionConfig(
231
+ seats=tuple(
232
+ PlaySeatSpec(name=f"P{index + 1}", kind=PlaySeatKind.PASSIVE_BOT)
233
+ for index in range(4)
234
+ ),
235
+ hand_count=hand_count,
236
+ hand_runner_config=HandRunnerConfig(seed=seed),
237
+ )
238
+ results = run_play_session(session_config)
239
+ return build_match_replay(hand_results=results, session_config=session_config, label="web-demo")
240
+
241
+
242
+ def build_replay_view_model(payload: dict[str, Any]) -> tuple[str, list[str], str]:
243
+ hand_ids = replay_hand_choices(payload)
244
+ first_hand_markdown = render_replay_hand_markdown(payload, hand_ids[0]) if hand_ids else "No hands."
245
+ return render_replay_summary_markdown(payload), hand_ids, first_hand_markdown
246
+
247
+
248
+ def render_live_table_html(controller: LiveMatchController | None) -> str:
249
+ if controller is None:
250
+ return "<div class='ppb-shell'><div class='ppb-table'>No live session loaded.</div></div>"
251
+
252
+ if controller.hand_state is None and controller.last_hand_result is not None:
253
+ board = list(cards_to_notation(controller.last_hand_result.hand_state.community_cards))
254
+ pool = list(controller.last_hand_result.persistent_pool_after)
255
+ players_payload = [
256
+ {
257
+ "seat": player.seat,
258
+ "name": player.name,
259
+ "stack": player.stack,
260
+ "committed_total": player.committed_total,
261
+ "folded": player.folded,
262
+ "all_in": player.all_in,
263
+ "seat_kind": controller.session_config.seats[player.seat].kind.value,
264
+ "hole_cards": list(cards_to_notation(player.hole_cards)) if len(player.hole_cards) == 2 else [],
265
+ }
266
+ for player in controller.last_hand_result.hand_state.players
267
+ ]
268
+ actor_index = None
269
+ street = "completed"
270
+ pot_total = controller.last_hand_result.hand_state.pot_total
271
+ elif controller.hand_state is not None:
272
+ board = list(cards_to_notation(controller.hand_state.community_cards))
273
+ pool = list(controller.persistent_pool.notation_snapshot())
274
+ players_payload = [
275
+ {
276
+ "seat": player.seat,
277
+ "name": player.name,
278
+ "stack": player.stack,
279
+ "committed_total": player.committed_total,
280
+ "folded": player.folded,
281
+ "all_in": player.all_in,
282
+ "seat_kind": controller.session_config.seats[player.seat].kind.value,
283
+ "hole_cards": (
284
+ list(cards_to_notation(player.hole_cards))
285
+ if player.seat in _visible_seats(controller) and len(player.hole_cards) == 2
286
+ else ["??", "??"]
287
+ ),
288
+ }
289
+ for player in controller.hand_state.players
290
+ ]
291
+ actor_index = controller.hand_state.actor_index if controller.hand_state.pending_actor_indices else None
292
+ street = controller.hand_state.street.value
293
+ pot_total = controller.hand_state.pot_total
294
+ else:
295
+ board = []
296
+ pool = list(controller.persistent_pool.notation_snapshot())
297
+ players_payload = []
298
+ actor_index = None
299
+ street = "not-started"
300
+ pot_total = 0
301
+
302
+ board_html = "".join(_render_card(card) for card in board) or _render_empty_slot("Board pending")
303
+ pool_html = "".join(_render_card(card, variant="hidden") for card in pool) or _render_empty_slot("Pool empty")
304
+ players_html = "".join(_render_player_panel(player, actor_index=actor_index) for player in players_payload)
305
+ turn_banner = (
306
+ f"<div class='ppb-turn-banner'><strong>Action:</strong> {escape(controller.current_actor_name() or 'Waiting for showdown')}</div>"
307
+ if controller.hand_state is not None
308
+ else "<div class='ppb-turn-banner'><strong>Action:</strong> hand completed</div>"
309
+ )
310
+
311
+ return f"""
312
+ <div class="ppb-shell">
313
+ <div class="ppb-table">
314
+ {turn_banner}
315
+ <div class="ppb-table-header">
316
+ <div>
317
+ <div class="ppb-pot">Pot {pot_total}</div>
318
+ <div class="ppb-badge">Street: {escape(street)}</div>
319
+ </div>
320
+ <div class="ppb-badge">Hands completed: {len(controller.completed_results)}/{controller.session_config.hand_count}</div>
321
+ </div>
322
+ <div class="ppb-meta">Community board</div>
323
+ <div class="ppb-card-row">{board_html}</div>
324
+ <div class="ppb-meta">Persistent public pool</div>
325
+ <div class="ppb-card-row">{pool_html}</div>
326
+ <div class="ppb-players">{players_html or "<div class='ppb-player'>No player state yet.</div>"}</div>
327
+ </div>
328
+ </div>
329
+ """
330
+
331
+
332
+ def render_live_status_html(controller: LiveMatchController | None) -> str:
333
+ if controller is None:
334
+ return "<div class='ppb-sidebar'>No active session.</div>"
335
+
336
+ status_lines = [
337
+ f"<div class='ppb-badge'>Status: {escape(controller.status_message)}</div>",
338
+ f"<div class='ppb-badge'>Current hand: {escape(str(controller.current_hand_id))}</div>",
339
+ f"<div class='ppb-badge'>Waiting seat: {escape(str(controller.waiting_for_human_seat))}</div>",
340
+ f"<div class='ppb-badge'>Finalized hands: {len(controller.completed_results)}</div>",
341
+ ]
342
+ legal_actions = controller.legal_actions_for_human()
343
+ if legal_actions is not None:
344
+ status_lines.append(
345
+ f"<div class='ppb-meta' style='margin-top:12px'><strong>Legal actions</strong><br>{escape(_legal_actions_text(legal_actions))}</div>"
346
+ )
347
+ if controller.current_tiebreak_events:
348
+ status_lines.append(
349
+ "<div class='ppb-meta' style='margin-top:12px'><strong>Dice tie-breaks</strong><br>"
350
+ + "<br>".join(escape(event["context"]) for event in controller.current_tiebreak_events)
351
+ + "</div>"
352
+ )
353
+ return "<div class='ppb-sidebar'><h3>Command Center</h3>" + "".join(status_lines) + "</div>"
354
+
355
+
356
+ def render_live_history_html(controller: LiveMatchController | None) -> str:
357
+ if controller is None:
358
+ return "<div class='ppb-sidebar'>No action log.</div>"
359
+
360
+ items: list[str] = []
361
+ transcript = controller.transcript[-10:]
362
+ for event in transcript:
363
+ executed = event["executed_action"]
364
+ amount_suffix = f" {executed['amount']}" if executed.get("amount") is not None else ""
365
+ items.append(
366
+ "<div class='ppb-history-item'>"
367
+ f"<div><strong>{escape(event['player_name'])}</strong> <span class='ppb-badge'>{escape(event['street'])}</span></div>"
368
+ f"<div class='ppb-meta'>action={escape(str(executed['action']))}{escape(amount_suffix)} | "
369
+ f"provider={escape(str(event.get('provider') or '-'))}</div>"
370
+ "</div>"
371
+ )
372
+ if controller.last_hand_result is not None:
373
+ items.append(
374
+ "<div class='ppb-history-item'>"
375
+ f"<div><strong>Hand complete</strong> <span class='ppb-badge'>{escape(controller.last_hand_result.hand_id)}</span></div>"
376
+ f"<div class='ppb-meta'>pool decision={escape(controller.last_hand_result.winner_pool_decision)}</div>"
377
+ "</div>"
378
+ )
379
+ if not items:
380
+ items.append("<div class='ppb-history-item'>No actions yet.</div>")
381
+ return "<div class='ppb-sidebar'><h3>Action History</h3><div class='ppb-history-list'>" + "".join(items) + "</div></div>"
382
+
383
+
384
+ def build_live_view_model(
385
+ controller: LiveMatchController | None,
386
+ ) -> tuple[str, str, str, str, list[str], str]:
387
+ replay_payload = (
388
+ build_match_replay(
389
+ hand_results=tuple(controller.completed_results),
390
+ session_config=controller.session_config,
391
+ label="live-web-session",
392
+ )
393
+ if controller is not None
394
+ else {"hands": []}
395
+ )
396
+ replay_json = json.dumps(replay_payload, indent=2, sort_keys=True)
397
+ summary, hand_ids, hand_markdown = build_replay_view_model(replay_payload)
398
+ return (
399
+ render_live_table_html(controller),
400
+ render_live_status_html(controller),
401
+ render_live_history_html(controller),
402
+ replay_json,
403
+ hand_ids,
404
+ hand_markdown if hand_ids else summary,
405
+ )
406
+
407
+
408
+ def build_web_app():
409
+ try:
410
+ gr = import_module("gradio")
411
+ except ImportError as exc:
412
+ raise ImportError("gradio is not installed. Install it with `pip install -e '.[ui]'`.") from exc
413
+
414
+ def generate_demo(seed: float, hand_count: float):
415
+ payload = generate_demo_replay_payload(seed=int(seed), hand_count=int(hand_count))
416
+ summary, hand_ids, first_hand_markdown = build_replay_view_model(payload)
417
+ replay_json = json.dumps(payload, indent=2, sort_keys=True)
418
+ default_hand = hand_ids[0] if hand_ids else None
419
+ return (
420
+ summary,
421
+ replay_json,
422
+ gr.update(choices=hand_ids, value=default_hand),
423
+ first_hand_markdown,
424
+ )
425
+
426
+ def load_replay(replay_json: str):
427
+ payload = json.loads(replay_json)
428
+ summary, hand_ids, first_hand_markdown = build_replay_view_model(payload)
429
+ default_hand = hand_ids[0] if hand_ids else None
430
+ return summary, gr.update(choices=hand_ids, value=default_hand), first_hand_markdown
431
+
432
+ def render_selected_hand(replay_json: str, hand_id: str):
433
+ payload = json.loads(replay_json)
434
+ return render_replay_hand_markdown(payload, hand_id)
435
+
436
+ def start_live_session(config_json: str):
437
+ session_config = parse_play_session_config(json.loads(config_json))
438
+ controller = LiveMatchController(session_config=session_config)
439
+ controller.start()
440
+ table_html, status_md, log_md, replay_json, hand_ids, hand_markdown = build_live_view_model(controller)
441
+ default_hand = hand_ids[0] if hand_ids else None
442
+ return (
443
+ controller,
444
+ table_html,
445
+ status_md,
446
+ log_md,
447
+ replay_json,
448
+ gr.update(choices=hand_ids, value=default_hand),
449
+ hand_markdown,
450
+ )
451
+
452
+ def submit_live_action(controller: LiveMatchController | None, action: str, amount: float | None, pool_decision: str):
453
+ if controller is None:
454
+ raise ValueError("Start a live session first.")
455
+ controller.submit_human_action(
456
+ HumanCommand(
457
+ action=action,
458
+ amount=None if amount is None else int(amount),
459
+ winner_pool_decision=WinnerPoolDecision(pool_decision),
460
+ )
461
+ )
462
+ table_html, status_md, log_md, replay_json, hand_ids, hand_markdown = build_live_view_model(controller)
463
+ default_hand = hand_ids[-1] if hand_ids else None
464
+ return (
465
+ controller,
466
+ table_html,
467
+ status_md,
468
+ log_md,
469
+ replay_json,
470
+ gr.update(choices=hand_ids, value=default_hand),
471
+ hand_markdown,
472
+ )
473
+
474
+ with gr.Blocks(title="PersistentPoker-Bench Replay Studio") as demo:
475
+ gr.Markdown(
476
+ """
477
+ # PersistentPoker-Bench
478
+ Live play for humans + LiteLLM seats, plus a structured replay studio for deterministic review.
479
+ """
480
+ )
481
+
482
+ with gr.Tab("Live Table"):
483
+ live_controller_state = gr.State(value=None)
484
+ with gr.Row():
485
+ with gr.Column(scale=5):
486
+ live_config = gr.Textbox(
487
+ label="Live Session Config JSON",
488
+ lines=18,
489
+ value=default_live_play_config_json(),
490
+ )
491
+ with gr.Row():
492
+ start_live_button = gr.Button("Start Live Session", variant="primary")
493
+ live_status = gr.HTML()
494
+ live_log = gr.HTML()
495
+ with gr.Column(scale=6):
496
+ live_table = gr.HTML()
497
+ with gr.Row():
498
+ live_action = gr.Dropdown(
499
+ choices=["fold", "check", "call", "bet", "raise", "all_in"],
500
+ value="check",
501
+ label="Human Action",
502
+ interactive=True,
503
+ )
504
+ live_amount = gr.Number(value=None, precision=0, label="Amount")
505
+ live_pool_decision = gr.Radio(
506
+ choices=["continue", "reset"],
507
+ value="continue",
508
+ label="Winner Pool Decision",
509
+ )
510
+ submit_action_button = gr.Button("Submit Human Action", variant="secondary")
511
+ live_replay_json = gr.Textbox(label="Structured Replay JSON", lines=18)
512
+ live_replay_hand_selector = gr.Dropdown(choices=[], label="Replay Hand", interactive=True)
513
+ live_replay_hand_markdown = gr.Markdown()
514
+
515
+ start_live_button.click(
516
+ fn=start_live_session,
517
+ inputs=[live_config],
518
+ outputs=[
519
+ live_controller_state,
520
+ live_table,
521
+ live_status,
522
+ live_log,
523
+ live_replay_json,
524
+ live_replay_hand_selector,
525
+ live_replay_hand_markdown,
526
+ ],
527
+ )
528
+ submit_action_button.click(
529
+ fn=submit_live_action,
530
+ inputs=[live_controller_state, live_action, live_amount, live_pool_decision],
531
+ outputs=[
532
+ live_controller_state,
533
+ live_table,
534
+ live_status,
535
+ live_log,
536
+ live_replay_json,
537
+ live_replay_hand_selector,
538
+ live_replay_hand_markdown,
539
+ ],
540
+ )
541
+ live_replay_hand_selector.change(
542
+ fn=render_selected_hand,
543
+ inputs=[live_replay_json, live_replay_hand_selector],
544
+ outputs=live_replay_hand_markdown,
545
+ )
546
+
547
+ with gr.Tab("Demo Replay"):
548
+ with gr.Row():
549
+ seed = gr.Number(value=20260428, precision=0, label="Seed")
550
+ hand_count = gr.Slider(minimum=1, maximum=20, value=2, step=1, label="Hands")
551
+ generate_button = gr.Button("Generate Replay", variant="primary")
552
+ demo_summary = gr.Markdown()
553
+ demo_replay_json = gr.Textbox(label="Replay JSON", lines=18)
554
+ demo_hand_selector = gr.Dropdown(choices=[], label="Select Hand", interactive=True)
555
+ demo_hand_markdown = gr.Markdown()
556
+ generate_button.click(
557
+ fn=generate_demo,
558
+ inputs=[seed, hand_count],
559
+ outputs=[demo_summary, demo_replay_json, demo_hand_selector, demo_hand_markdown],
560
+ )
561
+ demo_hand_selector.change(
562
+ fn=render_selected_hand,
563
+ inputs=[demo_replay_json, demo_hand_selector],
564
+ outputs=demo_hand_markdown,
565
+ )
566
+
567
+ with gr.Tab("Replay Viewer"):
568
+ replay_input = gr.Textbox(label="Paste Replay JSON", lines=18)
569
+ load_button = gr.Button("Load Replay", variant="primary")
570
+ viewer_summary = gr.Markdown()
571
+ viewer_hand_selector = gr.Dropdown(choices=[], label="Select Hand", interactive=True)
572
+ viewer_hand_markdown = gr.Markdown()
573
+ load_button.click(
574
+ fn=load_replay,
575
+ inputs=[replay_input],
576
+ outputs=[viewer_summary, viewer_hand_selector, viewer_hand_markdown],
577
+ )
578
+ viewer_hand_selector.change(
579
+ fn=render_selected_hand,
580
+ inputs=[replay_input, viewer_hand_selector],
581
+ outputs=viewer_hand_markdown,
582
+ )
583
+
584
+ return demo
585
+
586
+
587
+ def launch_web_app(*, host: str = "127.0.0.1", port: int = 7860, share: bool = False):
588
+ demo = build_web_app()
589
+ demo.launch(server_name=host, server_port=port, share=share, css=LIVE_UI_CSS)
590
+ return demo
591
+
592
+
593
+ def _visible_seats(controller: LiveMatchController) -> set[int]:
594
+ return {
595
+ index
596
+ for index, seat in enumerate(controller.session_config.seats)
597
+ if seat.kind is PlaySeatKind.HUMAN
598
+ }
599
+
600
+
601
+ def _render_card(card: str, *, variant: str = "face") -> str:
602
+ classes = "ppb-card" if variant == "face" else "ppb-card hidden"
603
+ return f"<div class='{classes}'>{escape(card)}</div>"
604
+
605
+
606
+ def _render_empty_slot(label: str) -> str:
607
+ return f"<div class='ppb-badge'>{escape(label)}</div>"
608
+
609
+
610
+ def _render_player_panel(player: dict[str, Any], *, actor_index: int | None) -> str:
611
+ classes = ["ppb-player"]
612
+ if actor_index == int(player["seat"]):
613
+ classes.append("active")
614
+ if player["folded"]:
615
+ classes.append("folded")
616
+ cards_html = "".join(_render_card(card, variant="hidden" if card == "??" else "face") for card in player["hole_cards"])
617
+ return (
618
+ f"<div class='{' '.join(classes)}'>"
619
+ f"<div class='ppb-player-top'><div class='ppb-player-name'>P{int(player['seat']) + 1} {escape(player['name'])}</div>"
620
+ f"<div class='ppb-badge'>{escape(str(player['seat_kind']))}</div></div>"
621
+ f"<div class='ppb-card-row'>{cards_html}</div>"
622
+ f"<div class='ppb-meta'>committed={int(player['committed_total'])} | "
623
+ f"stack={int(player['stack'])} | folded={player['folded']} | all_in={player['all_in']}</div>"
624
+ f"<div class='ppb-stack'><span style='width:{max(2, min(100, int((int(player['stack']) / 2000) * 100) if int(player['stack']) >= 0 else 2))}%'></span></div>"
625
+ f"</div>"
626
+ )
627
+
628
+
629
+ def _legal_actions_text(legal_actions: dict[str, Any]) -> str:
630
+ options: list[str] = []
631
+ if legal_actions["can_fold"]:
632
+ options.append("fold")
633
+ if legal_actions["can_check"]:
634
+ options.append("check")
635
+ if legal_actions["can_call"]:
636
+ options.append(f"call({legal_actions['call_amount']})")
637
+ if legal_actions["can_bet"]:
638
+ options.append(f"bet[{legal_actions['min_bet_to']}-{legal_actions['max_to']}]")
639
+ if legal_actions["can_raise"]:
640
+ options.append(f"raise[{legal_actions['min_raise_to']}-{legal_actions['max_to']}]")
641
+ if legal_actions["can_all_in"]:
642
+ options.append(f"all_in({legal_actions['max_to']})")
643
+ return ", ".join(options)