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.tsexposesgetMaterials(/account/materials, lines 19–25) andgetWallet(/account/wallet, lines 68–70); the GW2 client (apps/api/src/gw2/gw2-client.ts) hadaccount,accountMaterials,accountWalletand no bank/inventory method. - Gated ids and flags (
GET /v2/items?ids=…&lang=en, live): Mystic Clover19675, Obsidian Shard19925, Gift of Battle19678, Gift of Exploration19677, Bloodstone Shard20797— all Trophy, allAccountBound; none is a currency, so none is in the wallet. - Material storage contents (
GET /v2/materials?ids=all&lang=en, live — 8 categories):19675and19925are in category37(Advanced Crafting Materials) → trackable via/account/materials;19677,19678,20797appear in no category → inventory/bank-only. - The detail page already sidesteps this:
apps/web/.../mergeOwnVsNeed.tstreats 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.
aggregateNeedimportsRecipeTreefromapps/web/src/api(Orval-generated); the package owns the canonicalPricedTree/PricedTreeNode(packages/recipe-graph/src/types.ts) from the same Zod schema the generated type derives from → retarget the import, structurally identical.mergeOwnVsNeedimportsMaterial/WalletEntry({id,count}/{id,value}) fromapps/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.netSellPricealready 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/satisfiedand each row a meaningfulcraftable.
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/19925in material-storage category 37;19677/19678/20797inventory/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
tokeninfoprobe; missing scope →403→Gw2ForbiddenError(<scope>). Worth recording alongside the account-layer docs.