diff --git a/CLAUDE.md b/CLAUDE.md index ca215fdc..eeec02a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. | @@ -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 | diff --git a/backend/eq2db/aas.py b/backend/eq2db/aas.py index 95ed8853..6738500b 100644 --- a/backend/eq2db/aas.py +++ b/backend/eq2db/aas.py @@ -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 @@ -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] @@ -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) ────────────────────────────────────── @@ -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"), ), ) diff --git a/backend/eq2db/aas.sql b/backend/eq2db/aas.sql index 92d53438..eb90cbcf 100644 --- a/backend/eq2db/aas.sql +++ b/backend/eq2db/aas.sql @@ -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; diff --git a/backend/server/api/aa.py b/backend/server/api/aa.py index 095100e8..9154be06 100644 --- a/backend/server/api/aa.py +++ b/backend/server/api/aa.py @@ -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): @@ -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): @@ -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 @@ -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. @@ -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"] ], diff --git a/backend/server/api/aa_plans.py b/backend/server/api/aa_plans.py new file mode 100644 index 00000000..aa8d0264 --- /dev/null +++ b/backend/server/api/aa_plans.py @@ -0,0 +1,197 @@ +"""AA planner saved builds — CRUD + read-only share. + +Plans are private to their owner (list/update/delete are discord_id-scoped +in SQL, not just in the route) and pinned to the character they were planned +from. Every plan carries a share slug minted at creation; any logged-in user +holding the link can read it. The server validates allocations +STRUCTURALLY (shape, ints, bounded size) — rule legality (line thresholds, +caps, parent ranks) is the frontend engine's job and is re-checked on +render, so a hand-crafted illegal payload can never crash a viewer. +""" + +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, field_validator + +from backend.server.auth_deps import require_user_session as _require_user +from backend.server.core.text_moderation import contains_blocked_term, sanitize_text +from backend.server.core.validation import validate_character_name as _validate_character_name +from backend.server.db.aa_plans import store as aa_plans +from backend.server.limiter import limiter +from backend.server.server_context import current_world + +_log = logging.getLogger(__name__) + +router = APIRouter() + +MAX_PLANS_PER_CHARACTER = 20 +MAX_PLAN_NAME_LEN = 60 +# Structural bounds — far above any legal build (≤ ~12 trees × ~60 nodes). +_MAX_TREES = 32 +_MAX_NODES_PER_TREE = 200 +_MAX_RANK = 100 + + +class PlanSummary(BaseModel): + id: int + name: str + xpac: str | None = None + share_slug: str + created_at: int + updated_at: int + + +class PlanDetail(PlanSummary): + character_name: str + world: str + allocations: dict[str, dict[str, int]] + is_mine: bool = False + + +class PlanWriteRequest(BaseModel): + character_name: str + name: str + xpac: str | None = None + allocations: dict[str, dict[str, int]] + + @field_validator("allocations") + @classmethod + def _bounded(cls, v: dict[str, dict[str, int]]) -> dict[str, dict[str, int]]: + if len(v) > _MAX_TREES: + raise ValueError(f"too many trees (max {_MAX_TREES})") + for tree_key, alloc in v.items(): + if not tree_key.isdigit(): + raise ValueError("tree ids must be numeric strings") + if len(alloc) > _MAX_NODES_PER_TREE: + raise ValueError(f"too many nodes in tree {tree_key} (max {_MAX_NODES_PER_TREE})") + for node_key, rank in alloc.items(): + if not node_key.isdigit(): + raise ValueError("node ids must be numeric strings") + if not (0 < rank <= _MAX_RANK): + raise ValueError(f"rank out of range for node {node_key}") + return v + + +class DeletePlanResponse(BaseModel): + deleted: bool + + +def _clean_name(raw: str) -> str: + name = sanitize_text(raw, max_len=MAX_PLAN_NAME_LEN) + if not name: + raise HTTPException(status_code=400, detail="Plan name must not be empty") + if contains_blocked_term(name): + raise HTTPException(status_code=400, detail="Plan name contains a blocked term") + return name + + +def _clean_character(raw: str) -> str: + name = _validate_character_name(raw.strip()) + if name is None: + raise HTTPException(status_code=400, detail="Invalid character name") + return name.capitalize() + + +def _row_to_detail(row: dict, viewer_id: str) -> PlanDetail: + try: + allocations = json.loads(row["allocations"] or "{}") + except json.JSONDecodeError: + allocations = {} + return PlanDetail( + id=row["id"], + name=row["name"], + xpac=row["xpac"], + share_slug=row["share_slug"], + created_at=row["created_at"], + updated_at=row["updated_at"], + character_name=row["character_name"], + world=row["world"], + allocations=allocations, + is_mine=row["discord_id"] == viewer_id, + ) + + +@router.get("/aa/plans", response_model=list[PlanSummary]) +@limiter.limit("60/minute") +async def list_my_plans(request: Request, character: str) -> list[PlanSummary]: + """The caller's saved plans for one character on the active server.""" + user = _require_user(request) + character_name = _clean_character(character) + rows = await aa_plans.list_plans(user["id"], current_world(), character_name) + return [PlanSummary(**r) for r in rows] + + +@router.post("/aa/plans", response_model=PlanDetail) +@limiter.limit("20/minute") +async def create_plan(request: Request, body: PlanWriteRequest) -> PlanDetail: + user = _require_user(request) + world = current_world() + character_name = _clean_character(body.character_name) + name = _clean_name(body.name) + if await aa_plans.count_plans(user["id"], world, character_name) >= MAX_PLANS_PER_CHARACTER: + raise HTTPException( + status_code=409, + detail=f"Plan limit reached ({MAX_PLANS_PER_CHARACTER} per character). Delete one first.", + ) + row = await aa_plans.create_plan( + user["id"], + world, + character_name, + name, + body.xpac, + json.dumps(body.allocations), + ) + return _row_to_detail(row, user["id"]) + + +@router.get("/aa/plans/{plan_id}", response_model=PlanDetail) +@limiter.limit("60/minute") +async def get_my_plan(request: Request, plan_id: int) -> PlanDetail: + """Full plan payload — owner only (share links use the slug route).""" + user = _require_user(request) + row = await aa_plans.get_plan(plan_id) + if row is None or row["discord_id"] != user["id"]: + raise HTTPException(status_code=404, detail="Plan not found") + return _row_to_detail(row, user["id"]) + + +@router.put("/aa/plans/{plan_id}", response_model=PlanDetail) +@limiter.limit("30/minute") +async def update_plan(request: Request, plan_id: int, body: PlanWriteRequest) -> PlanDetail: + user = _require_user(request) + name = _clean_name(body.name) + updated = await aa_plans.update_plan( + plan_id, + user["id"], + name=name, + xpac=body.xpac, + allocations_json=json.dumps(body.allocations), + ) + if not updated: + raise HTTPException(status_code=404, detail="Plan not found") + row = await aa_plans.get_plan(plan_id) + if row is None: # pragma: no cover — deleted between update and read + raise HTTPException(status_code=404, detail="Plan not found") + return _row_to_detail(row, user["id"]) + + +@router.delete("/aa/plans/{plan_id}", response_model=DeletePlanResponse) +@limiter.limit("20/minute") +async def delete_plan(request: Request, plan_id: int) -> DeletePlanResponse: + user = _require_user(request) + return DeletePlanResponse(deleted=await aa_plans.delete_plan(plan_id, user["id"])) + + +@router.get("/aa/plan/{slug}", response_model=PlanDetail) +@limiter.limit("60/minute") +async def get_shared_plan(request: Request, slug: str) -> PlanDetail: + """Read-only fetch by share slug — any logged-in user with the link.""" + user = _require_user(request) + row = await aa_plans.get_plan_by_slug(slug) + if row is None: + raise HTTPException(status_code=404, detail="Plan not found") + return _row_to_detail(row, user["id"]) diff --git a/backend/server/app.py b/backend/server/app.py index ebe533e3..3cb32de7 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -14,6 +14,7 @@ load_dotenv() from pathlib import Path +from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse @@ -51,6 +52,7 @@ async def get_response(self, path: str, scope): # type: ignore[override] from backend.server import db as users_db from backend.server import server_context from backend.server.api.aa import router as aa_router +from backend.server.api.aa_plans import router as aa_plans_router from backend.server.api.act_triggers import router as act_triggers_router from backend.server.api.admin import router as admin_router from backend.server.api.auth import router as auth_router @@ -394,9 +396,12 @@ async def _validation_exception_handler(request: Request, exc: RequestValidation or "" ) _log.warning("[validation] 422 %s %s (request_id=%s) — %s", request.method, request.url.path, rid, summary) + # exc.errors() can embed non-JSON-serialisable objects (pydantic v2 puts + # the raised ValueError itself into a field_validator error's ctx) — + # jsonable_encoder stringifies them instead of crashing the 422 response. return JSONResponse( status_code=422, - content={"detail": exc.errors(), "request_id": rid}, + content={"detail": jsonable_encoder(exc.errors()), "request_id": rid}, headers={"X-Request-ID": rid}, ) @@ -605,6 +610,7 @@ async def _parse_cleanup_loop() -> None: guild_officer_router, item_watch_router, raid_planning_router, + aa_plans_router, raid_schedule_router, favorites_router, characters_router, diff --git a/backend/server/db/__init__.py b/backend/server/db/__init__.py index c099ac64..3e0a7b97 100644 --- a/backend/server/db/__init__.py +++ b/backend/server/db/__init__.py @@ -59,6 +59,7 @@ def init_db(path: Path | None = None) -> None: # re-points one attribute per store and every alias follows. # --------------------------------------------------------------------------- +from backend.server.db.aa_plans import store as aa_plans_store # noqa: E402 from backend.server.db.availability import store as availability_store # noqa: E402 from backend.server.db.claims import store as claims_store # noqa: E402 from backend.server.db.favorites import store as favorites_store # noqa: E402 @@ -123,6 +124,7 @@ def init_db(path: Path | None = None) -> None: #: Every domain store over users.db — conftest re-points `store.path` on #: each after re-resolving DB_PATH from the env. ALL_STORES = ( + aa_plans_store, availability_store, claims_store, favorites_store, diff --git a/backend/server/db/aa_plans.py b/backend/server/db/aa_plans.py new file mode 100644 index 00000000..14c266cb --- /dev/null +++ b/backend/server/db/aa_plans.py @@ -0,0 +1,99 @@ +"""users.db aa_plans helpers (async aiosqlite). + +Saved AA planner builds — owned by a Discord user, pinned to the character +they were planned from, shareable read-only via the always-minted +``share_slug``. Mirrors the raid_schedule domain: per-call connections via +the shared ``AsyncStoreBase._db()``; tests re-point ``store.path``. +""" + +from __future__ import annotations + +import secrets +from pathlib import Path + +from backend.db_catalogue import AsyncStoreBase +from backend.server.db import DB_PATH +from backend.sql_loader import load_sql + +_SQL = load_sql(__file__) + + +class AAPlansStore(AsyncStoreBase): + """users.db `aa_plans` domain. Schema/migrations are owned by the package + orchestrator (backend.server.db.init_db).""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + async def list_plans(self, discord_id: str, world: str, character_name: str) -> list[dict]: + """The user's plans for one character, newest-updated first (summary + rows — allocations excluded to keep the list light).""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_plans_for_character"], (discord_id, world, character_name)) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def count_plans(self, discord_id: str, world: str, character_name: str) -> int: + async with self._db() as db: + async with db.execute(_SQL["count_plans_for_character"], (discord_id, world, character_name)) as cur: + row = await cur.fetchone() + return int(row[0]) if row else 0 + + async def get_plan(self, plan_id: int) -> dict | None: + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_plan"], (plan_id,)) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def get_plan_by_slug(self, slug: str) -> dict | None: + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_plan_by_slug"], (slug,)) as cur: + row = await cur.fetchone() + return dict(row) if row else None + + async def create_plan( + self, + discord_id: str, + world: str, + character_name: str, + name: str, + xpac: str | None, + allocations_json: str, + ) -> dict: + """Insert a plan (share slug minted here) and return the full row.""" + slug = secrets.token_urlsafe(9) + async with self._db() as db: + cur = await db.execute( + _SQL["insert_plan"], + (discord_id, world, character_name, name, xpac, allocations_json, slug), + ) + await db.commit() + plan_id = cur.lastrowid + plan = await self.get_plan(int(plan_id or 0)) + if plan is None: # pragma: no cover — insert+select on one path + raise RuntimeError("aa_plan insert did not persist") + return plan + + async def update_plan( + self, + plan_id: int, + discord_id: str, + *, + name: str, + xpac: str | None, + allocations_json: str, + ) -> bool: + """Owner-scoped full update. Returns False when the plan isn't theirs.""" + async with self._db() as db: + cur = await db.execute(_SQL["update_plan"], (name, allocations_json, xpac, plan_id, discord_id)) + await db.commit() + return cur.rowcount > 0 + + async def delete_plan(self, plan_id: int, discord_id: str) -> bool: + async with self._db() as db: + cur = await db.execute(_SQL["delete_plan"], (plan_id, discord_id)) + await db.commit() + return cur.rowcount > 0 + + +# The shared default instance — every runtime consumer goes through this. +store = AAPlansStore() diff --git a/backend/server/db/aa_plans.sql b/backend/server/db/aa_plans.sql new file mode 100644 index 00000000..3edea541 --- /dev/null +++ b/backend/server/db/aa_plans.sql @@ -0,0 +1,33 @@ +-- users.db aa_plans DML (schema lives in schema.sql — the package init_db +-- orchestrator owns it). + +-- :name select_plans_for_character +SELECT id, name, xpac, share_slug, created_at, updated_at + FROM aa_plans + WHERE discord_id = ? AND world = ? AND character_name = ? + ORDER BY updated_at DESC; + +-- :name count_plans_for_character +SELECT COUNT(*) FROM aa_plans WHERE discord_id = ? AND world = ? AND character_name = ?; + +-- :name select_plan +SELECT id, discord_id, world, character_name, name, xpac, allocations, share_slug, created_at, updated_at + FROM aa_plans + WHERE id = ?; + +-- :name select_plan_by_slug +SELECT id, discord_id, world, character_name, name, xpac, allocations, share_slug, created_at, updated_at + FROM aa_plans + WHERE share_slug = ?; + +-- :name insert_plan +INSERT INTO aa_plans (discord_id, world, character_name, name, xpac, allocations, share_slug) +VALUES (?, ?, ?, ?, ?, ?, ?); + +-- :name update_plan +UPDATE aa_plans + SET name = ?, allocations = ?, xpac = ?, updated_at = strftime('%s','now') + WHERE id = ? AND discord_id = ?; + +-- :name delete_plan +DELETE FROM aa_plans WHERE id = ? AND discord_id = ?; diff --git a/backend/server/db/schema.sql b/backend/server/db/schema.sql index 73d2404c..faab82b4 100644 --- a/backend/server/db/schema.sql +++ b/backend/server/db/schema.sql @@ -277,3 +277,22 @@ CREATE TABLE IF NOT EXISTS user_availability ( status TEXT NOT NULL, -- tentative or afk PRIMARY KEY (discord_id, day) ); + +-- Saved AA planner builds. Owned by a user and pinned to the character they +-- were planned from (the planner lives on the character page's AA tab). +-- allocations is JSON {tree_id: {node_id: rank}}. share_slug is minted at +-- creation so any plan can be linked read-only without a separate publish step. +CREATE TABLE IF NOT EXISTS aa_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + discord_id TEXT NOT NULL REFERENCES users(discord_id) ON DELETE CASCADE, + world TEXT NOT NULL, + character_name TEXT NOT NULL, + name TEXT NOT NULL, + xpac TEXT, + allocations TEXT NOT NULL DEFAULT '{}', + share_slug TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE INDEX IF NOT EXISTS idx_aa_plans_owner ON aa_plans(discord_id, world, character_name); diff --git a/data/AAs/aa_limits.json b/data/AAs/aa_limits.json index a7dc5070..4913ad2e 100644 --- a/data/AAs/aa_limits.json +++ b/data/AAs/aa_limits.json @@ -11,27 +11,40 @@ }, "Kingdom of Sky": { "aa_cap": 50, - "notes": "AA system introduced — one class tree per archetype", - "unlocked_trees": ["class"] + "notes": "AA system introduced — one class tree per archetype. Class tree rows 5-6 (8-rank skills + second endline) are the Sentinel's Fate revamp — hidden here.", + "unlocked_trees": ["class"], + "visible_rows": { "class": [0, 1, 2, 3, 4] } }, "Echoes of Faydwer": { "aa_cap": 100, - "notes": "Subclass (Expertise) trees added — one per class; tradeskill AA tree added (own 45-pt cap, separate from the adventure cap)", - "unlocked_trees": ["class", "subclass", "tradeskill"] + "notes": "Subclass (Expertise) trees added — one per class; tradeskill AA tree added (own 45-pt cap, separate from the adventure cap). Class rows 5-6 and subclass rows 16 (70-point row) + 19 (final) are the Sentinel's Fate revamp — hidden. Verified empirically against live Wuoshi census data (300 chars, no spent points beyond these rows).", + "unlocked_trees": ["class", "subclass", "tradeskill"], + "visible_rows": { + "class": [0, 1, 2, 3, 4], + "subclass": [0, 3, 6, 9, 13] + } }, "Rise of Kunark": { "aa_cap": 140, - "notes": "AA cap increased, no new tree types", - "unlocked_trees": ["class", "subclass", "tradeskill"] + "notes": "AA cap increased, no new tree types or tree content", + "unlocked_trees": ["class", "subclass", "tradeskill"], + "visible_rows": { + "class": [0, 1, 2, 3, 4], + "subclass": [0, 3, 6, 9, 13] + } }, "The Shadow Odyssey": { "aa_cap": 200, - "notes": "Shadows tree added — one per class, cross-archetype nodes", - "unlocked_trees": ["class", "subclass", "shadows", "tradeskill"] + "notes": "Shadows tree added — one per class, cross-archetype nodes (whole tree from launch). Class/subclass trees still pre-revamp.", + "unlocked_trees": ["class", "subclass", "shadows", "tradeskill"], + "visible_rows": { + "class": [0, 1, 2, 3, 4], + "subclass": [0, 3, 6, 9, 13] + } }, "Sentinel's Fate": { "aa_cap": 250, - "notes": "AA cap increased, no new tree types", + "notes": "AA cap increased; class trees gain rows 5-6 (8-rank skills + endline), subclass trees gain rows 16 (70-point gate) + 19 (final) — all rows visible from here on", "unlocked_trees": ["class", "subclass", "shadows", "tradeskill"] }, "Destiny of Velious": { diff --git a/data/AAs/aas.db b/data/AAs/aas.db index 0a4f5e3f..65e197d1 100644 Binary files a/data/AAs/aas.db and b/data/AAs/aas.db differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 11e8fb6b..1c0ff08d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ const ParsesPage = lazy(() => import('./pages/ParsesPage')) const RaidZonePage = lazy(() => import('./pages/RaidZonePage')) const RaidZonesPage = lazy(() => import('./pages/RaidZonesPage')) const ComparePage = lazy(() => import('./pages/ComparePage')) +const AAPlanSharePage = lazy(() => import('./pages/AAPlanSharePage')) import { useAuth } from './hooks/useAuth' import { CensusStreamProvider } from './hooks/useCensusStream' import { ServerProvider } from './hooks/useServer' @@ -290,6 +291,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AATree.tsx b/frontend/src/components/AATree.tsx index 44824e9d..77c258dd 100644 --- a/frontend/src/components/AATree.tsx +++ b/frontend/src/components/AATree.tsx @@ -23,6 +23,12 @@ export interface AANode { points_to_unlock: number title: string spellcrc: number + // Unlock prerequisites (planner validation inputs) — optional so payloads + // cached before the planner shipped keep parsing. + classification_points_required?: number + points_global_to_unlock?: number + first_parent_id?: number | null + first_parent_required_tier?: number | null } interface SpellEffect { @@ -49,6 +55,14 @@ export interface AATreeData { interface AATreeProps { tree: AATreeData spent: Record // node_id (string) → tier spent + // Planner mode (all optional — absent = the read-only character view). + // Left-click adds a rank, right-click removes one; `locked` grays out + // rank-0 nodes whose requirements aren't met yet; `lockReason` puts the + // unmet requirement ("Requires 5 points spent in General") in the tooltip. + onAddRank?: (node: AANode) => void + onRemoveRank?: (node: AANode) => void + locked?: (node: AANode) => boolean + lockReason?: (node: AANode) => string | null } // ── Coordinate systems (native 640×480 space) ───────────────────────────────── @@ -121,6 +135,28 @@ function getBgOverlay(treeType: string): string | null { // Node diameter in native 640×480 space (matches NODE_R = 44 output at 2× scale → 22 native radius → 44 diameter) const NODE_D = 44 +// ── Per-node backdrop sprite (mirrors image/aa_tree.py) ─────────────────────── +// bg_sprite.png is 390×44: seven 44×44 backdrop circles with 1px gaps, keyed +// by the node's backdrop id. Unknown/absent ids fall back to the default +// circle at x=0. Rendered via CSS sprite math: percent background-size scales +// the 44px crop to the responsive node diameter. + +const SPRITE_W = 390 +const SPRITE_H = 44 +const SPRITE_CELL = 44 +const BACKDROP_X: Record = { [-1]: 0, 456: 45, 457: 90, 458: 135, 459: 180, 460: 225, 461: 270 } + +function backdropStyle(backdropId: number): React.CSSProperties { + const x = BACKDROP_X[backdropId] ?? 0 + return { + backgroundColor: '#0a0a0e', + backgroundImage: "url('/aa-assets/bg_sprite.png')", + backgroundRepeat: 'no-repeat', + backgroundSize: `${(SPRITE_W / SPRITE_CELL) * 100}% ${(SPRITE_H / SPRITE_CELL) * 100}%`, + backgroundPosition: `${(x / (SPRITE_W - SPRITE_CELL)) * 100}% 0%`, + } +} + // ── Hover tooltip ────────────────────────────────────────────────────────────── // Exported: the compare page's AA node-diff rows reuse the exact same tooltip // (name, rank, description, lazy-fetched spell effects) as the tree view. @@ -130,6 +166,8 @@ export interface AANodeTooltipData { tier: number mx: number my: number + /** Planner: why this node can't take another rank right now. */ + reason?: string | null } type TooltipData = AANodeTooltipData @@ -221,6 +259,13 @@ export function AANodeTooltip({ data }: { data: TooltipData }) { )} + {/* Planner: the unmet requirement blocking this node */} + {data.reason && ( +
+ {data.reason} +
+ )} + {/* Title */} {node.title && (
@@ -270,10 +315,12 @@ export function AANodeTooltip({ data }: { data: TooltipData }) { // ── Main component ───────────────────────────────────────────────────────────── -export function AATree({ tree, spent }: AATreeProps) { +export function AATree({ tree, spent, onAddRank, onRemoveRank, locked, lockReason }: AATreeProps) { const [hovered, setHovered] = useState(null) + const interactive = onAddRank != null const overlay = getBgOverlay(tree.tree_type) + const reasonFor = (node: AANode): string | null => lockReason?.(node) ?? null // As a % of the 640×480 native container const wPct = (NODE_D / 640) * 100 // ≈ 6.875 % @@ -325,6 +372,7 @@ export function AATree({ tree, spent }: AATreeProps) { const tier = spent[String(node.node_id)] ?? 0 const hasSpent = tier > 0 const maxed = tier >= node.maxtier + const isLocked = interactive && !hasSpent && (locked?.(node) ?? false) const borderColor = maxed ? '#22cc22' : hasSpent ? '#d4a017' @@ -343,23 +391,32 @@ export function AATree({ tree, spent }: AATreeProps) { width: `${wPct}%`, height: `${hPct}%`, transform: 'translate(-50%, -50%)', - cursor: 'default', + cursor: !interactive ? 'default' : isLocked ? 'not-allowed' : 'pointer', }} - onMouseEnter={e => setHovered({ node, tier, mx: e.clientX, my: e.clientY })} + onMouseEnter={e => setHovered({ node, tier, mx: e.clientX, my: e.clientY, reason: reasonFor(node) })} onMouseMove={e => setHovered(h => h ? { ...h, mx: e.clientX, my: e.clientY } : null)} onMouseLeave={() => setHovered(null)} - onClick={e => setHovered({ node, tier, mx: e.clientX, my: e.clientY })} + onClick={e => { + if (interactive) onAddRank?.(node) + setHovered({ node, tier, mx: e.clientX, my: e.clientY, reason: reasonFor(node) }) + }} + onContextMenu={e => { + if (!interactive) return + e.preventDefault() + onRemoveRank?.(node) + }} > - {/* Icon circle */} + {/* Icon circle over its sprite backdrop */}
{node.icon_id > 0 && ( needs the same no-Preflight reset as — without it the + UA font/chrome leaks through (first spotted on the AA planner's era + dropdown). Page-level utility classes still override any of this. */ + select { + font-family: inherit; + font-size: inherit; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 0.5rem 0.75rem; + outline: none; + cursor: pointer; + } + select:focus { border-color: var(--accent); } + /* The native popup list ignores most styling — keep it readable on dark. */ + option { background: var(--surface); color: var(--text); } } /* ───────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/pages/AAPlanSharePage.tsx b/frontend/src/pages/AAPlanSharePage.tsx new file mode 100644 index 00000000..975ef19d --- /dev/null +++ b/frontend/src/pages/AAPlanSharePage.tsx @@ -0,0 +1,169 @@ +/** + * AAPlanSharePage — read-only view of a shared AA plan (/aa-plan/:slug). + * + * Renders the plan's trees with its planned ranks using the same AATree + * replica as the character page. Owners get a hint that editing happens on + * the character's AA tab. + */ +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' + +import { AATree, type AATreeData } from '../components/AATree' +import Breadcrumb from '../components/Breadcrumb' +import { Button, Card, SectionLabel } from '../components/ui' +import { TabButton } from '../components/ui/TabButton' +import { aaFileName, buildAAFileXml, downloadAAFile } from './aaplanner/aaFile' +import { useFetch } from '../hooks/useFetch' +import { fmtLocalDate } from '../formatters' +import { + type AAConfig, + TREE_TYPE_LABEL, + filterTreeForEra, + getAAConfig, + getAAConfigFor, + getTreeData, +} from './CharacterAAsTab' +import type { PlanAllocations } from './aaplanner/engine' +import { TRADESKILL_TREE_TYPES, TREE_POINT_CAP, treePoints } from './aaplanner/engine' + +interface SharedPlan { + id: number + name: string + xpac: string | null + character_name: string + world: string + allocations: PlanAllocations + is_mine: boolean + updated_at: number + share_slug: string +} + +export default function AAPlanSharePage() { + const { slug } = useParams<{ slug: string }>() + const { data: plan, loading, error, statusCode } = useFetch( + slug ? `/api/aa/plan/${encodeURIComponent(slug)}` : null, + ) + + const [config, setConfig] = useState(null) + const [trees, setTrees] = useState([]) + const [selectedTreeId, setSelectedTreeId] = useState(null) + + useEffect(() => { + if (!plan) return + let cancelled = false + const treeIds = Object.keys(plan.allocations).map(Number) + // Render under the era the plan was saved for; unknown era → server config. + const configPromise = plan.xpac ? getAAConfigFor(plan.xpac).catch(() => getAAConfig()) : getAAConfig() + Promise.all([configPromise, Promise.all(treeIds.map(getTreeData))]).then(([cfg, tds]) => { + if (cancelled) return + const loaded = tds.filter((td): td is AATreeData => td !== null) + const filtered = loaded.map(td => filterTreeForEra(td, td.tree_type, cfg)) + setConfig(cfg) + setTrees(filtered) + setSelectedTreeId(prev => prev ?? filtered[0]?.tree_id ?? null) + }) + return () => { + cancelled = true + } + }, [plan]) + + const activeTree = trees.find(t => t.tree_id === selectedTreeId) ?? trees[0] + // Adventure vs tradeskill are separate pools with separate caps — never + // count tradeskill spend against the adventure xpac cap. + const pointsIn = (predicate: (t: AATreeData) => boolean) => + trees.filter(predicate).reduce((sum, t) => sum + treePoints(t, plan?.allocations[String(t.tree_id)]), 0) + const adventurePlanned = pointsIn(t => !TRADESKILL_TREE_TYPES.has(t.tree_type)) + const tradeskillPlanned = pointsIn(t => TRADESKILL_TREE_TYPES.has(t.tree_type)) + + return ( +
+ + + {loading &&

Loading plan…

} + {error && ( +

+ {statusCode === 404 ? 'This plan no longer exists (or the link is wrong).' : `Error: ${error}`} +

+ )} + + {plan && ( + <> +
+

{plan.name}

+ + AA plan for{' '} + + {plan.character_name} + {' '} + · {plan.world} + {plan.xpac ? ` · ${plan.xpac}` : ''} · updated {fmtLocalDate(plan.updated_at)} + +
+

+ {adventurePlanned.toLocaleString()} points planned + {config && config.aa_cap > 0 ? ` (cap ${config.aa_cap})` : ''} + {tradeskillPlanned > 0 && + ` · ${tradeskillPlanned.toLocaleString()} tradeskill${ + config && config.tradeskill_aa_cap > 0 ? ` (cap ${config.tradeskill_aa_cap})` : '' + }`} + .{plan.is_mine ? ' This is your plan — edit it from the character page’s AA tab.' : ' Read-only.'} +

+ {trees.length > 0 && config && ( +
+ +
+ )} + + {trees.length === 0 && !loading && ( + Tree data unavailable. + )} + + {trees.length > 0 && ( + <> +
+ {trees.map(t => { + const spent = treePoints(t, plan.allocations[String(t.tree_id)]) + return ( + setSelectedTreeId(t.tree_id)} + title={`${TREE_TYPE_LABEL[t.tree_type] ?? t.tree_type} · ${spent}/${TREE_POINT_CAP} pts`} + className="whitespace-nowrap" + > + {t.tree_name} ({spent}) + + ) + })} +
+ + {activeTree && ( + <> + + {TREE_TYPE_LABEL[activeTree.tree_type] ?? activeTree.tree_type} + +
+
+ +
+
+ + )} + + )} + + )} +
+ ) +} diff --git a/frontend/src/pages/CharacterAAsTab.tsx b/frontend/src/pages/CharacterAAsTab.tsx index 74d9619f..b26b1c30 100644 --- a/frontend/src/pages/CharacterAAsTab.tsx +++ b/frontend/src/pages/CharacterAAsTab.tsx @@ -1,9 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { AATree, AATreeData } from '../components/AATree' -import { Card, SectionLabel } from '../components/ui' +import { Button, Card, SectionLabel } from '../components/ui' import { TabButton } from '../components/ui/TabButton' import { StatGroup, StatRow } from './CharacterPage' +import { PlannerMode } from './aaplanner/PlannerMode' +import { aaFileName, buildAAFileXml, downloadAAFile } from './aaplanner/aaFile' +import { planFromSpent } from './aaplanner/engine' import { partitionAASpend, TRADESKILL_TYPES } from './aaSpend' import { mergeParams, safeSetParams } from '../lib/searchParams' @@ -34,6 +37,19 @@ export interface AAConfig { aa_cap: number // adventure AA cap (tradeskill excluded) tradeskill_aa_cap: number // separate tradeskill pool cap unlocked_tree_types: string[] + // Era-partial trees: tree_type → ycoord rows that exist in this xpac. + // Tree types absent from the map show ALL rows. + visible_rows?: Record +} + +/** Drop nodes whose row doesn't exist in the active era (e.g. the class + * tree's Sentinel's-Fate rows on a KoS/EoF server). A tree type absent from + * config.visible_rows shows every row. Shared with the AA planner. */ +export function filterTreeForEra(td: AATreeData, treeType: string, config: AAConfig): AATreeData { + const rows = config.visible_rows?.[treeType] + if (!rows) return td + const allow = new Set(rows) + return { ...td, nodes: td.nodes.filter(n => allow.has(n.ycoord)) } } // ── AA data cache + loaders ────────────────────────────────────────────────── @@ -66,6 +82,24 @@ export function getAAConfig(): Promise { return _configPromise } +// Per-era config cache — the planner's era dropdown plans under a different +// expansion's rules (?xpac=). Static reference data, cached per era. +const _eraConfigCache = new Map>() + +export function getAAConfigFor(xpac: string): Promise { + let p = _eraConfigCache.get(xpac) + if (!p) { + p = fetch(`/api/aa/config?xpac=${encodeURIComponent(xpac)}`, { credentials: 'include' }) + .then(r => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(`Config: HTTP ${r.status}`)))) + .catch(err => { + _eraConfigCache.delete(xpac) // allow retry + throw err + }) + _eraConfigCache.set(xpac, p) + } + return p +} + // Per-tree promise cache: a tree_id is fetched at most once per session (tree // JSON is static reference data). Failures aren't cached — the next caller // retries. @@ -266,7 +300,7 @@ function resolveProfile(want: string | null, profiles: CharAAProfile[]): ActiveP return idx >= 0 ? idx : 'current' } -export function AAsTab({ charName, aaCount }: { charName: string; aaCount: number }) { +export function AAsTab({ charName, aaCount, cls }: { charName: string; aaCount: number; cls?: string | null }) { const cacheKey = charName.toLowerCase() const cached = aaCache.get(cacheKey) @@ -285,6 +319,8 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe const [activeProfile, setActiveProfile] = useState( cached ? resolveProfile(deepLink.current.profile, cached.charAAs.profiles) : 'current' ) + // 'view' = the read-only character AAs; 'planner' = the interactive planner. + const [mode, setMode] = useState<'view' | 'planner'>('view') // Mirror active profile + tree to the URL (state is source of truth). The AA // tab is the only place this component mounts, so ?profile/?tree only appear @@ -333,6 +369,22 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe // filtered to the xpac-unlocked types. const visibleTrees: CharAATree[] = visibleTreesFor(charAAs, config, activeProfile) + const modeToggle = ( +
+ setMode('view')}>Current AAs + setMode('planner')}>Planner +
+ ) + + if (mode === 'planner') { + return ( +
+ {modeToggle} + +
+ ) + } + const activeCt = visibleTrees.find(t => t.tree_id === selectedTreeId) ?? visibleTrees[0] const activeTd = activeCt ? treeData.get(activeCt.tree_id) : undefined @@ -352,6 +404,8 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe : null return ( +
+ {modeToggle}
{/* ── Left sidebar ── */} @@ -389,6 +443,34 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe
)} + {/* Export the shown build (current or the selected in-game profile) + as a loadable .aa spec file — same exporter as the planner. */} + {visibleTrees.length > 0 && ( +
+ +
+ )} + {/* Expansion */} {config.xpac && ( @@ -487,7 +569,7 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe
{activeTd ? ( - + ) : ( Tree data unavailable (tree #{activeCt.tree_id}) @@ -502,5 +584,6 @@ export function AAsTab({ charName, aaCount }: { charName: string; aaCount: numbe
+
) } diff --git a/frontend/src/pages/CharacterPage.tsx b/frontend/src/pages/CharacterPage.tsx index 8dab07bd..4c5af20f 100644 --- a/frontend/src/pages/CharacterPage.tsx +++ b/frontend/src/pages/CharacterPage.tsx @@ -565,7 +565,7 @@ function CharacterView({ char, maxLevel, ratingConfig }: { char: Character; maxL )} {/* AAs tab */} - {activeTab === 'aas' && } + {activeTab === 'aas' && } {/* Spells tab */} {activeTab === 'spells' && } diff --git a/frontend/src/pages/aaplanner/PlannerMode.test.tsx b/frontend/src/pages/aaplanner/PlannerMode.test.tsx new file mode 100644 index 00000000..87b8bbfe --- /dev/null +++ b/frontend/src/pages/aaplanner/PlannerMode.test.tsx @@ -0,0 +1,214 @@ +/** + * PlannerMode interaction tests — click spends through the engine, + * right-click refunds with the no-stranding guard, blocked actions surface + * their reason, and Save round-trips the allocations. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +import type { AANode, AATreeData } from '../../components/AATree' +import { PlannerMode } from './PlannerMode' +import { downloadAAFile } from './aaFile' +import type { AAConfig, CharAAsResponse } from '../CharacterAAsTab' + +// Keep the XML builder real; only the browser download side-effect is mocked. +vi.mock('./aaFile', async importOriginal => ({ + ...(await importOriginal()), + downloadAAFile: vi.fn(), +})) + +const node = (id: number, name: string, over: Partial = {}): AANode => ({ + node_id: id, + name, + description: '', + classification: 'Strength', + xcoord: 1, + ycoord: 1, + icon_id: 1, // >0 so the {name} renders (our click target) + backdrop_id: -1, + maxtier: 10, + pointspertier: 1, + points_to_unlock: 0, + title: '', + spellcrc: 0, + ...over, +}) + +const TREE: AATreeData = { + tree_id: 42, + tree_name: 'Shaman', + tree_type: 'class', + nodes: [ + node(1, 'Leg Bite'), + node(2, 'Aura of Haste'), + node(3, 'Aura of Warding'), + node(4, 'Spiritual Foresight', { maxtier: 1, classification_points_required: 22 }), + ], +} + +const CONFIG: AAConfig = { + xpac: 'Echoes of Faydwer', + aa_cap: 100, + tradeskill_aa_cap: 45, + unlocked_tree_types: ['class'], +} + +const charAAs = (spent: Record): CharAAsResponse => ({ + character_name: 'Badbang', + total_spent: Object.values(spent).reduce((a, b) => a + b, 0), + trees: [{ tree_id: 42, tree_type: 'class', tree_name: 'Shaman', spent, total_spent: 0 }], + profiles: [], +}) + +function stubFetch() { + const calls: { url: string; init?: RequestInit }[] = [] + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }) + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) + if (url.includes('/api/aa/config?xpac=')) { + const xpac = decodeURIComponent(url.split('xpac=')[1]) + return ok({ + xpac, + aa_cap: xpac === 'Kingdom of Sky' ? 50 : 300, + tradeskill_aa_cap: 0, + unlocked_tree_types: ['class'], + visible_rows: { class: [0, 1, 2, 3, 4] }, + }) + } + if (url.includes('/api/aa/plans?')) return ok([]) + if (url.includes('/api/aa/plans')) { + return ok({ + id: 5, name: 'New plan', xpac: 'EoF', share_slug: 'slug123', + created_at: 1, updated_at: 1, character_name: 'Badbang', world: 'Wuoshi', + allocations: {}, is_mine: true, + }) + } + return ok({}) + }) as unknown as typeof fetch) + return calls +} + +function renderPlanner(spent: Record = {}, over: { cls?: string; config?: AAConfig } = {}) { + return render( + , + ) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('PlannerMode', () => { + it('click spends a rank; right-click refunds it', async () => { + stubFetch() + renderPlanner() + const legBite = await screen.findByAltText('Leg Bite') + fireEvent.click(legBite) + expect(await screen.findByText('(1)')).toBeInTheDocument() // tree tab counter + fireEvent.contextMenu(legBite) + expect(await screen.findByText('(0)')).toBeInTheDocument() + }) + + it('blocked spends surface the engine reason', async () => { + stubFetch() + renderPlanner() + fireEvent.click(await screen.findByAltText('Spiritual Foresight')) + // The reason shows in the status line (and also in the node tooltip). + const status = await screen.findByRole('status') + expect(status).toHaveTextContent('Requires 22 points spent in Strength') + expect(screen.getByText('(0)')).toBeInTheDocument() // nothing spent + }) + + it('hovering a locked node shows the unmet requirement in its tooltip', async () => { + stubFetch() + renderPlanner() + fireEvent.mouseEnter(await screen.findByAltText('Spiritual Foresight')) + // The reason renders inside the node tooltip (portal), before any click. + expect(await screen.findByText('Requires 22 points spent in Strength')).toBeInTheDocument() + }) + + it('blocks refunds that would strand a taken prerequisite', async () => { + stubFetch() + renderPlanner({ '1': 10, '2': 10, '3': 2, '4': 1 }) // exactly 22 in line + the final + fireEvent.contextMenu(await screen.findByAltText('Aura of Warding')) + expect(await screen.findByText(/Spiritual Foresight would lose its requirement/)).toBeInTheDocument() + expect(screen.getByText('(23)')).toBeInTheDocument() // untouched + }) + + it('resolver adds class trees the character lacks — but only era-unlocked ones', async () => { + const calls = stubFetch() + // Resolver knows Templar has a shadows tree (id 90) the char's Census + // record doesn't carry; tree 90 is fetched on demand. + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ url, init }) + const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) + if (url.includes('/api/aa/plan-trees')) { + return ok([ + { tree_id: 42, tree_type: 'class', tree_name: 'Shaman' }, + { tree_id: 90, tree_type: 'shadows', tree_name: 'Shadows' }, + ]) + } + if (url.includes('/api/aa/tree/90')) { + return ok({ tree_id: 90, tree_name: 'Shadows', tree_type: 'shadows', nodes: [node(900, 'Shadow Skill')] }) + } + if (url.includes('/api/aa/plans?')) return ok([]) + return ok({}) + }) as unknown as typeof fetch) + + const tsoConfig: AAConfig = { ...CONFIG, xpac: 'The Shadow Odyssey', aa_cap: 200, unlocked_tree_types: ['class', 'shadows'] } + renderPlanner({}, { cls: 'Templar', config: tsoConfig }) + // Both tabs appear: the char's class tree + the resolver's shadows tree. + expect(await screen.findByRole('button', { name: /Shadows/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Shaman/ })).toBeInTheDocument() + }) + + it('era dropdown re-plans under the selected expansion rules', async () => { + const calls = stubFetch() + renderPlanner() + // Server era first: budget shows the prop config's cap. + expect(await screen.findByText('/ 100')).toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Era'), { target: { value: 'Kingdom of Sky' } }) + // Era config fetched with ?xpac= and the budget re-caps to 50. + expect(await screen.findByText('/ 50')).toBeInTheDocument() + expect(calls.some(c => c.url.includes('/api/aa/config?xpac=Kingdom%20of%20Sky'))).toBe(true) + // Back to the server era restores the prop config. + fireEvent.change(screen.getByLabelText('Era'), { target: { value: '' } }) + expect(await screen.findByText('/ 100')).toBeInTheDocument() + }) + + it('Download .aa exports the plan as an in-game spec file', async () => { + stubFetch() + renderPlanner() + const download = await screen.findByRole('button', { name: /Download \.aa/ }) + expect(download).toBeDisabled() // empty plan — nothing to export + fireEvent.click(await screen.findByAltText('Leg Bite')) + fireEvent.click(screen.getByRole('button', { name: /Download \.aa/ })) + const mock = vi.mocked(downloadAAFile) + expect(mock).toHaveBeenCalledTimes(1) + const [filename, xml] = mock.mock.calls[0] + expect(filename).toBe('Badbang_New_plan.aa') + expect(xml).toContain('') + expect(xml).toContain('id="1" order="1" treeID="42"') + }) + + it('Save posts the current allocations and stores the share slug', async () => { + const calls = stubFetch() + renderPlanner() + fireEvent.click(await screen.findByAltText('Leg Bite')) + const save = screen.getByRole('button', { name: /Save as new/ }) + fireEvent.click(save) + await waitFor(() => { + const post = calls.find(c => c.init?.method === 'POST') + expect(post).toBeTruthy() + expect(JSON.parse(post!.init!.body as string).allocations).toEqual({ '42': { '1': 1 } }) + }) + // Saved → the share button appears + expect(await screen.findByRole('button', { name: /Share link/ })).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/aaplanner/PlannerMode.tsx b/frontend/src/pages/aaplanner/PlannerMode.tsx new file mode 100644 index 00000000..25cb7a25 --- /dev/null +++ b/frontend/src/pages/aaplanner/PlannerMode.tsx @@ -0,0 +1,509 @@ +/** + * PlannerMode — the interactive AA planner inside the character AA tab. + * + * Left-click a node to spend a rank, right-click to refund one; every edit + * goes through the engine (engine.ts) so a plan can never go illegal — + * including the no-stranding removal rule. Plans save privately per user + * (20 per character) and every plan carries a read-only share link. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { AATree, type AANode, type AATreeData } from '../../components/AATree' +import { Button, SectionLabel } from '../../components/ui' +import { TabButton } from '../../components/ui/TabButton' +import { handle } from '../../lib/api' +import { toErrorMessage } from '../../lib/errors' +import { + type AAConfig, + type CharAAsResponse, + TREE_TYPE_LABEL, + filterTreeForEra, + getAAConfigFor, + getTreeData, +} from '../CharacterAAsTab' +import { aaFileName, buildAAFileXml, downloadAAFile } from './aaFile' +import { + type PlanAllocations, + type PlannerCtx, + TRADESKILL_TREE_TYPES, + TREE_POINT_CAP, + addRank, + canAddRank, + canRemoveRank, + nodeUnlocked, + planFromSpent, + poolPoints, + removeRank, + treePoints, + validatePlan, +} from './engine' + +/** Eras the planner can target. Later expansions come once their row + * curation lands in aa_limits.json (currently verified up to DoV). */ +export const PLANNER_ERAS = [ + 'Kingdom of Sky', + 'Echoes of Faydwer', + 'Rise of Kunark', + 'The Shadow Odyssey', + "Sentinel's Fate", + 'Destiny of Velious', +] as const + +export interface PlanSummary { + id: number + name: string + xpac: string | null + share_slug: string + created_at: number + updated_at: number +} + +interface PlanDetail extends PlanSummary { + character_name: string + world: string + allocations: PlanAllocations + is_mine: boolean +} + +interface PlannerModeProps { + charName: string + cls?: string | null + charAAs: CharAAsResponse + config: AAConfig + treeData: Map +} + +interface PlanTreeEntry { + tree_id: number + tree_type: string + tree_name: string +} + +// Subclass → plannable trees (class/subclass/shadows/heroic), cached per +// class. Lets the planner offer trees the character's Census record doesn't +// carry yet — e.g. the shadows tree when era-planning TSO on an EoF server. +const _planTreesCache = new Map>() + +function getPlanTrees(cls: string): Promise { + let p = _planTreesCache.get(cls) + if (!p) { + p = fetch(`/api/aa/plan-trees?cls=${encodeURIComponent(cls)}`, { credentials: 'include' }) + .then(r => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(`HTTP ${r.status}`)))) + .catch(err => { + _planTreesCache.delete(cls) // allow retry + throw err + }) + _planTreesCache.set(cls, p) + } + return p +} + +export function PlannerMode({ charName, cls, charAAs, config, treeData }: PlannerModeProps) { + // Era selection: '' = the server's era (the config prop); a PLANNER_ERAS + // value plans under that expansion's cap/trees/rows instead. + const [era, setEra] = useState('') + const [eraConfig, setEraConfig] = useState(null) + const eraRef = useRef('') + const activeConfig = era && eraConfig ? eraConfig : config + + const selectEra = (value: string) => { + setEra(value) + eraRef.current = value + setError(null) + if (!value) { + setEraConfig(null) + return + } + getAAConfigFor(value) + .then(cfg => { + if (eraRef.current === value) setEraConfig(cfg) + }) + .catch(err => { + if (eraRef.current === value) setError(toErrorMessage(err)) + }) + } + + // The full plannable tree pool: the class resolver's trees (class, + // subclass, shadows, heroic — including ones the character's Census + // record doesn't carry yet) plus any extras the character has (tradeskill). + // Falls back to the character's own trees while loading / on resolver error. + const [treePool, setTreePool] = useState(null) + useEffect(() => { + let cancelled = false + ;(async () => { + const wanted: { tree_id: number }[] = [] + const seen = new Set() + if (cls) { + try { + for (const entry of await getPlanTrees(cls)) { + wanted.push(entry) + seen.add(entry.tree_id) + } + } catch { + /* resolver is an enhancement — the character's own trees still plan */ + } + } + for (const ct of charAAs.trees) { + if (!seen.has(ct.tree_id)) { + wanted.push(ct) + seen.add(ct.tree_id) + } + } + const datas = await Promise.all(wanted.map(w => treeData.get(w.tree_id) ?? getTreeData(w.tree_id))) + if (!cancelled) setTreePool(datas.filter((d): d is AATreeData => d !== null)) + })() + return () => { + cancelled = true + } + }, [cls, charAAs, treeData]) + + // Era-filtered plannable trees: only the era-unlocked tree types, each + // with off-era rows removed. + const trees = useMemo(() => { + const source = + treePool ?? + charAAs.trees.map(ct => treeData.get(ct.tree_id)).filter((td): td is AATreeData => td !== undefined) + const unlocked = new Set(activeConfig.unlocked_tree_types) + return source + .filter(td => unlocked.size === 0 || unlocked.has(td.tree_type)) + .map(td => filterTreeForEra(td, td.tree_type, activeConfig)) + }, [treePool, charAAs, treeData, activeConfig]) + const ctx: PlannerCtx = useMemo( + () => ({ trees, aaCap: activeConfig.aa_cap, tradeskillCap: activeConfig.tradeskill_aa_cap }), + [trees, activeConfig], + ) + + const [plan, setPlan] = useState(() => planFromSpent(charAAs.trees)) + const [planName, setPlanName] = useState('New plan') + const [activePlanId, setActivePlanId] = useState(null) + const [shareSlug, setShareSlug] = useState(null) + const [savedPlans, setSavedPlans] = useState([]) + const [selectedTreeId, setSelectedTreeId] = useState(trees[0]?.tree_id ?? null) + const [dirty, setDirty] = useState(false) + const [notice, setNotice] = useState(null) // rule feedback / save status + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + const listUrl = `/api/aa/plans?character=${encodeURIComponent(charName)}` + + const refreshSaved = useCallback(async () => { + try { + setSavedPlans(await handle(await fetch(listUrl, { credentials: 'include' }))) + } catch { + /* list is a convenience — planner still works without it */ + } + }, [listUrl]) + + useEffect(() => { + refreshSaved() + }, [refreshSaved]) + + // Transient rule feedback (why a click was refused). + useEffect(() => { + if (!notice) return + const t = setTimeout(() => setNotice(null), 3500) + return () => clearTimeout(t) + }, [notice]) + + const activeTree = trees.find(t => t.tree_id === selectedTreeId) ?? trees[0] + + const onAdd = (node: AANode) => { + if (!activeTree) return + const verdict = canAddRank(ctx, plan, activeTree, node) + if (!verdict.ok) { + setNotice(verdict.reason ?? 'Locked') + return + } + setPlan(p => addRank(p, activeTree, node)) + setDirty(true) + } + + const onRemove = (node: AANode) => { + if (!activeTree) return + const verdict = canRemoveRank(ctx, plan, activeTree, node) + if (!verdict.ok) { + setNotice(verdict.reason ?? 'Cannot remove') + return + } + setPlan(p => removeRank(p, activeTree, node)) + setDirty(true) + } + + const loadPlan = async (planId: number) => { + setBusy(true) + setError(null) + try { + const detail = await handle(await fetch(`/api/aa/plans/${planId}`, { credentials: 'include' })) + setPlan(detail.allocations) + setPlanName(detail.name) + setActivePlanId(detail.id) + setShareSlug(detail.share_slug) + setDirty(false) + // Re-open the plan under the era it was saved for. + if (detail.xpac && (PLANNER_ERAS as readonly string[]).includes(detail.xpac) && detail.xpac !== era) { + selectEra(detail.xpac) + } + } catch (err) { + setError(toErrorMessage(err)) + } finally { + setBusy(false) + } + } + + const startFrom = (source: 'current' | 'empty') => { + setPlan(source === 'current' ? planFromSpent(charAAs.trees) : {}) + setPlanName('New plan') + setActivePlanId(null) + setShareSlug(null) + setDirty(true) + } + + const save = async () => { + setBusy(true) + setError(null) + try { + const body = JSON.stringify({ + character_name: charName, + name: planName.trim() || 'New plan', + xpac: era || config.xpac || null, + allocations: plan, + }) + const url = activePlanId != null ? `/api/aa/plans/${activePlanId}` : '/api/aa/plans' + const detail = await handle( + await fetch(url, { + method: activePlanId != null ? 'PUT' : 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body, + }), + ) + setActivePlanId(detail.id) + setShareSlug(detail.share_slug) + setDirty(false) + setNotice('Saved') + await refreshSaved() + } catch (err) { + setError(toErrorMessage(err)) + } finally { + setBusy(false) + } + } + + const deletePlan = async () => { + if (activePlanId == null) return + if (!confirm(`Delete plan "${planName}"?`)) return + setBusy(true) + setError(null) + try { + await handle(await fetch(`/api/aa/plans/${activePlanId}`, { method: 'DELETE', credentials: 'include' })) + startFrom('current') + setDirty(false) + await refreshSaved() + } catch (err) { + setError(toErrorMessage(err)) + } finally { + setBusy(false) + } + } + + const downloadSpec = () => { + const { xml, unplacedCount } = buildAAFileXml(ctx, plan) + downloadAAFile(aaFileName(charName, planName), xml) + setNotice( + unplacedCount > 0 + ? `Downloaded — ${unplacedCount} point${unplacedCount !== 1 ? 's' : ''} skipped (not legal in this era)` + : 'Spec downloaded — load it from the in-game AA window', + ) + } + + const copyShareLink = async () => { + if (!shareSlug) return + const link = `${window.location.origin}/aa-plan/${shareSlug}` + try { + await navigator.clipboard.writeText(link) + setNotice('Share link copied') + } catch { + setNotice(link) // clipboard blocked — show it instead + } + } + + const adventureSpent = poolPoints(ctx, plan, 'adventure') + const tradeskillSpent = poolPoints(ctx, plan, 'tradeskill') + const hasTradeskill = trees.some(t => TRADESKILL_TREE_TYPES.has(t.tree_type)) + const violations = useMemo(() => validatePlan(ctx, plan), [ctx, plan]) + + if (trees.length === 0) { + return

No plannable tree data for this character.

+ } + + return ( +
+ {/* ── Left: plan management + budgets ── */} +
+ Era + + + Plan + { + setPlanName(e.target.value) + setDirty(true) + }} + maxLength={60} + aria-label="Plan name" + className="w-full bg-surface border border-border rounded-sm px-2 py-1 text-[0.85rem] mb-1.5" + /> +
+ + {shareSlug && ( + + )} + + {activePlanId != null && ( + + )} +
+
+ + +
+ + {savedPlans.length > 0 && ( +
+ My plans +
+ {savedPlans.map(p => ( + + ))} +
+
+ )} + + Budget +
+ Adventure:{' '} + {adventureSpent} + {activeConfig.aa_cap > 0 && / {activeConfig.aa_cap}} +
+ {hasTradeskill && ( +
+ Tradeskill:{' '} + {tradeskillSpent} + {activeConfig.tradeskill_aa_cap > 0 && ( + / {activeConfig.tradeskill_aa_cap} + )} +
+ )} +
+ Click a skill to spend a rank; right-click to refund. Greyed skills are locked until their requirements + are met. +
+
+ + {/* ── Right: tree tabs + interactive tree ── */} +
+
+ {trees.map(t => { + const spent = treePoints(t, plan[String(t.tree_id)]) + return ( + setSelectedTreeId(t.tree_id)} + title={`${TREE_TYPE_LABEL[t.tree_type] ?? t.tree_type} · ${spent}/${TREE_POINT_CAP} pts`} + className="whitespace-nowrap" + > + {t.tree_name} ({spent}) + + ) + })} +
+ + {(notice || error) && ( +
+ {error ?? notice} +
+ )} + {violations.length > 0 && ( +
+ This plan has {violations.length} rule violation{violations.length !== 1 ? 's' : ''} ( + {violations[0].nodeName}: {violations[0].reason} + {violations.length > 1 ? ', …' : ''}) — likely saved under different era rules. +
+ )} + + {activeTree && ( + <> +
+ + {TREE_TYPE_LABEL[activeTree.tree_type] ?? activeTree.tree_type} + + + {treePoints(activeTree, plan[String(activeTree.tree_id)])} / {TREE_POINT_CAP} in tree + +
+
+
+ !nodeUnlocked(ctx, plan, activeTree, node)} + lockReason={node => { + const verdict = canAddRank(ctx, plan, activeTree, node) + return verdict.ok ? null : (verdict.reason ?? null) + }} + /> +
+
+ + )} +
+
+ ) +} diff --git a/frontend/src/pages/aaplanner/aaFile.test.ts b/frontend/src/pages/aaplanner/aaFile.test.ts new file mode 100644 index 00000000..d91ac554 --- /dev/null +++ b/frontend/src/pages/aaplanner/aaFile.test.ts @@ -0,0 +1,110 @@ +/** + * aaFile exporter tests — legal purchase ordering (the in-game loader + * replays the file top-to-bottom) and the .aa XML shape reverse-engineered + * from a real in-game export. + */ +import { describe, expect, it } from 'vitest' + +import type { AANode, AATreeData } from '../../components/AATree' +import { aaFileName, buildAAFileXml } from './aaFile' +import { type PlanAllocations, type PlannerCtx, spendOrder } from './engine' + +const node = (id: number, over: Partial = {}): AANode => ({ + node_id: id, + name: `Node ${id}`, + description: '', + classification: 'Strength', + xcoord: 1, + ycoord: 1, + icon_id: 0, + backdrop_id: -1, + maxtier: 10, + pointspertier: 1, + points_to_unlock: 0, + title: '', + spellcrc: 0, + ...over, +}) + +const CLASS_TREE: AATreeData = { + tree_id: 3, + tree_name: 'Cleric', + tree_type: 'class', + nodes: [ + node(101, { ycoord: 1 }), + node(102, { ycoord: 2 }), + node(103, { ycoord: 3 }), + node(104, { name: 'Line Final', ycoord: 4, maxtier: 1, classification_points_required: 22 }), + ], +} + +const TS_TREE: AATreeData = { + tree_id: 73, + tree_name: 'Tradeskill', + tree_type: 'tradeskill', + nodes: [node(700, { classification: 'Crafting Expertise', maxtier: 45 })], +} + +const ctx: PlannerCtx = { trees: [CLASS_TREE, TS_TREE], aaCap: 200, tradeskillCap: 45 } + +describe('spendOrder', () => { + it('places gated ranks after their requirements are met', () => { + const plan: PlanAllocations = { '3': { '101': 10, '102': 10, '103': 2, '104': 1 } } + const { steps, unplaced } = spendOrder(ctx, plan) + expect(unplaced).toHaveLength(0) + expect(steps).toHaveLength(23) + // The 22-point line final must be the last purchase. + expect(steps[22]).toEqual({ tree_id: 3, node_id: 104 }) + // A node's ranks run consecutively (game export shape). + expect(steps.slice(0, 10).every(s => s.node_id === 101)).toBe(true) + }) + + it('returns unreachable ranks as unplaced instead of emitting them', () => { + const stranded: PlanAllocations = { '3': { '101': 10, '102': 10, '104': 1 } } // line at 20 < 22 + const { steps, unplaced } = spendOrder(ctx, stranded) + expect(steps).toHaveLength(20) + expect(unplaced).toEqual([{ tree_id: 3, node_id: 104 }]) + }) +}) + +describe('buildAAFileXml', () => { + it('buckets by typenum with per-bucket order counters and repeats ids per rank', () => { + const plan: PlanAllocations = { '3': { '101': 3 }, '73': { '700': 2 } } + const { xml, unplacedCount } = buildAAFileXml(ctx, plan) + expect(unplacedCount).toBe(0) + + const doc = new DOMParser().parseFromString(xml, 'application/xml') + expect(doc.documentElement.tagName).toBe('aa') + expect(doc.documentElement.getAttribute('game')).toBe('eq2') + + const sections = [...doc.querySelectorAll('alternateadvancements')] + const byType = Object.fromEntries(sections.map(s => [s.getAttribute('typenum'), s])) + // Adventure bucket: 3 entries for node 101, orders 1..3, treeID 3. + const adv = [...byType['0'].querySelectorAll('alternateadvancement')] + expect(adv.map(e => [e.getAttribute('id'), e.getAttribute('order'), e.getAttribute('treeID')])).toEqual([ + ['101', '1', '3'], + ['101', '2', '3'], + ['101', '3', '3'], + ]) + // Tradeskill bucket restarts its order counter at 1. + const ts = [...byType['3'].querySelectorAll('alternateadvancement')] + expect(ts.map(e => e.getAttribute('order'))).toEqual(['1', '2']) + // The game always writes an (empty) bucket 2 — mirrored for shape parity. + expect(byType['2']).toBeTruthy() + expect(byType['2'].children).toHaveLength(0) + }) + + it('reports skipped ranks so the UI can warn', () => { + const stranded: PlanAllocations = { '3': { '104': 1 } } + const { xml, unplacedCount } = buildAAFileXml(ctx, stranded) + expect(unplacedCount).toBe(1) + expect(xml).not.toContain('id="104"') + }) +}) + +describe('aaFileName', () => { + it('sanitises to a safe filename', () => { + expect(aaFileName('Menludiir', 'Raid DPS!')).toBe('Menludiir_Raid_DPS.aa') + expect(aaFileName('X', ' ')).toBe('X.aa') + }) +}) diff --git a/frontend/src/pages/aaplanner/aaFile.ts b/frontend/src/pages/aaplanner/aaFile.ts new file mode 100644 index 00000000..b83e1491 --- /dev/null +++ b/frontend/src/pages/aaplanner/aaFile.ts @@ -0,0 +1,84 @@ +/** + * aaFile — export a plan as the game's own `.aa` spec file. + * + * The in-game AA window's Save/Load writes and reads this format: one + * `` element PER POINT SPENT (node id repeated per + * rank) in purchase order, bucketed into `` + * sections. The game replays the file in order, so we emit through the + * engine's spendOrder — every line is legal at its position. + * + * typenum buckets were reverse-engineered from a real in-game export + * (scripts/dev/Menludiir_templar_dps.aa): class/subclass/shadows share + * bucket 0 (heroic assumed 0 with them — same AA window tab), tradeskill + * is 3, tradeskill-general 4. Bucket 2 appears empty in game exports and + * is mirrored for shape-compatibility. + */ + +import type { PlanAllocations, PlannerCtx } from './engine' +import { spendOrder } from './engine' + +export const TYPENUM_BY_TREE_TYPE: Record = { + class: 0, + subclass: 0, + shadows: 0, + heroic: 0, + warder: 1, + prestige: 2, + tradeskill: 3, + tradeskill_general: 4, +} + +export interface AAFileResult { + xml: string + /** Point ranks that could not be legally ordered (off-era imports etc.) — + * excluded from the file so the in-game load never jams. */ + unplacedCount: number +} + +export function buildAAFileXml(ctx: PlannerCtx, plan: PlanAllocations): AAFileResult { + const { steps, unplaced } = spendOrder(ctx, plan) + const typeByTree = new Map(ctx.trees.map(t => [t.tree_id, TYPENUM_BY_TREE_TYPE[t.tree_type] ?? 0])) + + // Bucket the ordered steps; per-bucket order counters restart at 1 + // (matches the game's own export). + const buckets = new Map() + for (const step of steps) { + const typenum = typeByTree.get(step.tree_id) ?? 0 + const lines = buckets.get(typenum) ?? [] + lines.push( + ` `, + ) + buckets.set(typenum, lines) + } + if (!buckets.has(2)) buckets.set(2, []) // the game always emits an (empty) bucket 2 + + const sections = [...buckets.keys()] + .sort((a, b) => a - b) + .map(typenum => { + const lines = buckets.get(typenum) ?? [] + if (lines.length === 0) return ` ` + return ` \n${lines.join('\n')}\n ` + }) + + const xml = `\n\n${sections.join('\n')}\n\n` + return { xml, unplacedCount: unplaced.length } +} + +/** Trigger a browser download of the spec file. */ +export function downloadAAFile(filename: string, xml: string): void { + const blob = new Blob([xml], { type: 'application/xml' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +} + +/** "Menludiir" + "Raid DPS!" → "Menludiir_Raid_DPS.aa" */ +export function aaFileName(charName: string, planName: string): string { + const safe = `${charName}_${planName}`.replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') + return `${safe || 'aa_plan'}.aa` +} diff --git a/frontend/src/pages/aaplanner/engine.test.ts b/frontend/src/pages/aaplanner/engine.test.ts new file mode 100644 index 00000000..4c5f87b4 --- /dev/null +++ b/frontend/src/pages/aaplanner/engine.test.ts @@ -0,0 +1,228 @@ +/** + * aaPlanner engine tests — the rule semantics the planner hard-blocks on: + * point-cost-weighted thresholds, self-exclusive unlock counters, parent + * rank prereqs, the flat 100/tree cap, separate pools, and no-stranding + * removals. + */ +import { describe, expect, it } from 'vitest' + +import type { AANode, AATreeData } from '../../components/AATree' +import { + type PlanAllocations, + type PlannerCtx, + addRank, + canAddRank, + canRemoveRank, + linePoints, + planFromSpent, + poolPoints, + removeRank, + treePoints, + validatePlan, +} from './engine' + +const node = (id: number, over: Partial = {}): AANode => ({ + node_id: id, + name: `Node ${id}`, + description: '', + classification: 'Strength', + xcoord: 1, + ycoord: 1, + icon_id: 0, + backdrop_id: -1, + maxtier: 10, + pointspertier: 1, + points_to_unlock: 0, + title: '', + spellcrc: 0, + ...over, +}) + +// A miniature Shaman-style class tree: three 10-rank Strength skills, the +// 22-point line-final (the user's Spiritual Foresight example), and a +// 2-point/rank endline gated on 16 points in tree. +const CLASS_TREE: AATreeData = { + tree_id: 42, + tree_name: 'Shaman', + tree_type: 'class', + nodes: [ + node(1, { name: 'Leg Bite' }), + node(2, { name: 'Aura of Haste' }), + node(3, { name: 'Aura of Warding' }), + node(4, { name: 'Spiritual Foresight', maxtier: 1, classification_points_required: 22 }), + node(5, { name: 'Endline', maxtier: 2, pointspertier: 2, points_to_unlock: 16, classification: '' }), + node(6, { name: 'Chained', maxtier: 5, first_parent_id: 1, first_parent_required_tier: 5 }), + ], +} + +const TS_TREE: AATreeData = { + tree_id: 77, + tree_name: 'Craftsman', + tree_type: 'tradeskill', + nodes: [node(70, { classification: 'Crafting Expertise', maxtier: 45 })], +} + +const ctx: PlannerCtx = { trees: [CLASS_TREE, TS_TREE], aaCap: 100, tradeskillCap: 45 } + +const alloc = (a: Record): PlanAllocations => ({ '42': a }) + +describe('point counting', () => { + it('weights by pointspertier, not rank count', () => { + const plan = alloc({ '1': 3, '5': 2 }) // 3×1 + 2×2 + expect(treePoints(CLASS_TREE, plan['42'])).toBe(7) + }) + + it('linePoints only counts the classification and honours exclusion', () => { + const plan = alloc({ '1': 4, '5': 2 }) // Endline has no classification + expect(linePoints(CLASS_TREE, plan['42'], 'Strength')).toBe(4) + expect(linePoints(CLASS_TREE, plan['42'], 'Strength', 1)).toBe(0) + }) + + it('pools are separate: tradeskill spend never counts as adventure', () => { + const plan: PlanAllocations = { '42': { '1': 2 }, '77': { '70': 10 } } + expect(poolPoints(ctx, plan, 'adventure')).toBe(2) + expect(poolPoints(ctx, plan, 'tradeskill')).toBe(10) + }) +}) + +describe('canAddRank', () => { + it('blocks the 22-point line-final until the line total reaches 22', () => { + const spiritualForesight = CLASS_TREE.nodes[3] + const at21 = alloc({ '1': 10, '2': 10, '3': 1 }) + expect(canAddRank(ctx, at21, CLASS_TREE, spiritualForesight).ok).toBe(false) + const at22 = alloc({ '1': 10, '2': 10, '3': 2 }) + expect(canAddRank(ctx, at22, CLASS_TREE, spiritualForesight).ok).toBe(true) + }) + + it('tree-points gates count 2-point ranks at their cost', () => { + const endline = CLASS_TREE.nodes[4] // needs 16 in tree + const plan = alloc({ '1': 8, '2': 8 }) + expect(canAddRank(ctx, plan, CLASS_TREE, endline).ok).toBe(true) + expect(canAddRank(ctx, alloc({ '1': 8, '2': 7 }), CLASS_TREE, endline).ok).toBe(false) + }) + + it('a node cannot satisfy its own threshold (self-exclusive)', () => { + // 16 needed in tree; endline rank 1 (2 pts) + 14 elsewhere = 16 total, + // but only 14 excluding itself → second rank stays locked. + const endline = CLASS_TREE.nodes[4] + const plan = alloc({ '1': 10, '2': 4, '5': 1 }) + expect(canAddRank(ctx, plan, CLASS_TREE, endline).ok).toBe(false) + }) + + it('enforces parent rank prereqs and maxtier', () => { + const chained = CLASS_TREE.nodes[5] + expect(canAddRank(ctx, alloc({ '1': 4 }), CLASS_TREE, chained).ok).toBe(false) + expect(canAddRank(ctx, alloc({ '1': 5 }), CLASS_TREE, chained).ok).toBe(true) + const maxed = alloc({ '1': 10 }) + expect(canAddRank(ctx, maxed, CLASS_TREE, CLASS_TREE.nodes[0])).toEqual({ + ok: false, + reason: 'Already at max rank', + }) + }) + + it('enforces the adventure cap and the flat 100 tree cap', () => { + const tight: PlannerCtx = { ...ctx, aaCap: 12 } + const plan = alloc({ '1': 10, '2': 2 }) + expect(canAddRank(tight, plan, CLASS_TREE, CLASS_TREE.nodes[2]).ok).toBe(false) + + // Tree cap: a wide synthetic tree that can exceed 100 under a big aaCap. + const wide: AATreeData = { + tree_id: 9, + tree_name: 'Wide', + tree_type: 'subclass', + nodes: Array.from({ length: 11 }, (_, i) => node(900 + i, { classification: '', maxtier: 10 })), + } + const wideCtx: PlannerCtx = { trees: [wide], aaCap: 200, tradeskillCap: 0 } + const full: PlanAllocations = { '9': Object.fromEntries(Array.from({ length: 10 }, (_, i) => [String(900 + i), 10])) } + expect(canAddRank(wideCtx, full, wide, wide.nodes[10])).toEqual({ + ok: false, + reason: 'Tree cap reached (100 points)', + }) + }) + + it('tradeskill spend draws from its own cap, not the adventure cap', () => { + const tiny: PlannerCtx = { ...ctx, aaCap: 1, tradeskillCap: 45 } + const plan: PlanAllocations = { '42': { '1': 1 } } // adventure cap exhausted + expect(canAddRank(tiny, plan, TS_TREE, TS_TREE.nodes[0]).ok).toBe(true) + }) +}) + +describe('shadows section gating (structural rule)', () => { + // Two sections: General (y=1) and Priest (y=6) — the TSO shadows shape. + const SHADOWS: AATreeData = { + tree_id: 49, + tree_name: 'Shadows', + tree_type: 'shadows', + nodes: [ + node(201, { name: 'General One', classification: 'General', ycoord: 1, maxtier: 5 }), + node(202, { name: 'General Two', classification: 'General', ycoord: 1, maxtier: 5 }), + node(211, { name: 'Litany of Combat', classification: 'Priest', ycoord: 6, maxtier: 5 }), + ], + } + const sctx: PlannerCtx = { trees: [SHADOWS], aaCap: 200, tradeskillCap: 0 } + const litany = SHADOWS.nodes[2] + + it('blocks the second section until 5 points sit in the one above', () => { + const at4: PlanAllocations = { '49': { '201': 2, '202': 2 } } + const verdict = canAddRank(sctx, at4, SHADOWS, litany) + expect(verdict).toEqual({ ok: false, reason: 'Requires 5 points spent in General' }) + const at5: PlanAllocations = { '49': { '201': 3, '202': 2 } } + expect(canAddRank(sctx, at5, SHADOWS, litany).ok).toBe(true) + }) + + it('first-section nodes are never section-gated', () => { + expect(canAddRank(sctx, {}, SHADOWS, SHADOWS.nodes[0]).ok).toBe(true) + }) + + it('blocks refunds that would drop the section below a taken dependent', () => { + const plan: PlanAllocations = { '49': { '201': 3, '202': 2, '211': 1 } } + const verdict = canRemoveRank(sctx, plan, SHADOWS, SHADOWS.nodes[0]) + expect(verdict.ok).toBe(false) + expect(verdict.reason).toContain('Litany of Combat') + }) +}) + +describe('canRemoveRank — no stranding', () => { + it('blocks dropping the line below a taken 22-point final', () => { + const plan = alloc({ '1': 10, '2': 10, '3': 2, '4': 1 }) + const verdict = canRemoveRank(ctx, plan, CLASS_TREE, CLASS_TREE.nodes[2]) + expect(verdict.ok).toBe(false) + expect(verdict.reason).toContain('Spiritual Foresight') + }) + + it('allows the same removal when there is slack in the line', () => { + const plan = alloc({ '1': 10, '2': 10, '3': 3, '4': 1 }) // 23 in line + expect(canRemoveRank(ctx, plan, CLASS_TREE, CLASS_TREE.nodes[2]).ok).toBe(true) + }) + + it('blocks dropping a parent below a chained child requirement', () => { + const plan = alloc({ '1': 5, '6': 1 }) + expect(canRemoveRank(ctx, plan, CLASS_TREE, CLASS_TREE.nodes[0]).ok).toBe(false) + }) + + it('refuses removals from empty nodes', () => { + expect(canRemoveRank(ctx, alloc({}), CLASS_TREE, CLASS_TREE.nodes[0]).ok).toBe(false) + }) +}) + +describe('plan mutation + seeding', () => { + it('addRank/removeRank are immutable and drop zero ranks', () => { + const plan = alloc({ '1': 1 }) + const added = addRank(plan, CLASS_TREE, CLASS_TREE.nodes[0]) + expect(added['42']['1']).toBe(2) + expect(plan['42']['1']).toBe(1) + const removed = removeRank(removeRank(added, CLASS_TREE, CLASS_TREE.nodes[0]), CLASS_TREE, CLASS_TREE.nodes[0]) + expect('1' in removed['42']).toBe(false) + }) + + it('planFromSpent copies the character spent maps', () => { + const plan = planFromSpent([{ tree_id: 42, spent: { '1': 7 } }]) + expect(plan['42']).toEqual({ '1': 7 }) + }) + + it('validatePlan flags an imported over-cap or stranded build', () => { + const stranded = alloc({ '1': 10, '2': 10, '4': 1 }) // line at 20 < 22 with the final taken + const violations = validatePlan(ctx, stranded) + expect(violations.some(v => v.nodeName === 'Spiritual Foresight')).toBe(true) + }) +}) diff --git a/frontend/src/pages/aaplanner/engine.ts b/frontend/src/pages/aaplanner/engine.ts new file mode 100644 index 00000000..f6cd8999 --- /dev/null +++ b/frontend/src/pages/aaplanner/engine.ts @@ -0,0 +1,297 @@ +/** + * aaPlanner — pure validation + allocation engine for the AA planner. + * + * Rule semantics (verified against aas.db node data + in-game behaviour): + * - Every threshold counts AA POINTS SPENT (rank × pointspertier), never + * rank counts — a 2-point/rank endline contributes 2 per rank. + * - Unlock thresholds are SELF-EXCLUSIVE: a node's own points never count + * toward its own requirement (the game checks the unlock before rank 1 + * goes in). This also makes "is this allocation achievable in some spend + * order" order-independent, because real trees gate rows monotonically. + * - Removals must not strand anything: dropping a rank is legal only if + * every other taken node still meets its requirements afterwards + * (validatePlan on the simulated allocation). + * - Flat TREE_POINT_CAP (100) per tree in every era; adventure vs + * tradeskill trees draw from separate pools with separate caps. + * + * Kept free of React so it unit-tests directly (engine.test.ts). + */ + +import type { AANode, AATreeData } from '../../components/AATree' + +export const TREE_POINT_CAP = 100 +export const TRADESKILL_TREE_TYPES: ReadonlySet = new Set(['tradeskill', 'tradeskill_general']) + +/** Shadows trees gate each section (General → Archetype → Class → Subclass) + * on points spent in the section above. This rule is STRUCTURAL — the Census + * node data carries no field for it (only each section's own endline has a + * classification_points_required), so the threshold lives here. */ +export const SHADOWS_SECTION_UNLOCK = 5 + +/** node_id (string, matches the API's spent maps) → rank taken. */ +export type Allocation = Record +/** tree_id (string) → that tree's allocation. */ +export type PlanAllocations = Record + +export interface PlannerCtx { + trees: AATreeData[] // era-filtered (filterTreeForEra) — the plannable trees + aaCap: number // adventure AA cap for the xpac + tradeskillCap: number // separate tradeskill pool cap +} + +export interface Verdict { + ok: boolean + reason?: string +} + +export interface PlanViolation { + treeId: number + nodeId: number + nodeName: string + reason: string +} + +const rankOf = (alloc: Allocation | undefined, nodeId: number): number => alloc?.[String(nodeId)] ?? 0 + +const isTradeskill = (tree: AATreeData): boolean => TRADESKILL_TREE_TYPES.has(tree.tree_type) + +/** AA points spent in one tree (rank × cost), optionally excluding a node. */ +export function treePoints(tree: AATreeData, alloc: Allocation | undefined, excludeNodeId?: number): number { + if (!alloc) return 0 + let total = 0 + for (const node of tree.nodes) { + if (node.node_id === excludeNodeId) continue + total += rankOf(alloc, node.node_id) * node.pointspertier + } + return total +} + +/** AA points spent in one classification line of a tree, optionally excluding a node. */ +export function linePoints( + tree: AATreeData, + alloc: Allocation | undefined, + classification: string, + excludeNodeId?: number, +): number { + if (!alloc || !classification) return 0 + let total = 0 + for (const node of tree.nodes) { + if (node.node_id === excludeNodeId || node.classification !== classification) continue + total += rankOf(alloc, node.node_id) * node.pointspertier + } + return total +} + +/** Points spent across a pool (adventure or tradeskill trees), optionally + * excluding one node (self-exclusive global thresholds). */ +export function poolPoints( + ctx: PlannerCtx, + plan: PlanAllocations, + pool: 'adventure' | 'tradeskill', + exclude?: { treeId: number; nodeId: number }, +): number { + let total = 0 + for (const tree of ctx.trees) { + if (isTradeskill(tree) !== (pool === 'tradeskill')) continue + const excludeNode = exclude && exclude.treeId === tree.tree_id ? exclude.nodeId : undefined + total += treePoints(tree, plan[String(tree.tree_id)], excludeNode) + } + return total +} + +/** The unmet unlock requirement for holding ranks in `node`, or null. + * All counters are self-exclusive (see module docs). */ +function unmetRequirement(ctx: PlannerCtx, plan: PlanAllocations, tree: AATreeData, node: AANode): string | null { + const alloc = plan[String(tree.tree_id)] + if (node.points_to_unlock > 0 && treePoints(tree, alloc, node.node_id) < node.points_to_unlock) { + return `Requires ${node.points_to_unlock} points spent in ${tree.tree_name}` + } + const clsReq = node.classification_points_required ?? 0 + if (clsReq > 0 && linePoints(tree, alloc, node.classification, node.node_id) < clsReq) { + return `Requires ${clsReq} points spent in ${node.classification}` + } + const globReq = node.points_global_to_unlock ?? 0 + if (globReq > 0 && poolPoints(ctx, plan, 'adventure', { treeId: tree.tree_id, nodeId: node.node_id }) < globReq) { + return `Requires ${globReq} total AA spent` + } + const parentId = node.first_parent_id + const parentTier = node.first_parent_required_tier ?? 0 + if (parentId != null && parentTier > 0 && rankOf(alloc, parentId) < parentTier) { + const parent = tree.nodes.find(n => n.node_id === parentId) + return `Requires ${parentTier} rank${parentTier !== 1 ? 's' : ''} in ${parent?.name ?? `node #${parentId}`}` + } + if (tree.tree_type === 'shadows') { + const shadowsGate = unmetShadowsSection(tree, alloc, node) + if (shadowsGate) return shadowsGate + } + return null +} + +/** The shadows structural rule: each section (distinct ycoord row, in order) + * needs SHADOWS_SECTION_UNLOCK points spent in the section above it. */ +function unmetShadowsSection(tree: AATreeData, alloc: Allocation | undefined, node: AANode): string | null { + const rows = [...new Set(tree.nodes.map(n => n.ycoord))].sort((a, b) => a - b) + const idx = rows.indexOf(node.ycoord) + if (idx <= 0) return null + const prevY = rows[idx - 1] + let prevPoints = 0 + for (const n of tree.nodes) { + if (n.ycoord === prevY) prevPoints += rankOf(alloc, n.node_id) * n.pointspertier + } + if (prevPoints >= SHADOWS_SECTION_UNLOCK) return null + const prevName = tree.nodes.find(n => n.ycoord === prevY)?.classification.trim() || 'the previous line' + return `Requires ${SHADOWS_SECTION_UNLOCK} points spent in ${prevName}` +} + +/** Is `node` clickable at rank 0 (used for the locked/greyed styling)? */ +export function nodeUnlocked(ctx: PlannerCtx, plan: PlanAllocations, tree: AATreeData, node: AANode): boolean { + return unmetRequirement(ctx, plan, tree, node) === null +} + +export function canAddRank(ctx: PlannerCtx, plan: PlanAllocations, tree: AATreeData, node: AANode): Verdict { + const alloc = plan[String(tree.tree_id)] + if (rankOf(alloc, node.node_id) >= node.maxtier) { + return { ok: false, reason: 'Already at max rank' } + } + const unmet = unmetRequirement(ctx, plan, tree, node) + if (unmet) return { ok: false, reason: unmet } + + const cost = node.pointspertier + if (treePoints(tree, alloc) + cost > TREE_POINT_CAP) { + return { ok: false, reason: `Tree cap reached (${TREE_POINT_CAP} points)` } + } + const pool = isTradeskill(tree) ? 'tradeskill' : 'adventure' + const cap = pool === 'tradeskill' ? ctx.tradeskillCap : ctx.aaCap + if (cap > 0 && poolPoints(ctx, plan, pool) + cost > cap) { + return { ok: false, reason: pool === 'tradeskill' ? `Tradeskill AA cap reached (${cap})` : `AA cap reached (${cap})` } + } + return { ok: true } +} + +/** Every taken rank re-checked against the (possibly simulated) allocation. */ +export function validatePlan(ctx: PlannerCtx, plan: PlanAllocations): PlanViolation[] { + const violations: PlanViolation[] = [] + for (const tree of ctx.trees) { + const alloc = plan[String(tree.tree_id)] + if (!alloc) continue + for (const node of tree.nodes) { + const r = rankOf(alloc, node.node_id) + if (r <= 0) continue + if (r > node.maxtier) { + violations.push({ treeId: tree.tree_id, nodeId: node.node_id, nodeName: node.name, reason: `Rank ${r} exceeds max ${node.maxtier}` }) + continue + } + const unmet = unmetRequirement(ctx, plan, tree, node) + if (unmet) { + violations.push({ treeId: tree.tree_id, nodeId: node.node_id, nodeName: node.name, reason: unmet }) + } + } + if (treePoints(tree, alloc) > TREE_POINT_CAP) { + violations.push({ treeId: tree.tree_id, nodeId: 0, nodeName: tree.tree_name, reason: `Over the ${TREE_POINT_CAP}-point tree cap` }) + } + } + if (ctx.aaCap > 0 && poolPoints(ctx, plan, 'adventure') > ctx.aaCap) { + violations.push({ treeId: 0, nodeId: 0, nodeName: 'Adventure AAs', reason: `Over the ${ctx.aaCap}-point AA cap` }) + } + if (ctx.tradeskillCap > 0 && poolPoints(ctx, plan, 'tradeskill') > ctx.tradeskillCap) { + violations.push({ treeId: 0, nodeId: 0, nodeName: 'Tradeskill AAs', reason: `Over the ${ctx.tradeskillCap}-point tradeskill cap` }) + } + return violations +} + +export function canRemoveRank(ctx: PlannerCtx, plan: PlanAllocations, tree: AATreeData, node: AANode): Verdict { + if (rankOf(plan[String(tree.tree_id)], node.node_id) <= 0) { + return { ok: false, reason: 'Nothing spent here' } + } + const simulated = withRank(plan, tree.tree_id, node.node_id, rankOf(plan[String(tree.tree_id)], node.node_id) - 1) + const violations = validatePlan(ctx, simulated) + if (violations.length > 0) { + const v = violations[0] + return { ok: false, reason: `${v.nodeName} would lose its requirement (${v.reason.toLowerCase()})` } + } + return { ok: true } +} + +/** Immutable rank set — ranks of 0 are dropped so allocations stay sparse. */ +export function withRank(plan: PlanAllocations, treeId: number, nodeId: number, rank: number): PlanAllocations { + const treeKey = String(treeId) + const nodeKey = String(nodeId) + const alloc = { ...(plan[treeKey] ?? {}) } + if (rank <= 0) delete alloc[nodeKey] + else alloc[nodeKey] = rank + return { ...plan, [treeKey]: alloc } +} + +export function addRank(plan: PlanAllocations, tree: AATreeData, node: AANode): PlanAllocations { + return withRank(plan, tree.tree_id, node.node_id, rankOf(plan[String(tree.tree_id)], node.node_id) + 1) +} + +export function removeRank(plan: PlanAllocations, tree: AATreeData, node: AANode): PlanAllocations { + return withRank(plan, tree.tree_id, node.node_id, rankOf(plan[String(tree.tree_id)], node.node_id) - 1) +} + +/** Seed a plan from the character's live spent maps (CharAATree.spent). */ +export function planFromSpent(trees: { tree_id: number; spent: Record }[]): PlanAllocations { + const plan: PlanAllocations = {} + for (const t of trees) { + plan[String(t.tree_id)] = { ...t.spent } + } + return plan +} + +export interface SpendStep { + tree_id: number + node_id: number +} + +/** A legal purchase order for the whole plan: one step per POINT RANK, + * replayed greedily through canAddRank so every step is valid at its + * position (the in-game .aa loader replays purchases in file order). + * Ranks the rules can never reach (e.g. an off-era import) come back in + * ``unplaced`` instead of being emitted. */ +export function spendOrder(ctx: PlannerCtx, plan: PlanAllocations): { steps: SpendStep[]; unplaced: SpendStep[] } { + const remaining = new Map() // "treeId:nodeId" → ranks left + for (const tree of ctx.trees) { + const alloc = plan[String(tree.tree_id)] + if (!alloc) continue + for (const node of tree.nodes) { + const r = Math.min(rankOf(alloc, node.node_id), node.maxtier) + if (r > 0) remaining.set(`${tree.tree_id}:${node.node_id}`, r) + } + } + + // Stable emission order per tree: reading order (row, then column). + const orderedNodes = ctx.trees.map(tree => ({ + tree, + nodes: [...tree.nodes].sort((a, b) => a.ycoord - b.ycoord || a.xcoord - b.xcoord), + })) + + const steps: SpendStep[] = [] + let partial: PlanAllocations = {} + let progress = true + while (progress && remaining.size > 0) { + progress = false + for (const { tree, nodes } of orderedNodes) { + for (const node of nodes) { + const key = `${tree.tree_id}:${node.node_id}` + // Emit as many consecutive ranks as the rules allow right now — + // matches the game's own export shape (a node's ranks run together). + while ((remaining.get(key) ?? 0) > 0 && canAddRank(ctx, partial, tree, node).ok) { + partial = addRank(partial, tree, node) + steps.push({ tree_id: tree.tree_id, node_id: node.node_id }) + const left = (remaining.get(key) ?? 0) - 1 + if (left <= 0) remaining.delete(key) + else remaining.set(key, left) + progress = true + } + } + } + } + + const unplaced: SpendStep[] = [] + for (const [key, count] of remaining) { + const [treeId, nodeId] = key.split(':').map(Number) + for (let i = 0; i < count; i++) unplaced.push({ tree_id: treeId, node_id: nodeId }) + } + return { steps, unplaced } +} diff --git a/frontend/src/pages/filterTreeForEra.test.ts b/frontend/src/pages/filterTreeForEra.test.ts new file mode 100644 index 00000000..ad91ca10 --- /dev/null +++ b/frontend/src/pages/filterTreeForEra.test.ts @@ -0,0 +1,61 @@ +/** + * filterTreeForEra — the era node filter behind the AA tab (and the + * upcoming planner): hide tree rows that don't exist in the server's + * expansion (e.g. the class tree's Sentinel's-Fate rows on an EoF server). + */ +import { describe, expect, it } from 'vitest' + +import type { AATreeData } from '../components/AATree' +import { type AAConfig, filterTreeForEra } from './CharacterAAsTab' + +const node = (id: number, ycoord: number) => ({ + node_id: id, + name: `Node ${id}`, + description: '', + classification: 'Strength', + xcoord: 1, + ycoord, + icon_id: 0, + backdrop_id: -1, + maxtier: 10, + pointspertier: 1, + points_to_unlock: 0, + title: '', + spellcrc: 0, +}) + +const TREE: AATreeData = { + tree_id: 42, + tree_name: 'Shaman', + tree_type: 'class', + nodes: [node(1, 0), node(2, 1), node(3, 4), node(4, 5), node(5, 6)], +} + +const config = (visible?: Record): AAConfig => ({ + xpac: 'Echoes of Faydwer', + aa_cap: 100, + tradeskill_aa_cap: 45, + unlocked_tree_types: ['class', 'subclass', 'tradeskill'], + visible_rows: visible, +}) + +describe('filterTreeForEra', () => { + it('drops nodes on rows the era does not have', () => { + const filtered = filterTreeForEra(TREE, 'class', config({ class: [0, 1, 2, 3, 4] })) + expect(filtered.nodes.map(n => n.node_id)).toEqual([1, 2, 3]) // rows 5+6 gone + }) + + it('leaves trees alone when their type has no visible_rows entry', () => { + const filtered = filterTreeForEra(TREE, 'class', config({ subclass: [0, 3] })) + expect(filtered.nodes).toHaveLength(5) + }) + + it('leaves everything alone when visible_rows is absent (SF+ eras / old config)', () => { + expect(filterTreeForEra(TREE, 'class', config(undefined)).nodes).toHaveLength(5) + }) + + it('does not mutate the cached tree object', () => { + filterTreeForEra(TREE, 'class', config({ class: [0] })) + expect(TREE.nodes).toHaveLength(5) + }) +}) diff --git a/scripts/dev/Menludiir_templar_dps.aa b/scripts/dev/Menludiir_templar_dps.aa new file mode 100644 index 00000000..22a44d33 --- /dev/null +++ b/scripts/dev/Menludiir_templar_dps.aa @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/eq2db/test_aas.py b/tests/eq2db/test_aas.py index 3aef4579..fd7639ac 100644 --- a/tests/eq2db/test_aas.py +++ b/tests/eq2db/test_aas.py @@ -157,11 +157,57 @@ def test_limits_round_trip_and_alias_resolution(tmp_path, cat): conn.close() for query in ("Destiny of Velious", "DoV", "dov", " DOV "): lim = cat.xpac_limits(query) - assert lim == {"aa_cap": 300, "unlocked_trees": ["class", "subclass"]}, query + assert lim == {"aa_cap": 300, "unlocked_trees": ["class", "subclass"], "visible_rows": {}}, query assert cat.xpac_limits("Unknown Xpac") is None assert aas.AACatalogue(tmp_path / "missing.db").xpac_limits("DoV") is None +def test_limits_visible_rows_round_trip(cat): + """Era-partial trees: visible_rows survives the upsert → read cycle.""" + conn = cat.init_db() + try: + cat.upsert_limits( + conn, + "Echoes of Faydwer", + { + "aa_cap": 100, + "unlocked_trees": ["class", "subclass", "tradeskill"], + "visible_rows": {"class": [0, 1, 2, 3, 4], "subclass": [0, 3, 6, 9, 13]}, + }, + ) + finally: + conn.close() + lim = cat.xpac_limits("EoF") + assert lim is not None + assert lim["visible_rows"] == {"class": [0, 1, 2, 3, 4], "subclass": [0, 3, 6, 9, 13]} + + +def test_init_db_migrates_pre_visible_rows_limits_table(tmp_path): + """init_db on a DB whose aa_limits predates the visible_rows column must + migrate in place and keep existing rows readable. + + Memory [test-migrations-against-old-db-shape]. + """ + import sqlite3 + + db_path = tmp_path / "old-aas.db" + conn = sqlite3.connect(db_path) + conn.execute( + "CREATE TABLE aa_limits (xpac TEXT PRIMARY KEY, aa_cap INTEGER NOT NULL DEFAULT 0," + " unlocked_trees TEXT NOT NULL DEFAULT '[]', notes TEXT)" + ) + conn.execute( + "INSERT INTO aa_limits (xpac, aa_cap, unlocked_trees, notes) VALUES ('Kingdom of Sky', 50, '[\"class\"]', 'x')" + ) + conn.commit() + conn.close() + + cat = aas.AACatalogue(db_path) + cat.init_db().close() # applies the ALTER migration + lim = cat.xpac_limits("KoS") + assert lim == {"aa_cap": 50, "unlocked_trees": ["class"], "visible_rows": {}} + + # --------------------------------------------------------------------------- # detect_tree_type (pure heuristic — build-time) # --------------------------------------------------------------------------- @@ -210,6 +256,24 @@ def test_committed_db_limits(): assert aas.catalogue.xpac_limits("KoS") == aas.catalogue.xpac_limits("Kingdom of Sky") +@_committed +def test_committed_db_era_visible_rows(): + """The 2026-07 era curation: pre-Sentinel's-Fate xpacs hide the class + tree's rows 5-6 and the subclass rows 16/19 (verified against live + Wuoshi census data, boundary user-confirmed); SF+ show everything.""" + kos = aas.catalogue.xpac_limits("Kingdom of Sky") + assert kos is not None and kos["visible_rows"] == {"class": [0, 1, 2, 3, 4]} + for xpac in ("Echoes of Faydwer", "Rise of Kunark", "The Shadow Odyssey"): + lim = aas.catalogue.xpac_limits(xpac) + assert lim is not None, xpac + assert lim["visible_rows"] == { + "class": [0, 1, 2, 3, 4], + "subclass": [0, 3, 6, 9, 13], + }, xpac + sf = aas.catalogue.xpac_limits("Sentinel's Fate") + assert sf is not None and sf["visible_rows"] == {} + + @_committed def test_committed_db_meta_stamps(): conn = aas.catalogue.init_db() diff --git a/tests/server/test_aa_plans.py b/tests/server/test_aa_plans.py new file mode 100644 index 00000000..3877456f --- /dev/null +++ b/tests/server/test_aa_plans.py @@ -0,0 +1,170 @@ +"""AA planner saved builds — DB layer + API tests. + +Same harness as test_favorites.py: temp users.db via init_db + +point_users_db_at, signed session cookies against the global app fixture. +""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path + +import itsdangerous +import pytest +from httpx import ASGITransport, AsyncClient + +from backend.server.db import init_db +from backend.server.db.aa_plans import store as aa_plans +from tests.fixtures.users_db import point_users_db_at + +_TEST_SECRET = "pytest-session-secret-not-real-0123456789" + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def users_db(tmp_path) -> Path: + db = tmp_path / "users.db" + init_db(db) + return db + + +@pytest.fixture(autouse=True) +def _stores_at_tmp(users_db: Path, monkeypatch: pytest.MonkeyPatch): + point_users_db_at(monkeypatch, users_db) + + +_ALLOC = {"42": {"101": 10, "102": 3}, "29": {"7": 1}} + + +# --------------------------------------------------------------------------- +# DB layer +# --------------------------------------------------------------------------- + + +async def test_create_get_roundtrip_and_slug(users_db): + row = await aa_plans.create_plan("disc1", "Wuoshi", "Badbang", "Raid DPS", "EoF", json.dumps(_ALLOC)) + assert row["name"] == "Raid DPS" + assert row["share_slug"] + fetched = await aa_plans.get_plan(row["id"]) + assert fetched is not None and json.loads(fetched["allocations"]) == _ALLOC + by_slug = await aa_plans.get_plan_by_slug(row["share_slug"]) + assert by_slug is not None and by_slug["id"] == row["id"] + + +async def test_list_scoped_to_owner_world_character(users_db): + await aa_plans.create_plan("disc1", "Wuoshi", "Badbang", "Mine", None, "{}") + await aa_plans.create_plan("disc2", "Wuoshi", "Badbang", "Not mine", None, "{}") + await aa_plans.create_plan("disc1", "Varsoon", "Badbang", "Other world", None, "{}") + await aa_plans.create_plan("disc1", "Wuoshi", "Otherchar", "Other char", None, "{}") + rows = await aa_plans.list_plans("disc1", "Wuoshi", "Badbang") + assert [r["name"] for r in rows] == ["Mine"] + assert await aa_plans.count_plans("disc1", "Wuoshi", "Badbang") == 1 + + +async def test_update_and_delete_are_owner_scoped(users_db): + row = await aa_plans.create_plan("disc1", "Wuoshi", "Badbang", "Plan", None, "{}") + assert await aa_plans.update_plan(row["id"], "disc2", name="Stolen", xpac=None, allocations_json="{}") is False + assert await aa_plans.update_plan(row["id"], "disc1", name="Renamed", xpac="RoK", allocations_json='{"1":{"2":3}}') + updated = await aa_plans.get_plan(row["id"]) + assert updated is not None and updated["name"] == "Renamed" and updated["xpac"] == "RoK" + assert await aa_plans.delete_plan(row["id"], "disc2") is False + assert await aa_plans.delete_plan(row["id"], "disc1") is True + assert await aa_plans.get_plan(row["id"]) is None + + +# --------------------------------------------------------------------------- +# API +# --------------------------------------------------------------------------- + + +def _cookies(user_id: str) -> dict: + payload = base64.b64encode(json.dumps({"user": {"id": user_id, "username": "tester"}}).encode()).decode() + return {"session": itsdangerous.TimestampSigner(_TEST_SECRET).sign(payload).decode()} + + +def _client(app, user_id: str | None = "disc-1") -> AsyncClient: + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies=_cookies(user_id) if user_id else None, + ) + + +_BODY = {"character_name": "Badbang", "name": "Raid DPS", "xpac": "EoF", "allocations": _ALLOC} + + +async def test_requires_session(app): + async with _client(app, user_id=None) as client: + assert (await client.get("/api/aa/plans?character=Badbang")).status_code == 401 + assert (await client.post("/api/aa/plans", json=_BODY)).status_code == 401 + + +async def test_create_list_get_flow(app): + async with _client(app) as client: + created = await client.post("/api/aa/plans", json=_BODY) + assert created.status_code == 200 + plan = created.json() + assert plan["allocations"] == _ALLOC + assert plan["is_mine"] is True + assert plan["share_slug"] + + listed = await client.get("/api/aa/plans?character=badbang") # lowercase URL is normalised + assert [p["name"] for p in listed.json()] == ["Raid DPS"] + + detail = await client.get(f"/api/aa/plans/{plan['id']}") + assert detail.status_code == 200 + + +async def test_other_users_cannot_see_or_edit_my_plan(app): + async with _client(app) as client: + plan = (await client.post("/api/aa/plans", json=_BODY)).json() + async with _client(app, user_id="disc-2") as other: + assert (await other.get(f"/api/aa/plans/{plan['id']}")).status_code == 404 + assert (await other.put(f"/api/aa/plans/{plan['id']}", json=_BODY)).status_code == 404 + assert (await other.delete(f"/api/aa/plans/{plan['id']}")).json() == {"deleted": False} + # …but the share slug is readable, flagged not-mine. + shared = await other.get(f"/api/aa/plan/{plan['share_slug']}") + assert shared.status_code == 200 + assert shared.json()["is_mine"] is False + + +async def test_update_and_delete_flow(app): + async with _client(app) as client: + plan = (await client.post("/api/aa/plans", json=_BODY)).json() + updated = await client.put( + f"/api/aa/plans/{plan['id']}", + json={**_BODY, "name": "Tank build", "allocations": {"42": {"101": 5}}}, + ) + assert updated.status_code == 200 + assert updated.json()["name"] == "Tank build" + assert updated.json()["allocations"] == {"42": {"101": 5}} + assert (await client.delete(f"/api/aa/plans/{plan['id']}")).json() == {"deleted": True} + assert (await client.get(f"/api/aa/plans/{plan['id']}")).status_code == 404 + + +async def test_plan_cap_409(app, monkeypatch): + monkeypatch.setattr("backend.server.api.aa_plans.MAX_PLANS_PER_CHARACTER", 2) + async with _client(app) as client: + for i in range(2): + assert (await client.post("/api/aa/plans", json={**_BODY, "name": f"Plan {i}"})).status_code == 200 + blocked = await client.post("/api/aa/plans", json={**_BODY, "name": "One too many"}) + assert blocked.status_code == 409 + + +async def test_validation_rejects_bad_payloads(app): + async with _client(app) as client: + bad_char = await client.post("/api/aa/plans", json={**_BODY, "character_name": "not a name!!"}) + assert bad_char.status_code == 400 + empty_name = await client.post("/api/aa/plans", json={**_BODY, "name": " "}) + assert empty_name.status_code == 400 + bad_rank = await client.post("/api/aa/plans", json={**_BODY, "allocations": {"42": {"101": 0}}}) + assert bad_rank.status_code == 422 + bad_key = await client.post("/api/aa/plans", json={**_BODY, "allocations": {"nope": {"101": 1}}}) + assert bad_key.status_code == 422 + + +async def test_unknown_slug_404(app): + async with _client(app) as client: + assert (await client.get("/api/aa/plan/doesnotexist")).status_code == 404 diff --git a/tests/server/test_aa_routes.py b/tests/server/test_aa_routes.py index 9e3f4635..bfbf4d00 100644 --- a/tests/server/test_aa_routes.py +++ b/tests/server/test_aa_routes.py @@ -133,6 +133,7 @@ async def test_limits_file_present_returns_xpac_values(self, app, tmp_path) -> N "Varsoon": { "aa_cap": 320, "unlocked_trees": ["class", "subclass", "shadows"], + "visible_rows": {"class": [0, 1, 2, 3, 4]}, } } mock_server = MagicMock() @@ -149,6 +150,36 @@ async def test_limits_file_present_returns_xpac_values(self, app, tmp_path) -> N body = r.json() assert body["aa_cap"] == 320 assert "class" in body["unlocked_tree_types"] + assert body["visible_rows"] == {"class": [0, 1, 2, 3, 4]} + + async def test_explicit_xpac_param_overrides_server_era(self, app, tmp_path) -> None: + """The planner's era dropdown passes ?xpac= — resolved independently of + the active server's current_xpac; unknown eras 404.""" + limits_data = { + "Varsoon": {"aa_cap": 320, "unlocked_trees": ["class", "subclass", "shadows"]}, + "Kingdom of Sky": { + "aa_cap": 50, + "unlocked_trees": ["class"], + "visible_rows": {"class": [0, 1, 2, 3, 4]}, + }, + } + mock_server = MagicMock() + mock_server.current_xpac = "Varsoon" + + with ( + self._limits_db(tmp_path, limits_data), + patch("backend.server.api.aa.current_server", return_value=mock_server), + ): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.get("/api/aa/config?xpac=Kingdom of Sky") + unknown = await client.get("/api/aa/config?xpac=Neverquest") + + assert r.status_code == 200 + body = r.json() + assert body["aa_cap"] == 50 + assert body["unlocked_tree_types"] == ["class"] + assert body["visible_rows"] == {"class": [0, 1, 2, 3, 4]} + assert unknown.status_code == 404 async def test_tradeskill_cap_derived_from_unlocked_trees(self, app, tmp_path) -> None: """tradeskill_aa_cap = Σ max points of the UNLOCKED tradeskill trees, derived @@ -526,3 +557,46 @@ async def test_requested_tier_zero_becomes_none_in_response(self, app) -> None: body = r.json() assert body["requested_tier"] is None + + +# --------------------------------------------------------------------------- +# GET /aa/plan-trees — subclass → plannable trees (committed aas.db) +# --------------------------------------------------------------------------- + + +class TestPlanTrees: + @pytest.mark.asyncio + async def test_templar_resolves_all_four_trees(self, app) -> None: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + r = await client.get("/api/aa/plan-trees?cls=Templar") + assert r.status_code == 200 + body = r.json() + assert [(e["tree_type"], e["tree_id"]) for e in body] == [ + ("class", 3), # Cleric + ("subclass", 25), # Templar + ("shadows", 49), + ("heroic", 63), + ] + assert body[0]["tree_name"] == "Cleric" + + @pytest.mark.asyncio + async def test_unknown_class_404s_and_case_is_forgiven(self, app) -> None: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + ok = await client.get("/api/aa/plan-trees?cls=mystic") + bad = await client.get("/api/aa/plan-trees?cls=Clown") + assert ok.status_code == 200 + assert bad.status_code == 404 + + def test_shadows_mapping_matches_node_classifications(self) -> None: + """The empirically-mined shadows mapping must agree with the committed + db's node classifications (each shadows tree names its subclass in a + node classification) — catches tree-id reshuffles on rebuilds.""" + from backend.eq2db.aas import catalogue + from backend.server.api.aa import _SHADOWS_TREE_BY_SUBCLASS + + for subclass, tree_id in _SHADOWS_TREE_BY_SUBCLASS.items(): + tree = catalogue.get_tree(tree_id) + assert tree is not None, f"{subclass}: shadows tree {tree_id} missing" + assert tree["tree_type"] == "shadows", f"{subclass}: tree {tree_id} is {tree['tree_type']}" + classifications = {(n["classification"] or "").strip() for n in tree["nodes"]} + assert subclass in classifications, f"{subclass}: tree {tree_id} classifications {classifications}" diff --git a/tests/server/test_db_facade.py b/tests/server/test_db_facade.py index acd070c6..a83e735b 100644 --- a/tests/server/test_db_facade.py +++ b/tests/server/test_db_facade.py @@ -39,6 +39,14 @@ "get_schedule", "replace_schedule", "list_all_teams_with_twitch", + # aa_plans domain bypasses the facade too (routes use the store directly) + "list_plans", + "count_plans", + "get_plan", + "get_plan_by_slug", + "create_plan", + "update_plan", + "delete_plan", # raid_planning + availability domains bypass the facade too "get_roles", "set_role",