Skip to content

Tasks 008 — Buy-vs-craft pricing ​

For agentic workers: REQUIRED SUB-SKILL — superpowers:subagent-driven-development (one implementer per task, then a two-stage review: spec compliance, then code quality). superpowers:test-driven-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on any surprise rather than guessing. Steps use - [ ] for tracking.

Derived from plan.md (approved, 2026-08-02). Each task is small, independently verifiable, and reviewed as its own diff. A task is done only when it satisfies the Definition of Done in CLAUDE.md: typecheck clean, tests pass, the named test traces to its criterion, no any / no unexplained escape hatches, the human has reviewed the diff.

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 (total craft cost, net-sell, profit) — and curate The Bifrost's four Mystic-Forge intermediates so its number is real gold, not zero.

Architecture: three composable pieces — a pure priceGraph(graph, priceMap) cost engine in apps/api (no I/O); a controller fold that injects Gw2Service, does one batched prices call, and calls the engine; and four curated forge recipes in @gw2priory/legendary-recipes (with a one-line fractional-outputCount schema relaxation). 007's resolver code is untouched.

Global Constraints live in plan.md (copied verbatim from the architecture docs) and apply to every task below — they are not repeated per task. The load-bearing ones for 008: no any, no non-null ! to silence the compiler, validate everything crossing a boundary at runtime, prefer pure functions, no new dependency, match surrounding style, no file written under docs/superpowers/.

Build order follows plan.md §Build order. Dependencies in parens. Tasks are drawn so a reviewer can accept one without the next.


T1 — Relax outputCount to a positive number (both schemas) ​

Satisfies: R11, SC9 (schema half). (no dependency) → unblocks T2's fractional clover.

Files:

  • Modify: packages/legendary-recipes/src/schema.ts (the CuratedRecipeSchema.outputCount line)
  • Modify: apps/api/src/recipe-graph/recipe-graph.schema.ts (the RecipeOptionSchema.outputCount line)
  • Test: packages/legendary-recipes/src/index.test.ts (curated schema), and a new apps/api/src/recipe-graph/recipe-graph.schema.test.ts (API response schema — created here, extended in T5)

Interfaces produced: both schemas now accept a positive non-integer outputCount; ingredient count stays a positive integer.

  • [ ] RED (curated schema): add to packages/legendary-recipes/src/index.test.ts:
ts
  it('SC9: a curated recipe with a fractional outputCount (expected-value yield) validates', () => {
    expect(() =>
      DatasetSchema.parse({
        legendaryOutputIds: [],
        recipes: [{ ...validRecipe, outputCount: 3.1 }],
      }),
    ).not.toThrow();
  });

  it('SC9: a curated recipe with outputCount 0 is still rejected', () => {
    expect(() =>
      DatasetSchema.parse({
        legendaryOutputIds: [],
        recipes: [{ ...validRecipe, outputCount: 0 }],
      }),
    ).toThrow();
  });

  it('SC9: a curated ingredient count stays integer — 2.5 is rejected', () => {
    expect(() =>
      DatasetSchema.parse({
        legendaryOutputIds: [],
        recipes: [
          { ...validRecipe, ingredients: [{ kind: 'item', itemId: 2, count: 2.5 }] },
        ],
      }),
    ).toThrow();
  });
  • [ ] Run it, watch the first test fail: pnpm --filter @gw2priory/legendary-recipes test Expected: SC9: … fractional outputCount validates FAILS (outputCount is currently .int()), the other two PASS. If the fractional test unexpectedly passes, the schema was already relaxed — stop and check.

  • [ ] GREEN: in packages/legendary-recipes/src/schema.ts, change the one line inside CuratedRecipeSchema:

ts
  outputCount: z.number().positive(), // was z.number().int().positive() — R11: fractional expected-value yield
  • [ ] Run: pnpm --filter @gw2priory/legendary-recipes test → all three new tests PASS, existing green.

  • [ ] RED (API response schema): create apps/api/src/recipe-graph/recipe-graph.schema.test.ts:

ts
import { describe, expect, it } from 'vitest';
import { RecipeTreeDto } from './recipe-graph.schema';

describe('recipe-graph response schema', () => {
  const node = {
    node: {
      id: 1, name: 'x', rarity: null, vendorValue: null,
      classification: 'buyable', leaf: false, recipes: [],
    },
    count: 1,
    recipe: {
      source: 'mystic-forge',
      outputCount: 3.1, // fractional — R11 / SC9
      ingredients: [{ kind: 'item', itemId: 2, count: 10 }],
      provenance: 'https://wiki.guildwars2.com/wiki/Mystic_Clover',
    },
    children: [],
  };

  it('SC9: the response schema accepts a fractional recipe outputCount', () => {
    expect(() => RecipeTreeDto.schema.parse(node)).not.toThrow();
  });
});
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/api test recipe-graph.schema Expected: FAIL — RecipeOptionSchema.outputCount is .int().

  • [ ] GREEN: in apps/api/src/recipe-graph/recipe-graph.schema.ts, change the one line in RecipeOptionSchema:

ts
  outputCount: z.number().positive(), // was z.number().int().positive() — R11: fractional expected-value yield
  • [ ] Run: pnpm --filter @gw2priory/api test recipe-graph.schema → PASS. Typecheck the touched packages.

  • [ ] Confirm the reject tests have teeth: temporarily revert one schema to .int().positive(), watch the fractional test fail, restore. (The integer-only path must be what the new test guards.)

  • [ ] Commit: git commit -am "specs: 008 T1 — relax outputCount to positive number (R11)"

Verified by: packages/legendary-recipes/src/index.test.ts (SC9 accept/reject trio) and apps/api/src/recipe-graph/recipe-graph.schema.test.ts ("SC9: the response schema accepts a fractional recipe outputCount"), both green.


T2 — Curate the four Bifrost forge recipes ​

Satisfies: R12, R13, P3 #1, P3 #3 (data half), SC9 (data half), SC10 (feeds T8). (T1)

Files:

  • Modify: packages/legendary-recipes/data/gen1-weapons.json (add four recipes)
  • Test: packages/legendary-recipes/src/index.test.ts (curated-dataset guards)

