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,654 @@
1
+ """Component mechanical and narrative schemas.
2
+
3
+ Each D&D character component (class, species, subclass, background) has:
4
+ - A Mechanics model: the independent variables the LLM must decide.
5
+ These feed directly into the computation graph as input overrides.
6
+ - A Narrative model: flavor, lore, descriptions.
7
+ These go to the knowledge graph (Neo4j), not the computation graph.
8
+
9
+ The LLM generates both, but they're cleanly separated. The computation
10
+ graph only ever sees Mechanics. The knowledge graph only ever sees Narrative.
11
+
12
+ Creature/monster stat blocks follow the same split.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Literal
18
+
19
+ from pydantic import BaseModel, Field
20
+
21
+ # =============================================================================
22
+ # Shared types
23
+ # =============================================================================
24
+
25
+ AbilityName = Literal[
26
+ "strength", "dexterity", "constitution",
27
+ "intelligence", "wisdom", "charisma",
28
+ ]
29
+
30
+ SpellcastingType = Literal[
31
+ "none", "full_caster", "half_caster", "pact_caster",
32
+ ]
33
+
34
+ DieSize = Literal[6, 8, 10, 12]
35
+
36
+ CreatureSize = Literal["Tiny", "Small", "Medium", "Large", "Huge", "Gargantuan"]
37
+
38
+
39
+ # =============================================================================
40
+ # Resource Mechanic (class-specific resource like Ki, Rage, Sorcery Points)
41
+ # =============================================================================
42
+
43
+
44
+ class ResourceMechanic(BaseModel):
45
+ """A class's unique resource pool. Mechanical definition only."""
46
+
47
+ starting_amount: int = Field(ge=0, description="Amount at level 1")
48
+ max_amount: int = Field(ge=0, description="Amount at level 20")
49
+ recovery: Literal["short_rest", "long_rest"] = "short_rest"
50
+ scaling: str = Field(
51
+ default="",
52
+ description="Brief rule for how it scales, e.g. 'equal to class level'",
53
+ )
54
+
55
+
56
+ class ResourceNarrative(BaseModel):
57
+ """Narrative flavor for a class resource."""
58
+
59
+ name: str = Field(description="Thematic name, e.g. 'Harmonics', 'Force Points'")
60
+ usage_description: str = Field(
61
+ description="What spending 1 point feels like narratively"
62
+ )
63
+
64
+
65
+ # =============================================================================
66
+ # CLASS
67
+ # =============================================================================
68
+
69
+
70
+ class ClassMechanics(BaseModel):
71
+ """Independent mechanical values for a character class.
72
+
73
+ These are the ONLY values the LLM needs to decide for a class.
74
+ Everything else (HP, proficiency bonus, save bonuses, spell save DC)
75
+ is computed by the graph from these inputs.
76
+ """
77
+
78
+ hit_die: DieSize
79
+ saving_throw_proficiencies: list[AbilityName] = Field(min_length=2, max_length=2)
80
+ armor_proficiencies: list[str] = Field(default_factory=list)
81
+ weapon_proficiencies: list[str] = Field(default_factory=list)
82
+ skill_proficiency_options: list[str] = Field(default_factory=list)
83
+ skill_proficiency_count: int = Field(default=2, ge=1, le=5)
84
+ spellcasting_type: SpellcastingType = "none"
85
+ spellcasting_ability: AbilityName | None = None
86
+ resource_mechanic: ResourceMechanic | None = None
87
+ archetype: str = Field(
88
+ description="full_martial, skill_martial, primal_martial, "
89
+ "half_caster, full_caster, pact_caster, support_caster"
90
+ )
91
+
92
+ # Level-gated features (mechanical effects only)
93
+ progression: list[LevelFeatureMechanics] = Field(default_factory=list)
94
+
95
+
96
+ class LevelFeatureMechanics(BaseModel):
97
+ """Mechanical effect of a class feature at a specific level.
98
+
99
+ Only tracks what mechanically changes. The name and flavor text
100
+ live in LevelFeatureNarrative.
101
+ """
102
+
103
+ level: int = Field(ge=1, le=20)
104
+ feature_id: str = Field(description="Stable ID for this feature, e.g. 'extra_attack'")
105
+ grants_proficiency: list[str] = Field(default_factory=list)
106
+ grants_resistance: list[str] = Field(default_factory=list)
107
+ grants_expertise: list[str] = Field(default_factory=list)
108
+ modifies_node: list[NodeModifier] = Field(
109
+ default_factory=list,
110
+ description="Direct overrides to computation graph nodes",
111
+ )
112
+
113
+
114
+ class NodeModifier(BaseModel):
115
+ """A modifier that a feature/item/feat applies to a graph node.
116
+
117
+ This is how mini-graphs plug into the main character graph.
118
+ Example: Alert feat → NodeModifier(target_node="initiative", op="add", value=5)
119
+ """
120
+
121
+ target_node: str = Field(description="ID of the graph node to modify")
122
+ op: Literal["add", "set", "max", "min", "multiply"] = "add"
123
+ value: int | float | bool | str = Field(description="The modifier value")
124
+ condition: str | None = Field(
125
+ default=None,
126
+ description="Optional condition, e.g. 'level >= 5'",
127
+ )
128
+
129
+
130
+ class ClassNarrative(BaseModel):
131
+ """Narrative/flavor content for a character class."""
132
+
133
+ class_name: str
134
+ description: str = Field(description="1-2 paragraph class fantasy")
135
+ resource_narrative: ResourceNarrative | None = None
136
+ progression_flavor: list[LevelFeatureNarrative] = Field(default_factory=list)
137
+ signature_spell_lore: list[SpellNarrative] = Field(default_factory=list)
138
+
139
+
140
+ class LevelFeatureNarrative(BaseModel):
141
+ """Name and flavor text for a class feature."""
142
+
143
+ level: int = Field(ge=1, le=20)
144
+ feature_id: str
145
+ name: str
146
+ description: str = Field(description="Narrative description of what this looks like")
147
+
148
+
149
+ class SpellNarrative(BaseModel):
150
+ """Narrative content for a signature spell."""
151
+
152
+ spell_id: str = Field(description="Stable ID matching a spell mechanics entry")
153
+ themed_name: str
154
+ lore: str = Field(description="1-2 paragraphs connecting spell to class identity")
155
+ manifestation: str = Field(description="Visual description of casting")
156
+ symbolism: list[str] = Field(default_factory=list)
157
+
158
+
159
+ class ClassGenerationOutput(BaseModel):
160
+ """Complete LLM output for class generation — mechanics + narrative split."""
161
+
162
+ mechanics: ClassMechanics
163
+ narrative: ClassNarrative
164
+
165
+
166
+ # =============================================================================
167
+ # SPECIES
168
+ # =============================================================================
169
+
170
+
171
+ class SpeciesMechanics(BaseModel):
172
+ """Independent mechanical values for a character species."""
173
+
174
+ size: Literal["Small", "Medium"] = "Medium"
175
+ creature_type: str = "Humanoid"
176
+ speed: SpeciesSpeed = Field(default_factory=lambda: SpeciesSpeed())
177
+ darkvision: int | None = Field(default=None, description="Darkvision range in feet")
178
+ resistances: list[str] = Field(default_factory=list)
179
+ damage_immunities: list[str] = Field(default_factory=list)
180
+ condition_immunities: list[str] = Field(default_factory=list)
181
+ skill_proficiencies: list[str] = Field(default_factory=list)
182
+ tool_proficiencies: list[str] = Field(default_factory=list)
183
+ languages: list[str] = Field(default_factory=lambda: ["Common"])
184
+ natural_weapons: list[NaturalWeaponMechanics] = Field(default_factory=list)
185
+ traits: list[SpeciesTraitMechanics] = Field(default_factory=list)
186
+ innate_spellcasting: InnateSpellcasting | None = None
187
+ rest_type: Literal["standard", "trance", "sentry"] = "standard"
188
+
189
+
190
+ class SpeciesSpeed(BaseModel):
191
+ """Movement speeds for a species."""
192
+
193
+ walk: int = 30
194
+ fly: int | None = None
195
+ swim: int | None = None
196
+ climb: int | None = None
197
+ burrow: int | None = None
198
+
199
+
200
+ class NaturalWeaponMechanics(BaseModel):
201
+ """Mechanical definition of a natural weapon."""
202
+
203
+ weapon_id: str
204
+ damage: str = Field(description="Damage dice, e.g. '1d6'")
205
+ damage_type: str = "slashing"
206
+ properties: list[str] = Field(default_factory=list)
207
+
208
+
209
+ class SpeciesTraitMechanics(BaseModel):
210
+ """Mechanical effect of a species trait."""
211
+
212
+ trait_id: str = Field(description="Stable ID, e.g. 'relentless_endurance'")
213
+ action_type: Literal["passive", "action", "bonus_action", "reaction"] | None = None
214
+ modifies_node: list[NodeModifier] = Field(default_factory=list)
215
+ grants_proficiency: list[str] = Field(default_factory=list)
216
+ grants_resistance: list[str] = Field(default_factory=list)
217
+ uses_per_rest: int | None = Field(
218
+ default=None, description="Uses before needing a rest"
219
+ )
220
+ recovery: Literal["short_rest", "long_rest"] | None = None
221
+
222
+
223
+ class InnateSpellcasting(BaseModel):
224
+ """Innate spellcasting from species (not class)."""
225
+
226
+ ability: AbilityName
227
+ spells: list[InnateSpellMechanics] = Field(default_factory=list)
228
+
229
+
230
+ class InnateSpellMechanics(BaseModel):
231
+ """Mechanical definition of an innate spell."""
232
+
233
+ spell_id: str
234
+ base_spell: str = Field(description="Official D&D spell name or 'custom'")
235
+ level: int = Field(ge=0, le=9)
236
+ uses_per_day: int | None = Field(
237
+ default=None, description="None = at will (cantrips)"
238
+ )
239
+
240
+
241
+ class SpeciesNarrative(BaseModel):
242
+ """Narrative/flavor content for a species."""
243
+
244
+ species_name: str
245
+ description: str = Field(description="1-2 paragraphs on origin and nature")
246
+ lifespan_description: str = ""
247
+ trait_flavor: list[SpeciesTraitNarrative] = Field(default_factory=list)
248
+ natural_weapon_flavor: list[NaturalWeaponNarrative] = Field(default_factory=list)
249
+ innate_spell_lore: list[SpellNarrative] = Field(default_factory=list)
250
+ lineages: list[LineageNarrative] = Field(default_factory=list)
251
+
252
+
253
+ class SpeciesTraitNarrative(BaseModel):
254
+ """Name and flavor text for a species trait."""
255
+
256
+ trait_id: str
257
+ name: str
258
+ description: str
259
+
260
+
261
+ class NaturalWeaponNarrative(BaseModel):
262
+ """Flavor for a natural weapon."""
263
+
264
+ weapon_id: str
265
+ name: str
266
+ description: str
267
+
268
+
269
+ class LineageNarrative(BaseModel):
270
+ """A species sub-lineage with narrative features."""
271
+
272
+ name: str
273
+ description: str
274
+ features: list[str] = Field(default_factory=list)
275
+
276
+
277
+ class SpeciesGenerationOutput(BaseModel):
278
+ """Complete LLM output for species generation."""
279
+
280
+ mechanics: SpeciesMechanics
281
+ narrative: SpeciesNarrative
282
+
283
+
284
+ # =============================================================================
285
+ # SUBCLASS
286
+ # =============================================================================
287
+
288
+
289
+ class SubclassMechanics(BaseModel):
290
+ """Independent mechanical values for a subclass."""
291
+
292
+ subclass_archetype: str = Field(
293
+ description="damage_dealer, tank_protector, support, control, healer"
294
+ )
295
+ bonus_proficiencies: list[str] = Field(default_factory=list)
296
+ domain_spells: list[DomainSpellLevel] = Field(default_factory=list)
297
+ features: list[LevelFeatureMechanics] = Field(default_factory=list)
298
+
299
+
300
+ class DomainSpellLevel(BaseModel):
301
+ """Spells granted at a specific spell level."""
302
+
303
+ spell_level: int = Field(ge=1, le=9)
304
+ spells: list[str] = Field(description="Spell names granted")
305
+
306
+
307
+ class SubclassNarrative(BaseModel):
308
+ """Narrative/flavor content for a subclass."""
309
+
310
+ subclass_name: str
311
+ description: str
312
+ feature_flavor: list[LevelFeatureNarrative] = Field(default_factory=list)
313
+
314
+
315
+ class SubclassGenerationOutput(BaseModel):
316
+ """Complete LLM output for subclass generation."""
317
+
318
+ mechanics: SubclassMechanics
319
+ narrative: SubclassNarrative
320
+
321
+
322
+ # =============================================================================
323
+ # BACKGROUND
324
+ # =============================================================================
325
+
326
+
327
+ class BackgroundMechanics(BaseModel):
328
+ """Independent mechanical values for a background.
329
+
330
+ Per D&D 2024 rules: 2 skills, 1 tool, ability score increases (+2/+1),
331
+ 1 origin feat, starting equipment.
332
+ """
333
+
334
+ skill_proficiencies: list[str] = Field(min_length=2, max_length=2)
335
+ tool_proficiency: str = ""
336
+ ability_score_increases: dict[AbilityName, int] = Field(
337
+ default_factory=dict,
338
+ description="Ability boosts, e.g. {'strength': 2, 'constitution': 1}",
339
+ )
340
+ origin_feat: str = Field(description="1st-level feat name")
341
+ equipment: list[str] = Field(default_factory=list)
342
+
343
+
344
+ class BackgroundNarrative(BaseModel):
345
+ """Narrative/flavor content for a background."""
346
+
347
+ background_name: str
348
+ description: str
349
+ feature_name: str = ""
350
+ feature_description: str = Field(
351
+ default="", description="Roleplay benefit description"
352
+ )
353
+
354
+
355
+ class BackgroundGenerationOutput(BaseModel):
356
+ """Complete LLM output for background generation."""
357
+
358
+ mechanics: BackgroundMechanics
359
+ narrative: BackgroundNarrative
360
+
361
+
362
+ # =============================================================================
363
+ # CREATURE / MONSTER
364
+ # =============================================================================
365
+
366
+
367
+ class CreatureMechanics(BaseModel):
368
+ """Independent mechanical values for a monster/creature stat block.
369
+
370
+ The LLM decides these. The graph computes proficiency bonus (from CR),
371
+ save bonuses, skill bonuses, effective HP, etc.
372
+ """
373
+
374
+ cr: str = Field(description="Challenge rating as string: '1/4', '5', '20'")
375
+ cr_numeric: float = Field(ge=0, le=30)
376
+ size: CreatureSize = "Medium"
377
+ creature_type: str = "Beast"
378
+
379
+ # Ability scores (all 6 are independent for creatures)
380
+ ability_scores: dict[AbilityName, int] = Field(
381
+ default_factory=lambda: {
382
+ "strength": 10, "dexterity": 10, "constitution": 10,
383
+ "intelligence": 10, "wisdom": 10, "charisma": 10,
384
+ }
385
+ )
386
+
387
+ # Defenses
388
+ ac: int = Field(10, ge=1, le=30, description="Armor class (includes natural armor)")
389
+ ac_type: str | None = Field(default=None, description="'natural armor', 'plate', etc.")
390
+ hp_dice_count: int = Field(1, ge=1, description="Number of hit dice")
391
+ # hit die SIZE is computed from size via CREATURE_SIZE_HIT_DIE lookup
392
+
393
+ # Movement
394
+ speed: SpeciesSpeed = Field(default_factory=lambda: SpeciesSpeed())
395
+
396
+ # Proficiencies (LLM picks which saves/skills are proficient)
397
+ saving_throw_proficiencies: list[AbilityName] = Field(default_factory=list)
398
+ skill_proficiencies: list[str] = Field(default_factory=list)
399
+
400
+ # Defenses
401
+ damage_vulnerabilities: list[str] = Field(default_factory=list)
402
+ damage_resistances: list[str] = Field(default_factory=list)
403
+ damage_immunities: list[str] = Field(default_factory=list)
404
+ condition_immunities: list[str] = Field(default_factory=list)
405
+
406
+ # Senses
407
+ darkvision: int | None = None
408
+ blindsight: int | None = None
409
+ tremorsense: int | None = None
410
+ truesight: int | None = None
411
+
412
+ # Languages
413
+ languages: list[str] = Field(default_factory=list)
414
+
415
+ # Actions (mechanical effects)
416
+ multiattack_count: int | None = Field(
417
+ default=None, description="Number of attacks in multiattack"
418
+ )
419
+ actions: list[CreatureActionMechanics] = Field(default_factory=list)
420
+ bonus_actions: list[CreatureActionMechanics] = Field(default_factory=list)
421
+ reactions: list[CreatureActionMechanics] = Field(default_factory=list)
422
+ traits: list[CreatureTraitMechanics] = Field(default_factory=list)
423
+
424
+ # Boss features
425
+ legendary_action_count: int = Field(default=0, ge=0, le=5)
426
+ legendary_actions: list[LegendaryActionMechanics] = Field(default_factory=list)
427
+ legendary_resistances: int = Field(default=0, ge=0, le=5)
428
+ lair_actions: list[LairActionMechanics] = Field(default_factory=list)
429
+
430
+
431
+ class CreatureActionMechanics(BaseModel):
432
+ """Mechanical definition of a creature action."""
433
+
434
+ action_id: str
435
+ action_type: Literal["melee", "ranged", "spell", "other"] = "melee"
436
+ attack_bonus: int | None = Field(
437
+ default=None,
438
+ description="To-hit bonus. If None, no attack roll (save-based).",
439
+ )
440
+ reach_range: str | None = Field(default=None, description="e.g. '5 ft.' or '30/120 ft.'")
441
+ damage: str | None = Field(default=None, description="e.g. '2d6 + 4'")
442
+ damage_type: str | None = None
443
+ save_dc: int | None = Field(default=None, description="Save DC if save-based")
444
+ save_ability: AbilityName | None = None
445
+ recharge: str | None = Field(default=None, description="e.g. '5-6'")
446
+
447
+
448
+ class CreatureTraitMechanics(BaseModel):
449
+ """Mechanical definition of a special trait."""
450
+
451
+ trait_id: str
452
+ recharge: str | None = None
453
+ modifies_node: list[NodeModifier] = Field(default_factory=list)
454
+
455
+
456
+ class LegendaryActionMechanics(BaseModel):
457
+ """Mechanical definition of a legendary action."""
458
+
459
+ action_id: str
460
+ cost: int = Field(1, ge=1, le=3)
461
+
462
+
463
+ class LairActionMechanics(BaseModel):
464
+ """Mechanical definition of a lair action."""
465
+
466
+ action_id: str
467
+ initiative_count: int = 20
468
+
469
+
470
+ class CreatureNarrative(BaseModel):
471
+ """Narrative/flavor content for a creature."""
472
+
473
+ name: str
474
+ description: str = Field(description="Physical appearance and general nature")
475
+ lore: str = Field(default="", description="Background/ecology")
476
+ tactics: str = Field(default="", description="How to run in combat")
477
+ alignment: str | None = None
478
+ action_descriptions: list[CreatureActionNarrative] = Field(default_factory=list)
479
+ trait_descriptions: list[CreatureTraitNarrative] = Field(default_factory=list)
480
+ legendary_action_descriptions: list[CreatureActionNarrative] = Field(
481
+ default_factory=list
482
+ )
483
+ lair_action_descriptions: list[LairActionNarrative] = Field(default_factory=list)
484
+ regional_effects: list[str] = Field(default_factory=list)
485
+ encounter_ideas: list[str] = Field(default_factory=list)
486
+
487
+
488
+ class CreatureActionNarrative(BaseModel):
489
+ """Name and flavor for a creature action."""
490
+
491
+ action_id: str
492
+ name: str
493
+ description: str
494
+
495
+
496
+ class CreatureTraitNarrative(BaseModel):
497
+ """Name and flavor for a creature trait."""
498
+
499
+ trait_id: str
500
+ name: str
501
+ description: str
502
+
503
+
504
+ class LairActionNarrative(BaseModel):
505
+ """Flavor for a lair action."""
506
+
507
+ action_id: str
508
+ description: str
509
+
510
+
511
+ class CreatureGenerationOutput(BaseModel):
512
+ """Complete LLM output for creature/monster generation."""
513
+
514
+ mechanics: CreatureMechanics
515
+ narrative: CreatureNarrative
516
+
517
+
518
+ # =============================================================================
519
+ # FEAT (mini-graph)
520
+ # =============================================================================
521
+
522
+
523
+ class FeatMechanics(BaseModel):
524
+ """Mechanical definition of a feat.
525
+
526
+ A feat is a mini-graph: it applies NodeModifiers to the character graph.
527
+ """
528
+
529
+ feat_id: str
530
+ prerequisite_level: int = Field(default=1, ge=1, le=20)
531
+ prerequisite_ability: AbilityName | None = None
532
+ prerequisite_ability_min: int | None = None
533
+ grants_ability_increase: dict[AbilityName, int] = Field(
534
+ default_factory=dict,
535
+ description="e.g. {'dexterity': 1} for feats that give +1",
536
+ )
537
+ grants_proficiency: list[str] = Field(default_factory=list)
538
+ grants_resistance: list[str] = Field(default_factory=list)
539
+ modifies_node: list[NodeModifier] = Field(
540
+ default_factory=list,
541
+ description="Direct graph node modifications",
542
+ )
543
+
544
+
545
+ class FeatNarrative(BaseModel):
546
+ """Flavor for a feat."""
547
+
548
+ feat_id: str
549
+ name: str
550
+ description: str
551
+
552
+
553
+ class FeatGenerationOutput(BaseModel):
554
+ """Complete LLM output for feat generation."""
555
+
556
+ mechanics: FeatMechanics
557
+ narrative: FeatNarrative
558
+
559
+
560
+ # =============================================================================
561
+ # EQUIPMENT ITEMS (mini-graphs)
562
+ # =============================================================================
563
+
564
+
565
+ class WeaponMechanics(BaseModel):
566
+ """Mechanical definition of a weapon."""
567
+
568
+ weapon_id: str
569
+ base_weapon: str = Field(description="Official weapon name, e.g. 'longsword'")
570
+ damage: str = Field(description="Damage dice, e.g. '1d8'")
571
+ damage_type: str = "slashing"
572
+ properties: list[str] = Field(
573
+ default_factory=list,
574
+ description="e.g. ['finesse', 'light', 'versatile']",
575
+ )
576
+ magic_bonus: int = Field(default=0, ge=0, le=3)
577
+ rarity: str = "common"
578
+ requires_attunement: bool = False
579
+ special_abilities: list[WeaponAbilityMechanics] = Field(default_factory=list)
580
+
581
+
582
+ class WeaponAbilityMechanics(BaseModel):
583
+ """Mechanical effect of a magic weapon ability."""
584
+
585
+ ability_id: str
586
+ modifies_node: list[NodeModifier] = Field(default_factory=list)
587
+ uses_per_rest: int | None = None
588
+ recovery: Literal["short_rest", "long_rest"] | None = None
589
+
590
+
591
+ class WeaponNarrative(BaseModel):
592
+ """Flavor for a weapon."""
593
+
594
+ weapon_id: str
595
+ themed_name: str
596
+ description: str = ""
597
+ lore: str = ""
598
+ appearance: str = ""
599
+ symbolism: list[str] = Field(default_factory=list)
600
+ is_signature: bool = False
601
+
602
+
603
+ class ArmorMechanics(BaseModel):
604
+ """Mechanical definition of an armor piece.
605
+
606
+ The base AC and max DEX are looked up from the armor type.
607
+ Only magic_bonus is an independent variable beyond armor_type.
608
+ """
609
+
610
+ armor_id: str
611
+ armor_type: str = Field(description="e.g. 'plate', 'leather', 'chain_mail'")
612
+ magic_bonus: int = Field(default=0, ge=0, le=3)
613
+ rarity: str = "common"
614
+ requires_attunement: bool = False
615
+
616
+
617
+ class ArmorNarrative(BaseModel):
618
+ """Flavor for armor."""
619
+
620
+ armor_id: str
621
+ themed_name: str
622
+ description: str = ""
623
+ appearance: str = ""
624
+ is_signature: bool = False
625
+
626
+
627
+ # =============================================================================
628
+ # SPELL (mini-graph for signature/innate spells)
629
+ # =============================================================================
630
+
631
+
632
+ class SpellMechanics(BaseModel):
633
+ """Mechanical definition of a spell."""
634
+
635
+ spell_id: str
636
+ base_spell: str = Field(description="Official D&D spell name or 'custom'")
637
+ level: int = Field(ge=0, le=9, description="0 = cantrip")
638
+ school: str = ""
639
+ casting_time: str = "1 action"
640
+ range: str = "Self"
641
+ components: list[str] = Field(default_factory=list, description="V, S, M")
642
+ duration: str = "Instantaneous"
643
+ concentration: bool = False
644
+ damage: str | None = Field(default=None, description="Damage dice if applicable")
645
+ damage_type: str | None = None
646
+ save_ability: AbilityName | None = None
647
+ is_signature: bool = False
648
+
649
+
650
+ class SpellGenerationOutput(BaseModel):
651
+ """Complete LLM output for a spell."""
652
+
653
+ mechanics: SpellMechanics
654
+ narrative: SpellNarrative