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,217 @@
|
|
|
1
|
+
"""Computation graph evaluator.
|
|
2
|
+
|
|
3
|
+
Pure function: evaluate(ruleset, input_values) -> all_computed_values.
|
|
4
|
+
No I/O, no side effects, trivially testable.
|
|
5
|
+
|
|
6
|
+
1. Topological sort on the graph (respecting node dependencies)
|
|
7
|
+
2. Walk in order, resolve each node's formula from its resolved inputs
|
|
8
|
+
3. Apply min/max constraints
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .operations import OPERATIONS
|
|
16
|
+
from .schema import ComputationNode, NodeType, Ruleset
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EvaluationError(Exception):
|
|
20
|
+
"""Raised when the evaluator encounters an unresolvable node."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _topological_sort(nodes: dict[str, ComputationNode]) -> list[str]:
|
|
24
|
+
"""Kahn's algorithm for topological sort.
|
|
25
|
+
|
|
26
|
+
Returns node IDs in evaluation order (dependencies before dependents).
|
|
27
|
+
Raises EvaluationError if cycles are detected.
|
|
28
|
+
"""
|
|
29
|
+
# Build adjacency and in-degree
|
|
30
|
+
in_degree: dict[str, int] = dict.fromkeys(nodes, 0)
|
|
31
|
+
dependents: dict[str, list[str]] = {nid: [] for nid in nodes}
|
|
32
|
+
|
|
33
|
+
for nid, node in nodes.items():
|
|
34
|
+
deps = _get_dependencies(node, nodes)
|
|
35
|
+
in_degree[nid] = len(deps)
|
|
36
|
+
for dep in deps:
|
|
37
|
+
if dep in dependents:
|
|
38
|
+
dependents[dep].append(nid)
|
|
39
|
+
|
|
40
|
+
# Start with nodes that have no dependencies
|
|
41
|
+
queue = [nid for nid, deg in in_degree.items() if deg == 0]
|
|
42
|
+
order: list[str] = []
|
|
43
|
+
|
|
44
|
+
while queue:
|
|
45
|
+
# Sort for deterministic order
|
|
46
|
+
queue.sort()
|
|
47
|
+
nid = queue.pop(0)
|
|
48
|
+
order.append(nid)
|
|
49
|
+
for dep_nid in dependents[nid]:
|
|
50
|
+
in_degree[dep_nid] -= 1
|
|
51
|
+
if in_degree[dep_nid] == 0:
|
|
52
|
+
queue.append(dep_nid)
|
|
53
|
+
|
|
54
|
+
if len(order) != len(nodes):
|
|
55
|
+
missing = set(nodes.keys()) - set(order)
|
|
56
|
+
raise EvaluationError(f"Cycle detected in computation graph. Unresolvable nodes: {missing}")
|
|
57
|
+
|
|
58
|
+
return order
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_dependencies(node: ComputationNode, all_nodes: dict[str, ComputationNode]) -> list[str]:
|
|
62
|
+
"""Extract dependency node IDs from a node's formula args and explicit inputs.
|
|
63
|
+
|
|
64
|
+
Formula args that are strings matching a node ID are dependencies.
|
|
65
|
+
Non-string args (constants) are not dependencies.
|
|
66
|
+
"""
|
|
67
|
+
deps = set()
|
|
68
|
+
|
|
69
|
+
# Explicit inputs
|
|
70
|
+
for inp in node.inputs:
|
|
71
|
+
if inp in all_nodes:
|
|
72
|
+
deps.add(inp)
|
|
73
|
+
|
|
74
|
+
# Formula args that reference other nodes
|
|
75
|
+
if node.formula:
|
|
76
|
+
for arg in node.formula.args:
|
|
77
|
+
if isinstance(arg, str) and arg in all_nodes:
|
|
78
|
+
deps.add(arg)
|
|
79
|
+
|
|
80
|
+
return list(deps)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_args(
|
|
84
|
+
args: list[Any],
|
|
85
|
+
computed: dict[str, Any],
|
|
86
|
+
all_nodes: dict[str, ComputationNode],
|
|
87
|
+
) -> list[Any]:
|
|
88
|
+
"""Resolve formula arguments.
|
|
89
|
+
|
|
90
|
+
- If an arg is a string matching a computed node ID, substitute the computed value.
|
|
91
|
+
- Otherwise, pass the arg through as a literal constant.
|
|
92
|
+
"""
|
|
93
|
+
resolved = []
|
|
94
|
+
for arg in args:
|
|
95
|
+
if isinstance(arg, str) and arg in all_nodes:
|
|
96
|
+
resolved.append(computed.get(arg))
|
|
97
|
+
else:
|
|
98
|
+
resolved.append(arg)
|
|
99
|
+
return resolved
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def evaluate(
|
|
103
|
+
ruleset: Ruleset,
|
|
104
|
+
input_values: dict[str, Any],
|
|
105
|
+
) -> dict[str, Any]:
|
|
106
|
+
"""Evaluate the computation graph with given inputs.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
ruleset: The graph definition (nodes + lookup tables).
|
|
110
|
+
input_values: Values for INPUT nodes, keyed by node ID.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Dict of all computed values, keyed by node ID.
|
|
114
|
+
"""
|
|
115
|
+
nodes = ruleset.nodes
|
|
116
|
+
tables = ruleset.lookup_tables
|
|
117
|
+
|
|
118
|
+
# Sort nodes in dependency order
|
|
119
|
+
order = _topological_sort(nodes)
|
|
120
|
+
|
|
121
|
+
computed: dict[str, Any] = {}
|
|
122
|
+
|
|
123
|
+
for nid in order:
|
|
124
|
+
node = nodes[nid]
|
|
125
|
+
|
|
126
|
+
if node.node_type == NodeType.INPUT:
|
|
127
|
+
# Use provided value, fall back to default
|
|
128
|
+
value = input_values.get(nid, node.default_value)
|
|
129
|
+
computed[nid] = value
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if node.formula is None:
|
|
133
|
+
# No formula — use default or None
|
|
134
|
+
computed[nid] = node.default_value
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Resolve formula arguments
|
|
138
|
+
resolved_args = _resolve_args(node.formula.args, computed, nodes)
|
|
139
|
+
|
|
140
|
+
# Look up operation
|
|
141
|
+
op_fn = OPERATIONS.get(node.formula.op)
|
|
142
|
+
if op_fn is None:
|
|
143
|
+
raise EvaluationError(f"Unknown operation '{node.formula.op}' in node '{nid}'")
|
|
144
|
+
|
|
145
|
+
# Execute operation
|
|
146
|
+
try:
|
|
147
|
+
value = op_fn(resolved_args, tables)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
raise EvaluationError(
|
|
150
|
+
f"Error evaluating node '{nid}' (op={node.formula.op}, args={resolved_args}): {e}"
|
|
151
|
+
) from e
|
|
152
|
+
|
|
153
|
+
# Apply constraints
|
|
154
|
+
if value is not None and isinstance(value, (int, float)):
|
|
155
|
+
if node.min_value is not None:
|
|
156
|
+
value = max(value, node.min_value)
|
|
157
|
+
if node.max_value is not None:
|
|
158
|
+
value = min(value, node.max_value)
|
|
159
|
+
|
|
160
|
+
computed[nid] = value
|
|
161
|
+
|
|
162
|
+
return computed
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_evaluation_order(ruleset: Ruleset) -> list[str]:
|
|
166
|
+
"""Return the topological evaluation order for debugging/visualization."""
|
|
167
|
+
return _topological_sort(ruleset.nodes)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_node_dependencies(ruleset: Ruleset, node_id: str) -> list[str]:
|
|
171
|
+
"""Return all upstream dependencies of a node (transitive closure)."""
|
|
172
|
+
nodes = ruleset.nodes
|
|
173
|
+
if node_id not in nodes:
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
visited = set()
|
|
177
|
+
stack = [node_id]
|
|
178
|
+
|
|
179
|
+
while stack:
|
|
180
|
+
nid = stack.pop()
|
|
181
|
+
if nid in visited:
|
|
182
|
+
continue
|
|
183
|
+
visited.add(nid)
|
|
184
|
+
node = nodes[nid]
|
|
185
|
+
deps = _get_dependencies(node, nodes)
|
|
186
|
+
stack.extend(deps)
|
|
187
|
+
|
|
188
|
+
visited.discard(node_id)
|
|
189
|
+
return sorted(visited)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_downstream_nodes(ruleset: Ruleset, node_id: str) -> list[str]:
|
|
193
|
+
"""Return all downstream nodes that depend on a given node."""
|
|
194
|
+
nodes = ruleset.nodes
|
|
195
|
+
if node_id not in nodes:
|
|
196
|
+
return []
|
|
197
|
+
|
|
198
|
+
# Build reverse adjacency
|
|
199
|
+
dependents: dict[str, set[str]] = {nid: set() for nid in nodes}
|
|
200
|
+
for nid, node in nodes.items():
|
|
201
|
+
deps = _get_dependencies(node, nodes)
|
|
202
|
+
for dep in deps:
|
|
203
|
+
if dep in dependents:
|
|
204
|
+
dependents[dep].add(nid)
|
|
205
|
+
|
|
206
|
+
visited = set()
|
|
207
|
+
stack = [node_id]
|
|
208
|
+
|
|
209
|
+
while stack:
|
|
210
|
+
nid = stack.pop()
|
|
211
|
+
if nid in visited:
|
|
212
|
+
continue
|
|
213
|
+
visited.add(nid)
|
|
214
|
+
stack.extend(dependents.get(nid, set()))
|
|
215
|
+
|
|
216
|
+
visited.discard(node_id)
|
|
217
|
+
return sorted(visited)
|