API: GET /tree/state
GET /tree/state
Returns the walker’s current passive-tree state plus computed available unlocks. The mobile tree viewer (phase 9 wireframes; phase 11 implementation) reads this on tree-screen mount and after each allocation action.
Status: draft contract — implementation in phase 8b.
Related: ADR-0001 (component topology), data/src/schemas/node.ts, data/src/schemas/keystone.ts, data/src/schemas/cluster.ts.
Test-phase mock mode
Phase 8b ships against the test-phase infrastructure per ADR-0006, not the production target. Differences from the contract below:
- No App Check token requirement.
X-Firebase-AppCheckheader is ignored. - No provisional badges in mock mode. All
allocations.nodes[].provisionalandallocations.keystones[].provisionalvalues arefalse— allocations are confirmed immediately at the point of allocation. The provisional/reconciled split re-engages at the production-migration phase. - No caching headers required. ETag +
Cache-Control: private, max-age=30, stale-while-revalidate=120is nice-to-have, not mandatory. Mock mode may returnCache-Control: no-storeand skip ETag entirely. - No Redis topology cache. Topology JSON is rebuilt from
@walkrpg/dataon each request in mock mode (data set is small enough that this is fine). Thetree:topology:<regionId>:<buildStamp>Memorystore key documented below is a production-target shape, not a phase 8b requirement.
The production contract below describes the target state, unfrozen when the production migration is greenlit.
Endpoint
| Field | Value |
|---|---|
| Method | GET |
| Path | /tree/state |
| Auth | Internal session JWT (Bearer) |
| Idempotent | Yes (read-only) |
| Rate limit | 30 req/min/walker |
Request
Headers
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Bearer <internal-session-JWT> |
X-Firebase-AppCheck | yes | Standard hardening |
If-None-Match | optional | ETag of prior payload; 304 if unchanged |
Query parameters
| Param | Required | Notes |
|---|---|---|
regionId | optional | Scope response to a single region (e.g. region.plenny). Default: all regions accessible to the walker per totalLifetimeSteps gating. |
includeTopology | optional bool | true (default) includes cluster + node + edge data so client can render without separate fetch. false returns only allocations + unlocks. |
Response
200 OK
{ "walker": { "id": "01HM4...", "availablePoints": 7, "totalLifetimeSteps": 472_300 }, "allocations": { "nodes": [ { "nodeId": "node.plenny-step-counter-1", "clusterId": "cluster.plenny-starting-circle", "allocatedAt": "2026-05-12T14:22:00Z", "provisional": false }, { "nodeId": "node.plenny-stride-1", "clusterId": "cluster.plenny-starting-circle", "allocatedAt": "2026-05-18T10:18:00Z", "provisional": true } ], "keystones": [ { "keystoneId": "keystone.unshaken-step", "clusterId": "cluster.plenny-starting-circle", "allocatedAt": "2026-05-15T19:00:00Z", "provisional": false, "questUnlockSource": "quest.001-first-road" } ] }, "available": { "unlockableNodes": [ { "nodeId": "node.plenny-stride-2", "blockedBy": [] }, { "nodeId": "node.plenny-vigour-1", "blockedBy": ["node.plenny-stride-1.NOT_FINAL"] } ], "unlockableKeystones": [] // TODO: example to be added when a second Plenny keystone is authored. }, "topology": { "regions": [ { "id": "region.plenny", "name": { "en": "Plenny", "pl": "Plennia" }, "gatingSteps": 0, "accessible": true, "clusters": ["cluster.plenny-starting-circle"] } ], "clusters": [ { "id": "cluster.plenny-starting-circle", "regionId": "region.plenny", "name": { "en": "Plenny Starting Circle", "pl": "Krąg Plenny" }, "position": { "x": 0, "y": 0 }, "theme": "endurance" } ], "nodes": [ { "id": "node.plenny-step-counter-1", "clusterId": "cluster.plenny-starting-circle", "type": "small", "name": { "en": "Step Counter I", "pl": "Krokomierz I" }, "position": { "x": 0, "y": 0 }, "cost": 1, "requires": [], "modifiers": [{ "stat": "energy_per_step", "op": "add", "value": 0.05 }] } ], "keystones": [ { "id": "keystone.unshaken-step", "clusterId": "cluster.plenny-starting-circle", "name": { "en": "Unshaken Step", "pl": "Krok Niezachwiany" }, "visibility": "public", "exclusiveWith": [], "unlockQuestId": "quest.001-first-road", "modifiers": [{ "stat": "streak_decay_resistance", "op": "add", "value": 1 }] } ] }, "etag": "W/\"tree-01HM4-v17\""}304 Not Modified
When If-None-Match matches.
401 Unauthorized
Session JWT invalid.
403 Forbidden
{ "error": "REGION_NOT_ACCESSIBLE", "message": "Walker has not reached the step threshold to view this region.", "details": { "regionId": "region.frostlands", "gatingSteps": 100000, "walkerSteps": 47230 }}Returned when ?regionId= is set to a region the walker hasn’t unlocked.
Cache strategy
Cache-Control: private, max-age=30, stale-while-revalidate=120.
- The topology block (regions/clusters/nodes/keystones) is immutable within a server build — sourced from
data/src/content/. Servers ship a build-stamp included in the ETag. - The allocations + available blocks are walker-mutable. ETag bumps when any of:
TreeAllocationinsert/update for this walkerKeystoneAllocationinsert/update for this walkerQuestProgresscompletion (changesunlockableKeystones)Walker.totalLifetimeStepscrosses a region gating threshold
The 30s max-age is tuned to avoid flicker during walker-driven actions (mobile invalidates client cache on local allocation event); the 120s stale-while-revalidate keeps the tree viewer responsive on flaky networks.
Server-side cache
Topology JSON for each region is cached in Memorystore Redis with key tree:topology:<regionId>:<buildStamp> and 1-hour TTL. Allocations are NOT cached server-side (per-walker hot path).
Topology vs allocations split
Mobile may opt out of topology with ?includeTopology=false after the first fetch. Topology rarely changes (content build redeploy); allocations change often. This split is the cache-friendly contract.
Mobile is encouraged to:
- First mount: fetch
?includeTopology=true, store topology in app-local DB keyed bybuildStamp. - Subsequent allocation actions: fetch
?includeTopology=falsefor minimal payload (~200-500 bytes). - On
buildStampmismatch (detected via response field): drop local cache and refetch full topology.
Allocation actions
This endpoint is read-only. Mutating actions go to:
POST /tree/allocate(phase 8b — node)POST /tree/keystone/allocate(phase 11 — keystone with conflict check)POST /tree/respec(phase 11+ — respec policy TBD)
Computed available block — semantics
unlockableNodes: nodes the walker has enough points + prerequisites to allocate right now.
unlockableKeystones: keystones whose unlockQuestId is currently being progressed by the walker. blockedBy lists the reason a node/keystone is shown but not yet allocatable (e.g. quest incomplete, prerequisite node not yet final, region locked).
Telemetry
info-level log: walkerId, regionId?, etag, cacheHit, topologyIncluded, latencyMs. No PII.
Open follow-ups
- WebSocket subscription
tree.state.updated: phase 11 — push allocations as they’re reconciled. - Server-rendered SVG snapshot for the regional map: phase 11 — for share/screenshot UX.
- Respec cost + policy: out-of-scope here; mechanics-designer authors phase 11.