ServicesTalent Trees

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-only

list 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.json

The 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:

PolicyEffect on the removed node's ranks and spend
refundRanks dropped, every point spent on it refunded to the wallet
grandfatherRanks and spend kept, even though the node left the definition
re_validateRefunds, 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 1

Deletion 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.

On this page