feat(rules): add alignment field to Combatant with axis gating flags

Adds the PF1e two-axis alignment code (LG/NG/CG/LN/N/CN/LE/NE/CE,
default N) to the Combatant model with a strict validator, derived
is_lawful/is_chaotic/is_good/is_evil properties, exposure in the
observation snapshot, and alignment values for all monsters and PCs
sourced from the bestiary KB and the Foundry sheets.
This commit is contained in:
2026-09-20 11:49:04 +02:00
parent 40088e1869
commit f9e51e377a
20 changed files with 265 additions and 2 deletions
+1
View File
@@ -1647,6 +1647,7 @@ class CombatEngine:
"size": state.combatant.size,
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
"aware": state.combatant.aware,
"alignment": state.combatant.alignment,
"creature_type": state.combatant.creature_type,
"bab": state.combatant.bab,
"initiative_mod": state.combatant.initiative_mod,
+2
View File
@@ -237,6 +237,8 @@ def load_combatant(path: Path) -> Combatant:
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
The ``aware`` flag (SRD "Surprise") is a plain boolean of the
``Combatant`` schema (default ``true``), so it parses directly here.
The ``alignment`` code (SRD "Alignment") is a plain string of the schema
(default ``"N"``), so a JSON ``alignment`` key loads directly too.
"""
try:
raw = path.read_text(encoding="utf-8")
+43
View File
@@ -281,6 +281,15 @@ class DamageReduction(_FrozenModel):
bypass: frozenset[str] = frozenset()
_ALIGNMENT_CODES: frozenset[str] = frozenset(
{"LG", "NG", "CG", "LN", "N", "CN", "LE", "NE", "CE"}
)
_LAWFUL_CODES: frozenset[str] = frozenset({"LG", "LN", "LE"})
_CHAOTIC_CODES: frozenset[str] = frozenset({"CG", "CN", "CE"})
_GOOD_CODES: frozenset[str] = frozenset({"LG", "NG", "CG"})
_EVIL_CODES: frozenset[str] = frozenset({"LE", "NE", "CE"})
class Combatant(_FrozenModel):
"""A fully parsed combatant, ready for the engine."""
@@ -320,3 +329,37 @@ class Combatant(_FrozenModel):
# begin." Unaware combatants do not act in the surprise round. Default
# True keeps every existing encounter on a full first round.
aware: bool = True
# PF1e two-axis alignment (SRD "Alignment"): law-good axis codes, one of
# LG/NG/CG/LN/N/CN/LE/NE/CE. Default "N" (true neutral).
alignment: str = "N"
@field_validator("alignment")
@classmethod
def _validate_alignment(cls, value: str) -> str:
if value not in _ALIGNMENT_CODES:
msg = (
f"alignment must be one of {', '.join(sorted(_ALIGNMENT_CODES))}, "
f"got {value!r}"
)
raise ValueError(msg)
return value
@property
def is_lawful(self) -> bool:
"""Law axis: LG/LN/LE."""
return self.alignment in _LAWFUL_CODES
@property
def is_chaotic(self) -> bool:
"""Law axis: CG/CN/CE."""
return self.alignment in _CHAOTIC_CODES
@property
def is_good(self) -> bool:
"""Good axis: LG/NG/CG."""
return self.alignment in _GOOD_CODES
@property
def is_evil(self) -> bool:
"""Evil axis: LE/NE/CE."""
return self.alignment in _EVIL_CODES