Skip to content

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.md V1) 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 P2 craftable flag 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 honest have/craftable. That adds the inventories and characters scopes, 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 same requireBearer rule 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's 403 as Gw2ForbiddenError(<scope>) (parsed per gw2-client.ts:71), which the endpoint maps to a 403 naming 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. 200 with a Zod-validated RankingList — an array of RankingRow, sorted by personalProfit descending, null profit last. The Zod schema is the source of truth that drives @ZodResponse, the OpenAPI document, and the Orval-generated web client (the legendaries.schema.ts pattern).

  • RankingRow shape:

    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

  1. 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 by personalProfit, highest first.
  2. 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 myCost equals marketCost minus the market value of the owned portion (owned summed once across all locations, short = max(0, ceil(need) − owned)), and personalProfit = netSell − myCost.
  3. Given I own none of a legendary's mats, when the row is computed, then myCost equals marketCost for that legendary.
  4. Given two legendaries with equal personalProfit, when they are ordered, then the tie breaks deterministically (by id ascending) so the ranking is stable across identical snapshots.
  5. 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.
  6. Given no Authorization header, when I call the endpoint, then it responds 400 and 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

  1. 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's have reflects the character-inventory stack, satisfied is true, and it is not falsely reported missing.
  2. Given a legendary needing a Gift of Exploration (19677) I do not own anywhere, when its row is computed, then that gated input has satisfied: false and the row's craftable is false.
  3. Given a legendary all of whose gated inputs I hold in sufficient quantity, when its row is computed, then every gated input is satisfied: true and craftable is true.
  4. Given the ranking, when it is ordered, then craftable does not change the sort — ordering is by personalProfit alone; craftability is annotation only.
  5. Given a key lacking the characters (or inventories) scope, when I call the endpoint, then it responds 403 naming 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/ranking returns a Zod-validated RankingList (array of RankingRow) for exactly the 21 Gen-1 weapons (ids 30684–30704). No other generation appears.
  • R2 — Auth reuses the account controller's requireBearer rule; a missing or malformed header yields 400 and the token is never logged.
  • R3 — marketCost = Σ ceil(need) × unitBuyPrice over the buyable-leaf frontier; myCost = Σ max(0, ceil(need) − owned) × unitBuyPrice; netSell applies the 15% tax via netSellPrice; personalProfit = netSell − myCost. Owned quantity for an id is counted once.
  • R4 — Owned quantity of an item id is the sum of count across material storage + bank + shared inventory + all character inventories. Character inventories are enumerated via GET /v2/characters then one GET /v2/characters/:id/inventory per character. Empty slots (null) are skipped. This single owned-items map feeds both myCost (①) and gated have (②). Wallet is not consulted (the ranking annotates items, not currencies).
  • R5 — Each row lists its account-bound inputs as gatedInputs, each with needed, have (from R4's owned map), satisfied (have ≥ needed); craftable is the conjunction of all satisfied. Gated inputs cost 0 gold and never enter marketCost/myCost (player-supplied, per the engine's existing rule). All five gated inputs are sourceable under R4 — verified in research.md V1/V4–V6.
  • R6 — Rows are sorted by personalProfit descending; ties break by id ascending; rows with null personalProfit sort last. craftable never 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; see research.md V2.)
  • R8 — The GW2 client gains four reads — accountBank, accountSharedInventory, characters (names), characterInventory(id) — each cached per-user with the same key-hashing/TTL discipline as accountMaterials/accountWallet (no plaintext key stored), and each mapping GW2 401/403 to the existing Gw2Unauthorized/Gw2ForbiddenError(<scope>) errors.
  • R9 — The ranking requires scopes account, inventories, characters. A key missing any required scope produces a 403 from 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 — aggregateNeed and mergeOwnVsNeed are lifted from apps/web into the shared @gw2priory/recipe-graph package as one implementation used by both the api ranking endpoint and the web detail page. mergeOwnVsNeed is 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's PricedTreeNode and a shared owned-map type — confirmed in research.md V3.
  • R11 — A web route /legendaries/ranking renders the ranking table; a "Browse ⇄ Profit ranking" tab header spans it and the existing /legendaries catalog. The static /legendaries/ranking route 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 403 it renders the reconnect-with-scopes prompt — neither an empty table.
  • R13 — The table shows, per row: identity (icon, name, subtype), the marketCost and myCoststacked vertically (labelled Market / You, right-aligned so the amounts line up), an emphasised personalProfit, and a per-gated-input have/missing badge. The craftable boolean is still computed and carried in RankingRow (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 personalProfit descending with a deterministic tie-break; no non-Gen-1 item appears.
  • SC2 — For any row, myCost ≤ marketCost, and myCost = marketCost exactly when the player owns none of that legendary's buyable leaves in any of the four sources.
  • SC3 — personalProfit = netSell − myCost for every row where both are non-null, with the 15% tax applied to netSell.
  • 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 correct have from an owned map that spans all four sources, satisfied = have ≥ needed, and craftable is 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 returns 403 naming 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 403 it 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/mergeOwnVsNeed move to the shared package — its existing test suite passes unchanged.
  • SC9 — pnpm typecheck, pnpm lint, pnpm test, and pnpm build are all green; no new files under docs/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 personalProfit only; gated inputs are annotation. Profit-per-gate is a named future spec.
  • A "craftable-only" filter toggle. The table always shows all 21; craftable is a visible flag.
  • Graceful degradation on partial scopes. A key missing inventories/characters gets a 403 + 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.

CriterionTest
P1 #1apps/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 #2apps/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 #3apps/api/src/legendaries/ranking.service.test.ts — "SC2: every row has myCost ≤ marketCost (empty owned → equal)"
P1 #4apps/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 #5apps/api/src/legendaries/ranking.service.test.ts — "SC4: prices the whole union in exactly ONE batch call"
P1 #6apps/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 #1apps/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 #2apps/api/src/legendaries/ranking.service.test.ts — "myCost=remainingCost, personalProfit=netSell-myCost, gated have from owned map"
P2 #3apps/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 #4apps/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 #5apps/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"
SC1apps/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"
SC2apps/api/src/legendaries/ranking.service.test.ts — "SC2: every row has myCost ≤ marketCost (empty owned → equal)"
SC3apps/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"
SC4apps/api/src/legendaries/ranking.service.test.ts — "SC4: prices the whole union in exactly ONE batch call"
SC5apps/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"
SC6apps/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"
SC7apps/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"
SC8packages/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
SC9pnpm 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/)