import base64 import time from concurrent.futures import ThreadPoolExecutor from collections.abc import Callable, Iterator from dataclasses import dataclass, replace import os from pathlib import Path from random import Random import sys from typing import Sequence import forge from art import ArtClient, illustrate_card from boss import boss_chooser from budget import Card, CardSpec, EffectPlan, cost_card from clients import boss_client_from_env from draft import backbone_deck, draft_anchor_indexes, draft_order from game import MAX_ENERGY, DuelState, PlayerState, create_player, draw_cards, play_card_from_hand, start_round, winner from generator import CardPackClient, distinct_name, generate_pack from play import OPENING_HAND_SIZE, SYNERGY_COSTS, best_draft_index, choose_enemy_card, playable_indexes, shuffled_deck from primitives import School CARD_PANEL_COUNT = 3 HAND_PANEL_COUNT = 10 THEME = "dark fantasy" # Minimum draft loading-screen window: every pick shows the same brief "forging" # beat, which also absorbs any pack that is not prefetched yet. Tunable via env. try: MIN_DRAFT_LOADING_SECONDS = float(os.environ.get("TABRAS_MIN_DRAFT_LOADING", "2.0")) except ValueError: MIN_DRAFT_LOADING_SECONDS = 2.0 @dataclass(frozen=True) class RunState: player_name: str world: str school: School enemy_school: School player_deck: tuple[Card, ...] enemy_deck: tuple[Card, ...] draft_step: int draft_order: tuple[int, ...] anchor_indexes: frozenset[int] draft_anchors: tuple[Card, ...] current_pack: tuple[Card, ...] duel: DuelState | None turn_order: tuple[str, ...] turn_position: int log: tuple[str, ...] rng: Random enemy_seed: int = 0 showcase: tuple[tuple[str, Card], ...] = () hp_flash: tuple[int, int] = (0, 0) round_flash: int = 0 boss_thinking: bool = False boss_thought: str = "" pack_fading: int = -1 loading: str = "" loading_since: float = 0.0 Steps = Iterator[RunState] # Return the next school used by the enemy. def enemy_school_for(school: School) -> School: return {"fire": "earth", "earth": "ice", "ice": "fire"}[school] # Start a new run shell with the starter deck visible immediately. def new_run_shell( player_name: str, world: str, school: School, seed: int = 7, ) -> RunState: rng = Random(seed) player_deck = backbone_deck(school, world or THEME) enemy_school = enemy_school_for(school) order = draft_order(len(SYNERGY_COSTS), draft_anchor_indexes(len(SYNERGY_COSTS), rng)) state = RunState( player_name=player_name or "You", world=world or THEME, school=school, enemy_school=enemy_school, player_deck=player_deck, enemy_deck=(), draft_step=0, draft_order=order, anchor_indexes=frozenset(order[:2]), draft_anchors=(), current_pack=(), duel=None, turn_order=(), turn_position=0, log=(f"{player_name or 'You'} enters {world or THEME}.",), rng=rng, enemy_seed=rng.getrandbits(32), ) return state # Start a new run, generate the first draft pack, and warm the background forge. def new_run( player_name: str, world: str, school: School, client: CardPackClient | None = None, seed: int = 7, art_client: ArtClient | None = None, ) -> RunState: state = new_run_shell(player_name, world, school, seed) warm_enemy_deck(state, client, art_client) state = deal_next_pack(state, client, art_client) prefetch_next_packs(state, client, art_client) return state # Finish the first generated draft pack for a visible starter-deck shell. def finish_opening_draft( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState: warm_enemy_deck(state, client, art_client) state = deal_next_pack(state, client, art_client) prefetch_next_packs(state, client, art_client) return state # Queue the next draft pack and render a loading state instead of blocking. def queue_next_pack( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState: warm_enemy_deck(state, client, art_client) if state.draft_step >= len(state.draft_order): return state cost = SYNERGY_COSTS[state.draft_order[state.draft_step]] forge.submit(pack_key(state), pack_maker(client, art_client, state, cost)) return replace(state, current_pack=(), loading=loading_message(state), loading_since=time.monotonic()) # Attach a queued draft pack once its background job finishes. def collect_ready_pack( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState: if state.duel is not None or state.current_pack or state.draft_step >= len(state.draft_order): return state cost = SYNERGY_COSTS[state.draft_order[state.draft_step]] pack = forge.take_ready(pack_key(state)) if pack is None: forge.submit(pack_key(state), pack_maker(client, art_client, state, cost)) return state # Hold the loading beat for a minimum window so every draft transition looks # the same (a deliberate "forging" pause) instead of some snapping in instantly # and some lagging; the prefetched pack is already done well within it. if state.loading_since and time.monotonic() - state.loading_since < MIN_DRAFT_LOADING_SECONDS: return state pack = dedupe_pack_against_deck(pack, state.player_deck) warm_card_art(art_client, pack) pack = collect_ready_cards(pack) # Show the pack the moment it is ready; prefetch the next packs in the # background (never block the visible pack on speculative work). state = replace(state, current_pack=pack, loading="") prefetch_next_packs(state, client, art_client) return state # Return whether all one-pick-ahead packs are already forged. def next_packs_ready(state: RunState) -> bool: for index in range(len(state.current_pack)): nxt = apply_pick(state, index) if nxt.draft_step >= len(nxt.draft_order): continue if forge.take_ready(pack_key(nxt)) is None: return False return True # Return copy for the visible draft loading state. def loading_message(state: RunState) -> str: if state.draft_step == 0: return "Forging your first draft pack" return "Forging the next draft pack" # Apply one draft pick to the run state. def apply_pick(state: RunState, index: int) -> RunState: selected = state.current_pack[index] cost_index = state.draft_order[state.draft_step] anchors = state.draft_anchors + ((selected,) if cost_index in state.anchor_indexes else ()) return replace( state, player_deck=state.player_deck + (selected,), draft_anchors=anchors, draft_step=state.draft_step + 1, log=state.log + (f"Drafted {selected.name}.",), ) # Choose a draft card, yielding paced battle states once drafting completes. def choose_draft_card_steps( state: RunState, index: int, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> Steps: state = refresh_art(state) if index < 0 or index >= len(state.current_pack): yield add_log(state, "That draft card is not available.") return state = apply_pick(state, index) yield replace(state, pack_fading=index) if state.draft_step >= len(state.draft_order): yield from start_battle_steps(state, client, art_client) return state = deal_next_pack(state, client, art_client) prefetch_next_packs(state, client, art_client) yield replace(state, pack_fading=-1) # Choose a draft card and queue the next pack without blocking UI rendering. def choose_draft_card_loading_steps( state: RunState, index: int, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> Steps: state = refresh_art(state) if index < 0 or index >= len(state.current_pack): yield add_log(state, "That draft card is not available.") return state = apply_pick(state, index) yield replace(state, pack_fading=index) if state.draft_step >= len(state.draft_order): yield from begin_battle_or_wait_steps(state, client, art_client) return yield queue_next_pack(replace(state, pack_fading=-1), client, art_client) # Choose a draft card and advance to the next pack or battle. def choose_draft_card( state: RunState, index: int, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState: return last_state(choose_draft_card_steps(state, index, client, art_client)) # Rename pack cards that collide with the drafted deck (or each other), drawing # fresh names from the school pool so the run never shows a repeat or a number. def dedupe_pack_against_deck(pack: Sequence[Card], deck: Sequence[Card]) -> tuple[Card, ...]: taken = {card.name for card in deck} result = [] for card in pack: name = distinct_name(card.name, taken, card.school) taken.add(name) result.append(card if name == card.name else replace(card, name=name)) return tuple(result) # Deal the next draft pack, preferring a forge-prefetched one. def deal_next_pack( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState: cost = SYNERGY_COSTS[state.draft_order[state.draft_step]] maker = pack_maker(client, art_client, state, cost) pack = forge.take(pack_key(state), maker) if client is not None else maker() pack = dedupe_pack_against_deck(pack, state.player_deck) warm_card_art(art_client, pack) return replace(state, current_pack=collect_ready_cards(pack)) # Return a deck-content signature for forge keys. def deck_sig(cards: Sequence[Card]) -> tuple[str, ...]: return tuple(f"{card.cost} {card.name}" for card in cards) # Return the forge key for the next pack of one draft state. def pack_key(state: RunState) -> tuple: return ("pack", state.enemy_seed, state.draft_step, deck_sig(state.player_deck), deck_sig(state.draft_anchors)) # Build a pack generator bound to one draft state. def pack_maker( client: CardPackClient | None, art_client: ArtClient | None, state: RunState, cost: int, ) -> Callable[[], tuple[Card, ...]]: return lambda: make_pack(client, None, state.school, state.world, state.player_deck, cost, state.draft_anchors) # Pre-generate a bounded number of next-pack branches while the player reads. def prefetch_next_packs(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None: if client is None or state.draft_step >= len(state.draft_order): return for index in range(min(len(state.current_pack), prefetch_pack_limit())): nxt = apply_pick(state, index) if nxt.draft_step >= len(nxt.draft_order): continue cost = SYNERGY_COSTS[nxt.draft_order[nxt.draft_step]] forge.submit(pack_key(nxt), pack_maker(client, art_client, nxt, cost)) # Return how many possible next draft branches to forge speculatively. def prefetch_pack_limit() -> int: try: # Prefetch every branch (one per visible card) so whichever card the # player picks, its next pack is already forging in the background. return max(0, int(os.environ.get("TABRAS_PREFETCH_PACKS", str(CARD_PANEL_COUNT)))) except ValueError: return CARD_PANEL_COUNT # Return the forge key for the boss deck of one run. def enemy_deck_key(state: RunState) -> tuple: return ("enemy", state.enemy_school, state.world, state.enemy_seed) # Build a boss-deck generator with its own deterministic rng. def enemy_deck_maker( client: CardPackClient | None, art_client: ArtClient | None, state: RunState, ) -> Callable[[], tuple[Card, ...]]: return lambda: draft_enemy_deck_for_ui(client, art_client, state.enemy_school, state.world, Random(state.enemy_seed)) # Start forging the boss deck on the slow lane, but only after the first couple of # picks so its ~9 card calls do not compete with the first visible draft pack. # The non-blocking battle handoff tolerates the deck arriving late. def warm_enemy_deck(state: RunState, client: CardPackClient | None, art_client: ArtClient | None) -> None: # Start forging the boss deck immediately on the slow lane: the reveal + rules # dwell and the player's own draft give it ample runway to finish before battle. if client is not None: forge.submit(enemy_deck_key(state), enemy_deck_maker(client, art_client, state), lane="slow") # Start combat after drafting, yielding paced states for the opening round. def start_battle_steps( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> Steps: state = refresh_art(state) maker = enemy_deck_maker(client, art_client, state) enemy_deck = forge.take(enemy_deck_key(state), maker) if client is not None else maker() yield from battle_opening_steps(state, enemy_deck, art_client) # Return the forged boss deck, building it inline only when no model is configured. # With a model it never blocks: it returns None while the deck forges in the background. def ready_enemy_deck( state: RunState, client: CardPackClient | None, art_client: ArtClient | None, ) -> tuple[Card, ...] | None: if client is None: return enemy_deck_maker(client, art_client, state)() deck = forge.take_ready(enemy_deck_key(state)) if deck is None: forge.submit(enemy_deck_key(state), enemy_deck_maker(client, art_client, state), lane="slow") return deck # Open the battle from a ready boss deck, yielding paced states for the first round. def battle_opening_steps( state: RunState, enemy_deck: tuple[Card, ...], art_client: ArtClient | None = None, ) -> Steps: state = refresh_art(state) duel = DuelState( create_player(state.player_name, shuffled_deck(state.player_deck, state.rng)), create_player("Boss", shuffled_deck(enemy_deck, state.rng)), ) draw_cards(duel.player, OPENING_HAND_SIZE) draw_cards(duel.enemy, OPENING_HAND_SIZE) # Warm the player's hand first, then the boss's: the draft is over, so the GPU # is free to illustrate the boss's cards ahead of time — they show art the # moment the boss plays them instead of shimmering in. warm_card_art(art_client, duel.player.hand) warm_card_art(art_client, duel.enemy.hand) state = replace( state, enemy_deck=enemy_deck, current_pack=(), duel=duel, loading="", log=state.log + ("The boss takes the far side of the table.",), ) for step in paced_action(state, advance_to_player_steps): warm_battle_art(art_client, step) yield step # Start the battle if the boss deck is forged, else show a non-blocking shuffle state. def begin_battle_or_wait_steps( state: RunState, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> Steps: state = refresh_art(state) enemy_deck = ready_enemy_deck(state, client, art_client) if enemy_deck is None: yield replace(state, current_pack=(), duel=None, pack_fading=-1, loading="The boss shuffles its deck") return yield from battle_opening_steps(replace(state, pack_fading=-1), enemy_deck, art_client) # Start the battle on a timer tick once the background boss deck finishes forging. def collect_ready_battle( state: RunState | None, client: CardPackClient | None = None, art_client: ArtClient | None = None, ) -> RunState | None: if state is None or state.duel is not None or state.current_pack: return state if state.draft_step < len(state.draft_order): return state enemy_deck = ready_enemy_deck(state, client, art_client) if enemy_deck is None: return state return last_state(battle_opening_steps(state, enemy_deck, art_client)) # Queue art for currently visible combat cards. def warm_battle_art(art_client: ArtClient | None, state: RunState) -> None: if state.duel is None: return # Post-draft the GPU is free: warm the player's hand, the boss's hand (so its # plays show art instantly), and the played-card showcase. warm_card_art(art_client, state.duel.player.hand) warm_card_art(art_client, state.duel.enemy.hand) warm_card_art(art_client, tuple(card for _, card in state.showcase)) # Draft a boss deck for the UI. def draft_enemy_deck_for_ui( client: CardPackClient | None, art_client: ArtClient | None, school: School, world: str, rng: Random, ) -> tuple[Card, ...]: deck = list(backbone_deck(school, world)) anchor_indexes = draft_anchor_indexes(len(SYNERGY_COSTS), rng) order = draft_order(len(SYNERGY_COSTS), anchor_indexes) # One card per boss pick, generated concurrently. The boss deck does not need # to be deck-aware (it is the opponent), so the 9 picks run in parallel instead # of ~22s sequentially, ensuring it is ready well before battle. def boss_pick(cost_index: int) -> Card: pack = make_pack(client, None, school, world, tuple(deck), SYNERGY_COSTS[cost_index], (), quick=True, pack_size=1) return pack[0] with ThreadPoolExecutor(max_workers=4) as pool: picks = list(pool.map(boss_pick, order)) return tuple(deck + picks) # Generate a model pack or deterministic fallback. def make_pack( client: CardPackClient | None, art_client: ArtClient | None, school: School, world: str, current_deck: Sequence[Card], cost: int, anchors: Sequence[Card] = (), quick: bool = False, pack_size: int = CARD_PANEL_COUNT, ) -> tuple[Card, ...]: if client is not None: try: pack = generate_pack(client, school, world, current_deck, cost, pack_size=pack_size, draft_anchors=anchors, quick=quick) return collect_ready_cards(pack) except Exception as exc: print(f"Tabras model pack failed; using fallback: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True) pack = fallback_pack(school, world, cost, anchors, current_deck) return collect_ready_cards(pack[:pack_size]) # Return the stable background-art key for one card face. def card_art_key(card: Card) -> tuple: return ("art", card.school, card.theme, card.cost, card.name, card.rules_text(), card.flavor, card.art_prompt) # Start card illustration jobs on the dedicated art lane so images never queue # behind the boss-deck draft (slow lane) or the player's packs (fast lane). def warm_card_art(art_client: ArtClient | None, cards: Sequence[Card]) -> None: if art_client is None: return for card in cards: if not card.art_uri: forge.submit(card_art_key(card), lambda card=card: illustrate_card(art_client, card), lane="art") # Attach a finished generated image to one card if the forge has it. def collect_ready_card(card: Card) -> Card: if card.art_uri: return card illustrated = forge.take_ready(card_art_key(card), card) if isinstance(illustrated, Card) and illustrated.art_uri: return illustrated return card # Attach any finished generated images to a card sequence. def collect_ready_cards(cards: Sequence[Card]) -> tuple[Card, ...]: return tuple(collect_ready_card(card) for card in cards) # Return cards plus whether all queued art jobs have settled. def collect_pack_art_status(cards: Sequence[Card], art_client: ArtClient | None) -> tuple[tuple[Card, ...], bool]: if art_client is None: return collect_ready_cards(cards), True collected = tuple(collect_ready_art_status(card) for card in cards) return tuple(card for card, _ in collected), all(ready for _, ready in collected) # Return one card plus whether its art job has settled. def collect_ready_art_status(card: Card) -> tuple[Card, bool]: if card.art_uri: return card, True illustrated = forge.take_ready(card_art_key(card)) if illustrated is None: return card, False if isinstance(illustrated, Card): return illustrated, True return card, True # Refresh generated art across the visible run state. def refresh_art(state: RunState | None) -> RunState | None: if state is None: return None return replace( state, player_deck=collect_ready_cards(state.player_deck), enemy_deck=collect_ready_cards(state.enemy_deck), current_pack=collect_ready_cards(state.current_pack), duel=refresh_duel_art(state.duel), ) # Attach finished generated images inside an active duel. def refresh_duel_art(duel: DuelState | None) -> DuelState | None: if duel is None: return None return replace(duel, player=refresh_player_art(duel.player), enemy=refresh_player_art(duel.enemy)) # Attach finished generated images inside one player zone. def refresh_player_art(player: PlayerState) -> PlayerState: return replace( player, deck=list(collect_ready_cards(player.deck)), hand=list(collect_ready_cards(player.hand)), discard=list(collect_ready_cards(player.discard)), ) # Return a deterministic draft pack when no model is configured. def fallback_pack( school: School, world: str, cost: int, anchors: Sequence[Card] = (), current_deck: Sequence[Card] = (), ) -> tuple[Card, ...]: plans = fallback_plans(school, anchors, cost, current_deck) return tuple(cost_card(CardSpec(name, cost, school, world, effects, flavor, art)) for name, effects, flavor, art in plans) # Return fallback effect plans for a school. def fallback_plans( school: School, anchors: Sequence[Card], cost: int = 1, current_deck: Sequence[Card] = (), ) -> tuple[tuple[str, tuple[EffectPlan, ...], str, str], ...]: if school == "fire": variants = ( ( ("Cinder Rush", (EffectPlan("deal"), EffectPlan("burn")), "Heat hits first, then keeps biting.", "a rushing ribbon of cinders across black stone"), ("Fuse Prayer", (EffectPlan("bomb"),), "A delayed blast tucked under the enemy's next breath.", "black powder sigil glowing under ash"), ("Kindling Draw", (EffectPlan("draw"), EffectPlan("deal")), "The next spark finds your hand.", "embers rising into a bright spiral"), ), ( ("Ember Warrant", (EffectPlan("deal"),), "The sentence arrives before the smoke.", "a red-hot seal stamped into dark basalt"), ("Blackpowder Hymn", (EffectPlan("bomb"), EffectPlan("burn")), "Every note waits for the fuse.", "sparks crawling through a ritual fuse circle"), ("Coalglass Vow", (EffectPlan("draw"), EffectPlan("burn")), "The oath glows brighter when broken.", "molten glass reflecting a small flame"), ), ( ("Ashen Oath", (EffectPlan("burn"), EffectPlan("scaling")), "A promise written in soot remembers every spark.", "ash lifting from a cracked ember sigil"), ("Red Sigil", (EffectPlan("deal"), EffectPlan("draw")), "One mark, one opening, one breath of heat.", "a crimson rune flaring in smoke"), ("Charcoal Saint", (EffectPlan("block"), EffectPlan("burn")), "The saint shields with one hand and burns with the other.", "a soot-black statue lit from within"), ), ) return variants[fallback_variant_index(cost, current_deck, len(variants))] if school == "ice": return ( ("Glass Tempo", (EffectPlan("initiative"), EffectPlan("vulnerable")), "The air freezes one heartbeat ahead.", "frosted hourglass above a frozen floor"), ("Needle Flurry", (EffectPlan("multi_hit"),), "Small cuts find the opening.", "ice needles crossing a moonlit hall"), ("Crack in Winter", (EffectPlan("vulnerable"), EffectPlan("conditional")), "A weakness appears where the frost thins.", "blue crack in a frozen shield"), ) if any(effect.primitive_id == "scaling" for card in anchors for effect in card.effects): return ( ("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"), ("Table Ward", (EffectPlan("block"), EffectPlan("ward")), "A quiet wall between you and ruin.", "earthen ward carved into dark stone"), ("Buried Leverage", (EffectPlan("draw"), EffectPlan("block")), "Patience becomes pressure.", "roots lifting cards from soil"), ) return ( ("Stone Intake", (EffectPlan("block"), EffectPlan("scaling")), "The shield drinks the blow and answers later.", "stone shield lit with stored gold"), ("Gravemoss Guard", (EffectPlan("block"), EffectPlan("weak")), "The ground takes their strength first.", "mossy shield under candlelight"), ("Cairn Echo", (EffectPlan("scaling"),), "Stored force returns with interest.", "runes glowing in cracked stone"), ) # Return the rotating fallback pack index. def fallback_variant_index(cost: int, current_deck: Sequence[Card], count: int) -> int: return (cost + len(current_deck)) % count # Play one hand card, yielding paced states for the boss response. def play_hand_card_steps(state: RunState, index: int) -> Steps: if state.duel is None or index < 0 or index >= len(state.duel.player.hand): yield state return card = state.duel.player.hand[index] if card.cost > state.duel.player.energy: yield add_log(state, f"{card.name} needs {card.cost} energy.") return yield from paced_action(state, resolve_card_steps, index) # Play one player hand card by index. def play_hand_card(state: RunState, index: int) -> RunState: return last_state(play_hand_card_steps(state, index)) # Pass the turn, yielding paced states for the boss response. def pass_turn_steps(state: RunState) -> Steps: if state.duel is None or winner(state.duel): yield state return yield from paced_action(state, resolve_pass_steps) # Pass the current player turn. def pass_turn(state: RunState) -> RunState: return last_state(pass_turn_steps(state)) # Yield action steps stamped with per-step damage and round-turnover flashes. def paced_action(state: RunState, steps_fn: Callable[..., Steps], *args: object) -> Steps: assert state.duel is not None duel = state.duel player_hp, enemy_hp, round_seen = duel.player.hp, duel.enemy.hp, duel.round_number fresh = replace(state, showcase=(), hp_flash=(0, 0), round_flash=0, boss_thinking=False, boss_thought="") for step in steps_fn(fresh, *args): assert step.duel is not None flash = (max(0, player_hp - step.duel.player.hp), max(0, enemy_hp - step.duel.enemy.hp)) turned = step.duel.round_number if step.duel.round_number != round_seen else 0 player_hp, enemy_hp, round_seen = step.duel.player.hp, step.duel.enemy.hp, step.duel.round_number yield replace(step, hp_flash=flash, round_flash=turned) # Resolve one affordable hand card; the turn ends only when the player ends it. def resolve_card_steps(state: RunState, index: int) -> Steps: assert state.duel is not None player = state.duel.player played = play_card_from_hand(state.duel, player, index) state = show_play(state, player.name, played) state = add_log(state, f"{player.name} plays {played.name}: {played.rules_text()}") if winner(state.duel): yield add_log(state, f"Winner: {winner(state.duel)}") return yield state # Resolve a pass by spending remaining energy, then advance. def resolve_pass_steps(state: RunState) -> Steps: assert state.duel is not None state.duel.player.energy = 0 state = add_log(state, f"{state.duel.player.name} passes.") yield state yield from advance_after_player_steps(state) # Yield combat steps after the player has ended their action. def advance_after_player_steps(state: RunState) -> Steps: assert state.duel is not None if winner(state.duel): yield state return position = state.turn_position + 1 if position < len(state.turn_order) and state.turn_order[position] == state.duel.enemy.name: state = yield from boss_turn_steps(replace(state, turn_position=position)) if winner(state.duel): yield add_log(state, f"Winner: {winner(state.duel)}") return yield from advance_to_player_steps(state) # Yield round turnovers until the player can act. def advance_to_player_steps(state: RunState) -> Steps: assert state.duel is not None if winner(state.duel): yield state return while not winner(state.duel): order = start_round(state.duel, state.rng) header = ( f"Round {state.duel.round_number}: {' then '.join(order)} · " f"{state.duel.player.name} {state.duel.player.hp} HP · Boss {state.duel.enemy.hp} HP" ) state = replace(state, turn_order=order, turn_position=0, log=state.log + (header,)) yield state if order[0] == state.duel.player.name: return state = yield from boss_turn_steps(state) if len(order) > 1 and order[1] == state.duel.player.name and not winner(state.duel): state = replace(state, turn_position=1) yield state return yield add_log(state, f"Winner: {winner(state.duel)}") _boss_chooser_cache = None # Return the boss chooser, wiring the env-configured boss model once. def ui_boss_chooser(): global _boss_chooser_cache if _boss_chooser_cache is None: _boss_chooser_cache = boss_chooser(boss_client_from_env(), choose_enemy_card) return _boss_chooser_cache # Yield boss plays one card at a time with thinking pauses; return the final state. def boss_turn_steps(state: RunState) -> Steps: assert state.duel is not None chooser = ui_boss_chooser() played_any = False while playable_indexes(state.duel.enemy): for thought in boss_thought_lines(state): yield replace(state, boss_thinking=True, boss_thought=thought) choice = chooser(state.duel, state.duel.enemy) if choice is None: state.duel.enemy.energy = 0 state = add_log(state, "Boss passes.") yield state return state card = play_card_from_hand(state.duel, state.duel.enemy, choice) played_any = True state = show_play(state, state.duel.enemy.name, card) state = add_log(state, f"Boss plays {card.name}: {card.rules_text()}") yield state if winner(state.duel): return state if not played_any: state = add_log(state, "Boss has no playable cards.") state.duel.enemy.energy = 0 return state # Return short visible boss reasoning beats from public board state. def boss_thought_lines(state: RunState) -> tuple[str, ...]: assert state.duel is not None return ( boss_pending_thought(state.duel), boss_health_thought(state.duel.enemy), boss_hand_thought(state.duel.enemy), ) # Return the boss thought about incoming delayed effects. def boss_pending_thought(duel: DuelState) -> str: threats = [effect for effect in duel.pending if effect.target == duel.enemy.name] bombs = [effect for effect in threats if effect.primitive_id == "bomb"] burns = [effect for effect in threats if effect.primitive_id == "burn"] if bombs: soonest = min(effect.delay + 1 for effect in bombs) total = sum(effect.amount for effect in bombs) label = "turn" if soonest == 1 else "turns" return f"The boss realizes a {total}-damage bomb lands in {soonest} {label}." if burns: total = sum(effect.amount for effect in burns) return f"The boss notices {total} burn damage still ticking." return "The boss scans the table for delayed threats." # Return the boss thought about its own health and defenses. def boss_health_thought(enemy: PlayerState) -> str: defenses = enemy.block + enemy.ward if enemy.hp <= 8: return f"The boss looks at its health: {enemy.hp} HP, with {defenses} defense." return f"The boss checks its health: {enemy.hp} HP, with {defenses} defense." # Return the boss thought about playable hand options. def boss_hand_thought(enemy: PlayerState) -> str: playable = playable_indexes(enemy) if not playable: return "The boss considers its hand and finds no playable card." count = len(playable) label = "line" if count == 1 else "lines" return f"The boss considers {count} playable {label} without revealing them." # Record one played card for the battlefield showcase. def show_play(state: RunState, owner: str, card: Card) -> RunState: return replace(state, showcase=state.showcase + ((owner, card),)) # Return the last state from an action generator. def last_state(steps: Steps) -> RunState: state = None for state in steps: pass assert state is not None return state # Add one line to the turn log. def add_log(state: RunState, line: str) -> RunState: return replace(state, log=(state.log + (line,))[-80:]) # Return HTML for one card face. def card_html(card: Card | None, classes: str = "", style: str = "", onclick: str = "") -> str: if card is None: return "
" return ( f'
' f"
{card.cost}
" f"
{escape_html(card.name)}
" f"{card_art_html(card)}" f"
{escape_html(card.rules_text())}
" f"
{escape_html(card.flavor)}
" "
" ) # Return a card art panel, using generated art when present. def card_art_html(card: Card) -> str: if not card.art_uri: return f"
" return f'
' # Return HTML for the full draft screen. def draft_screen_html(state: RunState | None) -> str: if state is None or state.duel is not None: return "" if not state.current_pack: loading = loading_html(state.loading or "Forging draft pack") return f"
{loading}{deck_strip_html(state)}
" if state.draft_step >= len(state.draft_order): banner = "

