Skip to content

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:

  1. A pure cost engine — priceGraph(graph, priceMap) in apps/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's sells price when it is buyable and priced; the craft option is the cheapest RecipeOption's ceil(Σ child.unitCost × edge.count / outputCount); unitCost = min of the defined options; decision is the winner (tie → buy), with gated-leaf → 0/gated and neither-defined → null/unknown. It then reuses 007's projectTree(graph, chooseRecipe) with a cheapest-recipe chooser to lay out the tree, annotates each node with unitBuyPrice / craftCost / unitCost / lineCost / decision (children never pruned — a buy node keeps its expansion), and attaches a PlanSummary to the root node. The root is always craft.

  2. The endpoint fold — the existing RecipeGraphController gains a Gw2Service dependency. Per request it resolves (007, unchanged), collects the distinct buyable ids ∪ {rootId}, fetches them in one batched Gw2Service.prices call, builds the priceMap, and returns priceGraph(...). resolve stays price-free; the 400/404 guards are unchanged. The response Zod schema is extended (TreeNodeSchema → PricedTreeNodeSchema + root summary) so OpenAPI documents the enriched shape.

  3. Curation — add four mystic-forge recipes (Gift of Might 19672, Gift of Magic 19673, Mystic Clover 19675, Gift of The Bifrost 19654) to the @gw2priory/legendary-recipes dataset, with exact ingredient ids from research V1–V3. Mystic Clover is an expected-value recipe (fractional outputCount ≈ 3.1), which requires relaxing outputCount from 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. Use unknown plus 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-error without 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" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.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, 429 on 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.

PathChangeResponsibility
packages/legendary-recipes/src/schema.tsmodifiedRelax 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.jsonmodifiedAdd 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.tsmodifiedAssert 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.tsmodifiedAdd PricedTreeNode, PlanSummary, PricedTree (types-only).
apps/api/src/recipe-graph/pricing.tsnewPure 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.tsnewUnit tests for the engine over hand-built graphs + price maps (P1 #1–8, SC1–SC7, SC11).
apps/api/src/recipe-graph/recipe-graph.schema.tsmodifiedExtend 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.tsmodifiedInject 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.tsmodifiedMocked 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.tsmodified (if needed)Ensure Gw2Service is injectable into the controller (import Gw2Module/StaticDataModule's Gw2 provider).
apps/api/src/recipe-graph/recipe-graph.bifrost.test.tsnewIntegration: 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.jsonmodifiedRegenerate; document the enriched GET /recipe-graph/{itemId} response.
apps/api/src/generate-openapi.test.tsmodifiedAssert the enriched response schema is documented and the emit stays deterministic (SC12).
apps/web/src/api/generated/**regeneratedOrval-regenerated client for the enriched response (mechanical; no hand edits).

Data & contracts ​

New types (packages/recipe-graph/src/types.ts):

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.

  1. Fractional outputCount schema relaxation — the two schema lines; tests prove a fractional value validates and a non-positive one still fails. (none) → unblocks curation. SC9.
  2. Curate the four forge recipes — data + dataset guard test; ids pinned via /v2/items. (1) Feeds SC10.
  3. Priced types — PricedTreeNode, PlanSummary, PricedTree in the types-only package. (none)
  4. Pure priceGraph engine — collectPricedIds + the per-id cost/decision fold + tree annotation + summary; the bulk of the unit tests. (3) SC1–SC7, SC11, P1 #1–8.
  5. Extend the response Zod schema + DTO — PricedTreeNodeSchema + root summary, relaxed outputCount. (3)
  6. Fold prices into the controller — inject Gw2Service, one batched prices, call priceGraph; update the controller test (mocked resolve + prices). (4, 5) SC8, SC9, P2 #1–3.
  7. OpenAPI + web client — regenerate openapi.json, deterministic-emit guard, orval client. (5, 6) SC12.
  8. Bifrost integration test — real resolve(30698) + fixed price map → non-zero complete totalCraftCost, earned tokens at 0. (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.ts drives hand-built ResolvedGraphs + 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) + mocked Gw2Service.prices (spy): assert enriched 200, 400/404, and that prices is called once with exactly the buyable ids ∪ root (SC8) and resolve once.
  • Curation (guard) — legendary-recipes real-data test asserts the four recipes' presence, ingredients, method, and the fractional-outputCount validation.
  • 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 tokens 0-leaves) and a non-zero, complete totalCraftCost (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.json re-emits byte-identically (SC12).
  • Not directly tested — the live market profit figure (data-dependent; observed at Step 5, not asserted); the buy-vs-craft min changing 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 pure priceGraph already holds the logic worth isolating. (Revisit if a second caller — e.g. the profit-ranking spec — appears.)
  • A new /recipe-graph/:itemId/plan endpoint (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/items in tasks.md and 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 outputCount ripple. Relaxing the type could let a bad value through elsewhere. Mitigation: only outputCount is relaxed (ingredient count stays 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.