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:27lists30698in 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) throwsNotFoundException('item … not found'). A non-integer or non-positive id is already rejected400byParseIntPipe+ theitemId <= 0guard (: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:21seeds the root atcount: 1and:47-52builds each child withcount: edge.count— the immediate ingredient count, never multiplied by the parent's count.pricing.ts:166-170(annotate) preserves thatt.countverbatim on thePricedTreeNode. lineCostis also per-line, not a rolled-up subtree total:pricing.ts:160(lineCost = unitCost * t.count). So a node'slineCostis its cost for one parent, not its absolute contribution to the root — it cannot be summed across the tree to get a personal total.outputCountmatters and is fractional:pricing.ts:66computes recipe cost asMath.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 are1. 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
recipeandRecipeOptionSchema.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—legendaryOutputIdshas 21 ids, all in the30684…30704Gen-1 range; a spot check of Gen-2 ids (76158,87109,79562) finds them in neitherlegendaryOutputIdsnor 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-41passes it through. - Caveat / detector correction:
pricing.ts:207force-sets the returned root'sdecisiontocraftregardless of whether it has a recipe, so "root is not craft" is not a valid signal. R12 detects the no-tree case byroot.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 onrecipe.ingredients). - Change required: R8 ("sum … cumulative required quantity") → an explicit outputCount-aware multiplier walk over items and
recipe.ingredientscurrencies, 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.countis per-edge;lineCostis per-line (not a subtree roll-up); the only rolled-up total is the rootsummary.totalCraftCost;outputCountis fractional (Mystic Clover 3.1) and continuous in the cost model; currency ingredients are not tree nodes. This belongs indocs/architecture/(near the recipe-graph / gw2-api notes) — any feature that reads the priced tree needs it.