Interfaces produced: the loaded dataset now contains curated mystic-forge recipes for outputs 19672, 19673, 19675 (fractional outputCount), 19654. 007's resolver unions these automatically — no resolver code changes.

  • [ ] Pin every ingredient id live first (the plan's one deferred detail — Open Questions). Query /v2/items?ids=… and cross-check names against research V1–V3 before writing the data. This is a discovery step (CLAUDE.md exception 2): run the query, record the ids, discard the throwaway query. Confirm each of these — the two marked ✅ are already pinned in research; the rest are candidates that MUST be confirmed live (name → id):

    | Ingredient | Candidate id | Status |
    | --- | --- | --- |
    | Vicious Fang | 24356 | verify live |
    | Armored Scale | 24289 | verify live |
    | Vicious Claw | 24351 | verify live |
    | Ancient Bone | 24358 | ✅ research V1 |
    | Vial of Powerful Blood | 24295 | ✅ research V1 |
    | Powerful Venom Sac | 24283 | verify live |
    | Elaborate Totem | 24300 | verify live |
    | Pile of Crystalline Dust | 24277 | ✅ (used in existing tests) |
    | Obsidian Shard | 19925 | ✅ research V3 (gated → 0) |
    | Mystic Coin | 19976 | ✅ research V3 (buyable) |
    | Glob of Ectoplasm | 19721 | ✅ research V3 (buyable) |
    | Mystic Crystal | 43772 | verify live (vendor → 0) |
    | Gift of Energy | 19623 | ✅ research V2 (station path, **not** curated) |
    | Gift of Color | 19638 | ✅ research V2 (station path, **not** curated) |
    | Icy Runestone | 22331 | verify live (karma vendor → 0) |
    | Superior Sigil of Nullification | 24658 | verify live (buyable) |
    
    Any candidate whose live name does not match → use the live id, and note the correction in the task's
    Notes. **Do not** curate Gift of Energy (19623) or Gift of Color (19638) — they are `/v2/recipes`
    discipline recipes 007's station path already resolves (research V2).
    
  • [ ] RED: add to packages/legendary-recipes/src/index.test.ts a guard over the four new recipes (use the ids confirmed above; constants shown with the research-confirmed values):

ts
  it('P3 #1: the dataset curates exactly the four Bifrost forge intermediates (Might/Magic/Clover/Bifrost-gift)', () => {
    const dataset = loadDataset();
    const byOutput = new Map(dataset.recipes.map((r) => [r.outputItemId, r]));

    for (const id of [19672, 19673, 19675, 19654]) {
      const r = byOutput.get(id);
      expect(r, `recipe for ${id}`).toBeDefined();
      expect(r?.method).toBe('mystic-forge');
      expect(r?.source).toMatch(/^https:\/\/wiki\.guildwars2\.com\//);
    }
    // The sub-gifts are discipline recipes 007 resolves via the station path — NOT curated here.
    expect(byOutput.has(19623)).toBe(false); // Gift of Energy
    expect(byOutput.has(19638)).toBe(false); // Gift of Color
  });

  it('P3 #1: Gift of Might (19672) = 250× each of the four T6 fine materials, all buyable-mat ids', () => {
    const r = loadDataset().recipes.find((x) => x.outputItemId === 19672);
    expect(r).toBeDefined();
    const items = r?.ingredients.filter((i) => i.kind === 'item') ?? [];
    expect(items).toHaveLength(4);
    expect(items.every((i) => i.count === 250)).toBe(true);
    // Ancient Bone + Vial of Powerful Blood are the two research-pinned ids; the rest verified live in step 1.
    expect(items.map((i) => (i.kind === 'item' ? i.itemId : 0))).toContain(24358); // Ancient Bone
  });

  it('P3 #3 / SC9: Mystic Clover (19675) is an expected-value forge recipe with a fractional outputCount', () => {
    const r = loadDataset().recipes.find((x) => x.outputItemId === 19675);
    expect(r).toBeDefined();
    expect(r?.method).toBe('mystic-forge');
    expect(r?.outputCount).toBeCloseTo(3.1, 5);
    expect(Number.isInteger(r?.outputCount)).toBe(false); // truly fractional (R11)
    const items = r?.ingredients.filter((i) => i.kind === 'item') ?? [];
    // 10-input batch: Obsidian 19925 + Mystic Coin 19976 + Ecto 19721 + Mystic Crystal, each count 10.
    expect(items.every((i) => i.count === 10)).toBe(true);
    const ids = items.map((i) => (i.kind === 'item' ? i.itemId : 0));
    expect(ids).toEqual(expect.arrayContaining([19925, 19976, 19721]));
  });

  it('P3 #1: Gift of The Bifrost (19654) = 1 Gift of Energy + 1 Gift of Color + 100 Icy Runestone + 1 Sigil', () => {
    const r = loadDataset().recipes.find((x) => x.outputItemId === 19654);
    expect(r).toBeDefined();
    const items = r?.ingredients.filter((i) => i.kind === 'item') ?? [];
    const counts = new Map(items.map((i) => [i.kind === 'item' ? i.itemId : 0, i.count]));
    expect(counts.get(19623)).toBe(1);  // Gift of Energy
    expect(counts.get(19638)).toBe(1);  // Gift of Color
    // Icy Runestone ×100 and Superior Sigil of Nullification ×1 — ids pinned live in step 1.
    expect(items).toHaveLength(4);
  });
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/legendary-recipes test Expected: the four new tests FAIL (recipes absent). Existing SC1/P1 #5 guards still pass — note that P1 #5 asserts legendaryOutputIds has 21 entries; the new recipes add outputs, not legendaries, so leave legendaryOutputIds untouched.

  • [ ] GREEN: append the four recipes to packages/legendary-recipes/data/gen1-weapons.json's recipes array (ids from step 1; T6-mat ids below are the candidates — replace any the live check corrected):

json
    {
      "outputItemId": 19672,
      "outputCount": 1,
      "method": "mystic-forge",
      "ingredients": [
        { "kind": "item", "itemId": 24356, "count": 250 },
        { "kind": "item", "itemId": 24289, "count": 250 },
        { "kind": "item", "itemId": 24351, "count": 250 },
        { "kind": "item", "itemId": 24358, "count": 250 }
      ],
      "source": "https://wiki.guildwars2.com/wiki/Gift_of_Might"
    },
    {
      "outputItemId": 19673,
      "outputCount": 1,
      "method": "mystic-forge",
      "ingredients": [
        { "kind": "item", "itemId": 24295, "count": 250 },
        { "kind": "item", "itemId": 24283, "count": 250 },
        { "kind": "item", "itemId": 24300, "count": 250 },
        { "kind": "item", "itemId": 24277, "count": 250 }
      ],
      "source": "https://wiki.guildwars2.com/wiki/Gift_of_Magic"
    },
    {
      "outputItemId": 19675,
      "outputCount": 3.1,
      "method": "mystic-forge",
      "ingredients": [
        { "kind": "item", "itemId": 19925, "count": 10 },
        { "kind": "item", "itemId": 19976, "count": 10 },
        { "kind": "item", "itemId": 19721, "count": 10 },
        { "kind": "item", "itemId": 43772, "count": 10 }
      ],
      "source": "https://wiki.guildwars2.com/wiki/Mystic_Clover"
    },
    {
      "outputItemId": 19654,
      "outputCount": 1,
      "method": "mystic-forge",
      "ingredients": [
        { "kind": "item", "itemId": 19623, "count": 1 },
        { "kind": "item", "itemId": 19638, "count": 1 },
        { "kind": "item", "itemId": 22331, "count": 100 },
        { "kind": "item", "itemId": 24658, "count": 1 }
      ],
      "source": "https://wiki.guildwars2.com/wiki/Gift_of_The_Bifrost"
    }
  • [ ] Run: pnpm --filter @gw2priory/legendary-recipes test → all green (new guards + existing SC1/P1 #5/6 + duplicate-detection). If the superRefine duplicate check fires, an id was already present — investigate.

  • [ ] Confirm teeth: change one clover id (e.g. 19976 → 19977), watch the clover ingredient test fail, restore.

  • [ ] Commit: git commit -am "specs: 008 T2 — curate the four Bifrost forge recipes (R12/R13)"

Verified by: packages/legendary-recipes/src/index.test.ts — "P3 #1: the dataset curates exactly the four Bifrost forge intermediates", "P3 #3 / SC9: Mystic Clover … fractional outputCount", and the two ingredient guards, all green.


T3 — Priced types in the types-only package ​

Satisfies: R5, R6 (type shapes). (no dependency) → consumed by T4, T5.

Files:

  • Modify: packages/recipe-graph/src/types.ts (append three types)

Interfaces produced (exact names later tasks rely on):

ts
export type PricingDecision = 'buy' | 'craft' | 'gated' | 'unknown';

export interface PricedTreeNode {
  node: GraphNode;                 // 007, unchanged
  count: number;                   // 007, unchanged
  recipe: RecipeOption | null;     // 007 shape — the chosen (cheapest) recipe
  children: PricedTreeNode[];      // 007 shape, never pruned
  decision: PricingDecision;
  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
}

// The HTTP body is the root PricedTreeNode carrying `summary`; child nodes omit it.
export type PricedRoot = PricedTreeNode & { summary: PlanSummary };
export interface PricedTree { root: PricedRoot; }
  • [ ] Write the types exactly as above, appended to packages/recipe-graph/src/types.ts (types-only package — no runtime code; this is why there is no unit test here, only the typecheck).

  • [ ] Typecheck the package: pnpm --filter @gw2priory/recipe-graph typecheck (or the repo pnpm typecheck). Expected: clean. A types-only change is verified by the compiler; T4/T5 exercise the shapes at runtime.

  • [ ] Commit: git commit -am "specs: 008 T3 — PricedTreeNode / PlanSummary / PricedTree types (R5/R6)"

Verified by: repo typecheck clean; the shapes are consumed (and thus behaviourally verified) by T4's engine tests and T5's schema drift guard.


T4 — The pure priceGraph cost engine ​

Satisfies: R1, R2, R3, R4, R5, R6, R7, R8; P1 #1–8; SC1–SC7, SC11. (T3)

Files:

  • Create: apps/api/src/recipe-graph/pricing.ts
  • Test: apps/api/src/recipe-graph/pricing.test.ts

Interfaces consumed: ResolvedGraph, GraphNode, RecipeOption, RecipeEdge, PricedTree, PricedTreeNode, PlanSummary, PricingDecision from @gw2priory/recipe-graph; projectTree from ./project-tree; netSellPrice from @gw2priory/domain.

Interfaces produced:

ts
export interface PriceEntry {
  buys: { quantity: number; unit_price: number };
  sells: { quantity: number; unit_price: number };
}
export type PriceMap = Map<number, PriceEntry>;

export function collectPricedIds(graph: ResolvedGraph): number[];        // distinct buyable ids ∪ {rootId}
export function priceGraph(graph: ResolvedGraph, priceMap: PriceMap): PricedTree;

Design (from plan.md §Approach): compute a per-distinct-id cost fold (memoised over the DAG), then reuse projectTree(graph, chooseCheapest) to lay out the tree, annotate each TreeNode with the per-id unitBuyPrice/craftCost/unitCost/decision plus its own lineCost = unitCost × count, and attach the summary to the root. Children are never pruned — a buy node keeps its full expansion; the roll-up uses each node's unitCost, so a buy-node's children don't inflate the total.

  • [ ] RED — start with collectPricedIds (SC8's id set is asserted in T6; here we test the collector): create pricing.test.ts with fixture helpers and the first test:
ts
import type { GraphNode, RecipeOption, ResolvedGraph } from '@gw2priory/recipe-graph';
import { describe, expect, it } from 'vitest';
import { collectPricedIds, priceGraph, type PriceMap } from './pricing';

function node(partial: Partial<GraphNode> & { id: number }): GraphNode {
  return {
    id: partial.id,
    name: partial.name ?? `item-${partial.id}`,
    rarity: null,
    vendorValue: null,
    classification: partial.classification ?? 'buyable',
    leaf: partial.recipes ? partial.recipes.length === 0 : true,
    recipes: partial.recipes ?? [],
  };
}
function recipe(outputCount: number, edges: Array<[number, number]>): RecipeOption {
  return {
    source: 'station',
    outputCount,
    ingredients: edges.map(([itemId, count]) => ({ kind: 'item', itemId, count })),
    provenance: 'test-fixture',
  };
}
function prices(entries: Array<[number, number, number]>): PriceMap {
  // [id, buyUnit, sellUnit] — quantity fixed at 100 (usable) unless a price is 0.
  return new Map(
    entries.map(([id, buy, sell]) => [
      id,
      { buys: { quantity: buy > 0 ? 100 : 0, unit_price: buy }, sells: { quantity: sell > 0 ? 100 : 0, unit_price: sell } },
    ]),
  );
}

describe('collectPricedIds', () => {
  it('returns the distinct buyable ids plus the root id, and nothing else', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, classification: 'gated', recipes: [recipe(1, [[1, 2], [2, 1]])] }),
        1: node({ id: 1, classification: 'buyable' }),
        2: node({ id: 2, classification: 'gated' }), // gated leaf — not buyable
      },
    };
    const ids = collectPricedIds(graph).sort((a, b) => a - b);
    expect(ids).toEqual([1, 100]); // buyable {1} ∪ root {100}; the gated leaf 2 is excluded
  });
});
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/api test pricing Expected: FAIL — ./pricing has no exports yet.

  • [ ] GREEN (minimal collectPricedIds): create pricing.ts:

