Skip to content

Research 021 — Legendary detail page ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. Every [NEEDS VERIFICATION] in spec.md has a verdict here — V1 confirmed, V2 confirmed-and-refuted the simple-sum premise (spec reopened to draft, R8/R9/P2 rewritten), V-gen confirmed (non-Gen-1 → root-only leaf, added during the sketch-reconciliation brainstorm).

Verified against the worktree at branch 021-legendary-detail (commit of the approved spec), codebase read on 2026-08-17. No live GW2 API call was made this session (the api was not running); the id-mapping and 404 claims are backed by the checked-in Bifrost integration test and the controller source, noted as a caveat where live confirmation is still owed.

V1 — Is a legendary list id an item id /recipe-graph/:itemId accepts, and does an unknown / non-legendary id yield 404? ​

Question. The detail page navigates to /legendaries/:id using the id from /api/legendaries, then calls /recipe-graph/:id. That only works if the legendary's id is the GW2 item id the recipe-graph resolves, and if a bad id degrades to a clean 404 (R2, R4).

Verdict. Confirmed. The legendary id is the GW2 item id; recipe-graph resolves it directly, and an unknown item id becomes a 404.

Evidence.

  • The Bifrost's id is a gen-1 legendary id in the source of truth: apps/api/src/legendaries/legendaries.data.ts:27 lists 30698 in the gen-1 array.
  • That same id drives recipe-graph end-to-end: apps/api/src/recipe-graph/recipe-graph.service.bifrost.test.ts:137 (const BIFROST = 30698) and :147 (await service.resolve(BIFROST)) — the test resolves and fully prices the tree from the legendary id with the real curated dataset. No id translation layer sits between the legendary list and recipe-graph; both key on the raw item id.
  • 404 path: apps/api/src/recipe-graph/recipe-graph.controller.ts:38-41 — a null-named root (the GW2 API didn't know the item during enrichment: 206/404) throws NotFoundException('item … not found'). A non-integer or non-positive id is already rejected 400 by ParseIntPipe + the itemId <= 0 guard (:31-35).

Caveat. Not exercised against the live GW2 API for an arbitrary non-legendary id this session (api not running). The 404 is guaranteed by controller code and the null-root invariant, not by a live observation — a dated manual check during implementation would close the last gap, but it is not a code risk.

V2 — Is PricedTreeNode.count per-edge or cumulative (so aggregateNeed can sum true totals)? ​

Question. R8/R9 assume the web side can compute each buyable leaf's total required quantity across the tree by walking nodes — the basis for "need 500 / have 250 / short 250" and the recalculated remaining cost. That needs the per-node count to be either cumulative, or per-edge in a way a simple walk can roll up.

Verdict. Confirmed per-edge — but with a complication that refutes the spec's "simple sum" framing.count is the per-edge ingredient quantity (relative to one unit of the immediate parent), not cumulative; and recipe outputCount — which is fractional in the real data — means the true aggregate quantity is a multiplier walk producing expected-value (non-integer) quantities, not a sum.

Evidence.

  • Per-edge, not cumulative: apps/api/src/recipe-graph/project-tree.ts:21 seeds the root at count: 1 and :47-52 builds each child with count: edge.count — the immediate ingredient count, never multiplied by the parent's count. pricing.ts:166-170 (annotate) preserves that t.count verbatim on the PricedTreeNode.
  • lineCost is also per-line, not a rolled-up subtree total: pricing.ts:160 (lineCost = unitCost * t.count). So a node's lineCost is its cost for one parent, not its absolute contribution to the root — it cannot be summed across the tree to get a personal total.
  • outputCount matters and is fractional: pricing.ts:66 computes recipe cost as Math.ceil(Σ childUnitCost × count / outputCount) — a continuous division by yield. The curated dataset has one fractional yield: packages/legendary-recipes/data/gen1-weapons.json — Mystic Clover (19675) outputCount: 3.1 (expected forge yield); the other 26 curated recipes are 1. Live station recipes (fetched from the GW2 API in production) can also yield > 1. So a leaf's true needed quantity = Σ over paths ( Π edge.count ÷ Π ancestor outputCount ) — fractional wherever a clover (or a multi-yield station recipe) sits on the path.
  • The data to do this walk is in the payload: each node carries its chosen recipe and RecipeOptionSchema.outputCount (apps/api/src/recipe-graph/recipe-graph.schema.ts:25-30). So the FE can compute it — it is just not a "sum," and the result is an expected-value quantity, not an integer shopping number.

Consequence for the spec. R8's "add its cumulative required quantity" and P2's implied integer precision (250/500) need to become an explicit expected-value multiplier walk, with a stated display rule (ceil for a shopping number). This is a design change to an already-approved spec, so it goes back to step 1 rather than being patched in place (see Refuted claims).

V-gen — How does /recipe-graph/:id resolve a non-Gen-1 legendary? ​

Question. The list links to all legendaries (Gen 2/3, armor, trinkets), but the curated Mystic Forge data is Gen-1 weapons only. R12 needs to know what a non-Gen-1 id resolves to, to show a sensible page instead of a broken tree.

Verdict. Confirmed — a root-only leaf. A non-Gen-1 legendary has no curated recipe and no station recipe, so it resolves to the root with empty children; the page detects this and shows header + buy price + a "Gen-1 only" note.

Evidence.

  • Curated coverage is Gen-1 only: packages/legendary-recipes/data/gen1-weapons.json — legendaryOutputIds has 21 ids, all in the 30684…30704 Gen-1 range; a spot check of Gen-2 ids (76158, 87109, 79562) finds them in neither legendaryOutputIds nor the recipe set.
  • resolve() unions the (0..1) curated forge recipe with (0..N) station recipes (recipe-graph.service.ts:118-143); a legendary is not station-crafted, so both are empty → the node is a leaf: recipe-graph.service.ts:100 (leaf: recipes.length === 0). The root therefore has no children.
  • The controller does not 404 it — the item name is non-null (it is a real item), so recipe-graph.controller.ts:38-41 passes it through.
  • Caveat / detector correction: pricing.ts:207 force-sets the returned root's decision to craft regardless of whether it has a recipe, so "root is not craft" is not a valid signal. R12 detects the no-tree case by root.children.length === 0.

F3 — Currency requirements are NOT tree nodes; they live on recipe.ingredients ​

Finding. projectTree builds children only from item edges — project-tree.ts:45 (.filter((edge) => edge.kind === 'item')). Currency edges (karma/Obsidian-shard-as-currency, spirit shards, etc.) are dropped from the tree structure and are treated as 0 gold by pricing (pricing.ts:61, continue on kind === 'currency'). They survive only inside each craft node's recipe.ingredients array (recipe-graph.schema.ts:12-23 keeps the currency edge shape).

Why it matters. P2 and R8/R9 promise own-vs-need against the wallet (currencies). Because currencies are not tree nodes, aggregateNeed cannot find them by walking children — it must read recipe.ingredients currency edges on each visited craft node. R8/R9 must say so, or wallet reconciliation silently produces nothing.

Refuted claims ​

  • Believed (spec R8/R9, P2): the web side computes precise, integer per-leaf totals by a simple aggregation of the tree's per-node counts, giving "need 500 / have 250 / short 250" and an exact remaining cost.
  • True (V2, F3): counts are per-edge; true totals require a multiplier walk that divides by each ancestor recipe's outputCount, which is fractional (Mystic Clover 3.1) — so aggregate need is an expected-value quantity, not an integer, and currencies are not tree nodes at all (they live on recipe.ingredients).
  • Change required: R8 ("sum … cumulative required quantity") → an explicit outputCount-aware multiplier walk over items and recipe.ingredients currencies, producing expected-value quantities with a stated display rule (e.g. ceil for the shopping number). R9's remaining cost is unaffected in form (Σ short × unitBuyPrice) but consumes those corrected quantities. This is the one design decision that reopens step 1; the frontend-vs-backend split itself is not refuted (the data needed is all in the existing keyless payload — no new endpoint is forced).

Graduation ​

Candidates to move to docs/architecture/ at step 6 (else the next spec re-discovers them):

  • Recipe-graph payload semantics: PricedTreeNode.count is per-edge; lineCost is per-line (not a subtree roll-up); the only rolled-up total is the root summary.totalCraftCost; outputCount is fractional (Mystic Clover 3.1) and continuous in the cost model; currency ingredients are not tree nodes. This belongs in docs/architecture/ (near the recipe-graph / gw2-api notes) — any feature that reads the priced tree needs it.