Skip to main content

monster_unit.py & monsters_db.py

monster_unit.py

"""Monster combat unit - wraps MonsterTemplate with battle state."""
from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import List, Optional
from data_types import *
from monsters_db import MonsterTemplate
from skills_db import SKILL_DB


@dataclass
class MonsterUnit:
    template: MonsterTemplate
    instance_name: str  # e.g. "Goblin A"

    current_hp: int = 0
    current_mp: int = 0
    shield_points: int = 0
    is_broken: bool = False
    break_turns: int = 0
    status_effects: List[StatusEffect] = field(default_factory=list)
    temp_buffs: dict = field(default_factory=dict)

    def __post_init__(self):
        self.current_hp = self.template.base_stats.hp
        self.current_mp = self.template.base_stats.mp
        self.shield_points = self.template.shield_points

    @property
    def is_dead(self): return self.current_hp <= 0

    @property
    def patk(self):
        v = self.template.base_stats.patk
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.ATTACK_DOWN: v = int(v*0.7)
        return v

    @property
    def matk(self):
        v = self.template.base_stats.matk
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.MAGIC_DOWN: v = int(v*0.7)
        return v

    @property
    def pdef(self):
        v = self.template.base_stats.pdef
        if self.is_broken: return 0
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.DEFENSE_DOWN: v = int(v*0.7)
        return v

    @property
    def mdef(self):
        v = self.template.base_stats.mdef
        if self.is_broken: return 0
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.MAGIC_DOWN: v = int(v*0.7)
        return v

    @property
    def spd(self):
        v = self.template.base_stats.spd
        for se in self.status_effects:
            if se.effect_type == StatusEffectType.SPEED_DOWN: v = int(v*0.7)
            if se.effect_type == StatusEffectType.HASTE: v = int(v*1.5)
        return v

    @property
    def eva(self):
        if self.is_broken: return 0.0
        v = self.template.base_stats.eva
        if self.has_status(StatusEffectType.BLIND): v = max(0, v - 0.3)
        return v

    @property
    def acc(self):
        v = self.template.base_stats.acc
        if self.has_status(StatusEffectType.ACCURACY_DOWN): v *= 0.6
        return v

    @property
    def max_hp(self): return self.template.base_stats.hp

    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):
        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 is_incapacitated(self) -> bool:
        incap = {StatusEffectType.SLEEP, StatusEffectType.STUN,
                 StatusEffectType.FREEZE, StatusEffectType.PARALYZE,
                 StatusEffectType.PETRIFY}
        return any(se.effect_type in incap for se in self.status_effects) or self.is_broken

    def take_damage(self, amount: int) -> int:
        """Returns actual damage dealt."""
        actual = min(amount, self.current_hp)
        self.current_hp -= actual
        return actual

    def hit_weakness(self, attack_weapon: Optional[WeaponType],
                     attack_element: Optional[Element]) -> bool:
        weaknesses = self.template.weaknesses
        if attack_weapon and attack_weapon in weaknesses:
            return True
        if attack_element and attack_element != Element.NONE and attack_element in weaknesses:
            return True
        return False

    def reduce_shield(self, hits: int) -> bool:
        """Returns True if break triggered."""
        if self.is_broken: return False
        self.shield_points = max(0, self.shield_points - hits)
        if self.shield_points == 0:
            self.is_broken = True
            self.break_turns = 1
            return True
        return False

    def tick_break(self):
        if self.is_broken:
            self.break_turns -= 1
            if self.break_turns <= 0:
                self.is_broken = False
                self.shield_points = self.template.shield_points // 2 + 1

    def tick_status_effects(self) -> List[str]:
        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.instance_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.instance_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.instance_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.instance_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.instance_name} bleeds! (-{dmg} HP)"
        elif se.effect_type == StatusEffectType.DOOM:
            if se.duration <= 1:
                self.current_hp = 0
                return f"  ☠ DOOM strikes {self.instance_name}!"
            return f"  ⏳ DOOM countdown: {se.duration} turns for {self.instance_name}."
        return None

    def choose_action(self, party: list) -> dict:
        """AI decides what to do."""
        ai = self.template.ai_type
        skill_ids = self.template.skill_ids
        # Filter to valid skills in DB
        valid_skills = [sid for sid in skill_ids if sid in SKILL_DB]

        hp_ratio = self.current_hp / max(1, self.max_hp)

        # Simple AI logic
        if ai == AIType.AGGRESSIVE:
            if valid_skills and random.random() < 0.4:
                return {"action": "skill", "skill_id": random.choice(valid_skills)}
            return {"action": "attack"}

        elif ai == AIType.DEFENSIVE:
            if hp_ratio < 0.3 and random.random() < 0.5:
                return {"action": "defend"}
            if valid_skills and random.random() < 0.3:
                return {"action": "skill", "skill_id": random.choice(valid_skills)}
            return {"action": "attack"}

        elif ai == AIType.SUPPORT:
            if valid_skills and random.random() < 0.6:
                skill_id = random.choice(valid_skills)
                return {"action": "skill", "skill_id": skill_id}
            return {"action": "attack"}

        elif ai == AIType.TACTICAL:
            if hp_ratio > 0.7:
                if valid_skills and random.random() < 0.5:
                    return {"action": "skill", "skill_id": random.choice(valid_skills)}
                return {"action": "attack"}
            elif hp_ratio > 0.4:
                if valid_skills and random.random() < 0.35:
                    return {"action": "skill", "skill_id": random.choice(valid_skills)}
                return {"action": "attack"}
            else:
                if valid_skills and random.random() < 0.7:
                    return {"action": "skill", "skill_id": random.choice(valid_skills)}
                return {"action": "attack"}

        elif ai == AIType.BERSERKER:
            if valid_skills and random.random() < 0.5:
                return {"action": "skill", "skill_id": random.choice(valid_skills)}
            return {"action": "attack"}

        else:  # RANDOM / BALANCED
            r = random.random()
            if valid_skills and r < 0.35:
                return {"action": "skill", "skill_id": random.choice(valid_skills)}
            return {"action": "attack"}

    def shield_bar(self) -> str:
        sp = self.shield_points
        total = self.template.shield_points
        filled = "◆" * sp + "◇" * (total - sp)
        return f"[{filled}]"

    def hp_bar(self, width=16) -> 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 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) + "]"

