Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A web companion site for EverQuest 2 (TLE), with a Discord bot for spot checks (
| `backend/census/item_parser.py` | Item data parsing helpers (parse_item, parse_stats, parse_effects, parse_flags, etc.) extracted from client.py |
| `backend/eq2db/spells.py` | Local SQLite spell catalogue — `SpellCatalogue` class (shared `catalogue` instance): strip_roman, unique_highest_entries, load_blocklist, find_by_ids, find_by_crc, spell_to_row, upsert_spells. Every eq2db module follows this convention: pure helpers are staticmethods with full bodies in the class, DB reads are instance methods on a per-instance `path`, tests construct `XCatalogue(tmp_db)` and patch the instance, `tests/conftest.py` re-points `catalogue.path`. All seven catalogues inherit `BaseCatalogue` (`backend/db_catalogue.py`) — `init_db` is a template method (connection preamble + commit in the base); subclasses implement `_create_schema` (+ optional `_post_init` backfills), set `FOREIGN_KEYS`/`CREATE_META` class flags, and override `clear_caches` when they hold caches. The same architecture covers the other SQLite stores: `CensusStore` (census/store.py), `ParsesStore` (server/parses/db.py), and the seven users.db domain stores (`XStore(AsyncStoreBase)` in server/db/ — schema stays with the package init_db orchestrator; the `backend.server.db` facade re-exports bound methods so `users_db.fn(...)` call sites are unchanged; conftest re-points every store via `users_db.ALL_STORES`). |
| `backend/eq2db/recipes.py` | Local SQLite recipe catalogue (~70k rows) — `RecipeCatalogue`: find_by_id, find_by_name, find_by_spell, find_spells_by_tier, find_by_output_id. Secondary components stored as JSON array. |
| `backend/eq2db/aas.py` | Local SQLite AA catalogue (aas.db — committed like classes.db). Tables `aa_trees` (tree_type + max_points precomputed at build) + `aa_nodes` + `aa_limits` (per-xpac caps from aa_limits.json). Accessors: `load_tree_index`, `tree_node_costs`, `tree_max_points`, `total_max_points`, `get_tree`, `xpac_limits` (short-code alias tolerant); `detect_tree_type` is build-time only. Rebuild: `scripts/download_aa_trees.py` (tree JSONs are local intermediates, gitignored) then `scripts/build_aas_db.py`; commit the db. |
| `backend/eq2db/aas.py` | Local SQLite AA catalogue (aas.db — committed like classes.db). Tables `aa_trees` (tree_type + max_points precomputed at build) + `aa_nodes` (incl. per-node unlock rules: points_to_unlock, classification_points_required, first_parent_id/tier) + `aa_limits` (per-xpac caps + `visible_rows` era node curation from aa_limits.json — pre-SF xpacs hide class rows 5-6 / subclass rows 16+19, verified against live Wuoshi census). Accessors: `load_tree_index`, `tree_node_costs`, `tree_max_points`, `total_max_points`, `get_tree`, `xpac_limits` (short-code alias tolerant; returns aa_cap + unlocked_trees + visible_rows); `detect_tree_type` is build-time only. Rebuild: `scripts/download_aa_trees.py` (tree JSONs are local intermediates, gitignored) then `scripts/build_aas_db.py`; commit the db. Era filtering surfaces via `/api/aa/config`.`visible_rows` → `filterTreeForEra` in CharacterAAsTab. |
| `backend/eq2db/zones.py` | Local SQLite zone catalogue (~1124 rows) — `ZoneCatalogue` over frozen dataclass models (`Zone`, `ZoneEncounter`, `ZoneEncounterMob`, `FeaturedRaid*`). Tables: `zones`, `zone_types`, `zone_aliases`, `zone_encounters`, `zone_encounter_mobs` + the featured-raid trio. Lookups: `find_by_name`, `list_by_expansion`, `list_by_event`, `list_by_type`, `list_bosses_for_zone`, `find_zones_by_boss`. Zone metadata from `scripts/dev/eq2_zones.cleaned.json`; rebuild via `scripts/build_zones_db.py`. Boss data is curator-managed in-place. |
| `backend/census/wikitext_md.py` | MediaWiki wikitext → markdown converter (mwparserfromhell). Handles EQ2i templates, wikilinks, nested lists, headings, bold/italic. Used by the raid scraper. |
| `backend/eq2db/raids.py` | Local SQLite raid-strategy catalogue — `RaidCatalogue`: `raid_zones` + `raid_encounters` (markdown blob per encounter) + `raid_encounter_revisions` + ACT trigger/spell-timer tables (helpers folded in from the former raids_act.py). `SOURCE_*` provenance tokens mirrored as class attributes. `DB_RAIDS_PATH` env var. |
Expand All @@ -31,6 +31,7 @@ A web companion site for EverQuest 2 (TLE), with a Discord bot for spot checks (
| `backend/server/api/server.py` | `GET /api/server` — bootstraps the frontend with the active server's world, display name, max_level, current_xpac, launch_dt, and the full public server list |
| `backend/server/cache.py` | TTLCache with stale-while-revalidate: character_cache, guild_cache, claim_cache. Character and guild read paths serve from `census_store` first and never block on Census. |
| `backend/server/api/aa.py` | GET /api/character/{name}/aas — AA profile list with per-tree data |
| `backend/server/api/aa_plans.py` | AA planner saved builds: CRUD (owner-scoped in SQL) + read-only share by always-minted slug (`GET /api/aa/plan/{slug}`, page `/aa-plan/{slug}`). Store `backend/server/db/aa_plans.py` (users.db). Rule legality is the frontend engine's job (`frontend/src/pages/aaplanner/engine.ts` — point-cost-weighted self-exclusive thresholds, parent ranks, flat 100/tree cap, separate tradeskill pool, no-stranding removals); the server validates structurally. Planner UI = "Planner" mode on the character AA tab (`aaplanner/PlannerMode.tsx`, interactive AATree: click spend / right-click refund). |
| `backend/server/api/character/gear_sets.py` | GET /api/character/{name}/gear-sets — saved in-game equipment sets (Census `adventure_sets`), store-first SWR mirroring aa.py; feeds the character-sheet set pills + the compare per-side set picker. Each set carries `stat_deltas` (set − worn, additive stats + active item-set bonuses, from items.db `item_stats`) computed in sibling `stat_deltas.py` — the sheet shows approximated stats when a set is selected |
| `backend/server/api/characters.py` | GET /api/characters/search — character name search |
| `backend/server/api/guild_officer.py` | Officer claim-review endpoints; imports _officer_chars, _roster_rank_map from guild.py |
Expand Down
21 changes: 16 additions & 5 deletions backend/eq2db/aas.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ def _create_schema(self, conn: sqlite3.Connection) -> None:
conn.execute(_SQL["schema_aa_nodes"])
conn.execute(_SQL["schema_aa_limits"])
conn.executescript(_SQL["indexes_aas"])
# Idempotent for DBs built before the era-row curation shipped
# (2026-07: per-xpac node visibility for the AA planner).
self._apply_migrations(conn, [_SQL["migrate_aa_limits_visible_rows"]])

def _query(self, name: str, params: tuple = ()) -> list:
"""Run one read query by its _SQL block name; [] when the DB is
Expand Down Expand Up @@ -211,9 +214,12 @@ def get_tree(self, tree_id: int) -> dict | None:
return self._trees[tree_id]

def xpac_limits(self, xpac: str) -> dict | None:
"""``{"aa_cap": int, "unlocked_trees": [tree_type, ...]}`` for an
expansion. Tolerates short codes ("DoV" → "Destiny of Velious") and
case/whitespace. None when unknown (caller decides the fallback)."""
"""``{"aa_cap": int, "unlocked_trees": [tree_type, ...],
"visible_rows": {tree_type: [ycoord, ...]}}`` for an expansion.
``visible_rows`` lists only PARTIAL trees — a tree type absent from
the map shows all rows. Tolerates short codes ("DoV" → "Destiny of
Velious") and case/whitespace. None when unknown (caller decides the
fallback)."""
if xpac not in self._limits:
self._limits[xpac] = self._resolve_limits(xpac)
return self._limits[xpac]
Expand Down Expand Up @@ -243,7 +249,11 @@ def _resolve_limits(self, xpac: str) -> dict | None:
unlocked = json.loads(row[1] or "[]")
except json.JSONDecodeError:
unlocked = []
return {"aa_cap": int(row[0]), "unlocked_trees": unlocked}
try:
visible_rows = json.loads(row[3] or "{}")
except (json.JSONDecodeError, IndexError):
visible_rows = {}
return {"aa_cap": int(row[0]), "unlocked_trees": unlocked, "visible_rows": visible_rows}

# ── Build (scripts/build_aas_db.py) ──────────────────────────────────────

Expand Down Expand Up @@ -317,13 +327,14 @@ def upsert_tree(self, conn: sqlite3.Connection, tree_id: int, tree_data: dict) -

def upsert_limits(self, conn: sqlite3.Connection, xpac: str, entry: dict) -> None:
"""Insert/replace one expansion's AA limits (from aa_limits.json's
``{xpac: {aa_cap, unlocked_trees, notes}}`` entries)."""
``{xpac: {aa_cap, unlocked_trees, visible_rows, notes}}`` entries)."""
conn.execute(
_SQL["upsert_limit"],
(
xpac,
_as_int(entry.get("aa_cap", 0)),
json.dumps(entry.get("unlocked_trees", [])),
json.dumps(entry.get("visible_rows", {})),
entry.get("notes"),
),
)
Expand Down
15 changes: 11 additions & 4 deletions backend/eq2db/aas.sql
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,24 @@ CREATE TABLE IF NOT EXISTS aa_limits (
xpac TEXT PRIMARY KEY, -- canonical expansion name
aa_cap INTEGER NOT NULL DEFAULT 0, -- adventure AA cap
unlocked_trees TEXT NOT NULL DEFAULT '[]',-- JSON array of tree_type keys
-- JSON {tree_type: [ycoord, ...]} — rows visible in this era. A tree type
-- absent from the map shows ALL rows (only partial trees are listed).
visible_rows TEXT NOT NULL DEFAULT '{}',
notes TEXT
);

-- :name migrate_aa_limits_visible_rows
ALTER TABLE aa_limits ADD COLUMN visible_rows TEXT NOT NULL DEFAULT '{}';

-- :name select_limit
SELECT aa_cap, unlocked_trees, notes FROM aa_limits WHERE xpac = ?;
SELECT aa_cap, unlocked_trees, notes, visible_rows FROM aa_limits WHERE xpac = ?;

-- :name select_limit_xpacs
SELECT xpac FROM aa_limits;

-- :name upsert_limit
INSERT INTO aa_limits (xpac, aa_cap, unlocked_trees, notes)
VALUES (?, ?, ?, ?)
INSERT INTO aa_limits (xpac, aa_cap, unlocked_trees, visible_rows, notes)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(xpac) DO UPDATE SET
aa_cap=excluded.aa_cap, unlocked_trees=excluded.unlocked_trees, notes=excluded.notes;
aa_cap=excluded.aa_cap, unlocked_trees=excluded.unlocked_trees,
visible_rows=excluded.visible_rows, notes=excluded.notes;
154 changes: 146 additions & 8 deletions backend/server/api/aa.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ class AAConfigResponse(BaseModel):
aa_cap: int # adventure AA cap (tradeskill excluded)
tradeskill_aa_cap: int = 0 # Σ max points of the unlocked tradeskill trees
unlocked_tree_types: list[str]
# Era-partial trees: tree_type → the ycoord rows that exist in this xpac.
# A tree type absent from the map shows ALL rows (see aa_limits.json).
visible_rows: dict[str, list[int]] = {}


class AANodeResponse(BaseModel):
Expand All @@ -67,6 +70,12 @@ class AANodeResponse(BaseModel):
points_to_unlock: int
title: str = ""
spellcrc: int = 0
# Unlock prerequisites (the planner's validation inputs). Thresholds count
# AA POINTS spent (rank × pointspertier), not ranks.
classification_points_required: int = 0 # points in this node's line
points_global_to_unlock: int = 0 # total adventure AA spent
first_parent_id: int | None = None # required prior skill (node_id)
first_parent_required_tier: int | None = None # ranks needed in it


class AATreeResponse(BaseModel):
Expand Down Expand Up @@ -102,15 +111,23 @@ class CharAAsResponse(BaseModel):


@router.get("/aa/config", response_model=AAConfigResponse)
async def get_aa_config() -> AAConfigResponse:
"""Return the current xpac's AA cap and which tree types are unlocked.
async def get_aa_config(xpac: str | None = None) -> AAConfigResponse:
"""Return an expansion's AA cap and which tree types are unlocked.
Defaults to the active server's current xpac; the AA planner's era
dropdown passes ``?xpac=`` to plan under a different era's rules
(alias-tolerant — "DoV" and "Destiny of Velious" both resolve).
All from aas.db (aa_limits + the precomputed per-tree max_points)."""
xpac = current_server().current_xpac or ""
limits = aa_db.xpac_limits(xpac)
if limits is None:
if xpac:
_log.warning("[aa] current_xpac %r has no aa_limits entry — AA cap reads 0", xpac)
limits = {"aa_cap": 0, "unlocked_trees": []}
if xpac is not None:
limits = aa_db.xpac_limits(xpac)
if limits is None:
raise HTTPException(status_code=404, detail=f"Unknown expansion: {xpac}")
else:
xpac = current_server().current_xpac or ""
limits = aa_db.xpac_limits(xpac)
if limits is None:
if xpac:
_log.warning("[aa] current_xpac %r has no aa_limits entry — AA cap reads 0", xpac)
limits = {"aa_cap": 0, "unlocked_trees": [], "visible_rows": {}}
unlocked = limits["unlocked_trees"]
# Tradeskill cap = the total the unlocked tradeskill trees add up to
# (Σ maxtier × points_per_tier), derived from the tree data rather than
Expand All @@ -121,9 +138,126 @@ async def get_aa_config() -> AAConfigResponse:
aa_cap=limits["aa_cap"],
tradeskill_aa_cap=tradeskill_cap,
unlocked_tree_types=unlocked,
visible_rows=limits.get("visible_rows", {}),
)


# ---------------------------------------------------------------------------
# Planner tree resolution (subclass → its adventure trees)
# ---------------------------------------------------------------------------
# Census tree data names every shadows tree "Shadows" and every heroic tree
# "Heroic" with no structured class field, so these mappings were mined
# empirically from live Varsoon census characters (2026-07-20: 250 max-AA
# chars, all 26 subclasses covered, zero conflicts). The shadows side is
# cross-checked against the committed db's node classifications in
# tests/server/test_aa_routes.py, which will catch a tree-id reshuffle on a
# future aas.db rebuild.

_SHADOWS_TREE_BY_SUBCLASS: dict[str, int] = {
"Assassin": 37,
"Ranger": 38,
"Brigand": 39,
"Swashbuckler": 40,
"Dirge": 41,
"Troubador": 42,
"Guardian": 43,
"Berserker": 44,
"Paladin": 45,
"Shadowknight": 46,
"Monk": 47,
"Bruiser": 48,
"Templar": 49,
"Inquisitor": 50,
"Warden": 51,
"Fury": 52,
"Defiler": 53,
"Mystic": 54,
"Wizard": 55,
"Warlock": 56,
"Conjuror": 57,
"Necromancer": 58,
"Coercer": 59,
"Illusionist": 60,
"Beastlord": 76,
"Channeler": 124,
}

_HEROIC_TREE_BY_SUBCLASS: dict[str, int] = {
"Dirge": 61,
"Troubador": 61,
"Bruiser": 62,
"Monk": 62,
"Inquisitor": 63,
"Templar": 63,
"Paladin": 64,
"Shadowknight": 64,
"Fury": 65,
"Warden": 65,
"Coercer": 66,
"Illusionist": 66,
"Assassin": 67,
"Ranger": 67,
"Brigand": 68,
"Swashbuckler": 68,
"Defiler": 69,
"Mystic": 69,
"Warlock": 70,
"Wizard": 70,
"Conjuror": 71,
"Necromancer": 71,
"Berserker": 72,
"Guardian": 72,
"Beastlord": 77,
"Channeler": 125,
}


class PlanTreeEntry(BaseModel):
tree_id: int
tree_type: str
tree_name: str


@lru_cache(maxsize=64)
def _plan_trees_for(cls: str) -> tuple[PlanTreeEntry, ...] | None:
"""The adventure trees a subclass can plan (class, subclass, shadows,
heroic — in tree-tab order). None for an unknown subclass. Era gating is
the caller's job via /aa/config's unlocked_tree_types."""
from backend.census.constants import SUBCLASS_GROUPS

index = aa_db.load_tree_index()
by_name_type = {(info["name"], info["type"]): tid for tid, info in index.items()}

subclass_tree = by_name_type.get((cls, "subclass"))
if subclass_tree is None:
return None
group = next((g for g, members in SUBCLASS_GROUPS if cls in members), None)

entries: list[PlanTreeEntry] = []
class_tree = by_name_type.get((group, "class")) if group else None
if class_tree is not None and group is not None:
entries.append(PlanTreeEntry(tree_id=class_tree, tree_type="class", tree_name=group))
entries.append(PlanTreeEntry(tree_id=subclass_tree, tree_type="subclass", tree_name=cls))
shadows = _SHADOWS_TREE_BY_SUBCLASS.get(cls)
if shadows is not None and shadows in index:
entries.append(PlanTreeEntry(tree_id=shadows, tree_type="shadows", tree_name="Shadows"))
heroic = _HEROIC_TREE_BY_SUBCLASS.get(cls)
if heroic is not None and heroic in index:
entries.append(PlanTreeEntry(tree_id=heroic, tree_type="heroic", tree_name="Heroic"))
return tuple(entries)


@router.get("/aa/plan-trees", response_model=list[PlanTreeEntry])
async def get_plan_trees(cls: str) -> list[PlanTreeEntry]:
"""The adventure trees a subclass can plan — lets the planner offer
shadows/heroic trees the character doesn't have on their Census record
yet (future-era planning on a pre-TSO server)."""
entries = _plan_trees_for(cls.strip().capitalize())
if entries is None:
raise HTTPException(status_code=404, detail=f"Unknown class: {cls}")
return list(entries)


@lru_cache(maxsize=128)
def _load_tree_for_response(tree_id: int) -> AATreeResponse | None:
"""Build the AATreeResponse for a single tree id from aas.db.
Expand Down Expand Up @@ -155,6 +289,10 @@ def _load_tree_for_response(tree_id: int) -> AATreeResponse | None:
points_to_unlock=n["points_to_unlock"],
title=n["title"],
spellcrc=n["spellcrc"],
classification_points_required=n["classification_points_required"],
points_global_to_unlock=n["points_global_to_unlock"],
first_parent_id=n["first_parent_id"],
first_parent_required_tier=n["first_parent_required_tier"],
)
for n in tree["nodes"]
],
Expand Down
Loading
Loading