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,412 @@
|
|
|
1
|
+
"""Closed set of formula operations for the computation graph DSL.
|
|
2
|
+
|
|
3
|
+
Every operation is a pure function: (args, lookup_tables) -> value.
|
|
4
|
+
No eval(), no lambdas, no side effects. JSON-serializable operation names
|
|
5
|
+
map to Python functions here (and will map to TypeScript functions in Phase 3).
|
|
6
|
+
|
|
7
|
+
Operations fall into three categories:
|
|
8
|
+
1. Arithmetic: add, sub, mul, floor_div, max_val, min_val
|
|
9
|
+
2. D&D compound ops: ability_mod, prof_bonus, prof_add, hp_at_level, ac_with_armor
|
|
10
|
+
3. Logic: if_then_else, eq, gt, gte, in_set, not_op
|
|
11
|
+
4. Utility: lookup, format_mod, coalesce, const
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Arithmetic
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def op_add(args: list[Any], _tables: dict) -> int | float:
|
|
24
|
+
"""Sum all arguments."""
|
|
25
|
+
return sum(args)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def op_sub(args: list[Any], _tables: dict) -> int | float:
|
|
29
|
+
"""Subtract: args[0] - args[1] - args[2] - ..."""
|
|
30
|
+
result = args[0]
|
|
31
|
+
for a in args[1:]:
|
|
32
|
+
result -= a
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def op_mul(args: list[Any], _tables: dict) -> int | float:
|
|
37
|
+
"""Multiply all arguments."""
|
|
38
|
+
result = 1
|
|
39
|
+
for a in args:
|
|
40
|
+
result *= a
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def op_floor_div(args: list[Any], _tables: dict) -> int:
|
|
45
|
+
"""Integer division: args[0] // args[1]."""
|
|
46
|
+
return int(args[0]) // int(args[1])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def op_max_val(args: list[Any], _tables: dict) -> int | float:
|
|
50
|
+
"""Maximum of all arguments."""
|
|
51
|
+
return max(args)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def op_min_val(args: list[Any], _tables: dict) -> int | float:
|
|
55
|
+
"""Minimum of all arguments."""
|
|
56
|
+
return min(args)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# D&D compound operations
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def op_ability_mod(args: list[Any], _tables: dict) -> int:
|
|
65
|
+
"""(score - 10) // 2. Standard D&D ability modifier."""
|
|
66
|
+
score = int(args[0])
|
|
67
|
+
return (score - 10) // 2
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def op_prof_bonus(args: list[Any], _tables: dict) -> int:
|
|
71
|
+
"""2 + ((level - 1) // 4). Proficiency bonus from character level."""
|
|
72
|
+
level = int(args[0])
|
|
73
|
+
return 2 + ((level - 1) // 4)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def op_prof_add(args: list[Any], _tables: dict) -> int:
|
|
77
|
+
"""ability_mod + (prof_bonus if proficient else 0).
|
|
78
|
+
|
|
79
|
+
args: [ability_mod, prof_bonus, is_proficient]
|
|
80
|
+
"""
|
|
81
|
+
ability_mod = int(args[0])
|
|
82
|
+
prof_bonus = int(args[1])
|
|
83
|
+
is_proficient = bool(args[2])
|
|
84
|
+
return ability_mod + (prof_bonus if is_proficient else 0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def op_skill_bonus(args: list[Any], _tables: dict) -> int:
|
|
88
|
+
"""ability_mod + (prof_bonus * 2 if expertise, prof_bonus if proficient, else 0).
|
|
89
|
+
|
|
90
|
+
args: [ability_mod, prof_bonus, is_proficient, has_expertise]
|
|
91
|
+
"""
|
|
92
|
+
ability_mod = int(args[0])
|
|
93
|
+
prof_bonus = int(args[1])
|
|
94
|
+
is_proficient = bool(args[2])
|
|
95
|
+
has_expertise = bool(args[3])
|
|
96
|
+
if has_expertise:
|
|
97
|
+
return ability_mod + prof_bonus * 2
|
|
98
|
+
elif is_proficient:
|
|
99
|
+
return ability_mod + prof_bonus
|
|
100
|
+
return ability_mod
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def op_hp_at_level(args: list[Any], _tables: dict) -> int:
|
|
104
|
+
"""HP = hit_die_max + con_mod + (level-1) * (avg_roll + con_mod), min level.
|
|
105
|
+
|
|
106
|
+
args: [hit_die_size, con_mod, level]
|
|
107
|
+
hit_die_size: integer (8 for d8, 10 for d10, etc.)
|
|
108
|
+
"""
|
|
109
|
+
hit_die_size = int(args[0])
|
|
110
|
+
con_mod = int(args[1])
|
|
111
|
+
level = int(args[2])
|
|
112
|
+
avg_roll = (hit_die_size // 2) + 1
|
|
113
|
+
hp = hit_die_size + con_mod # Level 1: max die + CON
|
|
114
|
+
hp += (level - 1) * (avg_roll + con_mod) # Levels 2+: average
|
|
115
|
+
return max(hp, level) # Minimum 1 HP per level
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def op_multiclass_hp(args: list[Any], _tables: dict) -> int:
|
|
119
|
+
"""HP for multiclass character.
|
|
120
|
+
|
|
121
|
+
args: [class_levels_json, class_hit_dice_json, con_mod]
|
|
122
|
+
class_levels_json: dict like {"bard": 5, "warlock": 3}
|
|
123
|
+
class_hit_dice_json: dict like {"bard": 8, "warlock": 8}
|
|
124
|
+
con_mod: constitution modifier
|
|
125
|
+
|
|
126
|
+
First level of primary class (first in order) gets max die.
|
|
127
|
+
All subsequent levels get average.
|
|
128
|
+
"""
|
|
129
|
+
class_levels = args[0] # dict
|
|
130
|
+
class_hit_dice = args[1] # dict
|
|
131
|
+
con_mod = int(args[2])
|
|
132
|
+
|
|
133
|
+
if not class_levels or not class_hit_dice:
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
total_hp = 0
|
|
137
|
+
first_class = True
|
|
138
|
+
|
|
139
|
+
for class_name, class_level in class_levels.items():
|
|
140
|
+
hit_die_size = class_hit_dice.get(class_name, 8)
|
|
141
|
+
avg_roll = (hit_die_size // 2) + 1
|
|
142
|
+
|
|
143
|
+
if first_class:
|
|
144
|
+
# First class, level 1: max die + CON
|
|
145
|
+
total_hp += hit_die_size + con_mod
|
|
146
|
+
# Remaining levels in first class: average
|
|
147
|
+
total_hp += (class_level - 1) * (avg_roll + con_mod)
|
|
148
|
+
first_class = False
|
|
149
|
+
else:
|
|
150
|
+
# All levels in secondary classes: average
|
|
151
|
+
total_hp += class_level * (avg_roll + con_mod)
|
|
152
|
+
|
|
153
|
+
character_level = sum(class_levels.values())
|
|
154
|
+
return max(total_hp, character_level)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def op_ac_with_armor(args: list[Any], tables: dict) -> int:
|
|
158
|
+
"""Compute AC from armor type + dex mod.
|
|
159
|
+
|
|
160
|
+
args: [armor_type, dex_mod, magic_bonus, has_shield]
|
|
161
|
+
Uses lookup tables: armor_base_ac, armor_max_dex
|
|
162
|
+
"""
|
|
163
|
+
armor_type = str(args[0]).lower().replace(" ", "_") if args[0] else "none"
|
|
164
|
+
dex_mod = int(args[1])
|
|
165
|
+
magic_bonus = int(args[2]) if len(args) > 2 else 0
|
|
166
|
+
has_shield = bool(args[3]) if len(args) > 3 else False
|
|
167
|
+
|
|
168
|
+
armor_base_ac = tables.get("armor_base_ac", {})
|
|
169
|
+
armor_max_dex = tables.get("armor_max_dex", {})
|
|
170
|
+
|
|
171
|
+
base_ac = armor_base_ac.get(armor_type, 10)
|
|
172
|
+
max_dex = armor_max_dex.get(armor_type)
|
|
173
|
+
|
|
174
|
+
if max_dex == 0: # Heavy armor
|
|
175
|
+
ac = base_ac
|
|
176
|
+
elif max_dex is not None: # Medium armor
|
|
177
|
+
ac = base_ac + min(dex_mod, max_dex)
|
|
178
|
+
else: # Light armor or unarmored
|
|
179
|
+
ac = base_ac + dex_mod
|
|
180
|
+
|
|
181
|
+
ac += magic_bonus
|
|
182
|
+
if has_shield:
|
|
183
|
+
ac += 2
|
|
184
|
+
|
|
185
|
+
return ac
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def op_spell_slots(args: list[Any], tables: dict) -> dict[str, int]:
|
|
189
|
+
"""Look up spell slots from progression table.
|
|
190
|
+
|
|
191
|
+
args: [spellcasting_type, level, class_name]
|
|
192
|
+
Uses lookup tables: spell_slots_full, spell_slots_half, spell_slots_warlock
|
|
193
|
+
"""
|
|
194
|
+
spellcasting_type = str(args[0]) if args[0] else "none"
|
|
195
|
+
level = int(args[1])
|
|
196
|
+
class_name = str(args[2]).lower() if len(args) > 2 and args[2] else ""
|
|
197
|
+
|
|
198
|
+
if class_name == "warlock":
|
|
199
|
+
table = tables.get("spell_slots_warlock", {})
|
|
200
|
+
return table.get(level, {})
|
|
201
|
+
elif spellcasting_type == "half_caster":
|
|
202
|
+
table = tables.get("spell_slots_half", {})
|
|
203
|
+
return table.get(level, {})
|
|
204
|
+
elif spellcasting_type in ("full_caster", "prepared_caster"):
|
|
205
|
+
table = tables.get("spell_slots_full", {})
|
|
206
|
+
return table.get(level, {})
|
|
207
|
+
return {}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def op_multiclass_spell_slots(args: list[Any], tables: dict) -> dict[str, int]:
|
|
211
|
+
"""Compute spell slots for multiclass characters.
|
|
212
|
+
|
|
213
|
+
Per D&D 5e multiclass rules: sum full caster levels + floor(half caster levels / 2),
|
|
214
|
+
look up on the full caster table. Warlock pact slots are separate.
|
|
215
|
+
|
|
216
|
+
args: [class_levels_json, class_spellcasting_types_json]
|
|
217
|
+
class_levels_json: {"wizard": 5, "fighter": 3}
|
|
218
|
+
class_spellcasting_types_json: {"wizard": "full_caster", "fighter": "none"}
|
|
219
|
+
"""
|
|
220
|
+
class_levels = args[0] # dict
|
|
221
|
+
class_types = args[1] # dict
|
|
222
|
+
|
|
223
|
+
if not class_levels or not class_types:
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
# Calculate effective caster level
|
|
227
|
+
effective_caster_level = 0
|
|
228
|
+
warlock_level = 0
|
|
229
|
+
has_warlock = False
|
|
230
|
+
|
|
231
|
+
for class_name, level in class_levels.items():
|
|
232
|
+
casting_type = class_types.get(class_name, "none")
|
|
233
|
+
if class_name.lower() == "warlock":
|
|
234
|
+
warlock_level = level
|
|
235
|
+
has_warlock = True
|
|
236
|
+
elif casting_type in ("full_caster", "prepared_caster"):
|
|
237
|
+
effective_caster_level += level
|
|
238
|
+
elif casting_type == "half_caster":
|
|
239
|
+
effective_caster_level += level // 2
|
|
240
|
+
|
|
241
|
+
result = {}
|
|
242
|
+
|
|
243
|
+
# Standard spell slots from effective caster level
|
|
244
|
+
if effective_caster_level > 0:
|
|
245
|
+
full_table = tables.get("spell_slots_full", {})
|
|
246
|
+
result = dict(full_table.get(effective_caster_level, {}))
|
|
247
|
+
|
|
248
|
+
# Warlock pact slots are separate (added alongside)
|
|
249
|
+
if has_warlock and warlock_level > 0:
|
|
250
|
+
warlock_table = tables.get("spell_slots_warlock", {})
|
|
251
|
+
pact_info = warlock_table.get(warlock_level, {})
|
|
252
|
+
if pact_info:
|
|
253
|
+
result["pact"] = pact_info.get("pact", 0)
|
|
254
|
+
result["pact_level"] = pact_info.get("pact_level", 0)
|
|
255
|
+
|
|
256
|
+
return result
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Logic
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def op_if_then_else(args: list[Any], _tables: dict) -> Any:
|
|
265
|
+
"""args: [condition, true_value, false_value]."""
|
|
266
|
+
return args[1] if args[0] else args[2]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def op_eq(args: list[Any], _tables: dict) -> bool:
|
|
270
|
+
"""args[0] == args[1]."""
|
|
271
|
+
return args[0] == args[1]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def op_gt(args: list[Any], _tables: dict) -> bool:
|
|
275
|
+
"""args[0] > args[1]."""
|
|
276
|
+
return args[0] > args[1]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def op_gte(args: list[Any], _tables: dict) -> bool:
|
|
280
|
+
"""args[0] >= args[1]."""
|
|
281
|
+
return args[0] >= args[1]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def op_in_set(args: list[Any], _tables: dict) -> bool:
|
|
285
|
+
"""args[0] in args[1] (where args[1] is a list/set)."""
|
|
286
|
+
return args[0] in args[1]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def op_not(args: list[Any], _tables: dict) -> bool:
|
|
290
|
+
"""not args[0]."""
|
|
291
|
+
return not args[0]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# Utility
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def op_lookup(args: list[Any], tables: dict) -> Any:
|
|
300
|
+
"""Look up a value from a named table.
|
|
301
|
+
|
|
302
|
+
args: [table_name, key] or [table_name, key, default]
|
|
303
|
+
"""
|
|
304
|
+
table_name = str(args[0])
|
|
305
|
+
key = args[1]
|
|
306
|
+
default = args[2] if len(args) > 2 else None
|
|
307
|
+
table = tables.get(table_name, {})
|
|
308
|
+
# Try direct lookup, then string version of key
|
|
309
|
+
result = table.get(key)
|
|
310
|
+
if result is None:
|
|
311
|
+
result = table.get(str(key))
|
|
312
|
+
if result is None:
|
|
313
|
+
result = default
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def op_format_mod(args: list[Any], _tables: dict) -> str:
|
|
318
|
+
"""Format an integer as a modifier string: +X or -X."""
|
|
319
|
+
val = int(args[0])
|
|
320
|
+
return f"+{val}" if val >= 0 else str(val)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def op_coalesce(args: list[Any], _tables: dict) -> Any:
|
|
324
|
+
"""Return first non-None argument."""
|
|
325
|
+
for a in args:
|
|
326
|
+
if a is not None:
|
|
327
|
+
return a
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def op_const(args: list[Any], _tables: dict) -> Any:
|
|
332
|
+
"""Return a constant value. args: [value]."""
|
|
333
|
+
return args[0]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def op_passive_score(args: list[Any], _tables: dict) -> int:
|
|
337
|
+
"""10 + skill_bonus. Standard passive score formula."""
|
|
338
|
+
return 10 + int(args[0])
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def op_spell_mod_resolve(args: list[Any], _tables: dict) -> int:
|
|
342
|
+
"""Resolve spellcasting modifier from ability name + all 6 modifiers.
|
|
343
|
+
|
|
344
|
+
args: [spell_ability_name, str_mod, dex_mod, con_mod, int_mod, wis_mod, cha_mod]
|
|
345
|
+
spell_ability_name is e.g. "intelligence", "wisdom", "charisma"
|
|
346
|
+
"""
|
|
347
|
+
ability_name = str(args[0]).lower() if args[0] else "charisma"
|
|
348
|
+
ability_order = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
|
|
349
|
+
try:
|
|
350
|
+
idx = ability_order.index(ability_name)
|
|
351
|
+
except ValueError:
|
|
352
|
+
idx = 5 # default to charisma
|
|
353
|
+
return int(args[1 + idx])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def op_hit_dice_str(args: list[Any], _tables: dict) -> str:
|
|
357
|
+
"""Format hit dice as string: '5d8' or '5d8 + 3d10' for multiclass.
|
|
358
|
+
|
|
359
|
+
args: [class_levels_json, class_hit_dice_json]
|
|
360
|
+
"""
|
|
361
|
+
class_levels = args[0] # dict
|
|
362
|
+
class_hit_dice = args[1] # dict
|
|
363
|
+
|
|
364
|
+
if not class_levels:
|
|
365
|
+
return "1d8"
|
|
366
|
+
|
|
367
|
+
parts = []
|
|
368
|
+
for class_name, class_level in class_levels.items():
|
|
369
|
+
die_size = class_hit_dice.get(class_name, 8)
|
|
370
|
+
parts.append(f"{class_level}d{die_size}")
|
|
371
|
+
|
|
372
|
+
return " + ".join(parts)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Operation registry
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
OPERATIONS: dict[str, callable] = {
|
|
380
|
+
# Arithmetic
|
|
381
|
+
"add": op_add,
|
|
382
|
+
"sub": op_sub,
|
|
383
|
+
"mul": op_mul,
|
|
384
|
+
"floor_div": op_floor_div,
|
|
385
|
+
"max_val": op_max_val,
|
|
386
|
+
"min_val": op_min_val,
|
|
387
|
+
# D&D compound
|
|
388
|
+
"ability_mod": op_ability_mod,
|
|
389
|
+
"prof_bonus": op_prof_bonus,
|
|
390
|
+
"prof_add": op_prof_add,
|
|
391
|
+
"skill_bonus": op_skill_bonus,
|
|
392
|
+
"hp_at_level": op_hp_at_level,
|
|
393
|
+
"multiclass_hp": op_multiclass_hp,
|
|
394
|
+
"ac_with_armor": op_ac_with_armor,
|
|
395
|
+
"spell_slots": op_spell_slots,
|
|
396
|
+
"multiclass_spell_slots": op_multiclass_spell_slots,
|
|
397
|
+
"hit_dice_str": op_hit_dice_str,
|
|
398
|
+
# Logic
|
|
399
|
+
"if_then_else": op_if_then_else,
|
|
400
|
+
"eq": op_eq,
|
|
401
|
+
"gt": op_gt,
|
|
402
|
+
"gte": op_gte,
|
|
403
|
+
"in_set": op_in_set,
|
|
404
|
+
"not": op_not,
|
|
405
|
+
# Utility
|
|
406
|
+
"lookup": op_lookup,
|
|
407
|
+
"format_mod": op_format_mod,
|
|
408
|
+
"coalesce": op_coalesce,
|
|
409
|
+
"const": op_const,
|
|
410
|
+
"passive_score": op_passive_score,
|
|
411
|
+
"spell_mod_resolve": op_spell_mod_resolve,
|
|
412
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pydantic models for the computation graph schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NodeType(str, Enum):
|
|
12
|
+
"""Type of computation node."""
|
|
13
|
+
|
|
14
|
+
INPUT = "input" # User-provided value (ability score, level, class)
|
|
15
|
+
FORMULA = "formula" # Computed from other nodes via a formula
|
|
16
|
+
LOOKUP = "lookup" # Value from a lookup table keyed by another node
|
|
17
|
+
AGGREGATE = "aggregate" # Combines multiple nodes (e.g., sum of class HP contributions)
|
|
18
|
+
OUTPUT = "output" # Terminal display node (formatted string, etc.)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FormulaSpec(BaseModel):
|
|
22
|
+
"""A formula expressed as a named operation + arguments.
|
|
23
|
+
|
|
24
|
+
Safe, JSON-serializable, portable to TypeScript. Never eval().
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
FormulaSpec(op="ability_mod", args=["str_score"])
|
|
28
|
+
FormulaSpec(op="add", args=["dex_mod", "proficiency_bonus"])
|
|
29
|
+
FormulaSpec(op="prof_add", args=["wis_mod", "proficiency_bonus", "save.wisdom.proficient"])
|
|
30
|
+
FormulaSpec(op="lookup", args=["armor_base_ac", "armor_type"])
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
op: str
|
|
34
|
+
args: list[Any] = Field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ComputationNode(BaseModel):
|
|
38
|
+
"""A single node in the character sheet computation graph.
|
|
39
|
+
|
|
40
|
+
Each node represents one value on the character sheet. Nodes can depend
|
|
41
|
+
on other nodes via their formula args (which reference node IDs).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
id: str # Unique ID: "str_mod", "hp_max", "skill.perception"
|
|
45
|
+
node_type: NodeType
|
|
46
|
+
label: str # Human-readable: "Strength Modifier"
|
|
47
|
+
layer: int = 0 # 0=input, 1=simple derived, 2=complex derived
|
|
48
|
+
group: str = "" # "ability_scores", "combat", "skills", "spellcasting"
|
|
49
|
+
formula: FormulaSpec | None = None # How to compute this node
|
|
50
|
+
inputs: list[str] = Field(default_factory=list) # Explicit dependency list (node IDs)
|
|
51
|
+
default_value: Any = None
|
|
52
|
+
min_value: float | None = None # Constraint: minimum allowed value
|
|
53
|
+
max_value: float | None = None # Constraint: maximum allowed value
|
|
54
|
+
description: str = "" # Rule citation, e.g., "PHB p.13"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Ruleset(BaseModel):
|
|
58
|
+
"""A complete computation graph definition + lookup tables.
|
|
59
|
+
|
|
60
|
+
The D&D 5e 2024 ruleset is the built-in default. Custom rulesets
|
|
61
|
+
(Phase 4) will be stored in Neo4j.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
id: str # "dnd_5e_2024"
|
|
65
|
+
name: str # "D&D 5th Edition (2024)"
|
|
66
|
+
version: str = "1.0.0"
|
|
67
|
+
nodes: dict[str, ComputationNode] # Keyed by node ID
|
|
68
|
+
lookup_tables: dict[str, Any] = Field(default_factory=dict) # Named tables
|
|
69
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Theme-specific scaling overrides for the computation graph.
|
|
2
|
+
|
|
3
|
+
Allows themes (sci_fi, modern_warfare, steampunk, cosmic_horror, etc.) to override
|
|
4
|
+
input node values and lookup table entries so the same computation graph produces
|
|
5
|
+
mechanically appropriate results for any setting.
|
|
6
|
+
|
|
7
|
+
Example: in a modern_warfare theme, "plate" armor becomes "tactical body armor"
|
|
8
|
+
with the same mechanical AC 18, but weapon ranges and mount speeds change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ThemeScalingLayer(BaseModel):
|
|
19
|
+
"""Theme-specific overrides for computation graph input values."""
|
|
20
|
+
|
|
21
|
+
theme: str = Field(..., description="Theme identifier (e.g., 'modern_warfare', 'sci_fi')")
|
|
22
|
+
description: str = Field("", description="Human-readable description of the theme")
|
|
23
|
+
input_overrides: dict[str, Any] = Field(
|
|
24
|
+
default_factory=dict,
|
|
25
|
+
description="Computation node ID -> override value. Applied before evaluation.",
|
|
26
|
+
)
|
|
27
|
+
lookup_overrides: dict[str, dict] = Field(
|
|
28
|
+
default_factory=dict,
|
|
29
|
+
description="Lookup table name -> {key: new_value}. Merged into ruleset tables.",
|
|
30
|
+
)
|
|
31
|
+
flavor_renames: dict[str, str] = Field(
|
|
32
|
+
default_factory=dict,
|
|
33
|
+
description="Original term -> themed term for display (e.g., 'plate' -> 'power armor').",
|
|
34
|
+
)
|
|
35
|
+
notes: list[str] = Field(
|
|
36
|
+
default_factory=list,
|
|
37
|
+
description="DM-facing notes about how the theme changes mechanics.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Predefined scaling profiles
|
|
43
|
+
# =============================================================================
|
|
44
|
+
|
|
45
|
+
PREDEFINED_THEME_SCALING: dict[str, ThemeScalingLayer] = {
|
|
46
|
+
"traditional_dnd": ThemeScalingLayer(
|
|
47
|
+
theme="traditional_dnd",
|
|
48
|
+
description="Standard D&D 5e rules. No mechanical overrides.",
|
|
49
|
+
input_overrides={},
|
|
50
|
+
lookup_overrides={},
|
|
51
|
+
flavor_renames={},
|
|
52
|
+
notes=["Default ruleset, no scaling applied."],
|
|
53
|
+
),
|
|
54
|
+
"modern_warfare": ThemeScalingLayer(
|
|
55
|
+
theme="modern_warfare",
|
|
56
|
+
description="Modern military setting. Firearms, ballistic vests, vehicles.",
|
|
57
|
+
input_overrides={},
|
|
58
|
+
lookup_overrides={
|
|
59
|
+
"armor_base_ac": {
|
|
60
|
+
"leather": 11,
|
|
61
|
+
"studded_leather": 12,
|
|
62
|
+
"chain_shirt": 14,
|
|
63
|
+
"breastplate": 15,
|
|
64
|
+
"half_plate": 16,
|
|
65
|
+
"plate": 18,
|
|
66
|
+
},
|
|
67
|
+
"weapon_ranges": {
|
|
68
|
+
"shortbow": 300,
|
|
69
|
+
"longbow": 600,
|
|
70
|
+
"light_crossbow": 400,
|
|
71
|
+
"heavy_crossbow": 800,
|
|
72
|
+
"hand_crossbow": 200,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
flavor_renames={
|
|
76
|
+
"plate": "tactical body armor",
|
|
77
|
+
"chain_mail": "flak jacket",
|
|
78
|
+
"breastplate": "kevlar vest",
|
|
79
|
+
"leather": "light ballistic vest",
|
|
80
|
+
"studded_leather": "reinforced ballistic vest",
|
|
81
|
+
"shield": "riot shield",
|
|
82
|
+
"longbow": "assault rifle",
|
|
83
|
+
"shortbow": "submachine gun",
|
|
84
|
+
"heavy_crossbow": "sniper rifle",
|
|
85
|
+
"light_crossbow": "carbine",
|
|
86
|
+
"hand_crossbow": "pistol",
|
|
87
|
+
"longsword": "combat knife",
|
|
88
|
+
"greatsword": "machete",
|
|
89
|
+
"mount_warhorse": "armored vehicle",
|
|
90
|
+
"mount_riding_horse": "jeep",
|
|
91
|
+
},
|
|
92
|
+
notes=[
|
|
93
|
+
"Firearms use crossbow/bow mechanics with extended ranges.",
|
|
94
|
+
"Armor retains AC values but is re-flavored as modern equipment.",
|
|
95
|
+
"Mounts become vehicles with equivalent movement.",
|
|
96
|
+
"Spellcasting can be re-flavored as tech gadgets or psionics.",
|
|
97
|
+
],
|
|
98
|
+
),
|
|
99
|
+
"sci_fi": ThemeScalingLayer(
|
|
100
|
+
theme="sci_fi",
|
|
101
|
+
description="Science fiction setting. Energy weapons, force shields, starships.",
|
|
102
|
+
input_overrides={},
|
|
103
|
+
lookup_overrides={
|
|
104
|
+
"armor_base_ac": {
|
|
105
|
+
"leather": 12,
|
|
106
|
+
"studded_leather": 13,
|
|
107
|
+
"chain_shirt": 14,
|
|
108
|
+
"breastplate": 15,
|
|
109
|
+
"half_plate": 16,
|
|
110
|
+
"plate": 19,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
flavor_renames={
|
|
114
|
+
"plate": "power armor",
|
|
115
|
+
"chain_mail": "medium exosuit",
|
|
116
|
+
"breastplate": "light exosuit",
|
|
117
|
+
"leather": "enviro-suit",
|
|
118
|
+
"studded_leather": "reinforced enviro-suit",
|
|
119
|
+
"shield": "energy shield generator",
|
|
120
|
+
"longbow": "plasma rifle",
|
|
121
|
+
"shortbow": "laser carbine",
|
|
122
|
+
"heavy_crossbow": "rail gun",
|
|
123
|
+
"light_crossbow": "blaster pistol",
|
|
124
|
+
"longsword": "energy blade",
|
|
125
|
+
"greatsword": "plasma sword",
|
|
126
|
+
"mount_warhorse": "hoverbike",
|
|
127
|
+
"mount_riding_horse": "speeder",
|
|
128
|
+
},
|
|
129
|
+
notes=[
|
|
130
|
+
"Energy weapons use existing damage types (radiant for lasers, force for plasma).",
|
|
131
|
+
"Shields are energy-based but use the same +2 AC bonus.",
|
|
132
|
+
"Heavy armor is powered exosuits; no STR requirement change.",
|
|
133
|
+
"Starship combat uses a separate encounter framework.",
|
|
134
|
+
],
|
|
135
|
+
),
|
|
136
|
+
"steampunk": ThemeScalingLayer(
|
|
137
|
+
theme="steampunk",
|
|
138
|
+
description="Victorian-era clockwork and steam-powered technology.",
|
|
139
|
+
input_overrides={},
|
|
140
|
+
lookup_overrides={
|
|
141
|
+
"armor_base_ac": {
|
|
142
|
+
"chain_shirt": 14,
|
|
143
|
+
"breastplate": 15,
|
|
144
|
+
"plate": 19,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
flavor_renames={
|
|
148
|
+
"plate": "clockwork full-plate",
|
|
149
|
+
"chain_mail": "riveted iron chassis",
|
|
150
|
+
"breastplate": "brass cuirass",
|
|
151
|
+
"leather": "oiled duster",
|
|
152
|
+
"studded_leather": "reinforced duster",
|
|
153
|
+
"shield": "pneumatic buckler",
|
|
154
|
+
"heavy_crossbow": "steam cannon",
|
|
155
|
+
"light_crossbow": "clockwork repeater",
|
|
156
|
+
"hand_crossbow": "derringer",
|
|
157
|
+
"longbow": "aether rifle",
|
|
158
|
+
"shortbow": "spring-bolt pistol",
|
|
159
|
+
"mount_warhorse": "steam walker",
|
|
160
|
+
"mount_riding_horse": "velocipede",
|
|
161
|
+
},
|
|
162
|
+
notes=[
|
|
163
|
+
"Clockwork mechanisms replace magic for non-spellcasters.",
|
|
164
|
+
"Steam-powered armor may overheat (DM discretion for flavor).",
|
|
165
|
+
"Alchemy replaces standard potion mechanics.",
|
|
166
|
+
],
|
|
167
|
+
),
|
|
168
|
+
"cosmic_horror": ThemeScalingLayer(
|
|
169
|
+
theme="cosmic_horror",
|
|
170
|
+
description="Lovecraftian horror. Sanity mechanics, reality distortion, eldritch entities.",
|
|
171
|
+
input_overrides={},
|
|
172
|
+
lookup_overrides={},
|
|
173
|
+
flavor_renames={
|
|
174
|
+
"plate": "warded armor",
|
|
175
|
+
"shield": "elder sign ward",
|
|
176
|
+
"longbow": "bolt-action rifle",
|
|
177
|
+
"shortbow": "revolver",
|
|
178
|
+
"heavy_crossbow": "elephant gun",
|
|
179
|
+
"greatsword": "ritual blade",
|
|
180
|
+
},
|
|
181
|
+
notes=[
|
|
182
|
+
"Optional sanity mechanic: WIS saves against cosmic horror (DC 12-20).",
|
|
183
|
+
"Failure costs 1d4 to 1d10 'Insight' (tracked separately from HP).",
|
|
184
|
+
"At 0 Insight, character gains a short-term madness.",
|
|
185
|
+
"Eldritch spells may require Insight instead of spell slots.",
|
|
186
|
+
"Reality distortion can impose disadvantage on perception checks.",
|
|
187
|
+
],
|
|
188
|
+
),
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_theme_scaling(theme: str) -> ThemeScalingLayer | None:
|
|
193
|
+
"""Get a predefined theme scaling layer, or None if not predefined."""
|
|
194
|
+
return PREDEFINED_THEME_SCALING.get(theme)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def list_predefined_themes() -> list[dict]:
|
|
198
|
+
"""Return summaries of all predefined theme scaling profiles."""
|
|
199
|
+
return [
|
|
200
|
+
{
|
|
201
|
+
"theme": layer.theme,
|
|
202
|
+
"description": layer.description,
|
|
203
|
+
"override_count": len(layer.input_overrides) + len(layer.lookup_overrides),
|
|
204
|
+
"rename_count": len(layer.flavor_renames),
|
|
205
|
+
}
|
|
206
|
+
for layer in PREDEFINED_THEME_SCALING.values()
|
|
207
|
+
]
|