fix(loaders): coerce combatant level with Pydantic-consistent rules
Feat resolution read level with an isinstance(int) check and fell back to 1, while Pydantic accepts numeric strings and lossless floats: a level-4 sheet authored as "4" got Toughness for level 1. _parse_level now runs the value through TypeAdapter(int) (same lax coercion as the model) and raises CombatantLoadError for values the model would reject.
This commit is contained in:
@@ -19,7 +19,7 @@ import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -187,6 +187,25 @@ class CombatantLoadError(Exception):
|
||||
self.reason = reason
|
||||
|
||||
|
||||
_LEVEL_ADAPTER: TypeAdapter[int] = TypeAdapter(int)
|
||||
|
||||
|
||||
def _parse_level(raw_level: Json, path: Path) -> int:
|
||||
"""Coerce the JSON ``level`` with the same lax rules Pydantic applies.
|
||||
|
||||
Feat resolution (Toughness) needs the level before model validation;
|
||||
reading it with a bare ``isinstance(int)`` check disagreed with
|
||||
Pydantic, which also accepts numeric strings ("4") and lossless
|
||||
floats (4.0) -- the feat then silently used level 1 on a level-4
|
||||
sheet. A value Pydantic would reject raises CombatantLoadError here.
|
||||
"""
|
||||
try:
|
||||
return _LEVEL_ADAPTER.validate_python(raw_level)
|
||||
except ValidationError as exc:
|
||||
msg = f"level must be an integer, got {raw_level!r}"
|
||||
raise CombatantLoadError(path, msg) from exc
|
||||
|
||||
|
||||
def _resolve_features(
|
||||
raw_features: list[Json], level: int, path: Path
|
||||
) -> list[AbilitySpec]:
|
||||
@@ -301,8 +320,7 @@ def load_combatant(path: Path) -> Combatant:
|
||||
# Resolve features (list of strings → list of AbilitySpec). A present
|
||||
# but non-list value (e.g. a bare string) used to be popped and dropped
|
||||
# silently, so the combatant lost every feat; it is now a data error.
|
||||
raw_level = data.get("level")
|
||||
level = raw_level if isinstance(raw_level, int) and not isinstance(raw_level, bool) else 1
|
||||
level = _parse_level(data.get("level", 1), path)
|
||||
had_features = "features" in data
|
||||
raw_features = data.pop("features", None)
|
||||
if had_features and not isinstance(raw_features, list):
|
||||
|
||||
Reference in New Issue
Block a user