ts
import type {
  GraphNode, PlanSummary, PricedTree, PricedTreeNode, PricingDecision,
  RecipeOption, ResolvedGraph, TreeNode,
} from '@gw2priory/recipe-graph';
import { netSellPrice } from '@gw2priory/domain';
import { projectTree } from './project-tree';

export interface PriceEntry {
  buys: { quantity: number; unit_price: number };
  sells: { quantity: number; unit_price: number };
}
export type PriceMap = Map<number, PriceEntry>;

/** Distinct buyable ids ∪ { rootId } — the exact set the endpoint batches into one prices call (R2/SC8). */
export function collectPricedIds(graph: ResolvedGraph): number[] {
  const ids = new Set<number>([graph.rootId]);
  for (const node of Object.values(graph.nodes)) {
    if (node.classification === 'buyable') ids.add(node.id);
  }
  return [...ids];
}
  • [ ] Run: pnpm --filter @gw2priory/api test pricing → collectPricedIds test PASS.

  • [ ] RED — the per-id cost fold + priceGraph. Add the engine tests, one behaviour at a time. Add all of these, then implement until green (golden values computed by hand in the test — no network):

ts
// Usable buy option: buyable + present + sells.quantity>0 + sells.unit_price>0 (R2).
function usableBuy(node: GraphNode, p: PriceMap): number | null {
  const e = p.get(node.id);
  if (node.classification !== 'buyable' || !e) return null;
  return e.sells.quantity > 0 && e.sells.unit_price > 0 ? e.sells.unit_price : null;
}

