defmodule ExTTRPGDev.RuleSystem.Evaluator do @moduledoc """ Evaluates a rule system DAG for a given character state. Takes the loaded system, a map of generated (base) values, and an optional list of active effects (from equipped items, active feats, etc.), and produces a fully-resolved map of all node values. """ alias ExTTRPGDev.RuleSystem.{Expression, Graph} @doc """ Evaluates all nodes in the DAG in topological order. - `system` — output of `Graph.build/1` - `generated_values` — map of `{type_id, concept_id, field_name} => number` for generated nodes - `effects` — list of `%{target: {type_id, concept_id, field_name}, value: number}` Returns `{:ok, resolved_map}` or `{:error, reason}`. ## Examples iex> system = ExTTRPGDev.RuleSystems.load_system!("dnd_5e_srd") iex> attrs = ~w[strength dexterity constitution wisdom intelligence charisma] iex> generated = Map.new(attrs, &{{"ability", &1, "base_score"}, 10}) iex> {:ok, resolved} = ExTTRPGDev.RuleSystem.Evaluator.evaluate(system, generated) iex> resolved[{"ability", "strength", "modifier"}] 0 """ def evaluate(system, generated_values, effects \\ []) do order = Graph.topological_order(system) Enum.reduce_while(order, {:ok, generated_values}, fn node_key, {:ok, resolved} -> case evaluate_node(node_key, system.nodes, resolved, effects) do {:ok, value} -> {:cont, {:ok, Map.put(resolved, node_key, value)}} {:error, _} = err -> {:halt, err} end end) end @doc "Same as `evaluate/3` but raises on error." def evaluate!(system, generated_values, effects \\ []) do case evaluate(system, generated_values, effects) do {:ok, resolved} -> resolved {:error, reason} -> raise "Evaluation failed: #{inspect(reason)}" end end defp evaluate_node(node_key, nodes, resolved, effects) do case Map.fetch(nodes, node_key) do {:ok, %{type: :generated}} -> fetch_generated(resolved, node_key) {:ok, %{type: :formula, formula: formula}} -> Expression.evaluate(formula, resolved) {:ok, %{type: :accumulator, base: base_formula}} -> evaluate_accumulator(base_formula, node_key, resolved, effects) {:ok, %{type: :mapping, input: input_formula, steps: steps}} -> evaluate_mapping(input_formula, steps, resolved) :error -> {:error, {:unknown_node, node_key}} end end defp evaluate_accumulator(base_formula, node_key, resolved, effects) do with {:ok, base_value} <- Expression.evaluate(base_formula, resolved) do effects |> Enum.filter(fn %{target: target} -> target == node_key end) |> Enum.reduce_while({:ok, base_value}, &apply_effect(&1, &2, resolved)) end end defp apply_effect(effect, {:ok, acc}, resolved) do condition = Map.get(effect, :when) item_fields = Map.get(effect, :item_fields, %{}) with {:ok, true} <- evaluate_condition(condition, resolved, item_fields), {:ok, n} <- resolve_effect_value(effect.value, resolved, item_fields) do {:cont, {:ok, acc + n}} else {:ok, false} -> {:cont, {:ok, acc}} error -> {:halt, error} end end defp evaluate_condition(nil, _resolved, _item_fields), do: {:ok, true} defp evaluate_condition(formula, resolved, item_fields) when is_binary(formula) do case formula |> substitute_item_fields(item_fields) |> Expression.evaluate(resolved) do {:ok, result} -> {:ok, truthy?(result)} error -> error end end defp truthy?(true), do: true defp truthy?(false), do: false defp truthy?(n) when is_number(n), do: n != 0 defp truthy?(_), do: false defp resolve_effect_value(value, _resolved, _item_fields) when is_number(value), do: {:ok, value} defp resolve_effect_value(formula, resolved, item_fields) when is_binary(formula) do formula |> substitute_item_fields(item_fields) |> Expression.evaluate(resolved) end defp substitute_item_fields(formula, item_fields) do Enum.reduce(item_fields, formula, fn {name, value}, acc -> String.replace(acc, "item.#{name}", to_string(value)) end) end defp evaluate_mapping(input_formula, steps, resolved) do with {:ok, input_value} <- Expression.evaluate(input_formula, resolved) do result = steps |> Enum.filter(fn [threshold, _] -> input_value >= threshold end) |> List.last() case result do [_, output] -> {:ok, output} nil -> {:error, {:mapping_no_match, input_value}} end end end defp fetch_generated(resolved, node_key) do case Map.fetch(resolved, node_key) do {:ok, value} -> {:ok, value} :error -> {:error, {:missing_generated_value, node_key}} end end end