monsters_db.py

"""Monster database with 50 monsters."""
from __future__ import annotations
from data_types import *
from dataclasses import dataclass, field
from typing import List, Dict, Tuple
import random


@dataclass
class MonsterTemplate:
    id: int
    name: str
    level: int
    base_stats: Stats
    weaknesses: List  # List of WeaponType | Element
    shield_points: int
    skill_ids: List[int]
    ai_type: AIType
    exp_reward: int
    tier: str  # early / mid / high / boss


def build_monster_db() -> Dict[int, MonsterTemplate]:
    db = {}

    def m(id, name, lv, hp, mp, pa, ma, pd, md, spd, eva, acc, crit,
          weak, sp, skills, ai, exp, tier):
        db[id] = MonsterTemplate(
            id, name, lv,
            Stats(hp, mp, pa, ma, pd, md, spd, eva, acc, crit),
            weak, sp, skills, ai, exp, tier
        )

    W = WeaponType; E = Element; AI = AIType

    # ══════════════════════════════════════════════════════════
    # EARLY MONSTERS (lv 1-10)
    # ══════════════════════════════════════════════════════════
    m(1,"Goblin",           2, 80,  10, 12, 4,  8,  5,  8,  0.08,0.85,0.05,
      [W.SWORD,E.FIRE,W.BOW],         3, [1,2],    AI.BALANCED, 15, "early")
    m(2,"Goblin Archer",    3, 70,  10, 10, 4,  6,  4,  10, 0.12,0.88,0.08,
      [W.SWORD,E.FIRE],               2, [1,20],   AI.AGGRESSIVE, 20, "early")
    m(3,"Goblin Shaman",    4, 65,  30, 8,  14, 5,  10, 7,  0.05,0.82,0.04,
      [W.SWORD,E.LIGHT],              2, [50,76],  AI.SUPPORT, 25, "early")
    m(4,"Wolf",             2, 90,  0,  14, 0,  6,  4,  14, 0.15,0.9, 0.1,
      [E.FIRE,W.SPEAR],               2, [1,15],   AI.AGGRESSIVE, 18, "early")
    m(5,"Dire Wolf",        5, 150, 0,  20, 0,  10, 6,  16, 0.18,0.88,0.15,
      [E.FIRE,W.SPEAR,W.AXE],         3, [1,2,15], AI.AGGRESSIVE, 40, "early")
    m(6,"Slime",            1, 60,  0,  6,  0,  12, 8,  4,  0.0, 0.8, 0.02,
      [E.FIRE,W.SWORD],               2, [1],      AI.RANDOM, 10, "early")
    m(7,"Fire Slime",       4, 80,  10, 8,  10, 6,  14, 5,  0.0, 0.8, 0.03,
      [E.ICE,W.SPEAR],                2, [50,51],  AI.BALANCED, 30, "early")
    m(8,"Ice Slime",        4, 80,  10, 8,  10, 6,  8,  5,  0.0, 0.8, 0.03,
      [E.FIRE,W.AXE],                 2, [55,56],  AI.BALANCED, 30, "early")
    m(9,"Bat",              2, 55,  0,  8,  0,  4,  4,  12, 0.20,0.85,0.08,
      [E.LIGHTNING,W.BOW],            2, [1,15],   AI.AGGRESSIVE, 12, "early")
    m(10,"Giant Bat",       5, 120, 0,  18, 0,  8,  6,  15, 0.22,0.87,0.12,
      [E.LIGHTNING,W.BOW,W.AXE],      3, [1,2,3],  AI.AGGRESSIVE, 45, "early")

    # ══════════════════════════════════════════════════════════
    # MID MONSTERS (lv 8-20)
    # ══════════════════════════════════════════════════════════
    m(11,"Orc",             8, 280, 10, 28, 5,  18, 10, 8,  0.05,0.82,0.08,
      [W.SPEAR,E.FIRE],               3, [2,11,30], AI.AGGRESSIVE, 80, "mid")
    m(12,"Orc Warrior",     10,350, 15, 35, 8,  22, 12, 9,  0.07,0.83,0.1,
      [W.SPEAR,E.FIRE,E.LIGHTNING],   3, [2,11,13], AI.AGGRESSIVE, 110, "mid")
    m(13,"Orc Shaman",      10,250, 60, 20, 25, 14, 18, 7,  0.05,0.82,0.06,
      [W.SWORD,E.LIGHT],              2, [50,77,76],AI.SUPPORT, 100, "mid")
    m(14,"Lizardman",       9, 310, 20, 30, 10, 16, 14, 12, 0.1, 0.85,0.1,
      [E.ICE,W.AXE],                  3, [1,3,29],  AI.BALANCED, 90, "mid")
    m(15,"Harpy",           10,260, 20, 25, 15, 12, 16, 18, 0.18,0.87,0.12,
      [E.LIGHTNING,W.BOW,W.AXE],      2, [1,65,66], AI.AGGRESSIVE, 105, "mid")
    m(16,"Skeleton",        8, 240, 0,  24, 0,  10, 18, 10, 0.08,0.85,0.12,
      [E.LIGHT,W.AXE,W.SWORD],        3, [1,2,3],   AI.AGGRESSIVE, 75, "mid")
    m(17,"Zombie",          9, 320, 0,  22, 10, 14, 12, 5,  0.05,0.78,0.05,
      [E.FIRE,E.LIGHT,W.AXE],         2, [1,16],    AI.AGGRESSIVE, 85, "mid")
    m(18,"Ghoul",           11,380, 20, 28, 14, 16, 16, 8,  0.1, 0.82,0.1,
      [E.FIRE,E.LIGHT],               3, [1,2,90],  AI.AGGRESSIVE, 120, "mid")
    m(19,"Wraith",          12,300, 50, 18, 22, 8,  24, 10, 0.15,0.85,0.1,
      [E.LIGHT,W.SWORD],              3, [75,77,79],AI.TACTICAL, 130, "mid")
    m(20,"Dark Mage",       14,320, 80, 15, 35, 10, 28, 9,  0.08,0.87,0.08,
      [E.LIGHT,W.SWORD],              2, [75,77,79,76],AI.TACTICAL, 160, "mid")
    m(21,"Stone Golem",     12,500, 0,  35, 0,  30, 25, 4,  0.0, 0.75,0.05,
      [E.LIGHTNING,W.AXE],            4, [2,70,71], AI.DEFENSIVE, 150, "mid")
    m(22,"Earth Golem",     15,600, 0,  38, 0,  32, 28, 3,  0.0, 0.75,0.05,
      [E.LIGHTNING,W.AXE,E.ICE],      4, [2,70,72], AI.DEFENSIVE, 200, "mid")
    m(23,"Bandit",          7, 220, 10, 22, 8,  12, 10, 14, 0.15,0.88,0.12,
      [E.LIGHT,W.SPEAR],              2, [1,15,16], AI.BALANCED, 70, "mid")
    m(24,"Bandit Leader",   11,350, 20, 30, 12, 18, 14, 16, 0.18,0.88,0.15,
      [E.LIGHT,W.SPEAR,E.FIRE],       3, [1,2,17],  AI.TACTICAL, 140, "mid")
    m(25,"Dark Knight",     15,480, 30, 38, 15, 26, 20, 11, 0.1, 0.85,0.12,
      [E.LIGHT,W.SPEAR],              4, [8,9,75,77],AI.TACTICAL, 210, "mid")

    # ══════════════════════════════════════════════════════════
    # HIGH MONSTERS (lv 18-35)
    # ══════════════════════════════════════════════════════════
    m(26,"Demon",           18,700, 80, 48, 42, 28, 32, 14, 0.12,0.87,0.15,
      [E.LIGHT,W.SWORD],              4, [75,77,79,91],AI.AGGRESSIVE, 350, "high")
    m(27,"High Demon",      22,900, 100,55, 50, 32, 38, 15, 0.15,0.87,0.18,
      [E.LIGHT,W.SPEAR],              4, [79,77,91,99],AI.AGGRESSIVE, 500, "high")
    m(28,"Vampire",         20,650, 60, 42, 38, 22, 30, 16, 0.2, 0.88,0.2,
      [E.LIGHT,W.AXE],                3, [90,15,75,77],AI.TACTICAL, 420, "high")
    m(29,"Lich",            25,800, 120,25, 65, 18, 55, 10, 0.1, 0.88,0.1,
      [E.LIGHT,W.SWORD,E.FIRE],       3, [79,99,77,76],AI.TACTICAL, 600, "high")
    m(30,"Basilisk",        20,750, 0,  45, 0,  38, 34, 9,  0.05,0.82,0.08,
      [E.FIRE,W.AXE,E.LIGHTNING],     4, [1,2,70,72], AI.DEFENSIVE, 380, "high")
    m(31,"Chimera",         22,850, 40, 50, 35, 30, 30, 12, 0.12,0.85,0.15,
      [E.ICE,W.SPEAR,W.BOW],          4, [1,2,50,65], AI.BALANCED, 450, "high")
    m(32,"Hydra",           24,1000,30, 48, 20, 35, 28, 8,  0.08,0.82,0.1,
      [E.LIGHTNING,W.SWORD,E.FIRE],   5, [1,2,3,90],  AI.AGGRESSIVE, 520, "high")
    m(33,"Medusa",          21,700, 60, 40, 45, 25, 35, 14, 0.15,0.88,0.15,
      [E.FIRE,W.BOW],                 3, [76,77,90,99],AI.TACTICAL, 430, "high")
    m(34,"Manticore",       23,880, 20, 52, 15, 32, 26, 16, 0.18,0.87,0.18,
      [E.ICE,W.SPEAR,W.SWORD],        4, [1,2,20,22], AI.AGGRESSIVE, 480, "high")
    m(35,"Golem King",      26,1200,0,  58, 0,  45, 40, 5,  0.0, 0.78,0.05,
      [E.LIGHTNING,W.AXE,E.ICE],      5, [2,70,72,92],AI.DEFENSIVE, 650, "high")

    # ══════════════════════════════════════════════════════════
    # DRAGONS (lv 30-50)
    # ══════════════════════════════════════════════════════════
    m(36,"Dragon",          30,2000,80, 70, 60, 50, 55, 16, 0.12,0.87,0.15,
      [E.ICE,W.SPEAR],                5, [1,2,50,51,52],AI.TACTICAL, 1000, "high")
    m(37,"Fire Dragon",     32,2200,80, 72, 65, 52, 50, 18, 0.15,0.87,0.18,
      [E.ICE,W.SPEAR,W.AXE],          5, [50,51,52,53,54],AI.AGGRESSIVE, 1200, "high")
    m(38,"Ice Dragon",      32,2200,80, 65, 70, 50, 58, 14, 0.1, 0.85,0.15,
      [E.FIRE,W.SPEAR,W.AXE],         5, [55,56,57,58,59],AI.AGGRESSIVE, 1200, "high")
    m(39,"Thunder Dragon",  33,2300,80, 68, 72, 48, 60, 19, 0.15,0.88,0.18,
      [E.EARTH,W.SPEAR,W.BOW],        5, [60,61,62,63,64],AI.AGGRESSIVE, 1300, "high")
    m(40,"Earth Dragon",    33,2400,60, 75, 55, 58, 50, 12, 0.08,0.83,0.1,
      [E.LIGHTNING,W.AXE,W.SPEAR],    5, [70,71,72,73,74],AI.DEFENSIVE, 1300, "high")
    m(41,"Shadow Dragon",   35,2500,100,70, 75, 50, 60, 18, 0.2, 0.87,0.2,
      [E.LIGHT,W.SPEAR],              5, [75,77,79,91,99],AI.TACTICAL, 1500, "high")
    m(42,"Holy Dragon",     38,2800,100,65, 80, 55, 65, 16, 0.12,0.88,0.15,
      [E.DARK,W.AXE],                 5, [80,82,84,47,44],AI.TACTICAL, 1800, "high")

    # ══════════════════════════════════════════════════════════
    # ELITE / BOSS-TYPE (lv 40-60)
    # ══════════════════════════════════════════════════════════
    m(43,"Arch Demon",      40,4000,150,85, 90, 60, 75, 18, 0.18,0.88,0.22,
      [E.LIGHT,W.SPEAR,W.SWORD],      5, [79,77,91,99,14],AI.TACTICAL, 2500, "boss")
    m(44,"Fallen Angel",    42,3800,150,80, 95, 55, 80, 20, 0.2, 0.9, 0.2,
      [E.DARK,W.BOW,W.SWORD],         5, [80,84,47,46,82],AI.TACTICAL, 2600, "boss")
    m(45,"Ancient Golem",   45,5000,0,  90, 0,  70, 65, 6,  0.0, 0.8, 0.05,
      [E.LIGHTNING,W.AXE],            5, [2,72,74,92,70],AI.DEFENSIVE, 3000, "boss")
    m(46,"Chaos Dragon",    50,6000,200,100,100,75, 85, 20, 0.25,0.88,0.25,
      [E.LIGHT,E.ICE,W.SPEAR],        5, [54,59,64,74,79,14],AI.TACTICAL, 4000, "boss")
    m(47,"Death Knight",    45,4500,100,95, 70, 65, 72, 16, 0.15,0.88,0.18,
      [E.LIGHT,W.SPEAR,W.SWORD],      5, [8,9,75,79,90,99],AI.TACTICAL, 3200, "boss")
    m(48,"Storm Titan",     48,5500,150,80, 105,65, 80, 18, 0.18,0.88,0.2,
      [E.EARTH,W.AXE,W.BOW],          5, [60,62,63,64,65],AI.AGGRESSIVE, 3800, "boss")
    m(49,"Abyssal Horror",  52,7000,200,95, 110,70, 90, 14, 0.15,0.87,0.2,
      [E.LIGHT,W.SPEAR,W.SWORD],      5, [79,99,77,76,19,14],AI.TACTICAL, 5000, "boss")
    m(50,"World Eater",     60,15000,300,120,130,90, 110,20, 0.3, 0.88,0.3,
      [E.LIGHT,E.ICE,W.SPEAR,W.SWORD],5, [14,54,59,64,74,79,100,34],AI.TACTICAL, 10000,"boss")

    return db

