Skip to content

Spec 006 — Static data module ​

Status: implemented Branch: 006-static-data

Status is set by the human, never by the agent. It moves draft → approved → implemented.

Problem ​

The GW2 API client (005) exposes raw items, recipes, prices, and searchRecipes calls, but nothing in the codebase can answer the two questions the legendary planner is built on: "how is item X made?" and "what is item X — can I buy it, or is it a gated grind?". Two obstacles stand in the way:

  1. The Mystic Forge is not a crafting discipline, so no Forge recipe exists in /v2/recipes (domain.md, hard fact). Every legendary combine step — the shared gifts, the weapon gift, the final assembly — is absent from the API and must come from a curated, version-controlled dataset. That dataset does not exist yet.
  2. Nothing classifies a leaf as buyable (priced on the Trading Post) versus a gated input (account-bound, earned over time), which the buy-vs-craft and profit models both depend on.

Without a data layer that unifies these, the recipe-graph resolver (007) has no facts to walk.

User stories ​

Ordered by priority. Each story must be independently testable and shippable — if only P1 ships, there is still something usable.

P1 — Curated Mystic Forge recipes for every Gen 1 legendary ​

As a planner (and the future resolver), I want to look up the Mystic Forge recipe for any Gen 1 legendary or its gifts — with exact ingredient quantities — so that a legendary's crafting tree can be expanded past the point where the GW2 API goes dark.

Independent test: CuratedRecipeService.getRecipe(id) is exercised against the curated packages/legendary-recipes dataset alone; no GW2 API and no other service is involved. The dataset's schema guard test proves integrity.

Acceptance scenarios

  1. Given the id of a Gen 1 legendary weapon (e.g. The Bifrost), when getRecipe is called with it, then it returns a forge recipe whose ingredients are exactly Precursor + Gift of Fortune + Gift of Mastery + the weapon-specific gift, each with its count.
  2. Given the id of a shared gift (e.g. Gift of Fortune), when getRecipe is called with it, then it returns that gift's forge recipe with exact quantities (Gift of Magic + Gift of Might + 77 Mystic Clovers + 250 Globs of Ectoplasm — verified, research.md V1).
  3. Given the id of a Gen 1 precursor (e.g. The Legend), when getRecipe is called with it, then it returns null — the precursor is a buyable leaf, not a curated forge node (Gen 1 precursors have no deterministic forge recipe and are TP-tradeable — verified, research.md V2).
  4. Given the curated dataset with a deliberately malformed entry (e.g. a negative count or a missing source), when the schema guard test runs, then it fails — malformed forge data never ships.
  5. Given the full curated dataset, when legendaries() is called, then it returns all 21 Gen 1 legendary weapons covered (17 land + 3 aquatic + Eternity, R11), and nothing that is not a legendary.
  6. Given the id of Eternity, when getRecipe is called, then it returns a recipe whose ingredients are Sunrise ×1 + Twilight ×1 + Pile of Crystalline Dust ×5 + Philosopher's Stone ×10 (the special no-precursor shape), and both Sunrise and Twilight have their own standard recipes in the dataset (resolvable recursively).

P2 — Classify a leaf as buyable or gated ​

As a planner (and the future profit model), I want each item classified as buyable (obtainable on the Trading Post) or gated (account-bound / earned, a time cost not a gold cost) so that cost resolution knows what to price and what to treat as player-supplied.

Independent test: ItemDataService.classify is fed GW2 item payloads (from the 005 fixtures / a mocked Gw2Service) and asserted, with no forge or station data involved.

Acceptance scenarios

  1. Given the item payload for each of the five known gated inputs (Gift of Exploration, Gift of Battle, Bloodstone Shard, Obsidian Shard, Mystic Clover), when classify is called, then each is labelled gated (all five carry AccountBound; the naive "any binding flag" reading is refuted — research.md V4).
  2. Given the item payload for a known Trading Post staple (e.g. Glob of Ectoplasm), when classify is called, then it is labelled buyable.
  3. Given a set of ids where some do not exist, when the item metadata is fetched, then the missing ids are silently omitted (inherited 206/404 client semantics) and no error is thrown.

P3 — Resolve all station recipes for a crafted ingredient ​

As a planner (and the future resolver), I want to resolve all discipline/station recipes that produce a given crafted ingredient, by output item id, so that the buy-vs-craft optimizer can pick the cheapest craft path when more than one exists.

Independent test: StationDataService.getRecipes is exercised against a mocked Gw2Service returning the 005 recipe-search + recipe fixtures; asserted without live API access.

Acceptance scenarios

  1. Given the output item id of a station-crafted ingredient with one recipe, when getRecipes is called, then it returns a one-element array with that station recipe (output, count, ingredients with counts) sourced via searchRecipes({ output }) then recipes.
  2. Given an output item id with multiple station recipes (e.g. Bolt of Damask, id 46742 → 2 recipes), when getRecipes is called, then it returns all of them — none is dropped.
  3. Given an output item id with no station recipe (e.g. a raw drop), when getRecipes is called, then it returns an empty array [] — the item is a leaf.

