Current section

Files

Jump to
ex_ttrpg_dev lib rule_system loader.ex
Raw

lib/rule_system/loader.ex

defmodule ExTTRPGDev.RuleSystem.Loader do
@moduledoc """
Reads a rule system directory of TOML files and produces a unified data map
ready for DAG construction.
The output map has the shape:
```
%{
module: %RuleModule{},
nodes: %{{type_id, concept_id, field_name} => node_map},
rolling_methods: %{method_id => method_map},
concept_metadata: %{{type_id, concept_id} => metadata_map},
effects: [effect_map]
}
```
"""
alias ExTTRPGDev.RuleSystem.{InventoryRules, RuleModule}
@module_file "module.toml"
@character_building_file "character_building.toml"
@inventory_rules_file "inventory_rules.toml"
@doc "Loads a rule system directory, returning `{:ok, data}` or `{:error, reason}`."
def load(path) do
with {:ok, rule_module} <- load_module(path),
{:ok, data} <- load_concept_files(path, rule_module) do
inventory_rules = load_inventory_rules(path)
data = expand_metadata_contributions(data, rule_module)
{:ok, data |> Map.put(:module, rule_module) |> Map.put(:inventory_rules, inventory_rules)}
end
end
@doc "Loads a rule system directory, raising on failure."
def load!(path) do
case load(path) do
{:ok, data} -> data
{:error, reason} -> raise "Failed to load rule system at #{path}: #{inspect(reason)}"
end
end
defp load_module(path) do
module_path = Path.join(path, @module_file)
with {:ok, contents} <- File.read(module_path),
{:ok, map} <- TomlElixir.decode(contents),
{:ok, rule_module} <- RuleModule.from_map(map) do
{:ok, %{rule_module | character_building_choices: load_character_building_choices(path)}}
else
{:error, reason} -> {:error, {:module_parse_error, reason}}
end
end
defp load_inventory_rules(path) do
rules_path = Path.join(path, @inventory_rules_file)
with {:ok, contents} <- File.read(rules_path),
{:ok, map} <- TomlElixir.decode(contents),
{:ok, rules} <- InventoryRules.from_map(map) do
rules
else
_ -> %InventoryRules{}
end
end
defp load_character_building_choices(path) do
building_path = Path.join(path, @character_building_file)
with {:ok, contents} <- File.read(building_path),
{:ok, map} <- TomlElixir.decode(contents) do
map
|> Map.get("character_choice", [])
|> Enum.map(fn cc ->
%RuleModule.CharacterChoice{
concept_type: cc["concept_type"],
required: Map.get(cc, "required", true)
}
end)
else
_ -> []
end
end
defp load_concept_files(path, rule_module) do
type_ids = RuleModule.concept_type_ids(rule_module)
initial = %{nodes: %{}, rolling_methods: %{}, concept_metadata: %{}, effects: []}
path
|> Path.join("concepts/**/*.toml")
|> Path.wildcard()
|> Enum.reduce_while({:ok, initial}, fn file_path, {:ok, acc} ->
with {:ok, contents} <- File.read(file_path),
{:ok, toml_map} <- TomlElixir.decode(contents) do
{:cont, {:ok, process_toml_map(toml_map, acc, type_ids)}}
else
{:error, reason} -> {:halt, {:error, {:file_parse_error, file_path, reason}}}
end
end)
end
defp process_toml_map(toml_map, acc, type_ids) do
Enum.reduce(toml_map, acc, fn {type_id, concepts}, acc ->
if MapSet.member?(type_ids, type_id) and is_map(concepts) do
process_type(type_id, concepts, acc)
else
acc
end
end)
end
defp process_type("rolling_method", concepts, acc) do
rolling_methods =
Enum.reduce(concepts, acc.rolling_methods, fn {id, fields}, rm ->
Map.put(rm, id, parse_rolling_method(fields))
end)
%{acc | rolling_methods: rolling_methods}
end
defp process_type(type_id, concepts, acc) do
Enum.reduce(concepts, acc, fn {concept_id, fields}, acc ->
process_concept(type_id, concept_id, fields, acc)
end)
end
defp process_concept(type_id, concept_id, fields, acc) when is_map(fields) do
{nodes, metadata, effects} = parse_concept_fields(type_id, concept_id, fields)
%{
acc
| nodes: Map.merge(acc.nodes, nodes),
concept_metadata: Map.put(acc.concept_metadata, {type_id, concept_id}, metadata),
effects: acc.effects ++ effects
}
end
defp parse_concept_fields(type_id, concept_id, fields) do
Enum.reduce(fields, {%{}, %{}, []}, fn {field_name, value}, {nodes, meta, effects} ->
cond do
field_name == "contributes" and is_list(value) ->
new_effects =
Enum.map(value, &parse_effect({type_id, concept_id}, &1))
{nodes, Map.put(meta, field_name, value), effects ++ new_effects}
is_map(value) and (Map.has_key?(value, "type") or Map.has_key?(value, "formula")) ->
node_key = {type_id, concept_id, field_name}
{Map.put(nodes, node_key, parse_node(value)), meta, effects}
true ->
{nodes, Map.put(meta, field_name, value), effects}
end
end)
end
defp parse_node(%{"type" => "generated"} = map) do
%{type: :generated, method: Map.get(map, "method")}
end
defp parse_node(%{"type" => "accumulator"} = map) do
%{type: :accumulator, base: Map.get(map, "base")}
end
defp parse_node(%{"type" => "mapping"} = map) do
%{type: :mapping, input: Map.get(map, "input"), steps: Map.get(map, "steps")}
end
defp parse_node(%{"formula" => formula}) do
%{type: :formula, formula: formula}
end
defp parse_rolling_method(fields) do
%{
name: fields["name"],
dice: fields["dice"],
drop: fields["drop"],
default: Map.get(fields, "default", false)
}
end
defp expand_metadata_contributions(data, rule_module) do
new_effects =
Enum.flat_map(rule_module.metadata_contributions, fn contribution ->
expand_contribution(data.concept_metadata, contribution)
end)
%{data | effects: data.effects ++ new_effects}
end
defp expand_contribution(concept_metadata, contribution) do
concept_metadata
|> Enum.filter(fn {{type, _id}, _meta} -> type == contribution.from_type end)
|> Enum.flat_map(fn {{_type, source_id}, meta} ->
expand_source_values(concept_metadata, contribution, source_id, meta)
end)
end
defp expand_source_values(concept_metadata, contribution, source_id, meta) do
values = Map.get(meta, contribution.from_field, [])
Enum.flat_map(values, fn val ->
targets = find_contribution_targets(concept_metadata, contribution, val)
Enum.map(targets, fn target_id ->
%{
source: {contribution.from_type, source_id},
target: {contribution.to_type, target_id, contribution.to_field},
value: contribution.value,
when: nil
}
end)
end)
end
defp find_contribution_targets(concept_metadata, contribution, val) do
case Enum.find(contribution.label_filters, fn lf -> lf.label == val end) do
%{filter_field: filter_field, filter_value: filter_value} ->
concept_metadata
|> Enum.filter(fn {{type, _id}, meta} ->
type == contribution.to_type and Map.get(meta, filter_field) == filter_value
end)
|> Enum.map(fn {{_type, id}, _meta} -> id end)
nil ->
concept_metadata
|> Enum.filter(fn {{type, id}, meta} ->
type == contribution.to_type and (id == val or Map.get(meta, "name") == val)
end)
|> Enum.map(fn {{_type, id}, _meta} -> id end)
end
end
defp parse_effect(source, %{"target" => target, "value" => value} = entry) do
parsed_target =
case Regex.run(~r/(\w+)\('([^']+)'\)\.(\w+)/, target) do
[_, type_id, concept_id, field_name] -> {type_id, concept_id, field_name}
_ -> target
end
%{source: source, target: parsed_target, value: value, when: Map.get(entry, "when")}
end
end