From f9e51e377afb6031949bf66217ac07197a32da40 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 11:49:04 +0200 Subject: [PATCH] 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. --- README.md | 2 + data/monsters/celestial_wolf.json | 3 +- data/monsters/giant_belostomatid.json | 1 + data/monsters/goblin.json | 1 + data/monsters/lich.json | 1 + data/monsters/orc.json | 1 + data/monsters/thrune_agent.json | 1 + data/pcs/esha.json | 1 + data/pcs/esha_mount.json | 3 +- data/pcs/harvie.json | 1 + data/pcs/ierlieth.json | 1 + data/pcs/jeanne.json | 1 + data/pcs/misty.json | 1 + data/pcs/nairda.json | 1 + data/pcs/oni.json | 1 + data/pcs/tammara.json | 1 + src/pf1e_simulator/combat.py | 1 + src/pf1e_simulator/loaders/combatant.py | 2 + src/pf1e_simulator/models.py | 43 +++++ tests/test_alignment.py | 200 ++++++++++++++++++++++++ 20 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 tests/test_alignment.py diff --git a/README.md b/README.md index 2fae697..2545c5d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/data/monsters/celestial_wolf.json b/data/monsters/celestial_wolf.json index f32af64..0f7b5c0 100644 --- a/data/monsters/celestial_wolf.json +++ b/data/monsters/celestial_wolf.json @@ -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": [], diff --git a/data/monsters/giant_belostomatid.json b/data/monsters/giant_belostomatid.json index 74aee93..4c57d98 100644 --- a/data/monsters/giant_belostomatid.json +++ b/data/monsters/giant_belostomatid.json @@ -1,5 +1,6 @@ { "name": "Giant Belostomatid", + "alignment": "N", "level": 3, "size": "Large", "cr": "3", diff --git a/data/monsters/goblin.json b/data/monsters/goblin.json index 1714b1f..977ed83 100644 --- a/data/monsters/goblin.json +++ b/data/monsters/goblin.json @@ -1,5 +1,6 @@ { "name": "Goblin", + "alignment": "NE", "level": 1, "size": "Small", "cr": "1/3", diff --git a/data/monsters/lich.json b/data/monsters/lich.json index 1284b37..30bf9b5 100644 --- a/data/monsters/lich.json +++ b/data/monsters/lich.json @@ -1,5 +1,6 @@ { "name": "Lich", + "alignment": "NE", "level": 11, "size": "Medium", "creature_type": "undead", diff --git a/data/monsters/orc.json b/data/monsters/orc.json index 12e5e7e..9aae290 100644 --- a/data/monsters/orc.json +++ b/data/monsters/orc.json @@ -1,5 +1,6 @@ { "name": "Orc", + "alignment": "CE", "level": 1, "size": "Medium", "cr": "1/3", diff --git a/data/monsters/thrune_agent.json b/data/monsters/thrune_agent.json index 4cd17e5..b0159bd 100644 --- a/data/monsters/thrune_agent.json +++ b/data/monsters/thrune_agent.json @@ -1,5 +1,6 @@ { "name": "Thrune Agent", + "alignment": "LE", "level": 8, "size": "Medium", "creature_type": "living", diff --git a/data/pcs/esha.json b/data/pcs/esha.json index 1e9cd1a..c44574e 100644 --- a/data/pcs/esha.json +++ b/data/pcs/esha.json @@ -1,5 +1,6 @@ { "name": "Esha Randu", + "alignment": "LG", "level": 4, "size": "Medium", "abilities": { diff --git a/data/pcs/esha_mount.json b/data/pcs/esha_mount.json index 996ebd6..6859e2b 100644 --- a/data/pcs/esha_mount.json +++ b/data/pcs/esha_mount.json @@ -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", diff --git a/data/pcs/harvie.json b/data/pcs/harvie.json index 50fee6a..f5ea52f 100644 --- a/data/pcs/harvie.json +++ b/data/pcs/harvie.json @@ -1,5 +1,6 @@ { "name": "Harvie", + "alignment": "N", "level": 2, "size": "Small", "abilities": { diff --git a/data/pcs/ierlieth.json b/data/pcs/ierlieth.json index 7eba5e9..431be76 100644 --- a/data/pcs/ierlieth.json +++ b/data/pcs/ierlieth.json @@ -1,5 +1,6 @@ { "name": "Ierlieth", + "alignment": "CN", "level": 4, "size": "Tiny", "abilities": { diff --git a/data/pcs/jeanne.json b/data/pcs/jeanne.json index 28d01a1..d29fa7b 100644 --- a/data/pcs/jeanne.json +++ b/data/pcs/jeanne.json @@ -1,5 +1,6 @@ { "name": "Jeanne", + "alignment": "N", "level": 4, "size": "Large", "abilities": { diff --git a/data/pcs/misty.json b/data/pcs/misty.json index a03ad2a..39830a8 100644 --- a/data/pcs/misty.json +++ b/data/pcs/misty.json @@ -1,5 +1,6 @@ { "name": "Misty", + "alignment": "NE", "level": 4, "size": "Medium", "abilities": { diff --git a/data/pcs/nairda.json b/data/pcs/nairda.json index ea9b506..d5543f1 100644 --- a/data/pcs/nairda.json +++ b/data/pcs/nairda.json @@ -1,5 +1,6 @@ { "name": "Nairda Guisenda", + "alignment": "CN", "level": 4, "size": "Medium", "abilities": { diff --git a/data/pcs/oni.json b/data/pcs/oni.json index 647cb5d..0757a2d 100644 --- a/data/pcs/oni.json +++ b/data/pcs/oni.json @@ -1,5 +1,6 @@ { "name": "Oni Triumvir", + "alignment": "NG", "level": 4, "size": "Medium", "abilities": { diff --git a/data/pcs/tammara.json b/data/pcs/tammara.json index 8ff6f2a..2272058 100644 --- a/data/pcs/tammara.json +++ b/data/pcs/tammara.json @@ -1,5 +1,6 @@ { "name": "Tammara Cailean", + "alignment": "CG", "level": 4, "size": "Medium", "abilities": { diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index d7ebe11..219bd63 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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, diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 4961c7e..8dec731 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -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") diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index 109e931..6291b7d 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -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 diff --git a/tests/test_alignment.py b/tests/test_alignment.py new file mode 100644 index 0000000..e25f35b --- /dev/null +++ b/tests/test_alignment.py @@ -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"