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
+2
View File
@@ -1681,6 +1681,8 @@ Règles modélisées :
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
alignment (SRD « Alignment » : code à deux axes parmi LG/NG/CG/LN/N/CN/LE/NE/CE,
défaut "N"),
dr {amount, bypass} ou None (C1), creature_type (C3),
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
+2 -1
View File
@@ -1,5 +1,6 @@
{
"name": "Celestial Wolf",
"alignment": "NG",
"level": 2,
"size": "Medium",
"abilities": {
@@ -56,7 +57,7 @@
"electricity": 5
},
"spell_resistance": 6,
"notes": "SRD Bestiary wolf (2d8+4 hp 13, AC 14/touch 12/flat 12, Fort +5, Will +1, bite +2 1d6+1 plus trip, Str 13 Dex 15 Con 15) + celestial template. Encoded from the template: DR 5/magic, energy resistance acid/cold/electricity 5, SR 6 (CR 1 + 5). Not encodable in this model: smite evil 2/day (+2 damage vs evil), the bite's free trip on hit (the engine has trip only as a standard-action maneuver, no auto-trip attack qualifier), and the weapon model has no magic property so no equipment bypasses the DR 5/magic (correct for mundane weapons). Summoning scaling (energy resistance +2 and SR +5 per summon spell level above 2nd) is not modeled; this sheet is the summon monster II baseline.",
"notes": "SRD Bestiary wolf (2d8+4 hp 13, AC 14/touch 12/flat 12, Fort +5, Will +1, bite +2 1d6+1 plus trip, Str 13 Dex 15 Con 15) + celestial template. Encoded from the template: DR 5/magic, energy resistance acid/cold/electricity 5, SR 6 (CR 1 + 5). Not encodable in this model: smite evil 2/day (+2 damage vs evil), the bite's free trip on hit (the engine has trip only as a standard-action maneuver, no auto-trip attack qualifier), and the weapon model has no magic property so no equipment bypasses the DR 5/magic (correct for mundane weapons). Summoning scaling (energy resistance +2 and SR +5 per summon spell level above 2nd) is not modeled; this sheet is the summon monster II baseline. Alignment inferred: SRD celestial template = any good; NG chosen for the good axis with neutral law axis",
"source": "summon monster II",
"features": [],
"spells": [],
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Giant Belostomatid",
"alignment": "N",
"level": 3,
"size": "Large",
"cr": "3",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Goblin",
"alignment": "NE",
"level": 1,
"size": "Small",
"cr": "1/3",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Lich",
"alignment": "NE",
"level": 11,
"size": "Medium",
"creature_type": "undead",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Orc",
"alignment": "CE",
"level": 1,
"size": "Medium",
"cr": "1/3",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Thrune Agent",
"alignment": "LE",
"level": 8,
"size": "Medium",
"creature_type": "living",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Esha Randu",
"alignment": "LG",
"level": 4,
"size": "Medium",
"abilities": {
+2 -1
View File
@@ -1,5 +1,6 @@
{
"name": "Esha's Mount",
"alignment": "N",
"level": 4,
"size": "Large",
"abilities": {
@@ -44,7 +45,7 @@
"natural": true
}
],
"notes": "Light-horse mount shared with Esha's sheet: hooves are 1d4 bludgeoning (SRD Bestiary horse, light - 2 hooves 1d4), natural attacks. No bite encoded: the SRD light horse has no bite attack and the Foundry sheet lists no mount bite (unlike Jeanne's sheet, which does).",
"notes": "Light-horse mount shared with Esha's sheet: hooves are 1d4 bludgeoning (SRD Bestiary horse, light - 2 hooves 1d4), natural attacks. No bite encoded: the SRD light horse has no bite attack and the Foundry sheet lists no mount bite (unlike Jeanne's sheet, which does). Alignment inferred: SRD animal (horse) is typically neutral; no alignment on the sheet",
"source": "fiches_personnages/esha_sheet.json (mount)",
"features": [
"Combat Reflexes",
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Harvie",
"alignment": "N",
"level": 2,
"size": "Small",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Ierlieth",
"alignment": "CN",
"level": 4,
"size": "Tiny",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Jeanne",
"alignment": "N",
"level": 4,
"size": "Large",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Misty",
"alignment": "NE",
"level": 4,
"size": "Medium",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Nairda Guisenda",
"alignment": "CN",
"level": 4,
"size": "Medium",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Oni Triumvir",
"alignment": "NG",
"level": 4,
"size": "Medium",
"abilities": {
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Tammara Cailean",
"alignment": "CG",
"level": 4,
"size": "Medium",
"abilities": {
+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
+200
View File
@@ -0,0 +1,200 @@
"""Tests for the Combatant alignment field (SRD "Alignment").
Covers:
- The validator accepts exactly the nine PF1e codes and rejects junk.
- The default is "N" (true neutral).
- A JSON file with an ``alignment`` key loads it via ``load_combatant``.
- The observation snapshot exposes the ``alignment`` key.
- The derived axis flags (is_lawful/is_chaotic/is_good/is_evil).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
import pytest
from pydantic import ValidationError
from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import DiceExpr
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import AbilityScores, ACProfile, Combatant, Saves
from pf1e_simulator.rng import ScriptedRng
if TYPE_CHECKING:
from pathlib import Path
_NINE_CODES = ("LG", "NG", "CG", "LN", "N", "CN", "LE", "NE", "CE")
def _combatant_dict(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"id": "dummy",
"name": "Dummy",
"level": 1,
"abilities": AbilityScores(
str_score=10,
dex_score=10,
con_score=10,
int_score=10,
wis_score=10,
cha_score=10,
),
"hp_max": 10,
"ac": ACProfile(total=15, touch=12, flat_footed=13),
"bab": 1,
"initiative_mod": 0,
"speed_land_ft": 30,
"saves": Saves(fort=2, ref=2, will=2),
"attacks": [
{
"id": "dummy:0",
"name": "slam",
"kind": "melee",
"attack_bonus": 1,
"damage": [{"formula": DiceExpr(count=1, sides=6), "types": ["bludgeoning"]}],
}
],
}
base.update(overrides)
return base
def _make_combatant(**overrides: object) -> Combatant:
return Combatant.model_validate(_combatant_dict(**overrides))
class TestAlignmentValidator:
@pytest.mark.parametrize("code", _NINE_CODES)
def test_accepts_the_nine_codes(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert combatant.alignment == code
@pytest.mark.parametrize(
"junk",
["n", "NN", "LGEE", "", "neutral", "lawful good", "TN", "True Neutral"],
)
def test_rejects_junk(self, junk: str) -> None:
with pytest.raises(ValidationError, match="alignment must be one of"):
_make_combatant(alignment=junk)
def test_default_is_true_neutral(self) -> None:
assert _make_combatant().alignment == "N"
class TestAlignmentDerivedFlags:
@pytest.mark.parametrize("code", ["LG", "LN", "LE"])
def test_lawful_axis(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert combatant.is_lawful
assert not combatant.is_chaotic
@pytest.mark.parametrize("code", ["CG", "CN", "CE"])
def test_chaotic_axis(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert combatant.is_chaotic
assert not combatant.is_lawful
@pytest.mark.parametrize("code", ["LG", "NG", "CG"])
def test_good_axis(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert combatant.is_good
assert not combatant.is_evil
@pytest.mark.parametrize("code", ["LE", "NE", "CE"])
def test_evil_axis(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert combatant.is_evil
assert not combatant.is_good
def test_le_is_lawful_and_evil(self) -> None:
combatant = _make_combatant(alignment="LE")
assert combatant.is_lawful
assert combatant.is_evil
assert not combatant.is_chaotic
assert not combatant.is_good
def test_ng_is_good_only(self) -> None:
combatant = _make_combatant(alignment="NG")
assert combatant.is_good
assert not combatant.is_lawful
assert not combatant.is_chaotic
assert not combatant.is_evil
@pytest.mark.parametrize("code", ["LN", "N", "CN"])
def test_neutral_on_good_axis(self, code: str) -> None:
combatant = _make_combatant(alignment=code)
assert not combatant.is_good
assert not combatant.is_evil
def test_n_is_neutral_on_both_axes(self) -> None:
combatant = _make_combatant(alignment="N")
assert not combatant.is_lawful
assert not combatant.is_chaotic
assert not combatant.is_good
assert not combatant.is_evil
def _json_payload(**overrides: object) -> dict[str, object]:
payload = _combatant_dict(**overrides)
payload["abilities"] = {
"str_score": 10,
"dex_score": 10,
"con_score": 10,
"int_score": 10,
"wis_score": 10,
"cha_score": 10,
}
payload["ac"] = {"total": 15, "touch": 12, "flat_footed": 13}
payload["saves"] = {"fort": 2, "ref": 2, "will": 2}
payload["attacks"] = [
{
"id": "dummy:0",
"name": "slam",
"kind": "melee",
"attack_bonus": 1,
"damage": [{"formula": "1d6", "types": ["bludgeoning"]}],
}
]
return payload
class TestAlignmentLoader:
def test_json_with_alignment_loads_it(self, tmp_path: Path) -> None:
path = tmp_path / "evil.json"
path.write_text(json.dumps(_json_payload(alignment="CE")), encoding="utf-8")
assert load_combatant(path).alignment == "CE"
def test_json_without_alignment_defaults_to_n(self, tmp_path: Path) -> None:
path = tmp_path / "plain.json"
path.write_text(json.dumps(_json_payload()), encoding="utf-8")
assert load_combatant(path).alignment == "N"
def test_json_with_bad_alignment_raises(self, tmp_path: Path) -> None:
path = tmp_path / "bad.json"
path.write_text(
json.dumps(_json_payload(alignment="CHAOTIC-EVIL")), encoding="utf-8"
)
with pytest.raises(CombatantLoadError, match="alignment"):
load_combatant(path)
class TestAlignmentObservation:
def test_snapshot_exposes_alignment(self) -> None:
hero = _make_combatant(id="hero", name="Hero", alignment="CG")
foe = _make_combatant(id="foe", name="Foe", alignment="LE")
hero_state = CombatantState(combatant=hero, side="players", pos=(0, 0), hp=10)
foe_state = CombatantState(combatant=foe, side="monsters", pos=(0, 3), hp=10)
legend = {".": TerrainType(type="floor", move_cost=1)}
grid = Grid.from_spec(
MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
)
engine = CombatEngine(ScriptedRng([]), grid, [hero_state, foe_state])
obs = engine._build_observation(hero_state).to_dict()
by_id = {c["id"]: c for c in obs["combatants"]}
assert by_id["hero"]["alignment"] == "CG"
assert by_id["foe"]["alignment"] == "LE"