f9e51e377a
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.
201 lines
6.9 KiB
Python
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"
|