Plan 008 — Buy-vs-craft pricing
Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved. Transcribed to approved on the human's explicit instruction (2026-08-02).
Produced alone — tasks.md stays untouched until this plan is approved in turn.
Goal
Turn 007's price-free structural tree into a priced plan: GET /recipe-graph/:itemId returns the same tree with every node carrying a cost and a buy/craft/gated/unknown decision, plus a root summary giving total craft cost, the root's net-sell and profit. And make the flagship number true by curating the four Mystic- Forge recipes on The Bifrost's path that today bottom out as free gated leaves — so a Bifrost prices to real gold, not zero.
Approach
Three independent-ish pieces that compose:
A pure cost engine —
priceGraph(graph, priceMap)inapps/api, no I/O. It computes a per-distinct- id chosen cost over the DAG (memoised, mirroring 007's dedup): the buy option is the item'ssellsprice when it isbuyableand priced; the craft option is the cheapestRecipeOption'sceil(Σ child.unitCost × edge.count / outputCount);unitCost = minof the defined options;decisionis the winner (tie →buy), with gated-leaf →0/gatedand neither-defined →null/unknown. It then reuses 007'sprojectTree(graph, chooseRecipe)with a cheapest-recipe chooser to lay out the tree, annotates each node withunitBuyPrice / craftCost / unitCost / lineCost / decision(children never pruned — abuynode keeps its expansion), and attaches aPlanSummaryto the root node. The root is alwayscraft.The endpoint fold — the existing
RecipeGraphControllergains aGw2Servicedependency. Per request itresolves (007, unchanged), collects the distinctbuyableids ∪{rootId}, fetches them in one batchedGw2Service.pricescall, builds thepriceMap, and returnspriceGraph(...).resolvestays price-free; the400/404guards are unchanged. The response Zod schema is extended (TreeNodeSchema→PricedTreeNodeSchema+ rootsummary) so OpenAPI documents the enriched shape.Curation — add four
mystic-forgerecipes (Gift of Might 19672, Gift of Magic 19673, Mystic Clover 19675, Gift of The Bifrost 19654) to the@gw2priory/legendary-recipesdataset, with exact ingredient ids from research V1–V3. Mystic Clover is an expected-value recipe (fractionaloutputCount ≈ 3.1), which requires relaxingoutputCountfrom integer to positive number in two schemas. 007's resolver code is untouched — it already unions curated recipes, so once these four exist the Bifrost tree deepens on its own (the sub-gifts Gift of Energy/Color resolve through the station path — research V2).
Nothing new is cached (005 owns the 60 s price TTL); netSell reuses @gw2priory/domain's netSellPrice.
Architecture
GET /recipe-graph/:itemId
RecipeGraphController.get(itemId)
├─ RecipeGraphService.resolve(itemId) ──────────► ResolvedGraph (007, UNCHANGED, price-free)
├─ collectPricedIds(graph) → distinct buyable ids ∪ {rootId} (pure, in pricing.ts)
├─ Gw2Service.prices(ids) → Gw2Price[] → priceMap (005, one batched call)
└─ priceGraph(graph, priceMap) ─────────────────► PricedTree (pure, in pricing.ts)
{ root: PricedTreeNode, ...root.summary }
data feeding resolve():
CuratedRecipeService ──► @gw2priory/legendary-recipes/data (+4 forge recipes, fractional outputCount)
StationDataService ──► /v2/recipes (Gift of Energy 4315 / Gift of Color 3165 resolve here, no curation)
types: @gw2priory/recipe-graph (types-only; +PricedTreeNode, PlanSummary, PricedTree)Boundaries: the engine (pricing.ts) is pure and holds all cost logic; the controller does I/O and orchestration only; the types live in the shared types-only package; the data + schema changes are isolated to legendary-recipes and the two Zod schemas. Each can be built and tested behind its own seam.
Tech stack
TypeScript across apps/api (NestJS) and packages/*, Vitest, Zod + nestjs-zod (createZodDto / @ZodResponse), pnpm workspaces. No new dependency. Reuses existing surfaces only: Gw2Service.prices (005), projectTree (007), @gw2priory/recipe-graph types (007), netSellPrice from @gw2priory/domain (existing). GW2 item/recipe/price facts confirmed live in research.md (2026-08-01/02).
Global Constraints
Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.
From docs/architecture/typescript.md:
- No
any. Not in app code, not in tests. Useunknownplus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why. - No non-null assertions (
!) to silence the compiler. - No
@ts-expect-errorwithout a comment explaining what is expected and when it can be removed. - Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
- Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
- Match the style of surrounding code. No new dependency without justification in the spec or plan.
moduleResolution: "node"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.md's api build model.
From docs/architecture/stack.md:
- Monorepo, pnpm workspaces.
apps/api— NestJS (TypeScript).apps/web— React (TypeScript).packages/*— shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.- Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
- Vitest everywhere, both apps.
- Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
- Static data (items, station recipes) is immutable: cache hard.
- Prices are volatile: short TTL, recomputed live.
- GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec,
429on overflow. Batch up to 200 ids per?ids=call. - All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
- API keys are user secrets: encrypted at rest, never logged, never returned to the client.
From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.
File Structure
Exact paths, and what each file is responsible for. A path here is a commitment; a task that touches a file not listed is a signal the plan missed something.
| Path | Change | Responsibility |
|---|---|---|
packages/legendary-recipes/src/schema.ts | modified | Relax outputCount (line 23) from z.number().int().positive() → z.number().positive() so an expected-value recipe can carry a fractional yield. Ingredient count stays integer. |
packages/legendary-recipes/data/gen1-weapons.json | modified | Add four mystic-forge recipes (outputs 19672, 19673, 19675, 19654) with exact ingredient ids/counts; Mystic Clover carries outputCount ≈ 3.1. |
packages/legendary-recipes/src/index.test.ts | modified | Assert the four new recipes load with the right ingredients/method; assert a fractional outputCount validates and a 0/negative one is rejected. |
packages/recipe-graph/src/types.ts | modified | Add PricedTreeNode, PlanSummary, PricedTree (types-only). |
apps/api/src/recipe-graph/pricing.ts | new | Pure cost engine: priceGraph(graph, priceMap), collectPricedIds(graph), and the internal per-id cost/decision fold. No I/O. |
apps/api/src/recipe-graph/pricing.test.ts | new | Unit tests for the engine over hand-built graphs + price maps (P1 #1–8, SC1–SC7, SC11). |
apps/api/src/recipe-graph/recipe-graph.schema.ts | modified | Extend TreeNodeSchema → PricedTreeNodeSchema (cost fields, z.lazy recursion, : z.ZodType<PricedTreeNode> drift guard); root DTO adds summary; relax the response outputCount. |
apps/api/src/recipe-graph/recipe-graph.controller.ts | modified | Inject Gw2Service; per request resolve → collectPricedIds → one prices call → priceGraph; return the enriched tree + root summary. 400/404 guards unchanged. |
apps/api/src/recipe-graph/recipe-graph.controller.test.ts | modified | Mocked RecipeGraphService + Gw2Service: assert 200 enriched body with 007 fields intact, 400/404, and one batched prices call over buyable ids ∪ root (SC8, SC9). |
apps/api/src/recipe-graph/recipe-graph.module.ts | modified (if needed) | Ensure Gw2Service is injectable into the controller (import Gw2Module/StaticDataModule's Gw2 provider). |
apps/api/src/recipe-graph/recipe-graph.bifrost.test.ts | new | Integration: resolve(30698) against the real dataset + a fixed price map → priceGraph → the four recipes expand, earned tokens stay 0-leaves, totalCraftCost is non-zero and complete (P3 #1–4, SC10). |
apps/api/src/generate-openapi.ts + committed openapi.json | modified | Regenerate; document the enriched GET /recipe-graph/{itemId} response. |
apps/api/src/generate-openapi.test.ts | modified | Assert the enriched response schema is documented and the emit stays deterministic (SC12). |
apps/web/src/api/generated/** | regenerated | Orval-regenerated client for the enriched response (mechanical; no hand edits). |
Data & contracts
New types (packages/recipe-graph/src/types.ts):
export interface PricedTreeNode {
node: GraphNode; // 007, unchanged
count: number; // 007, unchanged
recipe: RecipeOption | null; // 007, the chosen (cheapest) recipe
children: PricedTreeNode[]; // 007 shape, never pruned
decision: 'buy' | 'craft' | 'gated' | 'unknown';
unitBuyPrice: number | null; // sells.unit_price when buyable+priced, else null
craftCost: number | null; // cheapest recipe's per-unit cost, else null
unitCost: number | null; // min(unitBuyPrice, craftCost); 0 for gated leaf; null if neither
lineCost: number | null; // unitCost × count, or null
}
export interface PlanSummary {
rootId: number;
totalCraftCost: number | null; // root roll-up via unitCost (buy-nodes' children don't inflate)
rootBuyPrice: number | null; // root sells.unit_price
netSell: number | null; // netSellPrice(root.buys.unit_price), else null
profit: number | null; // netSell − totalCraftCost, else null
}
export interface PricedTree { root: PricedTreeNode & { summary: PlanSummary }; }The HTTP body is the root PricedTreeNode with a summary field present only at the root (child nodes omit it), so existing consumers still read every 007 field. priceMap is Map<number, { buys: { quantity: number; unit_price: number }; sells: { quantity: number; unit_price: number } }> — the subset of Gw2Price the engine needs, decoupled from the client type.
Curated recipe contents (research V1–V3; exact ingredient ids pinned per-recipe in tasks.md via /v2/items): Gift of Might 19672 = 250× {Vicious Fang, Armored Scale, Vicious Claw, Ancient Bone}; Gift of Magic 19673 = 250× {Vial of Powerful Blood, Powerful Venom Sac, Elaborate Totem, Pile of Crystalline Dust}; Mystic Clover 19675 = 10× {Obsidian Shard 19925, Mystic Coin 19976, Glob of Ectoplasm 19721, Mystic Crystal}, outputCount ≈ 3.1; Gift of The Bifrost 19654 = 1× Gift of Energy 19623 + 1× Gift of Color 19638 + 100× Icy Runestone + 1× Superior Sigil of Nullification. All method: 'mystic-forge', each with a wiki source url.
Build order
Tasks are drawn so a reviewer can accept one without the next. tasks.md (Step 3) expands each into bite-sized TDD steps with code. Dependencies in parens.
- Fractional
outputCountschema relaxation — the two schema lines; tests prove a fractional value validates and a non-positive one still fails. (none) → unblocks curation. SC9. - Curate the four forge recipes — data + dataset guard test; ids pinned via
/v2/items. (1) Feeds SC10. - Priced types —
PricedTreeNode,PlanSummary,PricedTreein the types-only package. (none) - Pure
priceGraphengine —collectPricedIds+ the per-id cost/decision fold + tree annotation + summary; the bulk of the unit tests. (3) SC1–SC7, SC11, P1 #1–8. - Extend the response Zod schema + DTO —
PricedTreeNodeSchema+ rootsummary, relaxedoutputCount. (3) - Fold prices into the controller — inject
Gw2Service, one batchedprices, callpriceGraph; update the controller test (mocked resolve + prices). (4, 5) SC8, SC9, P2 #1–3. - OpenAPI + web client — regenerate
openapi.json, deterministic-emit guard, orval client. (5, 6) SC12. - Bifrost integration test — real
resolve(30698)+ fixed price map → non-zero completetotalCraftCost, earned tokens at0. (2, 4, 6) SC10, P3 #1–4.
SC13 (zero docs/superpowers/ writes) is covered by the existing repo-invariant test and needs no new task — all 008 artifacts live under specs/008-buy-vs-craft-pricing/.
Test strategy
- Engine (unit, pure) —
pricing.test.tsdrives hand-builtResolvedGraphs + plain price maps: buy-wins / craft-wins, multi-recipe min, gated leaf vs gated-craftable, unknown propagation, root economics, non-sellable root, purity over a frozen input, JSON round-trip. Golden values are computed by hand in the test — no network. - Controller (unit) — mocked
RecipeGraphService(fixture graph) + mockedGw2Service.prices(spy): assert enriched200,400/404, and thatpricesis called once with exactly the buyable ids ∪ root (SC8) andresolveonce. - Curation (guard) —
legendary-recipesreal-data test asserts the four recipes' presence, ingredients,method, and the fractional-outputCountvalidation. - Bifrost (integration) — real dataset
resolve(30698)+ a fixed price map (checked-in fixture prices, not live) →priceGraph→ assert structure (four recipes expand, sub-gifts via station path, earned tokens0-leaves) and a non-zero, completetotalCraftCost(SC10). Uses a fixture price map so the test is deterministic — the live profit number is an observed value, not a test assertion. - OpenAPI (guard) — the enriched response schema is documented; the committed
openapi.jsonre-emits byte-identically (SC12). - Not directly tested — the live market profit figure (data-dependent; observed at Step 5, not asserted); the buy-vs-craft
minchanging a decision on Gen 1 (research F1 — structurally impossible there; the min is exercised on synthetic fixtures instead).
Alternatives considered
- A separate
PlanService(vs folding into the controller) — rejected: the orchestration is three calls and one pure function; a service adds a DI layer without a second consumer. The purepriceGraphalready holds the logic worth isolating. (Revisit if a second caller — e.g. the profit-ranking spec — appears.) - A new
/recipe-graph/:itemId/planendpoint (vs extending) — rejected per the human's decision to extend the existing route (spec R9). - Pruning buy-decision subtrees — rejected per the human's "keep full structure, mark decision" (spec R4).
- Integer clover approximation (e.g.
outputCount: 3) — rejected in favour of the fractional expected value (~3.1) for a truthful flagship number (spec R11); the schema relaxation is one line. - Single-input clover recipe (1 each + 6 Philosopher's Stone,
outputCount 0.31) — the 10-input batch is used instead; both give the same per-clover buyable cost, and the batch's yield rate is the wiki's headline.
Risks
- Modifying 007's endpoint tests. The controller test and any OpenAPI snapshot must move to the enriched shape. Mitigation: 007's resolver/service tests use mocked recipes and are untouched (research V6); only the controller + OpenAPI tests change, and Task 6/7 own those edits explicitly.
- Wrong ingredient id from a name collision (F2). Mitigation: every curated ingredient id is pinned via
/v2/itemsintasks.mdand cross-checked against research V1–V3; the Bifrost integration test (Task 8) fails loudly if a mis-id'd node fails to expand or prices to 0. - Fractional
outputCountripple. Relaxing the type could let a bad value through elsewhere. Mitigation: onlyoutputCountis relaxed (ingredientcountstays integer); Task 1's tests pin both the accept and reject cases. - Clover expected-value drift. The ~31% rate is community drop-research and ignores forge returns. Mitigation: documented as a conservative approximation (spec Assumptions); the rate lives in one data value, trivially re-tunable.
Open questions
None blocking. research.md resolved every [NEEDS VERIFICATION] (V1–V6) and both findings (F1, F2). The only deferred-to-implementation detail is mechanical: pinning each curated ingredient's exact itemId via /v2/items during Task 2 (Mystic Crystal, Icy Runestone, Superior Sigil of Nullification, and the eight T6 mats), cross- checked against research — reality answers this, and Task 8's integration test is the backstop.