describe('priceGraph — per-id decision and cost', () => {
  it('P1 #1 / SC1: node carries unitBuyPrice+craftCost, unitCost=min, decision, tie→buy', () => {
    // Node 100 is buyable-and-craftable: buy at 50, craft = ceil((10*2 + 5*1)/1) = 25 → craft wins.
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, classification: 'buyable', recipes: [recipe(1, [[1, 2], [2, 1]])] }),
        1: node({ id: 1 }), 2: node({ id: 2 }),
      },
    };
    const p = prices([[100, 40, 50], [1, 9, 10], [2, 4, 5]]);
    const { root } = priceGraph(graph, p);
    expect(root.unitBuyPrice).toBe(50);
    expect(root.craftCost).toBe(25);
    expect(root.unitCost).toBe(25);
    expect(root.decision).toBe('craft');

    // Tie → buy: craft also 50.
    const tie = priceGraph(
      { rootId: 100, nodes: { 100: node({ id: 100, recipes: [recipe(1, [[1, 1]])] }), 1: node({ id: 1 }) } },
      prices([[100, 40, 50], [1, 45, 50]]),
    ).root;
    expect(tie.craftCost).toBe(50);
    expect(tie.unitBuyPrice).toBe(50);
    expect(tie.decision).toBe('buy'); // tie resolves to buy (R3)
  });

  it('P1 #2 / SC2: a buy-decision node keeps its expanded subtree and its children do not inflate the total', () => {
    // Root crafts from child 1 (buy cheaper than its own craft). Child 1: buy 5, craft from 2 (cost 100) → buy wins.
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, recipes: [recipe(1, [[1, 2]])] }),
        1: node({ id: 1, recipes: [recipe(1, [[2, 1]])] }),
        2: node({ id: 2 }),
      },
    };
    const p = prices([[100, 0, 0], [1, 4, 5], [2, 90, 100]]);
    const { root } = priceGraph(graph, p);
    const child = root.children[0];
    expect(child?.decision).toBe('buy');
    expect(child?.unitCost).toBe(5);          // min(5, 100)
    expect(child?.recipe).not.toBeNull();      // NOT pruned
    expect(child?.children).toHaveLength(1);   // subtree still present
    // Root craft = ceil((5 * 2)/1) = 10 — uses child.unitCost (buy), not the crafted 100.
    expect(root.summary.totalCraftCost).toBe(10);
  });

  it('P1 #3 / SC3: cheapest recipe drives craftCost and the shown children', () => {
    // Two recipes for the root: via 1 (cost 20) or via 2 (cost 8) → pick 2.
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, classification: 'gated', recipes: [recipe(1, [[1, 2]]), recipe(1, [[2, 1]])] }),
        1: node({ id: 1 }), 2: node({ id: 2 }),
      },
    };
    const p = prices([[1, 9, 10], [2, 7, 8]]);
    const { root } = priceGraph(graph, p);
    expect(root.craftCost).toBe(8);
    expect(root.recipe?.ingredients[0]).toMatchObject({ itemId: 2 }); // cheapest option's expansion shown
    expect(root.children.map((c) => c.node.id)).toEqual([2]);
  });

  it('P1 #4 / SC4: a gated leaf is 0-gold, decision gated, excluded from totalCraftCost', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, classification: 'gated', recipes: [recipe(1, [[1, 1], [2, 1]])] }),
        1: node({ id: 1 }),                          // buyable leaf, cost 10
        2: node({ id: 2, classification: 'gated' }), // gated leaf → 0
      },
    };
    const p = prices([[1, 9, 10]]); // 2 absent
    const { root } = priceGraph(graph, p);
    const gatedLeaf = root.children.find((c) => c.node.id === 2);
    expect(gatedLeaf?.decision).toBe('gated');
    expect(gatedLeaf?.unitCost).toBe(0);
    expect(root.craftCost).toBe(10); // ceil((10*1 + 0*1)/1) — gated leaf adds 0
  });

  it('P1 #5 / SC4: a gated-craftable node (Mystic Clover) is crafted, rolled up from buyable mats', () => {
    // Node 100 gated WITH a recipe: no buy option, cost from buyable child 1. outputCount 3.1 (fractional).
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, classification: 'gated', recipes: [recipe(3.1, [[1, 10], [2, 10]])] }),
        1: node({ id: 1 }),                          // Mystic Coin — buyable 100
        2: node({ id: 2, classification: 'gated' }), // Obsidian Shard — gated 0
      },
    };
    const p = prices([[1, 90, 100]]);
    const { root } = priceGraph(graph, p);
    expect(root.unitBuyPrice).toBeNull();     // gated → no buy option
    expect(root.decision).toBe('craft');
    expect(root.children).toHaveLength(2);
    // ceil((100*10 + 0*10) / 3.1) = ceil(322.58…) = 323
    expect(root.craftCost).toBe(323);
    expect(root.unitCost).toBe(323);
  });

  it('P1 #6 / SC5: summary totalCraftCost/netSell/profit rolled up via unitCost', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: { 100: node({ id: 100, recipes: [recipe(1, [[1, 3]])] }), 1: node({ id: 1 }) },
    };
    const p = prices([[100, 1000, 1200], [1, 90, 100]]);
    const { root } = priceGraph(graph, p);
    expect(root.summary.totalCraftCost).toBe(300);       // ceil(100*3/1)
    expect(root.summary.rootBuyPrice).toBe(1200);         // sells.unit_price
    expect(root.summary.netSell).toBe(850);               // netSellPrice(1000) = floor(0.85*1000)
    expect(root.summary.profit).toBe(550);                // 850 - 300
  });

  it('P1 #7 / SC5: root decision is always craft; rootBuyPrice surfaced even when cheaper', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: { 100: node({ id: 100, recipes: [recipe(1, [[1, 100]])] }), 1: node({ id: 1 }) },
    };
    // Root buys for 50 but crafts for 100*… → craft anyway (root is the product).
    const p = prices([[100, 40, 50], [1, 90, 100]]);
    const { root } = priceGraph(graph, p);
    expect(root.decision).toBe('craft');
    expect(root.summary.rootBuyPrice).toBe(50);
  });

  it('P1 #8 / SC6: a non-sellable root yields null netSell and profit, total still produced', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: { 100: node({ id: 100, recipes: [recipe(1, [[1, 2]])] }), 1: node({ id: 1 }) },
    };
    const p = prices([[1, 90, 100]]); // root absent from prices → no buys
    const { root } = priceGraph(graph, p);
    expect(root.summary.totalCraftCost).toBe(200);
    expect(root.summary.netSell).toBeNull();
    expect(root.summary.profit).toBeNull();
    expect(root.summary.rootBuyPrice).toBeNull();
  });

  it('R3: neither option defined → unitCost null, decision unknown, propagates', () => {
    // Node 100 crafts from unpriced non-craftable child 1 → child unknown → root craftCost null.
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: {
        100: node({ id: 100, recipes: [recipe(1, [[1, 1]])] }),
        1: node({ id: 1, classification: null }), // no class, no price, no recipe
      },
    };
    const { root } = priceGraph(graph, new Map());
    const child = root.children[0];
    expect(child?.decision).toBe('unknown');
    expect(child?.unitCost).toBeNull();
    expect(root.craftCost).toBeNull();       // a null ingredient poisons the option
    expect(root.summary.totalCraftCost).toBeNull();
  });

  it('SC7: priceGraph is pure and deterministic over a frozen input', () => {
    const graph: ResolvedGraph = Object.freeze({
      rootId: 100,
      nodes: Object.freeze({ 100: node({ id: 100, recipes: [recipe(1, [[1, 2]])] }), 1: node({ id: 1 }) }),
    }) as ResolvedGraph;
    const p = prices([[100, 1000, 1200], [1, 90, 100]]);
    const a = priceGraph(graph, p);
    const b = priceGraph(graph, p);
    expect(a).toEqual(b);                     // same inputs → identical output
    expect(graph.nodes[1]?.recipes).toEqual([]); // input untouched
  });

  it('SC11: a PricedTree round-trips as plain JSON (no Map on the wire, summary + children intact)', () => {
    const graph: ResolvedGraph = {
      rootId: 100,
      nodes: { 100: node({ id: 100, recipes: [recipe(1, [[1, 2]])] }), 1: node({ id: 1 }) },
    };
    const tree = priceGraph(graph, prices([[100, 1000, 1200], [1, 90, 100]]));
    expect(JSON.parse(JSON.stringify(tree))).toEqual(tree);
    expect(tree.root.summary).toBeDefined();
    expect(tree.root.children[0]?.node.id).toBe(1);
  });
});
  • [ ] Run them, watch them fail: pnpm --filter @gw2priory/api test pricing — all new specs FAIL (priceGraph not implemented). Confirm each fails for the right reason (missing export), not a fixture typo.

  • [ ] GREEN — implement the engine in pricing.ts (append below collectPricedIds):

ts
interface IdCost {
  unitBuyPrice: number | null;
  craftCost: number | null;
  unitCost: number | null;
  decision: PricingDecision;
  chosenRecipe: RecipeOption | null; // cheapest fully-priced option, for the tree layout
}

function nodeOf(graph: ResolvedGraph, id: number): GraphNode {
  const n = graph.nodes[id];
  if (n === undefined) throw new Error(`priceGraph: node ${id} missing from graph`);
  return n;
}

function buyOption(node: GraphNode, priceMap: PriceMap): number | null {
  if (node.classification !== 'buyable') return null;
  const e = priceMap.get(node.id);
  if (!e) return null;
  return e.sells.quantity > 0 && e.sells.unit_price > 0 ? e.sells.unit_price : null;
}

// Cost of one recipe option: ceil( Σ childUnitCost × count / outputCount ). Currency edges are 0.
// Any item ingredient whose chosen unitCost is null poisons the whole option → null.
function recipeCost(
  option: RecipeOption,
  graph: ResolvedGraph,
  priceMap: PriceMap,
  costOf: (id: number) => IdCost,
): number | null {
  let sum = 0;
  for (const edge of option.ingredients) {
    if (edge.kind === 'currency') continue; // player-supplied — 0 gold (Assumptions)
    const child = costOf(edge.itemId).unitCost;
    if (child === null) return null;
    sum += child * edge.count;
  }
  return Math.ceil(sum / option.outputCount);
}

function foldCosts(graph: ResolvedGraph, priceMap: PriceMap): Map<number, IdCost> {
  const memo = new Map<number, IdCost>();
  const inProgress = new Set<number>(); // DAG is acyclic (007 guarantees), guard is belt-and-braces

  const costOf = (id: number): IdCost => {
    const cached = memo.get(id);
    if (cached) return cached;
    if (inProgress.has(id)) throw new Error(`priceGraph: unexpected cycle at ${id}`);
    inProgress.add(id);

    const node = nodeOf(graph, id);
    const unitBuyPrice = buyOption(node, priceMap);

    // Cheapest fully-priced recipe option.
    let craftCost: number | null = null;
    let chosenRecipe: RecipeOption | null = null;
    for (const option of node.recipes) {
      const c = recipeCost(option, graph, priceMap, costOf);
      if (c !== null && (craftCost === null || c < craftCost)) {
        craftCost = c;
        chosenRecipe = option;
      }
    }

    let unitCost: number | null;
    let decision: PricingDecision;
    if (node.classification === 'gated' && node.recipes.length === 0) {
      unitCost = 0; decision = 'gated'; // gated LEAF — player-supplied, 0 gold (R3)
    } else if (unitBuyPrice !== null && craftCost !== null) {
      unitCost = Math.min(unitBuyPrice, craftCost);
      decision = unitBuyPrice <= craftCost ? 'buy' : 'craft'; // tie → buy (R3)
    } else if (unitBuyPrice !== null) {
      unitCost = unitBuyPrice; decision = 'buy';
    } else if (craftCost !== null) {
      unitCost = craftCost; decision = 'craft';
    } else {
      unitCost = null; decision = 'unknown'; // R3 — propagates
    }

    const result: IdCost = { unitBuyPrice, craftCost, unitCost, decision, chosenRecipe };
    inProgress.delete(id);
    memo.set(id, result);
    return result;
  };

  for (const id of Object.keys(graph.nodes).map(Number)) costOf(id);
  return memo;
}

