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.
- dndwright/__init__.py +69 -0
- dndwright/content/__init__.py +56 -0
- dndwright/content/classes.json +147 -0
- dndwright/content/creatures.json +334 -0
- dndwright/content/generate.py +87 -0
- dndwright/content/magic_items.json +1915 -0
- dndwright/content/species.json +111 -0
- dndwright/ontology/__init__.py +25 -0
- dndwright/ontology/dnd.yaml +141 -0
- dndwright/ontology/loader.py +104 -0
- dndwright/rules/__init__.py +35 -0
- dndwright/rules/adapters.py +691 -0
- dndwright/rules/assembler.py +377 -0
- dndwright/rules/character_evaluator.py +248 -0
- dndwright/rules/components.py +654 -0
- dndwright/rules/dnd_5e_2024.py +606 -0
- dndwright/rules/evaluator.py +217 -0
- dndwright/rules/lookup_tables.py +501 -0
- dndwright/rules/operations.py +412 -0
- dndwright/rules/schema.py +69 -0
- dndwright/rules/theme_scaling.py +207 -0
- dndwright-0.2.0.dist-info/METADATA +101 -0
- dndwright-0.2.0.dist-info/RECORD +26 -0
- dndwright-0.2.0.dist-info/WHEEL +4 -0
- dndwright-0.2.0.dist-info/licenses/LICENSE +21 -0
- dndwright-0.2.0.dist-info/licenses/NOTICE +24 -0
|
@@ -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
|
+
}
|