Metadata-Version: 2.4
Name: dnd5e-character-builder
Version: 0.1.0
Summary: Build, level up and check D&D 2024 (SRD 5.2.1) character sheets, on top of the nat20 rules engine.
Keywords: dnd,5e,2024,srd,character-sheet,ttrpg
Author: Ben Strackany
Author-email: Ben Strackany <ben@developmentnow.com>
License-Expression: MIT
License-File: LICENSE
License-File: src/dnd5e_character_builder/data/5e-database/LICENSE.md
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Games/Entertainment :: Role-Playing
Classifier: Typing :: Typed
Requires-Dist: dnd5e-engine>=0.7,<0.8
Requires-Dist: dnd5e-srd-data>=0.7,<0.8
Requires-Dist: pydantic>=2.14.2
Requires-Dist: pyyaml>=6.0.3
Requires-Python: >=3.14
Project-URL: Repository, https://github.com/bstrackany/dnd5e-character-builder
Description-Content-Type: text/markdown

# dnd5e-character-builder

Build, level up and check D&D 2024 (SRD 5.2.1) character sheets in Python.

A sheet written by a person or a model is free text: "Rogue (Arcane Trickster)", "Shield — level 1, prepared".
This library reads it against the rules, lists every problem at once, writes in the numbers the rules derive, and says
what the character may still pick now or at the next level. It sits on [nat20](https://github.com/tapestria/nat20)'s
rules engine and SRD corpus; nat20 decides whether a build is constructible and derives its HP, AC, saves and slots.
This library adds the parts of a sheet nat20 takes as given: class spell lists, prepared and cantrip counts, wizard
spellbooks, feat slots, choice counts, skills and ability-score generation.

```
pip install dnd5e-character-builder
```

Python 3.14 or later.

## Use

```python
from dnd5e_character_builder import Feat, Sheet, check, fill, level_up, options

sheet = Sheet.model_validate({
    'name': 'Ana',
    'species': 'Human',
    'class': 'Fighter',
    'level': 4,
    'subclass': 'Champion',
    'background': 'Soldier',
    'abilities': {'str': 17, 'dex': 15, 'con': 14, 'int': 8, 'wis': 12, 'cha': 10},
    'skills': ['Athletics', 'Intimidation', 'Perception', 'Survival', 'Acrobatics'],
    'feats': ['Savage Attacker', 'Alert', Feat(name='Grappler', level=4)],
    'choices': ['Defense'],
    'inventory': ['Chain Mail', 'Longsword', 'Shield'],
})

sheet = fill(sheet)            # max_hp, ac, saving_throws and spell_slots from nat20
for issue in check(sheet):     # every problem at once
    print(issue.severity, issue.code, issue.message)

options(sheet)                 # every pick at this level (subclass, feature choices, feat slots, skills, spells) and what fills it
level_up(sheet, 5)             # what level 5 gives automatically and which picks it makes due
```

`level_up` and `options` report; neither changes the sheet. Write the picks, then run `fill` and `check`.

`build_spec(sheet)` and `derive(sheet)` give nat20's `CharacterBuildSpec` and `DerivedSheet` for a sheet, for hosts
that run nat20 combat.

### The sheet

| Field | Holds |
|---|---|
| `species`, `class`, `subclass`, `background` | Names as written. A subclass in parentheses after the class is read too. |
| | A background the content does not know is a custom one: +2/+1 or +1/+1/+1 to any abilities, any two skills and any one origin feat. |
| `level` | 1-20, one class. |
| `abilities` | Final scores by `str` ... `cha`. |
| `skills`, `expertise` | Skill names; `"Stealth (expertise)"` in `skills` also counts as Expertise. |
| `feats` | `Feat(name, level)`. `level` is the class level whose Ability Score Improvement the feat took; leave it `None` for an origin feat. A bare string is a feat with no level. |
| `choices` | Class, subclass and species options by name: `"Archery"`, `"Agonizing Blast"`, `"Protector"`. |
| `spells` | Cantrips known and spells prepared. |
| `spellbook` | A wizard's level 1+ spells. |
| `magic_initiate` | One record per Magic Initiate: list, ability, two cantrips, one level 1 spell. |
| `max_hp`, `ac`, `spell_slots`, `saving_throws` | The sheet's numbers; `fill` replaces them. |

### Issues

Each `Issue` has a stable `code`, a `severity` (`error` breaks the rules, `warning` is likely wrong, `info` is a
note), the sheet `field`, a `message` and a `detail` dict.

| Code | Severity | Meaning |
|---|---|---|
| `build.rejected` | error | nat20 cannot construct the build; its reason is in `detail.reason` |
| `class.unknown`, `class.multiclass` | error | No class the content knows, or more than one (multiclass is not supported yet) |
| `species.unknown`, `species.missing` | error | |
| `subclass.unknown`, `subclass.missing` | error | Not in the content, or none chosen at the subclass level |
| `feat.unknown` | error | Not in the content |
| `feat.no_slot` | error | Nothing gives this feat: no Ability Score Improvement at that level, the slot is taken, or an origin feat beyond the background's and species' |
| `feat.level_missing` | info | A feat in an Ability Score Improvement without the level it was taken at; it is checked against the first free one |
| `feat.slot_empty` | warning | An Ability Score Improvement level with no feat recorded |
| `feat.missing` | warning | The background's feat (a custom background's chosen origin feat) is not on the sheet |
| `abilities.not_buildable` | warning | Not the standard array or a 27-point buy plus the background's increase and the Ability Score Improvements taken |
| `abilities.over_cap` | error | Above 20 (30 with an Epic Boon) |
| `saves.mismatch` | error / warning | Not the class's saving throws (warning when the sheet lists none) |
| `skills.unknown` | error | Not a skill |
| `skills.too_many`, `skills.too_few` | warning | More or fewer than background, class, species, subclass and feats give |
| `skills.granted_missing` | warning | A skill given outright is not on the sheet |
| `expertise.too_many`, `expertise.not_proficient` | error | |
| `choice.unknown`, `choice.too_many` | error | Not an option this build has, or more picks than its level allows |
| `choice.missing` | warning | Picks still due |
| `spell.unknown` | error | Not in the content |
| `spell.not_on_list` | error | Not on the class list and nothing else grants it |
| `spell.too_many_cantrips`, `spell.too_many_prepared` | error | Over the class table; granted spells do not count |
| `spell.above_slot` | error | Prepared above the highest spell slot |
| `spellbook.missing`, `spellbook.not_wizard_spell` | error | Prepared but not in the book; a book entry that is not a level 1+ wizard spell |
| `spellbook.too_few` | warning | Fewer than 6 + 2 per level after the first |
| `magic_initiate.not_on_list`, `magic_initiate.too_many` | error | |
| `magic_initiate.unrecorded` | info | A Magic Initiate whose picks are not recorded |
| `max_hp.mismatch`, `ac.mismatch`, `spell_slots.mismatch` | warning | The sheet's number differs from the derived one |
| `numbers.unchecked` | info | HP, AC and slots not compared because something the derivation needs is unknown |

