tower-learns-you / tests /test_engine.py
vknt's picture
Deploy The Tower Learns You (custom gr.Server frontend, hf_inference + mock fallback)
22a027e verified
Raw
History Blame Contribute Delete
36.1 kB
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