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