Spells a character has without preparing them do not count against the class table: species and subclass grants,
spells a feature's own text grants ("You always have the Hunter's Mark spell prepared"), the Circle of the Land table
the sheet matches best, Mystic Arcanum, Magic Initiate, and the College of Lore's Magical Discoveries.

## Homebrew

`load_content(*paths)` layers YAML or JSON files (or folders of them) over the SRD; pass the result as
`content=` to any call. A file holds nat20-schema entries and this library's tables:

```yaml
subclasses: [...]        # nat20 schema; also classes, species, backgrounds, feats, features, spells
spell_lists:             # class -> spell level -> spells added to its list; a name with no entry is known by name and level
  wizard: {1: [Chromatic Orb], 2: [Dragon's Breath]}
subclass_spells:         # always-prepared spells by class level
  oath-of-glory: {3: [Guiding Bolt, Heroism], 5: [Enhance Ability, Magic Weapon]}
subclass_spell_choices:  # one table per option, as Circle of the Land
  circle-of-the-reef: {shallows: {3: [Fog Cloud, Water Breathing]}}
class_tables:            # cantrips and prepared per level, 20 each; a casting subclass also names progression and spell_list
  eldritch-knight: {cantrips: [...], prepared: [...], progression: third, spell_list: wizard}
aliases:
  subclasses: {glory: oath-of-glory}
```

A path may also be a mapping of the same shape. `load_content(loader=...)` builds on your own nat20 `AssetLoader` instead of nat20's bundled corpus.

Loading refuses anything the library could not check, with a `ContentError` naming it: a spellcasting class without a
table or spell list, a subclass with a Spellcasting feature and no table, a subclass whose spells feature grants no
spells, a spell listed at the wrong level, an alias to nothing. Entries without nat20 provenance get a placeholder one
so they pass nat20's schema.

## Limits

Single class only; see [BACKLOG.md](BACKLOG.md) for the rest.

## Data and attribution

- Rules engine and SRD corpus: [nat20](https://github.com/tapestria/nat20) (`dnd5e-engine`, `dnd5e-srd-data`), MIT.
- Class spell lists and the Cantrips and Prepared Spells columns: `5e-SRD-Spells.json` and `5e-SRD-Levels.json` from
  [5e-database](https://github.com/5e-bits/5e-database) (`src/2024/en`, commit `6f6299e`), MIT, vendored unchanged
  in `src/dnd5e_character_builder/data/5e-database` with its licence. Two columns are corrected from the SRD in
  `data/srd.yaml`.
- This work includes material from the System Reference Document 5.2.1 ("SRD 5.2.1") by Wizards of the Coast LLC,
  available at <https://www.dndbeyond.com/srd>. The SRD 5.2.1 is licensed under the Creative Commons Attribution 4.0
  International License, available at <https://creativecommons.org/licenses/by/4.0/legalcode>.

This library's code is MIT; see [LICENSE](LICENSE).

## Develop

```
uv sync
uv run ruff format --check src tests && uv run ruff check src tests && uv run mypy src tests && uv run pytest
```

## Release

Bump `version` in `pyproject.toml`, commit, then `git tag v<version> && git push origin v<version>`. The
`release.yml` workflow runs the checks and publishes to PyPI through Trusted Publishing.
