Talent Trees
The TraitTreeFlat graph, its node and edge types, and how a base64 loadout string decodes into the per-node selections the engine simulates
A talent tree is a graph: nodes connected by edges, where each node grants one or more spells and may have several ranks. A loadout, the string you paste from the game or from Wowhead, is just a compressed list of which nodes you bought and how many ranks. The data layer's job is two halves: describe the tree once, and decode a loadout against it into a concrete set of selections the engine can turn into spell ids.
The tree: TraitTreeFlat
TraitTreeFlat is the flattened description of one spec's entire trait graph. Like every other flat type it deserializes identically from CSV-transform output or a Supabase row:
The fields split into three groups. spec_id, spec_name, class_name, and tree_id are identity. all_node_ids is the full node membership, and nodes plus edges are the graph itself, with sub_trees holding the hero-talent subtrees. point_limits is the spend caps, defaulting to class 31, spec 30, and hero 10.
A TraitNode carries its grid position, max_ranks, a node_type, and a Vec<TraitNodeEntry>. The entries are the important part. Each one holds the data the engine ultimately cares about:
A normal node has one entry; a choice node has several, and the loadout records which one you picked. Edges are minimal, just id, from_node_id, to_node_id, and visual_style, and exist mostly for the planner UI. The engine cares about purchased nodes, not the topology connecting them.
Decoding a loadout
A loadout string is a base64-encoded bitstream in the same format the game client and Wowhead use. Decoding it is a pure function over the bytes, with no game data required. decode_trait_loadout reads a version byte, a 16-bit spec id, a 16-byte tree hash, and then a per-node run of selection bits: selected, purchased, partially-ranked, a 6-bit rank count, and for choice nodes a 2-bit choice index. The result is a DecodedTraitLoadout, the header plus a Vec<DecodedTraitNode>, one entry per node in tree order.
That decoded list is positional. It says "node 0 has 2 ranks, node 1 is unselected", so it only becomes meaningful once paired with the tree that defines what each position is. That pairing happens in apply_decoded_traits, which walks the tree's nodes alongside the decoded selections and produces a TraitTreeWithSelections: the tree flattened together with a Vec<TraitSelection>. Each TraitSelection is { node_id, selected, ranks_purchased, choice_index }, the engine-facing form.
The resolver wires these two functions together. DataResolver::decode_traits has a default body that fetches the tree with get_trait_tree, decodes the string with decode_trait_loadout, and applies it with apply_decoded_traits. Because the body is default, every backend gets loadout decoding for free as long as it can return a TraitTreeFlat.
From selections to spell ids
The engine does not simulate selections directly; it simulates the spells those selections grant. get_trait_spell_ids bridges the gap:
It decodes the loadout, then for every selection with ranks_purchased > 0 it finds the matching node, reads the chosen entry (choice_index defaults to 0), and collects entry.spell_id. The result is sorted and deduplicated. Those ids are exactly what bootstrap feeds into the resolution pass as extra spells to fetch, so a talented spell ends up in the engine's game-data view the same way a baseline spell does, a thread I pick up in Codegen.
The honest limitation here is that the WASM resolver cannot decode loadouts at all: its get_trait_tree is a stub that returns TraitTreeNotFound, so the default decode_traits body fails before it begins. In the browser, talent decoding therefore happens JS-side before the engine is invoked, not across the WASM boundary. I cover that asymmetry in Data Resolution.
Nächste Schritte
