Skip to content

Research 023 — Legendary profit ranking (personalized) ​

Status: complete

Step 1.5 output, written between the spec draft and the approval gate. Every [NEEDS VERIFICATION] marker in spec.md has a verdict below. V1 refuted the first draft's sourcing and sent the spec back to step 1; the human chose to expand sourcing (bank + shared inventory + character inventories), and the spec was revised accordingly — V4–V6 verify the endpoints that revision depends on.

Everything below was verified against the repository on branch 023-profit, and against the live GW2 API v2 (api.guildwars2.com) and the official wiki API docs (wiki.guildwars2.com) on 2026-08-18.

V1 — Which account endpoint exposes owned counts for each gated input? (spec R4, R5, P2, SC5) ​

Question. P2 wants each gated input annotated with have/satisfied. The first draft restricted owned sourcing to material storage + wallet. For that to support P2, the five gated inputs (Gift of Exploration, Gift of Battle, Bloodstone Shard, Obsidian Shard, Mystic Clover — domain.md:7) had to be readable from /v2/account/materials or /v2/account/wallet.

Verdict. Refuted (first draft) → resolved by revision. Only 2 of the 5 are in material storage; the other 3 are inventory/bank-only. The draft's R4 could not support P2, so the spec was revised to sum owned counts across material storage + bank + shared inventory + character inventories.

Evidence.

  • Only materials + wallet were fetched. apps/api/src/account/account.service.ts exposes getMaterials (/account/materials, lines 19–25) and getWallet (/account/wallet, lines 68–70); the GW2 client (apps/api/src/gw2/gw2-client.ts) had account, accountMaterials, accountWallet and no bank/inventory method.
  • Gated ids and flags (GET /v2/items?ids=…&lang=en, live): Mystic Clover 19675, Obsidian Shard 19925, Gift of Battle 19678, Gift of Exploration 19677, Bloodstone Shard 20797 — all Trophy, all AccountBound; none is a currency, so none is in the wallet.
  • Material storage contents (GET /v2/materials?ids=all&lang=en, live — 8 categories): 19675 and 19925 are in category 37 (Advanced Crafting Materials) → trackable via /account/materials; 19677, 19678, 20797 appear in no category → inventory/bank-only.
  • The detail page already sidesteps this: apps/web/.../mergeOwnVsNeed.ts treats gated items as need-only and never looks up owned quantities for them.

Consequence / decision. Under materials+wallet every Gen-1 legendary needs the two Gifts + a Bloodstone Shard, so craftable was always false and the annotation misleading. Human decision (2026-08-18): expand sourcing. Spec revised: R4 sums owned across four sources; R8 adds the four client reads; R9 requires the inventories+characters scopes.

V2 — How large is the buyable-id union across the 21 Gen-1 trees? (spec R7, SC4) ​

Question. Can the whole ranking price in one batched path, not one call per legendary?

Verdict. Confirmed for the claim the spec now gates on — a single prices() call over the union. The exact chunk count is no longer a spec criterion (SC4/R7 were softened to the single-batch path), so no unbacked number remains.

Evidence.

  • The engine already dedupes buyable ids per graph (apps/api/src/recipe-graph/pricing.ts:20–27, collectPricedIds), and a single legendary's full tree already prices inside one ≤199 chunk today.
  • Structural bound across the 21: Gift of Mastery is entirely gated (contributes 0 buyable ids); Gift of Fortune contributes a small shared buyable set; the variation is 21 precursors + heavily overlapping weapon-specific station mats. Order-of-magnitude ≈ 80–120 distinct buyable ids → one chunk. SC4 is asserted with a call-count spy (one prices() call), independent of the exact figure.

Caveat. The ≈80–120 figure is a structural estimate, not a measurement; because the spec no longer gates on it, an exact union count is left as an implementation-time observation, not an approval gate.

V3 — Can aggregateNeed/mergeOwnVsNeed be lifted into @gw2priory/recipe-graph? (spec R10) ​

Question. R10 lifts the two web functions into the shared package as one implementation.

Verdict. Confirmed feasible, with a bounded type-reconciliation step and a small generalisation of mergeOwnVsNeed.

