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,606 @@
1
+ """D&D 5th Edition (2024) character sheet computation graph.
2
+
3
+ ~80 nodes organized in layers:
4
+ Layer 0 — Inputs (12): ability scores, level, class, equipment
5
+ Layer 1 — Simple Derived (27): modifiers, proficiency bonus, saves
6
+ Layer 2 — Complex Derived (35+): skills, HP, AC, spellcasting, passives
7
+
8
+ This is the built-in default ruleset. Custom rulesets (Phase 4) will
9
+ fork-and-modify this graph.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .lookup_tables import get_all_lookup_tables
15
+ from .schema import ComputationNode, FormulaSpec, NodeType, Ruleset
16
+
17
+ ABILITIES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
18
+
19
+ SKILLS = {
20
+ "acrobatics": "dexterity",
21
+ "animal_handling": "wisdom",
22
+ "arcana": "intelligence",
23
+ "athletics": "strength",
24
+ "deception": "charisma",
25
+ "history": "intelligence",
26
+ "insight": "wisdom",
27
+ "intimidation": "charisma",
28
+ "investigation": "intelligence",
29
+ "medicine": "wisdom",
30
+ "nature": "intelligence",
31
+ "perception": "wisdom",
32
+ "performance": "charisma",
33
+ "persuasion": "charisma",
34
+ "religion": "intelligence",
35
+ "sleight_of_hand": "dexterity",
36
+ "stealth": "dexterity",
37
+ "survival": "wisdom",
38
+ }
39
+
40
+
41
+ def _build_nodes() -> dict[str, ComputationNode]:
42
+ """Build all computation nodes for the D&D 5e 2024 ruleset."""
43
+ nodes: dict[str, ComputationNode] = {}
44
+
45
+ def add(node: ComputationNode) -> None:
46
+ nodes[node.id] = node
47
+
48
+ # =================================================================
49
+ # Layer 0 — Input nodes
50
+ # =================================================================
51
+ for ability in ABILITIES:
52
+ add(
53
+ ComputationNode(
54
+ id=f"{ability}_score",
55
+ node_type=NodeType.INPUT,
56
+ label=f"{ability.capitalize()} Score",
57
+ layer=0,
58
+ group="ability_scores",
59
+ default_value=10,
60
+ min_value=1,
61
+ max_value=30,
62
+ description="Ability score (1-30). Standard range 3-20, can exceed via magic.",
63
+ )
64
+ )
65
+
66
+ add(
67
+ ComputationNode(
68
+ id="character_level",
69
+ node_type=NodeType.INPUT,
70
+ label="Character Level",
71
+ layer=0,
72
+ group="core",
73
+ default_value=1,
74
+ min_value=1,
75
+ max_value=20,
76
+ description="Total character level (sum of all class levels).",
77
+ )
78
+ )
79
+
80
+ add(
81
+ ComputationNode(
82
+ id="class_levels",
83
+ node_type=NodeType.INPUT,
84
+ label="Class Levels",
85
+ layer=0,
86
+ group="core",
87
+ default_value={},
88
+ description='Dict mapping class name to level, e.g. {"bard": 5, "warlock": 3}.',
89
+ )
90
+ )
91
+
92
+ add(
93
+ ComputationNode(
94
+ id="class_hit_dice",
95
+ node_type=NodeType.INPUT,
96
+ label="Class Hit Dice",
97
+ layer=0,
98
+ group="core",
99
+ default_value={},
100
+ description='Dict mapping class name to hit die size, e.g. {"bard": 8, "warlock": 8}.',
101
+ )
102
+ )
103
+
104
+ add(
105
+ ComputationNode(
106
+ id="primary_class",
107
+ node_type=NodeType.INPUT,
108
+ label="Primary Class",
109
+ layer=0,
110
+ group="core",
111
+ default_value="",
112
+ description="Primary class name (lowercase). Used for save profs, spell ability.",
113
+ )
114
+ )
115
+
116
+ add(
117
+ ComputationNode(
118
+ id="spellcasting_type",
119
+ node_type=NodeType.INPUT,
120
+ label="Spellcasting Type",
121
+ layer=0,
122
+ group="spellcasting",
123
+ default_value="none",
124
+ description="full_caster, half_caster, pact_caster, or none.",
125
+ )
126
+ )
127
+
128
+ add(
129
+ ComputationNode(
130
+ id="class_spellcasting_types",
131
+ node_type=NodeType.INPUT,
132
+ label="Class Spellcasting Types",
133
+ layer=0,
134
+ group="spellcasting",
135
+ default_value={},
136
+ description="Per-class spellcasting types for multiclass.",
137
+ )
138
+ )
139
+
140
+ add(
141
+ ComputationNode(
142
+ id="armor_type",
143
+ node_type=NodeType.INPUT,
144
+ label="Armor Type",
145
+ layer=0,
146
+ group="equipment",
147
+ default_value="none",
148
+ description="Equipped armor type (e.g., 'plate', 'leather', 'none').",
149
+ )
150
+ )
151
+
152
+ add(
153
+ ComputationNode(
154
+ id="armor_magic_bonus",
155
+ node_type=NodeType.INPUT,
156
+ label="Armor Magic Bonus",
157
+ layer=0,
158
+ group="equipment",
159
+ default_value=0,
160
+ min_value=0,
161
+ max_value=5,
162
+ )
163
+ )
164
+
165
+ add(
166
+ ComputationNode(
167
+ id="has_shield",
168
+ node_type=NodeType.INPUT,
169
+ label="Has Shield",
170
+ layer=0,
171
+ group="equipment",
172
+ default_value=False,
173
+ )
174
+ )
175
+
176
+ add(
177
+ ComputationNode(
178
+ id="speed_base",
179
+ node_type=NodeType.INPUT,
180
+ label="Base Speed",
181
+ layer=0,
182
+ group="core",
183
+ default_value=30,
184
+ description="Base walking speed from species.",
185
+ )
186
+ )
187
+
188
+ # Saving throw proficiency inputs (per ability)
189
+ for ability in ABILITIES:
190
+ add(
191
+ ComputationNode(
192
+ id=f"save.{ability}.proficient",
193
+ node_type=NodeType.INPUT,
194
+ label=f"{ability.capitalize()} Save Proficiency",
195
+ layer=0,
196
+ group="saves",
197
+ default_value=False,
198
+ )
199
+ )
200
+
201
+ # Skill proficiency inputs
202
+ for skill in SKILLS:
203
+ add(
204
+ ComputationNode(
205
+ id=f"skill.{skill}.proficient",
206
+ node_type=NodeType.INPUT,
207
+ label=f"{skill.replace('_', ' ').title()} Proficiency",
208
+ layer=0,
209
+ group="skills",
210
+ default_value=False,
211
+ )
212
+ )
213
+ add(
214
+ ComputationNode(
215
+ id=f"skill.{skill}.expertise",
216
+ node_type=NodeType.INPUT,
217
+ label=f"{skill.replace('_', ' ').title()} Expertise",
218
+ layer=0,
219
+ group="skills",
220
+ default_value=False,
221
+ )
222
+ )
223
+
224
+ # =================================================================
225
+ # Layer 1 — Simple Derived (modifiers, proficiency bonus)
226
+ # =================================================================
227
+
228
+ # Ability modifiers: (score - 10) // 2
229
+ for ability in ABILITIES:
230
+ add(
231
+ ComputationNode(
232
+ id=f"{ability}_mod",
233
+ node_type=NodeType.FORMULA,
234
+ label=f"{ability.capitalize()} Modifier",
235
+ layer=1,
236
+ group="ability_scores",
237
+ formula=FormulaSpec(op="ability_mod", args=[f"{ability}_score"]),
238
+ inputs=[f"{ability}_score"],
239
+ description="(score - 10) // 2. PHB p.13.",
240
+ )
241
+ )
242
+
243
+ # Proficiency bonus: 2 + ((level - 1) // 4)
244
+ add(
245
+ ComputationNode(
246
+ id="proficiency_bonus",
247
+ node_type=NodeType.FORMULA,
248
+ label="Proficiency Bonus",
249
+ layer=1,
250
+ group="core",
251
+ formula=FormulaSpec(op="prof_bonus", args=["character_level"]),
252
+ inputs=["character_level"],
253
+ min_value=2,
254
+ max_value=6,
255
+ description="2 + ((level - 1) // 4). PHB p.15.",
256
+ )
257
+ )
258
+
259
+ # Saving throw bonuses
260
+ for ability in ABILITIES:
261
+ add(
262
+ ComputationNode(
263
+ id=f"save.{ability}.bonus",
264
+ node_type=NodeType.FORMULA,
265
+ label=f"{ability.capitalize()} Save Bonus",
266
+ layer=1,
267
+ group="saves",
268
+ formula=FormulaSpec(
269
+ op="prof_add",
270
+ args=[f"{ability}_mod", "proficiency_bonus", f"save.{ability}.proficient"],
271
+ ),
272
+ inputs=[f"{ability}_mod", "proficiency_bonus", f"save.{ability}.proficient"],
273
+ )
274
+ )
275
+
276
+ # Spell ability modifier (uses primary class to determine which ability)
277
+ add(
278
+ ComputationNode(
279
+ id="spell_ability",
280
+ node_type=NodeType.LOOKUP,
281
+ label="Spellcasting Ability",
282
+ layer=1,
283
+ group="spellcasting",
284
+ formula=FormulaSpec(
285
+ op="lookup", args=["spell_ability_by_class", "primary_class", "charisma"]
286
+ ),
287
+ inputs=["primary_class"],
288
+ description="Spell ability determined by class. PHB varies by class.",
289
+ )
290
+ )
291
+
292
+ # =================================================================
293
+ # Layer 2 — Complex Derived
294
+ # =================================================================
295
+
296
+ # Spell modifier (the modifier value for the spell ability)
297
+ # This is a dynamic lookup — spell_ability resolves to an ability name,
298
+ # and we need the modifier for that ability. We handle this with a
299
+ # special operation.
300
+ add(
301
+ ComputationNode(
302
+ id="spell_mod",
303
+ node_type=NodeType.FORMULA,
304
+ label="Spellcasting Modifier",
305
+ layer=2,
306
+ group="spellcasting",
307
+ formula=FormulaSpec(
308
+ op="spell_mod_resolve",
309
+ args=[
310
+ "spell_ability",
311
+ "strength_mod",
312
+ "dexterity_mod",
313
+ "constitution_mod",
314
+ "intelligence_mod",
315
+ "wisdom_mod",
316
+ "charisma_mod",
317
+ ],
318
+ ),
319
+ inputs=[
320
+ "spell_ability",
321
+ "strength_mod",
322
+ "dexterity_mod",
323
+ "constitution_mod",
324
+ "intelligence_mod",
325
+ "wisdom_mod",
326
+ "charisma_mod",
327
+ ],
328
+ description="Modifier for the class's spellcasting ability.",
329
+ )
330
+ )
331
+
332
+ # Spell save DC: 8 + prof + spell_mod
333
+ add(
334
+ ComputationNode(
335
+ id="spell_save_dc",
336
+ node_type=NodeType.FORMULA,
337
+ label="Spell Save DC",
338
+ layer=2,
339
+ group="spellcasting",
340
+ formula=FormulaSpec(op="add", args=["spell_mod", "proficiency_bonus", 8]),
341
+ inputs=["spell_mod", "proficiency_bonus"],
342
+ min_value=1,
343
+ description="8 + proficiency_bonus + spellcasting_mod. PHB p.201.",
344
+ )
345
+ )
346
+
347
+ # Spell attack bonus: prof + spell_mod
348
+ add(
349
+ ComputationNode(
350
+ id="spell_attack",
351
+ node_type=NodeType.FORMULA,
352
+ label="Spell Attack Bonus",
353
+ layer=2,
354
+ group="spellcasting",
355
+ formula=FormulaSpec(op="add", args=["spell_mod", "proficiency_bonus"]),
356
+ inputs=["spell_mod", "proficiency_bonus"],
357
+ description="proficiency_bonus + spellcasting_mod. PHB p.201.",
358
+ )
359
+ )
360
+
361
+ # Spell slots
362
+ add(
363
+ ComputationNode(
364
+ id="spell_slots",
365
+ node_type=NodeType.FORMULA,
366
+ label="Spell Slots",
367
+ layer=2,
368
+ group="spellcasting",
369
+ formula=FormulaSpec(
370
+ op="spell_slots",
371
+ args=["spellcasting_type", "character_level", "primary_class"],
372
+ ),
373
+ inputs=["spellcasting_type", "character_level", "primary_class"],
374
+ description="Spell slot progression by class type and level.",
375
+ )
376
+ )
377
+
378
+ # Multiclass spell slots (used when multiple classes)
379
+ add(
380
+ ComputationNode(
381
+ id="multiclass_spell_slots",
382
+ node_type=NodeType.FORMULA,
383
+ label="Multiclass Spell Slots",
384
+ layer=2,
385
+ group="spellcasting",
386
+ formula=FormulaSpec(
387
+ op="multiclass_spell_slots",
388
+ args=["class_levels", "class_spellcasting_types"],
389
+ ),
390
+ inputs=["class_levels", "class_spellcasting_types"],
391
+ description="Combined spell slots for multiclass characters per PHB p.164.",
392
+ )
393
+ )
394
+
395
+ # HP
396
+ add(
397
+ ComputationNode(
398
+ id="hp_max",
399
+ node_type=NodeType.FORMULA,
400
+ label="Hit Points Maximum",
401
+ layer=2,
402
+ group="combat",
403
+ formula=FormulaSpec(
404
+ op="multiclass_hp",
405
+ args=["class_levels", "class_hit_dice", "constitution_mod"],
406
+ ),
407
+ inputs=["class_levels", "class_hit_dice", "constitution_mod"],
408
+ min_value=1,
409
+ description="L1: max die + CON. L2+: average + CON per level. Min 1/level.",
410
+ )
411
+ )
412
+
413
+ # Hit dice string
414
+ add(
415
+ ComputationNode(
416
+ id="hit_dice",
417
+ node_type=NodeType.FORMULA,
418
+ label="Hit Dice",
419
+ layer=2,
420
+ group="combat",
421
+ formula=FormulaSpec(
422
+ op="hit_dice_str",
423
+ args=["class_levels", "class_hit_dice"],
424
+ ),
425
+ inputs=["class_levels", "class_hit_dice"],
426
+ description="Hit dice display string, e.g. '5d8' or '5d8 + 3d10'.",
427
+ )
428
+ )
429
+
430
+ # AC
431
+ add(
432
+ ComputationNode(
433
+ id="armor_class",
434
+ node_type=NodeType.FORMULA,
435
+ label="Armor Class",
436
+ layer=2,
437
+ group="combat",
438
+ formula=FormulaSpec(
439
+ op="ac_with_armor",
440
+ args=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"],
441
+ ),
442
+ inputs=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"],
443
+ min_value=1,
444
+ description="Base AC from armor + DEX (capped) + magic + shield. PHB p.14.",
445
+ )
446
+ )
447
+
448
+ # Initiative: DEX modifier
449
+ add(
450
+ ComputationNode(
451
+ id="initiative",
452
+ node_type=NodeType.FORMULA,
453
+ label="Initiative",
454
+ layer=2,
455
+ group="combat",
456
+ formula=FormulaSpec(op="const", args=["dexterity_mod"]),
457
+ inputs=["dexterity_mod"],
458
+ description="Initiative = DEX modifier (before feats/features). PHB p.189.",
459
+ )
460
+ )
461
+
462
+ # Skill bonuses (18 skills)
463
+ for skill, ability in SKILLS.items():
464
+ add(
465
+ ComputationNode(
466
+ id=f"skill.{skill}.bonus",
467
+ node_type=NodeType.FORMULA,
468
+ label=f"{skill.replace('_', ' ').title()} Bonus",
469
+ layer=2,
470
+ group="skills",
471
+ formula=FormulaSpec(
472
+ op="skill_bonus",
473
+ args=[
474
+ f"{ability}_mod",
475
+ "proficiency_bonus",
476
+ f"skill.{skill}.proficient",
477
+ f"skill.{skill}.expertise",
478
+ ],
479
+ ),
480
+ inputs=[
481
+ f"{ability}_mod",
482
+ "proficiency_bonus",
483
+ f"skill.{skill}.proficient",
484
+ f"skill.{skill}.expertise",
485
+ ],
486
+ description=f"Based on {ability.upper()[:3]}. +prof/+2x prof.",
487
+ )
488
+ )
489
+
490
+ # Passive scores
491
+ for passive_skill in ("perception", "investigation", "insight"):
492
+ add(
493
+ ComputationNode(
494
+ id=f"passive_{passive_skill}",
495
+ node_type=NodeType.FORMULA,
496
+ label=f"Passive {passive_skill.capitalize()}",
497
+ layer=2,
498
+ group="combat",
499
+ formula=FormulaSpec(
500
+ op="passive_score",
501
+ args=[f"skill.{passive_skill}.bonus"],
502
+ ),
503
+ inputs=[f"skill.{passive_skill}.bonus"],
504
+ description=f"10 + {passive_skill} bonus. PHB p.175.",
505
+ )
506
+ )
507
+
508
+ # =================================================================
509
+ # Output nodes (formatted strings for display)
510
+ # =================================================================
511
+ for ability in ABILITIES:
512
+ add(
513
+ ComputationNode(
514
+ id=f"{ability}_mod_display",
515
+ node_type=NodeType.OUTPUT,
516
+ label=f"{ability.capitalize()} Modifier Display",
517
+ layer=2,
518
+ group="ability_scores",
519
+ formula=FormulaSpec(op="format_mod", args=[f"{ability}_mod"]),
520
+ inputs=[f"{ability}_mod"],
521
+ )
522
+ )
523
+
524
+ add(
525
+ ComputationNode(
526
+ id="proficiency_bonus_display",
527
+ node_type=NodeType.OUTPUT,
528
+ label="Proficiency Bonus Display",
529
+ layer=2,
530
+ group="core",
531
+ formula=FormulaSpec(op="format_mod", args=["proficiency_bonus"]),
532
+ inputs=["proficiency_bonus"],
533
+ )
534
+ )
535
+
536
+ add(
537
+ ComputationNode(
538
+ id="initiative_display",
539
+ node_type=NodeType.OUTPUT,
540
+ label="Initiative Display",
541
+ layer=2,
542
+ group="combat",
543
+ formula=FormulaSpec(op="format_mod", args=["initiative"]),
544
+ inputs=["initiative"],
545
+ )
546
+ )
547
+
548
+ add(
549
+ ComputationNode(
550
+ id="spell_attack_display",
551
+ node_type=NodeType.OUTPUT,
552
+ label="Spell Attack Display",
553
+ layer=2,
554
+ group="spellcasting",
555
+ formula=FormulaSpec(op="format_mod", args=["spell_attack"]),
556
+ inputs=["spell_attack"],
557
+ )
558
+ )
559
+
560
+ for ability in ABILITIES:
561
+ add(
562
+ ComputationNode(
563
+ id=f"save.{ability}.display",
564
+ node_type=NodeType.OUTPUT,
565
+ label=f"{ability.capitalize()} Save Display",
566
+ layer=2,
567
+ group="saves",
568
+ formula=FormulaSpec(op="format_mod", args=[f"save.{ability}.bonus"]),
569
+ inputs=[f"save.{ability}.bonus"],
570
+ )
571
+ )
572
+
573
+ for skill in SKILLS:
574
+ add(
575
+ ComputationNode(
576
+ id=f"skill.{skill}.display",
577
+ node_type=NodeType.OUTPUT,
578
+ label=f"{skill.replace('_', ' ').title()} Display",
579
+ layer=2,
580
+ group="skills",
581
+ formula=FormulaSpec(op="format_mod", args=[f"skill.{skill}.bonus"]),
582
+ inputs=[f"skill.{skill}.bonus"],
583
+ )
584
+ )
585
+
586
+ return nodes
587
+
588
+
589
+ def build_dnd_5e_2024_ruleset() -> Ruleset:
590
+ """Build the complete D&D 5e 2024 ruleset."""
591
+ return Ruleset(
592
+ id="dnd_5e_2024",
593
+ name="D&D 5th Edition (2024)",
594
+ version="1.0.0",
595
+ nodes=_build_nodes(),
596
+ lookup_tables=get_all_lookup_tables(),
597
+ metadata={
598
+ "system": "dnd",
599
+ "edition": "5e_2024",
600
+ "description": "D&D 5th Edition 2024 revision character sheet computation graph.",
601
+ },
602
+ )
603
+
604
+
605
+ # Singleton — built once, reused
606
+ DND_5E_2024_RULESET: Ruleset = build_dnd_5e_2024_ruleset()