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