"""OPTCG Deck Builder - Gradio Space. Auto-generates a 50-card OPTCG deck anchored on a chosen Leader. The ranker is the same color-legality + family-bonus synergy used in the explorer Space; the deck builder layers a target cost curve on top (aggro/midrange/control presets) and a 4-copies-per-card cap. No embedding model is loaded - the leader's vector is read directly from the precomputed corpus matrix. That keeps cold start to seconds and the Space lightweight on a free CPU runner. Data source: https://huggingface.co/datasets/t22000t/optcg-en-card-embeddings """ from __future__ import annotations import logging import os from typing import Any import gradio as gr from spaceutil.data import load_corpus from spaceutil.deck import COST_CURVES, Deck, build_deck, deck_to_text from spaceutil.plot import ( build_color_breakdown_figure, build_cost_curve_figure, build_type_breakdown_figure, ) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger("optcg-deck-builder") # ---------------------------------------------------------------------------- # Startup # ---------------------------------------------------------------------------- logger.info("Loading corpus from HF Hub...") CARDS, MATRIX, EMBED_PROV, ID_TO_IDX = load_corpus(token=os.environ.get("HF_TOKEN")) logger.info("Corpus loaded: %d cards, matrix shape %s", len(CARDS), MATRIX.shape) LEADER_CHOICES = sorted( f"{c['name']} ({c['id']})" for c in CARDS if c.get("card_type") == "Leader" ) N_CARDS = len(CARDS) N_SETS = len({c.get("set_code") for c in CARDS if c.get("set_code")}) N_LEADERS = sum(1 for c in CARDS if c.get("card_type") == "Leader") LATEST_SET = max((c.get("set_code") or "" for c in CARDS), default="?") EMBEDDING_DIM = MATRIX.shape[1] STYLE_OPTIONS = sorted(COST_CURVES.keys()) # ---------------------------------------------------------------------------- # Display helpers # ---------------------------------------------------------------------------- DECK_HEADERS = [ "Qty", "ID", "Name", "Cost", "Type", "Synergy", "Family", "Colors", "Set", ] def deck_to_rows(deck: Deck | None) -> list[list[Any]]: if deck is None: return [] return [ [ dc.quantity, dc.card_id, dc.name, dc.cost if dc.cost is not None else "-", dc.card_type, round(dc.synergy_score, 4), "yes" if dc.family_match else "no", ", ".join(dc.colors), dc.set_code, ] for dc in deck.cards ] LEADER_DETAIL_FIELDS = ( ("card_type", "Type"), ("colors", "Colors"), ("life", "Life"), ("attribute", "Attribute"), ("family", "Family"), ("rarity", "Rarity"), ("set_name", "Set"), ("effect_text", "Effect"), ) def _fmt_value(value: Any) -> str: if value is None or value == "": return "-" if isinstance(value, list): return ", ".join(str(v) for v in value) if value else "-" return str(value) def format_leader_detail(card: dict[str, Any]) -> str: lines = [f"### {card.get('name', '?')}\n`{card.get('id', '?')}`\n"] for key, label in LEADER_DETAIL_FIELDS: lines.append(f"**{label}:** {_fmt_value(card.get(key))}") return "\n\n".join(lines) def format_summary(deck: Deck | None) -> str: if deck is None: return "*Pick a leader and click Build deck.*" lines = [ f"**Total cards:** {deck.total_quantity} / 50", f"**Average cost:** {deck.avg_cost:.2f}", f"**Style:** {deck.style}", f"**Family-match cards:** {deck.family_match_count} / {deck.total_quantity}", f"**Unique cards:** {len(deck.cards)}", ] return "\n\n".join(lines) # ---------------------------------------------------------------------------- # Event handlers # ---------------------------------------------------------------------------- def _selection_to_idx(selection: str) -> int | None: if not selection: return None if "(" in selection and selection.endswith(")"): card_id = selection.rsplit("(", 1)[1][:-1] else: card_id = selection return ID_TO_IDX.get(card_id) def on_leader_change(selection: str): idx = _selection_to_idx(selection) if idx is None: return "*Pick a Leader to see its details.*" return format_leader_detail(CARDS[idx]) def on_build(selection: str, style: str, max_copies: int): idx = _selection_to_idx(selection) if idx is None: empty_fig = build_cost_curve_figure(None) return ( "*Pick a Leader first.*", "*Pick a Leader first.*", empty_fig, build_type_breakdown_figure(None), build_color_breakdown_figure(None), [], "", ) deck = build_deck(idx, CARDS, MATRIX, style=style, max_copies=int(max_copies)) return ( format_leader_detail(CARDS[idx]), format_summary(deck), build_cost_curve_figure(deck), build_type_breakdown_figure(deck), build_color_breakdown_figure(deck), deck_to_rows(deck), deck_to_text(deck), ) # ---------------------------------------------------------------------------- # UI # ---------------------------------------------------------------------------- CUSTOM_CSS = """ .gradio-container { max-width: 1280px !important; margin: 0 auto !important; } #header-row h1 { margin-bottom: 0.25em; } #header-row .subtitle { color: var(--body-text-color-subdued); margin-top: 0; } .stats-pill { display: inline-block; padding: 4px 10px; margin: 2px 4px 2px 0; border-radius: 12px; background: var(--background-fill-secondary); border: 1px solid var(--border-color-primary); font-size: 0.85em; } .muted { color: var(--body-text-color-subdued); font-size: 0.9em; } """ INSTRUCTIONS_MD = f""" **How to build a deck** 1. **Pick a Leader** from the dropdown (~{N_LEADERS} leaders in the corpus). The Leader anchors color identity, archetype, and the synergy scoring. 2. **Choose a style**: - `aggro` - cost curve weighted to 1-3, flood the early board. - `midrange` - 3-6 cost dominant, the safe default. - `control` - 4-8 cost weighted, bigger threats and fewer turns to defend. 3. **Set max copies** (1-4). Standard OPTCG rules cap at 4. Lowering the cap forces more variety. 4. Click **Build deck**. You get a 50-card list, a cost-curve check against the target preset, and type/color breakdowns. 5. **Export** the plain-text deck list at the bottom and paste into your sim of choice. **What the builder does** It scores every color-legal candidate by `cosine_similarity(leader, card) + family_bonus`, then walks the chosen cost curve bucket by bucket, taking top-synergy cards (up to `max_copies` each) until each bucket is filled. If a bucket is short, the deficit spills into a backfill pass that takes the remaining best-synergy cards regardless of cost - the deck total is *always* exactly 50. **What it does not do (yet)** - No archetype/strategy detection (it doesn't know whether your leader is "the rush leader" or "the control leader"). - No banlist or competitive-meta awareness. - No DON!! deck (always 10, hardcoded across the game). - The result is a *starting point* for tweaking, not a tournament-ready list. """ ABOUT_MD = f""" ### How synergy is scored Each card gets a score of `cosine_similarity(leader_vector, card_vector) + 0.10 if same_family else 0`. Vectors come from `Qwen/Qwen3-Embedding-0.6B` ({EMBEDDING_DIM}-dim, L2-normalized) on the published [optcg-en-card-embeddings](https://huggingface.co/datasets/t22000t/optcg-en-card-embeddings) dataset. Color legality is a hard filter (you must share at least one color with the leader). Other Leader cards are dropped. ### Why styles matter Two decks built around the same leader can play very differently depending on cost distribution. The presets are deliberately blunt - they're starting shapes, not optimized curves: - aggro: 4-12-12-8-6-4-2-2 (sum 50) - midrange: 0-6-10-10-8-8-4-4 - control: 0-4-8-8-8-8-6-8 ### Source Card data from [vegapull](https://github.com/Coko7/vegapull) scraping the official One Piece Card Game site. Pipeline: [github.com/timothy22000/optcg-cards](https://github.com/timothy22000/optcg-cards). Sister demo: [OPTCG Card Explorer](https://huggingface.co/spaces/t22000t/optcg-explorer) (semantic search + UMAP + similar-cards browser). Not affiliated with Bandai or the One Piece Card Game. """ with gr.Blocks( title="OPTCG Deck Builder", theme=gr.themes.Soft(primary_hue="red", secondary_hue="blue"), css=CUSTOM_CSS, ) as demo: # ----- Header ----- with gr.Row(elem_id="header-row"): gr.Markdown( f"""# OPTCG Deck Builder

