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:
@@ -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"
|
||||
Reference in New Issue
Block a user