From 66f4038dee84bfbb0b3896a3ad8ff9a0953fd1c8 Mon Sep 17 00:00:00 2001 From: VortexUK Date: Tue, 21 Jul 2026 00:06:52 +0100 Subject: [PATCH] =?UTF-8?q?feat(aa):=20AA=20planner=20=E2=80=94=20era=20ru?= =?UTF-8?q?les,=20validation=20engine,=20saved=20plans,=20.aa=20export?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Era data (aas.db + aa_limits.json): - Per-xpac node visibility curation: pre-Sentinel's-Fate eras hide class rows 5-6 and subclass rows 16/19 (verified against live Wuoshi census — 300 max-AA chars have zero spent points beyond those rows). aa_limits gains a visible_rows column (idempotent migration); xpac_limits returns it; /api/aa/config exposes it and accepts ?xpac= for era overrides. - The AA tab hides off-era nodes via the shared filterTreeForEra helper. - /api/aa/plan-trees resolves a subclass's class/subclass/shadows/heroic tree ids (shadows + heroic mined from live census — every shadows tree is named "Shadows" with no class field; a test cross-checks the mapping against node classifications so a rebuild reshuffle fails loudly). Planner (character AA tab "Planner" mode): - Pure validation engine: thresholds count AA points spent (rank × cost), self-exclusive unlock counters (line/tree/global/parent-rank), the flat 100/tree cap (confirmed in census maxpointsperlevelnode data), separate adventure/tradeskill pools, and no-stranding removals (refunds re-validate everything taken). Shadows section gating (5 pts in the section above) is structural — not in census node data — and lives in the engine. - Interactive AATree: click spend / right-click refund, locked greying, unmet requirements shown in the node tooltip and the status line. - Era dropdown (KoS→DoV): re-plans under any era's cap/trees/rows; the resolver offers trees the character's census record doesn't carry yet (e.g. shadows when era-planning TSO on an EoF server). - Saved plans: users.db aa_plans (owner-scoped in SQL, 20/character, name profanity-screened, structural payload validation) with always- minted share slugs; read-only /aa-plan/{slug} share page rendering under the plan's saved era with split adventure/tradeskill point totals. - .aa spec export (planner + current AAs + in-game profiles + share page): the game's own Save/Load XML format, reverse-engineered from a real export (scripts/dev/Menludiir_templar_dps.aa — all 47 node/tree pairs match aas.db). Purchase order is emitted by replaying the plan through the engine so every line is legal when the in-game loader replays it; unreachable ranks are skipped with a UI warning. Rendering + fixes: - Per-node backdrop sprites (bg_sprite.png CSS sprite math mirroring image/aa_tree.py) — subclass/shadows/heroic trees regain their icon chrome; class trees unchanged. - — 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",