Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion src/dndwright/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -109,6 +109,7 @@
validate_subclass_homebrew,
validate_background_homebrew,
validate_homebrew,
validate_power_budget,
VALIDATORS as HOMEBREW_VALIDATORS,
)

Expand Down Expand Up @@ -203,5 +204,6 @@
"validate_subclass_homebrew",
"validate_background_homebrew",
"validate_homebrew",
"validate_power_budget",
"HOMEBREW_VALIDATORS",
]
241 changes: 240 additions & 1 deletion src/dndwright/rules/homebrew_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand All @@ -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)
5 changes: 5 additions & 0 deletions tests/test_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"validate_subclass_homebrew",
"validate_background_homebrew",
"validate_homebrew",
"validate_power_budget",
"HOMEBREW_VALIDATORS",
}

Expand Down Expand Up @@ -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]'"
),
}


Expand Down
Loading
Loading