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.
- Vendors: given your race, primary class, deity, and current zone, ranks destinations by what a vendor sells (spells, armor, tradeskill supplies) vs. travel distance, filtering out zones where you'd be killed on sight or refused service.
- 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). Vendors (the planner):
- Finds vendor-sold goods of the selected type (spells matching your class and level range, or items of a category like Armor/Tradeskill Supplies matching your class)
- Traces which NPCs sell those goods and which zones those NPCs are in
- Computes hop distance from your current zone via BFS over zone adjacency edges
- Resolves faction standing from three dimensions (race, primary class, deity), taking the worst of the three
- Ranks zones by
goods_available / hops, 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 numbered, run-once migration in migrations/ (e.g. bun run migrations/012-normalize-skill-names.ts) that reads the current file, transforms it, and writes it back; see any existing migration for the pattern. src/graph.ts is the only code allowed to read/write it outside of migrations.
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?class=shaman,druid&levelMin=5&levelMax=7&from=Halas&race=barbarian&primaryClass=shaman&deity=the+tribunal |
Ranked zone recommendations |
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.