Spaces:
Sleeping
Sleeping
| """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 | |
| <p class="subtitle">Auto-generate a legal 50-card One Piece Card Game deck anchored on any Leader.</p> | |
| <div> | |
| <span class="stats-pill"><b>{N_CARDS}</b> cards</span> | |
| <span class="stats-pill"><b>{N_LEADERS}</b> leaders</span> | |
| <span class="stats-pill"><b>{N_SETS}</b> sets</span> | |
| <span class="stats-pill">latest <b>{LATEST_SET}</b></span> | |
| <span class="stats-pill">3 styles</span> | |
| </div> | |
| <p class="muted">Dataset: <a href="https://huggingface.co/datasets/t22000t/optcg-en-card-embeddings" target="_blank">t22000t/optcg-en-card-embeddings</a> · Code: <a href="https://github.com/timothy22000/optcg-cards" target="_blank">github.com/timothy22000/optcg-cards</a> · Sister: <a href="https://huggingface.co/spaces/t22000t/optcg-explorer" target="_blank">OPTCG Card Explorer</a></p> | |
| """ | |
| ) | |
| # ----- 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, " | |
| "`<qty>x <ID> <Name>`." | |
| ) | |
| 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( | |
| '<div class="muted" style="text-align:center; padding:12px 0;">' | |
| 'Built with <a href="https://gradio.app" target="_blank">Gradio</a>. ' | |
| 'Embeddings: Qwen/Qwen3-Embedding-0.6B. ' | |
| 'Card data via <a href="https://github.com/Coko7/vegapull" target="_blank">vegapull</a>. ' | |
| 'Not affiliated with Bandai or the One Piece Card Game.' | |
| '</div>' | |
| ) | |
| # ----- 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() | |