export function priceGraph(graph: ResolvedGraph, priceMap: PriceMap): PricedTree {
  const costs = foldCosts(graph, priceMap);
  const costFor = (id: number): IdCost =>
    costs.get(id) ?? { unitBuyPrice: null, craftCost: null, unitCost: null, decision: 'unknown', chosenRecipe: null };

  // Lay out the tree choosing each node's cheapest recipe (children never pruned).
  const structural = projectTree(graph, (node) => costFor(node.id).chosenRecipe);

  const annotate = (t: TreeNode): PricedTreeNode => {
    const c = costFor(t.node.id);
    const lineCost = c.unitCost === null ? null : c.unitCost * t.count;
    return {
      node: t.node,
      count: t.count,
      recipe: t.recipe,
      children: t.children.map(annotate),
      decision: c.decision,
      unitBuyPrice: c.unitBuyPrice,
      craftCost: c.craftCost,
      unitCost: c.unitCost,
      lineCost,
    };
  };

  const annotatedRoot = annotate(structural);
  const rootCost = costFor(graph.rootId);
  const rootPrice = priceMap.get(graph.rootId);
  const rootBuyOrder = rootPrice && rootPrice.buys.quantity > 0 && rootPrice.buys.unit_price > 0
    ? rootPrice.buys.unit_price : null;

  // The root is ALWAYS crafted (it is the product) — R4/P1 #7. Its rolled-up cost is its craftCost when it
  // has a recipe, else its unitCost (unknown/gated edge cases).
  const totalCraftCost = rootCost.craftCost ?? (rootCost.decision === 'unknown' ? null : rootCost.unitCost);
  const netSell = rootBuyOrder === null ? null : netSellPrice(rootBuyOrder);
  const profit = netSell === null || totalCraftCost === null ? null : netSell - totalCraftCost;

  const summary: PlanSummary = {
    rootId: graph.rootId,
    totalCraftCost,
    rootBuyPrice: rootPrice && rootPrice.sells.quantity > 0 && rootPrice.sells.unit_price > 0
      ? rootPrice.sells.unit_price : null,
    netSell,
    profit,
  };

  return { root: { ...annotatedRoot, decision: 'craft', summary } };
}
  • [ ] Run: pnpm --filter @gw2priory/api test pricing → all engine specs PASS. Investigate any golden mismatch with superpowers:systematic-debugging — the hand-computed value is the contract.

  • [ ] Confirm teeth: flip the tie rule (<= → <) and watch P1 #1's tie assertion fail; restore. Break the roll-up (use crafted 100 instead of child.unitCost) and watch P1 #2 fail; restore.

  • [ ] Typecheck: pnpm --filter @gw2priory/api typecheck — no any, no !.

  • [ ] Commit: git commit -am "specs: 008 T4 — pure priceGraph cost engine (R1-R8, SC1-SC7/SC11)"

Verified by: apps/api/src/recipe-graph/pricing.test.ts — the collectPricedIds test, the P1 #1–8 golden tests, and SC7 (purity) / SC11 (JSON round-trip), all green.


T5 — Extend the response schema to PricedTreeNodeSchema + root summary ​

Satisfies: R5, R6, R10 (schema half), SC8/SC12 (schema shape). (T3)

