Skip to content

refactor: introduce Node, Effect, and Decision structs - #161

Merged
QMalcolm merged 3 commits into
mainfrom
qmalcolm--refactor-node-effect-decision-structs
Jul 21, 2026
Merged

refactor: introduce Node, Effect, and Decision structs#161
QMalcolm merged 3 commits into
mainfrom
qmalcolm--refactor-node-effect-decision-structs

Conversation

@QMalcolm

@QMalcolm QMalcolm commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Closes #133.

What

Three commits, one struct each, replacing the shapeless maps whose schemas existed only as the union of their producers:

  1. RuleSystem.Node:type (:generated | :accumulator | :formula | :mapping) plus per-type fields, with a typespec documenting which fields each type uses. Loader constructs; Graph/Evaluator/Characters/Character match on the struct. Nodes are never persisted, so no serialization concerns. (Pending-choice entries — %{type: :pending} — are a different, caller-facing shape and deliberately remain maps.)

  2. RuleSystem.Effect — full shape declared, with item_fields a real field defaulting to %{} (previously bolted on by inventory_effects via Map.put and read defensively by the Evaluator with Map.get(effect, :item_fields, %{})) and source: nil for effects the character carries directly. The Evaluator now destructures %Effect{}; attaching item fields is a checked struct update.

  3. Characters.Decision — shape + new/3 + JSON encoding as to_json_map/from_json_map, moved verbatim from Character's private serialize_decision/deserialize_decision. All ~15 construction/matching sites across the library and CLI use the struct.

Format safety

Saved-character JSON is byte-identical to before. Beyond the existing round-trip tests (which now assert struct equality end-to-end), a new test pins the exact on-disk shape — decision scope "type:id" strings, effect target "type:id:field" strings — so a future struct change cannot silently alter saved files.

Notes

  • No bare-map node/effect/decision literals remain in library or CLI logic (verified by grep).
  • Test updates are struct-literal conversions plus two code-health-driven reorganizations: a graph_data/2 helper in graph_test.exs, and the xp_to_next_level/2 block moving to its own file (characters_test.exs crossed the 1000-line threshold).
  • All 383 tests pass; --warnings-as-errors (the type checker's struct-update check caught one capture-syntax issue during development), credo, and dialyzer clean.

Summary by Bito

  • Refactored character and rule system logic to use explicit structs (Decision, Effect) instead of generic maps.
  • Updated character progression and decision-making functions to utilize the new Decision struct.
  • Updated effect evaluation logic to use the Effect struct.
  • Added corresponding unit tests to verify the new struct-based implementations.

QMalcolm added 3 commits July 21, 2026 00:03
Part of #133.

DAG nodes were shapeless maps whose schema existed only as the union of
what Loader.parse_node produced and what Graph/Evaluator pattern-matched.
Adding a node type or field meant auditing every %{type: :...} match by
hand, with no compile-time help.

ExTTRPGDev.RuleSystem.Node now declares the shape: type (:generated |
:accumulator | :formula | :mapping) plus the per-type fields, with a
typespec documenting which fields each type uses. Loader constructs
%Node{} values; Graph, Evaluator, Characters (level threshold lookups),
and Character (generated-value rolling) match on the struct, so a typo'd
field is now a KeyError at the construction site rather than a silently
non-matching clause.

Node values are internal to a loaded system and never persisted, so no
serialization concerns apply. Pending-choice entries (%{type: :pending})
are a different, caller-facing shape and deliberately remain maps.
Part of #133.

Effects were shapeless maps whose schema existed only as the union of
their producers: the Loader emitted %{source, target, value, when},
character effects carried only %{target, value}, and inventory_effects
bolted :item_fields on via Map.put — a key the Evaluator then had to
read defensively with Map.get(effect, :item_fields, %{}) because no
other producer set it.

ExTTRPGDev.RuleSystem.Effect declares the full shape, with :item_fields
a real field defaulting to %{} and :source nil for effects the
character carries directly. The Evaluator destructures %Effect{} in
apply_effect instead of Map.get probing; inventory_effects uses map
update on a matched %Effect{} so attaching item fields is checked;
Loader/Graph/Characters/Character/Advancement construct and match on
the struct throughout.

Saved-character JSON is unchanged: effects persist as {"target",
"value"} and from_json! rebuilds %Effect{} with default source/when/
item_fields, which is exactly what runtime effect construction produces
— the existing round-trip test (character_test.exs) covers this.

graph_test's repeated loader-data boilerplate is folded into a
graph_data/2 helper while updating its literals (the two
undefined-target tests tripped the duplication check once touched).
Closes #133.

Decisions were %{scope, choice, selection} literals constructed and
matched at ~15 sites across the library and CLI, with the JSON
encoding knowledge living separately in Character's private
serialize_decision/deserialize_decision. Nothing tied the sites
together; a field rename would have been a grep hunt.

ExTTRPGDev.Characters.Decision declares the shape (with a typespec
documenting the scope forms), provides new/3, and owns the JSON
encoding as to_json_map/from_json_map — moved verbatim from Character,
so the saved-character format is unchanged. All construction and
matching sites in Characters, Character, Advancement, and the CLI
handlers/serializer now use the struct.

A new test pins the exact saved-character JSON shape for decisions and
effects (scope "type:id" strings, target "type:id:field" strings), so
a future struct change cannot silently alter the on-disk format; the
existing round-trip tests continue to cover load/save equality.

The struct-literal conversions pushed characters_test.exs over its
code-health size threshold, so the self-contained xp_to_next_level/2
describe block moves to test/characters/xp_to_next_level_test.exs,
continuing the split-out pattern used for apply_award and
resolve_progression_choice tests.
@QMalcolm
QMalcolm merged commit 3354139 into main Jul 21, 2026
5 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: introduce Node, Effect, and Decision structs

1 participant