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