feat(data): per-level spell slots for 8 PCs

This commit is contained in:
ctan
2026-08-31 11:15:37 +02:00
parent 721812f718
commit 3f65e769cf
12 changed files with 74 additions and 9 deletions
+23 -8
View File
@@ -194,6 +194,7 @@ class CombatantState:
active_buffs: list[ActiveBuff] = field(default_factory=list)
active_conditions: list[ActiveCondition] = field(default_factory=list)
resources_left: dict[str, int] = field(default_factory=dict)
spell_slots_left: dict[int, int] = field(default_factory=dict)
moved_this_turn: bool = False
power_attack: bool = False
grappled_with: str | None = None
@@ -201,6 +202,10 @@ class CombatantState:
summon_duration: int = 0
summon_caster_id: str | None = None
def __post_init__(self) -> None:
if not self.spell_slots_left:
self.spell_slots_left = dict(self.combatant.spell_slots)
def spend_resource(self, name: str, n: int = 1) -> bool:
"""Spend n points of a resource. Returns True if successful, False if insufficient."""
current = self.resources_left.get(name, 0)
@@ -209,6 +214,14 @@ class CombatantState:
self.resources_left[name] = current - n
return True
def spend_spell_slot(self, level: int) -> bool:
"""Spend one spell slot of ``level``. Returns True if a slot was spent."""
current = self.spell_slots_left.get(level, 0)
if current <= 0:
return False
self.spell_slots_left[level] = current - 1
return True
@property
def active(self) -> bool:
return self.hp > 0
@@ -1466,6 +1479,7 @@ class CombatEngine:
# Check for AoO from casting in threatened square (before resolving spell)
if self._check_casting_aoo(caster, spell.level):
return
caster.spend_spell_slot(spell.level)
# DC = 10 + spell level + caster's casting ability modifier
ability_name = caster.combatant.casting_ability.lower()
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
@@ -2151,6 +2165,12 @@ def _find_buff_spell(engine: CombatEngine, spells_known: list[str]) -> str | Non
return None
def _has_slot(engine: CombatEngine, caster: CombatantState, spell_name: str) -> bool:
"""True if the caster still has a spell slot of the given spell's level."""
spell = engine.spell_registry.get(spell_name)
return spell is not None and caster.spell_slots_left.get(spell.level, 0) > 0
def _choose_spell_action(
engine: CombatEngine, state: CombatantState, target: CombatantState
) -> tuple[Action, ...] | None:
@@ -2160,28 +2180,23 @@ def _choose_spell_action(
if not spells_known:
return None
# Check spell slots available
slots_left = caster.resources_left.get("spell_slots", 0)
if slots_left <= 0:
return None
# 1. Heal spell if ally below 50% HP
heal_spell = _find_heal_spell(engine, spells_known)
if heal_spell:
if heal_spell and _has_slot(engine, caster, heal_spell):
ally = _find_ally_needing_heal(engine, caster)
if ally and ally.hp < ally.combatant.hp_max // 2:
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
# 2. Offensive spell if enemy in range
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
if offensive_spell:
if offensive_spell and _has_slot(engine, caster, offensive_spell):
return (
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
)
# 3. Buff spell
buff_spell = _find_buff_spell(engine, spells_known)
if buff_spell:
if buff_spell and _has_slot(engine, caster, buff_spell):
return (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
return None