Requirements ​

  • R1 — The curated legendary-recipe dataset lives in a shared package, packages/legendary-recipes, typed and validated by a Zod schema, with the same workspace:* wiring as packages/domain. (Generic name, not mystic-forge: the same package will later hold back-item, trinket, and rune recipes — all reuse the shared gifts curated here — and eventually non-forge nodes, research.md F6/F8/F10.)
  • R2 — Every curated CuratedRecipe carries: outputItemId; outputCount; a method enum (every 006 entry = 'mystic-forge'; the field reserves 'discipline' | 'vendor' | 'collection' for later non-forge types, F9); an ingredients[] list; and a source citation (wiki URL) for the maintenance story. Each ingredient is a discriminated union — { kind: 'item'; itemId; count } (all 006 emits) — with { kind: 'currency'; currencyId; count } reserved for armor (F9). All quantities are exact. (Forward-compatibility seams only: no non-forge recipe is curated and no currency logic is built in 006 — see Out of scope.)
  • R3 — A schema guard test fails on any structurally invalid curated entry (negative/zero counts, missing source, unknown method, duplicate outputItemId, non-integer id). This is domain.md's "changed only deliberately."
  • R4 — CuratedRecipeService.getRecipe(id) returns the curated CuratedRecipe for id, or null; it performs no I/O. CuratedRecipeService.legendaries() returns the Gen 1 legendary outputs.
  • R5 — ItemDataService fetches item metadata through Gw2Service (never the GW2 API directly, per stack.md) and exposes both: metadata(ids) → ItemMeta[] (id, name, type, rarity, flags[], vendor_value — for the graph's display and classification), and classify(item) → 'buyable' | 'gated' derived from flags[] (R10). Missing ids are omitted, not errored (inherited 206/404 semantics).
  • R6 — StationDataService.getRecipes(outputItemId) → Recipe[] resolves all station recipes for an output via Gw2Service (searchRecipes({ output }) → recipes), returning every recipe (0..N); an empty array means the item is a leaf. (Plural because recipes/search returns number[] and a single output can have multiple recipes — e.g. Bolt of Damask — which the buy-vs-craft optimizer must min over.)
  • R7 — All three services are provided by a single StaticDataModule and depend only on the curated package and/or Gw2Service. Consumers compose them explicitly; the module hides no merge.
  • R8 — No new external process (no Postgres, no Redis). Static data is served from the curated package and the 005 client's existing in-memory caches.
  • R9 — Item ids for the gated inputs, shared gifts, weapon gifts, precursors, and legendary outputs used by the dataset must be real GW2 item ids. (Verified against /v2/items and the GW2 Wiki — research.md V5.)
  • R10 — The classifier keys on the AccountBound flag specifically (gated ⟺ flags[] includes AccountBound); SoulBindOnUse / NoSell / AccountBindOnUse alone do not imply gated. (Refined from the naive "any binding flag" reading — research.md V4/F2, which also records the /v2/commerce/prices cross-check as the authoritative buyable signal.)
  • R11 — Scope of the curated set: all 21 Gen 1 legendary weapons — 17 land + 3 aquatic (Frenzy, Kamohoali'i Kotaki, Kraitkin) + Eternity. (Human decision, transcribed 2026-07-27; resolves the clarification raised in research.md V3.) Eternity needs no schema variant: its recipe uses the same general ingredients[] list (Sunrise ×1 + Twilight ×1 + 5 Pile of Crystalline Dust + 10 Philosopher's Stone); because Sunrise and Twilight are themselves legendary outputs in the dataset, a consumer resolves them recursively via getRecipe.

Mark anything unresolved inline rather than assuming an answer. Two markers, split by who can answer:

  • [NEEDS CLARIFICATION: specific question] — only the human can answer.
  • [NEEDS VERIFICATION: specific question] — only reality can answer, resolved in research.md.

Success criteria ​

Measurable and technology-agnostic — outcomes, not implementation.

  • SC1 — For every in-scope Gen 1 legendary weapon (all 21, R11), getRecipe returns a complete forge recipe with each ingredient carrying an exact count and the recipe carrying a source: the 20 standard weapons expand to Precursor + Gift of Fortune + Gift of Mastery + Gift of <weapon>; Eternity expands to Sunrise + Twilight + 5 Pile of Crystalline Dust + 10 Philosopher's Stone. (Set = 21 (17 land + 3 aquatic + Eternity, R11); exact recipes available on the wiki — research.md V1/V3.)
  • SC2 — The curated dataset is schema-valid, and a deliberately malformed entry causes a test to fail.
  • SC3 — Each of the five known gated inputs classifies as gated, and a known Trading Post staple as buyable, via the AccountBound-flag rule (R10). (Confirmed for the AccountBound-specific rule; the naive "any binding flag" reading is refuted — research.md V4/F2 — with /v2/commerce/prices membership as the authoritative cross-check.)
  • SC4 — A leaf (no known recipe, including a Gen 1 precursor) yields no recipe from either source: CuratedRecipeService.getRecipe(id) returns null and StationDataService.getRecipes(id) returns [].
  • SC5 — A station-crafted ingredient resolves to all its station recipes by output id through the client (an output with several recipes returns them all); a raw drop resolves to [].
  • SC6 — Zero files are written under docs/superpowers/ (the existing repo test still passes).

Out of scope ​

  • Postgres / any persistence beyond in-memory — its own spec when the caching story earns it.
  • Cost, price, and profit math — the resolver / optimizer (007+).
  • Days / time-to-acquire estimation for gated inputs — a later spec (time optimizer / legendary page). 006 exposes the exact gated quantities that make it possible; it does not compute it.
  • The recipe-graph resolver / tree walking — spec 007. 006 serves single-item facts only.
  • Gen 2 / Gen 3 legendaries and non-weapon legendaries (armor, back, trinkets, runes/sigils/relic) as curated content. The schema is deliberately future-proofed for them (R1/R2 seams, research.md F6–F10), but 006 curates only Gen 1 weapons.
  • Non-forge ingredient handling — the method values 'discipline' | 'vendor' | 'collection' and the currency ingredient kind are schema seams only (R2); 006 curates no such recipe and builds no currency/vendor logic. That machinery belongs to the eventual armor/trinket spec (F9).
  • Precursor-crafting collections (the deterministic HoT precursor path). Gen 1 precursors are treated as buyable leaves.

Assumptions ​

  • The 005 Gw2Service (items, recipes, prices, searchRecipes) is available for DI and its 206/404/partial semantics and caching hold as documented in docs/architecture/gw2-api.md.
  • The curated Forge quantities are sourced from the current live GW2 Wiki and are a maintenance point that will drift with game patches; the source field records provenance for that upkeep.
  • Classification is binary (buyable vs gated) at the leaf level, per domain.md; non-leaf items are not classified by this module (the resolver decides leaf-ness).

Traceability ​

Each acceptance scenario and success criterion maps to a named test. Paths: cur = apps/api/src/static-data/curated-recipe.service.test.ts, item = …/item-data.service.test.ts, stn = …/station-data.service.test.ts, mod = …/static-data.module.test.ts, pkg = packages/legendary-recipes/src/index.test.ts.

CriterionTest
P1 #1cur — "P1 #1: getRecipe(30698) returns The Bifrost's assembly"
P1 #2cur — "P1 #2: getRecipe(19626) returns Gift of Fortune with exact counts"
P1 #3cur — "P1 #3 / SC4: getRecipe returns null for a leaf ingredient or an unknown id"
P1 #4cur — "P1 #4: legendaries() returns all 21"
P1 #5pkg — "P1 #5: legendaryOutputIds has exactly 21 entries"
P1 #6cur — "P1 #6: getRecipe(30689) returns Eternity's recipe"; pkg — "P1 #6: Eternity's recipe is Sunrise + Twilight + 5 Pile of Crystalline Dust + 10 Philosopher's Stone"
P2 #1item — "SC3 / P2 #1: the five gated inputs classify as 'gated'"
P2 #2item — "P2 #2: a Trading Post staple classifies as 'buyable'"
P2 #3item — "P2 #3: metadata maps fields and omits missing ids"
P3 #1stn — "P3 #1: a single-recipe output returns a one-element array with mapped fields"
P3 #2stn — "P3 #2: an output with multiple recipes returns them all (Bolt of Damask 46742)"
P3 #3stn — "P3 #3 / SC4 / SC5: an output with no recipe returns [] without calling recipes"
SC1pkg — "SC1: every legendaryOutputId resolves to a recipe", "SC1: each standard legendary recipe contains Gift of Fortune + Gift of Mastery plus exactly two other item ingredients", "SC1: no precursor item id appears as a recipe outputItemId"
SC2pkg — "SC2: loadDataset() parses the committed dataset", and four SC2: malformed-rejection tests (count 0 / missing source / bad method / duplicate outputItemId)
SC3item — "SC3 / P2 #1: …gated", "SC3: The Legend (precursor) classifies as 'buyable' despite binding flags", "keys on 'AccountBound' specifically, not 'AccountBindOnUse' alone"
SC4cur — "P1 #3 / SC4: getRecipe returns null…"; stn — "P3 #3 / SC4 / SC5: …returns []"
SC5stn — "P3 #3 / SC4 / SC5: an output with no recipe returns [] without calling recipes"
SC6tests/workflow/repo-invariants.test.ts — the existing docs/superpowers/ zero-write invariant (all 006 artifacts live under specs/006-static-data/)
R7mod — "R7: …resolves all three services as singletons", "R7: …exports all three services to a consuming module"