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-developmentapplies inside every task: no production code before a failing test that demands it. Reach forsuperpowers:systematic-debuggingon 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(theCuratedRecipeSchema.outputCountline) - Modify:
apps/api/src/recipe-graph/recipe-graph.schema.ts(theRecipeOptionSchema.outputCountline) - Test:
packages/legendary-recipes/src/index.test.ts(curated schema), and a newapps/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:
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 testExpected:SC9: … fractional outputCount validatesFAILS (outputCountis 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 insideCuratedRecipeSchema:
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:
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.schemaExpected: FAIL —RecipeOptionSchema.outputCountis.int().[ ] GREEN: in
apps/api/src/recipe-graph/recipe-graph.schema.ts, change the one line inRecipeOptionSchema:
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.tsa guard over the four new recipes (use the ids confirmed above; constants shown with the research-confirmed values):
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 testExpected: the four new tests FAIL (recipes absent). ExistingSC1/P1 #5guards still pass — note thatP1 #5assertslegendaryOutputIdshas 21 entries; the new recipes add outputs, not legendaries, so leavelegendaryOutputIdsuntouched.[ ] GREEN: append the four recipes to
packages/legendary-recipes/data/gen1-weapons.json'srecipesarray (ids from step 1; T6-mat ids below are the candidates — replace any the live check corrected):
{
"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 thesuperRefineduplicate 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):
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 repopnpm 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:
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): createpricing.test.tswith fixture helpers and the first test:
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 pricingExpected: FAIL —./pricinghas no exports yet.[ ] GREEN (minimal
collectPricedIds): createpricing.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→collectPricedIdstest 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):
// 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 (priceGraphnot implemented). Confirm each fails for the right reason (missing export), not a fixture typo.[ ] GREEN — implement the engine in
pricing.ts(append belowcollectPricedIds):
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 withsuperpowers:systematic-debugging— the hand-computed value is the contract.[ ] Confirm teeth: flip the tie rule (
<=→<) and watchP1 #1's tie assertion fail; restore. Break the roll-up (use crafted 100 instead of child.unitCost) and watchP1 #2fail; restore.[ ] Typecheck:
pnpm --filter @gw2priory/api typecheck— noany, 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:
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(keepRecipeEdgeSchema,RecipeOptionSchemawith the T1 fractionaloutputCount, andGraphNodeSchemaas-is):
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
decisionfrom 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(importGw2ModulesoGw2Serviceinjects) - Test:
apps/api/src/recipe-graph/recipe-graph.controller.test.ts(rewrite the body assertions to the enriched shape; add the mockedGw2Service)
Interfaces consumed: priceGraph, collectPricedIds, PriceMap from ./pricing; Gw2Service from ../gw2/gw2.service.
- [ ] RED — update the existing controller test. The current test asserts
res.json()equalsprojectTree(graph); that body is now enriched. RewritebuildAppto also provide a mockedGw2Service, and assert the enriched body + the single batchedpricescall. Replace theT10describe block's first test and add the batching test (note: the guard tests for 400/404 keep working — extendbuildApp):
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.controllerExpected: the enriched-body test and the batching test FAIL (controller still returns bareprojectTree, noGw2Serviceprovided → DI error until the controller is updated).[ ] GREEN — rewrite the controller
recipe-graph.controller.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:
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 —Gw2ModuleexportsGw2Service, so the real controller resolves).[ ] Confirm teeth: make the controller call
pricesper-node (map over ids), watch "one batched prices call" fail ontoHaveBeenCalledTimes(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.tsan assertion that theGET /recipe-graph/{itemId}response documents the enriched shape. Reuse the file's existingbuildOpenApiDocument()helper and its typedisReferenceObject/ component-schema derivation style (do not reach past the package's public types — the file already models this withoutany):
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-openapiExpected: 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 untilopenapi.jsonis regenerated.)[ ] GREEN — regenerate the committed document. The emitter is the built CLI (
generate:openapirunsnode dist/generate-openapi.cli.js), so build first:
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 regeneratedopenapi.json).[ ] Regenerate the web client: run orval against the new document:
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 statusshows 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);metadatareturns a name for each id so the root-null404 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:
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 bifrostExpected: FAIL until the fixture builders (buildRealService,bifrostFixturePrices) are written; then it exercises real resolution over T2's curated data.[ ] GREEN — write
stationFixture(),itemFixture(), andbifrostFixturePrices()(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,totalCraftCostis 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/30698against the live GW2 API; observe the realsummary.totalCraftCost/netSell/profitand record the figure (an observation, not an assertion — spec §Test strategy). Sanity-check it against research V5's live numbers (Bifrostbuys ≈ 1250g,netSell ≈ 1062gon 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, thensuperpowers:receiving-code-review. - [ ] Status transition — on the human's decision, transcribe
spec.mdstatusapproved→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 thanrecipe-graph.bifrost.test.ts(plan File Structure). - Response-schema tests live in a dedicated
recipe-graph.schema.test.ts(spec Traceability'ssch), 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.
- Bifrost integration test →
- 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/itemsbefore the data is written. Record any correction here. - Currency edges cost 0 (Assumptions) — a recipe requiring Coin as a
currencyedge 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/itemspinning corrected: Vicious Fang24356→24357, Icy Runestone22331→19676, Superior Sigil of Nullification24658→24572, Mystic Crystal43772→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/SC1tie→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) thetotalCraftCostfallback keyed offdecision, so a priced root with a poisoned craft path silently reported its buy price instead ofnull(violating R3) — fixed to key offrootNode.recipes.length, with a new guard test. Both are brief-code defects, not spec defects. - T5 — the response DTO models root
summaryas an optional field on the recursive node schema, not the brief's.and()intersection. The intersection renders as OpenAPIallOf, which splits root/children into separate components and breaks SC12's recursive$refcycle. Captured inspec.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: builtrequire(...)returns850), because pnpm's symlink realpath resolves outsidenode_modules. Note added todocs/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.