game_state.py

"""Game state: manages party, inventory, battle progression."""

from __future__ import annotations

import random

from dataclasses import dataclass, field

from typing import List, Dict, Optional, Tuple

from data_types import *

from character import Character, create_player_character

from monster_unit import MonsterUnit

from monsters_db import get_encounter, MONSTER_DB

from items_db import ITEM_DB

from skills_db import SKILL_DB

from battle import Battle

from companions_db import summon_companion

# Starting inventory

DEFAULT_INVENTORY = {

 1: 5, # Potion x5

 6: 3, # Ether x3

 31: 2, # Phoenix Feather x2

 41: 2, # Antidote x2

 86: 2, # Fire Bomb x2

}

# Battle milestones for companion rewards

COMPANION_MILESTONES = {

 1: 2, # After battle 1: lv2 companion

 5: 5, # After battle 5: lv5 companion

 10: 7, # After battle 10: lv7-8 companion

 20: 15, # After battle 20: lv15 companion

 50: 40, # After battle 50: lv40 companion

 75: 47, # After battle 75: lv45-50 companion

 100:60, # After battle 100: lv60 companion

}

class GameState:

 def __init__(self):

 self.player: Optional[Character] = None

 self.party: List[Character] = []

 self.inventory: Dict[int, int] = dict(DEFAULT_INVENTORY)

 self.battle_number: int = 0

 self.game_over: bool = False

 self.deceased_companions: List[Character] = []

 # Records for game over screen

 self.all_party_members: List[Character] = []

 # ── Inventory ─────────────────────────────────────────────────────────

 def add_item(self, item_id: int, count: int = 1):

 self.inventory[item_id] = self.inventory.get(item_id, 0) + count

 def display_inventory(self):

 if not self.inventory:

 print(" (Empty)")

 return

 print(" INVENTORY:")

 for iid, count in sorted(self.inventory.items()):

 if iid in ITEM_DB and count > 0:

 item = ITEM_DB[iid]

 print(f" {item.name} x{count} - {item.description}")

 def use_item_outside_battle(self, item_id: int, target: Character) -> bool:

 if item_id not in self.inventory or self.inventory[item_id] <= 0:

 print(" You don't have that item!")

 return False

 item = ITEM_DB[item_id]

 self.inventory[item_id] -= 1

 if self.inventory[item_id] <= 0:

 del self.inventory[item_id]

 if item.item_type == ItemType.RECOVERY:

 if item.effect_value == -100:

 target.current_hp = target.max_hp

 target.current_mp = target.max_mp

 print(f" {target.name} fully restored!")

 elif item.effect_value < 0:

 pct = abs(item.effect_value)

 amt = int(target.max_hp * pct / 100)

 actual = target.heal(amt)

 print(f" {target.name} recovers {actual} HP!")

 elif item.id in (6,7,8,9,12,20):

 mp = min(item.effect_value, target.max_mp - target.current_mp)

 target.current_mp += mp

 print(f" {target.name} recovers {mp} MP!")

 else:

 actual = target.heal(item.effect_value)

 print(f" {target.name} recovers {actual} HP!")

 elif item.item_type == ItemType.REVIVAL:

 if target.is_ko and not target.is_dead:

 target.revive(item.effect_value)

 print(f" {target.name} revived with {item.effect_value}% HP!")

 else:

 print(f" {target.name} isn't KO'd.")

 self.inventory[item_id] = self.inventory.get(item_id, 0) + 1 # refund

 return False

 elif item.item_type == ItemType.STATUS_CURE:

 if item.status_cure:

 for stype in item.status_cure:

 target.remove_status(stype)

 print(f" {target.name} cleansed!")

 return True

 # ── Party management ──────────────────────────────────────────────────

 def display_party(self):

 print("\n PARTY:")

 for i, ch in enumerate(self.party):

 rarity_str = f" {'★'*ch.rarity}" if ch.rarity > 0 else " [PLAYER]"

 status = ch.short_status()

 print(f" [{i+1}] {ch.name} Lv{ch.level} {ch.job.value}{rarity_str} - {status}")

 print(f" HP:{ch.current_hp}/{ch.max_hp} MP:{ch.current_mp}/{ch.max_mp}")

 print(f" PATK:{ch.patk} MATK:{ch.matk} PDEF:{ch.pdef} MDEF:{ch.mdef} SPD:{ch.spd}")

 def display_character_detail(self, ch: Character):

 print(f"\n ── {ch.name} ──────────────────────────────")

 rarity_str = f"{'★'*ch.rarity}" if ch.rarity > 0 else "PLAYER"

 print(f" Job: {ch.job.value} Rarity: {rarity_str}")

 print(f" Weapon: {ch.weapon.value} Element: {ch.element.value}")

 print(f" Level: {ch.level} EXP: {ch.exp}/{ch.exp_to_next}")

 print(f" HP: {ch.current_hp}/{ch.max_hp} MP: {ch.current_mp}/{ch.max_mp}")

 print(f" PATK:{ch.patk} MATK:{ch.matk} PDEF:{ch.pdef} MDEF:{ch.mdef}")

 print(f" SPD:{ch.spd} EVA:{ch.base_stats.eva:.0%} ACC:{ch.base_stats.acc:.0%} CRIT:{ch.base_stats.crit:.0%}")

 print(f" Skills:")

 for sid in ch.skill_pool:

 if sid in SKILL_DB:

 sk = SKILL_DB[sid]

 unlocked = "✓" if sid in ch.unlocked_skills else "✗"

 unlock_lv = ""

 if sk.tier == SkillTier.INTERMEDIATE:

 unlock_lv = " (Lv5/10)"

 elif sk.tier == SkillTier.ULTIMATE:

 unlock_lv = " (Lv20)"

 print(f" [{unlocked}] {sk.name} [{sk.tier.value}] MP:{sk.mp_cost}{unlock_lv}")

 def offer_companion(self, companion: Character):

 """Offer a new companion to the player."""

 print("\n" + "★"*65)

 rarity_str = "★" * companion.rarity

 print(f" A new companion has appeared! [{rarity_str}]")

 print(f" {companion.name} Lv{companion.level} | {companion.job.value}")

 print(f" HP:{companion.max_hp} PATK:{companion.base_stats.patk} MATK:{companion.base_stats.matk}")

 print(f" SPD:{companion.base_stats.spd} Weapon:{companion.weapon.value} Element:{companion.element.value}")

 alive_party = [ch for ch in self.party if not ch.is_dead]

 if len(alive_party) < 4:

 print(f"\n Adding {companion.name} to the party!")

 self.party.append(companion)

 self.all_party_members.append(companion)

 input(" [Press Enter]")

 else:

 print(f"\n Your party is full (4/4). Dismiss a member to make room?")

 self.display_party()

 print(f" [1-{len(self.party)}] Dismiss member [0] Decline companion")

 while True:

 try:

 choice = int(input(" > "))

 if choice == 0:

 print(f" Declined {companion.name}.")

 input(" [Press Enter]")

 return

 idx = choice - 1

 if 0 <= idx < len(self.party):

 dismiss_target = self.party[idx]

 if dismiss_target == self.player:

 print(" Cannot dismiss player character!")

 continue

 self.party.remove(dismiss_target)

 print(f" {dismiss_target.name} has left the party.")

 self.party.append(companion)

 self.all_party_members.append(companion)

 print(f" {companion.name} joined the party!")

 input(" [Press Enter]")

 return

 except ValueError:

 pass

 print(" Invalid choice.")

 

 # ── Battle ────────────────────────────────────────────────────────────

 def run_battle(self) -> bool:

 """Run a battle. Returns True if player survived."""

 self.battle_number += 1

 # Get encounter

 templates = get_encounter(self.battle_number)

 # Name duplicates

 name_count: Dict[str, int] = {}

 monster_units = []

 for t in templates:

 name_count[t.name] = name_count.get(t.name, 0) + 1

 used: Dict[str, int] = {}

 for t in templates:

 used[t.name] = used.get(t.name, 0) + 1

 label = t.name if name_count[t.name] == 1 else f"{t.name} {chr(64 + used[t.name])}"

 mu = MonsterUnit(template=t, instance_name=label)

 monster_units.append(mu)

 alive_party = [ch for ch in self.party if not ch.is_dead]

 battle = Battle(alive_party, monster_units)

 # Inject item use capability into battle

 battle._item_use_callback = self._battle_item_callback

 battle._original_use_item_menu = battle._use_item_menu

 battle._use_item_menu = lambda ch: self._battle_item_ui(ch, battle)

 victory = battle.run()

 if victory:

 exp = battle.calculate_exp_reward()

 print(f"\n ✓ VICTORY! Earned {exp} EXP.")

 # Award loot

 self._award_loot()

 alive_after = [ch for ch in alive_party if not ch.is_dead]

 for ch in alive_after:

 msgs = ch.gain_exp(exp)

 for m in msgs:

 print(m)

 input(" [Press Enter]")

 # Handle companion death

 for ch in alive_party:

 if ch.is_dead and ch != self.player:

 print(f"\n 💀 {ch.name} has permanently died and left the party.")

 self.party.remove(ch)

 self.deceased_companions.append(ch)

 # Check milestone companions

 self._check_companion_milestone()

 else:

 # Check if player character survived

 player_dead = self.player.is_dead or self.player.is_ko

 if player_dead:

 self.game_over = True

 print(f"\n ☠ {self.player.name} has fallen... GAME OVER")

 return False

 else:

 print(f"\n DEFEAT! Some party members have fallen.")

 for ch in alive_party:

 if ch.is_dead and ch != self.player:

 print(f" 💀 {ch.name} has permanently died.")

 self.party.remove(ch)

 self.deceased_companions.append(ch)

 input(" [Press Enter]")

 # Post-battle: reset KO state for surviving members

 for ch in self.party:

 if not ch.is_dead:

 if ch.is_ko and ch.current_hp > 0:

 ch.is_ko = False

 ch.ko_turns = 0

 # Partial HP restore (30% if KO'd but saved)

 if ch.current_hp <= 0:

 ch.current_hp = max(1, ch.max_hp // 5)

 ch.is_ko = False

 ch.ko_turns = 0

 return True

 def _battle_item_callback(self, ch, item_id: int, battle: Battle) -> bool:

 return battle.use_item_in_battle(ch, item_id, self.inventory)

 def _battle_item_ui(self, ch: Character, battle: Battle) -> bool:

 if not self.inventory:

 print(" Inventory is empty!")

 return False

 print(" INVENTORY (0=back):")

 items = [(iid, cnt) for iid, cnt in sorted(self.inventory.items()) if cnt > 0]

 for i, (iid, cnt) in enumerate(items):

 if iid in ITEM_DB:

 item = ITEM_DB[iid]

 print(f" [{i+1}] {item.name} x{cnt} - {item.description}")

 while True:

 raw = input(" > ").strip()

 if raw == "0": return False

 try:

 idx = int(raw) - 1

 if 0 <= idx < len(items):

 item_id = items[idx][0]

 return battle.use_item_in_battle(ch, item_id, self.inventory)

 except ValueError:

 pass

 print(" Invalid choice.")

 def _award_loot(self):

 """Random item drops after victory."""

 # Simple loot: random item from early pool

 loot_pool = [1, 2, 6, 7, 31, 41, 42, 66, 67, 86, 87, 88]

 advanced_pool = [3, 5, 8, 32, 56, 68]

 rare_pool = [4, 10, 33, 57, 84, 85]

 # Base drop

 if random.random() < 0.7:

 item_id = random.choice(loot_pool)

 self.add_item(item_id)

 print(f" Item found: {ITEM_DB[item_id].name}!")

 # Extra drop for later battles

 if self.battle_number > 20 and random.random() < 0.4:

 item_id = random.choice(advanced_pool)

 self.add_item(item_id)

 print(f" Item found: {ITEM_DB[item_id].name}!")

 if self.battle_number > 50 and random.random() < 0.2:

 item_id = random.choice(rare_pool)

 self.add_item(item_id)

 print(f" Rare item found: {ITEM_DB[item_id].name}!")

 def _check_companion_milestone(self):

 if self.battle_number in COMPANION_MILESTONES:

 target_lv = COMPANION_MILESTONES[self.battle_number]

 if isinstance(target_lv, tuple):

 target_lv = random.randint(target_lv[0], target_lv[1])

 companion = summon_companion(target_lv)

 self.offer_companion(companion)

 # ── Between battles menu ──────────────────────────────────────────────

 def between_battles_menu(self):

 while True:

 print("\n" + "─"*65)

 print(f" ── Camp ── (Battle {self.battle_number} completed)")

 print(" [1] View Party [2] Character Details [3] Use Item")

 print(" [4] View Inventory [5] Next Battle [6] Quit")

 choice = input(" > ").strip()

 if choice == "1":

 self.display_party()

 elif choice == "2":

 self.display_party()

 try:

 idx = int(input(" Choose character (0=back): ")) - 1

 if 0 <= idx < len(self.party):

 self.display_character_detail(self.party[idx])

 except ValueError:

 pass

 elif choice == "3":

 self._outside_battle_item_menu()

 elif choice == "4":

 self.display_inventory()

 elif choice == "5":

 return True

 elif choice == "6":

 return False

 else:

 print(" Invalid choice.")

 def _outside_battle_item_menu(self):

 self.display_inventory()

 items = [(iid, cnt) for iid, cnt in sorted(self.inventory.items()) if cnt > 0]

 if not items:

 return

 print(" Use item on (0=back):")

 raw = input(" Item # > ").strip()

 if raw == "0": return

 try:

 idx = int(raw) - 1

 if 0 <= idx < len(items):

 item_id = items[idx][0]

 self.display_party()

 tidx = int(input(" On character # > ")) - 1

 if 0 <= tidx < len(self.party):

 self.use_item_outside_battle(item_id, self.party[tidx])

 except ValueError:

 pass

 # ── Game over screen ──────────────────────────────────────────────────

 def show_game_over_screen(self):

 print("\n" + "☠"*65)

 print(" GAME OVER")

 print("☠"*65)

 print(f"\n {self.player.name} has fallen after {self.battle_number} battles.")

 print(f"\n ── BATTLE RECORD ──")

 print(f" Total Battles: {self.battle_number}")

 print(f" Battles Won: {self.battle_number - 1}") # lost the last one

 print(f"\n ── PARTY HISTORY ──")

 all_chars = list({id(c): c for c in self.all_party_members + self.deceased_companions}.values())

 if self.player not in all_chars:

 all_chars.insert(0, self.player)

 for ch in all_chars:

 status = "💀 DECEASED" if ch.is_dead else "ALIVE"

 rarity = f"{'★'*ch.rarity}" if ch.rarity else "PLAYER"

 print(f"\n {ch.name} Lv{ch.level} {ch.job.value} [{rarity}] - {status}")

 print(f" HP:{ch.max_hp} MP:{ch.max_mp} PATK:{ch.base_stats.patk} "

 f"MATK:{ch.base_stats.matk} PDEF:{ch.base_stats.pdef} "

 f"MDEF:{ch.base_stats.mdef} SPD:{ch.base_stats.spd}")

 print(f" Weapon:{ch.weapon.value} Element:{ch.element.value}")

 print(f" Skills: ", end="")

 skill_names = []

 for sid in ch.skill_pool:

 if sid in SKILL_DB:

 unlocked = "✓" if sid in ch.unlocked_skills else "✗"

 skill_names.append(f"{SKILL_DB[sid].name}[{unlocked}]")

 print(", ".join(skill_names))

 print("\n" + "☠"*65)

 input(" [Press Enter to exit]")

 

  