Skip to content

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-AppCheck header is ignored.
  • No provisional badges in mock mode. All allocations.nodes[].provisional and allocations.keystones[].provisional values are false — 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=120 is nice-to-have, not mandatory. Mock mode may return Cache-Control: no-store and skip ETag entirely.
  • No Redis topology cache. Topology JSON is rebuilt from @walkrpg/data on each request in mock mode (data set is small enough that this is fine). The tree: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

FieldValue
MethodGET
Path/tree/state
AuthInternal session JWT (Bearer)
IdempotentYes (read-only)
Rate limit30 req/min/walker

Request

Headers

HeaderRequiredNotes
AuthorizationyesBearer <internal-session-JWT>
X-Firebase-AppCheckyesStandard hardening
If-None-MatchoptionalETag of prior payload; 304 if unchanged

Query parameters

ParamRequiredNotes
regionIdoptionalScope response to a single region (e.g. region.plenny). Default: all regions accessible to the walker per totalLifetimeSteps gating.
includeTopologyoptional booltrue (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:
    • TreeAllocation insert/update for this walker
    • KeystoneAllocation insert/update for this walker
    • QuestProgress completion (changes unlockableKeystones)
    • Walker.totalLifetimeSteps crosses 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:

  1. First mount: fetch ?includeTopology=true, store topology in app-local DB keyed by buildStamp.
  2. Subsequent allocation actions: fetch ?includeTopology=false for minimal payload (~200-500 bytes).
  3. On buildStamp mismatch (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.