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("