chore(lint): restore green gate

This commit is contained in:
ctan
2026-08-31 10:57:11 +02:00
parent 4409b0de17
commit 721812f718
9 changed files with 733 additions and 540 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -53,6 +53,7 @@ StatTarget = Literal[
"linguistics", "perception", "perform", "profession", "ride", "linguistics", "perception", "perform", "profession", "ride",
"sense_motive", "sleight_of_hand", "spellcraft", "stealth", "sense_motive", "sleight_of_hand", "spellcraft", "stealth",
"survival", "swim", "use_magic_device", "survival", "swim", "use_magic_device",
"dex_skills",
] ]
_STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"}) _STACKING_TYPES: frozenset[str] = frozenset({"", "dodge", "racial", "trait"})
+5 -46
View File
@@ -26,56 +26,14 @@ if TYPE_CHECKING:
from pf1e_simulator.loaders.foundry import Json from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.abilities import ( from pf1e_simulator.abilities import (
ADOPTED,
ALERTNESS, ALERTNESS,
ARMOR_PROFICIENCY_LIGHT,
ARMOR_PROFICIENCY_MEDIUM,
AXE_TO_GRIND,
BITTER,
BORN_DAMNED,
BREEZE_KISSED,
BREW_POTION,
COMBAT_REFLEXES,
CRAFT_WONDROUS_ITEM,
DAZZLING_DISPLAY,
DODGE, DODGE,
EXTRA_HEX,
ESCHEW_MATERIALS,
EXALTED_SOCIETY,
EXTREMELY_FASHIONABLE,
FATES_FAVORED,
FEY_TOUCHED,
FOCUSED_STUDY,
FORTIFIED_DRINKER,
FRIENDLY_FACE,
GIFTED_ADEPT,
GREAT_FORTITUDE, GREAT_FORTITUDE,
GUNSMITHING,
HARMONIC_SPELL,
IRON_WILL, IRON_WILL,
LIKE_THE_WIND,
LIGHTNING_REFLEXES, LIGHTNING_REFLEXES,
LONELY,
LARGER_THAN_LIFE,
MAW_OR_CLAW,
MOUNTED_COMBAT,
POINT_BLANK_SHOT, POINT_BLANK_SHOT,
POWER_ATTACK, POWER_ATTACK,
PRECISE_SHOT, PRECISE_SHOT,
PREHENSILE_TAIL,
RAPID_RELOAD,
SACRED_SUMS,
SELECTIVE_CHANNELING,
SKILL_FOCUS_PERCEPTION,
SPELL_VULNERABILITY_DIVINATION,
STEALTH_SYNERGY,
SUMMON_GOOD_MONSTER,
THOROUGHBRED,
VAGABOND_CHILD,
WHISPERING_WIND,
VAGABOND_CHILD,
WHISPERING_WIND,
WEAPON_FINESSE,
AbilitySpec, AbilitySpec,
toughness, toughness,
weapon_focus, weapon_focus,
@@ -175,16 +133,17 @@ def load_combatant(path: Path) -> Combatant:
_auto_attack_ids(data, monster_id) _auto_attack_ids(data, monster_id)
# Resolve features (list of strings → list of AbilitySpec) # Resolve features (list of strings → list of AbilitySpec)
level = data.get("level", 1) if isinstance(data.get("level"), int) else 1 raw_level = data.get("level")
level = raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else 1
raw_features = data.pop("features", None) raw_features = data.pop("features", None)
validate_payload: dict[str, object] = dict(data)
if isinstance(raw_features, list): if isinstance(raw_features, list):
# Type ignore: we assign AbilitySpec list into a Json dict before Pydantic validation validate_payload["features"] = _resolve_features(raw_features, level)
data["features"] = _resolve_features(raw_features, level) # type: ignore[assignment]
# spells stays as list[str] — Pydantic validates it directly # spells stays as list[str] — Pydantic validates it directly
try: try:
return Combatant.model_validate(data) return Combatant.model_validate(validate_payload)
except ValidationError as exc: except ValidationError as exc:
raise CombatantLoadError(path, str(exc)) from exc raise CombatantLoadError(path, str(exc)) from exc
+10
View File
@@ -27,6 +27,16 @@ class AbilityScores(_FrozenModel):
wis_score: int wis_score: int
cha_score: int cha_score: int
@property
def str_mod(self) -> int:
"""Strength ability modifier."""
return (self.str_score - 10) // 2
@property
def dex_mod(self) -> int:
"""Dexterity ability modifier."""
return (self.dex_score - 10) // 2
class ACProfile(_FrozenModel): class ACProfile(_FrozenModel):
total: int total: int
+113 -39
View File
@@ -17,10 +17,10 @@ from __future__ import annotations
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal, cast from typing import TYPE_CHECKING, Literal, TypeGuard, get_args
from pf1e_simulator.dice import DiceExpr, parse_dice from pf1e_simulator.dice import DiceExpr, parse_dice
from pf1e_simulator.effects import BonusType, StatModifier from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
if TYPE_CHECKING: if TYPE_CHECKING:
from pf1e_simulator.loaders.foundry import Json from pf1e_simulator.loaders.foundry import Json
@@ -57,7 +57,7 @@ class BuffEffect:
@dataclass(frozen=True) @dataclass(frozen=True)
class AreaEffect: class AreaEffect:
"""Area of effect spell (burst only for Phase 0). """Area of effect spell (burst only for Phase 0).
- shape: only "burst" supported (circle around target point) - shape: only "burst" supported (circle around target point)
- radius_ft: radius in feet (e.g., 20 for fireball) - radius_ft: radius in feet (e.g., 20 for fireball)
- effects: the effects applied to each creature in the area - effects: the effects applied to each creature in the area
@@ -71,7 +71,7 @@ class AreaEffect:
@dataclass(frozen=True) @dataclass(frozen=True)
class SummonEffect: class SummonEffect:
"""Summoning spell effect. """Summoning spell effect.
- creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json") - creature: path to the creature JSON file (e.g., "data/monsters/celestial_wolf.json")
- duration_rounds: how long the summoned creature stays - duration_rounds: how long the summoned creature stays
""" """
@@ -106,64 +106,138 @@ def spell_range_ft(spell: SpellSpec, caster_level: int) -> int:
return 400 + 40 * caster_level return 400 + 40 * caster_level
def _parse_effect(data: dict[str, Json]) -> SpellEffect: def _as_dict(value: Json) -> dict[str, Json]:
effect_type = str(data["type"]) if isinstance(value, dict):
return value
msg = f"expected object, got {value!r}"
raise ValueError(msg)
def _as_list(value: Json) -> list[Json]:
if isinstance(value, list):
return value
msg = f"expected list, got {value!r}"
raise ValueError(msg)
def _as_str(value: Json) -> str:
if isinstance(value, str):
return value
msg = f"expected string, got {value!r}"
raise ValueError(msg)
def _as_int(value: Json) -> int:
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
return int(value)
msg = f"expected integer, got {value!r}"
raise ValueError(msg)
def _as_optional_str(value: Json) -> str | None:
if value is None:
return None
return _as_str(value)
def _is_spell_range(value: str) -> TypeGuard[SpellRange]:
return value in ("personal", "touch", "close", "medium", "long")
def _is_save_type(value: str) -> TypeGuard[SaveType]:
return value in ("fort", "ref", "will", "none")
def _is_stat_target(value: str) -> TypeGuard[StatTarget]:
return value in get_args(StatTarget)
def _is_bonus_type(value: str) -> TypeGuard[BonusType]:
return value in get_args(BonusType)
def _parse_modifier(value: Json) -> StatModifier:
m = _as_dict(value)
target = _as_str(m["target"])
if not _is_stat_target(target):
msg = f"unknown stat target: {target!r}"
raise ValueError(msg)
bonus = _as_str(m.get("bonus_type", ""))
if not _is_bonus_type(bonus):
msg = f"unknown bonus type: {bonus!r}"
raise ValueError(msg)
return StatModifier(
target=target,
value=_as_int(m["value"]),
bonus_type=bonus,
condition=_as_optional_str(m.get("condition")),
weapon_filter=_as_optional_str(m.get("weapon_filter")),
)
def _parse_effect(data: Json) -> SpellEffect:
effect = _as_dict(data)
effect_type = _as_str(effect["type"])
if effect_type == "damage": if effect_type == "damage":
return DamageEffect( return DamageEffect(
formula=parse_dice(str(data["formula"])), formula=parse_dice(_as_str(effect["formula"])),
types=tuple(str(t) for t in data["types"]), # type: ignore[index] types=tuple(_as_str(t) for t in _as_list(effect["types"])),
half_on_save=bool(data.get("half_on_save", True)), half_on_save=bool(effect.get("half_on_save", True)),
) )
if effect_type == "condition": if effect_type == "condition":
return ConditionEffect( return ConditionEffect(
condition_name=str(data["condition_name"]), condition_name=_as_str(effect["condition_name"]),
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] duration_rounds=_as_int(effect["duration_rounds"]),
save_negates=bool(data.get("save_negates", True)), save_negates=bool(effect.get("save_negates", True)),
) )
if effect_type == "heal": if effect_type == "heal":
return HealEffect( return HealEffect(
formula=parse_dice(str(data["formula"])), formula=parse_dice(_as_str(effect["formula"])),
) )
if effect_type == "buff": if effect_type == "buff":
return BuffEffect( return BuffEffect(
modifiers=tuple( modifiers=tuple(_parse_modifier(m) for m in _as_list(effect["modifiers"])),
StatModifier( duration_rounds=_as_int(effect.get("duration_rounds", 0)),
target=str(m["target"]), # type: ignore[index]
value=int(m["value"]), # type: ignore[arg-type]
bonus_type=cast("BonusType", str(m.get("bonus_type", ""))), # type: ignore[union-attr]
condition=m.get("condition"), # type: ignore[assignment]
weapon_filter=m.get("weapon_filter"), # type: ignore[assignment]
)
for m in cast("list[dict[str, Json]]", data["modifiers"])
),
duration_rounds=int(data.get("duration_rounds", 0)), # type: ignore[arg-type]
) )
if effect_type == "area": if effect_type == "area":
return AreaEffect( return AreaEffect(
radius_ft=int(data.get("radius_ft", 20)), # type: ignore[arg-type] radius_ft=_as_int(effect.get("radius_ft", 20)),
effects=tuple(_parse_effect(e) for e in data["effects"]), # type: ignore[index] effects=tuple(_parse_effect(e) for e in _as_list(effect["effects"])),
save_for_half=bool(data.get("save_for_half", True)), save_for_half=bool(effect.get("save_for_half", True)),
) )
if effect_type == "summon": if effect_type == "summon":
return SummonEffect( return SummonEffect(
creature=str(data["creature"]), # type: ignore[index] creature=_as_str(effect["creature"]),
duration_rounds=int(data["duration_rounds"]), # type: ignore[arg-type] duration_rounds=_as_int(effect["duration_rounds"]),
) )
raise ValueError(f"Unknown effect type: {effect_type}") msg = f"Unknown effect type: {effect_type}"
raise ValueError(msg)
def load_spell(path: str | Path) -> SpellSpec: def load_spell(path: str | Path) -> SpellSpec:
data: dict[str, Json] = json.loads(Path(path).read_text()) data = _as_dict(json.loads(Path(path).read_text()))
effects = tuple(_parse_effect(e) for e in data.get("effects", [])) # type: ignore[arg-type] effects = tuple(_parse_effect(e) for e in _as_list(data.get("effects", [])))
range_raw = _as_str(data.get("range", "medium"))
if not _is_spell_range(range_raw):
msg = f"unknown spell range: {range_raw!r}"
raise ValueError(msg)
save_raw = _as_str(data.get("save", "none"))
if not _is_save_type(save_raw):
msg = f"unknown save type: {save_raw!r}"
raise ValueError(msg)
return SpellSpec( return SpellSpec(
name=str(data["name"]), name=_as_str(data["name"]),
level=int(data["level"]), # type: ignore[arg-type] level=_as_int(data["level"]),
school=str(data["school"]), school=_as_str(data["school"]),
range=str(data.get("range", "medium")), # type: ignore[assignment] range=range_raw,
save=str(data.get("save", "none")), # type: ignore[assignment] save=save_raw,
base_dc=int(data.get("base_dc", 10)), # type: ignore[arg-type] base_dc=_as_int(data.get("base_dc", 10)),
effects=effects, effects=effects,
description=str(data.get("description", "")), description=_as_str(data.get("description", "")),
) )
+7 -4
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
from pf1e_simulator.abilities import ( from pf1e_simulator.abilities import (
ALERTNESS, ALERTNESS,
DODGE, DODGE,
@@ -9,6 +11,7 @@ from pf1e_simulator.abilities import (
IRON_WILL, IRON_WILL,
LIGHTNING_REFLEXES, LIGHTNING_REFLEXES,
POINT_BLANK_SHOT, POINT_BLANK_SHOT,
AbilitySpec,
weapon_focus, weapon_focus,
) )
from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.combat import CombatantState, CombatEngine
@@ -30,16 +33,16 @@ from pf1e_simulator.rng import ScriptedRng
def _make_hero( def _make_hero(
cid: str = "hero", cid: str = "hero",
*, *,
features: list[object] | None = None, features: list[AbilitySpec] | None = None,
weapon_name: str = "sword", weapon_name: str = "sword",
weapon_kind: str = "melee", weapon_kind: Literal["melee", "ranged", "touch"] = "melee",
attack_bonus: int = 5, attack_bonus: int = 5,
damage_bonus: int = 2, damage_bonus: int = 2,
) -> Combatant: ) -> Combatant:
attack = AttackSpec( attack = AttackSpec(
id=f"{cid}-w", id=f"{cid}-w",
name=weapon_name, name=weapon_name,
kind=weapon_kind, # type: ignore[arg-type] kind=weapon_kind,
attack_bonus=attack_bonus, attack_bonus=attack_bonus,
damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])], damage=[DamageComponent(formula=parse_dice("1d8"), types=["slashing"])],
damage_bonus=damage_bonus, damage_bonus=damage_bonus,
@@ -61,7 +64,7 @@ def _make_hero(
speed_land_ft=30, speed_land_ft=30,
saves=Saves(fort=5, ref=4, will=3), saves=Saves(fort=5, ref=4, will=3),
attacks=[attack], attacks=[attack],
features=features or [], # type: ignore[arg-type] features=features or [],
) )
+4 -1
View File
@@ -8,6 +8,8 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from pf1e_simulator.loaders.foundry import Json
from pf1e_simulator.abilities import ( from pf1e_simulator.abilities import (
DODGE, DODGE,
IRON_WILL, IRON_WILL,
@@ -79,7 +81,8 @@ class TestResolveFeatures:
assert result[1] is DODGE assert result[1] is DODGE
def test_filters_non_strings(self) -> None: def test_filters_non_strings(self) -> None:
result = _resolve_features(["Iron Will", 42, None, True], 1) # type: ignore[list-item] raw: list[Json] = ["Iron Will", 42, None, True]
result = _resolve_features(raw, 1)
assert len(result) == 1 assert len(result) == 1
def test_empty_list(self) -> None: def test_empty_list(self) -> None:
+3 -3
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from pf1e_simulator.abilities import POWER_ATTACK from pf1e_simulator.abilities import POWER_ATTACK, AbilitySpec
from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.combat import CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
@@ -24,7 +24,7 @@ def _make_fighter(
bab: int = 4, bab: int = 4,
attack_bonus: int = 5, attack_bonus: int = 5,
damage_bonus: int = 2, damage_bonus: int = 2,
features: list[object] | None = None, features: list[AbilitySpec] | None = None,
) -> Combatant: ) -> Combatant:
attack = AttackSpec( attack = AttackSpec(
id=f"{cid}-w", id=f"{cid}-w",
@@ -50,7 +50,7 @@ def _make_fighter(
speed_land_ft=30, speed_land_ft=30,
saves=Saves(fort=6, ref=2, will=2), saves=Saves(fort=6, ref=2, will=2),
attacks=[attack], attacks=[attack],
features=features or [], # type: ignore[arg-type] features=features or [],
) )
+2 -1
View File
@@ -407,7 +407,8 @@ class TestSpellDamageWithDr:
"dr": DamageReduction(amount=5, bypass=frozenset()), "dr": DamageReduction(amount=5, bypass=frozenset()),
}) })
# 2 save rolls (2 targets in area) + 6 damage dice # 2 save rolls (2 targets in area) + 6 damage dice
engine, c, t = _make_engine(caster, target_with_dr, [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]) queue = [2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3]
engine, c, t = _make_engine(caster, target_with_dr, queue)
hp_before = t.hp hp_before = t.hp
engine._cast_spell(c, t, "Fireball") engine._cast_spell(c, t, "Fireball")
# Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage # Save fails (2), 6d6 rolls all 3 = 18 damage, DR 5/- -> 13 damage