from collections import Counter from assets import icon from engine import collection, runes from engine.battle import Battle from engine.relics import RELIC_BY_KEY, grant_rune_rewards, roll_relics, rune_reward_count from engine.roster import make_enemy_team, pick_team, player_team_level, player_team_stage from engine.unit import TEAM_ENEMY, TEAM_PLAYER def _pack_reward_items(pack_types): """Reward entries (label + pack sprite path) from a list of pack type names, e.g. ['wildflower', 'wildflower'] -> [{'label': '2 Wildflower packs', ...}].""" counts = Counter(pack_types) items = [] for pt in ("general", "lesser", "tower", "radiant", "nocturnal", "ice", "garden", "wildflower"): n = counts.get(pt, 0) if n: name = "Wildflower" if pt == "wildflower" else pt.capitalize() items.append({ "label": f"{n} {name} pack{'s' if n > 1 else ''}", "kind": "pack", "img": icon("seed_packs/closed", f"{pt}_seedpack"), }) return items def _rune_reward_item(rune): """Reward entry for a rune drop (label + rune sprite path).""" return { "label": rune["name"], "kind": "rune", "img": icon("runes", runes.RUNE_ICON_FILES.get(rune["color"], "rune_brown")), } def _reward_labels(items): """Flatten reward entries (dicts or plain strings) into label text.""" return [it.get("label", "") if isinstance(it, dict) else it for it in items] def _totals_txt(total_water, total_packs, run_relics): """Tower run totals line, including pending rune rewards from rune relics.""" parts = [f"{total_water} water", f"{total_packs} tower pack(s)"] n = rune_reward_count(run_relics) if n: parts.append(f"{n} rune{'s' if n != 1 else ''}") return ", ".join(parts) def _battle_win_reward(floor): """Grant and describe a floor-battle win: a lesser pack, or on every 5th win (5, 10, 15, ...) a better general/type-boosted pack instead. Advances the floor. Runes can drop on any win regardless.""" collection.advance_floor() wins = collection.record_win() if wins % 5 == 0: reward = _pack_reward_items(collection.grant_fifth_win_bonus()) else: reward = _pack_reward_items(collection.add_pack("lesser")) rune = collection.roll_battle_rune() if rune: reward.append(_rune_reward_item(rune)) return reward def run_tower(): import ui.scaling as scaling from ui.picker import TeamSelect roster_pick = TeamSelect(tower_mode=True, pick_goal=8, min_goal=1, show_slots=False) roster_res = roster_pick.run() if scaling.QUIT_REQUESTED: return "quit" if roster_res is None or roster_res == "back" or not roster_res[0]: return "back" # back to the battle/tower selection screen roster = list(roster_res[0]) collection.mark_owned(roster) from engine.roster import ( build_tower_enemy_team, roll_tower_enemy_team, tower_enemy_level, tower_reward, ) level = player_team_level(roster) # Gate the tower to the highest stage the account qualifies for; clearing # that stage finishes the run (the tower can't be pushed past the gate). from engine.collection import tower_max_stage max_stage = tower_max_stage() def build_team(enemies=()): el = tower_enemy_level(level, stage) ts = TeamSelect(tower_mode=True, pick_goal=4, restrict_to=roster, initial_enemies=list(enemies), enemy_levels=[el] * len(enemies)) res = ts.run() if scaling.QUIT_REQUESTED: return "quit" if res is None or res == "back": return None return list(res[0]) active = build_team() if active == "quit": return "quit" if active is None: return "back" # back to the battle/tower selection screen run_relics = [] total_water = 0 total_packs = 0 stage = 1 rolled_stage = -1 enemy_recipe = None while stage <= max_stage: water, packs = tower_reward(stage) reward_display = [] if water: reward_display.append({"label": f"{water} water", "kind": "text"}) if packs: reward_display.append({"label": f"{packs} tower pack{'s' if packs > 1 else ''}", "kind": "pack", "img": icon("seed_packs/closed", "tower_seedpack")}) if stage != rolled_stage: # Roll the enemy team once per stage; retrying a lost stage (or # swapping teams) rebuilds the exact same enemies instead of rerolling. enemy_recipe = roll_tower_enemy_team(stage, tower_enemy_level(level, stage)) rolled_stage = stage enemy_names, injections, enemy_runes = enemy_recipe enemy_units = build_tower_enemy_team(enemy_names, injections, enemy_runes, tower_enemy_level(level, stage), stage) players = pick_team(active, TEAM_PLAYER) # fresh units: cooldowns/status reset each battle battle = Battle(players, enemy_units, relics=run_relics) from ui.main import Game Game(battle, on_win=lambda d=reward_display: d, on_lose=lambda: [], tower=True, relics=[RELIC_BY_KEY[k] for k in run_relics if k in RELIC_BY_KEY], stage=stage).run() if scaling.QUIT_REQUESTED: return "quit" totals_txt = _totals_txt(total_water, total_packs, run_relics) from ui.tower import ConfirmScreen, TowerScreen if battle.winner != TEAM_PLAYER: while True: choice = TowerScreen(stage, max_stage, "", totals_txt, mode="lost", enemies=enemy_names).run() if scaling.QUIT_REQUESTED: return "quit" if choice == "retry": break # re-fight this stage if choice == "change": active = build_team(enemies=enemy_names) if active == "quit": return "quit" if active is None: # back in the team picker: ask before leaving the tower if ConfirmScreen( "Leave the tower?", "You'll keep the rewards you've earned so far.").run(): TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() if scaling.QUIT_REQUESTED: return "quit" grant_rune_rewards(run_relics) return "back" continue # stay: show the lost screen again break # team changed, re-fight this stage TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() if scaling.QUIT_REQUESTED: return "quit" grant_rune_rewards(run_relics) return "back" # gave up -> back to the selection screen continue if water: collection.add_water(water) if packs: collection.add_tower_packs(packs) collection.grant_xp(active, 11 * stage, count_battle=False) total_water += water total_packs += packs collection.set_tower_best(stage) totals_txt = _totals_txt(total_water, total_packs, run_relics) if stage >= max_stage: TowerScreen(max_stage, max_stage, "", totals_txt, mode="cleared").run() if scaling.QUIT_REQUESTED: return "quit" break # cleared the highest available tower stage from ui.relic_choice import RelicChoiceScreen offered = roll_relics(3, exclude=run_relics, stage=stage) if offered: picked = RelicChoiceScreen(offered, stage, owned=run_relics).run() if scaling.QUIT_REQUESTED: return "quit" if picked is not None: run_relics.append(picked.key) reward_txt = ", ".join(_reward_labels(reward_display)) if reward_display else "—" while True: choice = TowerScreen(stage, max_stage, reward_txt, totals_txt, mode="battle").run() if scaling.QUIT_REQUESTED: return "quit" if choice == "exit": TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() if scaling.QUIT_REQUESTED: return "quit" grant_rune_rewards(run_relics) return "back" if choice == "adjust": active = build_team() if active == "quit": return "quit" if active is None: # back in the team picker: ask before leaving the tower if ConfirmScreen( "Leave the tower?", "You'll keep the rewards you've earned so far.").run(): TowerScreen(stage, max_stage, "", totals_txt, mode="summary").run() if scaling.QUIT_REQUESTED: return "quit" grant_rune_rewards(run_relics) return "back" continue # stay: show the stage screen again continue # re-fight this stage with the new team break # "next": advance to the next stage stage += 1 grant_rune_rewards(run_relics) return "back" # tower loop ended (cleared all available stages) -> selection screen def main(): import pygame import ui.scaling as scaling try: if not collection.save_exists(): from ui.starter import StarterScreen if not StarterScreen().run(): return # closed the starter window without picking a pack next_page = "menu" while True: from ui.menu import Menu choice = Menu(page=next_page).run() if scaling.QUIT_REQUESTED or choice is None: return next_page = "menu" if choice == "tower": result = run_tower() if scaling.QUIT_REQUESTED or result == "quit": return if result == "back": next_page = "battle_setup" continue floor = int(choice) player_type = collection.unit_type("Pinwheel") if "Pinwheel" in collection.owned() \ else collection.unit_type(next(iter(collection.owned() or ["Pinwheel"]))) from engine.roster import (build_floor_enemies, floor_enemy_level, floor_enemy_names, roll_enemy_types) while True: floor = collection.floor_progress() enemy_names = floor_enemy_names(floor, player_type) enemy_types = roll_enemy_types(enemy_names, player_type, floor) enemy_levels = [floor_enemy_level(floor)] * len(enemy_names) from ui.picker import TeamSelect ts = TeamSelect(initial_enemies=enemy_names, difficulty=floor, enemy_types=enemy_types, no_roll=True, enemy_levels=enemy_levels) result = ts.run() if scaling.QUIT_REQUESTED or result is None: return if result == "back": next_page = "battle_setup" break # back to the battle/tower selection screen allies, enemies = result collection.mark_owned(allies) collection.mark_found(enemies) players = pick_team(allies, TEAM_PLAYER) enemy_units = build_floor_enemies(enemy_names, enemy_types, floor) battle = Battle(players, enemy_units) from ui.main import Game outcome = Game(battle, on_win=lambda f=floor: _battle_win_reward(f), on_lose=lambda: [], stage=floor).run() if scaling.QUIT_REQUESTED: return won = battle.winner == TEAM_PLAYER # Losses give nothing; wins scale XP gently with the floor. xp = 40 + 15 * floor if won else 0 collection.grant_xp(allies, xp, count_battle=won, fertilizer=floor if won else 0) if outcome == "back": next_page = "battle_setup" break # back to the battle/tower selection screen if outcome == "rematch": # A win already advanced the floor (in _battle_win_reward), so # the next loop iteration fights the new unbeaten floor. # A loss retries the same floor. continue return finally: pygame.quit() if __name__ == "__main__": main()