dndwright 0.2.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,377 @@
1
+ """Character assembler — combines component mechanics into graph inputs.
2
+
3
+ Takes ClassMechanics + SpeciesMechanics + BackgroundMechanics + SubclassMechanics
4
+ (+ equipped items, feats) and produces the flat input dict for the evaluator.
5
+
6
+ The assembler is the ONLY place where component mechanics get translated
7
+ into computation graph node values. No other code should manually set
8
+ graph inputs.
9
+
10
+ Usage:
11
+ from rules.assembler import assemble_character_inputs
12
+ from rules.evaluator import evaluate
13
+ from rules.dnd_5e_2024 import DND_5E_2024_RULESET
14
+
15
+ inputs = assemble_character_inputs(
16
+ class_mechanics=class_mech,
17
+ species_mechanics=species_mech,
18
+ background_mechanics=bg_mech,
19
+ ability_scores={"strength": 16, ...},
20
+ level=5,
21
+ )
22
+ computed = evaluate(DND_5E_2024_RULESET, inputs)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ from .components import (
30
+ ArmorMechanics,
31
+ BackgroundMechanics,
32
+ ClassMechanics,
33
+ CreatureMechanics,
34
+ FeatMechanics,
35
+ NodeModifier,
36
+ SpeciesMechanics,
37
+ SubclassMechanics,
38
+ )
39
+ from .lookup_tables import (
40
+ SKILL_ABILITY_MAP,
41
+ )
42
+
43
+
44
+ def assemble_character_inputs(
45
+ class_mechanics: ClassMechanics,
46
+ species_mechanics: SpeciesMechanics | None = None,
47
+ subclass_mechanics: SubclassMechanics | None = None,
48
+ background_mechanics: BackgroundMechanics | None = None,
49
+ ability_scores: dict[str, int] | None = None,
50
+ level: int = 1,
51
+ class_name: str = "",
52
+ # Multiclass support
53
+ additional_classes: dict[str, ClassMechanics] | None = None,
54
+ additional_class_levels: dict[str, int] | None = None,
55
+ # Equipment
56
+ equipped_armor: ArmorMechanics | None = None,
57
+ has_shield: bool = False,
58
+ # Feats
59
+ feats: list[FeatMechanics] | None = None,
60
+ ) -> dict[str, Any]:
61
+ """Assemble all component mechanics into a flat graph input dict.
62
+
63
+ This is the single point of truth for translating D&D component
64
+ choices into computation graph inputs.
65
+
66
+ Returns:
67
+ Dict keyed by computation node IDs, ready for evaluate().
68
+ """
69
+ inputs: dict[str, Any] = {}
70
+ scores = ability_scores or {
71
+ "strength": 10, "dexterity": 10, "constitution": 10,
72
+ "intelligence": 10, "wisdom": 10, "charisma": 10,
73
+ }
74
+
75
+ # ------------------------------------------------------------------
76
+ # 1. Background ability score increases (applied BEFORE setting scores)
77
+ # ------------------------------------------------------------------
78
+ if background_mechanics:
79
+ for ability, increase in background_mechanics.ability_score_increases.items():
80
+ if ability in scores:
81
+ scores = dict(scores) # copy to avoid mutating original
82
+ scores[ability] = scores[ability] + increase
83
+
84
+ # ------------------------------------------------------------------
85
+ # 2. Feat ability score increases
86
+ # ------------------------------------------------------------------
87
+ if feats:
88
+ for feat in feats:
89
+ for ability, increase in feat.grants_ability_increase.items():
90
+ if ability in scores:
91
+ scores = dict(scores)
92
+ scores[ability] = scores[ability] + increase
93
+
94
+ # ------------------------------------------------------------------
95
+ # 3. Ability scores → graph inputs
96
+ # ------------------------------------------------------------------
97
+ for ability in ("strength", "dexterity", "constitution",
98
+ "intelligence", "wisdom", "charisma"):
99
+ inputs[f"{ability}_score"] = scores.get(ability, 10)
100
+
101
+ # ------------------------------------------------------------------
102
+ # 4. Level and class structure
103
+ # ------------------------------------------------------------------
104
+ cn = class_name.lower()
105
+ inputs["character_level"] = level
106
+ inputs["primary_class"] = cn
107
+
108
+ # Build class_levels and class_hit_dice for multiclass
109
+ class_levels: dict[str, int] = {}
110
+ class_hit_dice: dict[str, int] = {}
111
+ class_spellcasting_types: dict[str, str] = {}
112
+
113
+ if additional_classes and additional_class_levels:
114
+ # Multiclass — primary class gets remaining levels
115
+ other_levels = sum(additional_class_levels.values())
116
+ class_levels[cn] = max(level - other_levels, 1)
117
+ class_hit_dice[cn] = class_mechanics.hit_die
118
+ class_spellcasting_types[cn] = class_mechanics.spellcasting_type
119
+ for other_name, other_mech in additional_classes.items():
120
+ other_cn = other_name.lower()
121
+ class_levels[other_cn] = additional_class_levels.get(other_name, 1)
122
+ class_hit_dice[other_cn] = other_mech.hit_die
123
+ class_spellcasting_types[other_cn] = other_mech.spellcasting_type
124
+ else:
125
+ # Single class
126
+ class_levels[cn] = level
127
+ class_hit_dice[cn] = class_mechanics.hit_die
128
+ class_spellcasting_types[cn] = class_mechanics.spellcasting_type
129
+
130
+ inputs["class_levels"] = class_levels
131
+ inputs["class_hit_dice"] = class_hit_dice
132
+ inputs["class_spellcasting_types"] = class_spellcasting_types
133
+
134
+ # ------------------------------------------------------------------
135
+ # 5. Spellcasting
136
+ # ------------------------------------------------------------------
137
+ inputs["spellcasting_type"] = class_mechanics.spellcasting_type
138
+
139
+ # ------------------------------------------------------------------
140
+ # 6. Speed (from species)
141
+ # ------------------------------------------------------------------
142
+ if species_mechanics:
143
+ inputs["speed_base"] = species_mechanics.speed.walk
144
+ else:
145
+ inputs["speed_base"] = 30
146
+
147
+ # ------------------------------------------------------------------
148
+ # 7. Equipment → AC inputs
149
+ # ------------------------------------------------------------------
150
+ if equipped_armor:
151
+ inputs["armor_type"] = equipped_armor.armor_type
152
+ inputs["armor_magic_bonus"] = equipped_armor.magic_bonus
153
+ else:
154
+ inputs["armor_type"] = "none"
155
+ inputs["armor_magic_bonus"] = 0
156
+ inputs["has_shield"] = has_shield
157
+
158
+ # ------------------------------------------------------------------
159
+ # 8. Saving throw proficiencies
160
+ # ------------------------------------------------------------------
161
+ save_profs: set[str] = set(class_mechanics.saving_throw_proficiencies)
162
+ # Subclass might grant additional saves (rare but possible)
163
+ if subclass_mechanics:
164
+ for feat_mech in subclass_mechanics.features:
165
+ for prof in feat_mech.grants_proficiency:
166
+ if prof in ("strength", "dexterity", "constitution",
167
+ "intelligence", "wisdom", "charisma"):
168
+ save_profs.add(prof)
169
+
170
+ for ability in ("strength", "dexterity", "constitution",
171
+ "intelligence", "wisdom", "charisma"):
172
+ inputs[f"save.{ability}.proficient"] = ability in save_profs
173
+
174
+ # ------------------------------------------------------------------
175
+ # 9. Skill proficiencies (aggregated from all sources)
176
+ # ------------------------------------------------------------------
177
+ skill_profs: set[str] = set()
178
+ expertise_skills: set[str] = set()
179
+
180
+ # From class
181
+ # Note: class provides OPTIONS, not all of them. The user picks
182
+ # skill_proficiency_count from skill_proficiency_options.
183
+ # For the assembler, we accept a pre-selected set. If not provided,
184
+ # we use whatever the class offers (for backwards compat).
185
+ for skill in class_mechanics.skill_proficiency_options:
186
+ skill_profs.add(skill.lower().replace(" ", "_"))
187
+
188
+ # From background
189
+ if background_mechanics:
190
+ for skill in background_mechanics.skill_proficiencies:
191
+ skill_profs.add(skill.lower().replace(" ", "_"))
192
+
193
+ # From species
194
+ if species_mechanics:
195
+ for skill in species_mechanics.skill_proficiencies:
196
+ skill_profs.add(skill.lower().replace(" ", "_"))
197
+
198
+ # From subclass
199
+ if subclass_mechanics:
200
+ for prof in subclass_mechanics.bonus_proficiencies:
201
+ normalized = prof.lower().replace(" ", "_")
202
+ if normalized in SKILL_ABILITY_MAP:
203
+ skill_profs.add(normalized)
204
+
205
+ # Features can grant proficiency or expertise
206
+ for feat_mech in subclass_mechanics.features:
207
+ if feat_mech.level <= level:
208
+ for prof in feat_mech.grants_proficiency:
209
+ normalized = prof.lower().replace(" ", "_")
210
+ if normalized in SKILL_ABILITY_MAP:
211
+ skill_profs.add(normalized)
212
+ for exp in feat_mech.grants_expertise:
213
+ expertise_skills.add(exp.lower().replace(" ", "_"))
214
+
215
+ # From class features at current level
216
+ for feat_mech in class_mechanics.progression:
217
+ if feat_mech.level <= level:
218
+ for prof in feat_mech.grants_proficiency:
219
+ normalized = prof.lower().replace(" ", "_")
220
+ if normalized in SKILL_ABILITY_MAP:
221
+ skill_profs.add(normalized)
222
+ for exp in feat_mech.grants_expertise:
223
+ expertise_skills.add(exp.lower().replace(" ", "_"))
224
+
225
+ # From feats
226
+ if feats:
227
+ for feat in feats:
228
+ for prof in feat.grants_proficiency:
229
+ normalized = prof.lower().replace(" ", "_")
230
+ if normalized in SKILL_ABILITY_MAP:
231
+ skill_profs.add(normalized)
232
+
233
+ for skill in SKILL_ABILITY_MAP:
234
+ inputs[f"skill.{skill}.proficient"] = skill in skill_profs
235
+ inputs[f"skill.{skill}.expertise"] = skill in expertise_skills
236
+
237
+ # ------------------------------------------------------------------
238
+ # 10. Collect NodeModifiers from all components
239
+ # ------------------------------------------------------------------
240
+ modifiers = _collect_modifiers(
241
+ class_mechanics=class_mechanics,
242
+ species_mechanics=species_mechanics,
243
+ subclass_mechanics=subclass_mechanics,
244
+ feats=feats,
245
+ level=level,
246
+ )
247
+ # Store modifiers for post-evaluation application
248
+ inputs["_node_modifiers"] = modifiers
249
+
250
+ return inputs
251
+
252
+
253
+ def apply_modifiers(
254
+ computed: dict[str, Any],
255
+ inputs: dict[str, Any],
256
+ ) -> dict[str, Any]:
257
+ """Apply NodeModifiers to computed values after graph evaluation.
258
+
259
+ Some features/feats/traits add flat bonuses to computed values
260
+ (e.g., Alert feat adds +5 to initiative). These are applied AFTER
261
+ the base graph evaluation.
262
+
263
+ Returns a new dict with modifiers applied.
264
+ """
265
+ modifiers: list[NodeModifier] = inputs.get("_node_modifiers", [])
266
+ if not modifiers:
267
+ return computed
268
+
269
+ result = dict(computed)
270
+ for mod in modifiers:
271
+ if mod.target_node not in result:
272
+ continue
273
+
274
+ current = result[mod.target_node]
275
+ if current is None:
276
+ continue
277
+
278
+ if mod.op == "add" and isinstance(current, (int, float)):
279
+ result[mod.target_node] = current + mod.value
280
+ elif mod.op == "set":
281
+ result[mod.target_node] = mod.value
282
+ elif mod.op == "max" and isinstance(current, (int, float)):
283
+ result[mod.target_node] = max(current, mod.value)
284
+ elif mod.op == "min" and isinstance(current, (int, float)):
285
+ result[mod.target_node] = min(current, mod.value)
286
+ elif mod.op == "multiply" and isinstance(current, (int, float)):
287
+ result[mod.target_node] = current * mod.value
288
+
289
+ return result
290
+
291
+
292
+ def _collect_modifiers(
293
+ class_mechanics: ClassMechanics,
294
+ species_mechanics: SpeciesMechanics | None,
295
+ subclass_mechanics: SubclassMechanics | None,
296
+ feats: list[FeatMechanics] | None,
297
+ level: int,
298
+ ) -> list[NodeModifier]:
299
+ """Collect all NodeModifiers from all components, filtered by level."""
300
+ modifiers: list[NodeModifier] = []
301
+
302
+ # Class features at or below current level
303
+ for feat_mech in class_mechanics.progression:
304
+ if feat_mech.level <= level:
305
+ modifiers.extend(feat_mech.modifies_node)
306
+
307
+ # Species traits (always active)
308
+ if species_mechanics:
309
+ for trait in species_mechanics.traits:
310
+ modifiers.extend(trait.modifies_node)
311
+
312
+ # Subclass features at or below current level
313
+ if subclass_mechanics:
314
+ for feat_mech in subclass_mechanics.features:
315
+ if feat_mech.level <= level:
316
+ modifiers.extend(feat_mech.modifies_node)
317
+
318
+ # Feats (always active once taken)
319
+ if feats:
320
+ for feat in feats:
321
+ modifiers.extend(feat.modifies_node)
322
+
323
+ return modifiers
324
+
325
+
326
+ def assemble_creature_inputs(
327
+ mechanics: CreatureMechanics,
328
+ ) -> dict[str, Any]:
329
+ """Assemble creature mechanics into graph inputs for a creature ruleset.
330
+
331
+ Creatures use a different graph than characters (CR-based proficiency,
332
+ size-based hit dice, no class levels).
333
+ """
334
+
335
+ inputs: dict[str, Any] = {}
336
+
337
+ # Ability scores
338
+ for ability in ("strength", "dexterity", "constitution",
339
+ "intelligence", "wisdom", "charisma"):
340
+ inputs[f"{ability}_score"] = mechanics.ability_scores.get(ability, 10)
341
+
342
+ # CR and size
343
+ inputs["cr"] = mechanics.cr
344
+ inputs["cr_numeric"] = mechanics.cr_numeric
345
+ inputs["size"] = mechanics.size
346
+ inputs["creature_type"] = mechanics.creature_type
347
+ inputs["hp_dice_count"] = mechanics.hp_dice_count
348
+
349
+ # AC (for creatures, AC is an independent value — includes natural armor)
350
+ inputs["ac"] = mechanics.ac
351
+ inputs["ac_type"] = mechanics.ac_type
352
+
353
+ # Speed
354
+ inputs["speed_walk"] = mechanics.speed.walk
355
+ inputs["speed_fly"] = mechanics.speed.fly
356
+ inputs["speed_swim"] = mechanics.speed.swim
357
+ inputs["speed_climb"] = mechanics.speed.climb
358
+ inputs["speed_burrow"] = mechanics.speed.burrow
359
+
360
+ # Save proficiencies
361
+ for ability in ("strength", "dexterity", "constitution",
362
+ "intelligence", "wisdom", "charisma"):
363
+ inputs[f"save.{ability}.proficient"] = (
364
+ ability in mechanics.saving_throw_proficiencies
365
+ )
366
+
367
+ # Skill proficiencies
368
+ for skill in SKILL_ABILITY_MAP:
369
+ inputs[f"skill.{skill}.proficient"] = (
370
+ skill in [s.lower().replace(" ", "_") for s in mechanics.skill_proficiencies]
371
+ )
372
+
373
+ # Boss features
374
+ inputs["legendary_action_count"] = mechanics.legendary_action_count
375
+ inputs["legendary_resistances"] = mechanics.legendary_resistances
376
+
377
+ return inputs
@@ -0,0 +1,248 @@
1
+ """Character evaluator — single entry point for computing character sheets.
2
+
3
+ Replaces the old derive_character_sheet() with computation graph evaluation.
4
+ Pure functions, no I/O, no async.
5
+
6
+ Usage:
7
+ from rules.character_evaluator import evaluate_character, compute_stat_diff
8
+
9
+ sheet = evaluate_character(session_data)
10
+ diff = compute_stat_diff(before_data, after_data)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from typing import Any
17
+
18
+ from .adapters import character_data_to_inputs, computed_values_to_sheet
19
+ from .assembler import apply_modifiers
20
+ from .dnd_5e_2024 import DND_5E_2024_RULESET
21
+ from .evaluator import evaluate
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Key stats to include in diffs and summaries
26
+ KEY_STAT_NODES = {
27
+ "hp_max": "Hit Points",
28
+ "armor_class": "Armor Class",
29
+ "initiative": "Initiative",
30
+ "speed_base": "Speed",
31
+ "proficiency_bonus": "Proficiency Bonus",
32
+ "spell_save_dc": "Spell Save DC",
33
+ "spell_attack": "Spell Attack",
34
+ "save.strength.bonus": "STR Save",
35
+ "save.dexterity.bonus": "DEX Save",
36
+ "save.constitution.bonus": "CON Save",
37
+ "save.intelligence.bonus": "INT Save",
38
+ "save.wisdom.bonus": "WIS Save",
39
+ "save.charisma.bonus": "CHA Save",
40
+ "strength_mod": "STR Modifier",
41
+ "dexterity_mod": "DEX Modifier",
42
+ "constitution_mod": "CON Modifier",
43
+ "intelligence_mod": "INT Modifier",
44
+ "wisdom_mod": "WIS Modifier",
45
+ "charisma_mod": "CHA Modifier",
46
+ "passive_perception": "Passive Perception",
47
+ }
48
+
49
+
50
+ def _extract_session_fields(data: dict) -> dict:
51
+ """Extract the fields needed for evaluation from session data.
52
+
53
+ Session data may be the full session dict or the inner 'data' sub-dict.
54
+ This normalizes either format.
55
+ """
56
+ # If this is a full session, unwrap to the data sub-dict
57
+ inner = data.get("data", data)
58
+
59
+ ability_scores = inner.get("ability_scores") or inner.get(
60
+ "modified_ability_scores", {}
61
+ )
62
+ if not ability_scores:
63
+ # Try identity phase format
64
+ identity = inner.get("identity", {})
65
+ ability_scores = identity.get("ability_scores", {})
66
+
67
+ class_data = inner.get("class_data") or inner.get("class", {}) or {}
68
+ subclass_data = inner.get("subclass_data") or inner.get("subclass")
69
+ species_data = inner.get("species_data") or inner.get("species", {}) or {}
70
+ background_data = inner.get("background_data") or inner.get("background")
71
+ level = inner.get("level", 1)
72
+ equipment = inner.get("equipment")
73
+ spells = inner.get("spells")
74
+ character_name = inner.get("character_name", "Unnamed")
75
+ alignment = inner.get("alignment")
76
+ selected_feats = inner.get("selected_feats", [])
77
+
78
+ # Narrative pass-through
79
+ narrative = {}
80
+ for key in ("backstory", "personality", "appearance", "concept"):
81
+ if inner.get(key):
82
+ narrative[key] = inner[key]
83
+
84
+ return {
85
+ "ability_scores": ability_scores,
86
+ "class_data": class_data,
87
+ "subclass_data": subclass_data,
88
+ "species_data": species_data,
89
+ "background_data": background_data,
90
+ "level": level,
91
+ "equipment": equipment,
92
+ "spells": spells,
93
+ "narrative": narrative,
94
+ "character_name": character_name,
95
+ "alignment": alignment,
96
+ "selected_feats": selected_feats,
97
+ }
98
+
99
+
100
+ def evaluate_character(session_data: dict) -> dict:
101
+ """Evaluate a character from session data → full computed character sheet.
102
+
103
+ This is the single entry point that replaces derive_character_sheet().
104
+ Pure function, no I/O, no async.
105
+
106
+ Args:
107
+ session_data: Session data dict (full session or inner data sub-dict).
108
+
109
+ Returns:
110
+ Complete character sheet dict matching the old derive_character_sheet() shape.
111
+ """
112
+ fields = _extract_session_fields(session_data)
113
+
114
+ # Convert to flat graph inputs
115
+ inputs = character_data_to_inputs(
116
+ ability_scores=fields["ability_scores"],
117
+ class_data=fields["class_data"],
118
+ subclass_data=fields["subclass_data"],
119
+ species_data=fields["species_data"],
120
+ background_data=fields["background_data"],
121
+ level=fields["level"],
122
+ equipment=fields["equipment"],
123
+ )
124
+
125
+ # Evaluate the computation graph
126
+ computed = evaluate(DND_5E_2024_RULESET, inputs)
127
+
128
+ # Apply NodeModifiers from feats, class features, etc.
129
+ computed = apply_modifiers(computed, inputs)
130
+
131
+ # Reshape to character sheet format
132
+ sheet = computed_values_to_sheet(
133
+ computed=computed,
134
+ ability_scores=fields["ability_scores"],
135
+ class_data=fields["class_data"],
136
+ subclass_data=fields["subclass_data"],
137
+ species_data=fields["species_data"],
138
+ background_data=fields["background_data"],
139
+ level=fields["level"],
140
+ equipment=fields["equipment"],
141
+ spells=fields["spells"],
142
+ narrative=fields["narrative"],
143
+ character_name=fields["character_name"],
144
+ alignment=fields["alignment"],
145
+ selected_feats=fields["selected_feats"],
146
+ )
147
+
148
+ return sheet
149
+
150
+
151
+ def compute_stat_diff(
152
+ before_data: dict,
153
+ after_data: dict,
154
+ ) -> dict[str, dict[str, Any]]:
155
+ """Compute before/after stat changes between two session states.
156
+
157
+ Returns a dict of node_id → {before, after, delta, label} for stats
158
+ that changed. Only includes KEY_STAT_NODES for readability.
159
+
160
+ Args:
161
+ before_data: Session data before the change.
162
+ after_data: Session data after the change.
163
+
164
+ Returns:
165
+ Dict of changed stats with before/after/delta/label.
166
+ """
167
+ before_fields = _extract_session_fields(before_data)
168
+ after_fields = _extract_session_fields(after_data)
169
+
170
+ # Build graph inputs for both states
171
+ before_inputs = character_data_to_inputs(
172
+ ability_scores=before_fields["ability_scores"],
173
+ class_data=before_fields["class_data"],
174
+ subclass_data=before_fields["subclass_data"],
175
+ species_data=before_fields["species_data"],
176
+ background_data=before_fields["background_data"],
177
+ level=before_fields["level"],
178
+ equipment=before_fields["equipment"],
179
+ )
180
+ after_inputs = character_data_to_inputs(
181
+ ability_scores=after_fields["ability_scores"],
182
+ class_data=after_fields["class_data"],
183
+ subclass_data=after_fields["subclass_data"],
184
+ species_data=after_fields["species_data"],
185
+ background_data=after_fields["background_data"],
186
+ level=after_fields["level"],
187
+ equipment=after_fields["equipment"],
188
+ )
189
+
190
+ # Evaluate both
191
+ before_computed = apply_modifiers(
192
+ evaluate(DND_5E_2024_RULESET, before_inputs), before_inputs
193
+ )
194
+ after_computed = apply_modifiers(
195
+ evaluate(DND_5E_2024_RULESET, after_inputs), after_inputs
196
+ )
197
+
198
+ # Build diff for key stats only
199
+ diff: dict[str, dict[str, Any]] = {}
200
+ for node_id, label in KEY_STAT_NODES.items():
201
+ before_val = before_computed.get(node_id)
202
+ after_val = after_computed.get(node_id)
203
+
204
+ # Skip non-numeric values and unchanged values
205
+ if not isinstance(before_val, (int, float)) or not isinstance(
206
+ after_val, (int, float)
207
+ ):
208
+ continue
209
+ if before_val == after_val:
210
+ continue
211
+
212
+ diff[node_id] = {
213
+ "before": before_val,
214
+ "after": after_val,
215
+ "delta": after_val - before_val,
216
+ "label": label,
217
+ }
218
+
219
+ return diff
220
+
221
+
222
+ def compute_key_stats(session_data: dict) -> dict[str, Any]:
223
+ """Compute key stats from session data for display purposes.
224
+
225
+ Returns a flat dict of node_id → value for KEY_STAT_NODES only.
226
+ Useful for showing baseline stats in advancement options.
227
+ """
228
+ fields = _extract_session_fields(session_data)
229
+
230
+ inputs = character_data_to_inputs(
231
+ ability_scores=fields["ability_scores"],
232
+ class_data=fields["class_data"],
233
+ subclass_data=fields["subclass_data"],
234
+ species_data=fields["species_data"],
235
+ background_data=fields["background_data"],
236
+ level=fields["level"],
237
+ equipment=fields["equipment"],
238
+ )
239
+
240
+ computed = apply_modifiers(
241
+ evaluate(DND_5E_2024_RULESET, inputs), inputs
242
+ )
243
+
244
+ return {
245
+ node_id: {"value": computed.get(node_id), "label": label}
246
+ for node_id, label in KEY_STAT_NODES.items()
247
+ if computed.get(node_id) is not None
248
+ }