fix(loaders): non-list features and blank/non-string entries fail cleanly

features was popped and only re-added when it was a list, so a bare
string (or dict/null) loaded with zero feats - silent data loss. Now a
present non-list features raises CombatantLoadError, and the list
resolution adopts an accept-string-or-error contract: non-string or
blank entries raise instead of being silently dropped.
This commit is contained in:
2026-09-20 17:02:39 +02:00
parent 9656ae820a
commit b8e96294a6
2 changed files with 60 additions and 21 deletions
+26 -9
View File
@@ -187,13 +187,24 @@ class CombatantLoadError(Exception):
self.reason = reason
def _resolve_features(raw_features: list[Json], level: int) -> list[AbilitySpec]:
"""Resolve a list of feat name strings to AbilitySpec objects."""
return [
_resolve_feat(item, level)
for item in raw_features
if isinstance(item, str) and item.strip()
]
def _resolve_features(
raw_features: list[Json], level: int, path: Path
) -> list[AbilitySpec]:
"""Resolve a list of feat name strings to AbilitySpec objects.
Data contract (accept-string-or-error): every entry must be a
non-blank string. A non-string entry (``42``, ``None``, a nested
list) or a blank/whitespace-only string is a data error and raises
``CombatantLoadError`` -- nothing is silently dropped, so a feat can
never disappear from a sheet without the loader failing loudly.
"""
specs: list[AbilitySpec] = []
for item in raw_features:
if not isinstance(item, str) or not item.strip():
msg = f"features entries must be non-empty feat name strings, got {item!r}"
raise CombatantLoadError(path, msg)
specs.append(_resolve_feat(item, level))
return specs
def _merge_resource_grants(
@@ -287,13 +298,19 @@ def load_combatant(path: Path) -> Combatant:
_auto_attack_ids(data, monster_id)
# Resolve features (list of strings → list of AbilitySpec)
# 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
had_features = "features" in data
raw_features = data.pop("features", None)
if had_features and not isinstance(raw_features, list):
msg = f"features must be a list of feat name strings, got {raw_features!r}"
raise CombatantLoadError(path, msg)
validate_payload: dict[str, object] = dict(data)
if isinstance(raw_features, list):
specs = _resolve_features(raw_features, level)
specs = _resolve_features(raw_features, level, path)
validate_payload["features"] = specs
granted = _merge_resource_grants(data.get("resources"), specs)
if granted: