An interactive wiki for EverQuest Legends (EQL). Six tools share one faction-aware graph of zones, spells, classes, quests, items, and tradeskills, each surfacing a direct, filtered answer instead of a wiki page you'd have to read in full.
- Items: finds anything you need, spells included, and every vendor that actually sells it, ranked by travel distance from your current zone and checked against your race, primary class, and deity so a zone that won't deal with you (or would kill you on sight) sorts to the bottom instead of hiding the item.
- Maps: shortest path between any two zones (boat/translocator hops flagged), plus the destination's own map and mob levels.
- Classes: everything a class has access to (spells, class-defining abilities, stances, invocations, Alternate Advancements, and relevant quests), filterable by up to three classes at once.
- Quests: quest lines worth running, their rewards, and the factions they shift.
- Leveling Guide: milestones worth knowing before you hit them.
- Tradeskills: a trade's leveling recipes and who sells the supplies.
- Norraph is a reference, not a tool. The interactive planning and filtering exist to surface existing EQL content, not to be an end in themselves.
- Accuracy over user experience and completeness. Both matter, but when they conflict, correctness wins.
- The data is the product. A well-structured, shareable dataset (
data/graph.json) is what Norraph ships; the frontend is a byproduct of that data being useful.
Requires Bun >= 1.0.
bun install
bun run dev # → http://localhost:4321
bun run typecheck # TypeScript validationZone, spell, class-ability, quest, item, and recipe data all live in one graph (data/graph.json). Items (the planner):
- Finds goods of the selected type matching your class, with spells also matching your level range. Spell is a real type alongside computed item types like Armor, Weapon, Food, and Tradeskill Materials, not a separate system.
- Traces which NPCs sell each matching good and which zones those NPCs are in, attaching every such zone/vendor pair to that good as an "offer" (a good with no vendor at all, like a drop-only item, still shows up with an empty offer list)
- Computes hop distance from your current zone to each offer's zone via BFS over zone adjacency edges
- Resolves faction standing per zone from three dimensions (race, primary class, deity), taking the worst of the three
- Sorts each good's own offers by faction then hop distance, with KOS and won't-sell zones sorted to the bottom
Maps walks the same zone adjacency graph via BFS for a plain point-to-point path, with no faction or spell context. Classes reads spell/stance/invocation/AA/ability/quest nodes directly, filtered by whichever 1-3 classes are selected. Quests and Tradeskills read quest and recipe nodes directly, filterable by class, zone, and level.
| Standing | Meaning | Cause |
|---|---|---|
safe |
Welcome | Good race/class/deity alignment |
neutral |
No issues | Outdoor zones, neutral combos |
wont_sell |
Merchants refuse | Evil class or deity in a good city (or vice versa) |
kos |
Guards attack | Wrong race for that city |
"Primary Class" and "Shopping For" are separate inputs because secondary/tertiary classes don't carry faction penalties in EQ; only your primary identity matters for access.
1,082 spells, 112 zones, 131 Alternate Advancements, 78 tradeskill recipes, 1,363 quests (across 69 quest groups), 1,318 items, 202 spell lines, 31 class-defining abilities, 9 stances, and 9 invocations, all cross-linked to the 6,380 NPCs and zones that carry them. See decisions/ for how each of these was scraped or derived, and why stances/invocations/AAs cover a broader class roster than spells do.
Updating the graph: data/graph.json is never hand-edited or regenerated wholesale. Changes go through a one-off script in scripts/ (e.g. bun run scripts/normalize-skill-names.ts) that reads the current file, transforms it, and writes it back, reusing scripts/graph-lib.ts's helpers. Once run and committed, the script can be deleted — git history is the record, not a permanently kept folder. src/graph.ts is the only code allowed to read/write the graph outside these scripts.
If this app is deployed as a Lambda (see "Deploying" below), the deployment has its own bundled snapshot of data/graph.json; it does not read live from this repo. Running a migration locally changes nothing for a live deployment until that deployment is rebuilt and redeployed.
The levelMin/levelMax params take a range; class/spells/zones take comma-separated lists.
| Endpoint | Purpose |
|---|---|
GET /api/plan?type=Spell&class=shaman,druid&from=Halas&race=barbarian&primaryClass=shaman&deity=the+tribunal |
Matching goods (type defaults to Spell), each with its own ranked vendor offers |
GET /api/item-types |
Every real good type (Spell plus computed item types like Armor, Weapon, Food) |
GET /api/route?from=Halas&to=Kelethin |
Point-to-point route, boat hops flagged |
GET /api/zone-distances?from=zone:east-freeport&zones=zone:a,zone:b |
Hop distance from one zone to several |
GET /api/spells?class=shaman&levels=9 |
List spells for a class/level (omit class for every class) |
GET /api/spells/search?q=spirit |
Spell name autocomplete |
GET /api/spell/:id/vendors |
NPCs and zones selling a spell |
GET /api/spell-lines |
All spell lines |
GET /api/zones |
All zones |
GET /api/npcs?zone=zone:oasis-of-marr |
NPCs located in a zone |
GET /api/items?ids=item:a,item:b |
Item details for the given ids |
GET /api/classes |
Classes with purchasable spell data |
GET /api/classes/abilities |
Classes with stance/invocation/AA/ability data (a separate, broader roster; see decisions/) |
GET /api/stances-invocations?class=warrior,paladin |
Stances and invocations for the given classes |
GET /api/aa?class=warrior,paladin |
Alternate Advancements, grouped by category (general/archetype/class/special) |
GET /api/abilities?class=rogue,paladin |
Class-defining special abilities (Rogue poison disciplines, Backstab, Kick, Lay Hands, etc.) |
GET /api/quests?class=warrior,paladin&zone=zone:ak-anon&levelMin=9&levelMax=11 |
Quests matching the given filters |
GET /api/quest-groups?class=paladin&zone=...&levelMin=...&levelMax=... |
Quest groups matching the given filters |
GET /api/tradeskills |
Distinct tradeskill names |
GET /api/recipes?tradeskill=Brewing |
Recipes for a tradeskill |
GET /api/tradeskill-vendors?tradeskill=Brewing&zone=zone:east-freeport |
NPCs selling that trade's ingredients |
GET /api/graph |
Full Cytoscape-format graph |
GET /api/stats |
Node/edge counts |
| Endpoint | Purpose |
|---|---|
POST /api/spell |
Add a spell |
POST /api/npc |
Add an NPC |
POST /api/zone |
Add a zone |
POST /api/sells |
Link NPC → spell |
POST /api/connects |
Link zone ↔ zone |
DELETE /api/node/:id |
Remove a node and its edges |
These have no auth and only exist in src/server.ts (the local Bun server); see decisions/ for why they're never bundled into the Lambda build.
bun run dev: the full app (API + static frontend) via Bun, for local use.bun run build:lambda: packagessrc/lambda.ts(a Lambda handler wrapping the same read-only route tableserver.tsuses) plusdata/graph.jsonintodist-lambda.zip. Whatever infra deploys this app owns all routing/domain/path/CDN decisions; none of that lives here.src/lambda.tsexpects to receive request paths already relative to its own root; stripping any external path prefix is the deploying infra's job.
The frontend (public/*) doesn't assume it's served from a domain root either: all API calls and inter-page links are relative paths, not /api/... absolutes, so the same static files work whether served from / or from underneath some path prefix.