MONSTER_DB: Dict[int, MonsterTemplate] = build_monster_db()

# Difficulty tiers for battle selection
EARLY_MONSTERS = [id for id,m in MONSTER_DB.items() if m.tier == "early"]
MID_MONSTERS   = [id for id,m in MONSTER_DB.items() if m.tier == "mid"]
HIGH_MONSTERS  = [id for id,m in MONSTER_DB.items() if m.tier == "high"]
BOSS_MONSTERS  = [id for id,m in MONSTER_DB.items() if m.tier == "boss"]


def get_encounter(battle_number: int) -> List[MonsterTemplate]:
    """Return a list of monsters for the given battle number."""
    import copy
    if battle_number <= 5:
        pool, count = EARLY_MONSTERS, random.randint(1, 2)
    elif battle_number <= 15:
        pool = EARLY_MONSTERS + MID_MONSTERS[:5]
        count = random.randint(1, 3)
    elif battle_number <= 30:
        pool, count = MID_MONSTERS, random.randint(1, 3)
    elif battle_number <= 50:
        pool = MID_MONSTERS[5:] + HIGH_MONSTERS[:10]
        count = random.randint(1, 3)
    elif battle_number <= 75:
        pool, count = HIGH_MONSTERS, random.randint(1, 3)
    else:
        pool = HIGH_MONSTERS + BOSS_MONSTERS
        count = random.randint(1, 2)

    chosen = random.choices(pool, k=count)
    # Scale stats slightly based on battle number
    result = []
    for mid in chosen:
        t = copy.deepcopy(MONSTER_DB[mid])
        # Scale-up beyond base level
        extra_levels = max(0, (battle_number // 5) - t.level // 5)
        scale = 1.0 + extra_levels * 0.05
        t.base_stats.hp   = int(t.base_stats.hp   * scale)
        t.base_stats.patk = int(t.base_stats.patk * scale)
        t.base_stats.matk = int(t.base_stats.matk * scale)
        t.base_stats.pdef = int(t.base_stats.pdef * scale)
        t.base_stats.mdef = int(t.base_stats.mdef * scale)
        result.append(t)
    return result