feat(data): per-level spell slots for 8 PCs
This commit is contained in:
@@ -25,6 +25,7 @@
|
|||||||
"will": 5
|
"will": 5
|
||||||
},
|
},
|
||||||
"casting_ability": "int",
|
"casting_ability": "int",
|
||||||
|
"spell_slots": {},
|
||||||
"resources": {},
|
"resources": {},
|
||||||
"attacks": [
|
"attacks": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
],
|
],
|
||||||
"spells": [],
|
"spells": [],
|
||||||
"casting_ability": "wis",
|
"casting_ability": "wis",
|
||||||
|
"spell_slots": {},
|
||||||
"resources": {},
|
"resources": {},
|
||||||
"metamagic_feats": [],
|
"metamagic_feats": [],
|
||||||
"skills": {}
|
"skills": {}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
],
|
],
|
||||||
"spells": [],
|
"spells": [],
|
||||||
"casting_ability": "wis",
|
"casting_ability": "wis",
|
||||||
|
"spell_slots": {},
|
||||||
"resources": {},
|
"resources": {},
|
||||||
"metamagic_feats": [],
|
"metamagic_feats": [],
|
||||||
"skills": {}
|
"skills": {}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
],
|
],
|
||||||
"spells": [],
|
"spells": [],
|
||||||
"casting_ability": "wis",
|
"casting_ability": "wis",
|
||||||
|
"spell_slots": {},
|
||||||
"resources": {},
|
"resources": {},
|
||||||
"metamagic_feats": [],
|
"metamagic_feats": [],
|
||||||
"skills": {}
|
"skills": {}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"will": 3
|
"will": 3
|
||||||
},
|
},
|
||||||
"casting_ability": "cha",
|
"casting_ability": "cha",
|
||||||
|
"spell_slots": {
|
||||||
|
"1": 4,
|
||||||
|
"2": 3
|
||||||
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"bardic_rounds": 12
|
"bardic_rounds": 12
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"will": 7
|
"will": 7
|
||||||
},
|
},
|
||||||
"casting_ability": "int",
|
"casting_ability": "int",
|
||||||
|
"spell_slots": {
|
||||||
|
"1": 4,
|
||||||
|
"2": 3
|
||||||
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"hexes": 5
|
"hexes": 5
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"will": 9
|
"will": 9
|
||||||
},
|
},
|
||||||
"casting_ability": "wis",
|
"casting_ability": "wis",
|
||||||
|
"spell_slots": {
|
||||||
|
"1": 4,
|
||||||
|
"2": 3
|
||||||
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"channel_energy": 6
|
"channel_energy": 6
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"will": 8
|
"will": 8
|
||||||
},
|
},
|
||||||
"casting_ability": "wis",
|
"casting_ability": "wis",
|
||||||
|
"spell_slots": {
|
||||||
|
"1": 4,
|
||||||
|
"2": 3
|
||||||
|
},
|
||||||
"resources": {
|
"resources": {
|
||||||
"grit": 3
|
"grit": 3
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ class CombatantState:
|
|||||||
active_buffs: list[ActiveBuff] = field(default_factory=list)
|
active_buffs: list[ActiveBuff] = field(default_factory=list)
|
||||||
active_conditions: list[ActiveCondition] = field(default_factory=list)
|
active_conditions: list[ActiveCondition] = field(default_factory=list)
|
||||||
resources_left: dict[str, int] = field(default_factory=dict)
|
resources_left: dict[str, int] = field(default_factory=dict)
|
||||||
|
spell_slots_left: dict[int, int] = field(default_factory=dict)
|
||||||
moved_this_turn: bool = False
|
moved_this_turn: bool = False
|
||||||
power_attack: bool = False
|
power_attack: bool = False
|
||||||
grappled_with: str | None = None
|
grappled_with: str | None = None
|
||||||
@@ -201,6 +202,10 @@ class CombatantState:
|
|||||||
summon_duration: int = 0
|
summon_duration: int = 0
|
||||||
summon_caster_id: str | None = None
|
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:
|
def spend_resource(self, name: str, n: int = 1) -> bool:
|
||||||
"""Spend n points of a resource. Returns True if successful, False if insufficient."""
|
"""Spend n points of a resource. Returns True if successful, False if insufficient."""
|
||||||
current = self.resources_left.get(name, 0)
|
current = self.resources_left.get(name, 0)
|
||||||
@@ -209,6 +214,14 @@ class CombatantState:
|
|||||||
self.resources_left[name] = current - n
|
self.resources_left[name] = current - n
|
||||||
return True
|
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
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
return self.hp > 0
|
return self.hp > 0
|
||||||
@@ -1466,6 +1479,7 @@ class CombatEngine:
|
|||||||
# Check for AoO from casting in threatened square (before resolving spell)
|
# Check for AoO from casting in threatened square (before resolving spell)
|
||||||
if self._check_casting_aoo(caster, spell.level):
|
if self._check_casting_aoo(caster, spell.level):
|
||||||
return
|
return
|
||||||
|
caster.spend_spell_slot(spell.level)
|
||||||
# DC = 10 + spell level + caster's casting ability modifier
|
# DC = 10 + spell level + caster's casting ability modifier
|
||||||
ability_name = caster.combatant.casting_ability.lower()
|
ability_name = caster.combatant.casting_ability.lower()
|
||||||
ability_score = getattr(caster.combatant.abilities, f"{ability_name}_score")
|
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
|
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(
|
def _choose_spell_action(
|
||||||
engine: CombatEngine, state: CombatantState, target: CombatantState
|
engine: CombatEngine, state: CombatantState, target: CombatantState
|
||||||
) -> tuple[Action, ...] | None:
|
) -> tuple[Action, ...] | None:
|
||||||
@@ -2160,28 +2180,23 @@ def _choose_spell_action(
|
|||||||
if not spells_known:
|
if not spells_known:
|
||||||
return None
|
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
|
# 1. Heal spell if ally below 50% HP
|
||||||
heal_spell = _find_heal_spell(engine, spells_known)
|
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)
|
ally = _find_ally_needing_heal(engine, caster)
|
||||||
if ally and ally.hp < ally.combatant.hp_max // 2:
|
if ally and ally.hp < ally.combatant.hp_max // 2:
|
||||||
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
return (Action(kind="cast_spell", target_id=ally.combatant.id, spell_name=heal_spell),)
|
||||||
|
|
||||||
# 2. Offensive spell if enemy in range
|
# 2. Offensive spell if enemy in range
|
||||||
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
offensive_spell = _find_offensive_spell(engine, spells_known, caster, target)
|
||||||
if offensive_spell:
|
if offensive_spell and _has_slot(engine, caster, offensive_spell):
|
||||||
return (
|
return (
|
||||||
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
Action(kind="cast_spell", target_id=target.combatant.id, spell_name=offensive_spell),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Buff spell
|
# 3. Buff spell
|
||||||
buff_spell = _find_buff_spell(engine, spells_known)
|
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 (Action(kind="cast_spell", target_id=caster.combatant.id, spell_name=buff_spell),)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ def load_combatant(path: Path) -> Combatant:
|
|||||||
|
|
||||||
# spells stays as list[str] — Pydantic validates it directly
|
# 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:
|
try:
|
||||||
return Combatant.model_validate(validate_payload)
|
return Combatant.model_validate(validate_payload)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ class Combatant(_FrozenModel):
|
|||||||
cmb: int | None = None # Combat Maneuver Bonus
|
cmb: int | None = None # Combat Maneuver Bonus
|
||||||
cmd: int | None = None # Combat Maneuver Defense
|
cmd: int | None = None # Combat Maneuver Defense
|
||||||
casting_ability: str = "wis" # ability used for spell DCs: str/dex/con/int/wis/cha
|
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
|
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
|
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
|
||||||
spells: list[str] = Field(default_factory=list)
|
spells: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
+26
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
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.dice import parse_dice
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import MapSpec, TerrainType
|
from pf1e_simulator.map import MapSpec, TerrainType
|
||||||
@@ -436,3 +436,28 @@ class TestSummonMonster:
|
|||||||
assert summoned[0].hp == 15
|
assert summoned[0].hp == 15
|
||||||
# Check transcript mentions summon
|
# Check transcript mentions summon
|
||||||
assert any("summon" in line.lower() for line in engine._transcript)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user