Deck complete

The boss shuffles its deck…

" else: cost_index = state.draft_order[state.draft_step] kind = "Anchor pick" if cost_index in state.anchor_indexes else "Pick" banner = ( f"

{kind} {state.draft_step + 1} of {len(state.draft_order)}

" f"

{escape_html(state.player_name)} of {escape_html(state.world)} — {school_mark(state.school)}

" ) cards = "".join( draft_card_html(state, index, card) for index, card in enumerate(state.current_pack) ) return f"
{banner}
{cards}
{deck_strip_html(state)}
" # Return loading HTML for asynchronous draft generation. def loading_html(message: str) -> str: return ( "
" f"
{escape_html(message)}
" "
" "
Your first draft pack is being prepared.
" "
" ) # Return one starter-deck card without draft click behavior. def starter_card_html(card: Card) -> str: return card_html(card, "starter-card") # Return one draft card, fading the old pack out after a pick. def draft_card_html(state: RunState, index: int, card: Card) -> str: if state.pack_fading < 0: return card_html(card, "draft-card", "", f"tabrasClick('draft-btn-{index}')") chosen = index == state.pack_fading return card_html(card, "draft-card picked" if chosen else "draft-card fading") # Return a mini-chip strip of the deck drafted so far. def deck_strip_html(state: RunState) -> str: chips = "".join( f"" f"{card.cost} {escape_html(card.name)}" for card in state.player_deck ) return f"
Deck {len(state.player_deck)}/15{chips}
" # Return HTML for the full Hearthstone-style battle board. def board_html(state: RunState | None) -> str: if state is None or state.duel is None: return "" duel = state.duel over = " game-over" if winner(duel) else "" quake = " quake" if state.hp_flash[0] >= 4 else "" thinking = " thinking" if state.boss_thinking else "" return ( f"
" f"{enemy_zone_html(state)}" f"{battlefield_html(state)}" f"{player_zone_html(state)}" f"{round_splash_html(state)}" f"{winner_banner_html(state)}" "
" ) # Return the enemy side of the board. def enemy_zone_html(state: RunState) -> str: assert state.duel is not None enemy = state.duel.enemy thought = state.boss_thought or "The boss studies the board." thinking = f"
{escape_html(thought)}
" if state.boss_thinking else "" return ( "
" f"
{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1], boss_portrait_uri(state.world))}{thinking}
" f"{piles_html(enemy)}" "
" ) # Return the player side of the board. def player_zone_html(state: RunState) -> str: assert state.duel is not None player = state.duel.player return ( "
" "
" f"{hero_html(player, state.school, state.player_name, False, state.hp_flash[0], player_portrait_uri(state.world, state.school))}" f"{mana_html(player, state.duel.round_number)}" f"{hand_fan_html(player)}" "
" f"{piles_html(player)}" "
" ) # Return the middle strip with pending effects, played cards, round banner, and end turn. def battlefield_html(state: RunState) -> str: assert state.duel is not None duel = state.duel over = winner(duel) is not None pulse = " pulse" if not over and not playable_indexes(duel.player) else "" waiting = "the boss responds" if state.boss_thinking else "your turn" banner = "duel over" if over else waiting end_turn = "" if over else f"" return ( "
" f"
{pending_tokens_html(duel, duel.enemy.name)}
" f"{showcase_html(state)}" f"
Round {duel.round_number} — {banner}
" f"{end_turn}" f"
{pending_tokens_html(duel, duel.player.name)}
" "
" ) # Return the cards played since the last player action, labeled, newest animated in. def showcase_html(state: RunState) -> str: assert state.duel is not None enemy = state.duel.enemy.name last = len(state.showcase) - 1 slots = [] for index, (owner, card) in enumerate(state.showcase): boss = owner == enemy fresh = " fresh" if index == last else "" slots.append( f"
" f"{'Boss played' if boss else 'You played'}" f"{card_html(card, 'played-card ' + ('enemy-play' if boss else 'player-play'))}" "
" ) return f"
{''.join(slots)}
" # Return the darkened round-turnover splash announcing initiative. def round_splash_html(state: RunState) -> str: if not state.round_flash or state.duel is None: return "" first = state.turn_order[0] if state.turn_order else state.duel.player.name you_first = first == state.duel.player.name line = "YOU GO FIRST" if you_first else "BOSS GOES FIRST" return ( "
" f"
ROUND {state.round_flash}
" f"
{line}
" "
" ) # Cached portrait thumbnails (small JPEGs) keyed by asset stem, embedded as data # URIs so the board can re-render each timer tick without streaming the big PNGs. _THUMBS = Path(__file__).parent / "assets" / "thumbs" _portrait_cache: dict[str, str] = {} _WORLD_PREFIX = {"dark fantasy": "darkFantasy", "cyberpunk": "cyberpunk", "anime": "anime"} # Return a thumbnail data URI for an asset stem, or "" if it is missing. def thumb_uri(stem: str) -> str: if stem not in _portrait_cache: path = _THUMBS / f"{stem}.jpg" _portrait_cache[stem] = ( "data:image/jpeg;base64," + base64.b64encode(path.read_bytes()).decode("ascii") if path.exists() else "" ) return _portrait_cache[stem] # Return the asset file prefix for a world (handles the display-name casings). def world_prefix(world: str) -> str: return _WORLD_PREFIX.get(world.strip().lower(), "darkFantasy") # Return the player's portrait for their world + school (tries casing variants). def player_portrait_uri(world: str, school: str) -> str: prefix = world_prefix(world) for variant in (school.title(), school.lower(), school.capitalize()): uri = thumb_uri(f"{prefix}{variant}") if uri: return uri return "" # Return the boss portrait (circle thumbnail) for a world. def boss_portrait_uri(world: str) -> str: return thumb_uri(f"{world_prefix(world)}Boss") # Return the full boss splash image (medium JPEG) for the reveal screen. def boss_splash_uri(world: str) -> str: return thumb_uri(f"{world_prefix(world)}Boss_splash") # Return a hero portrait with HP gem, defenses, status chips, and damage pop. def hero_html(player: PlayerState, school: School, title: str, enemy: bool, damage: int = 0, portrait: str = "") -> str: block = f"
{player.block}
" if player.block else "" ward = f"
{player.ward}
" if player.ward else "" pop = f"
-{damage}
" if damage else "" hit = " hit" if damage else "" face = ( f"
" if portrait else f"
{school_mark(school)}
" ) return ( f"
" f"
{face}" f"
{player.hp}
{block}{ward}{pop}
" f"
{escape_html(title)}
" f"{status_chips_html(player)}" "
" ) # Return status chips for charge, weak, and vulnerable. def status_chips_html(player: PlayerState) -> str: chips = [] if player.shield_charge: chips.append(f"Charge {player.shield_charge}") if player.weak: chips.append(f"Weak {player.weak} ({player.weak_turns}t)") if player.vulnerable: chips.append(f"Vulnerable +{player.vulnerable} ({player.vulnerable_turns}t)") return f"
{''.join(chips)}
" # Return the mana crystal bar for the player. def mana_html(player: PlayerState, round_number: int) -> str: cap = min(MAX_ENERGY, round_number) total = max(cap, player.energy) gems = "".join(f"" for index in range(total)) return f"
{gems}{player.energy}/{cap}
" # Return deck and discard piles with a fatigue warning when decked out. def piles_html(player: PlayerState) -> str: fatigue = f"
Fatigue {player.fatigue}
" if not player.deck else "" return ( "
" f"
{len(player.deck)}{fatigue}
" f"
{len(player.discard)}
" "
" ) # Return the face-down enemy hand fan. def enemy_hand_html(enemy: PlayerState) -> str: backs = "".join("
" for _ in enemy.hand[:HAND_PANEL_COUNT]) return f"
{backs}
" # Return the fanned, clickable player hand. def hand_fan_html(player: PlayerState) -> str: cards = player.hand[:HAND_PANEL_COUNT] middle = (len(cards) - 1) / 2 pieces = [] for index, card in enumerate(cards): offset = index - middle style = f"--rot:{offset * 3.5:.1f}deg;--ty:{abs(offset) * 8:.0f}px;z-index:{index + 1};" playable = card.cost <= player.energy classes = "hand-card" if playable else "hand-card unplayable" onclick = f"tabrasPlay('hand-btn-{index}', this)" if playable else "" pieces.append(card_html(card, classes, style, onclick)) return f"
{''.join(pieces)}
" # Return telegraphed pending-effect tokens aimed at one player. def pending_tokens_html(duel: DuelState, target: str) -> str: tokens = [] for effect in duel.pending: if effect.target != target: continue if effect.primitive_id == "bomb": tokens.append(f"
{effect.amount}bomb in {effect.delay + 1}
") else: tokens.append(f"
{effect.amount}burn ×{max(effect.duration, 1)}
") return "".join(tokens) # Return the end-of-duel banner with a new-run control. def winner_banner_html(state: RunState) -> str: assert state.duel is not None win = winner(state.duel) if win is None: return "" text = "VICTORY" if win == state.duel.player.name else ("DRAW" if win == "draw" else "DEFEAT") return ( f"

{text}

" "
" ) # Return the battle log as styled entries, newest at the bottom. def log_html(state: RunState | None) -> str: if state is None: return "" entries = "".join(log_entry_html(line) for line in reversed(state.log[-24:])) return f"
{entries}
" # Return one styled log entry, breaking plays into owner, card, and effect. def log_entry_html(line: str) -> str: kind = log_kind(line) if " plays " in line and ": " in line: head, rules = line.split(": ", 1) owner, card_name = head.split(" plays ", 1) return ( f"
{escape_html(owner)} played" f"{escape_html(card_name)}{escape_html(rules)}
" ) return f"
{escape_html(line)}
" # Classify one log line for styling. def log_kind(line: str) -> str: if line.startswith("Round "): return "log-round" if line.startswith("Winner:"): return "log-winner" if " plays " in line: return "log-play log-boss" if line.startswith("Boss") else "log-play log-you" if "Drafted" in line: return "log-draft" if "passes" in line or "no playable" in line: return "log-muted" return "log-info" # Return the school display mark. def school_mark(school: str) -> str: return {"fire": "Fire", "ice": "Ice", "earth": "Earth"}.get(school, school.title()) # Escape HTML for generated card text. def escape_html(text: str) -> str: return ( text.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'") )