tabras / draft.py
Codex
Pre-bake backbone card art (instant, no shimmer) + stop warming face-down enemy hand
a33d5b8
Raw
History Blame Contribute Delete
4.17 kB
import base64
import re
from pathlib import Path
from random import Random
from typing import Callable
from budget import Card
from generator import CardModelClient, CardPackClient, generate_card, generate_pack
from primitives import Effect
from primitives import School
PackChooser = Callable[[tuple[Card, ...], tuple[Card, ...]], int]
_CARD_ART = Path(__file__).parent / "assets" / "cards"
_backbone_art_cache: dict[str, str] = {}
# Return the pre-baked art data URI for a backbone card. The six backbone cards are
# identical every run, so their art is generated once and bundled (no live SDXL call,
# no shimmer).
def prebaked_backbone_art(name: str, theme: str) -> str:
world = re.sub(r"[^a-z0-9]+", "", theme.lower())
card = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
key = f"{world}__{card}"
if key not in _backbone_art_cache:
path = _CARD_ART / f"{key}.jpg"
_backbone_art_cache[key] = (
"data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") if path.exists() else ""
)
return _backbone_art_cache[key]
BACKBONE_CARDS: tuple[tuple[str, int, Effect], ...] = (
("Attack (weak)", 1, Effect("deal", amount=2)),
("Attack (medium)", 3, Effect("deal", amount=4)),
("Attack (strong)", 5, Effect("deal", amount=6)),
("Block (weak)", 2, Effect("block", amount=3)),
("Block (medium)", 3, Effect("block", amount=4)),
("Draw (small)", 2, Effect("draw", amount=1)),
)
# Build the six standardized backbone cards for a run.
def backbone_deck(school: School, theme: str) -> tuple[Card, ...]:
return tuple(backbone_card(name, cost, effect, school, theme) for name, cost, effect in BACKBONE_CARDS)
# Build one standardized backbone card, with pre-baked art so it never shimmers.
def backbone_card(name: str, cost: int, effect: Effect, school: School, theme: str) -> Card:
return Card(name, cost, school, theme, (effect,), flavor=f"Standard {name.lower()}.", art_uri=prebaked_backbone_art(name, theme))
# Build a full 15-card text deck with nine generated synergy cards.
def draft_deck(client: CardModelClient, school: School, theme: str, costs: tuple[int, ...]) -> tuple[Card, ...]:
if len(costs) != 9:
raise ValueError("draft needs exactly nine synergy costs")
deck = list(backbone_deck(school, theme))
for cost in costs:
deck.append(generate_card(client, school, theme, deck, cost))
return tuple(deck)
# Build a full deck by drafting one card from each generated pack.
def draft_deck_from_packs(
client: CardPackClient,
school: School,
theme: str,
costs: tuple[int, ...],
choose_index: PackChooser,
rng: Random | None = None,
) -> tuple[Card, ...]:
if len(costs) != 9:
raise ValueError("draft needs exactly nine synergy costs")
deck = list(backbone_deck(school, theme))
anchors: list[Card] = []
anchor_indexes = draft_anchor_indexes(len(costs), rng or Random())
for index in draft_order(len(costs), anchor_indexes):
pack = generate_pack(client, school, theme, deck, costs[index], draft_anchors=anchors)
selected = pack[validated_pack_choice(choose_index(tuple(deck), pack), pack)]
deck.append(selected)
if index in anchor_indexes:
anchors.append(selected)
return tuple(deck)
# Return random synergy-card indexes that anchor the rest of the draft.
def draft_anchor_indexes(count: int, rng: Random) -> frozenset[int]:
if count < 2:
raise ValueError("draft needs at least two anchor candidates")
return frozenset(rng.sample(range(count), 2))
# Return anchor picks first, then the remaining picks in cost order.
def draft_order(count: int, anchor_indexes: frozenset[int]) -> tuple[int, ...]:
anchors = tuple(sorted(anchor_indexes))
remaining = tuple(index for index in range(count) if index not in anchor_indexes)
return anchors + remaining
# Return a valid draft pack index or reject it.
def validated_pack_choice(index: int, pack: tuple[Card, ...]) -> int:
if index < 0 or index >= len(pack):
raise ValueError("draft choice is outside the pack")
return index