Spec 023 — Legendary profit ranking (personalized)
Status: implemented Branch: 023-profit
Status is set by the human, never by the agent. It moves draft → approved → implemented.
Revision note (post-discovery). Discovery (
research.mdV1) verified that three of the five gated inputs — Gift of Exploration (19677), Gift of Battle (19678), Bloodstone Shard (20797) — are account-bound items held in inventory/bank, reachable by neither the materials nor wallet endpoint the app fetches today; only Mystic Clover (19675) and Obsidian Shard (19925) sit in material storage. The first draft sourced owned quantities from materials + wallet only, which made the P2craftableflag always-false and the annotation misleading. Human decision: expand sourcing — this revision counts owned items across material storage + bank + shared inventory + every character's inventory, so all five gated inputs get honesthave/craftable. That adds theinventoriesandcharactersscopes, four new GW2 client methods, and a per-account fetch fan-out.
Problem
The app can already answer "what does one legendary cost me to craft, and would selling it profit?" — the legendary detail page (021), which merges a public priced tree with the player's owned materials and wallet. What it cannot answer is persona #2's question, the seller's question: "across all Gen-1 legendaries, which are the most profitable for me to craft-and-sell right now, given what I already own — and which can I even complete?" Answering it means running the priced engine over all 21 Gen-1 weapons at once and personalising each against account state — a batch the browser cannot assemble cheaply and, per 021's own note, the job this spec exists to do server-side.
HTTP endpoints (contract-first)
This spec adds one backend endpoint and four new GW2-client reads behind it. Endpoints and their OpenAPI shape are first-class spec content.
GET /legendaries/ranking (authenticated)
Returns the 21 Gen-1 legendaries ranked by the player's personal profit. Personalisation is intrinsic, so the endpoint is authenticated-only — it mirrors the account endpoints rather than growing a two-mode keyed/keyless contract (the shape 021 deliberately avoided).
Auth.
Authorization: Bearer <key>, extracted by the samerequireBearerrule the account controller uses (^Bearer (.+)$, trimmed; missing or blank →400; the token is never logged).Required scopes on the key:
account,inventories,characters(wallet is not required — the ranking annotates items, not currencies). A key missing any required scope surfaces GW2's403asGw2ForbiddenError(<scope>)(parsed pergw2-client.ts:71), which the endpoint maps to a403naming the missing scope; the web page prompts to reconnect a key with the needed scopes. There is no partial/degraded ranking on missing scopes (see Out of scope).Response.
200with a Zod-validatedRankingList— an array ofRankingRow, sorted bypersonalProfitdescending,nullprofit last. The Zod schema is the source of truth that drives@ZodResponse, the OpenAPI document, and the Orval-generated web client (thelegendaries.schema.tspattern).RankingRowshape:RankingRow = { id: number, // legendary item id name: string, icon: string | null, subtype: string | null, netSell: number | null, // TP sell price − 15% tax marketCost: number | null, // Σ ceil(need) × unitBuyPrice over buyable leaves (ignores holdings) myCost: number | null, // Σ max(0, ceil(need) − owned) × unitBuyPrice (out-of-pocket) personalProfit: number | null, // netSell − myCost; the sort key gatedInputs: Array<{ id: number, name: string, needed: number, have: number, satisfied: boolean }>, craftable: boolean // every gatedInput satisfied }
Upstream reads assembled behind the endpoint
Static item/recipe/Forge data is served from the warm cache — tree expansion costs no live request. The live/authenticated reads, all cached per-user like materials/wallet today:
GET /v2/commerce/prices(keyless) — one logical batch over the union of buyable ids across all 21 trees, chunked ≤199 ids by the existing client.GET /v2/account/materials(account+inventories) — item slots{ id, count }(existing method).GET /v2/account/bank(account+inventories) — array of slots{ id, count, … } | null. New.GET /v2/account/inventory(account+inventories) — shared-inventory slots{ id, count, … } | null. New.GET /v2/characters(account+characters) — the account's character names. New.GET /v2/characters/:id/inventory(characters+inventories), one per character —{ bags: [{ id, size, inventory: [{ id, count, … } | null] } | null] }. New.
Owned count of an item id = Σ count over every matching slot across material storage + bank + shared inventory + all character inventories. This one owned-items map feeds both the ① owned-mats subtraction (myCost) and the ② gated-input have.
User stories
Ordered by priority. Each story is independently testable and shippable.
P1 — Rank Gen-1 legendaries by my profit
As a seller, I want the 21 Gen-1 legendaries ranked by the gold I would net crafting-and-selling each — after subtracting materials I already own — so that I can see where my crafting gold is best spent right now.
Independent test: With a stubbed price map and a stubbed owned-items map, the ranking service returns all 21 rows ordered by personalProfit descending, each row's myCost reduced from its marketCost by the value of owned buyable leaves; the web page renders that order.
Acceptance scenarios
- Given a connected account (all scopes) and live prices, when I open
/legendaries/ranking, then I see all 21 Gen-1 legendaries as rows ordered bypersonalProfit, highest first. - Given a legendary whose buyable leaves I partly own (across any of material storage, bank, shared inventory, or a character), when the row is computed, then
myCostequalsmarketCostminus the market value of the owned portion (owned summed once across all locations,short = max(0, ceil(need) − owned)), andpersonalProfit = netSell − myCost. - Given I own none of a legendary's mats, when the row is computed, then
myCostequalsmarketCostfor that legendary. - Given two legendaries with equal
personalProfit, when they are ordered, then the tie breaks deterministically (byidascending) so the ranking is stable across identical snapshots. - Given the union of buyable ids across all 21 trees, when the endpoint prices them, then it issues one logical prices batch (≤199 ids per HTTP chunk), not one call per legendary.
- Given no
Authorizationheader, when I call the endpoint, then it responds400and logs nothing containing the token; and the web page shows a connect-account prompt.
P2 — Tell me which I can actually complete (gated-input annotation)
As a seller, I want each row to show the account-bound inputs it needs and whether I currently have them — counted wherever they sit (bank, shared inventory, a character's bags, material storage) — so that a high-profit legendary I cannot finish (missing a Gift of Battle) is visibly distinguished from one I can.
Independent test: With a stubbed owned-items map spanning all sources, each row carries a gatedInputs list with correct needed/have/satisfied, and craftable is true iff every gated input is satisfied; the web page renders a per-input have/missing badge and a craftable indicator.
Acceptance scenarios
- Given a legendary needing a Gift of Battle (
19678) I hold only in a character's inventory, when its row is computed, then that gated input'shavereflects the character-inventory stack,satisfiedistrue, and it is not falsely reported missing. - Given a legendary needing a Gift of Exploration (
19677) I do not own anywhere, when its row is computed, then that gated input hassatisfied: falseand the row'scraftableisfalse. - Given a legendary all of whose gated inputs I hold in sufficient quantity, when its row is computed, then every gated input is
satisfied: trueandcraftableistrue. - Given the ranking, when it is ordered, then
craftabledoes not change the sort — ordering is bypersonalProfitalone; craftability is annotation only. - Given a key lacking the
characters(orinventories) scope, when I call the endpoint, then it responds403naming the missing scope, and the web page prompts to reconnect a key with the required scopes rather than showing a misleading partial ranking.
Requirements
- R1 — A new authenticated
GET /legendaries/rankingreturns a Zod-validatedRankingList(array ofRankingRow) for exactly the 21 Gen-1 weapons (ids30684–30704). No other generation appears. - R2 — Auth reuses the account controller's
requireBearerrule; a missing or malformed header yields400and the token is never logged. - R3 —
marketCost = Σ ceil(need) × unitBuyPriceover the buyable-leaf frontier;myCost = Σ max(0, ceil(need) − owned) × unitBuyPrice;netSellapplies the 15% tax vianetSellPrice;personalProfit = netSell − myCost. Owned quantity for an id is counted once. - R4 — Owned quantity of an item id is the sum of
countacross material storage + bank + shared inventory + all character inventories. Character inventories are enumerated viaGET /v2/charactersthen oneGET /v2/characters/:id/inventoryper character. Empty slots (null) are skipped. This single owned-items map feeds bothmyCost(①) and gatedhave(②). Wallet is not consulted (the ranking annotates items, not currencies). - R5 — Each row lists its account-bound inputs as
gatedInputs, each withneeded,have(from R4's owned map),satisfied(have ≥ needed);craftableis the conjunction of allsatisfied. Gated inputs cost 0 gold and never entermarketCost/myCost(player-supplied, per the engine's existing rule). All five gated inputs are sourceable under R4 — verified inresearch.mdV1/V4–V6. - R6 — Rows are sorted by
personalProfitdescending; ties break byidascending; rows withnullpersonalProfitsort last.craftablenever affects the order. - R7 — Pricing issues a single batched prices path over the union of buyable ids across all 21 trees — one
prices()call from the ranking service (internally chunked to ≤199 ids/HTTP by the existing client), not one call per legendary. Tree expansion uses the warm static cache and costs no live request. (Discovery estimates the union at ≈80–120 distinct ids — comfortably one chunk — but the spec gates on the single-batch path, not an exact chunk count; seeresearch.mdV2.) - R8 — The GW2 client gains four reads —
accountBank,accountSharedInventory,characters(names),characterInventory(id)— each cached per-user with the same key-hashing/TTL discipline asaccountMaterials/accountWallet(no plaintext key stored), and each mapping GW2401/403to the existingGw2Unauthorized/Gw2ForbiddenError(<scope>)errors. - R9 — The ranking requires scopes
account,inventories,characters. A key missing any required scope produces a403from the endpoint that names the missing scope; there is no partial/degraded ranking. The web page renders a reconnect-with-scopes prompt for that case. - R10 —
aggregateNeedandmergeOwnVsNeedare lifted fromapps/webinto the shared@gw2priory/recipe-graphpackage as one implementation used by both the api ranking endpoint and the web detail page.mergeOwnVsNeedis generalised to accept a unified owned-items map (id → summed count) rather than a raw materials array, so each caller supplies its own owned source. The web detail page is migrated with no behavioural change — it keeps supplying owned from materials (+ wallet for currencies) exactly as today, and its existing tests stay green. The lift is feasible with a bounded type reconciliation to the package'sPricedTreeNodeand a shared owned-map type — confirmed inresearch.mdV3. - R11 — A web route
/legendaries/rankingrenders the ranking table; a "Browse ⇄ Profit ranking" tab header spans it and the existing/legendariescatalog. The static/legendaries/rankingroute resolves ahead of/legendaries/:id. - R12 — The ranking page delegates loading and error states to the shell's Suspense + QueryBoundary (no local loading/error branch); with no stored API key it renders the connect-account prompt, and on a missing-scope
403it renders the reconnect-with-scopes prompt — neither an empty table. - R13 — The table shows, per row: identity (icon, name, subtype), the
marketCostandmyCoststacked vertically (labelled Market / You, right-aligned so the amounts line up), an emphasisedpersonalProfit, and a per-gated-input have/missing badge. Thecraftableboolean is still computed and carried inRankingRow(P2/SC5) but is not surfaced as a standalone indicator — the per-input badges already convey it, and a "Not craftable" summary carried no player value (dropped on human review of the running app). All monetary values render through the shared coin component; colours and spacing use design-system tokens, never literals. - R14 — Every acceptance scenario and success criterion maps to a named test in the traceability table; typecheck and lint are clean; no
any, no unexplained escape hatch.
Success criteria
Measurable and technology-agnostic.
- SC1 — All 21 Gen-1 legendaries appear in the ranking, ordered by
personalProfitdescending with a deterministic tie-break; no non-Gen-1 item appears. - SC2 — For any row,
myCost ≤ marketCost, andmyCost = marketCostexactly when the player owns none of that legendary's buyable leaves in any of the four sources. - SC3 —
personalProfit = netSell − myCostfor every row where both are non-null, with the 15% tax applied tonetSell. - SC4 — Computing the full ranking makes one
prices()call from the ranking service over the union of buyable ids (chunked by the client), not one per legendary — assertable with a call spy. - SC5 — Each of the five gated inputs (
19675,19925,19677,19678,20797) resolves a correcthavefrom an owned map that spans all four sources,satisfied = have ≥ needed, andcraftableis true iff all are satisfied — including the case where an input is held only in a character's inventory. - SC6 — A request with no/blank Authorization header returns
400; a key missing a required scope returns403naming the scope; no log line contains the key. - SC7 — With no stored API key the web page shows the connect prompt (no ranking request); on a missing-scope
403it shows the reconnect-with-scopes prompt; with a fully-scoped key it renders the ranked table. - SC8 — The web detail page (021) behaves identically after
aggregateNeed/mergeOwnVsNeedmove to the shared package — its existing test suite passes unchanged. - SC9 —
pnpm typecheck,pnpm lint,pnpm test, andpnpm buildare all green; no new files underdocs/superpowers/.
Out of scope
- Cross-legendary allocation — which combination of legendaries to craft given finite shared holdings. Each row is an independent "if I made this one" what-if; rows are not additive.
- Profit-per-gated-input as a metric or sort. Sort is
personalProfitonly; gated inputs are annotation. Profit-per-gate is a named future spec. - A "craftable-only" filter toggle. The table always shows all 21;
craftableis a visible flag. - Graceful degradation on partial scopes. A key missing
inventories/charactersgets a403+ reconnect prompt; the ranking does not compute a partial/"unknown-have" result from whatever scopes are present (that would reintroduce the misleading annotation discovery flagged). - Widening the detail page's owned sourcing. 021 keeps materials(+wallet)-only owned counting; the four-source owned map is used by the ranking. Aligning the detail page is a possible later change.
- Gen-2/Gen-3 and non-weapon legendaries; a keyless market-only ranking.
Assumptions
- The engine's existing per-legendary profit (
priceGraph→PlanSummary) and the 15% tax (netSellPrice) are correct and reused as-is; this spec ranks and personalises, it does not restate the profit maths. - Gated/account-bound inputs are valued at 0 gold (player-supplied), consistent with the engine and the detail page.
- Prices are a single live snapshot per request via the existing short-TTL pricing path.
- Per request, owned-item reads fan out to material storage + bank + shared inventory + one call per character; all are cached per-user (existing
ACCOUNT_TTL), so repeat ranking loads are cheap. For a typical account (≤~20 characters) this stays well inside the 300/min budget. - Each independent row may assume it can use all of the player's current holdings — the honest consequence of the "independent what-if" decision, not a claim that two rows can both be realised.
Traceability
Each acceptance scenario and success criterion maps to a named test. Filled in during implementation.
| Criterion | Test |
|---|---|
| P1 #1 | apps/api/src/legendaries/ranking.service.test.ts — "SC1/P1 #1/#4: orders by personalProfit desc, id asc, nulls last"; apps/web/src/features/legendaries/__tests__/RankingTable.test.tsx — "renders rows in the given order" |
| P1 #2 | apps/api/src/account/account.service.owned.test.ts — "sums an id across materials + bank, skips nulls, counts a character-only id"; packages/recipe-graph/src/__tests__/own-vs-need.test.ts — "ceils need, counts owned once, computes short (own 250 / need 500 -> 250)"; apps/api/src/legendaries/ranking.service.test.ts — "myCost=remainingCost, personalProfit=netSell-myCost, gated have from owned map" |
| P1 #3 | apps/api/src/legendaries/ranking.service.test.ts — "SC2: every row has myCost ≤ marketCost (empty owned → equal)" |
| P1 #4 | apps/api/src/legendaries/ranking.service.test.ts — "sorts by personalProfit desc, id asc, nulls last"; "both rows null personalProfit → orders by id ascending"; "SC1/P1 #1/#4: orders by personalProfit desc, id asc, nulls last" |
| P1 #5 | apps/api/src/legendaries/ranking.service.test.ts — "SC4: prices the whole union in exactly ONE batch call" |
| P1 #6 | apps/api/src/legendaries/legendaries.controller.test.ts — "R2: missing Authorization header -> 400, service not called"; apps/web/src/features/legendaries/__tests__/RankingPage.test.tsx — "R12/SC6: no key renders ConnectAccountPrompt and issues no ranking request" |
| P2 #1 | apps/api/src/account/account.service.owned.test.ts — "sums an id across materials + bank, skips nulls, counts a character-only id"; apps/api/src/legendaries/ranking.service.test.ts — "a gated item fully owned is satisfied, and craftable is true when all gated inputs are satisfied" |
| P2 #2 | apps/api/src/legendaries/ranking.service.test.ts — "myCost=remainingCost, personalProfit=netSell-myCost, gated have from owned map" |
| P2 #3 | apps/api/src/legendaries/ranking.service.test.ts — "a gated item fully owned is satisfied, and craftable is true when all gated inputs are satisfied" |
| P2 #4 | apps/api/src/legendaries/ranking.service.test.ts — "a legendary with an unpriced non-gated buyable leaf reports null profit and sorts LAST, despite a high netSell" |
| P2 #5 | apps/api/src/legendaries/legendaries.controller.test.ts — "R2: Gw2ForbiddenError('characters') from the service -> 403 naming the scope"; apps/web/src/features/legendaries/__tests__/RankingPage.test.tsx — "R12/SC7: a MissingScopeError renders the reconnect-scopes prompt"; apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — "R12/SC7: a backend 403 throws MissingScopeError, not a ZodError from a failed parse" |
| SC1 | apps/api/src/legendaries/ranking.service.test.ts — "SC1/P1 #1/#4: orders by personalProfit desc, id asc, nulls last"; "carries catalog identity (name/icon/subtype) from LegendariesService.list" (asserts list({ generation: 1 })); apps/api/src/legendaries/legendaries.data.test.ts — "is the 21 Gen-1 weapon ids" |
| SC2 | apps/api/src/legendaries/ranking.service.test.ts — "SC2: every row has myCost ≤ marketCost (empty owned → equal)" |
| SC3 | apps/api/src/legendaries/ranking.service.test.ts — "SC3 invariant: marketCost equals the engine roll-up summary.totalCraftCost"; "myCost=remainingCost, personalProfit=netSell-myCost, gated have from owned map" |
| SC4 | apps/api/src/legendaries/ranking.service.test.ts — "SC4: prices the whole union in exactly ONE batch call" |
| SC5 | apps/api/src/account/account.service.owned.test.ts — "sums an id across materials + bank, skips nulls, counts a character-only id"; apps/api/src/legendaries/ranking.service.test.ts — "fractional qty on a gated item ceils needed, and satisfied compares against the ceiled value"; "a gated item fully owned is satisfied, and craftable is true when all gated inputs are satisfied" |
| SC6 | apps/api/src/legendaries/legendaries.controller.test.ts — "R2: missing Authorization header -> 400, service not called"; "R2: blank bearer token -> 400"; "R2: Gw2ForbiddenError('characters') from the service -> 403 naming the scope" |
| SC7 | apps/web/src/features/legendaries/__tests__/RankingPage.test.tsx — "R12/SC6: no key renders ConnectAccountPrompt and issues no ranking request"; "R12/SC7: a MissingScopeError renders the reconnect-scopes prompt"; apps/web/src/features/legendaries/__tests__/RankingTable.test.tsx — "renders rows in the given order"; apps/web/src/api/__tests__/useLegendaryRanking.test.tsx — "R12/SC7: a backend 403 throws MissingScopeError, not a ZodError from a failed parse" |
| SC8 | packages/recipe-graph/src/__tests__/aggregate-need.test.ts + packages/recipe-graph/src/__tests__/own-vs-need.test.ts (lifted, unchanged assertions); apps/web/src/features/legendaries/__tests__/LegendaryDetailView.test.tsx, LegendarySummary.test.tsx, ShoppingList.test.tsx, OwnVsNeedProvider.test.tsx — retained from 021, unmodified by this spec |
| SC9 | pnpm typecheck && pnpm lint && pnpm test && pnpm build green; tests/workflow/repo-invariants.test.ts — "SC3: no workflow artifact is written outside specs/NNN-<slug>/" (asserts zero files under docs/superpowers/) |