dnd5e-character-builder 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnd5e_character_builder/__init__.py +34 -0
- dnd5e_character_builder/advance.py +208 -0
- dnd5e_character_builder/build.py +323 -0
- dnd5e_character_builder/check.py +380 -0
- dnd5e_character_builder/content.py +453 -0
- dnd5e_character_builder/data/5e-database/5e-SRD-Levels.json +8680 -0
- dnd5e_character_builder/data/5e-database/5e-SRD-Spells.json +13514 -0
- dnd5e_character_builder/data/5e-database/LICENSE.md +21 -0
- dnd5e_character_builder/data/srd.yaml +40 -0
- dnd5e_character_builder/py.typed +0 -0
- dnd5e_character_builder/sheet.py +96 -0
- dnd5e_character_builder/spells.py +294 -0
- dnd5e_character_builder-0.1.0.dist-info/METADATA +181 -0
- dnd5e_character_builder-0.1.0.dist-info/RECORD +17 -0
- dnd5e_character_builder-0.1.0.dist-info/WHEEL +4 -0
- dnd5e_character_builder-0.1.0.dist-info/licenses/LICENSE +21 -0
- dnd5e_character_builder-0.1.0.dist-info/licenses/src/dnd5e_character_builder/data/5e-database/LICENSE.md +21 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Build, level up and check 2024 (SRD 5.2.1) character sheets, on top of the nat20 rules engine.
|
|
2
|
+
|
|
3
|
+
``check`` lists every problem with a sheet; ``fill`` writes in the numbers nat20 derives; ``options`` says what a sheet
|
|
4
|
+
may still pick; ``level_up`` reports what a new level gives and which picks it makes due. ``load_content`` layers
|
|
5
|
+
homebrew over the SRD.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dnd5e_character_builder.advance import Choice, FeatSlot, Gained, LevelUp, Options, SpellOption, SpellOptions, level_up, options
|
|
9
|
+
from dnd5e_character_builder.check import build_spec, check, derive, fill
|
|
10
|
+
from dnd5e_character_builder.content import Content, ContentError, load_content
|
|
11
|
+
from dnd5e_character_builder.sheet import Feat, Issue, MagicInitiate, Sheet
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
'Choice',
|
|
15
|
+
'Content',
|
|
16
|
+
'ContentError',
|
|
17
|
+
'Feat',
|
|
18
|
+
'FeatSlot',
|
|
19
|
+
'Gained',
|
|
20
|
+
'Issue',
|
|
21
|
+
'LevelUp',
|
|
22
|
+
'MagicInitiate',
|
|
23
|
+
'Options',
|
|
24
|
+
'Sheet',
|
|
25
|
+
'SpellOption',
|
|
26
|
+
'SpellOptions',
|
|
27
|
+
'build_spec',
|
|
28
|
+
'check',
|
|
29
|
+
'derive',
|
|
30
|
+
'fill',
|
|
31
|
+
'level_up',
|
|
32
|
+
'load_content',
|
|
33
|
+
'options',
|
|
34
|
+
]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""``options``: every pick a sheet has at its level and what fills it; ``level_up``: what a new level gives and which
|
|
2
|
+
picks it makes due.
|
|
3
|
+
|
|
4
|
+
Both report; neither changes the sheet. The caller writes the picks, then runs ``fill`` and ``check``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
from dnd5e_character_builder.build import Build, read
|
|
12
|
+
from dnd5e_character_builder.check import SKILLS, _skill_names, _srd, derived_numbers, skill_pools
|
|
13
|
+
from dnd5e_character_builder.content import Content
|
|
14
|
+
from dnd5e_character_builder.sheet import Sheet
|
|
15
|
+
from dnd5e_character_builder.spells import caster, granted, slots, state
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Choice(BaseModel):
|
|
19
|
+
"""A pick the build has: ``count`` from ``options`` by this level, ``chosen`` the sheet's own. Open while
|
|
20
|
+
``len(chosen) < count``."""
|
|
21
|
+
|
|
22
|
+
title: str
|
|
23
|
+
count: int
|
|
24
|
+
options: list[str]
|
|
25
|
+
chosen: list[str] = Field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SpellOption(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
level: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FeatSlot(BaseModel):
|
|
34
|
+
"""An Ability Score Improvement level reached with no feat recorded, and the feats that fit it."""
|
|
35
|
+
|
|
36
|
+
level: int
|
|
37
|
+
options: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SpellOptions(BaseModel):
|
|
41
|
+
"""The class table's spell picks: ``cantrips`` and ``prepared`` are the limits, ``known_cantrips`` and
|
|
42
|
+
``prepared_spells`` the sheet's entries that count against them, the options by level then name. Granted spells
|
|
43
|
+
count against nothing and are offered nowhere. A wizard prepares from the spellbook (``options``) and writes
|
|
44
|
+
new spells from ``spellbook_options``; ``spellbook`` is the least the book holds at this level."""
|
|
45
|
+
|
|
46
|
+
cantrips: int
|
|
47
|
+
prepared: int
|
|
48
|
+
max_level: int
|
|
49
|
+
known_cantrips: list[str] = Field(default_factory=list)
|
|
50
|
+
prepared_spells: list[str] = Field(default_factory=list)
|
|
51
|
+
cantrip_options: list[SpellOption] = Field(default_factory=list)
|
|
52
|
+
options: list[SpellOption] = Field(default_factory=list)
|
|
53
|
+
spellbook: int = 0
|
|
54
|
+
spellbook_options: list[SpellOption] = Field(default_factory=list)
|
|
55
|
+
granted: list[str] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Options(BaseModel):
|
|
59
|
+
"""Every pick a sheet has at its level, open or made."""
|
|
60
|
+
|
|
61
|
+
subclasses: list[str] = Field(default_factory=list) # when the level has reached the subclass and none is chosen
|
|
62
|
+
choices: list[Choice] = Field(default_factory=list)
|
|
63
|
+
feat_slots: list[FeatSlot] = Field(default_factory=list)
|
|
64
|
+
skills: list[Choice] = Field(default_factory=list)
|
|
65
|
+
expertise: list[Choice] = Field(default_factory=list)
|
|
66
|
+
spells: SpellOptions | None = None
|
|
67
|
+
magic_initiate: int = 0 # Magic Initiate feats with no record of their picks
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Gained(BaseModel):
|
|
71
|
+
"""What a level gives without a choice."""
|
|
72
|
+
|
|
73
|
+
features: list[str] = Field(default_factory=list)
|
|
74
|
+
spells: list[str] = Field(default_factory=list) # newly always prepared
|
|
75
|
+
proficiency_bonus: int | None = None
|
|
76
|
+
max_hp: int | None = None
|
|
77
|
+
spell_slots: dict[int, int] = Field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LevelUp(BaseModel):
|
|
81
|
+
"""``level_up``'s report: ``gained`` automatically, ``due`` the picks open at the new level."""
|
|
82
|
+
|
|
83
|
+
from_level: int
|
|
84
|
+
to_level: int
|
|
85
|
+
gained: Gained
|
|
86
|
+
due: Options
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _names(content: Content, slugs: set[str]) -> list[str]:
|
|
90
|
+
return [o.name for o in _spells(content, slugs)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _spells(content: Content, slugs: set[str]) -> list[SpellOption]:
|
|
94
|
+
found = [content.spells[s] for s in slugs if s in content.spells]
|
|
95
|
+
return [SpellOption(name=s.name, level=s.level) for s in sorted(found, key=lambda s: (s.level, s.name.lower()))]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _feat_options(b: Build, level: int) -> list[str]:
|
|
99
|
+
"""Feats a single-class character may take at an Ability Score Improvement of ``level`` (its character level)."""
|
|
100
|
+
loader = b.content.loader
|
|
101
|
+
owned = {p.slug for p in b.feats if p.slug} | set(b.derived.features if b.derived else ())
|
|
102
|
+
styles = any(p.title == 'Fighting Style' for p in b.pools)
|
|
103
|
+
out = []
|
|
104
|
+
for slug in loader.list_slugs('feats'):
|
|
105
|
+
feat = loader.get_feat(slug)
|
|
106
|
+
if feat is None or (slug in owned and not feat.repeatable):
|
|
107
|
+
continue
|
|
108
|
+
category = str(getattr(feat.category, 'value', feat.category))
|
|
109
|
+
if (category == 'epic_boon' and level < 19) or (category == 'fighting_style' and not styles):
|
|
110
|
+
continue
|
|
111
|
+
if any((p.level or 0) > level or (set(p.feats) - owned - ({'fighting-style'} if styles else set())) for p in feat.prerequisites):
|
|
112
|
+
continue
|
|
113
|
+
out.append(feat.name)
|
|
114
|
+
return sorted(out)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _spell_options(b: Build) -> SpellOptions | None:
|
|
118
|
+
st = state(b)
|
|
119
|
+
if not st.caster:
|
|
120
|
+
return None
|
|
121
|
+
c = b.content
|
|
122
|
+
_, own = caster(b)
|
|
123
|
+
out = SpellOptions(
|
|
124
|
+
cantrips=st.cantrip_limit,
|
|
125
|
+
prepared=st.prepared_limit,
|
|
126
|
+
max_level=st.max_level,
|
|
127
|
+
known_cantrips=st.cantrips,
|
|
128
|
+
prepared_spells=st.prepared,
|
|
129
|
+
cantrip_options=_spells(c, c.spell_list(own, 0) - st.granted),
|
|
130
|
+
options=_spells(c, c.spell_list(own, st.max_level, 1) - st.granted) if st.max_level else [],
|
|
131
|
+
granted=_names(c, st.granted),
|
|
132
|
+
)
|
|
133
|
+
if b.klass == 'wizard':
|
|
134
|
+
book = {s.slug for s in (c.spell(e) for e in b.sheet.spellbook) if s is not None}
|
|
135
|
+
out.options = _spells(c, {s for s in book if 1 <= c.spells[s].level <= st.max_level} - st.granted)
|
|
136
|
+
out.spellbook = 6 + 2 * (b.sheet.level - 1) # "six level 1 Wizard spells"; "two Wizard spells of your choice" each level after
|
|
137
|
+
out.spellbook_options = _spells(c, c.spell_list('wizard', st.max_level, 1))
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _options(b: Build) -> Options:
|
|
142
|
+
loader, sheet = b.content.loader, b.sheet
|
|
143
|
+
out = Options()
|
|
144
|
+
gate = b.subclass_gate
|
|
145
|
+
if b.klass and not sheet.subclass.strip() and '(' not in sheet.character_class and gate is not None and sheet.level >= gate:
|
|
146
|
+
out.subclasses = sorted(
|
|
147
|
+
s.name
|
|
148
|
+
for slug in loader.list_slugs('subclasses')
|
|
149
|
+
if (s := loader.get_subclass(slug)) is not None and str(s.class_identifier) == b.klass
|
|
150
|
+
)
|
|
151
|
+
out.choices = [Choice(title=p.title, count=p.count, options=list(p.options), chosen=p.chosen) for p in b.pools]
|
|
152
|
+
taken = {p.level for p in b.feats if p.source == 'slot'}
|
|
153
|
+
out.feat_slots = [FeatSlot(level=lv, options=_feat_options(b, lv)) for lv in b.asi_levels if lv <= sheet.level and lv not in taken]
|
|
154
|
+
proficient, expertise, _ = _skill_names(sheet.skills)
|
|
155
|
+
expertise += _skill_names(sheet.expertise)[0]
|
|
156
|
+
pools = list(skill_pools(b, sheet.level))
|
|
157
|
+
background = loader.get_background(b.background) if b.background else None
|
|
158
|
+
fixed = [SKILLS[s] for s in background.skill_proficiencies if s in SKILLS] if background is not None else []
|
|
159
|
+
spare = len([s for s in proficient if s not in fixed])
|
|
160
|
+
for mode, title, count, options in pools:
|
|
161
|
+
have = set(expertise) if mode == 'expertise' else set(proficient)
|
|
162
|
+
mine = [o for o in options if o in have]
|
|
163
|
+
if mode != 'expertise': # proficiencies the sheet already has fill the pools in order
|
|
164
|
+
used = min(count, spare)
|
|
165
|
+
spare -= used
|
|
166
|
+
else:
|
|
167
|
+
used = min(count, len(expertise))
|
|
168
|
+
expertise = expertise[used:]
|
|
169
|
+
target = out.expertise if mode == 'expertise' else out.skills
|
|
170
|
+
target.append(Choice(title=title, count=count, options=options, chosen=mine[:used]))
|
|
171
|
+
out.spells = _spell_options(b)
|
|
172
|
+
out.magic_initiate = max(0, sum(1 for p in b.feats if p.slug == 'magic-initiate') - len(sheet.magic_initiate))
|
|
173
|
+
return out
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def options(sheet: Sheet, *, content: Content | None = None) -> Options:
|
|
177
|
+
"""What a sheet may still pick at its level: subclass, feature choices, feats, skills and spells."""
|
|
178
|
+
return _options(read(sheet, content or _srd()))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def level_up(sheet: Sheet, to_level: int, *, content: Content | None = None) -> LevelUp:
|
|
182
|
+
"""What reaching ``to_level`` gives automatically and which picks are then due. The sheet is not changed.
|
|
183
|
+
|
|
184
|
+
:raises ValueError: When ``to_level`` is not above the sheet's level or is above 20.
|
|
185
|
+
"""
|
|
186
|
+
if not sheet.level < to_level <= 20:
|
|
187
|
+
raise ValueError(f'to_level must be above {sheet.level} and at most 20, not {to_level}')
|
|
188
|
+
content = content or _srd()
|
|
189
|
+
now = read(sheet, content)
|
|
190
|
+
after = read(sheet.model_copy(update={'level': to_level}), content)
|
|
191
|
+
loader = content.loader
|
|
192
|
+
features: list[str] = []
|
|
193
|
+
for doc in (loader.get_class(after.klass) if after.klass else None, loader.get_subclass(after.subclass) if after.subclass else None):
|
|
194
|
+
for g in getattr(doc, 'granted_features', None) or []:
|
|
195
|
+
if g.ref_type == 'feature' and sheet.level < g.level <= to_level and (f := loader.get_feature(g.slug)) is not None:
|
|
196
|
+
features.append(f.name)
|
|
197
|
+
numbers = derived_numbers(after)
|
|
198
|
+
gained = Gained(
|
|
199
|
+
features=list(dict.fromkeys(features)),
|
|
200
|
+
spells=_names(content, granted(after, to_level) - granted(now)),
|
|
201
|
+
proficiency_bonus=after.derived.proficiency_bonus if after.derived else None,
|
|
202
|
+
max_hp=numbers.get('max_hp'),
|
|
203
|
+
spell_slots=numbers.get('spell_slots') or slots(after, to_level),
|
|
204
|
+
)
|
|
205
|
+
return LevelUp(from_level=sheet.level, to_level=to_level, gained=gained, due=_options(after))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = ['Choice', 'FeatSlot', 'Gained', 'LevelUp', 'Options', 'SpellOption', 'SpellOptions', 'level_up', 'options']
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""One reading of a sheet: what it names in the content, the nat20 build it describes, and what nat20 derives from it.
|
|
2
|
+
|
|
3
|
+
``read`` is the single pass ``check``, ``fill``, ``options`` and ``level_up`` share. Every name is resolved here,
|
|
4
|
+
feats are placed in the slots that allow them, picks become nat20 ``selected_choices`` tokens, and nat20's
|
|
5
|
+
``derive_sheet`` runs; its ``ValueError`` becomes a ``build.rejected`` issue.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from dnd5e_engine.build_spec import CharacterBuildSpec, DerivedSheet, derive_sheet, make_build_spec
|
|
15
|
+
from dnd5e_engine.rules.character import ability_score_improvement, subclass_gate_level
|
|
16
|
+
|
|
17
|
+
from dnd5e_character_builder.content import Content, clean_name, key
|
|
18
|
+
from dnd5e_character_builder.sheet import Feat, Issue, Severity, Sheet
|
|
19
|
+
|
|
20
|
+
_PAREN = re.compile(r'\s*\((.*?)\)\s*')
|
|
21
|
+
_POOL_TITLES = {'fightingStyle': 'Fighting Style', 'metamagic': 'Metamagic', 'eldritchInvocation': 'Eldritch Invocations'}
|
|
22
|
+
_ASI = 'ability-score-improvement'
|
|
23
|
+
_MAGIC_INITIATE = 'magic-initiate'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Pool:
|
|
28
|
+
"""A feature choice the build has opened (Fighting Style, Metamagic, Divine Order): ``count`` picks due by the
|
|
29
|
+
sheet's level, its options by name and slug, and the sheet's picks (names) credited to it."""
|
|
30
|
+
|
|
31
|
+
title: str
|
|
32
|
+
source: str
|
|
33
|
+
count: int
|
|
34
|
+
options: dict[str, str]
|
|
35
|
+
chosen: list[str] = field(default_factory=list)
|
|
36
|
+
schedule: tuple[tuple[int, int], ...] = ()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PlacedFeat:
|
|
41
|
+
"""A sheet feat and where it came from: ``background``, ``species``, ``pool`` (a style taken through Fighting
|
|
42
|
+
Style), ``slot`` (the Ability Score Improvement at ``level``) or ``None`` when nothing on the sheet grants it."""
|
|
43
|
+
|
|
44
|
+
feat: Feat
|
|
45
|
+
slug: str | None
|
|
46
|
+
source: str | None = None
|
|
47
|
+
level: int | None = None
|
|
48
|
+
delivered: bool = False # nat20 knows the build takes it
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Build:
|
|
53
|
+
sheet: Sheet
|
|
54
|
+
content: Content
|
|
55
|
+
klass: str | None = None
|
|
56
|
+
subclass: str | None = None
|
|
57
|
+
species: str | None = None
|
|
58
|
+
background: str | None = None
|
|
59
|
+
pools: list[Pool] = field(default_factory=list)
|
|
60
|
+
feats: list[PlacedFeat] = field(default_factory=list)
|
|
61
|
+
asi_levels: list[int] = field(default_factory=list)
|
|
62
|
+
spec: CharacterBuildSpec | None = None
|
|
63
|
+
derived: DerivedSheet | None = None
|
|
64
|
+
partial: list[str] = field(default_factory=list) # why derived numbers may not match the sheet
|
|
65
|
+
issues: list[Issue] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def issue(self, code: str, severity: Severity, field_: str, message: str, **detail: Any) -> None:
|
|
68
|
+
self.issues.append(Issue(code=code, severity=severity, field=field_, message=message, detail=detail))
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def subclass_gate(self) -> int | None:
|
|
72
|
+
doc = self.content.loader.get_class(self.klass) if self.klass else None
|
|
73
|
+
return subclass_gate_level(doc) if doc is not None else None
|
|
74
|
+
|
|
75
|
+
def feat_count(self, slug: str) -> int:
|
|
76
|
+
return sum(1 for f in self.feats if f.slug == slug)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def split_class(character_class: str, subclass: str) -> tuple[str, str]:
|
|
80
|
+
"""Move a parenthesised subclass out of the class text when the subclass is empty: ``"Rogue (Thief)"``."""
|
|
81
|
+
match = _PAREN.search(character_class)
|
|
82
|
+
if match and not subclass.strip():
|
|
83
|
+
return _PAREN.sub(' ', character_class).strip(), match.group(1).strip()
|
|
84
|
+
return character_class.strip(), subclass.strip()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _classes_named(content: Content, text: str) -> list[str]:
|
|
88
|
+
whole = content.resolve('classes', text)
|
|
89
|
+
if whole:
|
|
90
|
+
return [whole]
|
|
91
|
+
words = key(re.sub(r'\d+', ' ', text)).split()
|
|
92
|
+
found = [s for w in words if (s := content.resolve('classes', w))]
|
|
93
|
+
return list(dict.fromkeys(found))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _option_doc(content: Content, ref: Any) -> Any:
|
|
97
|
+
return content.loader.get_feat(ref.slug) if ref.ref_type == 'feat' else content.loader.get_feature(ref.slug)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _option_name(doc: Any) -> str:
|
|
101
|
+
return str(doc.name).split(': ', 1)[-1]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def pools_at(b: Build, level: int) -> list[Pool]:
|
|
105
|
+
"""The feature choices a build has opened by ``level``, the sheet's picks credited to them.
|
|
106
|
+
|
|
107
|
+
A species pool of origin feats (the human's Versatile) is left to the feats. Two pools offering the same options
|
|
108
|
+
(a fighter's Fighting Style at 1 and the Champion's Additional Fighting Style at 7) share the picks: each takes up
|
|
109
|
+
to its count of the ones not yet taken. A style listed in ``choices`` fills a pool first; one listed only in
|
|
110
|
+
``feats`` beyond the pools was taken with a feat slot.
|
|
111
|
+
"""
|
|
112
|
+
loader = b.content.loader
|
|
113
|
+
sources = [(d, 'class') for d in [loader.get_class(b.klass) if b.klass else None] if d is not None]
|
|
114
|
+
sources += [(d, 'subclass') for d in [loader.get_subclass(b.subclass) if b.subclass else None] if d is not None]
|
|
115
|
+
sources += [(d, 'species') for d in [loader.get_species(b.species) if b.species else None] if d is not None]
|
|
116
|
+
taken = {clean_name(c) for c in [*b.sheet.choices, *(f.name for f in b.sheet.feats)]}
|
|
117
|
+
pools: list[Pool] = []
|
|
118
|
+
for doc, source in sources:
|
|
119
|
+
for choice in doc.feature_choices or []:
|
|
120
|
+
if source == 'species' and all(ref.ref_type == 'feat' for ref in choice.pool):
|
|
121
|
+
continue
|
|
122
|
+
count = sum(step.count for step in choice.schedule if step.level <= level)
|
|
123
|
+
schedule = tuple((step.level, step.count) for step in choice.schedule if step.count)
|
|
124
|
+
if not schedule:
|
|
125
|
+
continue
|
|
126
|
+
options = {_option_name(d): ref.slug for ref in choice.pool if (d := _option_doc(b.content, ref)) is not None}
|
|
127
|
+
first = _option_doc(b.content, choice.pool[0]) if choice.pool else None
|
|
128
|
+
title = _POOL_TITLES.get(choice.restriction_subtype) or (
|
|
129
|
+
str(first.name).split(': ', 1)[0] if first is not None and ': ' in first.name else f'{doc.name} option'
|
|
130
|
+
)
|
|
131
|
+
chosen = [name for name, slug in options.items() if key(name) in taken or slug in taken]
|
|
132
|
+
pools.append(Pool(title, source, count, options, chosen, schedule))
|
|
133
|
+
only_choice = {clean_name(c) for c in b.sheet.choices} - {clean_name(f.name) for f in b.sheet.feats}
|
|
134
|
+
assigned: set[str] = set()
|
|
135
|
+
for i, pool in enumerate(pools):
|
|
136
|
+
free = sorted((n for n in pool.chosen if n not in assigned), key=lambda n: key(n) not in only_choice)
|
|
137
|
+
later = any(set(pool.chosen) & set(p.options) for p in pools[i + 1 :])
|
|
138
|
+
pool.chosen = free[: pool.count] if later else free[: pool.count] + [n for n in free[pool.count :] if key(n) in only_choice]
|
|
139
|
+
assigned.update(pool.chosen)
|
|
140
|
+
return [p for p in pools if p.count > 0 or p.chosen]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _place_feats(b: Build) -> None:
|
|
144
|
+
"""Put each sheet feat where the rules allow it; an unplaceable feat is an issue."""
|
|
145
|
+
loader = b.content.loader
|
|
146
|
+
background = loader.get_background(b.background) if b.background else None
|
|
147
|
+
species = loader.get_species(b.species) if b.species else None
|
|
148
|
+
origin_pools = [
|
|
149
|
+
{r.slug for r in c.pool} for c in (species.feature_choices if species else []) if all(r.ref_type == 'feat' for r in c.pool)
|
|
150
|
+
]
|
|
151
|
+
in_pools = {key(n) for p in b.pools for n in p.chosen}
|
|
152
|
+
background_feat = background.starting_feat_slug if background is not None else None
|
|
153
|
+
# A custom background (any the content does not know): "+2 and +1, or +1 to three" to any abilities, two skills
|
|
154
|
+
# and one Origin feat of the player's choice.
|
|
155
|
+
custom_feat = background is None
|
|
156
|
+
free_slots = [lv for lv in b.asi_levels if lv <= b.sheet.level]
|
|
157
|
+
used: set[int] = set()
|
|
158
|
+
unplaced: list[PlacedFeat] = []
|
|
159
|
+
for feat in b.sheet.feats:
|
|
160
|
+
slug = b.content.resolve('feats', feat.name)
|
|
161
|
+
doc = loader.get_feat(slug) if slug else None
|
|
162
|
+
placed = PlacedFeat(feat, slug)
|
|
163
|
+
b.feats.append(placed)
|
|
164
|
+
category = str(getattr(doc.category, 'value', doc.category)) if doc is not None else ''
|
|
165
|
+
if slug is None:
|
|
166
|
+
b.issue('feat.unknown', 'error', 'feats', f'"{feat.name}" is not a feat in the content', feat=feat.name)
|
|
167
|
+
if feat.level is None and key(feat.name) in in_pools:
|
|
168
|
+
placed.source, placed.delivered = 'pool', True
|
|
169
|
+
continue
|
|
170
|
+
if feat.level is None and (category == 'origin' or slug is None):
|
|
171
|
+
if slug is not None and slug == background_feat:
|
|
172
|
+
placed.source, placed.delivered, background_feat = 'background', True, None
|
|
173
|
+
elif slug is not None and (pool := next((p for p in origin_pools if slug in p), None)) is not None:
|
|
174
|
+
placed.source, placed.delivered = 'species', True
|
|
175
|
+
origin_pools.remove(pool)
|
|
176
|
+
elif custom_feat:
|
|
177
|
+
placed.source, custom_feat = 'background', False # nat20 has no custom background to tell
|
|
178
|
+
elif slug is not None and len(unplaced) < len([lv for lv in free_slots if lv not in used]):
|
|
179
|
+
unplaced.append(placed) # an origin feat may also fill an Ability Score Improvement
|
|
180
|
+
elif slug is not None:
|
|
181
|
+
gives = (
|
|
182
|
+
(background.name + ' gives ' + (background_feat or 'none left')) if background is not None else 'its one already taken'
|
|
183
|
+
)
|
|
184
|
+
b.issue(
|
|
185
|
+
'feat.no_slot',
|
|
186
|
+
'error',
|
|
187
|
+
'feats',
|
|
188
|
+
f'"{feat.name}": an origin feat comes from the background ({gives}) or the species',
|
|
189
|
+
feat=feat.name,
|
|
190
|
+
)
|
|
191
|
+
continue
|
|
192
|
+
if feat.level is None:
|
|
193
|
+
unplaced.append(placed)
|
|
194
|
+
continue
|
|
195
|
+
if feat.level not in free_slots or feat.level in used:
|
|
196
|
+
why = (
|
|
197
|
+
'already taken by another feat'
|
|
198
|
+
if feat.level in used
|
|
199
|
+
else f'not a {b.klass} level with an Ability Score Improvement the sheet has reached'
|
|
200
|
+
)
|
|
201
|
+
b.issue('feat.no_slot', 'error', 'feats', f'"{feat.name}" at level {feat.level}: {why}', feat=feat.name, level=feat.level)
|
|
202
|
+
continue
|
|
203
|
+
placed.source, placed.level = 'slot', feat.level
|
|
204
|
+
used.add(feat.level)
|
|
205
|
+
for placed in unplaced: # a general feat with no level: the first free slot, and a warning to record it
|
|
206
|
+
level = next((lv for lv in free_slots if lv not in used), None)
|
|
207
|
+
b.issue(
|
|
208
|
+
'feat.level_missing',
|
|
209
|
+
'info',
|
|
210
|
+
'feats',
|
|
211
|
+
f'"{placed.feat.name}": record the level it was taken at' + ('' if level else ' (no Ability Score Improvement left for it)'),
|
|
212
|
+
feat=placed.feat.name,
|
|
213
|
+
)
|
|
214
|
+
if level is not None:
|
|
215
|
+
placed.source, placed.level = 'slot', level
|
|
216
|
+
used.add(level)
|
|
217
|
+
for placed in b.feats:
|
|
218
|
+
if placed.source == 'slot' and placed.slug not in (None, _ASI):
|
|
219
|
+
placed.delivered = True
|
|
220
|
+
for level in (lv for lv in free_slots if lv not in used):
|
|
221
|
+
b.issue(
|
|
222
|
+
'feat.slot_empty',
|
|
223
|
+
'warning',
|
|
224
|
+
'feats',
|
|
225
|
+
f'Level {level}: no feat recorded for the Ability Score Improvement (list "Ability Score Improvement" or the feat taken)',
|
|
226
|
+
level=level,
|
|
227
|
+
)
|
|
228
|
+
if custom_feat:
|
|
229
|
+
b.issue('feat.missing', 'warning', 'feats', 'A background gives one origin feat; none is on the sheet')
|
|
230
|
+
if background_feat and background is not None:
|
|
231
|
+
found = loader.get_feat(background_feat)
|
|
232
|
+
b.issue(
|
|
233
|
+
'feat.missing',
|
|
234
|
+
'warning',
|
|
235
|
+
'feats',
|
|
236
|
+
f'The {background.name} background gives the {found.name if found else background_feat} feat',
|
|
237
|
+
feat=background_feat,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _tokens(b: Build) -> tuple[str, ...]:
|
|
242
|
+
tokens = [p.options[name] for p in b.pools for name in p.chosen[: p.count]]
|
|
243
|
+
tokens += [p.slug for p in b.feats if p.source == 'species' and p.slug]
|
|
244
|
+
tokens += [f'feat:{b.klass}:{p.level}:{p.slug}' for p in b.feats if p.source == 'slot' and p.slug not in (None, _ASI)]
|
|
245
|
+
delivered = sum(1 for p in b.feats if p.slug == _MAGIC_INITIATE and p.delivered)
|
|
246
|
+
for record in b.sheet.magic_initiate[:delivered]:
|
|
247
|
+
found = [b.content.spell(s) for s in [*record.cantrips, record.spell]]
|
|
248
|
+
if all(s is not None and b.content.loader.get_spell(s.slug) is not None for s in found):
|
|
249
|
+
slugs = [s.slug for s in found if s is not None]
|
|
250
|
+
tokens.append(f'magic-initiate:{record.spell_list}:{record.ability}:{slugs[0]},{slugs[1]}:{slugs[2]}')
|
|
251
|
+
return tuple(dict.fromkeys(tokens))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def read(sheet: Sheet, content: Content) -> Build:
|
|
255
|
+
"""Resolve a sheet against the content and derive it with nat20."""
|
|
256
|
+
b = Build(sheet, content)
|
|
257
|
+
class_text, sub_text = split_class(sheet.character_class, sheet.subclass)
|
|
258
|
+
named = _classes_named(content, class_text) if class_text else []
|
|
259
|
+
if len(named) > 1:
|
|
260
|
+
b.issue(
|
|
261
|
+
'class.multiclass',
|
|
262
|
+
'error',
|
|
263
|
+
'class',
|
|
264
|
+
f'"{sheet.character_class}" names {len(named)} classes; multiclass sheets are not supported yet',
|
|
265
|
+
classes=named,
|
|
266
|
+
)
|
|
267
|
+
elif not named:
|
|
268
|
+
b.issue('class.unknown', 'error', 'class', f'"{sheet.character_class}" is not a class in the content')
|
|
269
|
+
else:
|
|
270
|
+
b.klass = named[0]
|
|
271
|
+
if sheet.species.strip():
|
|
272
|
+
b.species = content.resolve('species', sheet.species)
|
|
273
|
+
if b.species is None:
|
|
274
|
+
b.issue('species.unknown', 'error', 'species', f'"{sheet.species}" is not a species in the content')
|
|
275
|
+
else:
|
|
276
|
+
b.issue('species.missing', 'error', 'species', 'No species')
|
|
277
|
+
if sheet.background.strip():
|
|
278
|
+
b.background = content.resolve('backgrounds', sheet.background)
|
|
279
|
+
if b.klass:
|
|
280
|
+
doc = content.loader.get_class(b.klass)
|
|
281
|
+
b.asi_levels = [lv for lv in range(1, 21) if ability_score_improvement(doc, lv) is not None]
|
|
282
|
+
gate = b.subclass_gate
|
|
283
|
+
if sub_text:
|
|
284
|
+
b.subclass = content.resolve('subclasses', sub_text)
|
|
285
|
+
if b.subclass is None:
|
|
286
|
+
b.issue('subclass.unknown', 'error', 'subclass', f'"{sub_text}" is not a subclass in the content')
|
|
287
|
+
b.partial.append('the subclass is unknown')
|
|
288
|
+
elif gate is not None and sheet.level >= gate:
|
|
289
|
+
b.issue(
|
|
290
|
+
'subclass.missing',
|
|
291
|
+
'error',
|
|
292
|
+
'subclass',
|
|
293
|
+
f'A level {sheet.level} {doc.name} has chosen a subclass (at level {gate})',
|
|
294
|
+
level=gate,
|
|
295
|
+
)
|
|
296
|
+
b.pools = pools_at(b, sheet.level)
|
|
297
|
+
_place_feats(b)
|
|
298
|
+
# A known feat nat20 is not told about (an origin feat from a background outside the content) changes no derived
|
|
299
|
+
# number: nat20 feats carry no passive effects, and the one that adds AC (Defense) always comes through a pool or slot.
|
|
300
|
+
if any(p.slug is None for p in b.feats):
|
|
301
|
+
b.partial.append('a feat is not in the content')
|
|
302
|
+
if b.klass is None or b.species is None or len(named) > 1:
|
|
303
|
+
return b
|
|
304
|
+
equipment = tuple(s for s in (content.resolve('items', x) for x in sheet.inventory) if s)
|
|
305
|
+
try:
|
|
306
|
+
b.spec = make_build_spec(
|
|
307
|
+
species_slug=b.species,
|
|
308
|
+
class_slug=b.klass,
|
|
309
|
+
level=sheet.level,
|
|
310
|
+
subclass_slug=b.subclass,
|
|
311
|
+
ability_scores=dict(sheet.abilities),
|
|
312
|
+
equipment=equipment,
|
|
313
|
+
background_slug=b.background,
|
|
314
|
+
selected_choices=_tokens(b),
|
|
315
|
+
)
|
|
316
|
+
b.derived = derive_sheet(b.spec, loader=content.loader)
|
|
317
|
+
except ValueError as exc:
|
|
318
|
+
b.issue('build.rejected', 'error', 'build', f'nat20 cannot build this sheet: {exc}', reason=str(exc))
|
|
319
|
+
b.derived = None
|
|
320
|
+
return b
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
__all__ = ['Build', 'PlacedFeat', 'Pool', 'pools_at', 'read', 'split_class']
|