Evidence.

  • aggregateNeed imports RecipeTree from apps/web/src/api (Orval-generated); the package owns the canonical PricedTree/PricedTreeNode (packages/recipe-graph/src/types.ts) from the same Zod schema the generated type derives from → retarget the import, structurally identical.
  • mergeOwnVsNeed imports Material/WalletEntry ({id,count}/{id,value}) from apps/web/src/api. Generalise it to take a unified owned-items map (id → count) so each caller supplies its own source; the web detail page builds that map from materials exactly as today (behaviour-preserving).
  • The package is currently types-only (packages/recipe-graph/src/index.ts:1), so adding logic modules is greenfield. netSellPrice already lives in @gw2priory/domain (packages/domain/src/index.ts:7, Math.floor(gross * 0.85)).

V4 — /v2/account/bank shape + scopes (spec R4, R8; sourcing decision) ​

Question. Can the bank be read for owned-item counts, and under what scope?

Verdict. Confirmed. Array of item slots, null for empty; scopes account + inventories.

Evidence. GW2 wiki API doc (API:2/account/bank): "an array of objects, each representing an item slot in the vault… null" for empty; each item has id + count (plus optional charges/skin/binding/…). Required scopes: account, inventories. inventories is already in play — accountMaterials documents Gw2ForbiddenError('inventories') (gw2-client.ts:197).

V5 — /v2/account/inventory (shared inventory) shape + scopes (spec R4, R8) ​

Verdict. Confirmed. Array of shared-inventory slots, null for empty; each { id, count, … }; scopes account + inventories.

Evidence. GW2 wiki API doc (API:2/account/inventory): "an array of objects, each representing an item slot in the shared inventory… null" for empty; fields id, count, optional binding ("Account"). Scopes account, inventories.

V6 — Character enumeration + /v2/characters/:id/inventory shape + scopes (spec R4, R8) ​

Question. How to enumerate characters and read each one's inventory for owned-item counts.

Verdict. Confirmed. GET /v2/characters returns the account's character names (account + characters); GET /v2/characters/:id/inventory returns { bags: [{ id, size, inventory: [slot|null] } | null] } with slots { id, count, … } (characters + inventories). characters is a new scope this feature introduces.

Evidence. GW2 wiki API doc (API:2/characters): no-id form returns "an array of characters by name" (scopes account, characters); the /inventory sub-endpoint returns a bags array, each bag { id, size, inventory }, each slot an item object or null when empty.

Caveat. Owned-item counting fans out to one /characters/:id/inventory call per character. Bounded and cached per-user (ACCOUNT_TTL); for a typical account (≤~20 characters) well inside 300/min.

F1 — Gen-1 profit maths already exists end-to-end (touches Assumptions, R3) ​

A confirmation that de-risks the build. priceGraph already returns a per-root PlanSummary { totalCraftCost, netSell, profit } with the 15% tax applied (apps/api/src/recipe-graph/pricing.ts:170–195). The ranking reuses this per root; marketCost/ myCost come from aggregateNeed + a generalised mergeOwnVsNeed (V3), not new maths.

F2 — No scope pre-check exists; missing scope is reactive (touches R9, SC6) ​

There is no tokeninfo/permissions probe in apps/api. A key missing a scope surfaces only when a scoped call returns 403, parsed into Gw2ForbiddenError(<scope>) (gw2-client.ts:71, mapping at 187/380). So R9's "name the missing scope" is implemented by letting the first scoped read throw and mapping it — no upfront tokeninfo call is required (though one is a possible optimisation).

Refuted claims ​

Believed (first-draft R4 + R5 + P2): owned gated-input counts can be sourced from material storage

  • wallet, so every gated input gets have/satisfied and each row a meaningful craftable.

True (V1): only Mystic Clover and Obsidian Shard are in material storage; Gift of Exploration, Gift of Battle and Bloodstone Shard are account-bound inventory/bank items reachable by neither endpoint.

Changed in the spec: revised to expand sourcing — R4 sums owned across material storage + bank + shared inventory + all character inventories; R8 adds four client reads; R9 requires inventories+characters scopes with a 403+reconnect-prompt on absence; the revision note at the top of spec.md records the decision. Owned counts now feed both myCost and gated have.

Graduation ​

Findings that outlive this feature, for step 6 → docs/architecture/:

  • Gated-input storage map (V1): ids 19675/19925 in material-storage category 37; 19677/19678/20797 inventory/bank-only, account-bound — durable for any owned-vs-need work.
  • Owned-item sourcing across four locations (V4–V6) and the fact that a full owned-count sums material storage + bank + shared inventory + per-character inventories — relevant to docs/architecture/gw2-api.md.
  • Scope handling is reactive (F2): no tokeninfo probe; missing scope → 403 → Gw2ForbiddenError(<scope>). Worth recording alongside the account-layer docs.