Auto-generate a legal 50-card One Piece Card Game deck anchored on any Leader.

{N_CARDS} cards {N_LEADERS} leaders {N_SETS} sets latest {LATEST_SET} 3 styles

Dataset: t22000t/optcg-en-card-embeddings  ·  Code: github.com/timothy22000/optcg-cards  ·  Sister: OPTCG Card Explorer

""" ) # ----- Instructions ----- with gr.Accordion("How to use this Space", open=True): gr.Markdown(INSTRUCTIONS_MD) # ----- Controls ----- with gr.Row(): leader_picker = gr.Dropdown( choices=LEADER_CHOICES, label="Leader", value=None, allow_custom_value=False, filterable=True, info="Type to filter by name or card ID.", scale=4, ) style_picker = gr.Dropdown( choices=STYLE_OPTIONS, value="midrange", label="Style", scale=1, ) max_copies_slider = gr.Slider( minimum=1, maximum=4, value=4, step=1, label="Max copies", scale=1, ) build_btn = gr.Button("Build deck", variant="primary", scale=1) # ----- Leader detail + summary ----- with gr.Row(): leader_detail_md = gr.Markdown( "*Pick a Leader to see its details.*", label="Leader", ) summary_md = gr.Markdown( "*Pick a Leader and click Build deck.*", label="Deck summary", ) # ----- Charts ----- with gr.Row(): cost_curve_plot = gr.Plot( value=build_cost_curve_figure(None), label="Cost curve", ) with gr.Row(): with gr.Column(): type_plot = gr.Plot( value=build_type_breakdown_figure(None), label="Type mix", ) with gr.Column(): color_plot = gr.Plot( value=build_color_breakdown_figure(None), label="Color mix", ) # ----- Deck list ----- gr.Markdown("### Deck list (sorted by cost, then synergy)") deck_df = gr.Dataframe( headers=DECK_HEADERS, value=[], label="Cards", interactive=False, wrap=True, column_widths=["6%", "11%", "26%", "6%", "11%", "10%", "8%", "14%", "8%"], ) # ----- Export ----- with gr.Accordion("Plain-text export", open=False): gr.Markdown( "Copy this and paste into your sim of choice. Format: one card per line, " "`x `." ) export_text = gr.Textbox( value="", label="Deck text", lines=12, max_lines=20, show_copy_button=True, interactive=False, ) # ----- About ----- with gr.Accordion("About this Space", open=False): gr.Markdown(ABOUT_MD) gr.Markdown( '
' 'Built with Gradio. ' 'Embeddings: Qwen/Qwen3-Embedding-0.6B. ' 'Card data via vegapull. ' 'Not affiliated with Bandai or the One Piece Card Game.' '
' ) # ----- Wiring ----- leader_picker.change( on_leader_change, inputs=[leader_picker], outputs=[leader_detail_md], ) build_outputs = [ leader_detail_md, summary_md, cost_curve_plot, type_plot, color_plot, deck_df, export_text, ] build_btn.click( on_build, inputs=[leader_picker, style_picker, max_copies_slider], outputs=build_outputs, ) style_picker.change( on_build, inputs=[leader_picker, style_picker, max_copies_slider], outputs=build_outputs, ) max_copies_slider.change( on_build, inputs=[leader_picker, style_picker, max_copies_slider], outputs=build_outputs, ) if __name__ == "__main__": demo.launch()