Spaces:
Running
Running
| import threading | |
| import time | |
| import forge | |
| from ui import ( | |
| CARD_PANEL_COUNT, | |
| board_html, | |
| boss_thought_lines, | |
| card_html, | |
| choose_draft_card, | |
| collect_ready_pack, | |
| choose_draft_card_steps, | |
| draft_screen_html, | |
| enemy_school_for, | |
| fallback_pack, | |
| finish_opening_draft, | |
| log_html, | |
| make_pack, | |
| mana_html, | |
| new_run, | |
| new_run_shell, | |
| pass_turn, | |
| pass_turn_steps, | |
| pending_tokens_html, | |
| play_hand_card, | |
| prefetch_pack_limit, | |
| queue_next_pack, | |
| refresh_art, | |
| ) | |
| # Build a run state that has finished drafting and entered battle. | |
| def battle_state(seed: int = 2): | |
| state = new_run("Ada", "dark fantasy", "fire", seed=seed) | |
| for _ in range(9): | |
| state = choose_draft_card(state, 0) | |
| return state | |
| # Verify enemy school rotation is deterministic. | |
| def test_enemy_school_for() -> None: | |
| assert enemy_school_for("fire") == "earth" | |
| assert enemy_school_for("earth") == "ice" | |
| assert enemy_school_for("ice") == "fire" | |
| # Verify fallback packs produce three costed card panels. | |
| def test_fallback_pack() -> None: | |
| pack = fallback_pack("earth", "dark fantasy", 3) | |
| assert len(pack) == CARD_PANEL_COUNT | |
| assert any("banked shield charge" in card.rules_text() for card in pack) | |
| # Verify fallback Fire packs vary instead of repeating the same visible names. | |
| def test_fire_fallback_pack_rotates_names() -> None: | |
| first = fallback_pack("fire", "dark fantasy", 2) | |
| second = fallback_pack("fire", "dark fantasy", 3, current_deck=first) | |
| assert {card.name for card in first}.isdisjoint({card.name for card in second}) | |
| assert all("card" not in card.art_prompt.lower() for card in first + second) | |
| # Verify model pack failures are visible in server logs before fallback. | |
| def test_make_pack_logs_model_failure(capsys) -> None: | |
| pack = make_pack(BrokenPackClient(), None, "fire", "dark fantasy", (), 1) | |
| captured = capsys.readouterr() | |
| assert len(pack) == CARD_PANEL_COUNT | |
| assert "Tabras model pack failed" in captured.err | |
| assert "RuntimeError: bad json" in captured.err | |
| # Verify a new run starts in draft state with three clickable cards. | |
| def test_new_run_starts_draft() -> None: | |
| state = new_run("Ada", "anime", "ice", seed=1) | |
| assert state.player_name == "Ada" | |
| assert state.enemy_school == "fire" | |
| assert state.duel is None | |
| html = draft_screen_html(state) | |
| assert html.count("tabras-card") == CARD_PANEL_COUNT | |
| assert all(f"draft-btn-{index}" in html for index in range(CARD_PANEL_COUNT)) | |
| assert "Deck 6/15" in html | |
| # Verify a new run shell shows loading instead of unfinished starter cards. | |
| def test_new_run_shell_shows_loading() -> None: | |
| state = new_run_shell("Ada", "anime", "ice", seed=1) | |
| html = draft_screen_html(state) | |
| assert "loading-bar" in html | |
| assert "Starter Deck" not in html | |
| assert "starter-card" not in html | |
| assert "draft-btn" not in html | |
| # Verify queued first packs render a loading bar instead of blocking. | |
| def test_queue_next_pack_shows_loading() -> None: | |
| forge.reset() | |
| client = ForgePackClient() | |
| state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client) | |
| html = draft_screen_html(state) | |
| assert "loading-bar" in html | |
| assert "Forging your first draft pack" in html | |
| assert "draft-btn" not in html | |
| # Verify a ready background pack replaces the loading state. | |
| def test_collect_ready_pack_installs_pack(monkeypatch) -> None: | |
| monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0) | |
| forge.reset() | |
| client = ForgePackClient() | |
| state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client) | |
| assert "loading-bar" in draft_screen_html(state) | |
| forge.drain() | |
| state = collect_ready_pack(state, client) | |
| html = draft_screen_html(state) | |
| assert "loading-bar" not in html | |
| assert html.count("draft-card") == CARD_PANEL_COUNT | |
| assert "draft-btn-0" in html | |
| # Verify draft packs reveal text before queued art settles. | |
| def test_collect_ready_pack_reveals_before_art(monkeypatch) -> None: | |
| monkeypatch.setattr("ui.MIN_DRAFT_LOADING_SECONDS", 0.0) | |
| forge.reset() | |
| client = ForgePackClient() | |
| art_client = GatedArtClient() | |
| state = queue_next_pack(new_run_shell("Ada", "anime", "ice", seed=1), client, art_client) | |
| assert "loading-bar" in draft_screen_html(state) | |
| forge.drain() | |
| for _ in range(20): | |
| time.sleep(0.01) | |
| state = collect_ready_pack(state, client, art_client) | |
| if state.current_pack: | |
| break | |
| html = draft_screen_html(state) | |
| assert "draft-btn-0" in html | |
| assert "pending-art" in html | |
| assert all(card.art_uri == "" for card in state.current_pack) | |
| art_client.released.set() | |
| forge.drain() | |
| state = refresh_art(state) | |
| assert all(card.art_uri for card in state.current_pack) | |
| # Verify opening draft completion replaces the starter view with clickable picks. | |
| def test_finish_opening_draft_deals_first_pack() -> None: | |
| state = finish_opening_draft(new_run_shell("Ada", "anime", "ice", seed=1)) | |
| html = draft_screen_html(state) | |
| assert "Starter Deck" not in html | |
| assert html.count("draft-card") == CARD_PANEL_COUNT | |
| assert "draft-btn-0" in html | |
| # Verify choosing nine draft cards starts combat. | |
| def test_choose_draft_card_starts_battle() -> None: | |
| state = battle_state() | |
| assert state.duel is not None | |
| assert len(state.player_deck) == 15 | |
| assert len(state.enemy_deck) == 15 | |
| assert "Boss" in state.duel.enemy.name | |
| assert draft_screen_html(state) == "" | |
| # Verify the board renders heroes, mana, hand, and end-turn control. | |
| def test_board_html_shows_battle_state() -> None: | |
| state = battle_state() | |
| html = board_html(state) | |
| assert "hp-gem" in html | |
| assert "mana-bar" in html | |
| assert "hand-fan" in html | |
| assert "end-turn-btn" in html | |
| assert html.count("hand-card") >= len(state.duel.player.hand) | |
| # Verify the board is empty before battle. | |
| def test_board_html_empty_outside_battle() -> None: | |
| assert board_html(None) == "" | |
| assert board_html(new_run("Ada", "anime", "ice", seed=1)) == "" | |
| # Verify picking a draft card first fades the old pack, then deals the next. | |
| def test_draft_pick_fades_between_packs() -> None: | |
| state = new_run("Ada", "anime", "ice", seed=1) | |
| old_pack = state.current_pack | |
| steps = list(choose_draft_card_steps(state, 1)) | |
| fading, fresh = steps[0], steps[1] | |
| assert fading.pack_fading == 1 | |
| assert fading.current_pack == old_pack | |
| html = draft_screen_html(fading) | |
| assert html.count("fading") == 2 | |
| assert "picked" in html | |
| assert "draft-btn" not in html | |
| assert fresh.pack_fading == -1 | |
| assert "draft-btn-0" in draft_screen_html(fresh) | |
| # Verify playing a card never ends the turn; only END TURN brings the boss out. | |
| def test_playing_card_keeps_turn() -> None: | |
| state = battle_state() | |
| state.duel.player.energy = sum(card.cost for card in state.duel.player.hand) | |
| for _ in range(len(state.duel.player.hand)): | |
| state = play_hand_card(state, 0) | |
| assert all(owner != "Boss" for owner, _ in state.showcase) | |
| assert state.duel.round_number == battle_state().duel.round_number | |
| # Verify the battlefield drops END TURN and the your-turn banner when the duel ends. | |
| def test_battlefield_quiets_after_winner() -> None: | |
| state = battle_state() | |
| live = board_html(state) | |
| assert "END TURN" in live and "your turn" in live | |
| state.duel.enemy.hp = 0 | |
| over = board_html(state) | |
| assert "END TURN" not in over | |
| assert "your turn" not in over | |
| assert "duel over" in over | |
| # Verify playing a hand card consumes it and logs the play. | |
| def test_play_hand_card_plays_and_logs() -> None: | |
| state = battle_state() | |
| state.duel.player.energy = max(card.cost for card in state.duel.player.hand) | |
| before = len(state.duel.player.hand) | |
| played = play_hand_card(state, 0) | |
| assert any("plays" in line for line in played.log) | |
| assert len(played.duel.player.hand) <= before | |
| # Verify an unaffordable card is refused with a log line. | |
| def test_play_hand_card_refuses_expensive_card() -> None: | |
| state = battle_state() | |
| expensive = next((i for i, card in enumerate(state.duel.player.hand) if card.cost > state.duel.player.energy), None) | |
| if expensive is None: | |
| return | |
| played = play_hand_card(state, expensive) | |
| assert "needs" in played.log[-1] | |
| assert len(played.duel.player.hand) == len(state.duel.player.hand) | |
| # Verify out-of-range plays leave the state unchanged. | |
| def test_play_hand_card_ignores_bad_index() -> None: | |
| state = battle_state() | |
| assert play_hand_card(state, 99) is state | |
| assert play_hand_card(state, -1) is state | |
| # Verify passing advances the battle log. | |
| def test_pass_turn_updates_log() -> None: | |
| state = battle_state(seed=4) | |
| passed = pass_turn(state) | |
| assert any("passes" in line for line in passed.log) | |
| # Verify played cards land in the showcase with an owner label. | |
| def test_showcase_records_plays() -> None: | |
| state = battle_state() | |
| state.duel.player.energy = max(card.cost for card in state.duel.player.hand) | |
| played = play_hand_card(state, 0) | |
| assert played.showcase[0][0] == "Ada" | |
| html = board_html(played) | |
| assert "played-card" in html | |
| assert "You played" in html | |
| # Verify a round turnover step announces the round and initiative on a darkened board. | |
| def test_round_splash_announces_initiative() -> None: | |
| state = battle_state() | |
| steps = list(pass_turn_steps(state)) | |
| splash = next(step for step in steps if step.round_flash) | |
| html = board_html(splash) | |
| assert f"ROUND {splash.round_flash}" in html | |
| assert "YOU GO FIRST" in html or "BOSS GOES FIRST" in html | |
| assert "splash-initiative" in html | |
| # Verify the boss takes a visible thinking step before playing. | |
| def test_boss_turn_shows_thinking() -> None: | |
| state = battle_state(seed=4) | |
| for _ in range(3): | |
| steps = list(pass_turn_steps(state)) | |
| thinking = [step for step in steps if step.boss_thinking] | |
| if thinking: | |
| assert "boss-thinking" in board_html(thinking[0]) | |
| assert "The boss" in board_html(thinking[0]) | |
| assert "Boss is scheming" not in board_html(thinking[0]) | |
| return | |
| state = steps[-1] | |
| raise AssertionError("boss never took a visible thinking step") | |
| # Verify boss thinking exposes public tactical trace beats. | |
| def test_boss_thought_lines_show_public_trace() -> None: | |
| from game import PendingEffect | |
| state = battle_state(seed=4) | |
| state.duel.enemy.hp = 7 | |
| state.duel.enemy.energy = 5 | |
| state.duel.pending.append(PendingEffect("bomb", state.duel.player.name, state.duel.enemy.name, 5, delay=1)) | |
| thoughts = boss_thought_lines(state) | |
| assert thoughts[0] == "The boss realizes a 5-damage bomb lands in 2 turns." | |
| assert "looks at its health: 7 HP" in thoughts[1] | |
| assert "without revealing them" in thoughts[2] | |
| assert all(card.name not in thoughts[2] for card in state.duel.enemy.hand) | |
| # Verify boss trace frames use short delays to keep turns responsive. | |
| def test_frame_delay_shortens_boss_trace() -> None: | |
| from dataclasses import replace | |
| from app import frame_delay | |
| state = battle_state(seed=4) | |
| assert frame_delay(replace(state, boss_thinking=True)) == 0.35 | |
| assert frame_delay(state) == 0.45 | |
| # Verify boss cards stream in one step at a time, newest marked fresh. | |
| def test_boss_plays_stream_stepwise() -> None: | |
| state = battle_state(seed=4) | |
| for _ in range(3): | |
| steps = list(pass_turn_steps(state)) | |
| counts = [len(step.showcase) for step in steps if step.showcase] | |
| if counts: | |
| assert counts == sorted(counts) | |
| final = next(step for step in reversed(steps) if step.showcase) | |
| assert board_html(final).count("play-slot fresh") == 1 | |
| return | |
| state = steps[-1] | |
| raise AssertionError("boss never played a card") | |
| # Verify playable hand cards launch through the animated play handler. | |
| def test_hand_cards_use_play_handler() -> None: | |
| state = battle_state() | |
| state.duel.player.energy = max(card.cost for card in state.duel.player.hand) | |
| assert "tabrasPlay('hand-btn-0', this)" in board_html(state) | |
| # Verify play log entries are structured with owner, card, and effect text. | |
| def test_log_html_styles_plays_and_rounds() -> None: | |
| state = battle_state() | |
| state.duel.player.energy = max(card.cost for card in state.duel.player.hand) | |
| html = log_html(play_hand_card(state, 0)) | |
| assert "log-play log-you" in html | |
| assert "log-owner" in html | |
| assert "log-round" in html | |
| # Verify boss plays join the showcase after the player passes. | |
| def test_showcase_includes_boss_plays() -> None: | |
| state = battle_state(seed=4) | |
| for _ in range(3): | |
| state = pass_turn(state) | |
| assert any(owner == "Boss" for owner, _ in state.showcase) | |
| # Verify per-step damage flashes sum to the action's total HP loss. | |
| def test_hp_flash_tracks_damage() -> None: | |
| state = battle_state(seed=4) | |
| for _ in range(8): | |
| hp_before = state.duel.player.hp | |
| steps = list(pass_turn_steps(state)) | |
| flashes = [step for step in steps if step.hp_flash[0] > 0] | |
| assert sum(step.hp_flash[0] for step in steps) == max(0, hp_before - steps[-1].duel.player.hp) | |
| if flashes: | |
| assert "dmg-pop" in board_html(flashes[0]) | |
| return | |
| state = steps[-1] | |
| raise AssertionError("boss never dealt damage in eight rounds") | |
| # Verify unplayable hand cards render dimmed without a click handler. | |
| def test_hand_fan_marks_unplayable_cards() -> None: | |
| state = battle_state() | |
| player = state.duel.player | |
| if all(card.cost <= player.energy for card in player.hand): | |
| player.energy = 0 | |
| html = board_html(state) | |
| assert "unplayable" in html | |
| # Verify mana crystals reflect energy against the round cap. | |
| def test_mana_html_fills_to_energy() -> None: | |
| state = battle_state() | |
| player = state.duel.player | |
| html = mana_html(player, state.duel.round_number) | |
| assert html.count("mana filled") == player.energy | |
| assert f"{player.energy}/" in html | |
| # Verify pending bombs render as telegraphed tokens. | |
| def test_pending_tokens_show_bombs() -> None: | |
| state = battle_state() | |
| duel = state.duel | |
| from game import PendingEffect | |
| duel.pending.append(PendingEffect("bomb", duel.player.name, duel.enemy.name, 9, delay=2)) | |
| html = pending_tokens_html(duel, duel.enemy.name) | |
| assert "bomb in 3" in html | |
| assert pending_tokens_html(duel, duel.player.name) == "" | |
| # Verify the winner banner appears when the duel ends. | |
| def test_winner_banner_on_victory() -> None: | |
| state = battle_state() | |
| state.duel.enemy.hp = 0 | |
| html = board_html(state) | |
| assert "VICTORY" in html | |
| assert "restart-btn" in html | |
| assert "game-over" in html | |
| # Verify the boss chooser is built once and falls back without a model. | |
| def test_ui_boss_chooser_cached_fallback(monkeypatch) -> None: | |
| import ui | |
| monkeypatch.delenv("TABRAS_AI_BOSS", raising=False) | |
| monkeypatch.setattr(ui, "_boss_chooser_cache", None) | |
| chooser = ui.ui_boss_chooser() | |
| assert ui.ui_boss_chooser() is chooser | |
| state = battle_state(seed=4) | |
| from play import choose_enemy_card | |
| assert chooser(state.duel, state.duel.enemy) == choose_enemy_card(state.duel, state.duel.enemy) | |
| # Verify the draft banner survives the final fade frame after pick nine. | |
| def test_draft_banner_after_final_pick() -> None: | |
| state = new_run("Ada", "anime", "ice", seed=1) | |
| for _ in range(8): | |
| state = choose_draft_card(state, 0) | |
| final_steps = list(choose_draft_card_steps(state, 0)) | |
| fade = final_steps[0] | |
| assert fade.pack_fading == 0 | |
| assert "Deck complete" in draft_screen_html(fade) | |
| assert final_steps[-1].duel is not None | |
| # Verify the log renders newest-first for bottom-anchored scroll. | |
| def test_log_html_newest_first() -> None: | |
| state = battle_state() | |
| html = log_html(state) | |
| assert html.startswith("<div class='log-scroll'>") | |
| newest = state.log[-1] | |
| assert html.index(newest[:20]) < html.index(state.log[0][:10]) | |
| assert log_html(None) == "" | |
| class ForgePackClient: | |
| # Thread-safe fake pack client with distinct card names per call. | |
| def __init__(self) -> None: | |
| self.lock = threading.Lock() | |
| self.calls = 0 | |
| # Return one synthetic pack, numbering cards across calls. | |
| def create_pack(self, payload): | |
| with self.lock: | |
| self.calls += 1 | |
| base = self.calls * 10 | |
| card = lambda i: {"name": f"Forged {base + i}", "flavor": "x", "art_prompt": "y", "effects": [{"primitive_id": "deal", "weight": 1}]} | |
| return {"cards": [card(i) for i in range(payload["pack_size"])]} | |
| class BrokenPackClient: | |
| # Raise like a model response that cannot be parsed or costed. | |
| def create_pack(self, payload): | |
| raise RuntimeError("bad json") | |
| class FakeArtClient: | |
| # Return a stable URI for each generated art prompt. | |
| def create_art(self, prompt: str) -> str: | |
| return f"uri:{prompt}" | |
| class GatedArtClient: | |
| # Wait until the test releases the image job. | |
| def __init__(self) -> None: | |
| self.released = threading.Event() | |
| # Return a stable URI after the gate opens. | |
| def create_art(self, prompt: str) -> str: | |
| self.released.wait(1) | |
| return f"uri:{prompt}" | |
| # Verify a new run pre-forges candidate packs and the boss deck in the background. | |
| def test_new_run_warms_forge(monkeypatch) -> None: | |
| monkeypatch.delenv("TABRAS_PREFETCH_PACKS", raising=False) | |
| forge.reset() | |
| client = ForgePackClient() | |
| state = new_run("Ada", "anime", "ice", client, seed=3) | |
| forge.drain() | |
| # pack 1 + three speculative branch packs + nine boss-deck picks (the boss deck | |
| # now forges from the start, one card per pick, on the slow lane). | |
| assert client.calls == 13 | |
| picked = choose_draft_card(state, 0, client) | |
| assert all(card.name.startswith("Forged") for card in picked.current_pack) | |
| # Verify the speculative draft fanout can be tuned without crashing. | |
| def test_prefetch_pack_limit_env(monkeypatch) -> None: | |
| monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "0") | |
| assert prefetch_pack_limit() == 0 | |
| monkeypatch.setenv("TABRAS_PREFETCH_PACKS", "bad") | |
| assert prefetch_pack_limit() == CARD_PANEL_COUNT | |
| # Verify battle start consumes the pre-forged boss deck. | |
| def test_battle_uses_forged_boss_deck() -> None: | |
| forge.reset() | |
| client = ForgePackClient() | |
| state = new_run("Ada", "anime", "ice", client, seed=3) | |
| for _ in range(9): | |
| state = choose_draft_card(state, 0, client) | |
| assert state.duel is not None | |
| assert any(card.name.startswith("Forged") for card in state.enemy_deck) | |
| # Verify generated card art renders into the card face. | |
| def test_card_html_renders_generated_art() -> None: | |
| card = fallback_pack("fire", "dark fantasy", 2)[0] | |
| html = card_html(card.__class__(**{**card.__dict__, "art_uri": "data:image/png;base64,abc"})) | |
| assert "card-art generated" in html | |
| assert "data:image/png;base64,abc" in html | |
| # Verify draft packs can be illustrated through the art client. | |
| def test_new_run_illustrates_draft_cards() -> None: | |
| forge.reset() | |
| client = ForgePackClient() | |
| art_client = GatedArtClient() | |
| state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client) | |
| assert all(card.art_uri == "" for card in state.current_pack) | |
| art_client.released.set() | |
| forge.drain() | |
| state = refresh_art(state) | |
| assert all(card.art_uri.startswith("uri:") for card in state.current_pack) | |
| # Verify battle start queues art for visible combat cards. | |
| def test_battle_warms_visible_card_art() -> None: | |
| forge.reset() | |
| client = ForgePackClient() | |
| art_client = FakeArtClient() | |
| state = new_run("Ada", "anime", "ice", client, seed=3, art_client=art_client) | |
| for _ in range(9): | |
| state = choose_draft_card(state, 0, client, art_client) | |
| forge.drain() | |
| state = refresh_art(state) | |
| duel = state.duel | |
| # The player's hand is visible and fully illustrated; the enemy hand renders | |
| # face-down (card backs), so its art is intentionally not warmed. | |
| assert all(card.art_uri for card in duel.player.hand) | |