Spaces:
Running
Running
| 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 | |
| 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 "<div class='tabras-card empty'></div>" | |
| return ( | |
| f'<div class="tabras-card school-{card.school} {classes}" style="{style}" onclick="{onclick}">' | |
| f"<div class='card-cost'>{card.cost}</div>" | |
| f"<div class='card-name'>{escape_html(card.name)}</div>" | |
| f"{card_art_html(card)}" | |
| f"<div class='card-rules'>{escape_html(card.rules_text())}</div>" | |
| f"<div class='card-flavor'>{escape_html(card.flavor)}</div>" | |
| "</div>" | |
| ) | |
| # Return a card art panel, using generated art when present. | |
| def card_art_html(card: Card) -> str: | |
| if not card.art_uri: | |
| return f"<div class='card-art pending-art school-art-{card.school}'></div>" | |
| return f'<div class="card-art generated" style="background-image:url("{escape_html(card.art_uri)}")"></div>' | |
| # 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"<div class='draft-board loading-board'>{loading}{deck_strip_html(state)}</div>" | |
| if state.draft_step >= len(state.draft_order): | |
| banner = "<div class='draft-banner'><h2>Deck complete</h2><p>The boss shuffles its deck…</p></div>" | |
| else: | |
| cost_index = state.draft_order[state.draft_step] | |
| kind = "Anchor pick" if cost_index in state.anchor_indexes else "Pick" | |
| banner = ( | |
| f"<div class='draft-banner'><h2>{kind} {state.draft_step + 1} of {len(state.draft_order)}</h2>" | |
| f"<p>{escape_html(state.player_name)} of {escape_html(state.world)} — {school_mark(state.school)}</p></div>" | |
| ) | |
| cards = "".join( | |
| draft_card_html(state, index, card) for index, card in enumerate(state.current_pack) | |
| ) | |
| return f"<div class='draft-board'>{banner}<div class='draft-pack'>{cards}</div>{deck_strip_html(state)}</div>" | |
| # Return loading HTML for asynchronous draft generation. | |
| def loading_html(message: str) -> str: | |
| return ( | |
| "<div class='draft-loading'>" | |
| f"<div class='loading-title'>{escape_html(message)}</div>" | |
| "<div class='loading-bar'><span></span></div>" | |
| "<div class='loading-subtitle'>Your first draft pack is being prepared.</div>" | |
| "</div>" | |
| ) | |
| # 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"<span class='deck-chip{' anchor' if card in state.draft_anchors else ''}'>" | |
| f"<b>{card.cost}</b> {escape_html(card.name)}</span>" | |
| for card in state.player_deck | |
| ) | |
| return f"<div class='deck-strip'><span class='deck-strip-label'>Deck {len(state.player_deck)}/15</span>{chips}</div>" | |
| # 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"<div class='board{over}{quake}{thinking}'>" | |
| f"{enemy_zone_html(state)}" | |
| f"{battlefield_html(state)}" | |
| f"{player_zone_html(state)}" | |
| f"{round_splash_html(state)}" | |
| f"{winner_banner_html(state)}" | |
| "</div>" | |
| ) | |
| # 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"<div class='boss-thinking'>{escape_html(thought)}</div>" if state.boss_thinking else "" | |
| return ( | |
| "<div class='zone enemy-zone'>" | |
| f"<div class='zone-center'>{enemy_hand_html(enemy)}{hero_html(enemy, state.enemy_school, 'Boss', True, state.hp_flash[1], boss_portrait_uri(state.world))}{thinking}</div>" | |
| f"{piles_html(enemy)}" | |
| "</div>" | |
| ) | |
| # 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 ( | |
| "<div class='zone player-zone'>" | |
| "<div class='zone-center'>" | |
| 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)}" | |
| "</div>" | |
| f"{piles_html(player)}" | |
| "</div>" | |
| ) | |
| # 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"<button class='end-turn{pulse}' onclick=\"tabrasClick('end-turn-btn')\">END TURN</button>" | |
| return ( | |
| "<div class='battlefield'>" | |
| f"<div class='pending-row'>{pending_tokens_html(duel, duel.enemy.name)}</div>" | |
| f"{showcase_html(state)}" | |
| f"<div class='round-banner'>Round {duel.round_number} — {banner}</div>" | |
| f"{end_turn}" | |
| f"<div class='pending-row'>{pending_tokens_html(duel, duel.player.name)}</div>" | |
| "</div>" | |
| ) | |
| # 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"<div class='play-slot{fresh}'>" | |
| f"<span class='play-label {'boss' if boss else 'you'}'>{'Boss played' if boss else 'You played'}</span>" | |
| f"{card_html(card, 'played-card ' + ('enemy-play' if boss else 'player-play'))}" | |
| "</div>" | |
| ) | |
| return f"<div class='showcase'>{''.join(slots)}</div>" | |
| # 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 ( | |
| "<div class='round-splash'>" | |
| f"<div class='splash-round'>ROUND {state.round_flash}</div>" | |
| f"<div class='splash-initiative {'you' if you_first else 'boss'}'>{line}</div>" | |
| "</div>" | |
| ) | |
| # 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"<div class='block-gem'>{player.block}</div>" if player.block else "" | |
| ward = f"<div class='ward-gem'>{player.ward}</div>" if player.ward else "" | |
| pop = f"<div class='dmg-pop'>-{damage}</div>" if damage else "" | |
| hit = " hit" if damage else "" | |
| face = ( | |
| f"<div class='hero-face portrait' style=\"background-image:url('{portrait}')\"></div>" | |
| if portrait | |
| else f"<div class='hero-face'>{school_mark(school)}</div>" | |
| ) | |
| return ( | |
| f"<div class='hero {'enemy' if enemy else 'you'}'>" | |
| f"<div class='hero-frame{hit}'>{face}" | |
| f"<div class='hp-gem'>{player.hp}</div>{block}{ward}{pop}</div>" | |
| f"<div class='hero-name'>{escape_html(title)}</div>" | |
| f"{status_chips_html(player)}" | |
| "</div>" | |
| ) | |
| # Return status chips for charge, weak, and vulnerable. | |
| def status_chips_html(player: PlayerState) -> str: | |
| chips = [] | |
| if player.shield_charge: | |
| chips.append(f"<span class='chip charge'>Charge {player.shield_charge}</span>") | |
| if player.weak: | |
| chips.append(f"<span class='chip weak'>Weak {player.weak} ({player.weak_turns}t)</span>") | |
| if player.vulnerable: | |
| chips.append(f"<span class='chip vuln'>Vulnerable +{player.vulnerable} ({player.vulnerable_turns}t)</span>") | |
| return f"<div class='chips'>{''.join(chips)}</div>" | |
| # 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"<span class='mana{' filled' if index < player.energy else ''}'></span>" for index in range(total)) | |
| return f"<div class='mana-bar'>{gems}<span class='mana-count'>{player.energy}/{cap}</span></div>" | |
| # Return deck and discard piles with a fatigue warning when decked out. | |
| def piles_html(player: PlayerState) -> str: | |
| fatigue = f"<div class='fatigue-warn'>Fatigue {player.fatigue}</div>" if not player.deck else "" | |
| return ( | |
| "<div class='piles'>" | |
| f"<div class='pile deck'><span>{len(player.deck)}</span><label>Deck</label>{fatigue}</div>" | |
| f"<div class='pile discard'><span>{len(player.discard)}</span><label>Discard</label></div>" | |
| "</div>" | |
| ) | |
| # Return the face-down enemy hand fan. | |
| def enemy_hand_html(enemy: PlayerState) -> str: | |
| backs = "".join("<div class='enemy-card-back'></div>" for _ in enemy.hand[:HAND_PANEL_COUNT]) | |
| return f"<div class='enemy-hand'>{backs}</div>" | |
| # 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"<div class='hand-fan'>{''.join(pieces)}</div>" | |
| # 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"<div class='token bomb'><b>{effect.amount}</b><span>bomb in {effect.delay + 1}</span></div>") | |
| else: | |
| tokens.append(f"<div class='token burn'><b>{effect.amount}</b><span>burn ×{max(effect.duration, 1)}</span></div>") | |
| 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"<div class='winner-banner {text.lower()}'><h1>{text}</h1>" | |
| "<button class='new-run' onclick=\"tabrasClick('restart-btn')\">New Run</button></div>" | |
| ) | |
| # 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"<div class='log-scroll'>{entries}</div>" | |
| # 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"<div class='log-line {kind}'><span class='log-owner'>{escape_html(owner)} played</span>" | |
| f"<b>{escape_html(card_name)}</b><span class='log-rules'>{escape_html(rules)}</span></div>" | |
| ) | |
| return f"<div class='log-line {kind}'>{escape_html(line)}</div>" | |
| # 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("'", "'") | |
| ) | |