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.
@@ -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']