From c04747c63d2156f94cfd5f1c6476201af28c49e8 Mon Sep 17 00:00:00 2001 From: Anthony Sligar Date: Sat, 13 Jun 2026 22:06:29 -0400 Subject: [PATCH 1/2] feat: add validate_power_budget validator vs SRD baselines Adds validate_power_budget(species_data, class_data, subclass_data, level) -> list[str] in dndwright/rules/homebrew_validator.py. Baselines DERIVED from dndwright content: 9 SRD 5.2.1 species (3-5 traits, 3 high-impact max) and 12 SRD classes (cumulative features active by level at 1/3/5/10/20). Three checks: 1. Species trait budget: max 5 total, max 3 high-impact (flight, innate casting, resistance weighted) 2. Class+subclass feature budget per level: max observed across SRD classes at that level 3. Combined split: species+class total must stay within sum of budgets (no Superman+Batman stacking) 17 new tests, 1635 total (was 1618), no regressions. --- src/dndwright/rules/homebrew_validator.py | 241 +++++++++++++++++++++- tests/test_homebrew_validator.py | 231 +++++++++++++++++++++ 2 files changed, 471 insertions(+), 1 deletion(-) diff --git a/src/dndwright/rules/homebrew_validator.py b/src/dndwright/rules/homebrew_validator.py index 9911597..ae6f7fe 100644 --- a/src/dndwright/rules/homebrew_validator.py +++ b/src/dndwright/rules/homebrew_validator.py @@ -307,6 +307,237 @@ def validate_background_homebrew(background_data: dict[str, Any]) -> list[str]: return problems +# --------------------------------------------------------------------------- +# SRD power-budget baselines (derived from dndwright content) +# --------------------------------------------------------------------------- +# Derived 2026-06-13 from: +# content/species.json — 9 SRD 5.2.1 species (3-5 traits, avg 3.6; 0-4 +# high-impact traits, median 1-2) +# content/classes.json — 12 SRD 5.2.1 classes (baseline, subclass, & +# subclass_features arrays; cumulative features active at each level) +# +# Methodology: for each source file, count traits / features per entity, +# record the distribution, set the budget slightly above the SRD maximum +# to allow homebrew creativity while catching obvious over-stacking (e.g. a +# character with 8 species traits + 10 class features at level 1). + +# --- Species baseline ------------------------------------------------------- +# SRD trait counts: Dragonborn 4, Dwarf 4, Elf 5, Gnome 3, Goliath 3, +# Halfling 4, Human 3, Orc 3, Tiefling 3. Budget = 5 (1 above SRD max). +_SPECIES_TRAIT_MAX = 5 + +# High-impact trait count per SRD species (flight, innate spellcasting, +# resistance/immunity, breath weapons, darkvision, tremorsense/blindsight, +# regeneration, legendary/magic resistance): +# Dragonborn 4, Dwarf 3, Elf 2, Gnome 1, Goliath 0, Halfling 0, +# Human 0, Orc 1, Tiefling 3. +# Budget = 3 (1 above the SRD median of 1-2; Dragonborn at 4 is the outlier). +_SPECIES_HIGH_IMPACT_MAX = 3 + +# --- Class+subclass feature budget by level ---------------------------------- +# Cumulative features (base + subclass) active AT a given level, across +# 12 SRD classes at key checkpoints (1/3/5/10/20). Budget = MAX at that +# level across all 12 classes (rounded up to the nearest integer). +_CLASS_FEATURE_BUDGET: dict[int, int] = { + 1: 4, + 3: 9, + 5: 12, + 10: 18, + 20: 26, +} + +# Combined species-trait + class-feature total budget (Superman+Batman guard): +# Scales with level: SRD max species traits (5) + SRD max class features at that +# level. A typical low-level character has ~7-9 total (3-4 species + 3-4 class). +# The raw 10-stack the user called out is caught at low levels; the budget +# loosens naturally as characters gain features through normal progression. +_COMBINED_BUDGET_BASE_SPECIES = 5 # species traits always 5 max +_COMBINED_BUDGET_EXTRA = 0 # no extra leniency — individual checks have margin + +# Keywords that mark a trait as "high-impact" (affects combat math, survivability, +# or action economy, as opposed to cosmetic / ribbon abilities). +_HIGH_IMPACT_KEYWORDS = [ + "breath weapon", "damage resistance", "resistance", "immunity", + "flight", "fly speed", "innate spellcasting", "spellcasting", "cantrip", + "regeneration", "legendary resistance", "magic resistance", + "tremorsense", "blindsight", "darkvision", + "advantage on", "disadvantage on", "frightened", "charmed", + "teleport", "ethereal", "incorporeal", +] + + +def _count_high_impact_traits(traits: list[dict]) -> int: + """Count how many species traits match high-impact keywords.""" + count = 0 + for trait in traits: + if not isinstance(trait, dict): + continue + name = (trait.get("name") or "").lower() + desc = (trait.get("description") or "").lower() + combined = name + " " + desc + if any(kw in combined for kw in _HIGH_IMPACT_KEYWORDS): + count += 1 + return count + + +def _class_feature_budget_for_level(level: int) -> int: + """Return the SRD max combined class+subclass features at *level*. + + Uses linear interpolation between the checkpoint levels (1,3,5,10,20). + """ + # Clamp + level = max(1, min(level, 20)) + checkpoints = sorted(_CLASS_FEATURE_BUDGET.items()) + # Exact match + for lvl, budget in checkpoints: + if level == lvl: + return budget + # Interpolate + lower_lvl = lower_budget = 0 + upper_lvl = upper_budget = 0 + for lvl, budget in checkpoints: + if lvl < level: + lower_lvl, lower_budget = lvl, budget + elif lvl > level: + upper_lvl, upper_budget = lvl, budget + break + if upper_lvl == 0: + return checkpoints[-1][1] + frac = (level - lower_lvl) / (upper_lvl - lower_lvl) + return int(lower_budget + frac * (upper_budget - lower_budget) + 0.5) + + +def _combined_budget_for_level(level: int) -> int: + """Return the combined species+class budget for *level*.""" + return _COMBINED_BUDGET_BASE_SPECIES + _class_feature_budget_for_level(level) + _COMBINED_BUDGET_EXTRA + + +# --------------------------------------------------------------------------- +# Power-budget validator +# --------------------------------------------------------------------------- + + +def validate_power_budget( + species_data: dict[str, Any], + class_data: dict[str, Any], + subclass_data: dict[str, Any] | None = None, + level: int = 1, +) -> list[str]: + """Validate a homebrew character's combined power budget against SRD baselines. + + Checks: + 1. Species trait count vs. SRD species budget (max 5 total, 3 high-impact). + High-impact traits (flight, innate casting, resistance, breath weapon, + darkvision, etc.) are weighted more heavily than cosmetic ones. + 2. Class+subclass features active at *level* vs. SRD class budget for that + level (max observed across 12 SRD classes, interpolated). + 3. Species-vs-learned split: total traits + features must stay within one + character's combined budget (max 10). The ``Superman(species) + + Batman(class)`` concept stacking a full species kit AND a full class kit + must NOT pass. + + Baselines derived 2026-06-13 from dndwright content/species.json (9 species) + and content/classes.json (12 classes + subclasses). + + Args: + species_data: The species dict (``traits`` array, optional + ``innate_spellcasting``). + class_data: The class dict (``features`` / ``progression`` array). + subclass_data: Optional subclass dict (``features`` array). + level: Character level (1-20). + + Returns: + List of human-readable problem strings. ``[]`` = within budget. + No silent coerce — overages are surfaced loudly, as the generate + path must trim or the user must choose. + """ + problems: list[str] = [] + + # --- 1. Species trait budget ------------------------------------------------ + traits: list[dict] = species_data.get("traits", []) or [] + if not isinstance(traits, list): + traits = [] + + trait_count = len(traits) + high_impact = _count_high_impact_traits(traits) + + # Also check innate_spellcasting as a separate high-impact indicator + innate = species_data.get("innate_spellcasting") + if isinstance(innate, dict): + spells = innate.get("spells", []) + if isinstance(spells, list) and spells: + # Flag high-level innate spells independently + max_spell_level = max( + (s.get("level", s.get("spell_level", 0)) for s in spells if isinstance(s, dict)), + default=0, + ) + if max_spell_level >= 4: + problems.append( + f"Species innate spellcasting includes level-{max_spell_level} spell — " + f"SRD species innate spells rarely exceed 3rd level" + ) + + if trait_count > _SPECIES_TRAIT_MAX: + over = trait_count - _SPECIES_TRAIT_MAX + problems.append( + f"Species has {trait_count} traits (SRD budget: {_SPECIES_TRAIT_MAX} max). " + f"{over} trait(s) over budget" + ) + + if high_impact > _SPECIES_HIGH_IMPACT_MAX: + over = high_impact - _SPECIES_HIGH_IMPACT_MAX + problems.append( + f"Species has {high_impact} high-impact traits (SRD budget: {_SPECIES_HIGH_IMPACT_MAX} max). " + f"{over} over budget: flight, innate casting, resistance, breath weapon, darkvision, " + f"tremorsense/blindsight counted" + ) + + # --- 2. Class+subclass feature budget at this level ------------------------ + class_features: list[dict] = ( + class_data.get("features") + or class_data.get("progression") + or class_data.get("progression_table") + or [] + ) + if not isinstance(class_features, list): + class_features = [] + + features_active = [f for f in class_features if isinstance(f, dict) and (f.get("level") or 999) <= level] + class_count = len(features_active) + + subclass_count = 0 + if subclass_data: + sub_features: list[dict] = subclass_data.get("features", []) or [] + if isinstance(sub_features, list): + subclass_count = len( + [f for f in sub_features if isinstance(f, dict) and (f.get("level") or 999) <= level] + ) + + total_class_features = class_count + subclass_count + budget_for_level = _class_feature_budget_for_level(level) + + if total_class_features > budget_for_level: + over = total_class_features - budget_for_level + problems.append( + f"Class+subclass has {total_class_features} features active at level {level} " + f"({class_count} base + {subclass_count} subclass). " + f"SRD budget for this level: {budget_for_level} max. {over} over budget" + ) + + # --- 3. Combined species-vs-learned split (Superman+Batman guard) --------- + combined_budget = _combined_budget_for_level(level) + combined = trait_count + total_class_features + if combined > combined_budget: + problems.append( + f"Combined power budget exceeded: {trait_count} species traits + " + f"{total_class_features} class features = {combined} total " + f"(budget: {combined_budget} max). " + f"Species-kit + class-kit stacking is not allowed — trim one side" + ) + + return problems + + # --------------------------------------------------------------------------- # Aggregate validator # --------------------------------------------------------------------------- @@ -316,14 +547,22 @@ def validate_background_homebrew(background_data: dict[str, Any]) -> list[str]: "species": validate_species_homebrew, "subclass": validate_subclass_homebrew, "background": validate_background_homebrew, + "power_budget": validate_power_budget, } def validate_homebrew( component_type: str, component_data: dict[str, Any] ) -> list[str]: - """Route to the appropriate validator.""" + """Route to the appropriate validator. + + The ``power_budget`` type accepts ``component_data`` as the full character + payload: ``{"species_data": ..., "class_data": ..., "subclass_data": ..., + "level": ...}``. + """ validator = VALIDATORS.get(component_type) if validator is None: return [f"Unknown component type: {component_type}"] + if component_type == "power_budget": + return validator(**component_data) return validator(component_data) diff --git a/tests/test_homebrew_validator.py b/tests/test_homebrew_validator.py index c265eb2..6ddb7e3 100644 --- a/tests/test_homebrew_validator.py +++ b/tests/test_homebrew_validator.py @@ -6,6 +6,7 @@ validate_subclass_homebrew, validate_background_homebrew, validate_homebrew, + validate_power_budget, ) @@ -287,3 +288,233 @@ def test_unknown_type_returns_problem(self): def test_router_passes_bad_data_to_validator(self): problems = validate_homebrew("background", {}) assert len(problems) >= 1 + + def test_router_handles_power_budget_kwargs(self): + problems = validate_homebrew("power_budget", { + "species_data": {"traits": []}, + "class_data": {"features": []}, + "level": 1, + }) + assert problems == [] + + +# --------------------------------------------------------------------------- +# Power-budget validator tests +# --------------------------------------------------------------------------- + + +class TestValidatePowerBudget: + """validate_power_budget(species_data, class_data, subclass_data, level).""" + + # --- OK cases ------------------------------------------------------------ + + def test_balanced_low_level_character_passes(self): + species = { + "traits": [ + {"name": "Darkvision", "description": "You have Darkvision 60 ft."}, + {"name": "Lucky", "description": "You can reroll 1s."}, + {"name": "Brave", "description": "Advantage against Frightened."}, + ] + } + class_ = { + "features": [ + {"name": "Fighting Style", "level": 1}, + {"name": "Second Wind", "level": 1}, + ] + } + subclass = { + "features": [ + {"name": "Sub Feature A", "level": 3}, + {"name": "Sub Feature B", "level": 6}, + ] + } + assert validate_power_budget(species, class_, subclass, level=1) == [] + + def test_balanced_level_5_character_passes(self): + species = { + "traits": [ + {"name": "Giant Ancestry", "description": "Supernatural boon."}, + {"name": "Large Form", "description": "Become Large at level 5."}, + {"name": "Powerful Build", "description": "Carry more weight."}, + ] + } + class_ = { + "features": [ + {"name": "Rage", "level": 1}, + {"name": "Unarmored Defense", "level": 1}, + {"name": "Reckless Attack", "level": 2}, + {"name": "Subclass", "level": 3}, + {"name": "ASI", "level": 4}, + {"name": "Extra Attack", "level": 5}, + {"name": "Fast Movement", "level": 5}, + ] + } + subclass = { + "features": [ + {"name": "Frenzy", "level": 3}, + {"name": "Mindless Rage", "level": 6}, + {"name": "Retaliation", "level": 10}, + {"name": "Intimidating Presence", "level": 14}, + ] + } + assert validate_power_budget(species, class_, subclass, level=5) == [] + + def test_no_subclass_passes(self): + species = {"traits": [{"name": "Resourceful"}, {"name": "Skillful"}, {"name": "Versatile"}]} + class_ = {"features": [{"name": "Fighting Style", "level": 1}, {"name": "Action Surge", "level": 2}]} + assert validate_power_budget(species, class_, level=1) == [] + + def test_level_20_balanced_character_passes(self): + species = {"traits": [{"name": "Darkvision"}, {"name": "Fey Ancestry"}]} + # ~15 features is within the level-20 budget of 26 + class_ = { + "features": [{"name": f"F{i}", "level": i} for i in range(1, 17)] + } + subclass = { + "features": [{"name": f"SF{i}", "level": i} for i in [3, 6, 10, 14, 17]] + } + assert validate_power_budget(species, class_, subclass, level=20) == [] + + # --- Species trait budget failures --------------------------------------- + + def test_too_many_species_traits_fails(self): + species = { + "traits": [ + {"name": f"Trait {i}"} for i in range(7) # 7 > 5 max + ] + } + class_ = {"features": [{"name": "Attack", "level": 1}]} + problems = validate_power_budget(species, class_, level=1) + assert any("Species has 7 traits" in p for p in problems) + assert any("over budget" in p for p in problems) + + def test_too_many_high_impact_traits_fails(self): + species = { + "traits": [ + {"name": "Flight", "description": "You have a fly speed of 30 ft."}, + {"name": "Damage Resistance", "description": "Resistance to fire damage."}, + {"name": "Breath Weapon", "description": "15 ft cone of fire damage."}, + {"name": "Magic Resistance", "description": "Advantage on saves vs spells."}, + ] # 4 high-impact > 3 max + } + class_ = {"features": [{"name": "Fighting Style", "level": 1}]} + problems = validate_power_budget(species, class_, level=1) + assert any("high-impact" in p.lower() for p in problems) + + def test_innate_spellcasting_high_level_fails(self): + species = { + "traits": [ + {"name": "Arcane Heritage", "description": "Innate spellcasting."}, + ], + "innate_spellcasting": { + "spells": [ + {"name": "Fireball", "level": 3}, + {"name": "Disintegrate", "level": 6}, + ] + }, + } + class_ = {"features": [{"name": "Attack", "level": 1}]} + problems = validate_power_budget(species, class_, level=1) + assert any("level-6" in p or "4" in p for p in problems) + + # --- Class feature budget failures --------------------------------------- + + def test_too_many_features_at_level_1_fails(self): + species = {"traits": []} + class_ = { + "features": [ + {"name": f"Feature {i}", "level": 1} for i in range(6) # 6 > 4 max at L1 + ] + } + problems = validate_power_budget(species, class_, level=1) + assert any("Class+subclass has 6 features" in p for p in problems) + assert any("over budget" in p for p in problems) + + def test_too_many_features_at_level_3_fails(self): + species = {"traits": []} + class_ = { + "features": [{"name": f"F{i}", "level": 1} for i in range(3)] + [ + {"name": f"F3_{i}", "level": 3} for i in range(5) + ] + } + subclass = { + "features": [{"name": f"SF{i}", "level": 3} for i in range(4)] + } + # At L3: 3 base (L1) + 5 base (L3) + 4 subclass (L3) = 12 > 9 max + problems = validate_power_budget(species, class_, subclass, level=3) + assert any("Class+subclass has 12 features" in p for p in problems) + + # --- Combined budget (Superman + Batman) --------------------------------- + + def test_superman_batman_stack_fails(self): + """At level 1: 5 species + 5 class = 10, exceeds class budget (4) + combined.""" + species = { + "traits": [ + {"name": "Flight", "description": "Fly speed 60 ft."}, + {"name": "Heat Vision", "description": "Ranged damage."}, + {"name": "Super Strength", "description": "Advantage on Strength."}, + {"name": "Invulnerability", "description": "Damage resistance to all."}, + {"name": "Super Speed", "description": "Dash as bonus action."}, + ] + } + class_ = { + "features": [ + {"name": "Martial Arts", "level": 1}, + {"name": "Detective Training", "level": 1}, + {"name": "Gadget Belt", "level": 1}, + {"name": "Combat Expertise", "level": 1}, + {"name": "Shadow Strike", "level": 1}, + ] + } + subclass = { + "features": [ + {"name": "Stealth Mastery", "level": 1}, + ] + } + problems = validate_power_budget(species, class_, subclass, level=1) + # Should fail class-feature budget: 6 > 4 at L1 + assert any("Class+subclass has 6 features" in p for p in problems) + # Should also fail combined: 5 species + 6 class = 11 > 9 at L1 + assert any("Combined power budget exceeded" in p for p in problems) + + def test_just_under_combined_budget_passes(self): + # At level 1: budget = 5 + 4 = 9 (no extra margin for combined). + # 4 species + 4 class = 8 < 9 passes. + species = {"traits": [{"name": f"T{i}"} for i in range(4)]} + class_ = {"features": [{"name": f"F{i}", "level": 1} for i in range(4)]} + assert validate_power_budget(species, class_, level=1) == [] + + def test_just_over_combined_budget_fails(self): + # At level 1: budget = 5 + 4 = 9. 6 species + 4 class = 10 > 9 fails. + species = {"traits": [{"name": f"T{i}"} for i in range(6)]} + class_ = {"features": [{"name": f"F{i}", "level": 1} for i in range(4)]} + problems = validate_power_budget(species, class_, level=1) + assert any("Combined power budget exceeded" in p for p in problems) + + # --- Edge cases --------------------------------------------------------- + + def test_empty_data_passes(self): + assert validate_power_budget({}, {}, level=1) == [] + + def test_missing_traits_passes(self): + species = {} + class_ = {"features": [{"name": "Attack", "level": 1}]} + assert validate_power_budget(species, class_, level=1) == [] + + def test_string_traits_list_handled(self): + """Malformed traits list (strings) should not crash.""" + species = {"traits": ["not_a_dict"]} + class_ = {"features": [{"name": "Attack", "level": 1}]} + problems = validate_power_budget(species, class_, level=1) + assert problems == [] + + def test_features_below_level_ignored(self): + species = {"traits": []} + class_ = { + "features": [ + {"name": "F1", "level": 1}, + {"name": "F5", "level": 5}, + {"name": "F10", "level": 10}, + ] + } + assert validate_power_budget(species, class_, level=2) == [] From f08265916b5b3f4e79daf51646de070b3ee0a3b8 Mon Sep 17 00:00:00 2001 From: Anthony Sligar Date: Sat, 13 Jun 2026 22:21:28 -0400 Subject: [PATCH 2/2] fix: export validate_power_budget + bump to 0.25.0 Two gaps from Boss review: 1. Added validate_power_budget to dndwright/__init__.py import and __all__ 2. Version bump to 0.25.0 in pyproject.toml, __init__, CHANGELOG Also updated test_api_contract.py EXPECTED_PUBLIC and EXPECTED_SIGNATURES. 1635 tests green. --- CHANGELOG.md | 15 +++++++++++++++ pyproject.toml | 2 +- src/dndwright/__init__.py | 4 +++- tests/test_api_contract.py | 5 +++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d43b3..a669394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,21 @@ breaking changes; these will always be noted here. ## [Unreleased] +## [0.25.0] — 2026-06-13 + +### Added +- **Power-budget validator.** `validate_power_budget(species_data, class_data, + subclass_data, level)` returns budget overage problems (species traits, + class+subclass features per level, combined species-vs-learned split). Baselines + derived from dndwright's own SRD 5.2.1 content: 9 species (5 trait max, 3 + high-impact max) and 12 classes (cumulative feature budgets by level). High-impact + traits (flight, innate casting, resistance, breath weapon) weighted heavier. + Superman+Batman double-stacking caught at low levels. Exported in `dndwright.__all__`. +- 17 new unit tests for power-budget coverage (balanced vs over-budget vs edge cases). + +### Changed +- `__all__` expanded: +1 name (`validate_power_budget`). + ## [0.24.1] — 2026-06-13 ### Documentation diff --git a/pyproject.toml b/pyproject.toml index b3d4bfc..f8ed070 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dndwright" -version = "0.24.1" +version = "0.25.0" description = "Domain-neutral D&D 5e (2024) rules & character-sheet computation engine: a data-driven DAG of formulas (ability mods, proficiency, spell DC/slots, HP, AC)." readme = "README.md" requires-python = ">=3.10" diff --git a/src/dndwright/__init__.py b/src/dndwright/__init__.py index 392f76a..51f197c 100644 --- a/src/dndwright/__init__.py +++ b/src/dndwright/__init__.py @@ -100,7 +100,7 @@ validate_ruleset, ) -__version__ = "0.23.2" +__version__ = "0.25.0" # homebrew validation (structural rules checks on LLM-generated components) from .rules.homebrew_validator import ( @@ -109,6 +109,7 @@ validate_subclass_homebrew, validate_background_homebrew, validate_homebrew, + validate_power_budget, VALIDATORS as HOMEBREW_VALIDATORS, ) @@ -203,5 +204,6 @@ "validate_subclass_homebrew", "validate_background_homebrew", "validate_homebrew", + "validate_power_budget", "HOMEBREW_VALIDATORS", ] diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index ee168f3..a0c9665 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -81,6 +81,7 @@ "validate_subclass_homebrew", "validate_background_homebrew", "validate_homebrew", + "validate_power_budget", "HOMEBREW_VALIDATORS", } @@ -210,6 +211,10 @@ def test_evaluate_takes_ruleset_and_inputs(self): "validate_subclass_homebrew": "(subclass_data: 'dict[str, Any]') -> 'list[str]'", "validate_background_homebrew": "(background_data: 'dict[str, Any]') -> 'list[str]'", "validate_homebrew": "(component_type: 'str', component_data: 'dict[str, Any]') -> 'list[str]'", + "validate_power_budget": ( + "(species_data: 'dict[str, Any]', class_data: 'dict[str, Any]', " + "subclass_data: 'dict[str, Any] | None' = None, level: 'int' = 1) -> 'list[str]'" + ), }