From 3f65e769cf0af46ac853faf8e8e52d2818a99552 Mon Sep 17 00:00:00 2001 From: ctan Date: Mon, 31 Aug 2026 11:15:37 +0200 Subject: [PATCH] feat(data): per-level spell slots for 8 PCs --- data/pcs/esha.json | 1 + data/pcs/harvie.json | 1 + data/pcs/ierlieth.json | 1 + data/pcs/jeanne.json | 1 + data/pcs/misty.json | 4 ++++ data/pcs/nairda.json | 4 ++++ data/pcs/oni.json | 4 ++++ data/pcs/tammara.json | 4 ++++ src/pf1e_simulator/combat.py | 31 ++++++++++++++++++------- src/pf1e_simulator/loaders/combatant.py | 4 ++++ src/pf1e_simulator/models.py | 1 + tests/test_spells.py | 27 ++++++++++++++++++++- 12 files changed, 74 insertions(+), 9 deletions(-) diff --git a/data/pcs/esha.json b/data/pcs/esha.json index affc986..97734ef 100644 --- a/data/pcs/esha.json +++ b/data/pcs/esha.json @@ -25,6 +25,7 @@ "will": 5 }, "casting_ability": "int", + "spell_slots": {}, "resources": {}, "attacks": [ { diff --git a/data/pcs/harvie.json b/data/pcs/harvie.json index 8fdfd9b..d7b1eff 100644 --- a/data/pcs/harvie.json +++ b/data/pcs/harvie.json @@ -69,6 +69,7 @@ ], "spells": [], "casting_ability": "wis", + "spell_slots": {}, "resources": {}, "metamagic_feats": [], "skills": {} diff --git a/data/pcs/ierlieth.json b/data/pcs/ierlieth.json index 0b3814e..5ad6e13 100644 --- a/data/pcs/ierlieth.json +++ b/data/pcs/ierlieth.json @@ -52,6 +52,7 @@ ], "spells": [], "casting_ability": "wis", + "spell_slots": {}, "resources": {}, "metamagic_feats": [], "skills": {} diff --git a/data/pcs/jeanne.json b/data/pcs/jeanne.json index a2ad697..8627095 100644 --- a/data/pcs/jeanne.json +++ b/data/pcs/jeanne.json @@ -54,6 +54,7 @@ ], "spells": [], "casting_ability": "wis", + "spell_slots": {}, "resources": {}, "metamagic_feats": [], "skills": {} diff --git a/data/pcs/misty.json b/data/pcs/misty.json index 064f219..d8fdf01 100644 --- a/data/pcs/misty.json +++ b/data/pcs/misty.json @@ -25,6 +25,10 @@ "will": 3 }, "casting_ability": "cha", + "spell_slots": { + "1": 4, + "2": 3 + }, "resources": { "bardic_rounds": 12 }, diff --git a/data/pcs/nairda.json b/data/pcs/nairda.json index c24eec9..ea9b506 100644 --- a/data/pcs/nairda.json +++ b/data/pcs/nairda.json @@ -25,6 +25,10 @@ "will": 7 }, "casting_ability": "int", + "spell_slots": { + "1": 4, + "2": 3 + }, "resources": { "hexes": 5 }, diff --git a/data/pcs/oni.json b/data/pcs/oni.json index ce6fb2d..dd3c4fe 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -25,6 +25,10 @@ "will": 9 }, "casting_ability": "wis", + "spell_slots": { + "1": 4, + "2": 3 + }, "resources": { "channel_energy": 6 }, diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index fcd030e..c5154be 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -25,6 +25,10 @@ "will": 8 }, "casting_ability": "wis", + "spell_slots": { + "1": 4, + "2": 3 + }, "resources": { "grit": 3 }, diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index e0a96d7..9675404 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 2eba2a7..e01aca9 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -142,6 +142,10 @@ def load_combatant(path: Path) -> Combatant: # spells stays as list[str] — Pydantic validates it directly + raw_slots = data.pop("spell_slots", None) + if isinstance(raw_slots, dict): + validate_payload["spell_slots"] = {int(k): v for k, v in raw_slots.items()} + try: return Combatant.model_validate(validate_payload) except ValidationError as exc: diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 5aa30d4..74a4ad9 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -286,6 +286,7 @@ class Combatant(_FrozenModel): cmb: int | None = None # Combat Maneuver Bonus cmd: int | None = None # Combat Maneuver Defense casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha + spell_slots: dict[int, int] = Field(default_factory=dict) # max slots per spell level resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell spells: list[str] = Field(default_factory=list) diff --git a/tests/test_spells.py b/tests/test_spells.py index 072c509..5341828 100644 --- a/tests/test_spells.py +++ b/tests/test_spells.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path -from pf1e_simulator.combat import Action, CombatantState, CombatEngine +from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _choose_spell_action from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType @@ -436,3 +436,28 @@ class TestSummonMonster: assert summoned[0].hp == 15 # Check transcript mentions summon assert any("summon" in line.lower() for line in engine._transcript) + + +class TestSpellSlots: + """Given: a caster with per-level spell_slots + When: a spell of a level is cast + Then: that level's slot count decrements and exhausted levels block further casts.""" + + def test_slot_depense(self) -> None: + caster = _make_caster().model_copy(update={"spell_slots": {1: 2, 2: 1}}) + target = _make_target(hp_max=100) + engine, c, t = _make_engine(caster, target, [3]) + engine._cast_spell(c, t, "Magic Missile") # level 1 + assert c.spell_slots_left[1] == 1 + assert c.spell_slots_left[2] == 1 + + def test_slots_epuises(self) -> None: + caster = _make_caster().model_copy( + update={"spell_slots": {1: 1}, "spells": ["Magic Missile"]} + ) + target = _make_target(hp_max=100) + engine, c, t = _make_engine(caster, target, [3]) + assert _choose_spell_action(engine, c, t) is not None + engine._cast_spell(c, t, "Magic Missile") # spends the only level-1 slot + assert c.spell_slots_left[1] == 0 + assert _choose_spell_action(engine, c, t) is None