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,691 @@
|
|
|
1
|
+
"""Adapters for backwards compatibility.
|
|
2
|
+
|
|
3
|
+
Converts between the old derive_character_sheet() input/output format
|
|
4
|
+
and the computation graph's flat input/output dictionaries.
|
|
5
|
+
|
|
6
|
+
Phase 1: transparent refactor — same inputs in, same output shape out.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .lookup_tables import (
|
|
14
|
+
HIT_DIE_BY_ARCHETYPE,
|
|
15
|
+
HIT_DIE_BY_CLASS,
|
|
16
|
+
SAVE_PROFICIENCIES_BY_CLASS,
|
|
17
|
+
SKILL_ABILITY_MAP,
|
|
18
|
+
SPELLCASTING_TYPE_BY_CLASS,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def character_data_to_inputs(
|
|
23
|
+
ability_scores: dict[str, int],
|
|
24
|
+
class_data: dict,
|
|
25
|
+
subclass_data: dict | None,
|
|
26
|
+
species_data: dict,
|
|
27
|
+
background_data: dict | None,
|
|
28
|
+
level: int,
|
|
29
|
+
equipment: dict | None = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""Convert the old derive_character_sheet() parameters to flat graph inputs.
|
|
32
|
+
|
|
33
|
+
Returns a dict keyed by computation node IDs.
|
|
34
|
+
"""
|
|
35
|
+
inputs: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
# --- Ability scores ---
|
|
38
|
+
for ability in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"):
|
|
39
|
+
inputs[f"{ability}_score"] = ability_scores.get(ability, 10)
|
|
40
|
+
|
|
41
|
+
# --- Level and class ---
|
|
42
|
+
inputs["character_level"] = level
|
|
43
|
+
|
|
44
|
+
class_name = class_data.get("class_name", "").lower()
|
|
45
|
+
inputs["primary_class"] = class_name
|
|
46
|
+
|
|
47
|
+
# Build class_levels dict (single class for now, multiclass in Phase 2)
|
|
48
|
+
inputs["class_levels"] = {class_name: level} if class_name else {"unknown": level}
|
|
49
|
+
|
|
50
|
+
# Hit dice per class
|
|
51
|
+
hit_die_str = class_data.get("hit_die", "d8")
|
|
52
|
+
hit_die_size = int(hit_die_str.replace("d", "")) if isinstance(hit_die_str, str) else 8
|
|
53
|
+
# Also try lookup by class name or archetype
|
|
54
|
+
archetype = class_data.get("archetype") or class_data.get("properties", {}).get("archetype", "")
|
|
55
|
+
if class_name in HIT_DIE_BY_CLASS:
|
|
56
|
+
hit_die_size = HIT_DIE_BY_CLASS[class_name]
|
|
57
|
+
elif archetype in HIT_DIE_BY_ARCHETYPE:
|
|
58
|
+
hit_die_size = HIT_DIE_BY_ARCHETYPE[archetype]
|
|
59
|
+
inputs["class_hit_dice"] = (
|
|
60
|
+
{class_name: hit_die_size} if class_name else {"unknown": hit_die_size}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# --- Speed ---
|
|
64
|
+
speed = species_data.get("speed", 30)
|
|
65
|
+
if isinstance(speed, dict):
|
|
66
|
+
inputs["speed_base"] = speed.get("walk", 30)
|
|
67
|
+
else:
|
|
68
|
+
inputs["speed_base"] = speed
|
|
69
|
+
|
|
70
|
+
# --- Equipment → AC inputs ---
|
|
71
|
+
armor_type = "none"
|
|
72
|
+
armor_magic = 0
|
|
73
|
+
has_shield = False
|
|
74
|
+
if equipment:
|
|
75
|
+
armor = equipment.get("armor")
|
|
76
|
+
if armor:
|
|
77
|
+
armor_type = (
|
|
78
|
+
(armor.get("type") or armor.get("armor_type") or armor.get("base_armor") or "none")
|
|
79
|
+
.lower()
|
|
80
|
+
.replace(" ", "_")
|
|
81
|
+
)
|
|
82
|
+
armor_magic = armor.get("magic_bonus", 0)
|
|
83
|
+
has_shield = bool(equipment.get("shield"))
|
|
84
|
+
|
|
85
|
+
inputs["armor_type"] = armor_type
|
|
86
|
+
inputs["armor_magic_bonus"] = armor_magic
|
|
87
|
+
inputs["has_shield"] = has_shield
|
|
88
|
+
|
|
89
|
+
# --- Spellcasting ---
|
|
90
|
+
spellcasting_type = class_data.get("properties", {}).get("spellcasting_type") or class_data.get(
|
|
91
|
+
"spellcasting_type", "none"
|
|
92
|
+
)
|
|
93
|
+
# Also use lookup table as fallback
|
|
94
|
+
if spellcasting_type == "none" and class_name in SPELLCASTING_TYPE_BY_CLASS:
|
|
95
|
+
spellcasting_type = SPELLCASTING_TYPE_BY_CLASS[class_name]
|
|
96
|
+
inputs["spellcasting_type"] = spellcasting_type
|
|
97
|
+
inputs["class_spellcasting_types"] = {class_name: spellcasting_type} if class_name else {}
|
|
98
|
+
|
|
99
|
+
# --- Saving throw proficiencies ---
|
|
100
|
+
class_save_profs = (
|
|
101
|
+
class_data.get("properties", {}).get("saving_throw_proficiencies", [])
|
|
102
|
+
or class_data.get("saving_throw_proficiencies", [])
|
|
103
|
+
or class_data.get("properties", {}).get("saving_throws", [])
|
|
104
|
+
or class_data.get("saving_throws", [])
|
|
105
|
+
)
|
|
106
|
+
save_prof_set = {s.lower() for s in class_save_profs}
|
|
107
|
+
# Fallback to lookup table
|
|
108
|
+
if not save_prof_set and class_name in SAVE_PROFICIENCIES_BY_CLASS:
|
|
109
|
+
save_prof_set = set(SAVE_PROFICIENCIES_BY_CLASS[class_name])
|
|
110
|
+
|
|
111
|
+
for ability in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"):
|
|
112
|
+
inputs[f"save.{ability}.proficient"] = ability in save_prof_set
|
|
113
|
+
|
|
114
|
+
# --- Skill proficiencies ---
|
|
115
|
+
skill_profs: set[str] = set()
|
|
116
|
+
expertise_skills: set[str] = set()
|
|
117
|
+
|
|
118
|
+
# From class
|
|
119
|
+
class_skills = class_data.get("properties", {}).get(
|
|
120
|
+
"skill_proficiencies", []
|
|
121
|
+
) or class_data.get("skill_proficiencies", [])
|
|
122
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in class_skills)
|
|
123
|
+
|
|
124
|
+
# From background
|
|
125
|
+
if background_data:
|
|
126
|
+
bg_skills = background_data.get("skill_proficiencies", [])
|
|
127
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in bg_skills)
|
|
128
|
+
|
|
129
|
+
# From species
|
|
130
|
+
species_skills = species_data.get("properties", {}).get(
|
|
131
|
+
"skill_proficiencies", []
|
|
132
|
+
) or species_data.get("skill_proficiencies", [])
|
|
133
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in species_skills)
|
|
134
|
+
|
|
135
|
+
for skill in SKILL_ABILITY_MAP:
|
|
136
|
+
inputs[f"skill.{skill}.proficient"] = skill in skill_profs
|
|
137
|
+
inputs[f"skill.{skill}.expertise"] = skill in expertise_skills
|
|
138
|
+
|
|
139
|
+
return inputs
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def computed_values_to_sheet(
|
|
143
|
+
computed: dict[str, Any],
|
|
144
|
+
ability_scores: dict[str, int],
|
|
145
|
+
class_data: dict,
|
|
146
|
+
subclass_data: dict | None,
|
|
147
|
+
species_data: dict,
|
|
148
|
+
background_data: dict | None,
|
|
149
|
+
level: int,
|
|
150
|
+
equipment: dict | None = None,
|
|
151
|
+
spells: dict | None = None,
|
|
152
|
+
narrative: dict | None = None,
|
|
153
|
+
character_name: str = "Unnamed",
|
|
154
|
+
alignment: str | None = None,
|
|
155
|
+
selected_feats: list[dict] | None = None,
|
|
156
|
+
) -> dict:
|
|
157
|
+
"""Reshape flat computed values back into the nested dict format.
|
|
158
|
+
|
|
159
|
+
This produces the same output shape as the old derive_character_sheet().
|
|
160
|
+
Non-computed fields (features, equipment details, personality) are passed through.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
def _fmt(val: int | None) -> str:
|
|
164
|
+
if val is None:
|
|
165
|
+
val = 0
|
|
166
|
+
return f"+{val}" if val >= 0 else str(val)
|
|
167
|
+
|
|
168
|
+
class_name = class_data.get("class_name", "Unknown")
|
|
169
|
+
|
|
170
|
+
# --- Ability modifiers ---
|
|
171
|
+
# `ability_modifiers` is the integer-typed canonical shape — the
|
|
172
|
+
# frontend (and the prior Rust compute_stats stub) consume it as
|
|
173
|
+
# numbers and do downstream math like `mod + prof_bonus`. The
|
|
174
|
+
# formatted "+N" strings ship in parallel as
|
|
175
|
+
# `ability_modifiers_display` so any display-only callers can keep
|
|
176
|
+
# using them without reformatting. Originally only the display
|
|
177
|
+
# shape was emitted, which silently NaN'd downstream math.
|
|
178
|
+
ability_modifiers_int = {}
|
|
179
|
+
ability_modifiers_display = {}
|
|
180
|
+
for ability in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"):
|
|
181
|
+
ability_modifiers_int[ability] = int(computed.get(f"{ability}_mod", 0))
|
|
182
|
+
ability_modifiers_display[ability] = computed.get(
|
|
183
|
+
f"{ability}_mod_display", _fmt(computed.get(f"{ability}_mod", 0))
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# --- Saving throws ---
|
|
187
|
+
saving_throws = {}
|
|
188
|
+
for ability in ("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"):
|
|
189
|
+
mod = computed.get(f"{ability}_mod", 0)
|
|
190
|
+
proficient = computed.get(f"save.{ability}.proficient", False)
|
|
191
|
+
bonus = computed.get(f"save.{ability}.bonus", mod)
|
|
192
|
+
saving_throws[ability] = {
|
|
193
|
+
"modifier": mod,
|
|
194
|
+
"proficient": proficient,
|
|
195
|
+
"bonus": bonus,
|
|
196
|
+
"display": computed.get(f"save.{ability}.display", _fmt(bonus)),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
# --- Skills ---
|
|
200
|
+
skills = {}
|
|
201
|
+
for skill, ability in SKILL_ABILITY_MAP.items():
|
|
202
|
+
proficient = computed.get(f"skill.{skill}.proficient", False)
|
|
203
|
+
expertise = computed.get(f"skill.{skill}.expertise", False)
|
|
204
|
+
bonus = computed.get(f"skill.{skill}.bonus", 0)
|
|
205
|
+
skills[skill] = {
|
|
206
|
+
"ability": ability,
|
|
207
|
+
"proficient": proficient,
|
|
208
|
+
"expertise": expertise,
|
|
209
|
+
"bonus": bonus,
|
|
210
|
+
"display": computed.get(f"skill.{skill}.display", _fmt(bonus)),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# --- Passive scores ---
|
|
214
|
+
passive_scores = {
|
|
215
|
+
"perception": computed.get("passive_perception", 10),
|
|
216
|
+
"investigation": computed.get("passive_investigation", 10),
|
|
217
|
+
"insight": computed.get("passive_insight", 10),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# --- Speed ---
|
|
221
|
+
speed = species_data.get("speed", 30)
|
|
222
|
+
if isinstance(speed, dict):
|
|
223
|
+
movement_types = speed
|
|
224
|
+
speed_val = speed.get("walk", 30)
|
|
225
|
+
else:
|
|
226
|
+
movement_types = {"walk": speed}
|
|
227
|
+
speed_val = speed
|
|
228
|
+
|
|
229
|
+
# --- Hit dice ---
|
|
230
|
+
hit_die = class_data.get("hit_die", "d8")
|
|
231
|
+
hit_dice_str = computed.get("hit_dice", f"{level}{hit_die}")
|
|
232
|
+
|
|
233
|
+
# --- Spellcasting ---
|
|
234
|
+
# `character_data_to_inputs` falls back to SPELLCASTING_TYPE_BY_CLASS when
|
|
235
|
+
# class_data doesn't declare spellcasting_type — but only writes it into
|
|
236
|
+
# `inputs["spellcasting_type"]`. The sheet builder used to re-read from
|
|
237
|
+
# class_data only, so a thin class_data (e.g. {"class_name": "wizard"})
|
|
238
|
+
# ended up with `spellcasting=None` even though the inputs/compute path
|
|
239
|
+
# correctly identified it as a full_caster. Fall back to the computed
|
|
240
|
+
# value (which carries the lookup-resolved type) before defaulting to "none".
|
|
241
|
+
spellcasting_info = None
|
|
242
|
+
spellcasting_type = (
|
|
243
|
+
class_data.get("properties", {}).get("spellcasting_type")
|
|
244
|
+
or class_data.get("spellcasting_type")
|
|
245
|
+
or computed.get("spellcasting_type")
|
|
246
|
+
or "none"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if spellcasting_type and spellcasting_type != "none":
|
|
250
|
+
spell_ability_name = computed.get("spell_ability", "charisma")
|
|
251
|
+
|
|
252
|
+
cantrips_known = []
|
|
253
|
+
spells_known = []
|
|
254
|
+
if spells:
|
|
255
|
+
cantrips_known = spells.get("selected_cantrips", [])
|
|
256
|
+
spells_known = spells.get("selected_spells", [])
|
|
257
|
+
|
|
258
|
+
# Determine which spell slots to use (multiclass vs single)
|
|
259
|
+
class_levels = computed.get("class_levels", {})
|
|
260
|
+
num_classes = len(class_levels) if isinstance(class_levels, dict) else 1
|
|
261
|
+
if num_classes > 1:
|
|
262
|
+
spell_slots = computed.get("multiclass_spell_slots", {})
|
|
263
|
+
else:
|
|
264
|
+
spell_slots = computed.get("spell_slots", {})
|
|
265
|
+
|
|
266
|
+
spellcasting_info = {
|
|
267
|
+
"ability": spell_ability_name.capitalize()
|
|
268
|
+
if isinstance(spell_ability_name, str)
|
|
269
|
+
else "Charisma",
|
|
270
|
+
"save_dc": computed.get("spell_save_dc", 8),
|
|
271
|
+
"attack_bonus": computed.get("spell_attack", 0),
|
|
272
|
+
"attack_display": computed.get(
|
|
273
|
+
"spell_attack_display", _fmt(computed.get("spell_attack", 0))
|
|
274
|
+
),
|
|
275
|
+
"spell_slots": spell_slots,
|
|
276
|
+
"cantrips_known": cantrips_known,
|
|
277
|
+
"spells_known": spells_known,
|
|
278
|
+
"spellcasting_type": spellcasting_type,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
# --- Proficiencies (pass-through from input data) ---
|
|
282
|
+
proficiencies = _build_proficiencies(class_data, species_data, background_data)
|
|
283
|
+
|
|
284
|
+
# --- Features & traits (pass-through from input data) ---
|
|
285
|
+
features_and_traits = _build_features(
|
|
286
|
+
class_data, subclass_data, species_data, background_data, selected_feats
|
|
287
|
+
)
|
|
288
|
+
species_traits_consolidated = _build_species_traits(species_data)
|
|
289
|
+
|
|
290
|
+
# --- Equipment (pass-through — weapon attack/damage calc stays imperative for now) ---
|
|
291
|
+
equipment_info = _build_equipment_info(equipment, computed, level)
|
|
292
|
+
|
|
293
|
+
# --- Personality (pass-through) ---
|
|
294
|
+
personality = _build_personality(narrative)
|
|
295
|
+
|
|
296
|
+
# --- Assemble ---
|
|
297
|
+
return {
|
|
298
|
+
"character_name": character_name,
|
|
299
|
+
"class_name": class_name,
|
|
300
|
+
"subclass_name": subclass_data.get("subclass_name") if subclass_data else None,
|
|
301
|
+
"level": level,
|
|
302
|
+
"species_name": species_data.get("species_name", species_data.get("name", "Unknown")),
|
|
303
|
+
"background_name": (
|
|
304
|
+
background_data.get("background_name", background_data.get("name", "Unknown"))
|
|
305
|
+
if background_data
|
|
306
|
+
else None
|
|
307
|
+
),
|
|
308
|
+
"alignment": alignment,
|
|
309
|
+
"ability_scores": ability_scores,
|
|
310
|
+
"ability_modifiers": ability_modifiers_int,
|
|
311
|
+
"ability_modifiers_display": ability_modifiers_display,
|
|
312
|
+
"armor_class": computed.get("armor_class", 10),
|
|
313
|
+
"hit_points": computed.get("hp_max", 1),
|
|
314
|
+
"hit_dice": hit_dice_str,
|
|
315
|
+
"hit_die_type": hit_die,
|
|
316
|
+
"speed": speed_val,
|
|
317
|
+
"movement_types": movement_types,
|
|
318
|
+
"initiative": computed.get("initiative", 0),
|
|
319
|
+
"initiative_display": computed.get("initiative_display", "+0"),
|
|
320
|
+
"proficiency_bonus": computed.get("proficiency_bonus", 2),
|
|
321
|
+
"proficiency_display": computed.get("proficiency_bonus_display", "+2"),
|
|
322
|
+
"saving_throws": saving_throws,
|
|
323
|
+
"skills": skills,
|
|
324
|
+
"passive_scores": passive_scores,
|
|
325
|
+
"proficiencies": proficiencies,
|
|
326
|
+
"spellcasting": spellcasting_info,
|
|
327
|
+
"features_and_traits": features_and_traits,
|
|
328
|
+
"species_traits_consolidated": species_traits_consolidated,
|
|
329
|
+
"equipment": equipment_info,
|
|
330
|
+
"personality": personality,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# Pass-through builders (features, proficiencies, etc. not in the graph yet)
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _build_proficiencies(
|
|
340
|
+
class_data: dict,
|
|
341
|
+
species_data: dict,
|
|
342
|
+
background_data: dict | None,
|
|
343
|
+
) -> dict:
|
|
344
|
+
"""Build proficiencies dict from source data (not computed by graph)."""
|
|
345
|
+
from .lookup_tables import ARCHETYPE_ARMOR_PROFICIENCIES, ARCHETYPE_WEAPON_PROFICIENCIES
|
|
346
|
+
|
|
347
|
+
# Languages
|
|
348
|
+
languages = []
|
|
349
|
+
species_langs = species_data.get("properties", {}).get("languages", []) or species_data.get(
|
|
350
|
+
"languages", []
|
|
351
|
+
)
|
|
352
|
+
languages.extend(species_langs)
|
|
353
|
+
if background_data:
|
|
354
|
+
languages.extend(background_data.get("languages", []))
|
|
355
|
+
languages = list(set(languages)) or ["Common"]
|
|
356
|
+
|
|
357
|
+
# Weapon proficiencies
|
|
358
|
+
weapon_profs = class_data.get("properties", {}).get(
|
|
359
|
+
"weapon_proficiencies", []
|
|
360
|
+
) or class_data.get("weapon_proficiencies", [])
|
|
361
|
+
armor_profs = class_data.get("properties", {}).get("armor_proficiencies", []) or class_data.get(
|
|
362
|
+
"armor_proficiencies", []
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
archetype = class_data.get("archetype") or class_data.get("properties", {}).get("archetype", "")
|
|
366
|
+
if not weapon_profs and archetype:
|
|
367
|
+
weapon_profs = ARCHETYPE_WEAPON_PROFICIENCIES.get(archetype, ["Simple weapons"])
|
|
368
|
+
if not armor_profs and archetype:
|
|
369
|
+
armor_profs = ARCHETYPE_ARMOR_PROFICIENCIES.get(archetype, [])
|
|
370
|
+
|
|
371
|
+
# Tool proficiencies
|
|
372
|
+
tool_profs = []
|
|
373
|
+
if background_data:
|
|
374
|
+
bg_tools = background_data.get(
|
|
375
|
+
"tool_proficiency", background_data.get("tool_proficiencies", [])
|
|
376
|
+
)
|
|
377
|
+
if isinstance(bg_tools, str):
|
|
378
|
+
tool_profs.append(bg_tools)
|
|
379
|
+
elif isinstance(bg_tools, list):
|
|
380
|
+
tool_profs.extend(bg_tools)
|
|
381
|
+
class_tools = class_data.get("properties", {}).get("tool_proficiencies", [])
|
|
382
|
+
tool_profs.extend(class_tools)
|
|
383
|
+
tool_profs = list(set(tool_profs))
|
|
384
|
+
|
|
385
|
+
# Save prof names
|
|
386
|
+
class_save_profs = (
|
|
387
|
+
class_data.get("properties", {}).get("saving_throw_proficiencies", [])
|
|
388
|
+
or class_data.get("saving_throw_proficiencies", [])
|
|
389
|
+
or class_data.get("properties", {}).get("saving_throws", [])
|
|
390
|
+
or class_data.get("saving_throws", [])
|
|
391
|
+
)
|
|
392
|
+
save_prof_names = [s.capitalize() for s in class_save_profs]
|
|
393
|
+
|
|
394
|
+
# Skill proficiency names
|
|
395
|
+
skill_profs: set[str] = set()
|
|
396
|
+
class_skills = class_data.get("properties", {}).get(
|
|
397
|
+
"skill_proficiencies", []
|
|
398
|
+
) or class_data.get("skill_proficiencies", [])
|
|
399
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in class_skills)
|
|
400
|
+
if background_data:
|
|
401
|
+
bg_skills = background_data.get("skill_proficiencies", [])
|
|
402
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in bg_skills)
|
|
403
|
+
species_skills = species_data.get("properties", {}).get(
|
|
404
|
+
"skill_proficiencies", []
|
|
405
|
+
) or species_data.get("skill_proficiencies", [])
|
|
406
|
+
skill_profs.update(s.lower().replace(" ", "_") for s in species_skills)
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
"languages": languages,
|
|
410
|
+
"weapons": weapon_profs,
|
|
411
|
+
"armor": armor_profs,
|
|
412
|
+
"tools": tool_profs,
|
|
413
|
+
"saving_throws": save_prof_names,
|
|
414
|
+
"skills": list(skill_profs),
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _build_features(
|
|
419
|
+
class_data: dict,
|
|
420
|
+
subclass_data: dict | None,
|
|
421
|
+
species_data: dict,
|
|
422
|
+
background_data: dict | None,
|
|
423
|
+
selected_feats: list[dict] | None,
|
|
424
|
+
) -> dict:
|
|
425
|
+
"""Build features and traits dict from source data."""
|
|
426
|
+
class_features = class_data.get("properties", {}).get("core_features", []) or class_data.get(
|
|
427
|
+
"core_features", []
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
subclass_features = []
|
|
431
|
+
if subclass_data:
|
|
432
|
+
subclass_features = subclass_data.get("features", []) or subclass_data.get(
|
|
433
|
+
"properties", {}
|
|
434
|
+
).get("features", [])
|
|
435
|
+
|
|
436
|
+
species_traits = species_data.get("traits", []) or species_data.get("properties", {}).get(
|
|
437
|
+
"core_traits", []
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
background_feature = None
|
|
441
|
+
origin_feat = None
|
|
442
|
+
if background_data:
|
|
443
|
+
bg_feature = background_data.get("feature", {})
|
|
444
|
+
if bg_feature:
|
|
445
|
+
background_feature = {
|
|
446
|
+
"name": bg_feature.get("name", "Background Feature"),
|
|
447
|
+
"description": bg_feature.get("description", ""),
|
|
448
|
+
}
|
|
449
|
+
origin_feat_data = background_data.get("origin_feat") or background_data.get("feat")
|
|
450
|
+
if origin_feat_data:
|
|
451
|
+
if isinstance(origin_feat_data, str):
|
|
452
|
+
origin_feat = {"name": origin_feat_data, "description": ""}
|
|
453
|
+
elif isinstance(origin_feat_data, dict):
|
|
454
|
+
origin_feat = {
|
|
455
|
+
"name": origin_feat_data.get("name", origin_feat_data.get("feat_name", "")),
|
|
456
|
+
"description": origin_feat_data.get("description", ""),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
"class_features": class_features,
|
|
461
|
+
"subclass_features": subclass_features,
|
|
462
|
+
"species_traits": species_traits,
|
|
463
|
+
"background_feature": background_feature,
|
|
464
|
+
"origin_feat": origin_feat,
|
|
465
|
+
"selected_feats": selected_feats or [],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _build_species_traits(species_data: dict) -> list[dict]:
|
|
470
|
+
"""Build consolidated species traits list."""
|
|
471
|
+
result = []
|
|
472
|
+
senses = species_data.get("senses", {})
|
|
473
|
+
if isinstance(senses, dict):
|
|
474
|
+
darkvision = senses.get("darkvision")
|
|
475
|
+
if darkvision:
|
|
476
|
+
distance = darkvision if isinstance(darkvision, int) else 60
|
|
477
|
+
result.append({"name": f"Darkvision {distance}ft", "category": "sense"})
|
|
478
|
+
for s in senses.get("special") or []:
|
|
479
|
+
s_name = s.get("name", s) if isinstance(s, dict) else s
|
|
480
|
+
result.append({"name": str(s_name), "category": "sense"})
|
|
481
|
+
|
|
482
|
+
rest_type = species_data.get("rest_type")
|
|
483
|
+
if (
|
|
484
|
+
rest_type
|
|
485
|
+
and isinstance(rest_type, str)
|
|
486
|
+
and rest_type.lower() not in ("long", "long rest", "standard")
|
|
487
|
+
):
|
|
488
|
+
result.append({"name": rest_type, "category": "rest"})
|
|
489
|
+
|
|
490
|
+
for r in species_data.get("resistances", []):
|
|
491
|
+
r_name = r.get("type", r.get("name", r)) if isinstance(r, dict) else r
|
|
492
|
+
result.append({"name": f"{r_name} Resistance", "category": "resistance"})
|
|
493
|
+
|
|
494
|
+
sp_immunities = species_data.get("immunities", {})
|
|
495
|
+
if isinstance(sp_immunities, dict):
|
|
496
|
+
for c in sp_immunities.get("conditions", []):
|
|
497
|
+
result.append({"name": f"{c} Immunity", "category": "immunity"})
|
|
498
|
+
for d in sp_immunities.get("damage", []):
|
|
499
|
+
result.append({"name": f"{d} Immunity", "category": "resistance"})
|
|
500
|
+
elif isinstance(sp_immunities, list):
|
|
501
|
+
for im in sp_immunities:
|
|
502
|
+
result.append({"name": f"{im} Immunity", "category": "immunity"})
|
|
503
|
+
|
|
504
|
+
for nw in species_data.get("natural_weapons", []):
|
|
505
|
+
nw_name = nw.get("name", nw) if isinstance(nw, dict) else nw
|
|
506
|
+
result.append({"name": str(nw_name), "category": "natural_weapon"})
|
|
507
|
+
|
|
508
|
+
for ct in species_data.get("traits", []):
|
|
509
|
+
ct_name = ct.get("name", ct) if isinstance(ct, dict) else ct
|
|
510
|
+
result.append({"name": str(ct_name), "category": "custom"})
|
|
511
|
+
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _build_equipment_info(
|
|
516
|
+
equipment: dict | None,
|
|
517
|
+
computed: dict[str, Any],
|
|
518
|
+
level: int,
|
|
519
|
+
) -> dict:
|
|
520
|
+
"""Build equipment info dict. Weapon attack/damage calcs still imperative."""
|
|
521
|
+
from .lookup_tables import (
|
|
522
|
+
ARMOR_BASE_AC,
|
|
523
|
+
ARMOR_MAX_DEX,
|
|
524
|
+
RARITY_UNLOCK_REQUIREMENTS,
|
|
525
|
+
WEAPON_MASTERY_DESCRIPTIONS,
|
|
526
|
+
WEAPON_MASTERY_MAP,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
equipment_info: dict[str, Any] = {
|
|
530
|
+
"weapons": [],
|
|
531
|
+
"armor": None,
|
|
532
|
+
"shield": False,
|
|
533
|
+
"other": [],
|
|
534
|
+
"currency": {"gp": 0, "sp": 0, "cp": 0},
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if not equipment:
|
|
538
|
+
return equipment_info
|
|
539
|
+
|
|
540
|
+
str_mod = computed.get("strength_mod", 0)
|
|
541
|
+
dex_mod = computed.get("dexterity_mod", 0)
|
|
542
|
+
prof_bonus = computed.get("proficiency_bonus", 2)
|
|
543
|
+
|
|
544
|
+
def _fmt(val: int) -> str:
|
|
545
|
+
return f"+{val}" if val >= 0 else str(val)
|
|
546
|
+
|
|
547
|
+
def _is_unlocked(rarity: str) -> bool:
|
|
548
|
+
reqs = RARITY_UNLOCK_REQUIREMENTS.get(rarity, {"min_level": 1})
|
|
549
|
+
return level >= reqs["min_level"]
|
|
550
|
+
|
|
551
|
+
# Weapons
|
|
552
|
+
weapons = list(equipment.get("weapons", []))
|
|
553
|
+
weapons.extend(equipment.get("signature_weapons", []))
|
|
554
|
+
for weapon in weapons:
|
|
555
|
+
weapon_name = weapon.get("name") or weapon.get("themed_name", "Unknown Weapon")
|
|
556
|
+
weapon_props = weapon.get("properties", [])
|
|
557
|
+
is_finesse = "finesse" in [p.lower() for p in weapon_props] if weapon_props else False
|
|
558
|
+
is_ranged = (
|
|
559
|
+
weapon.get("weapon_type", "").endswith("_ranged") or weapon.get("type") == "ranged"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if is_ranged:
|
|
563
|
+
attack_mod = dex_mod
|
|
564
|
+
elif is_finesse:
|
|
565
|
+
attack_mod = max(str_mod, dex_mod)
|
|
566
|
+
else:
|
|
567
|
+
attack_mod = str_mod
|
|
568
|
+
|
|
569
|
+
magic_bonus = weapon.get("magic_bonus", 0)
|
|
570
|
+
attack_bonus = attack_mod + prof_bonus + magic_bonus
|
|
571
|
+
damage_bonus = attack_mod + magic_bonus
|
|
572
|
+
|
|
573
|
+
weapon_rarity = weapon.get("rarity", "common")
|
|
574
|
+
unlock_reqs = RARITY_UNLOCK_REQUIREMENTS.get(weapon_rarity, {"min_level": 1, "min_xp": 0})
|
|
575
|
+
|
|
576
|
+
weapon_mastery = weapon.get("mastery")
|
|
577
|
+
if not weapon_mastery and weapon.get("base_weapon"):
|
|
578
|
+
weapon_mastery = WEAPON_MASTERY_MAP.get(weapon["base_weapon"].lower())
|
|
579
|
+
if not weapon_mastery:
|
|
580
|
+
weapon_mastery = WEAPON_MASTERY_MAP.get(weapon_name.lower())
|
|
581
|
+
|
|
582
|
+
# rule-text join for the equipped weapon's
|
|
583
|
+
# mastery. `mastery_description` is null for weapons with no
|
|
584
|
+
# mastery (simple 2024 weapons) AND for weapons with a mastery
|
|
585
|
+
# label that isn't in the descriptions map (typos / unknown).
|
|
586
|
+
# `mastery_unknown` distinguishes the two — when true, the
|
|
587
|
+
# frontend surfaces an "unknown mastery" warning rather than
|
|
588
|
+
# silently rendering a blank cell (anti-pattern #7).
|
|
589
|
+
mastery_description = (
|
|
590
|
+
WEAPON_MASTERY_DESCRIPTIONS.get(weapon_mastery) if weapon_mastery else None
|
|
591
|
+
)
|
|
592
|
+
mastery_unknown = weapon_mastery is not None and mastery_description is None
|
|
593
|
+
|
|
594
|
+
equipment_info["weapons"].append(
|
|
595
|
+
{
|
|
596
|
+
"name": weapon_name,
|
|
597
|
+
"attack_bonus": _fmt(attack_bonus),
|
|
598
|
+
"damage": weapon.get("damage", "1d6"),
|
|
599
|
+
"damage_type": weapon.get("damage_type", "bludgeoning"),
|
|
600
|
+
"damage_bonus": _fmt(damage_bonus) if damage_bonus != 0 else "",
|
|
601
|
+
"properties": weapon_props,
|
|
602
|
+
"mastery": weapon_mastery,
|
|
603
|
+
"mastery_description": mastery_description,
|
|
604
|
+
"mastery_unknown": mastery_unknown,
|
|
605
|
+
"magic_bonus": magic_bonus,
|
|
606
|
+
"description": weapon.get("description"),
|
|
607
|
+
"lore": weapon.get("lore"),
|
|
608
|
+
"appearance": weapon.get("appearance"),
|
|
609
|
+
"rarity": weapon_rarity,
|
|
610
|
+
"requires_attunement": weapon.get("requires_attunement", False),
|
|
611
|
+
"special_abilities": weapon.get("special_abilities", []),
|
|
612
|
+
"is_signature": weapon.get("is_signature", False),
|
|
613
|
+
"symbolism": weapon.get("symbolism", []),
|
|
614
|
+
"base_weapon": weapon.get("base_weapon"),
|
|
615
|
+
"unlock_level": unlock_reqs["min_level"],
|
|
616
|
+
"unlock_xp": unlock_reqs["min_xp"],
|
|
617
|
+
"is_locked": not _is_unlocked(weapon_rarity),
|
|
618
|
+
}
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# Armor
|
|
622
|
+
armor = equipment.get("armor")
|
|
623
|
+
if armor:
|
|
624
|
+
armor_type = (
|
|
625
|
+
(armor.get("type") or armor.get("armor_type") or armor.get("base_armor") or "none")
|
|
626
|
+
.lower()
|
|
627
|
+
.replace(" ", "_")
|
|
628
|
+
)
|
|
629
|
+
base_armor_ac = ARMOR_BASE_AC.get(armor_type, armor.get("base_ac", 10))
|
|
630
|
+
max_dex = ARMOR_MAX_DEX.get(armor_type, armor.get("max_dex_bonus"))
|
|
631
|
+
|
|
632
|
+
if max_dex == 0:
|
|
633
|
+
armor_ac = base_armor_ac
|
|
634
|
+
elif max_dex is not None:
|
|
635
|
+
armor_ac = base_armor_ac + min(dex_mod, max_dex)
|
|
636
|
+
else:
|
|
637
|
+
armor_ac = base_armor_ac + dex_mod
|
|
638
|
+
armor_ac += armor.get("magic_bonus", 0)
|
|
639
|
+
|
|
640
|
+
armor_rarity = armor.get("rarity", "common")
|
|
641
|
+
armor_unlock_reqs = RARITY_UNLOCK_REQUIREMENTS.get(
|
|
642
|
+
armor_rarity, {"min_level": 1, "min_xp": 0}
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
equipment_info["armor"] = {
|
|
646
|
+
"name": armor.get("name") or armor.get("themed_name", "Armor"),
|
|
647
|
+
"ac": armor_ac,
|
|
648
|
+
"type": armor_type,
|
|
649
|
+
"base_ac": base_armor_ac,
|
|
650
|
+
"magic_bonus": armor.get("magic_bonus", 0),
|
|
651
|
+
"description": armor.get("description"),
|
|
652
|
+
"appearance": armor.get("appearance"),
|
|
653
|
+
"rarity": armor_rarity,
|
|
654
|
+
"is_signature": armor.get("is_signature", False),
|
|
655
|
+
"unlock_level": armor_unlock_reqs["min_level"],
|
|
656
|
+
"unlock_xp": armor_unlock_reqs["min_xp"],
|
|
657
|
+
"is_locked": not _is_unlocked(armor_rarity),
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
# Shield
|
|
661
|
+
if equipment.get("shield"):
|
|
662
|
+
equipment_info["shield"] = True
|
|
663
|
+
|
|
664
|
+
# Other
|
|
665
|
+
equipment_info["other"] = equipment.get("other_equipment", [])
|
|
666
|
+
equipment_info["currency"] = equipment.get("currency", {"gp": 0})
|
|
667
|
+
|
|
668
|
+
return equipment_info
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _build_personality(narrative: dict | None) -> dict:
|
|
672
|
+
"""Build personality dict from narrative data."""
|
|
673
|
+
personality = {
|
|
674
|
+
"traits": [],
|
|
675
|
+
"ideals": [],
|
|
676
|
+
"bonds": [],
|
|
677
|
+
"flaws": [],
|
|
678
|
+
"backstory": "",
|
|
679
|
+
}
|
|
680
|
+
if narrative:
|
|
681
|
+
pers = narrative.get("personality", {})
|
|
682
|
+
personality["traits"] = pers.get("traits", [])
|
|
683
|
+
personality["ideals"] = pers.get("ideals", [])
|
|
684
|
+
personality["bonds"] = pers.get("bonds", [])
|
|
685
|
+
personality["flaws"] = pers.get("flaws", [])
|
|
686
|
+
backstory_obj = narrative.get("backstory", {})
|
|
687
|
+
if isinstance(backstory_obj, dict):
|
|
688
|
+
personality["backstory"] = backstory_obj.get("full_narrative", "")
|
|
689
|
+
else:
|
|
690
|
+
personality["backstory"] = str(backstory_obj) if backstory_obj else ""
|
|
691
|
+
return personality
|