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