Files
ctan f9e51e377a 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.
2026-09-20 11:49:04 +02:00

201 lines
6.9 KiB
Python

"""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"