Files:

  • Modify: apps/api/src/recipe-graph/recipe-graph.schema.ts
  • Test: apps/api/src/recipe-graph/recipe-graph.schema.test.ts (extend T1's file)

Interfaces produced: RecipeTreeDto now validates a PricedTreeNode (cost fields on every node) with a summary: PlanSummary on the root; the : z.ZodType<PricedTreeNode> annotation is the drift guard against the T3 package type.

  • [ ] RED: extend recipe-graph.schema.test.ts — the enriched body must validate, and a node missing a cost field must be rejected:
ts
  const pricedLeaf = {
    node: { id: 1, name: 'Mystic Coin', rarity: null, vendorValue: null, classification: 'buyable', leaf: true, recipes: [] },
    count: 10, recipe: null, children: [],
    decision: 'buy', unitBuyPrice: 100, craftCost: null, unitCost: 100, lineCost: 1000,
  };

  it('SC8: the schema validates an enriched node with cost fields', () => {
    expect(() => RecipeTreeDto.schema.parse(pricedLeaf)).not.toThrow();
  });

  it('SC8: the root DTO accepts a summary', () => {
    const root = {
      ...pricedLeaf,
      node: { ...pricedLeaf.node, id: 100, leaf: false },
      decision: 'craft',
      summary: { rootId: 100, totalCraftCost: 1000, rootBuyPrice: 1200, netSell: 850, profit: null },
    };
    expect(() => RecipeTreeDto.schema.parse(root)).not.toThrow();
  });

  it('SC8: a node missing unitCost is rejected (cost fields are required)', () => {
    const { unitCost, ...broken } = pricedLeaf;
    expect(() => RecipeTreeDto.schema.parse(broken)).toThrow();
  });
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/api test recipe-graph.schema — the enriched tests FAIL (schema has no cost fields yet).

  • [ ] GREEN: rewrite the recursive schema in recipe-graph.schema.ts (keep RecipeEdgeSchema, RecipeOptionSchema with the T1 fractional outputCount, and GraphNodeSchema as-is):

ts
import type { PlanSummary, PricedTreeNode } from '@gw2priory/recipe-graph';
// … RecipeEdgeSchema / RecipeOptionSchema / GraphNodeSchema unchanged (RecipeOptionSchema.outputCount relaxed in T1) …

const DecisionSchema = z.enum(['buy', 'craft', 'gated', 'unknown']);

// Recursive priced node. `: z.ZodType<PricedTreeNode>` both breaks the self-reference cycle and pins the
// schema to the package type (drift guard) — this file stops compiling if they diverge (R10).
const PricedTreeNodeSchema: z.ZodType<PricedTreeNode> = z.lazy(() =>
  z.object({
    node: GraphNodeSchema,
    count: z.number().int().positive(),
    recipe: RecipeOptionSchema.nullable(),
    children: z.array(PricedTreeNodeSchema),
    decision: DecisionSchema,
    unitBuyPrice: z.number().nullable(),
    craftCost: z.number().nullable(),
    unitCost: z.number().nullable(),
    lineCost: z.number().nullable(),
  }),
);

const PlanSummarySchema: z.ZodType<PlanSummary> = z.object({
  rootId: z.number().int().positive(),
  totalCraftCost: z.number().nullable(),
  rootBuyPrice: z.number().nullable(),
  netSell: z.number().nullable(),
  profit: z.number().nullable(),
});

// The HTTP body is the root priced node PLUS a summary. `.and()` keeps the recursive node schema intact and
// layers the root-only field on top (child nodes carry no summary).
const PricedRootSchema = PricedTreeNodeSchema.and(z.object({ summary: PlanSummarySchema }));

export class RecipeTreeDto extends createZodDto(PricedRootSchema) {}
  • [ ] Run: pnpm --filter @gw2priory/api test recipe-graph.schema → PASS (enriched validates, summary accepted, missing-field rejected, T1's fractional test still green).

  • [ ] Typecheck: pnpm --filter @gw2priory/api typecheck — the drift-guard annotations must compile against the T3 types. A mismatch here means the schema and the package type disagree; fix the schema, not the type.

  • [ ] Confirm teeth: drop decision from the schema object, watch "a node missing unitCost is rejected"'s sibling behaviour (and the enriched-validate test) shift; restore.

  • [ ] Commit: git commit -am "specs: 008 T5 — PricedTreeNodeSchema + root summary DTO (R5/R6/R10)"

Verified by: apps/api/src/recipe-graph/recipe-graph.schema.test.ts — "SC8: the schema validates an enriched node", "SC8: the root DTO accepts a summary", "SC8: a node missing unitCost is rejected", plus T1's fractional test, all green; typecheck clean (drift guard holds).


T6 — Fold prices into the controller ​

Satisfies: R9, R14; P2 #1–3; SC8. (T4, T5)

Files:

  • Modify: apps/api/src/recipe-graph/recipe-graph.controller.ts
  • Modify: apps/api/src/recipe-graph/recipe-graph.module.ts (import Gw2Module so Gw2Service injects)
  • Test: apps/api/src/recipe-graph/recipe-graph.controller.test.ts (rewrite the body assertions to the enriched shape; add the mocked Gw2Service)

Interfaces consumed: priceGraph, collectPricedIds, PriceMap from ./pricing; Gw2Service from ../gw2/gw2.service.

  • [ ] RED — update the existing controller test. The current test asserts res.json() equals projectTree(graph); that body is now enriched. Rewrite buildApp to also provide a mocked Gw2Service, and assert the enriched body + the single batched prices call. Replace the T10 describe block's first test and add the batching test (note: the guard tests for 400/404 keep working — extend buildApp):
ts
import { priceGraph, collectPricedIds, type PriceMap } from './pricing';
import { Gw2Service } from '../gw2/gw2.service';

async function buildApp(
  resolve: (itemId: number) => Promise<ResolvedGraph>,
  prices: (ids: number[]) => Promise<
    Array<{ id: number; whitelisted: boolean; buys: { quantity: number; unit_price: number }; sells: { quantity: number; unit_price: number } }>
  > = async () => [],
): Promise<NestFastifyApplication> {
  const ref = await Test.createTestingModule({
    controllers: [RecipeGraphController],
    providers: [
      { provide: RecipeGraphService, useValue: { resolve } },
      { provide: Gw2Service, useValue: { prices } },
    ],
  }).compile();
  const app = ref.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
  app.useGlobalPipes(new ZodValidationPipe());
  await app.init();
  await app.getHttpAdapter().getInstance().ready();
  return app;
}

  it('P2 #1 / SC8: GET returns 200, enriched tree + root summary, 007 fields intact', async () => {
    const child = leaf(1, 'Bolt of Cloth');
    const rootRecipe: RecipeOption = {
      source: 'station', outputCount: 1,
      ingredients: [{ kind: 'item', itemId: 1, count: 2 }], provenance: 'test-fixture',
    };
    const root: GraphNode = {
      id: 100, name: 'Root Item', rarity: 'Exotic', vendorValue: 10,
      classification: 'buyable', leaf: false, recipes: [rootRecipe],
    };
    const graph: ResolvedGraph = { rootId: 100, nodes: { 100: root, 1: child } };
    const resolve = vi.fn(async () => graph);
    const prices = vi.fn(async () => [
      { id: 100, whitelisted: true, buys: { quantity: 5, unit_price: 1000 }, sells: { quantity: 5, unit_price: 1200 } },
      { id: 1, whitelisted: true, buys: { quantity: 9, unit_price: 90 }, sells: { quantity: 9, unit_price: 100 } },
    ]);

    app = await buildApp(resolve, prices);
    const res = await app.inject({ method: 'GET', url: '/recipe-graph/100' });

    expect(res.statusCode).toBe(200);
    const body = res.json();
    // 007 fields intact
    expect(body.node.id).toBe(100);
    expect(body.count).toBe(1);
    expect(body.recipe.source).toBe('station');
    expect(body.children[0].node.id).toBe(1);
    // 008 enrichment
    expect(body.decision).toBe('craft');
    expect(body.unitCost).toBe(200);            // ceil(100*2/1)
    expect(body.summary.totalCraftCost).toBe(200);
    expect(body.summary.netSell).toBe(850);     // floor(0.85*1000)
    // Matches the pure engine over the same price map.
    const priceMap: PriceMap = new Map([
      [100, { buys: { quantity: 5, unit_price: 1000 }, sells: { quantity: 5, unit_price: 1200 } }],
      [1, { buys: { quantity: 9, unit_price: 90 }, sells: { quantity: 9, unit_price: 100 } }],
    ]);
    expect(body).toEqual(JSON.parse(JSON.stringify(priceGraph(graph, priceMap).root)));
  });

  it('P2 #3 / SC8: one batched prices call over buyable ids ∪ root; resolve once', async () => {
    const child = leaf(1, 'Bolt of Cloth');
    const gated: GraphNode = { id: 2, name: 'Token', rarity: null, vendorValue: null, classification: 'gated', leaf: true, recipes: [] };
    const rootRecipe: RecipeOption = {
      source: 'station', outputCount: 1,
      ingredients: [{ kind: 'item', itemId: 1, count: 2 }, { kind: 'item', itemId: 2, count: 1 }],
      provenance: 'test-fixture',
    };
    const root: GraphNode = { id: 100, name: 'Root', rarity: null, vendorValue: null, classification: 'buyable', leaf: false, recipes: [rootRecipe] };
    const graph: ResolvedGraph = { rootId: 100, nodes: { 100: root, 1: child, 2: gated } };
    const resolve = vi.fn(async () => graph);
    const prices = vi.fn(async () => []);

    app = await buildApp(resolve, prices);
    await app.inject({ method: 'GET', url: '/recipe-graph/100' });

    expect(resolve).toHaveBeenCalledTimes(1);
    expect(prices).toHaveBeenCalledTimes(1);          // exactly one batched call
    const idArg = (prices.mock.calls[0]?.[0] ?? []).slice().sort((a, b) => a - b);
    expect(idArg).toEqual([1, 100]);                   // buyable {1,100} ∪ root {100}; gated leaf 2 excluded
  });
  Keep the three existing 400/404 tests, but each now calls `buildApp(resolve)` (the default empty `prices`
  is fine — for 400 the resolver isn't reached; for 404 the null-root guard fires before pricing).
  • [ ] Run them, watch them fail: pnpm --filter @gw2priory/api test recipe-graph.controller Expected: the enriched-body test and the batching test FAIL (controller still returns bare projectTree, no Gw2Service provided → DI error until the controller is updated).

  • [ ] GREEN — rewrite the controller recipe-graph.controller.ts:

ts
import type { PricedRoot } from '@gw2priory/recipe-graph';
import {
  BadRequestException, Controller, Get, NotFoundException, Param, ParseIntPipe,
} from '@nestjs/common';
import { ZodResponse } from 'nestjs-zod';
import { collectPricedIds, priceGraph, type PriceMap } from './pricing';
import { RecipeTreeDto } from './recipe-graph.schema';
// value imports — Nest DI reads these constructor param types at runtime via SWC design:paramtypes metadata.
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { RecipeGraphService } from './recipe-graph.service';
// biome-ignore lint/style/useImportType: value import — Nest DI needs the runtime reference.
import { Gw2Service } from '../gw2/gw2.service';

@Controller('recipe-graph')
export class RecipeGraphController {
  constructor(
    private readonly recipeGraph: RecipeGraphService,
    private readonly gw2: Gw2Service,
  ) {}

  @Get(':itemId')
  @ZodResponse({ status: 200, type: RecipeTreeDto })
  async get(@Param('itemId', ParseIntPipe) itemId: number): Promise<PricedRoot> {
    if (itemId <= 0) {
      throw new BadRequestException('itemId must be a positive integer');
    }
    const graph = await this.recipeGraph.resolve(itemId); // 007 — unchanged, price-free
    if (graph.nodes[itemId]?.name === null) {
      throw new NotFoundException(`item ${itemId} not found`);
    }
    // One batched prices call over the distinct buyable ids ∪ root (R2/R9/SC8).
    const priced = await this.gw2.prices(collectPricedIds(graph));
    const priceMap: PriceMap = new Map(
      priced.map((p) => [p.id, { buys: p.buys, sells: p.sells }]),
    );
    return priceGraph(graph, priceMap).root;
  }
}
  • [ ] GREEN — wire the module recipe-graph.module.ts:
ts
import { Module } from '@nestjs/common';
import { Gw2Module } from '../gw2/gw2.module';
import { StaticDataModule } from '../static-data/static-data.module';
import { RecipeGraphController } from './recipe-graph.controller';
import { RecipeGraphService } from './recipe-graph.service';

@Module({
  imports: [StaticDataModule, Gw2Module],
  controllers: [RecipeGraphController],
  providers: [RecipeGraphService],
  exports: [RecipeGraphService],
})
export class RecipeGraphModule {}
  • [ ] Run: pnpm --filter @gw2priory/api test recipe-graph.controller recipe-graph.module → all green (enriched body, batching, 400/404, and the module DI test — Gw2Module exports Gw2Service, so the real controller resolves).

  • [ ] Confirm teeth: make the controller call prices per-node (map over ids), watch "one batched prices call" fail on toHaveBeenCalledTimes(1); restore.

  • [ ] Typecheck: pnpm --filter @gw2priory/api typecheck.

  • [ ] Commit: git commit -am "specs: 008 T6 — fold prices into recipe-graph controller (R9/SC8)"

Verified by: apps/api/src/recipe-graph/recipe-graph.controller.test.ts — "P2 #1 / SC8: … enriched tree + root summary, 007 fields intact", "P2 #3 / SC8: one batched prices call over buyable ids ∪ root; resolve once", "P2 #2 / SC8: … 400 / 404" (the retained guards), and recipe-graph.module.test.ts, all green.


T7 — Regenerate OpenAPI + web client ​

Satisfies: R10, SC12. (T5, T6)

Files:

  • Modify: committed apps/api/openapi.json (regenerated, not hand-edited)

  • Modify: apps/api/src/generate-openapi.test.ts (assert the enriched shape is documented)

  • Regenerate: apps/web/src/api/generated/** (orval — mechanical, no hand edits)

  • [ ] RED: add to apps/api/src/generate-openapi.test.ts an assertion that the GET /recipe-graph/{itemId} response documents the enriched shape. Reuse the file's existing buildOpenApiDocument() helper and its typed isReferenceObject / component-schema derivation style (do not reach past the package's public types — the file already models this without any):

ts
  it('SC12: GET /recipe-graph/{itemId} documents the enriched priced shape (decision + summary)', async () => {
    const doc = await buildOpenApiDocument();
    // The recursive node + root summary land in components.schemas; assert the enriched field names appear.
    const schemaText = JSON.stringify(doc.components?.schemas ?? {});
    expect(doc.paths['/recipe-graph/{itemId}']?.get).toBeDefined();
    expect(schemaText).toContain('decision');
    expect(schemaText).toContain('unitCost');
    expect(schemaText).toContain('summary');
    expect(schemaText).toContain('totalCraftCost');
  });
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/api test generate-openapi Expected: FAIL — buildOpenApiDocument() reflects T5's DTO, but confirm the assertion fails against a stale expectation if the committed doc is what a sibling guard compares to. (If the doc is built live in this test, it should already pass once T5 landed — in that case the RED is the deterministic-emit guard below, which fails until openapi.json is regenerated.)

  • [ ] GREEN — regenerate the committed document. The emitter is the built CLI (generate:openapi runs node dist/generate-openapi.cli.js), so build first:

bash
pnpm --filter @gw2priory/api build
pnpm --filter @gw2priory/api generate:openapi   # rewrites the committed apps/api/openapi.json from RecipeTreeDto
  No hand edits to `openapi.json`.
  • [ ] Run: pnpm --filter @gw2priory/api test generate-openapi → the new assertion PASSES and the existing deterministic-emit guard PASSES (re-running the generator produces a byte-identical file — commit the regenerated openapi.json).

  • [ ] Regenerate the web client: run orval against the new document:

bash
pnpm --filter @gw2priory/web generate:api   # orval — check the actual script name
  Commit `apps/web/src/api/generated/**` as-is (mechanical). Typecheck the web package to confirm the
  generated types compile.
  • [ ] Confirm determinism: run the generator twice; git status shows no diff on the second run.

  • [ ] Commit: git commit -am "specs: 008 T7 — regenerate OpenAPI + web client for enriched response (SC12)"

Verified by: apps/api/src/generate-openapi.test.ts — "SC12: … documents the enriched priced shape" and the retained deterministic-emit guard, both green; apps/web typecheck clean against the regenerated client.


T8 — Bifrost integration test (real dataset resolve → priced, non-zero total) ​

Satisfies: P3 #2, P3 #4, SC10. (T2, T4, T6)

Files:

  • Create: apps/api/src/recipe-graph/recipe-graph.service.bifrost.test.ts

Interfaces consumed: the real RecipeGraphService.resolve (007, unchanged) using the realCuratedRecipeService (which loads T2's curated dataset synchronously — no mock) + priceGraph over a fixed, checked-in price map. StationDataService and ItemDataService DO hit the GW2 API, so they are fixtured here (sub-gift discipline recipes + per-id classification) to keep the test deterministic and offline. Bootstrap mirrors recipe-graph.service.test.ts's buildService, but swaps NOTHING_CURATED for the real CuratedRecipeService.

Fixtures the station/item fakes must cover for the Bifrost path (from research V2/V3):

  • StationDataService.getRecipes(19623) → Gift of Energy recipe 4315 (250× each dust 24274/24275/24276/24277); getRecipes(19638) → Gift of Color recipe 3165 (100 Unidentified Dye 20323 + 100 Opal Orb 24522 + 250 dust 24277 + 1 Gift of Zhaitan 19669); [] for every other id (Forge/vendor/leaves).

  • ItemDataService.classify = the real rule (AccountBound → gated, else buyable); metadata returns a name for each id so the root-null 404 guard never trips and earned tokens (19925/20797/19677/19678/19669) classify gated. Icy Runestone / Mystic Crystal likewise gated (vendor/karma).

  • [ ] RED: create recipe-graph.service.bifrost.test.ts. Resolve The Bifrost (30698), price it, and assert the forge path expanded, earned tokens stay 0-leaves, and the total is non-zero and complete:

ts
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { CuratedRecipeService } from '../static-data/curated-recipe.service';
import { ItemDataService } from '../static-data/item-data.service';
import { StationDataService } from '../static-data/station-data.service';
import { RecipeGraphService } from './recipe-graph.service';
import { priceGraph, collectPricedIds, type PriceMap } from './pricing';

// The REAL CuratedRecipeService (loads T2's dataset). Only station + item are fixtured (they hit the API).
async function buildRealService(): Promise<RecipeGraphService> {
  const moduleRef = await Test.createTestingModule({
    providers: [
      RecipeGraphService,
      CuratedRecipeService, // real — exercises the curated data under test
      { provide: StationDataService, useValue: stationFixture() },
      { provide: ItemDataService, useValue: itemFixture() },
    ],
  }).compile();
  return moduleRef.get(RecipeGraphService);
}
// stationFixture(): returns 4315/3165 contents for 19623/19638, [] otherwise (see fixtures list above).
// itemFixture(): { metadata: async (ids) => ids.map(nameAndFlags), classify: real AccountBound rule }.

const BIFROST = 30698;
const GIFT_OF_MIGHT = 19672, GIFT_OF_MAGIC = 19673, MYSTIC_CLOVER = 19675, GIFT_OF_BIFROST = 19654;
const EARNED = [19925, 20797, 19677, 19678]; // Obsidian, Bloodstone Shard, Gift of Exploration, Gift of Battle

describe('The Bifrost priced end-to-end (real dataset)', () => {
  it('P3 #2 / SC10: resolve(30698) expands the forge path to buyable raw materials', async () => {
    const service = buildRealService(); // per recipe-graph.service.test.ts
    const graph = await service.resolve(BIFROST);

    // The four curated intermediates are present AND non-leaf (they expanded).
    for (const id of [GIFT_OF_MIGHT, GIFT_OF_MAGIC, MYSTIC_CLOVER, GIFT_OF_BIFROST]) {
      const n = graph.nodes[id];
      expect(n, `node ${id}`).toBeDefined();
      expect(n?.recipes.length ?? 0).toBeGreaterThan(0); // not a leaf
    }
    // Sub-gifts reached via the station path (not curated) — present and expanded.
    expect(graph.nodes[19623]).toBeDefined(); // Gift of Energy
    expect(graph.nodes[19638]).toBeDefined(); // Gift of Color
  });

  it('P3 #4: earned/vendor tokens remain leaves valued at 0', async () => {
    const service = buildRealService();
    const graph = await service.resolve(BIFROST);
    const priceMap: PriceMap = bifrostFixturePrices(collectPricedIds(graph)); // fixed prices, checked-in
    const tree = priceGraph(graph, priceMap);

    const findAll = (t = tree.root, acc: typeof tree.root[] = []): typeof tree.root[] => {
      acc.push(t); for (const c of t.children) findAll(c, acc); return acc;
    };
    const nodes = findAll();
    for (const id of EARNED) {
      const hits = nodes.filter((n) => n.node.id === id);
      if (hits.length === 0) continue; // not all appear on every branch
      for (const h of hits) { expect(h.decision).toBe('gated'); expect(h.unitCost).toBe(0); }
    }
  });

  it('P3 #2 / SC10: the priced Bifrost has a non-zero, complete totalCraftCost', async () => {
    const service = buildRealService();
    const graph = await service.resolve(BIFROST);
    const priceMap: PriceMap = bifrostFixturePrices(collectPricedIds(graph));
    const tree = priceGraph(graph, priceMap);

    expect(tree.root.summary.totalCraftCost).not.toBeNull();
    expect(tree.root.summary.totalCraftCost ?? 0).toBeGreaterThan(0); // no gold-bearing node bottomed out at 0
    expect(tree.root.decision).toBe('craft');
  });
});
  `bifrostFixturePrices(ids)` returns a `PriceMap` with a fixed price for **every buyable id** in the
  resolved Bifrost graph (a small checked-in table — e.g. Mystic Coin, Ecto, T6 mats, dyes, sigil each given
  a plausible non-zero `buys`/`sells`), so the total is deterministic. Earned/vendor ids are simply absent
  (→ gated 0). The *live* profit figure is observed at Step 5, **not** asserted here.
  • [ ] Run it, watch it fail: pnpm --filter @gw2priory/api test bifrost Expected: FAIL until the fixture builders (buildRealService, bifrostFixturePrices) are written; then it exercises real resolution over T2's curated data.

  • [ ] GREEN — write stationFixture(), itemFixture(), and bifrostFixturePrices() (no production code changes — this task is a test over already-shipped code). The curated dataset is the real thing under test; station recipes and item metadata are fixtured (per the list above) so resolution is offline and deterministic. A curated id that fails to expand or an earned token that fails to classify gated will fail the assertions loudly (the plan's id-collision backstop).

  • [ ] Run: pnpm --filter @gw2priory/api test bifrost → green: the four recipes expand, earned tokens are 0-leaves, totalCraftCost is non-zero and non-null.

  • [ ] Confirm teeth: temporarily remove one curated recipe from the dataset (e.g. Mystic Clover), watch "expands the forge path" fail (clover becomes a leaf) and the total possibly drop/incomplete; restore.

  • [ ] Commit: git commit -am "specs: 008 T8 — Bifrost priced end-to-end integration test (SC10)"

Verified by: apps/api/src/recipe-graph/recipe-graph.service.bifrost.test.ts — "P3 #2 / SC10: … expands the forge path", "P3 #4: earned tokens remain 0-leaves", "P3 #2 / SC10: … non-zero, complete totalCraftCost".


Verification (Step 5 — after T8) ​

Not a TDD task; the closeout gate before requesting review (superpowers:verification-before-completion).

  • [ ] Full typecheck + tests: pnpm typecheck && pnpm test (or the repo's aggregate scripts) — all green.
  • [ ] Run the app and hit GET /recipe-graph/30698 against the live GW2 API; observe the real summary.totalCraftCost / netSell / profit and record the figure (an observation, not an assertion — spec §Test strategy). Sanity-check it against research V5's live numbers (Bifrost buys ≈ 1250g, netSell ≈ 1062g on 2026-08-01).
  • [ ] Fill the spec's Traceability table — replace each planned test name with the actual test title now that they exist (SC1–SC13, P1 #1–8, P2 #1–3, P3 #1–4). SC13 is the existing repo-invariant test (no new work). The table must be a transcription, not an excavation.
  • [ ] Code review — superpowers:requesting-code-review, then superpowers:receiving-code-review.
  • [ ] Status transition — on the human's decision, transcribe spec.md status approved → implementedinside the branch, part of the PR diff (CLAUDE.md Definition of Done). The agent transcribes; the human decides.

Notes ​

Staging area for decisions and surprises found during implementation. Move each into spec.md, research.md, or docs/ before closing the feature — this section is not a home.

  • Plan vs spec filename reconciliation (recorded per the constitution — "a path is a commitment"). These tasks follow the spec's Traceability table test-file names, which the plan's File Structure named slightly differently:
    • Bifrost integration test → recipe-graph.service.bifrost.test.ts (spec) rather than recipe-graph.bifrost.test.ts (plan File Structure).
    • Response-schema tests live in a dedicated recipe-graph.schema.test.ts (spec Traceability's sch), created in T1 and extended in T5 — the plan folded them into the schema file's description without naming a test file. No behaviour differs; only the filename is reconciled to the spec.
  • T6-material ids to pin live in T2 (the plan's one deferred detail). research V1 pinned only Ancient Bone (24358) and Vial of Powerful Blood (24295); Vicious Fang/Armored Scale/Vicious Claw/Powerful Venom Sac/ Elaborate Totem and Mystic Crystal/Icy Runestone/Superior Sigil of Nullification carry candidate ids in T2 that must be confirmed against /v2/items before the data is written. Record any correction here.
  • Currency edges cost 0 (Assumptions) — a recipe requiring Coin as a currency edge is a known gap, recorded not handled. None of the four curated Bifrost recipes uses a currency edge.
  • Clover expected value (~3.1 / 10-batch) is community drop-research ignoring forge returns — a conservative approximation living in one data value (T2), trivially re-tunable.

Implementation findings (captured post-run — surprises the briefs above did not anticipate) ​

  • T2 — four candidate ingredient ids were wrong (the F2 name-collision risk, realized). Live /v2/items pinning corrected: Vicious Fang 24356→24357, Icy Runestone 22331→19676, Superior Sigil of Nullification 24658→24572, Mystic Crystal 43772→20799 (candidates resolved to unrelated items). The shipped data + the strengthened guard tests use the corrected ids. This is exactly why the plan mandated live pinning.
  • T4 — two defects in this file's own T4 brief code, fixed during implementation: (1) the P1 #1/SC1 tie→buy sub-case asserted the tie on the root, but R4/P1 #7 force the root's decision to 'craft', so it could never pass — relocated to assert the tie on a non-root child (spec unchanged; R3 and R4 both hold). (2) the totalCraftCost fallback keyed off decision, so a priced root with a poisoned craft path silently reported its buy price instead of null (violating R3) — fixed to key off rootNode.recipes.length, with a new guard test. Both are brief-code defects, not spec defects.
  • T5 — the response DTO models root summary as an optional field on the recursive node schema, not the brief's .and() intersection. The intersection renders as OpenAPI allOf, which splits root/children into separate components and breaks SC12's recursive $ref cycle. Captured in spec.md §Assumptions (R6/R10/SC12 note). The root-only guarantee holds at the type level and at runtime.
  • F12 (monorepo.md) is narrower than documented. T4 imports a runtime value (netSellPrice) from @gw2priory/domain's package root and it loads fine in the SWC-built api (verified: built require(...) returns 850), because pnpm's symlink realpath resolves outside node_modules. Note added to docs/architecture/monorepo.md; flagged for follow-up re-measurement.
  • T7 scope shrank: the no-drift contract guard forced T5 to regenerate openapi.json + the orval web client when the schema changed, so T7 became just the SC12 assertion + a determinism confirmation.