from tower_game.ai import MockGateway import pytest from pydantic import ValidationError from tower_game.engine import DIFFICULTY_PROFILES, TowerGame from tower_game.registry import SKILLS, STARTER_ELIGIBLE_SKILLS from tower_game.render import reward_icon, skill_button_label, skill_card_label from tower_game.schemas import Equipment from tower_game.state import load_save, make_save, new_meta_state from tower_game.stats import derived_player_stats, effective_primary_stats ANSWERS = ["q1_s", "q2_e", "q3_i", "q4_a", "q5_s"] def ready_state(game: TowerGame): state, _ = game.new_character() game.confirm_allocation(state, [6, 6, 6, 6]) return game.choose_starter_skill(state, 0) def win_current_combat(game: TowerGame, state): state["base_str"] = 9 state["str_scaling_dial"] = 2.5 state["global_dmg_mult"] = 2.0 state["crit_chance_dial"] = 60 state["enemy_evasion"] = 0 while state["game_phase"] == "combat": state["enemy_hp"] = min(state["enemy_hp"], 1) game.act(state, "strike") return state def select_required_rewards(game: TowerGame, state): while state["game_phase"] == "victory" and not state["proceed_ready"]: if state.get("victory_step") == "level_skill": game.choose_level_skill(state, 0) if state["game_phase"] == "skill_replacement": game.replace_skill(state, 0) continue index = next(index for index, reward in enumerate(state["pending_loot"]) if not reward.get("claimed")) game.choose_loot(state, index) if state["game_phase"] == "skill_replacement": game.replace_skill(state, 0) return state def complete_victory(game: TowerGame, state): select_required_rewards(game, state) game.proceed(state) return state def test_new_character_bypasses_quiz_and_uses_editable_allocation(): game = TowerGame(MockGateway()) state, _ = game.new_character() assert state["game_phase"] == "allocation" assert state["proposed_allocation"] == { "strength": 6, "agility": 6, "intelligence": 6, "endurance": 6, } game.confirm_allocation(state, [9, 5, 5, 5]) assert state["game_phase"] == "starter_skill" assert len(state["pending_starter_skills"]) == 10 game.choose_starter_skill(state, 0) assert state["game_phase"] == "combat" assert state["current_floor"] == 1 assert state["completed_quiz"] assert state["character_profile"]["final_allocation"]["strength"] == 9 def test_dormant_quiz_flow_remains_compatible(): game = TowerGame(MockGateway()) state = game.new() state["game_phase"] = "quiz" game.evaluate_quiz(state, ["q1_s"] * 5) assert state["game_phase"] == "allocation" game.confirm_allocation(state, [9, 5, 5, 5]) assert state["game_phase"] == "starter_skill" game.choose_starter_skill(state, 0) assert state["game_phase"] == "combat" def test_starter_choices_are_distinct_bounded_and_deterministic(): game = TowerGame(MockGateway()) first, _ = game.new_character(seed=44) second, _ = game.new_character(seed=44) game.confirm_allocation(first, [6, 6, 6, 6]) game.confirm_allocation(second, [6, 6, 6, 6]) first_ids = [skill["id"] for skill in first["pending_starter_skills"]] second_ids = [skill["id"] for skill in second["pending_starter_skills"]] assert len(STARTER_ELIGIBLE_SKILLS) == 16 assert len(first_ids) == 10 assert len(set(first_ids)) == 10 assert first_ids == second_ids assert all(SKILLS[skill_id].ap_cost <= 2 for skill_id in first_ids) def test_starter_selection_round_trips_and_fresh_run_rerolls(): game = TowerGame(MockGateway()) state, meta = game.new_character(seed=70) game.confirm_allocation(state, [6, 6, 6, 6]) original_choices = [skill["id"] for skill in state["pending_starter_skills"]] restored = load_save(make_save(state, meta)) assert restored is not None loaded, _ = restored assert loaded["game_phase"] == "starter_skill" assert [skill["id"] for skill in loaded["pending_starter_skills"]] == original_choices game.choose_starter_skill(state, 1) restarted = game.restart(state, meta) rerolled = [skill["id"] for skill in restarted["pending_starter_skills"]] assert restarted["game_phase"] == "starter_skill" assert rerolled != original_choices def test_legacy_quiz_profile_can_start_a_fresh_run(): game = TowerGame(MockGateway()) profile = { "quiz_answers": ANSWERS, "opening_archetype": "Iron Challenger", "tower_reading": "A legacy reading.", "proposed_allocation": {"strength": 8, "agility": 6, "intelligence": 5, "endurance": 5}, "final_allocation": {"strength": 8, "agility": 6, "intelligence": 5, "endurance": 5}, "starting_skill_id": "power_strike", } state = game._run_from_profile(profile, new_meta_state(), 91) assert state["game_phase"] == "starter_skill" assert state["opening_archetype"] == "Iron Challenger" assert len(state["pending_starter_skills"]) == 10 def test_combat_caps_and_resistance_direction(): game = TowerGame(MockGateway()) state = ready_state(game) state["base_end"] = 99 state["global_armor_bonus"] = 99 state["current_hp"] = 100 state["max_hp"] = 100 state["base_agi"] = 0 state["evasion_bonus_dial"] = 0 game._damage_player(state, 100, "Physical") assert state["current_hp"] == 70 state["base_end"] = 0 state["global_armor_bonus"] = 0 state["current_hp"] = 100 state["elemental_affinity_dict"]["Fire"] = 0.5 game._damage_player(state, 100, "Fire") assert state["current_hp"] == 50 def test_victory_requires_rewards_and_proceed(): game = TowerGame(MockGateway()) state = ready_state(game) win_current_combat(game, state) assert state["game_phase"] == "victory" assert state["current_floor"] == 1 assert not state["proceed_ready"] game.proceed(state) assert state["current_floor"] == 1 game.choose_loot(state, 0) assert not state["proceed_ready"] assert state["rewards_selected"] == 0 game.choose_level_skill(state, 0) game.choose_loot(state, 0) assert state["proceed_ready"] assert state["current_floor"] == 1 game.proceed(state) assert state["current_floor"] == 2 assert state["game_phase"] == "combat" def test_level_growth_does_not_restore_ap_and_clamps_hp(): game = TowerGame(MockGateway()) state = ready_state(game) start_max_hp = state["max_hp"] start_max_ap = state["max_ap"] state["current_hp"] = state["max_hp"] - 2 state["current_ap"] = 0 win_current_combat(game, state) assert state["level"] == 2 assert state["max_hp"] == start_max_hp + 5 assert state["current_hp"] == state["max_hp"] assert state["current_hp"] <= state["max_hp"] assert state["max_ap"] == start_max_ap assert state["current_ap"] == 0 complete_victory(game, state) state["current_ap"] = 0 win_current_combat(game, state) assert state["level"] == 3 assert state["max_ap"] == start_max_ap + 1 assert state["current_ap"] == 0 def test_reward_healing_uses_asset_only_for_equipment(): game = TowerGame(MockGateway()) state = ready_state(game) state["current_hp"] = 1 win_current_combat(game, state) heal_index = next(index for index, reward in enumerate(state["pending_loot"]) if reward["kind"] == "heal") heal = state["pending_loot"][heal_index] assert reward_icon(heal, game.assets) is None equipment = next(reward for reward in state["pending_loot"] if reward["kind"] == "equipment") assert reward_icon(equipment, game.assets) game.choose_level_skill(state, 0) game.choose_loot(state, heal_index) assert state["reward_heal_claimed"] assert state["current_hp"] <= state["max_hp"] def test_progression_reaches_evolution_and_refusal(): game = TowerGame(MockGateway()) state = ready_state(game) for expected_floor in [1, 2, 3, 4]: assert state["current_floor"] == expected_floor complete_victory(game, win_current_combat(game, state)) assert state["current_floor"] == 4.5 assert state["game_phase"] == "evolution_reveal" assert state["pending_evolution"] game.embrace_evolution(state) if state["game_phase"] == "evolution_skill_replace": game.replace_skill(state, 0) assert state["game_phase"] == "evolution_healing" game.heal_choice(state, False) assert state["current_floor"] == 5 assert state["heals_refused"] == 1 def test_complete_deterministic_ascension_and_quiz_not_repeated(): game = TowerGame(MockGateway()) state = ready_state(game) guard = 0 while state["game_phase"] != "ascension" and guard < 150: guard += 1 if state["game_phase"] == "combat": win_current_combat(game, state) elif state["game_phase"] == "victory": select_required_rewards(game, state) game.proceed(state) elif state["game_phase"] == "evolution_reveal": game.embrace_evolution(state) elif state["game_phase"] == "evolution_healing": game.heal_choice(state, True) elif state["game_phase"] in {"skill_replacement", "evolution_skill_replace"}: game.replace_skill(state, 0) assert state["game_phase"] == "ascension" assert state["ascension_level"] == 1 next_state, meta = game.continue_ascension(state, new_meta_state()) assert meta["completed_runs"] == 1 assert next_state["game_phase"] == "starter_skill" assert len(next_state["pending_starter_skills"]) == 10 game.choose_starter_skill(next_state, 0) assert next_state["game_phase"] == "combat" assert next_state["completed_quiz"] assert next_state["level"] == 1 def test_defeat_restart_preserves_character_without_quiz(): game = TowerGame(MockGateway()) state = ready_state(game) state["game_phase"] = "defeat" restarted = game.restart(state, new_meta_state()) assert restarted["game_phase"] == "starter_skill" assert restarted["completed_quiz"] assert restarted["final_allocation"] == state["final_allocation"] assert len(restarted["pending_starter_skills"]) == 10 def test_save_round_trip_and_invalid_save(): game = TowerGame(MockGateway()) state = ready_state(game) state["current_hp"] = state["max_hp"] + 50 state["_sidebar_open"] = False payload = make_save(state, new_meta_state()) restored = load_save(payload) assert restored is not None loaded_state, _ = restored assert loaded_state["game_phase"] == "combat" assert loaded_state["current_hp"] == loaded_state["max_hp"] assert loaded_state["animation"] == "idle" assert "_sidebar_open" not in loaded_state assert load_save({"version": 1}) is None assert load_save({"version": 999}) is None def test_cooldown_labels_count_down(): game = TowerGame(MockGateway()) state = ready_state(game) skill = SKILLS["power_strike"].model_dump() state["cooldowns"]["power_strike"] = 2 assert skill_button_label(skill, state).endswith("[2T]") assert "CD: 2T" in skill_card_label(skill, state, "18 damage") game._tick(state) assert skill_button_label(skill, state).endswith("[1T]") assert "CD: 1T" in skill_card_label(skill, state, "18 damage") game._tick(state) assert "[1T]" not in skill_button_label(skill, state) assert "CD: 1T" in skill_card_label(skill, state, "18 damage") shadow = SKILLS["shadow_step"].model_dump() assert "CD:" not in skill_card_label(shadow, state) def test_attack_animation_tracks_element_and_resets_for_new_encounter(): game = TowerGame(MockGateway()) state = ready_state(game) state["active_skills"] = [SKILLS["arc_bolt"].model_dump()] state["current_ap"] = state["max_ap"] state["enemy_intent"] = "Defend" game.act(state, "skill", "arc_bolt") assert state["animation"] == "skill" assert state["animation_element"] == "Lightning" nonce = state["render_nonce"] game._start_encounter(state) assert state["animation"] == "idle" assert state["animation_element"] == "Physical" assert state["render_nonce"] == nonce assert state["visual_event"] is None def test_unavailable_skill_does_not_start_an_animation(): game = TowerGame(MockGateway()) state = ready_state(game) state["active_skills"] = [SKILLS["arc_bolt"].model_dump()] state["current_ap"] = 0 game.act(state, "skill", "arc_bolt") assert state["animation"] == "idle" assert state["animation_element"] == "Physical" def test_equipment_stats_replace_cleanly_and_clamp_resources(): game = TowerGame(MockGateway()) state = ready_state(game) base = derived_player_stats(state) state["equipment"]["armor"] = { "id": "endurance_plate", "name": "Endurance Plate", "slot": "armor", "rarity": "Rare", "asset_id": "armor_1", "stat": "endurance", "value": 4, "element": "Physical", } game._recalculate_player(state, full_heal=True) boosted = derived_player_stats(state) assert effective_primary_stats(state)["endurance"] == base["endurance"] + 4 assert boosted["max_hp"] == base["max_hp"] + 20 assert boosted["armor"] == base["armor"] + 4 state["equipment"]["armor"] = { **state["equipment"]["armor"], "id": "lighter_plate", "name": "Lighter Plate", "value": 1, } state["current_hp"] = boosted["max_hp"] game._recalculate_player(state) replaced = derived_player_stats(state) assert effective_primary_stats(state)["endurance"] == base["endurance"] + 1 assert replaced["max_hp"] == base["max_hp"] + 5 assert state["current_hp"] == state["max_hp"] == replaced["max_hp"] state["equipment"]["accessory"] = { "id": "ap_charm", "name": "AP Charm", "slot": "accessory", "rarity": "Epic", "asset_id": "accessory_1", "stat": "max_ap", "value": 2, "element": "Magical", } game._recalculate_player(state, full_heal=True) assert state["max_ap"] == base["max_ap"] + 2 state["equipment"]["accessory"] = None game._recalculate_player(state) assert state["current_ap"] == state["max_ap"] == base["max_ap"] def test_equipment_primary_stats_update_damage_preview_and_ap_regen(): game = TowerGame(MockGateway()) state = ready_state(game) state["crit_chance_dial"] = 0 state["enemy_base_def"] = 0 before = game.skill_preview(state, "power_strike") state["equipment"]["weapon"] = { "id": "strength_blade", "name": "Strength Blade", "slot": "weapon", "rarity": "Rare", "asset_id": "weapon_sword_1", "stat": "strength", "value": 4, "element": "Physical", } after = game.skill_preview(state, "power_strike") assert after > before state["equipment"]["accessory"] = { "id": "regen_ring", "name": "Regen Ring", "slot": "accessory", "rarity": "Rare", "asset_id": "accessory_1", "stat": "ap_regen", "value": 2, "element": "Magical", } state["current_ap"] = 0 game._tick(state) assert state["current_ap"] == min(state["max_ap"], 3) def test_generated_loot_never_contains_skills(): game = TowerGame(MockGateway()) state = ready_state(game) win_current_combat(game, state) rewards = state["pending_loot"] assert [reward["item"]["slot"] for reward in rewards[:3]] == [ "weapon", "armor", "accessory", ] assert all(reward["kind"] != "skill" for reward in rewards) def test_item_selection_uses_the_selected_inventory_entry(): game = TowerGame(MockGateway()) state = ready_state(game) state["inventory"] = [ {"id": "minor_tonic", "name": "Minor Tonic", "effect": "heal", "value": 20}, {"id": "spark_draught", "name": "Spark Draught", "effect": "ap", "value": 2}, ] state["current_ap"] = 0 state["enemy_intent"] = "Defend" game.choose_item(state, 1) assert [item["id"] for item in state["inventory"]] == ["minor_tonic"] assert "used_item" in state["action_history"] assert 0 < state["current_ap"] <= state["max_ap"] def test_new_runs_hide_items_by_starting_with_empty_inventory(): game = TowerGame(MockGateway()) state = ready_state(game) assert state["inventory"] == [] def test_floor_five_requires_two_distinct_reward_selections(): game = TowerGame(MockGateway()) state = ready_state(game) for _ in range(4): complete_victory(game, win_current_combat(game, state)) assert state["game_phase"] == "evolution_reveal" game.embrace_evolution(state) if state["game_phase"] == "evolution_skill_replace": game.replace_skill(state, 0) game.heal_choice(state, False) assert state["current_floor"] == 5 win_current_combat(game, state) assert state["loot_picks_remaining"] == 2 game.choose_level_skill(state, 0) if state["game_phase"] == "skill_replacement": game.replace_skill(state, 0) game.choose_loot(state, 0) assert not state["proceed_ready"] game.choose_loot(state, 0) assert state["rewards_selected"] == 1 second = next(index for index, reward in enumerate(state["pending_loot"]) if not reward.get("claimed")) game.choose_loot(state, second) if state["game_phase"] == "skill_replacement": game.replace_skill(state, 0) assert state["proceed_ready"] def test_floor_ten_victory_waits_for_manual_ascension_proceed(): game = TowerGame(MockGateway()) state = ready_state(game) guard = 0 while state["current_floor"] != 10 and guard < 100: guard += 1 if state["game_phase"] == "combat": win_current_combat(game, state) elif state["game_phase"] == "victory": complete_victory(game, state) elif state["game_phase"] == "evolution_reveal": game.embrace_evolution(state) elif state["game_phase"] == "evolution_healing": game.heal_choice(state, True) elif state["game_phase"] in {"skill_replacement", "evolution_skill_replace"}: game.replace_skill(state, 0) assert state["current_floor"] == 10 win_current_combat(game, state) assert state["game_phase"] == "victory" assert state["pending_loot"] == [] assert state["pending_level_skills"] == [] assert state["victory_step"] == "final" assert state["proceed_ready"] game.proceed(state) assert state["game_phase"] == "ascension" def test_victory_reward_progress_round_trips_through_save(): game = TowerGame(MockGateway()) state = ready_state(game) win_current_combat(game, state) game.choose_level_skill(state, 0) game.choose_loot(state, 0) restored = load_save(make_save(state, new_meta_state())) assert restored is not None loaded_state, _ = restored assert loaded_state["game_phase"] == "victory" assert loaded_state["pending_loot"][0]["claimed"] assert loaded_state["proceed_ready"] def test_level_skill_choices_are_distinct_deterministic_and_exclude_active(): game = TowerGame(MockGateway()) first = ready_state(game) second = ready_state(game) win_current_combat(game, first) win_current_combat(game, second) first_ids = [skill["id"] for skill in first["pending_level_skills"]] second_ids = [skill["id"] for skill in second["pending_level_skills"]] assert len(first_ids) == 2 assert len(set(first_ids)) == 2 assert first_ids == second_ids assert not set(first_ids) & {skill["id"] for skill in first["active_skills"]} def test_level_skill_adds_directly_below_six_and_unlocks_loot(): game = TowerGame(MockGateway()) state = ready_state(game) win_current_combat(game, state) chosen = state["pending_level_skills"][1]["id"] game.choose_level_skill(state, 1) assert state["game_phase"] == "victory" assert state["victory_step"] == "loot" assert chosen in {skill["id"] for skill in state["active_skills"]} assert state["pending_level_skills"] == [] def test_level_skill_uses_replacement_overlay_at_six_skills(): game = TowerGame(MockGateway()) state = ready_state(game) state["active_skills"] = [skill.model_dump() for skill in list(SKILLS.values())[:6]] win_current_combat(game, state) chosen = state["pending_level_skills"][0]["id"] game.choose_level_skill(state, 0) assert state["game_phase"] == "skill_replacement" assert state["victory_step"] == "loot" assert state["pending_skill"]["id"] == chosen game.replace_skill(state, 0) assert state["game_phase"] == "victory" assert len(state["active_skills"]) == 6 def test_difficulty_profiles_are_exact_and_lock_after_floor_one(): game = TowerGame(MockGateway()) easy, _ = game.new_character(seed=21) normal, _ = game.new_character(seed=21) for state, mode in ((easy, "easy"), (normal, "normal")): game.confirm_allocation(state, [6, 6, 6, 6]) game.set_difficulty(state, mode) assert state["difficulty_mode"] == mode game.choose_starter_skill(state, 0) profile = DIFFICULTY_PROFILES[mode] assert state["enemy_max_hp"] == profile["grunt_hp_base"] + profile["grunt_hp_floor"] assert state["enemy_base_def"] == round(profile["grunt_defense_floor"]) assert state["enemy_evasion"] == profile["grunt_evasion_base"] + 1 game.set_difficulty(state, "normal" if mode == "easy" else "easy") assert state["difficulty_mode"] == mode assert easy["enemy_max_hp"] == 26 assert normal["enemy_max_hp"] == 30 def test_boss_difficulty_profiles_use_exact_hp_defense_and_evasion(): game = TowerGame(MockGateway()) state = ready_state(game) for mode in ("easy", "normal"): state["difficulty_mode"] = mode for floor in (5, 10): state["current_floor"] = floor game._start_encounter(state) profile = DIFFICULTY_PROFILES[mode] assert state["enemy_max_hp"] == profile["boss_hp"][floor] assert state["enemy_base_def"] == profile["boss_defense"][floor] assert state["enemy_evasion"] == profile["boss_evasion"][floor] def test_equipment_schema_and_rarity_bonus_counts(): common = Equipment( id="common", name="Common Blade", slot="weapon", rarity="Common", asset_id="weapon_sword_1", bonuses=[{"stat": "strength", "value": 1}], ) assert len(common.bonuses) == 1 rare = Equipment( id="rare", name="Rare Blade", slot="weapon", rarity="Rare", asset_id="weapon_sword_1", bonuses=[ {"stat": "strength", "value": 2}, {"stat": "crit", "value": 3}, ], ) assert len(rare.bonuses) == 2 with pytest.raises(ValidationError): Equipment( id="invalid", name="Invalid", slot="armor", rarity="Epic", asset_id="armor_1", bonuses=[{"stat": "armor", "value": 8}], ) with pytest.raises(ValidationError): Equipment( id="duplicate", name="Duplicate", slot="accessory", rarity="Rare", asset_id="accessory_1", bonuses=[ {"stat": "crit", "value": 5}, {"stat": "crit", "value": 3}, ], ) def test_equipment_scaling_tables_and_slot_identity(): game = TowerGame(MockGateway()) assert [ game._equipment_value("strength", rarity) for rarity in ("Common", "Rare", "Epic", "Legendary") ] == [1, 2, 3, 4] assert [ game._equipment_value("armor", rarity) for rarity in ("Common", "Rare", "Epic", "Legendary") ] == [3, 5, 8, 12] assert [ game._equipment_value("max_hp", rarity) for rarity in ("Common", "Rare", "Epic", "Legendary") ] == [8, 14, 22, 32] assert game._equipment_value("max_ap", "Epic") == 1 assert game._equipment_value("max_ap", "Legendary") == 2 assert game._equipment_value("ap_regen", "Legendary") == 1 assert game._equipment_value("crit", "Epic", secondary=True) == 5 from random import Random for seed in range(30): sword = game._equipment_stats("weapon", "weapon_sword_1", "Legendary", Random(seed)) bow = game._equipment_stats("weapon", "weapon_bow_1", "Legendary", Random(seed)) staff = game._equipment_stats("weapon", "weapon_staff_1", "Legendary", Random(seed)) shield = game._equipment_stats("weapon", "weapon_shield_1", "Legendary", Random(seed)) assert sword[0] == "strength" and sword[1] in {"crit", "endurance"} assert bow[0] == "agility" and bow[1] in {"evasion", "crit"} assert staff[0] == "intelligence" assert staff[1] in {"max_hp", "max_ap", "ap_regen"} assert shield[0] in {"armor", "endurance"} assert shield[1] in {"max_hp", "armor"} - {shield[0]} def test_two_bonus_equipment_updates_all_supported_derived_values(): game = TowerGame(MockGateway()) state = ready_state(game) base = derived_player_stats(state) state["equipment"] = { "weapon": { "id": "hybrid", "name": "Hybrid Blade", "slot": "weapon", "rarity": "Legendary", "asset_id": "weapon_sword_1", "bonuses": [ {"stat": "strength", "value": 4}, {"stat": "crit", "value": 8}, ], }, "armor": { "id": "ward", "name": "Wind Ward", "slot": "armor", "rarity": "Epic", "asset_id": "armor_1", "bonuses": [ {"stat": "max_hp", "value": 22}, {"stat": "evasion", "value": 5}, ], }, "accessory": { "id": "engine", "name": "Tower Engine", "slot": "accessory", "rarity": "Legendary", "asset_id": "accessory_1", "bonuses": [ {"stat": "max_ap", "value": 2}, {"stat": "ap_regen", "value": 1}, ], }, } game._recalculate_player(state, full_heal=True) derived = derived_player_stats(state) assert derived["strength"] == base["strength"] + 4 assert derived["crit"] == base["crit"] + 8 assert derived["evasion"] == base["evasion"] + 5 assert derived["max_hp"] == base["max_hp"] + 22 assert derived["max_ap"] == base["max_ap"] + 2 assert derived["ap_regen"] == base["ap_regen"] + 1 def test_legacy_equipment_and_pending_skill_loot_migrate(): game = TowerGame(MockGateway()) state = ready_state(game) state["equipment"]["weapon"] = { "id": "old", "name": "Old Sword", "slot": "weapon", "rarity": "Common", "asset_id": "weapon_sword_1", "stat": "strength", "value": 2, } state["pending_loot"] = [ { "kind": "skill", "item": SKILLS["arc_bolt"].model_dump(), "claimed": False, } ] payload = {"version": 2, "state": state, "meta": new_meta_state()} restored = load_save(payload) assert restored is not None loaded, _ = restored assert loaded["equipment"]["weapon"]["bonuses"] == [ {"stat": "strength", "value": 2} ] assert loaded["pending_loot"][0]["kind"] == "equipment" assert loaded["pending_loot"][0]["item"]["bonuses"] def test_enemy_guard_charge_and_stun_timing(): game = TowerGame(MockGateway()) state = ready_state(game) state["enemy_hp"] = 999 state["enemy_max_hp"] = 999 state["enemy_evasion"] = 0 state["enemy_intent"] = "Defend" game.act(state, "strike") assert state["enemy_statuses"]["defup"] == 1 guarded_preview = game.skill_preview(state, "power_strike") state["enemy_statuses"].clear() assert guarded_preview < game.skill_preview(state, "power_strike") state["current_floor"] = 5 state["boss_deck"] = [ {"move_id": "charge", "power": 10, "intent": "Tactic", "element": "Physical"}, {"move_id": "crush", "power": 10, "intent": "Attack", "element": "Physical"}, ] state["boss_current_decision"] = {"move_index": 0, "reaction": "Charge."} game.act(state, "defend") assert state["enemy_statuses"]["atkup"] == 1 state["boss_current_decision"] = {"move_index": 1, "reaction": "Crush."} game.act(state, "defend") assert "atkup" not in state["enemy_statuses"] before_actions = list(state["action_history"]) state["player_statuses"]["stun"] = 1 game.act(state, "strike") assert state["action_history"] == before_actions assert "stun" not in state["player_statuses"] def test_decline_remaining_rewards_satisfies_unused_picks(): game = TowerGame(MockGateway()) state = ready_state(game) state["game_phase"] = "victory" state["victory_step"] = "loot" state["loot_picks_remaining"] = 2 state["rewards_selected"] = 1 game.decline_remaining_rewards(state) assert state["rewards_selected"] == 2 assert state["rewards_declined"] == 1 assert state["proceed_ready"] assert "declined_rewards:1" in state["action_history"] def test_floor_five_equipment_is_strictly_better_in_every_slot(): game = TowerGame(MockGateway()) state = ready_state(game) state["current_floor"] = 5 state["equipment"] = { "weapon": { "id": "old_weapon", "name": "Old Weapon", "slot": "weapon", "rarity": "Rare", "asset_id": "weapon_sword_1", "bonuses": [ {"stat": "strength", "value": 2}, {"stat": "crit", "value": 3}, ], "element": "Physical", }, "armor": { "id": "old_armor", "name": "Old Armor", "slot": "armor", "rarity": "Rare", "asset_id": "armor_1", "bonuses": [ {"stat": "armor", "value": 5}, {"stat": "endurance", "value": 1}, ], "element": "Physical", }, "accessory": { "id": "old_accessory", "name": "Old Accessory", "slot": "accessory", "rarity": "Rare", "asset_id": "accessory_1", "bonuses": [ {"stat": "crit", "value": 5}, {"stat": "agility", "value": 1}, ], "element": "Physical", }, } rewards = game._generate_loot(state, 3, guaranteed_upgrade=True) assert all(reward["item"]["rarity"] in {"Epic", "Legendary"} for reward in rewards) for reward in rewards: item = reward["item"] assert game._equipment_score(item) > game._equipment_score( state["equipment"][item["slot"]] ) def test_enemy_animation_variants_are_complete_deterministic_and_reset(): game = TowerGame(MockGateway()) state = ready_state(game) grunt_variants = set() boss_variants = set() for turn in range(1, 80): state["active_turn"] = turn grunt_variants.add(game._enemy_animation_variant(state, boss=False)) boss_variants.add(game._enemy_animation_variant(state, boss=True)) assert grunt_variants == { "silver-slash", "thrust", "triple-claw", "projectile", "hex-pulse", } assert boss_variants == {"heavy-cleave", "ground-shockwave"} state["enemy_animation"] = "grunt" state["enemy_animation_variant"] = "triple-claw" game._start_encounter(state) assert state["enemy_animation"] == "idle" assert state["enemy_animation_variant"] == "" def test_evolution_reveal_precedes_replacement_and_healing(): game = TowerGame(MockGateway()) state = ready_state(game) state["active_skills"] = [ skill.model_dump() for skill in list(SKILLS.values())[6:12] ] state["current_floor"] = 4 game._advance_floor(state) assert state["game_phase"] == "evolution_reveal" old_class = state["current_class_name"] assert state["pending_evolution"]["class_name"] != old_class game.embrace_evolution(state) assert state["current_class_name"] != old_class assert state["game_phase"] == "evolution_skill_replace" game.replace_skill(state, 0) assert state["game_phase"] == "evolution_healing" def test_one_visual_event_is_created_per_completed_round(): game = TowerGame(MockGateway()) state = ready_state(game) state["enemy_hp"] = state["enemy_max_hp"] = 999 state["enemy_evasion"] = 0 before = state["visual_event_counter"] game.act(state, "strike") event = state["visual_event"] assert state["visual_event_counter"] == before + 1 assert event["event_id"] == before + 1 assert event["turn_id"] == 1 assert event["player_animation"] == "strike" def test_pending_evolution_round_trips_without_being_applied(): game = TowerGame(MockGateway()) state = ready_state(game) state["current_floor"] = 4 game._advance_floor(state) proposed = state["pending_evolution"]["class_name"] current_class = state["current_class_name"] restored = load_save(make_save(state, new_meta_state())) assert restored is not None loaded, _ = restored assert loaded["game_phase"] == "evolution_reveal" assert loaded["pending_evolution"]["class_name"] == proposed assert loaded["current_class_name"] == current_class def test_mock_boss_selects_a_typed_decision_for_every_turn(): game = TowerGame(MockGateway()) state = ready_state(game) state["current_floor"] = 5 game._start_encounter(state) assert state["boss_current_decision"] is not None first = state["boss_current_decision"]["move_index"] state["enemy_hp"] = state["enemy_max_hp"] = 999 game.act(state, "defend") assert state["boss_current_decision"] is not None assert 0 <= state["boss_current_decision"]["move_index"] < len(state["boss_deck"]) assert state["last_boss_reaction"] assert isinstance(first, int) def test_structured_events_share_turn_number_and_keep_latest_hundred(): game = TowerGame(MockGateway()) state = ready_state(game) state["enemy_hp"] = 999 state["enemy_max_hp"] = 999 state["enemy_evasion"] = 0 state["enemy_intent"] = "Attack" game.act(state, "strike") recent = [ event for event in state["combat_events"] if event["turn"] == 1 and event["actor"] in {"Player", "Enemy"} ] assert {event["actor"] for event in recent} == {"Player", "Enemy"} for index in range(120): from tower_game.state import append_log append_log(state, f"event {index}", turn=index) assert len(state["combat_events"]) == 100 assert len(state["combat_log"]) == 100