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,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