Skip to main content

character.py

"""Character class - handles stats, leveling, skills, status effects."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from data_types import *
from skills_db import SKILL_DB, JOB_SKILL_POOL


# ── Base stats per job ────────────────────────────────────────────────────────
JOB_BASE_STATS: Dict[JobClass, Stats] = {
    JobClass.WARRIOR:    Stats(220,40, 28,8,  18,12,12,0.06,0.88,0.10),
    JobClass.KNIGHT:     Stats(280,50, 22,8,  28,18,10,0.04,0.87,0.06),
    JobClass.PALADIN:    Stats(260,80, 20,18, 24,22,11,0.05,0.87,0.07),
    JobClass.BERSERKER:  Stats(240,30, 35,6,  14,10,13,0.05,0.83,0.18),
    JobClass.ASSASSIN:   Stats(180,60, 26,14, 12,14,20,0.18,0.90,0.22),
    JobClass.RANGER:     Stats(190,55, 24,12, 14,14,18,0.14,0.92,0.16),
    JobClass.HUNTER:     Stats(195,55, 24,12, 15,14,17,0.13,0.91,0.15),
    JobClass.SPEARMAN:   Stats(210,45, 26,8,  16,14,15,0.08,0.88,0.10),
    JobClass.DRAGOON:    Stats(220,50, 28,10, 16,15,16,0.09,0.88,0.12),
    JobClass.MONK:       Stats(230,45, 30,8,  16,12,18,0.10,0.89,0.12),
    JobClass.CLERIC:     Stats(180,100,14,26, 16,24,12,0.05,0.87,0.05),
    JobClass.PRIEST:     Stats(170,120,12,28, 14,26,11,0.04,0.86,0.04),
    JobClass.FIRE_MAGE:  Stats(160,110,10,34, 10,18,13,0.07,0.87,0.08),
    JobClass.ICE_MAGE:   Stats(160,110,10,32, 12,20,12,0.07,0.87,0.08),
    JobClass.STORM_MAGE: Stats(160,110,10,33, 10,18,14,0.08,0.87,0.08),
    JobClass.WIND_MAGE:  Stats(155,105,10,30, 10,18,16,0.10,0.88,0.08),
    JobClass.EARTH_MAGE: Stats(170,105,12,30, 14,20,11,0.06,0.87,0.07),
    JobClass.DARK_MAGE:  Stats(165,110,10,35, 10,16,14,0.09,0.87,0.12),
    JobClass.LIGHT_MAGE: Stats(165,110,10,33, 12,20,13,0.07,0.87,0.08),
    JobClass.ARCANE_SAGE:Stats(175,130,12,36, 12,22,12,0.07,0.88,0.10),
}

JOB_GROWTH: Dict[JobClass, GrowthRates] = {
    JobClass.WARRIOR:    GrowthRates(0.85,0.40,0.65,0.30,0.55,0.40,0.40,0.20,0.35,0.25),
    JobClass.KNIGHT:     GrowthRates(0.90,0.45,0.50,0.25,0.70,0.55,0.30,0.15,0.30,0.15),
    JobClass.PALADIN:    GrowthRates(0.88,0.60,0.48,0.42,0.60,0.60,0.32,0.18,0.32,0.18),
    JobClass.BERSERKER:  GrowthRates(0.82,0.30,0.75,0.20,0.40,0.30,0.45,0.15,0.28,0.40),
    JobClass.ASSASSIN:   GrowthRates(0.65,0.55,0.58,0.35,0.35,0.42,0.65,0.50,0.55,0.60),
    JobClass.RANGER:     GrowthRates(0.70,0.55,0.55,0.38,0.38,0.42,0.55,0.45,0.60,0.45),
    JobClass.HUNTER:     GrowthRates(0.72,0.55,0.55,0.38,0.40,0.42,0.52,0.42,0.58,0.42),
    JobClass.SPEARMAN:   GrowthRates(0.78,0.45,0.60,0.28,0.48,0.40,0.50,0.25,0.40,0.28),
    JobClass.DRAGOON:    GrowthRates(0.80,0.48,0.62,0.32,0.48,0.42,0.52,0.28,0.42,0.32),
    JobClass.MONK:       GrowthRates(0.80,0.42,0.65,0.25,0.50,0.38,0.55,0.30,0.45,0.35),
    JobClass.CLERIC:     GrowthRates(0.72,0.75,0.28,0.58,0.45,0.65,0.35,0.20,0.32,0.15),
    JobClass.PRIEST:     GrowthRates(0.68,0.80,0.25,0.62,0.42,0.70,0.32,0.18,0.30,0.12),
    JobClass.FIRE_MAGE:  GrowthRates(0.60,0.78,0.22,0.72,0.28,0.48,0.40,0.22,0.35,0.22),
    JobClass.ICE_MAGE:   GrowthRates(0.60,0.78,0.22,0.70,0.30,0.50,0.38,0.22,0.35,0.22),
    JobClass.STORM_MAGE: GrowthRates(0.60,0.78,0.22,0.72,0.28,0.48,0.42,0.22,0.35,0.22),
    JobClass.WIND_MAGE:  GrowthRates(0.58,0.75,0.22,0.68,0.28,0.48,0.50,0.28,0.38,0.20),
    JobClass.EARTH_MAGE: GrowthRates(0.65,0.75,0.25,0.68,0.35,0.50,0.35,0.18,0.32,0.18),
    JobClass.DARK_MAGE:  GrowthRates(0.60,0.78,0.20,0.75,0.26,0.46,0.42,0.28,0.35,0.30),
    JobClass.LIGHT_MAGE: GrowthRates(0.62,0.78,0.22,0.72,0.30,0.52,0.40,0.22,0.34,0.20),
    JobClass.ARCANE_SAGE:GrowthRates(0.65,0.82,0.28,0.78,0.32,0.55,0.42,0.25,0.38,0.25),
}


@dataclass
class Character:
    name: str
    job: JobClass
    weapon: WeaponType
    element: Element
    rarity: int  # 0=player, 3/4/5=companion

    base_stats: Stats
    growth: GrowthRates

    # All skills the character CAN have (assigned at creation)
    skill_pool: List[int] = field(default_factory=list)  # 5 skill ids
    # Skills currently UNLOCKED
    unlocked_skills: List[int] = field(default_factory=list)

    level: int = 1
    exp: int = 0
    exp_to_next: int = 100

    # Runtime state
    current_hp: int = 0
    current_mp: int = 0
    status_effects: List[StatusEffect] = field(default_factory=list)
    temp_buffs: Dict[str, Tuple[float,int]] = field(default_factory=dict)  # stat -> (mult, turns_remaining)

    is_dead: bool = False  # permanent death
    ko_turns: int = 0      # turns spent at 0 hp (KO counter)
    is_ko: bool = False    # currently knocked out in battle

    battle_count: int = 0  # total battles participated

    def __post_init__(self):
        if self.current_hp == 0:
            self.current_hp = self.base_stats.hp
        if self.current_mp == 0:
            self.current_mp = self.base_stats.mp

    # ── Stat helpers ─────────────────────────────────────────────────────
    def effective_stat(self, stat_name: str) -> float:
        base = getattr(self.base_stats, stat_name)
        mult = 1.0
        for effect_name, (m, turns) in self.temp_buffs.items():
            if effect_name == stat_name:
                mult *= m
        # Status effects
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.ATTACK_DOWN and stat_name == "patk":
                mult *= 0.7
            elif se.effect_type == StatusEffectType.DEFENSE_DOWN and stat_name == "pdef":
                mult *= 0.7
            elif se.effect_type == StatusEffectType.MAGIC_DOWN and stat_name == "matk":
                mult *= 0.7
            elif se.effect_type == StatusEffectType.SPEED_DOWN and stat_name == "spd":
                mult *= 0.7
            elif se.effect_type == StatusEffectType.ACCURACY_DOWN and stat_name == "acc":
                mult *= 0.6
            elif se.effect_type == StatusEffectType.BERSERK:
                if stat_name == "patk": mult *= 1.5
                if stat_name == "pdef": mult *= 0.7
            elif se.effect_type == StatusEffectType.GUARD_UP:
                if stat_name in ("pdef","mdef"): mult *= 1.4
            elif se.effect_type == StatusEffectType.FOCUS:
                if stat_name in ("acc","crit"): mult *= 1.5
            elif se.effect_type == StatusEffectType.MAGIC_BOOST:
                if stat_name == "matk": mult *= 1.4
            elif se.effect_type == StatusEffectType.HASTE:
                if stat_name == "spd": mult *= 1.5
        return base * mult

    @property
    def max_hp(self): return self.base_stats.hp
    @property
    def max_mp(self): return self.base_stats.mp
    @property
    def spd(self): return int(self.effective_stat("spd"))
    @property
    def patk(self): return int(self.effective_stat("patk"))
    @property
    def matk(self): return int(self.effective_stat("matk"))
    @property
    def pdef(self): return int(self.effective_stat("pdef"))
    @property
    def mdef(self): return int(self.effective_stat("mdef"))
    @property
    def eva(self): return self.effective_stat("eva")
    @property
    def acc(self): return self.effective_stat("acc")
    @property
    def crit(self): return self.effective_stat("crit")

    def is_incapacitated(self) -> bool:
        """Cannot act at all."""
        incap = {StatusEffectType.SLEEP, StatusEffectType.STUN,
                 StatusEffectType.FREEZE, StatusEffectType.PARALYZE,
                 StatusEffectType.PETRIFY, StatusEffectType.TIME_STOP}
        return any(se.effect_type in incap for se in self.status_effects) or self.is_ko

    def can_use_magic(self) -> bool:
        blocked = {StatusEffectType.SILENCE, StatusEffectType.MANA_BURN}
        return not any(se.effect_type in blocked for se in self.status_effects)

    def can_use_skills(self) -> bool:
        return not any(se.effect_type == StatusEffectType.SKILL_SEAL for se in self.status_effects)

    def can_use_items(self) -> bool:
        return not any(se.effect_type == StatusEffectType.ITEM_SEAL for se in self.status_effects)

    def can_be_healed(self) -> bool:
        return not any(se.effect_type == StatusEffectType.HEAL_BLOCK for se in self.status_effects)

    def has_status(self, stype: StatusEffectType) -> bool:
        return any(se.effect_type == stype for se in self.status_effects)

    def add_status(self, effect: StatusEffect):
        # Don't stack same type (refresh duration instead)
        for existing in self.status_effects:
            if existing.effect_type == effect.effect_type:
                existing.duration = max(existing.duration, effect.duration)
                return
        self.status_effects.append(effect)

    def remove_status(self, stype: StatusEffectType):
        self.status_effects = [s for s in self.status_effects if s.effect_type != stype]

    def add_buff(self, stat: str, mult: float, duration: int):
        if stat in self.temp_buffs:
            old_mult, old_dur = self.temp_buffs[stat]
            self.temp_buffs[stat] = (max(old_mult, mult), max(old_dur, duration))
        else:
            self.temp_buffs[stat] = (mult, duration)

    def tick_status_effects(self) -> List[str]:
        """Process status effects at turn end. Returns list of messages."""
        messages = []
        to_remove = []
        for se in self.status_effects:
            msg = self._apply_status_tick(se)
            if msg:
                messages.append(msg)
            if not se.tick():
                to_remove.append(se)
        self.status_effects = [s for s in self.status_effects if s not in to_remove]
        for expired in to_remove:
            messages.append(f"  {self.name}'s {expired.effect_type.value} wore off.")
        return messages

    def _apply_status_tick(self, se: StatusEffect) -> Optional[str]:
        if se.effect_type == StatusEffectType.POISON:
            dmg = max(1, int(self.max_hp * 0.05))
            self.take_damage(dmg)
            return f"  {self.name} is poisoned! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.VENOM:
            dmg = max(1, int(self.max_hp * 0.08))
            self.take_damage(dmg)
            return f"  {self.name} suffers venom! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.BURN:
            dmg = max(1, int(self.max_hp * 0.06))
            self.take_damage(dmg)
            return f"  {self.name} is burning! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.BLEED:
            dmg = max(1, int(self.max_hp * 0.04))
            self.take_damage(dmg)
            return f"  {self.name} bleeds! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.CURSE:
            dmg = max(1, int(self.max_hp * 0.03))
            self.take_damage(dmg)
            return f"  {self.name} is cursed! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.REGEN:
            heal = max(1, int(self.mdef * 1.5))
            self.heal(heal)
            return f"  {self.name} regenerates! (+{heal} HP)"
        elif se.effect_type == StatusEffectType.MANA_REGEN:
            mp = max(1, int(self.max_mp * 0.05))
            self.current_mp = min(self.max_mp, self.current_mp + mp)
            return f"  {self.name} regenerates MP! (+{mp} MP)"
        elif se.effect_type == StatusEffectType.DOOM:
            if se.duration <= 1:
                self.current_hp = 0
                self.is_ko = True
                return f"  ☠ DOOM strikes {self.name}! Instant KO!"
            else:
                return f"  ⏳ DOOM countdown: {se.duration} turns left for {self.name}!"
        return None

    def tick_buffs(self) -> List[str]:
        messages = []
        expired = [k for k,(m,d) in self.temp_buffs.items() if d <= 1]
        self.temp_buffs = {k:(m,d-1) for k,(m,d) in self.temp_buffs.items() if d > 1}
        for k in expired:
            messages.append(f"  {self.name}'s {k} buff expired.")
        return messages

    def take_damage(self, amount: int):
        # Check shield
        if self.has_status(StatusEffectType.SHIELD):
            se = next(s for s in self.status_effects if s.effect_type == StatusEffectType.SHIELD)
            shield_val = int(se.power)
            if shield_val >= amount:
                se.power -= amount
                if se.power <= 0:
                    self.remove_status(StatusEffectType.SHIELD)
                return
            else:
                amount -= shield_val
                self.remove_status(StatusEffectType.SHIELD)
        self.current_hp = max(0, self.current_hp - amount)
        if self.current_hp == 0:
            self.is_ko = True

    def heal(self, amount: int):
        if not self.can_be_healed():
            return 0
        actual = min(amount, self.max_hp - self.current_hp)
        self.current_hp += actual
        if self.current_hp > 0:
            self.is_ko = False
            self.ko_turns = 0
        return actual

    def revive(self, hp_percent: int):
        self.is_ko = False
        self.ko_turns = 0
        self.current_hp = max(1, int(self.max_hp * hp_percent / 100))

    # ── Leveling ─────────────────────────────────────────────────────────
    def gain_exp(self, amount: int) -> List[str]:
        messages = []
        self.exp += amount
        while self.exp >= self.exp_to_next:
            self.exp -= self.exp_to_next
            messages += self._level_up()
        return messages

    def _level_up(self) -> List[str]:
        self.level += 1
        self.exp_to_next = int(self.exp_to_next * 1.15)
        g = self.growth
        messages = [f"  ★ {self.name} reached Level {self.level}!"]

        def roll(rate, lo, hi):
            return random.randint(lo, hi) if random.random() < rate else 0

        hp_gain   = roll(g.hp,   5, 12)
        mp_gain   = roll(g.mp,   3, 8)
        patk_gain = roll(g.patk, 1, 4)
        matk_gain = roll(g.matk, 1, 4)
        pdef_gain = roll(g.pdef, 1, 3)
        mdef_gain = roll(g.mdef, 1, 3)
        spd_gain  = roll(g.spd,  1, 1)
        eva_gain  = roll(g.eva,  0, 0)  # tracked differently
        acc_gain  = 0
        crit_gain = 0

        self.base_stats.hp   += hp_gain
        self.base_stats.mp   += mp_gain
        self.base_stats.patk += patk_gain
        self.base_stats.matk += matk_gain
        self.base_stats.pdef += pdef_gain
        self.base_stats.mdef += mdef_gain
        self.base_stats.spd  += spd_gain
        if random.random() < g.eva:  self.base_stats.eva = min(0.95, self.base_stats.eva + 0.01)
        if random.random() < g.acc:  self.base_stats.acc = min(1.0,  self.base_stats.acc + 0.005)
        if random.random() < g.crit: self.base_stats.crit= min(0.95, self.base_stats.crit+ 0.005)

        # Heal to full on level up
        self.current_hp = self.base_stats.hp
        self.current_mp = self.base_stats.mp

        gains = []
        if hp_gain:   gains.append(f"HP+{hp_gain}")
        if mp_gain:   gains.append(f"MP+{mp_gain}")
        if patk_gain: gains.append(f"PATK+{patk_gain}")
        if matk_gain: gains.append(f"MATK+{matk_gain}")
        if pdef_gain: gains.append(f"PDEF+{pdef_gain}")
        if mdef_gain: gains.append(f"MDEF+{mdef_gain}")
        if spd_gain:  gains.append(f"SPD+{spd_gain}")
        if gains:
            messages.append(f"    Stats: {', '.join(gains)}")

        # Unlock skills
        unlock_msgs = self._check_skill_unlocks()
        messages.extend(unlock_msgs)
        return messages

    def _check_skill_unlocks(self) -> List[str]:
        msgs = []
        # Intermediate: lv 5 and 10
        intermediate_ids = [sid for sid in self.skill_pool
                           if sid in SKILL_DB and SKILL_DB[sid].tier == SkillTier.INTERMEDIATE]
        if self.level == 5 and len(intermediate_ids) >= 1:
            sid = intermediate_ids[0]
            if sid not in self.unlocked_skills:
                self.unlocked_skills.append(sid)
                msgs.append(f"    ✦ Skill Unlocked: {SKILL_DB[sid].name}!")
        if self.level == 10 and len(intermediate_ids) >= 2:
            sid = intermediate_ids[1]
            if sid not in self.unlocked_skills:
                self.unlocked_skills.append(sid)
                msgs.append(f"    ✦ Skill Unlocked: {SKILL_DB[sid].name}!")
        # Ultimate: lv 20
        if self.level == 20:
            ultimate_ids = [sid for sid in self.skill_pool
                           if sid in SKILL_DB and SKILL_DB[sid].tier == SkillTier.ULTIMATE]
            if ultimate_ids:
                sid = ultimate_ids[0]
                if sid not in self.unlocked_skills:
                    self.unlocked_skills.append(sid)
                    msgs.append(f"    ★ ULTIMATE SKILL UNLOCKED: {SKILL_DB[sid].name}!!")
        return msgs

    def hp_bar(self, width=20) -> str:
        ratio = self.current_hp / max(1, self.max_hp)
        filled = int(ratio * width)
        bar = "█" * filled + "░" * (width - filled)
        return f"[{bar}] {self.current_hp}/{self.max_hp}"

    def mp_bar(self, width=10) -> str:
        ratio = self.current_mp / max(1, self.max_mp)
        filled = int(ratio * width)
        bar = "▓" * filled + "░" * (width - filled)
        return f"[{bar}] {self.current_mp}/{self.max_mp}"

    def status_str(self) -> str:
        if not self.status_effects:
            return ""
        tags = [se.effect_type.value[:3].upper() for se in self.status_effects]
        return " [" + ",".join(tags) + "]"

    def short_status(self) -> str:
        if self.is_dead:   return "DEAD"
        if self.is_ko:     return f"KO({self.ko_turns})"
        return "OK"


def create_player_character(name: str, job: JobClass,
                             weapon: WeaponType, element: Element) -> Character:
    base = JOB_BASE_STATS[job].copy()
    growth = JOB_GROWTH[job]
    pool = JOB_SKILL_POOL[job]

    # Pick 2 basic, 2 intermediate, 1 ultimate from pool
    basics = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.BASIC][:4]
    inters = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.INTERMEDIATE][:4]
    ultims = [sid for sid in pool if SKILL_DB[sid].tier == SkillTier.ULTIMATE][:2]

    chosen_basic = random.sample(basics, min(2, len(basics)))
    chosen_inter = random.sample(inters, min(2, len(inters)))
    chosen_ultim = random.sample(ultims, min(1, len(ultims)))

    skill_pool = chosen_basic + chosen_inter + chosen_ultim
    unlocked = chosen_basic[:]  # Only basics at level 1

    char = Character(
        name=name, job=job, weapon=weapon, element=element, rarity=0,
        base_stats=base, growth=growth,
        skill_pool=skill_pool, unlocked_skills=unlocked
    )
    return char