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:
@@ -1681,6 +1681,8 @@ Règles modélisées :
|
|||||||
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
en pieds — 0 pour Tiny/Diminutive/Fine, 5 pour Small/Medium, 10 pour
|
||||||
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
Large… ; l'allonge effective d'une attaque reste `max(allonge naturelle,
|
||||||
reach_ft de l'arme)`), aware (SRD « Surprise » : agit au round de surprise),
|
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),
|
dr {amount, bypass} ou None (C1), creature_type (C3),
|
||||||
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
||||||
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Celestial Wolf",
|
"name": "Celestial Wolf",
|
||||||
|
"alignment": "NG",
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -56,7 +57,7 @@
|
|||||||
"electricity": 5
|
"electricity": 5
|
||||||
},
|
},
|
||||||
"spell_resistance": 6,
|
"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",
|
"source": "summon monster II",
|
||||||
"features": [],
|
"features": [],
|
||||||
"spells": [],
|
"spells": [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Giant Belostomatid",
|
"name": "Giant Belostomatid",
|
||||||
|
"alignment": "N",
|
||||||
"level": 3,
|
"level": 3,
|
||||||
"size": "Large",
|
"size": "Large",
|
||||||
"cr": "3",
|
"cr": "3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Goblin",
|
"name": "Goblin",
|
||||||
|
"alignment": "NE",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"size": "Small",
|
"size": "Small",
|
||||||
"cr": "1/3",
|
"cr": "1/3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Lich",
|
"name": "Lich",
|
||||||
|
"alignment": "NE",
|
||||||
"level": 11,
|
"level": 11,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"creature_type": "undead",
|
"creature_type": "undead",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Orc",
|
"name": "Orc",
|
||||||
|
"alignment": "CE",
|
||||||
"level": 1,
|
"level": 1,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"cr": "1/3",
|
"cr": "1/3",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Thrune Agent",
|
"name": "Thrune Agent",
|
||||||
|
"alignment": "LE",
|
||||||
"level": 8,
|
"level": 8,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"creature_type": "living",
|
"creature_type": "living",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Esha Randu",
|
"name": "Esha Randu",
|
||||||
|
"alignment": "LG",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Esha's Mount",
|
"name": "Esha's Mount",
|
||||||
|
"alignment": "N",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Large",
|
"size": "Large",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
"natural": true
|
"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)",
|
"source": "fiches_personnages/esha_sheet.json (mount)",
|
||||||
"features": [
|
"features": [
|
||||||
"Combat Reflexes",
|
"Combat Reflexes",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Harvie",
|
"name": "Harvie",
|
||||||
|
"alignment": "N",
|
||||||
"level": 2,
|
"level": 2,
|
||||||
"size": "Small",
|
"size": "Small",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Ierlieth",
|
"name": "Ierlieth",
|
||||||
|
"alignment": "CN",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Tiny",
|
"size": "Tiny",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Jeanne",
|
"name": "Jeanne",
|
||||||
|
"alignment": "N",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Large",
|
"size": "Large",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Misty",
|
"name": "Misty",
|
||||||
|
"alignment": "NE",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Nairda Guisenda",
|
"name": "Nairda Guisenda",
|
||||||
|
"alignment": "CN",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Oni Triumvir",
|
"name": "Oni Triumvir",
|
||||||
|
"alignment": "NG",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Tammara Cailean",
|
"name": "Tammara Cailean",
|
||||||
|
"alignment": "CG",
|
||||||
"level": 4,
|
"level": 4,
|
||||||
"size": "Medium",
|
"size": "Medium",
|
||||||
"abilities": {
|
"abilities": {
|
||||||
|
|||||||
@@ -1647,6 +1647,7 @@ class CombatEngine:
|
|||||||
"size": state.combatant.size,
|
"size": state.combatant.size,
|
||||||
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
|
"natural_reach_ft": _SIZE_NATURAL_REACH_FT.get(state.combatant.size, 5),
|
||||||
"aware": state.combatant.aware,
|
"aware": state.combatant.aware,
|
||||||
|
"alignment": state.combatant.alignment,
|
||||||
"creature_type": state.combatant.creature_type,
|
"creature_type": state.combatant.creature_type,
|
||||||
"bab": state.combatant.bab,
|
"bab": state.combatant.bab,
|
||||||
"initiative_mod": state.combatant.initiative_mod,
|
"initiative_mod": state.combatant.initiative_mod,
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ def load_combatant(path: Path) -> Combatant:
|
|||||||
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
|
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
|
||||||
The ``aware`` flag (SRD "Surprise") is a plain boolean of the
|
The ``aware`` flag (SRD "Surprise") is a plain boolean of the
|
||||||
``Combatant`` schema (default ``true``), so it parses directly here.
|
``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:
|
try:
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -281,6 +281,15 @@ class DamageReduction(_FrozenModel):
|
|||||||
bypass: frozenset[str] = frozenset()
|
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):
|
class Combatant(_FrozenModel):
|
||||||
"""A fully parsed combatant, ready for the engine."""
|
"""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
|
# begin." Unaware combatants do not act in the surprise round. Default
|
||||||
# True keeps every existing encounter on a full first round.
|
# True keeps every existing encounter on a full first round.
|
||||||
aware: bool = True
|
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
|
||||||
|
|||||||
@@ -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