t22000t's picture
Initial commit: optcg-deck-builder Gradio Space
16eaadc
"""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> &nbsp;&middot;&nbsp; Code: <a href="https://github.com/timothy22000/optcg-cards" target="_blank">github.com/timothy22000/optcg-cards</a> &nbsp;&middot;&nbsp; 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()