from __future__ import annotations import json from html import escape from pathlib import Path from typing import Any import gradio as gr from persistentpoker_bench.cards import cards_to_notation from persistentpoker_bench.replay import ( build_match_replay, replay_hand_choices, render_replay_hand_markdown, render_replay_summary_markdown, ) # --- CSS CASINO WINAMAX-STYLE --- LIVE_UI_CSS = """ :root { --ppb-bg: #0b0f1a; --ppb-table: #1a472a; --ppb-table-border: #3d2b1f; --ppb-gold: #f2b84b; --ppb-text: #e0e0e0; } .gradio-container { background-color: var(--ppb-bg) !important; } /* La Table de Poker Visuelle */ .poker-table-container { position: relative; width: 100%; max-width: 800px; height: 450px; background: radial-gradient(circle, #2d5a27 0%, #1a472a 100%); border: 12px solid var(--ppb-table-border); border-radius: 220px; box-shadow: inset 0 0 60px rgba(0,0,0,0.6), 0 20px 40px rgba(0,0,0,0.5); margin: 30px auto; } .poker-table-felt { position: absolute; top: 10px; left: 10px; right: 10px; bottom: 10px; border: 2px solid rgba(255,255,255,0.05); border-radius: 210px; } /* Community Cards au centre */ .community-cards-area { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; gap: 15px; z-index: 10; } .cards-row { display: flex; gap: 8px; } .pot-display { background: rgba(0,0,0,0.6); padding: 5px 15px; border-radius: 20px; color: var(--ppb-gold); font-weight: bold; font-size: 1.2em; border: 1px solid var(--ppb-gold); } /* Positions des Joueurs */ .player-seat { position: absolute; width: 120px; text-align: center; z-index: 20; } .seat-0 { bottom: -20px; left: 50%; transform: translateX(-50%); } .seat-1 { top: 50%; left: -60px; transform: translateY(-50%); } .seat-2 { top: -20px; left: 50%; transform: translateX(-50%); } .seat-3 { top: 50%; right: -60px; transform: translateY(-50%); } .player-avatar { background: #1c2331; border: 3px solid var(--ppb-gold); border-radius: 50%; width: 70px; height: 70px; margin: 0 auto 5px; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 1.5em; box-shadow: 0 4px 10px rgba(0,0,0,0.5); color: white; } .player-info { background: rgba(18, 28, 38, 0.95); padding: 6px 10px; border-radius: 10px; font-size: 0.9em; color: white; border: 1px solid rgba(255,255,255,0.1); } .action-badge { position: absolute; top: -30px; left: 50%; transform: translateX(-50%); background: var(--ppb-gold); color: black; padding: 3px 12px; border-radius: 6px; font-weight: 900; font-size: 0.8em; text-transform: uppercase; box-shadow: 0 2px 5px rgba(0,0,0,0.3); } /* Style des Cartes */ .ppb-card { display: inline-block; width: 42px; height: 60px; background: white; border-radius: 6px; color: black; font-weight: bold; text-align: center; line-height: 60px; font-size: 1.1em; box-shadow: 2px 2px 6px rgba(0,0,0,0.4); border: 1px solid #ccc; } .ppb-card.hearts, .ppb-card.diamonds { color: #d12e2e; } .ppb-card.clubs, .ppb-card.spades { color: #222; } .ppb-card.hidden { background: linear-gradient(135deg, #203244 0%, #0b0f1a 100%); border: 1px solid var(--ppb-gold); color: transparent; } """ def _card_to_html(card_str: str, is_hidden: bool = False) -> str: if card_str == "??" or is_hidden: return '' suit_map = {"h": "hearts", "d": "diamonds", "c": "clubs", "s": "spades"} suit_icon = {"h": "♥", "d": "♦", "c": "♣", "s": "♠"} rank = card_str[:-1] suit_code = card_str[-1].lower() suit_class = suit_map.get(suit_code, "") icon = suit_icon.get(suit_code, "") return f'
{rank}{icon}
' def render_visual_table(hand_data: dict[str, Any]) -> str: players = hand_data.get("players", []) community = hand_data.get("community_cards", []) pot = hand_data.get("pot_total", 0) variant = hand_data.get("variant", "holdem").upper() cards_html = "".join([_card_to_html(c) for c in community]) players_html = "" for i, p in enumerate(players): seat_class = f"seat-{i}" status = p.get("status", "active") cards = p.get("hole_cards", ["??", "??"]) # On ne montre les cartes que si la main est finie ou si c'est le vainqueur cards_display = "".join([_card_to_html(c, is_hidden=(status == "folded")) for c in cards]) opacity = "opacity: 0.4;" if status == "folded" else "" players_html += f'''
{p["name"][0].upper()}
{cards_display}
{p["name"]}
{p["stack"]} chips
''' return f'''
{variant}
POT: {pot}
{cards_html}
{players_html}
''' def build_web_app(): with gr.Blocks(css=LIVE_UI_CSS, title="PersistentPoker-Bench Studio") as demo: gr.Markdown("# 🃏 PersistentPoker-Bench Replay Studio") with gr.Tab("Visual Replay Viewer"): with gr.Row(): with gr.Column(scale=1): file_input = gr.File(label="Drag & Drop results.jsonl here", file_types=[".jsonl", ".json"]) load_btn = gr.Button("🚀 Load Match", variant="primary") demo_btn = gr.Button("🎲 Generate Dummy Demo", variant="secondary") hand_selector = gr.Dropdown(label="Select Hand", choices=[]) with gr.Column(scale=3): table_display = gr.HTML(value='
Load a match to see the table
') hand_summary = gr.Markdown() # État interne pour stocker les données du match chargé match_state = gr.State() def on_load_file(file): if file is None: return None, gr.update(choices=[]), "No file selected" try: raw_actions = [] with open(file.name, "r") as f: for line in f: if not line.strip(): continue try: parsed = json.loads(line) # Si le fichier est un MatchRecord (V1), il a une clé 'hand_results' if "hand_results" in parsed: hands_list = parsed["hand_results"] hand_names = [f"Hand {i+1}" for i in range(len(hands_list))] return {"format": "v1", "data": hands_list}, gr.update(choices=hand_names, value=hand_names[0] if hand_names else None), f"V1 Match loaded: {len(hands_list)} hands." # Si c'est un Marathon (V2), c'est un dictionnaire avec une clé 'transcript' qui contient toutes les actions if "transcript" in parsed: raw_actions.extend(parsed["transcript"]) except: continue if not raw_actions: return None, gr.update(choices=[]), "No valid poker data found." # On regroupe par hand_id hands_map = {} for act in raw_actions: hid = act.get("hand_id", "unknown") if hid not in hands_map: hands_map[hid] = [] hands_map[hid].append(act) sorted_hand_ids = sorted(hands_map.keys()) hand_names = [f"Hand {i+1} ({hid})" for i, hid in enumerate(sorted_hand_ids)] return {"format": "marathon", "data": hands_map, "ids": sorted_hand_ids}, gr.update(choices=hand_names, value=hand_names[0] if hand_names else None), f"Marathon loaded: {len(sorted_hand_ids)} hands." except Exception as e: return None, gr.update(choices=[]), f"Error loading file: {e}" def on_hand_change(hand_name, match_state): if not match_state or not hand_name: return "", "" if match_state["format"] == "v1": hand_idx = int(hand_name.split(" ")[1]) - 1 hand_data = match_state["data"][hand_idx] hand_state_obj = hand_data["hand_state"] viz_data = { "variant": hand_state_obj.get("variant", "holdem"), "pot_total": hand_state_obj.get("pot_total", 0), "community_cards": hand_state_obj.get("community_cards", []), "players": [] } for i, p in enumerate(hand_state_obj.get("players", [])): viz_data["players"].append({ "name": p.get("name", f"Seat {i}"), "stack": p.get("stack", 0), "status": "folded" if p.get("folded") else "active", "hole_cards": p.get("hole_cards", ["??", "??"]) }) return render_visual_table(viz_data), render_replay_hand_markdown(hand_data) else: # format marathon hid = hand_name.split("(")[1].split(")")[0] actions = match_state["data"][hid] last_act = actions[-1] # On prend la dernière action pour l'état final # Dans les traces de marathon, l'état complet est dans 'game_snapshot' snapshot = last_act.get("game_snapshot", {}) viz_data = { "variant": last_act.get("variant", snapshot.get("variant", "holdem")), "pot_total": snapshot.get("pot_total", 0), "community_cards": snapshot.get("community_cards", last_act.get("believed_pool", [])), "players": [] } for i, p in enumerate(snapshot.get("players", [])): # On cherche les cartes privées dans l'action si elles ne sont pas dans le snapshot global viz_data["players"].append({ "name": p.get("name", f"Seat {i}"), "stack": p.get("stack", 0), "status": p.get("status", "active"), "hole_cards": p.get("hole_cards", ["??", "??"]) }) return render_visual_table(viz_data), f"Hand ID: {hid}\nActions in this hand: {len(actions)}" def on_generate_demo(): demo_path = Path("marathon_demo.jsonl") if not demo_path.exists(): return None, gr.update(choices=[]), "Demo file 'marathon_demo.jsonl' not found on the server." try: raw_actions = [] with open(demo_path, "r") as f: for line in f: if not line.strip(): continue try: parsed = json.loads(line) # On cherche la clé transcript dans le fichier results.jsonl renommé if "transcript" in parsed: raw_actions.extend(parsed["transcript"]) except: continue if not raw_actions: return None, gr.update(choices=[]), "No actions found in demo file." hands_map = {} for act in raw_actions: hid = act.get("hand_id", "unknown") if hid not in hands_map: hands_map[hid] = [] hands_map[hid].append(act) sorted_hand_ids = sorted(hands_map.keys()) hand_names = [f"Hand {i+1} ({hid})" for i, hid in enumerate(sorted_hand_ids)] return {"format": "marathon", "data": hands_map, "ids": sorted_hand_ids}, gr.update(choices=hand_names, value=hand_names[0] if hand_names else None), f"Demo Marathon loaded: {len(sorted_hand_ids)} hands." except Exception as e: return None, gr.update(choices=[]), f"Error loading demo file: {e}" demo_btn.click(on_generate_demo, inputs=[], outputs=[match_state, hand_selector, hand_summary]) load_btn.click(on_load_file, inputs=[file_input], outputs=[match_state, hand_selector, hand_summary]) hand_selector.change(on_hand_change, inputs=[hand_selector, match_state], outputs=[table_display, hand_summary]) return demo if __name__ == "__main__": build_web_app().launch()