Authoring and Versioning
The admin side of talent: writing tree and curve documents, validating
drafts, and rebalancing live trees with versioned publishes and declarative
player migration. Every write here - create, validate, publish, delete -
requires a platform admin token; the get and list reads are open to any
authenticated caller. All outputs were captured live.
The tree document
One JSON document describes the whole graph - this exact file was created and exercised for every example in this section:
{
"tree_id": "docs4-tree",
"name": "Docs Pyromancer",
"point_currencies": ["docs4-points"],
"tiers": [{ "tier": 1 }, { "tier": 2, "gate": { "points_spent_in_tree": 3 } }],
"nodes": [
{ "id": "node_ignite", "name": "Ignite", "tier": 1, "max_rank": 3,
"cost_per_rank": [{ "currency": "docs4-points", "amount": 1 }],
"grants": { "stat": { "key": "fire_damage", "value_per_rank": 5.0 } } },
{ "id": "node_firewall", "name": "Firewall", "tier": 1,
"cost_per_rank": [{ "currency": "docs4-points", "amount": 1 }],
"prerequisites": { "all_of": [{ "node": "node_ignite", "min_rank": 2 }] } },
{ "id": "node_inferno", "name": "Inferno", "tier": 2, "exclusive_group": "spec",
"cost_per_rank": [{ "currency": "docs4-points", "amount": 2 }] },
{ "id": "node_frostfire", "name": "Frostfire", "tier": 2, "exclusive_group": "spec",
"cost_per_rank": [{ "currency": "docs4-points", "amount": 2 }] }
],
"exclusive_groups": [{ "id": "spec", "max_selected": 1 }],
"respec": { "full": { "allowed": true, "refund_ratio": 1.0 },
"partial": { "allowed": true, "refund_ratio": 1.0 } }
}Defaults worth knowing: max_rank is 1, prerequisite min_rank is 1,
max_selected per exclusive group is 1, and a missing respec policy means
respecs are refused with 409 respec_not_allowed - on a tree published without the block, both a full and a partial respec came
back full respec is not enabled for this tree and partial respec is not enabled for this tree. grants takes a stat, feature_flag, or
resource; prerequisites takes all_of or any_of.
snug talent definitions create --file ./tree.json
snug talent definitions get --tree-id docs4-tree
snug talent definitions list --active-onlylist returns every stored version, active and prior, and takes the shared
q/sort_by/sort_order/pagination parameters (see
Search queries) plus a talent-specific
active_only flag instead of a raw filter. q matches the tree name and
description, so ?q=Pyromancer returned both stored versions of the tree
above, while ?active_only=true returned one row per tree.
Validating drafts
validate dry-runs a document and reports every structural problem instead
of failing on the first; for a deliberately broken draft:
{
"valid": false,
"issues": [
"node node_a references undefined tier 3",
"node node_a costs undeclared currency docs4-gems",
"node node_a has prerequisite on missing node node_missing"
]
}The same checks run on create and publish, where they fail the request
with 400 invalid_tree and join the issues into one msg separated by
; rather than returning the array.
XP curves
Curves are separate documents shared across trees:
{
"curve_id": "docs4-curve",
"kind": "linear",
"params": { "per_level": 100 },
"max_level": 10,
"points_per_level": [{ "currency": "docs4-points", "amount": 2 }],
"bonus_levels": [{ "level": 3, "extra": [{ "currency": "docs4-points", "amount": 1 }] }]
}snug talent curves create --file ./curve.json and
snug talent curves get --curve-id docs4-curve. There is no curve delete
or update endpoint - treat curve ids as append-only.
Publishing a new version
Rebalancing a live tree - removing a node, renaming it, changing costs -
would normally break every existing build, so every publish carries a
migration plan. PUT /api/v1/talent/definitions/{tree_id} takes the full
new definition plus a required migration block; the CLI reads both from
one file:
snug talent definitions publish --tree-id docs4-tree --file ./tree-v2.jsonThe response is the stored definition, 201 Created:
{ "definition_id": "docs4-tree:2", "version": 2, "active": true }The new version defaults to migration.from_version + 1. The migration
block used in this walkthrough removes one node:
"migration": {
"from_version": 1,
"removed_nodes": { "node_inferno": { "policy": "refund" } },
"default": "grandfather"
}Prior versions stay stored and queryable for audit - definitions list
returned both (version 2, active: true) and (version 1, active: false).
Note that the analytics counters are keyed per version, so a publish also
restarts take-rate and archetype reporting from zero.
What migration does to player state
Migration is lazy and atomic: the first state read or allocation after a publish migrates that player under a per-user lock, then proceeds against the new version. Nobody pays the cost at publish time.
For a node that survives (same id, or listed in remapped_nodes with a
target that exists in the new version), ranks carry over capped to the new
max_rank. If the publish changes costs, the optional cost_changes
policy governs the spent-points ledger: refund_difference re-charges at
the new cost and refunds the delta; refund_excess refunds the delta and
keeps the lesser spend; anything else carries the original spend forward.
For a node that no longer exists, the per-node policy in removed_nodes
(falling back to default) decides:
| Policy | Effect on the removed node's ranks and spend |
|---|---|
refund | Ranks dropped, every point spent on it refunded to the wallet |
grandfather | Ranks and spend kept, even though the node left the definition |
re_validate | Refunds, identically to refund |
remap, refund_excess, and refund_difference also collapse to a plain
refund when used as a removed-node policy; they only differ under
cost_changes. Exclusive-group selections follow surviving or remapped
nodes; a selection pointing at a removed node is cleared.
Verified end to end: a player holding node_ignite: 2, node_firewall: 1,
node_inferno: 1 (5 points spent, 2 in the wallet) read their state after
the v2 publish above and came back already migrated:
{
"tree_version": 2,
"state": {
"ranks": { "node_ignite": 2, "node_firewall": 1 },
"points_spent": { "docs4-points": 3 },
"exclusive_selections": {}
}
}The two points spent on node_inferno were back in the wallet (2 became
4), and the grandfathered nodes kept their ranks. The migration is recorded
as a talent.migrated event on the user's timeline stream:
{ "from_version": 1, "to_version": 2,
"removed_nodes": ["node_inferno"], "refunded": { "docs4-points": 2 } }Deleting versions
snug talent definitions delete --tree-id docs4-tree --version 1Deletion is per-version and returns
{"tree_id": "...", "version": 1, "message": "Tree docs4-tree@v1 deleted"};
an unknown version is 404 tree_not_found. The server does not stop you
from deleting the active version (verified), and doing so strands the
tree: every player-facing call against it - state, allocate, respec -
then fails with 404 tree_not_found. Delete only superseded versions unless
you are retiring the tree entirely.
Endpoint details are in the Talent API reference.