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