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