diff --git a/README.md b/README.md index b54dba6..ab92bd8 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ npm run build | `POB_CMD` | `luajit` | LuaJIT binary path | | `POB_TIMEOUT_MS` | `10000` | Lua request timeout (ms) | | `POE_TRADE_ENABLED` | `false` | Enable Trade API tools | +| `POE_SESSION_ID` | (none) | POESESSID cookie value, used for fetching private PoE profiles via `lua_import_character` / `lua_list_characters`. **Sensitive** — treat like a password; do not commit or share. | ### Setting Up the Lua Bridge @@ -154,10 +155,12 @@ sudo apt-get install luajit ```bash git clone https://github.com/ianderse/PathOfBuilding.git cd PathOfBuilding -git checkout api-stdio +git checkout dev ``` Note the full path to the `src/` directory — that's your `POB_FORK_PATH`. +> **Note**: the JSON-RPC API now lives on the `dev` branch (the head of upstream PR [PathOfBuildingCommunity/PathOfBuilding#9505](https://github.com/PathOfBuildingCommunity/PathOfBuilding/pull/9505)), kept in sync with `PathOfBuildingCommunity/dev`. The older `api-stdio` branch is stale and missing recent tree-version data — using it will crash on `lua_load_build` for current-league builds. + #### 3. Verify ```bash luajit -v @@ -166,11 +169,29 @@ ls /path/to/PathOfBuilding/src/HeadlessWrapper.lua #### 4. Update Claude Desktop config and restart Claude Desktop +### Importing a Live Character from PoE + +You can import any of your live characters directly from the official Path of Exile API into the loaded build — tree, jewels, items, and skill gems are pulled from the game and applied: + +``` +1. lua_start +2. lua_list_characters (account_name: "YourName#1234") +3. lua_new_build (or lua_load_build for an existing template) +4. lua_import_character (account_name: "YourName#1234", character_name: "MyChar") +5. lua_save_build (build_name: "MyChar.xml") +``` + +`lua_import_character` returns a before/after diff so you can see exactly what changed (stats, items per slot, skill groups, tree node count). The active spec, items, and gems are **replaced**; build notes, configuration, other specs, and other item sets are **preserved**. + +**Weapon swap behavior**: by default, the import places the in-game active weapons in the primary slots and forces PoB's calc engine to use those primary slots (so PoB stats match what you actually wear in game). If you maintain a custom swap configuration in PoB (e.g. leveling weapons stored in the swap slots) and want it preserved, pass `ignore_weapon_swap: true` — that skips the swap-slot import AND leaves your existing weapon-set toggle untouched. + +For **private profiles** (the PoE default), set `POE_SESSION_ID` to your `POESESSID` cookie value (32 hex chars from `pathofexile.com` cookies). Treat it like a password — never commit it. Public profiles do not need this. + --- ## Available Tools -The server registers **91 tools** across 10 categories. +The server registers **93 tools** across 10 categories. ### XML-Based Tools (Always Available) @@ -223,6 +244,8 @@ The server registers **91 tools** across 10 categories. | `list_item_sets` | List all item sets in the current build | | `select_item_set` | Switch active item set | | `plan_leveling` | Generate a leveling plan for a build | +| `lua_list_characters` | List characters on a PoE account via the official API (sorted by last login) | +| `lua_import_character` | Import a live character (tree/jewels/items/gems) into the loaded build with a before/after diff | **`lua_set_tree` class IDs**: 0=Scion, 1=Marauder, 2=Ranger, 3=Witch, 4=Duelist, 5=Templar, 6=Shadow diff --git a/src/handlers/importHandlers.ts b/src/handlers/importHandlers.ts new file mode 100644 index 0000000..e50d0b1 --- /dev/null +++ b/src/handlers/importHandlers.ts @@ -0,0 +1,594 @@ +/** + * Character Import Handlers + * + * Bridge between the official PoE character API (HTTP, on the Node side) and + * the Lua import handlers (`import_passive_tree`, `import_items_skills`). + * + * PoB headless cannot make HTTP requests itself (lcurl is disabled), so this + * module fetches the JSON bodies from pathofexile.com and forwards them to + * the Lua side for the actual import. + */ + +import type { LuaHandlerContext } from "./luaHandlers.js"; +import { + fetchCharacterList, + fetchPassiveSkills, + fetchItems, + type PoeRealm, + type PoeCharacterListEntry, +} from "../services/poeCharacterApi.js"; +import { wrapHandler } from "../utils/errorHandling.js"; + +/** Options accepted by `handleImportCharacter`, mirroring the PoB GUI defaults. */ +export interface ImportCharacterOptions { + clearJewels?: boolean; + clearItems?: boolean; + clearSkills?: boolean; + ignoreWeaponSwap?: boolean; +} + +/** Subset of the LuaHandlerContext that import handlers actually need. */ +export type ImportHandlerContext = Pick< + LuaHandlerContext, + "getLuaClient" | "ensureLuaClient" +>; + +const CLASS_NAMES: Record = { + 0: "Scion", + 1: "Marauder", + 2: "Ranger", + 3: "Witch", + 4: "Duelist", + 5: "Templar", + 6: "Shadow", +}; + +const ASCENDANCY_NAMES: Record> = { + 0: { 1: "Ascendant" }, + 1: { 1: "Juggernaut", 2: "Berserker", 3: "Chieftain" }, + 2: { 1: "Raider", 2: "Deadeye", 3: "Pathfinder" }, + 3: { 1: "Occultist", 2: "Elementalist", 3: "Necromancer" }, + 4: { 1: "Slayer", 2: "Gladiator", 3: "Champion" }, + 5: { 1: "Inquisitor", 2: "Hierophant", 3: "Guardian" }, + 6: { 1: "Assassin", 2: "Trickster", 3: "Saboteur" }, +}; + +// Reverse-map the API's `class` field (which is the ascendancy name) to the +// base class. The PoE get-characters endpoint does NOT return classId. +const ASCENDANCY_TO_BASE_CLASS: Record = (() => { + const map: Record = {}; + for (const [classIdStr, ascObj] of Object.entries(ASCENDANCY_NAMES)) { + const baseClass = CLASS_NAMES[Number(classIdStr)]; + if (!baseClass) continue; + for (const ascName of Object.values(ascObj)) { + map[ascName] = baseClass; + } + } + // Also include base-class names so an unascended character maps to itself. + for (const baseClass of Object.values(CLASS_NAMES)) { + map[baseClass] = baseClass; + } + return map; +})(); + +function classLabel(entry: PoeCharacterListEntry): string { + if (typeof entry.class === "string" && entry.class) return entry.class; + if (typeof entry.classId === "number") return CLASS_NAMES[entry.classId] ?? `Class ${entry.classId}`; + return "Unknown"; +} + +function baseClassFor(entry: PoeCharacterListEntry): string { + const ascOrClass = classLabel(entry); + return ASCENDANCY_TO_BASE_CLASS[ascOrClass] ?? "Unknown"; +} + +function ascendancyLabel(entry: PoeCharacterListEntry): string { + const ascOrClass = classLabel(entry); + const base = ASCENDANCY_TO_BASE_CLASS[ascOrClass]; + if (!base) return "Unknown"; + // If `class` IS a base class, the character has no ascendancy yet. + if (base === ascOrClass) return "None"; + return ascOrClass; +} + +function resolveAccountName(accountName: string | undefined): string { + const explicit = accountName?.trim(); + if (explicit) return explicit; + + const fromEnv = process.env.POE_ACCOUNT_NAME?.trim(); + if (fromEnv) return fromEnv; + + throw new Error( + "account_name is required (with discriminator, e.g. account#1234), or set POE_ACCOUNT_NAME" + ); +} + +function formatLastLogin(unixSeconds: number | undefined, nowMs: number): string { + if (typeof unixSeconds !== "number" || !Number.isFinite(unixSeconds)) return "?"; + const ms = unixSeconds * 1000; + const date = new Date(ms); + const iso = date.toISOString().slice(0, 16).replace("T", " "); + const deltaMs = nowMs - ms; + if (deltaMs < 0) return `${iso} UTC (future?)`; + const deltaMin = Math.floor(deltaMs / 60_000); + let rel: string; + if (deltaMin < 60) rel = `${deltaMin}m ago`; + else if (deltaMin < 60 * 24) rel = `${Math.floor(deltaMin / 60)}h ago`; + else rel = `${Math.floor(deltaMin / (60 * 24))}d ago`; + return `${iso} UTC (${rel})`; +} + +/** + * List all characters on a PoE account. + * Does NOT require the Lua bridge to be running — purely an HTTP call. + */ +export async function handleListCharacters( + _context: ImportHandlerContext, + accountName?: string, + realm?: string +) { + return wrapHandler("list characters", async () => { + const accountTrimmed = resolveAccountName(accountName); + + const effectiveRealm = (realm ?? "pc") as PoeRealm; + const characters = await fetchCharacterList(accountTrimmed, effectiveRealm); + + if (characters.length === 0) { + return { + content: [ + { + type: "text" as const, + text: + `No characters found on account "${accountTrimmed}" (realm: ${effectiveRealm}).\n\n` + + `Tips:\n` + + `- Make sure the account name includes the discriminator (e.g. account#1234).\n` + + `- If the profile is private, set POE_SESSION_ID env var.`, + }, + ], + }; + } + + // Sort by last-login descending so the active character is on top. + const sorted = [...characters].sort((a, b) => { + const ta = typeof a.lastLoginTime === "number" ? a.lastLoginTime : 0; + const tb = typeof b.lastLoginTime === "number" ? b.lastLoginTime : 0; + return tb - ta; + }); + + // Render a markdown table with all fields the API actually returns. + const lines: string[] = []; + lines.push(`=== Characters on ${accountTrimmed} ===`); + lines.push(""); + lines.push(`| Name | Level | Class | Ascendancy | League | Realm | Last Login | Pinnable |`); + lines.push(`|------|-------|-------|------------|--------|-------|------------|----------|`); + const nowMs = Date.now(); + for (const c of sorted) { + const level = typeof c.level === "number" ? String(c.level) : "?"; + const base = baseClassFor(c); + const asc = ascendancyLabel(c); + const league = typeof c.league === "string" ? c.league : "?"; + const realm = typeof c.realm === "string" ? c.realm : "?"; + const lastLogin = formatLastLogin(c.lastLoginTime, nowMs); + let pinnable: string = "?"; + if (typeof c.pinnable === "boolean") pinnable = c.pinnable ? "yes" : "no"; + lines.push(`| ${c.name} | ${level} | ${base} | ${asc} | ${league} | ${realm} | ${lastLogin} | ${pinnable} |`); + } + lines.push(""); + lines.push(`Total: ${characters.length} character(s). Sorted by most recent login.`); + lines.push( + `Use lua_import_character with account_name + character_name to import one into the loaded build.` + ); + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; + }); +} + +/** + * A snapshot of the build state captured before/after import so we can + * compute a diff and show the user what actually changed. + */ +interface BuildSnapshot { + level: number | null; + className: string | null; + ascendClassName: string | null; + treeNodeCount: number; + stats: Record; + // slot name → item summary + itemsBySlot: Record; + socketGroups: Array<{ + index: number; + label: string; + slot: string; + gems: Array<{ name: string; level: number; quality: number }>; + }>; +} + +/** Stat keys we surface in the diff — kept short, focused on actionable numbers. */ +const DIFF_STATS: ReadonlyArray<{ key: string; label: string; pct?: boolean }> = [ + { key: "Life", label: "Life" }, + { key: "EnergyShield", label: "ES" }, + { key: "Mana", label: "Mana" }, + { key: "TotalEHP", label: "EHP" }, + { key: "Armour", label: "Armour" }, + { key: "Evasion", label: "Evasion" }, + { key: "FireResist", label: "Fire Res", pct: true }, + { key: "ColdResist", label: "Cold Res", pct: true }, + { key: "LightningResist", label: "Light Res", pct: true }, + { key: "ChaosResist", label: "Chaos Res", pct: true }, + { key: "BlockChance", label: "Block", pct: true }, + { key: "EffectiveSpellSuppressionChance", label: "Spell Supp", pct: true }, +]; + +async function captureBuildSnapshot( + luaClient: import("../pobLuaBridge.js").PoBLuaApiClient +): Promise { + // The bridge does not support concurrent requests (single-threaded stdio), + // so we MUST call these sequentially, not via Promise.all. + const info = await luaClient.getBuildInfo().catch(() => null); + const stats = await luaClient.getStats().catch(() => ({})); + const items = await luaClient.getItems().catch(() => []); + const skills = await luaClient.getSkills().catch(() => null); + const tree = await luaClient.getTree().catch(() => null); + + const itemsBySlot: BuildSnapshot["itemsBySlot"] = {}; + if (Array.isArray(items)) { + for (const it of items) { + if (!it || typeof it !== "object") continue; + const slot = typeof it.slot === "string" ? it.slot : null; + const name = typeof it.name === "string" ? it.name : null; + if (!slot || !name) continue; + // PoB returns id=0 for empty slot entries. + if (it.id === 0) continue; + itemsBySlot[slot] = { + name, + baseName: typeof it.baseName === "string" ? it.baseName : undefined, + rarity: typeof it.rarity === "string" ? it.rarity : undefined, + }; + } + } + + const socketGroups: BuildSnapshot["socketGroups"] = []; + if (skills && Array.isArray(skills.groups)) { + for (const g of skills.groups) { + if (!g || typeof g !== "object") continue; + const gemList: BuildSnapshot["socketGroups"][number]["gems"] = []; + if (Array.isArray(g.gems)) { + for (const gem of g.gems) { + if (!gem || typeof gem.name !== "string") continue; + gemList.push({ + name: gem.name, + level: typeof gem.level === "number" ? gem.level : 0, + quality: typeof gem.quality === "number" ? gem.quality : 0, + }); + } + } + socketGroups.push({ + index: typeof g.index === "number" ? g.index : 0, + label: typeof g.label === "string" ? g.label : "", + slot: typeof g.slot === "string" ? g.slot : "", + gems: gemList, + }); + } + } + + return { + level: info && typeof info.level === "number" ? info.level : null, + className: info && typeof info.className === "string" ? info.className : null, + ascendClassName: + info && typeof info.ascendClassName === "string" ? info.ascendClassName : null, + treeNodeCount: tree && Array.isArray(tree.nodes) ? tree.nodes.length : 0, + stats: (stats as Record) || {}, + itemsBySlot, + socketGroups, + }; +} + +function fmtNum(n: number, pct: boolean | undefined): string { + if (!Number.isFinite(n)) return "?"; + if (pct) return `${Math.round(n)}%`; + if (Math.abs(n) >= 1000) return Math.round(n).toLocaleString("en-US"); + return Math.round(n * 10) / 10 + ""; +} + +function fmtDelta(delta: number, pct: boolean | undefined): string { + if (delta === 0) return "—"; + const sign = delta > 0 ? "+" : ""; + return `${sign}${fmtNum(delta, pct)}`; +} + +function buildStatsDiff(before: BuildSnapshot, after: BuildSnapshot): string[] { + const lines: string[] = []; + lines.push(`| Stat | Before | After | Δ |`); + lines.push(`|------|--------|-------|---|`); + for (const { key, label, pct } of DIFF_STATS) { + const b = Number(before.stats[key] ?? 0); + const a = Number(after.stats[key] ?? 0); + if (b === 0 && a === 0) continue; + const delta = a - b; + const indicator = delta > 0 ? "▲" : delta < 0 ? "▼" : "="; + lines.push( + `| ${label} | ${fmtNum(b, pct)} | ${fmtNum(a, pct)} | ${indicator} ${fmtDelta(delta, pct)} |` + ); + } + return lines; +} + +function buildItemsDiff(before: BuildSnapshot, after: BuildSnapshot): string[] { + const lines: string[] = []; + const slots = new Set([ + ...Object.keys(before.itemsBySlot), + ...Object.keys(after.itemsBySlot), + ]); + const orderedSlots = Array.from(slots).sort((a, b) => a.localeCompare(b)); + + const added: string[] = []; + const removed: string[] = []; + const replaced: string[] = []; + const unchanged: string[] = []; + + for (const slot of orderedSlots) { + const b = before.itemsBySlot[slot]; + const a = after.itemsBySlot[slot]; + if (!b && a) added.push(`+ **${slot}**: ${a.name}${a.rarity ? ` (${a.rarity})` : ""}`); + else if (b && !a) removed.push(`- **${slot}**: ${b.name}${b.rarity ? ` (${b.rarity})` : ""}`); + else if (b && a) { + if (b.name !== a.name || b.baseName !== a.baseName) { + replaced.push(`~ **${slot}**: ${b.name} → ${a.name}`); + } else { + unchanged.push(slot); + } + } + } + + lines.push(`Items: ${added.length} added, ${removed.length} removed, ${replaced.length} replaced, ${unchanged.length} unchanged.`); + if (added.length || removed.length || replaced.length) { + lines.push(""); + if (added.length) { + lines.push("**Added:**"); + lines.push(...added); + } + if (removed.length) { + if (added.length) lines.push(""); + lines.push("**Removed:**"); + lines.push(...removed); + } + if (replaced.length) { + if (added.length || removed.length) lines.push(""); + lines.push("**Replaced (same slot):**"); + lines.push(...replaced); + } + } + return lines; +} + +function gemSignature(g: { name: string; level: number; quality: number }): string { + return `${g.name} ${g.level}/${g.quality}`; +} + +function buildSkillsDiff(before: BuildSnapshot, after: BuildSnapshot): string[] { + const lines: string[] = []; + const totalBefore = before.socketGroups.length; + const totalAfter = after.socketGroups.length; + const totalGemsBefore = before.socketGroups.reduce((acc, g) => acc + g.gems.length, 0); + const totalGemsAfter = after.socketGroups.reduce((acc, g) => acc + g.gems.length, 0); + + lines.push( + `Socket groups: ${totalBefore} → ${totalAfter} (${fmtDelta(totalAfter - totalBefore, false)}). ` + + `Gems total: ${totalGemsBefore} → ${totalGemsAfter} (${fmtDelta(totalGemsAfter - totalGemsBefore, false)}).` + ); + + // Try to match groups by label+slot. If too noisy, just show overall. + const beforeKeys = new Set(before.socketGroups.map((g) => `${g.label}|${g.slot}`)); + const afterKeys = new Set(after.socketGroups.map((g) => `${g.label}|${g.slot}`)); + const removedGroups = before.socketGroups.filter((g) => !afterKeys.has(`${g.label}|${g.slot}`)); + const addedGroups = after.socketGroups.filter((g) => !beforeKeys.has(`${g.label}|${g.slot}`)); + + if (removedGroups.length || addedGroups.length) { + lines.push(""); + if (removedGroups.length) { + lines.push(`**Removed groups (${removedGroups.length}):**`); + for (const g of removedGroups) { + lines.push(`- ${g.label || "(no label)"} [${g.slot}]: ${g.gems.map(gemSignature).join(", ") || "no gems"}`); + } + } + if (addedGroups.length) { + if (removedGroups.length) lines.push(""); + lines.push(`**Added groups (${addedGroups.length}):**`); + for (const g of addedGroups) { + lines.push(`+ ${g.label || "(no label)"} [${g.slot}]: ${g.gems.map(gemSignature).join(", ") || "no gems"}`); + } + } + } + return lines; +} + +/** + * Import a character from the official PoE API into the currently loaded + * Lua-bridge build. Replaces tree, jewels, items, and skill gems. + * + * Captures a before/after snapshot and reports stat deltas + item/gem changes + * so the user can verify the import did what they expected. + */ +export async function handleImportCharacter( + context: ImportHandlerContext, + accountName: string | undefined, + characterName: string, + realm?: string, + options?: ImportCharacterOptions +) { + return wrapHandler("import character", async () => { + const accountTrimmed = resolveAccountName(accountName); + if (!characterName || !characterName.trim()) { + throw new Error("character_name is required"); + } + + const effectiveRealm = (realm ?? "pc") as PoeRealm; + const charTrimmed = characterName.trim(); + + // 1. Bridge required — must have a build loaded for items/tree to attach to. + await context.ensureLuaClient(); + const luaClient = context.getLuaClient(); + if (!luaClient) { + throw new Error("Lua client not initialized"); + } + + const buildInfo = await luaClient.getBuildInfo().catch(() => null); + if (!buildInfo) { + throw new Error("No build loaded. Use lua_load_build or lua_new_build first."); + } + + // Resolve defaults to match the PoB GUI behavior. + const clearJewels = options?.clearJewels ?? true; + const clearItems = options?.clearItems ?? true; + const clearSkills = options?.clearSkills ?? true; + const ignoreWeaponSwap = options?.ignoreWeaponSwap ?? false; + + // 2. Fetch passive skills + items + character list in parallel. + // The list is needed only for `name`, `level`, `league`, and the + // ascendancy name (returned in `class`); the actual classId / ascendId + // come from the passive-skills JSON itself, not from this list. + // See ImportTab.lua:709 for the canonical contract — `charData` only + // needs `name`, `level`, `class`, and `league`. + const [passiveJson, itemsJson, characters] = await Promise.all([ + fetchPassiveSkills(accountTrimmed, charTrimmed, effectiveRealm), + fetchItems(accountTrimmed, charTrimmed, effectiveRealm), + fetchCharacterList(accountTrimmed, effectiveRealm), + ]); + + // 3. Find this character in the list to build char_data. + const charMeta = characters.find( + (c) => typeof c.name === "string" && c.name === charTrimmed + ); + if (!charMeta) { + throw new Error( + `Character "${charTrimmed}" not found on account "${accountTrimmed}" (realm: ${effectiveRealm}). ` + + `Use lua_list_characters to see available characters.` + ); + } + + const charDataForLua = { + name: charMeta.name, + level: typeof charMeta.level === "number" ? charMeta.level : 1, + class: classLabel(charMeta), + league: typeof charMeta.league === "string" ? charMeta.league : "Standard", + }; + + // 4. Snapshot the build state BEFORE applying the import. + const before = await captureBuildSnapshot(luaClient); + + // 5. Apply imports — sequential because the Lua bridge is single-request. + // A failure between the tree and items calls leaves the build in a + // partial state (new tree, old items). Surface that explicitly so the + // user knows they should `lua_reload_build` to revert. + try { + await luaClient.importPassiveTree({ + json: passiveJson, + char_data: charDataForLua, + clear_jewels: clearJewels, + }); + } catch (err) { + throw new Error( + `import_passive_tree failed: ${(err as Error).message}. The build is unchanged — no rollback needed.` + ); + } + + try { + await luaClient.importItemsSkills({ + json: itemsJson, + clear_items: clearItems, + clear_skills: clearSkills, + ignore_weapon_swap: ignoreWeaponSwap, + }); + } catch (err) { + throw new Error( + `import_items_skills failed after the passive tree was already imported: ${(err as Error).message}. ` + + `The build is in a PARTIAL state (new tree, old items). Run lua_reload_build to revert to the on-disk state, ` + + `or lua_save_build to a new filename to keep the partial state.` + ); + } + + // 6. Snapshot AFTER and compute the diff. + const after = await captureBuildSnapshot(luaClient); + + // 7. Format the report. + const ascLabel = ascendancyLabel(charMeta); + const baseClass = baseClassFor(charMeta); + const importedParts: string[] = ["passive tree"]; + if (clearJewels) importedParts.push("jewels"); + if (clearItems) importedParts.push("items"); + if (clearSkills) importedParts.push("skill gems"); + if (ignoreWeaponSwap) importedParts.push("(ignored weapon swap items)"); + + const lines: string[] = []; + lines.push(`# Import: "${charDataForLua.name}"`); + lines.push(""); + lines.push( + `Level ${charDataForLua.level} ${baseClass}${ascLabel !== "None" ? ` (${ascLabel})` : ""} — ${charDataForLua.league} (${effectiveRealm})` + ); + lines.push(""); + lines.push(`Replaced from PoE API: ${importedParts.join(", ")}.`); + + // Class / ascendancy / level deltas (most often unchanged but worth showing). + if ( + before.level !== after.level || + before.className !== after.className || + before.ascendClassName !== after.ascendClassName + ) { + lines.push(""); + lines.push(`## Build identity`); + lines.push( + `Level: ${before.level ?? "?"} → ${after.level ?? "?"} (${fmtDelta((after.level ?? 0) - (before.level ?? 0), false)})` + ); + lines.push( + `Class: ${before.className ?? "?"} → ${after.className ?? "?"}` + ); + lines.push( + `Ascendancy: ${before.ascendClassName ?? "?"} → ${after.ascendClassName ?? "?"}` + ); + } + + // Tree node count. + const treeDelta = after.treeNodeCount - before.treeNodeCount; + lines.push(""); + lines.push(`## Passive tree`); + lines.push( + `Allocated nodes: ${before.treeNodeCount} → ${after.treeNodeCount} (${fmtDelta(treeDelta, false)})` + ); + + // Stats diff. + lines.push(""); + lines.push(`## Stats`); + lines.push(...buildStatsDiff(before, after)); + + // Items diff. + lines.push(""); + lines.push(`## Items`); + lines.push(...buildItemsDiff(before, after)); + + // Skills diff. + lines.push(""); + lines.push(`## Skills`); + lines.push(...buildSkillsDiff(before, after)); + + // What's preserved (informational footer). + lines.push(""); + lines.push(`## Preserved (NOT touched by import)`); + lines.push( + `- Configuration (bandit, pantheon, enemy stats, charge toggles)` + ); + lines.push(`- Build notes`); + lines.push(`- Other tree specs (only the active spec was replaced)`); + lines.push(`- Other item sets (only the active set was replaced)`); + lines.push(`- pob-mcp snapshots/history`); + lines.push(""); + lines.push( + `Run \`lua_save_build\` to persist these changes to disk, or \`validate_build\` for a full health audit.` + ); + + return { + content: [{ type: "text" as const, text: lines.join("\n") }], + }; + }); +} diff --git a/src/pobLuaBridge.ts b/src/pobLuaBridge.ts index 66589f3..a5394e7 100644 --- a/src/pobLuaBridge.ts +++ b/src/pobLuaBridge.ts @@ -286,7 +286,7 @@ export class PoBLuaApiClient { return res.tree; } - + async getItems(): Promise { const res = await this.send({ action: "get_items" }); @@ -477,6 +477,27 @@ async setTree(params: { return res.result; } + async importPassiveTree(params: { json: string; char_data: any; clear_jewels?: boolean }): Promise { + const res = await this.send({ action: "import_passive_tree", params }); + if (!res.ok) throw new Error(res.error || "import_passive_tree failed"); + return { + status: res.status, + level: res.level, + className: res.className, + ascendClassName: res.ascendClassName, + }; + } + + async importItemsSkills(params: { json: string; clear_items?: boolean; clear_skills?: boolean; ignore_weapon_swap?: boolean }): Promise { + const res = await this.send({ action: "import_items_skills", params }); + if (!res.ok) throw new Error(res.error || "import_items_skills failed"); + return { + status: res.status, + level: res.level, + character: res.character, + }; + } + async stop(): Promise { if (!this.proc) return; try { diff --git a/src/server/toolRouter.ts b/src/server/toolRouter.ts index 57d8411..8bc34f7 100644 --- a/src/server/toolRouter.ts +++ b/src/server/toolRouter.ts @@ -18,6 +18,7 @@ import { handleStartWatching, handleStopWatching, handleGetRecentChanges, handle import { handleCompareTrees, handleGetNearbyNodes, handleFindPath, handleGetPassiveUpgrades, handleSuggestMasteries } from "../handlers/treeHandlers.js"; import { handleGetBuildIssues, formatIssuesResponse } from "../handlers/buildGoalsHandlers.js"; import { handleLuaStart, handleLuaStop, handleLuaNewBuild, handleLuaSaveBuild, handleLuaLoadBuild, handleLuaGetStats, handleLuaGetTree, handleLuaSetTree, handleSearchTreeNodes, handleLuaGetBuildInfo, handleLuaReloadBuild, handleUpdateTreeDelta, handleCreateSpec, handleListSpecs, handleSelectSpec, handleDeleteSpec, handleRenameSpec, handleListItemSets, handleSelectItemSet } from "../handlers/luaHandlers.js"; +import { handleListCharacters, handleImportCharacter } from "../handlers/importHandlers.js"; import { handleAddItem, handleGetEquippedItems, handleToggleFlask, handleGetSkillSetup, handleSetMainSkill, handleCreateSocketGroup, handleAddGem, handleSetGemLevel, handleSetGemQuality, handleRemoveSkill, handleRemoveGem, handleSetupSkillWithGems, handleAddMultipleItems, handleSetSocketGroupEnabled, handleSetGemEnabled } from "../handlers/itemSkillHandlers.js"; import { handleAnalyzeDefenses, handleSuggestOptimalNodes, handleOptimizeTree } from "../handlers/optimizationHandlers.js"; import { handleAnalyzeItems, handleOptimizeSkillLinks, handleCreateBudgetBuild } from "../handlers/advancedOptimizationHandlers.js"; @@ -158,6 +159,28 @@ export async function routeToolCall( args.name as string | undefined ); + case "lua_list_characters": + return await handleListCharacters( + luaContext, + args?.account_name as string | undefined, + args?.realm as string | undefined + ); + + case "lua_import_character": + if (!args?.character_name) throw new Error("Missing character_name"); + return await handleImportCharacter( + luaContext, + args.account_name as string | undefined, + args.character_name as string, + args.realm as string | undefined, + { + clearJewels: args.clear_jewels as boolean | undefined, + clearItems: args.clear_items as boolean | undefined, + clearSkills: args.clear_skills as boolean | undefined, + ignoreWeaponSwap: args.ignore_weapon_swap as boolean | undefined, + } + ); + case "set_character_level": { if (!args) throw new Error("Missing arguments"); const level = args.level as number; diff --git a/src/server/toolSchemas.ts b/src/server/toolSchemas.ts index 7d7cba8..caa142f 100644 --- a/src/server/toolSchemas.ts +++ b/src/server/toolSchemas.ts @@ -60,6 +60,25 @@ export function getToolSchemas(): ToolSchema[] { properties: {}, }, }, + { + name: "lua_list_characters", + description: "List all characters on a PoE account via the official API. Does not require the Lua bridge to be enabled. Use this to find a character to import. Account name needs the discriminator (e.g., account#1234). If omitted, falls back to POE_ACCOUNT_NAME.", + inputSchema: { + type: "object", + properties: { + account_name: { + type: "string", + description: "PoE account name including discriminator (e.g., 'account#1234'). Optional when POE_ACCOUNT_NAME is set.", + }, + realm: { + type: "string", + description: "Realm: 'pc', 'xbox', or 'sony' (default: 'pc').", + enum: ["pc", "xbox", "sony"], + default: "pc", + }, + }, + }, + }, { name: "get_build_stats", description: "Extract specific stats from a build (Life, DPS, resistances, etc.)", @@ -272,6 +291,50 @@ export function getLuaToolSchemas(): any[] { required: ["build_name"], }, }, + { + name: "lua_import_character", + description: "Import a character from the official PoE API into the currently loaded build (replaces tree, items, and gems). Requires a build loaded via lua_load_build or lua_new_build. If account_name is omitted, falls back to POE_ACCOUNT_NAME. Set POE_SESSION_ID env var for private profiles.", + inputSchema: { + type: "object", + properties: { + account_name: { + type: "string", + description: "PoE account name including discriminator (e.g., 'account#1234'). Optional when POE_ACCOUNT_NAME is set.", + }, + character_name: { + type: "string", + description: "Exact character name to import (case-sensitive).", + }, + realm: { + type: "string", + description: "Realm: 'pc', 'xbox', or 'sony' (default: 'pc').", + enum: ["pc", "xbox", "sony"], + default: "pc", + }, + clear_jewels: { + type: "boolean", + description: "Clear existing jewels before importing the new tree (default: true).", + default: true, + }, + clear_items: { + type: "boolean", + description: "Clear existing equipped items before importing (default: true).", + default: true, + }, + clear_skills: { + type: "boolean", + description: "Clear existing skill gems before importing (default: true).", + default: true, + }, + ignore_weapon_swap: { + type: "boolean", + description: "Skip importing the weapon swap slots AND keep the build's existing 'use second weapon set' flag untouched. Use this when you have a custom swap configuration (e.g. leveling weapons in the swap) you want preserved. Default false: import the swap items and force the calc engine to use the primary slots so stats match the character's in-game active set.", + default: false, + }, + }, + required: ["character_name"], + }, + }, { name: "list_specs", description: "List all passive tree specs in the currently loaded build. Each spec can have a different tree allocation, class, and ascendancy.", diff --git a/src/services/poeCharacterApi.ts b/src/services/poeCharacterApi.ts new file mode 100644 index 0000000..365a649 --- /dev/null +++ b/src/services/poeCharacterApi.ts @@ -0,0 +1,209 @@ +/** + * PoE Character API Client + * + * Thin wrapper around the official Path of Exile character-window endpoints. + * PoB headless does not have HTTP support (lcurl is disabled), so all HTTP + * requests must happen on the Node side and the resulting JSON body is + * forwarded to the Lua side via the import_passive_tree / import_items_skills + * JSON-RPC handlers. + * + * Endpoints used: + * - GET /character-window/get-characters + * - GET /character-window/get-passive-skills + * - GET /character-window/get-items + * + * Notes: + * - Account names use a discriminator like `account#1234`. The `#` MUST be + * URL-encoded as `%23` (the PoE API does not accept the bare `#`). + * - A `User-Agent` header is required by the PoE API. + * - Private profiles require a POESESSID cookie (passed via sessionId arg + * or POE_SESSION_ID env var). + */ + +const BASE_URL = "https://www.pathofexile.com"; +// GGG's API requires an identifying User-Agent. Keep this in sync with the +// `version` field in package.json — current value should match the published +// MCP server version so support requests can correlate. +const USER_AGENT = "pob-mcp/1.0.0"; + +export type PoeRealm = "pc" | "xbox" | "sony"; + +/** Character entry returned by /character-window/get-characters. + * Confirmed fields from the live API (2026-04 capture): + * name, realm, class, league, level, lastLoginTime (unix seconds), pinnable. + * Other fields (classId, ascendancyClass, etc.) are NOT returned — `class` + * contains the ascendancy name directly (e.g. "Saboteur", "Chieftain"). */ +export interface PoeCharacterListEntry { + name: string; + realm?: string; + class?: string; + league?: string; + level?: number; + lastLoginTime?: number; + pinnable?: boolean; + // Pass-through for any future fields the API adds. + [key: string]: unknown; +} + +/** Encode an account name for the query string (PoE requires `#` as `%23`). */ +function encodeAccountName(accountName: string): string { + // encodeURIComponent escapes `#` to `%23` and handles every other character + // the API rejects when sent raw. + return encodeURIComponent(accountName); +} + +/** Default per-request timeout for PoE API calls (ms). */ +const DEFAULT_TIMEOUT_MS = 15000; + +/** + * Wrap a fetch call with an AbortController so a hung connection cannot block + * the bridge indefinitely. Returns the Response on success and re-throws with + * a clearer message on timeout. + */ +async function fetchWithTimeout(url: string, init: RequestInit, context: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + if ((err as { name?: string })?.name === "AbortError") { + throw new Error(`PoE API request timed out after ${DEFAULT_TIMEOUT_MS}ms (${context}).`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +/** + * Build the standard headers for a PoE API request. + * Adds the Cookie header when a sessionId is available. + */ +function buildHeaders(sessionId?: string): Record { + const headers: Record = { + "User-Agent": USER_AGENT, + Accept: "application/json", + }; + const effectiveSessionId = + sessionId !== undefined ? sessionId : process.env.POE_SESSION_ID; + if (effectiveSessionId) { + headers["Cookie"] = `POESESSID=${effectiveSessionId}`; + } + return headers; +} + +/** + * Map a non-2xx HTTP response to a friendly Error message. + * Reads the body as text so it can be included for diagnostic value. + */ +async function throwForResponse(res: Response, context: string): Promise { + if (res.status === 403) { + throw new Error( + "Profile is private. Set POE_SESSION_ID env var or make profile public." + ); + } + if (res.status === 404) { + throw new Error( + "Character not found. Check account name (with discriminator like account#1234) and character name." + ); + } + if (res.status === 429) { + throw new Error("Rate limited by PoE API. Wait a few seconds and retry."); + } + let body = ""; + try { + body = await res.text(); + } catch { + /* ignore — body might already be consumed */ + } + const snippet = body.length > 500 ? body.slice(0, 500) + "..." : body; + throw new Error( + `PoE API request failed (${context}): HTTP ${res.status} ${res.statusText}${snippet ? " — " + snippet : ""}` + ); +} + +/** + * Fetch the list of characters for an account. + * + * @param accountName Account name including discriminator (e.g. `account#1234`). + * @param realm Defaults to `pc`. + * @param sessionId Optional POESESSID; falls back to POE_SESSION_ID env var. + */ +export async function fetchCharacterList( + accountName: string, + realm: PoeRealm = "pc", + sessionId?: string +): Promise { + if (!accountName || !accountName.trim()) { + throw new Error("accountName is required"); + } + const url = `${BASE_URL}/character-window/get-characters?accountName=${encodeAccountName( + accountName + )}&realm=${encodeURIComponent(realm)}`; + + const res = await fetchWithTimeout(url, { headers: buildHeaders(sessionId) }, "get-characters"); + if (!res.ok) { + await throwForResponse(res, "get-characters"); + } + const data = (await res.json()) as unknown; + if (!Array.isArray(data)) { + throw new Error( + `Unexpected response from PoE API (get-characters): expected array, got ${typeof data}` + ); + } + return data as PoeCharacterListEntry[]; +} + +/** + * Fetch the raw passive-skills JSON body for a character. + * Returns the body as a string so the Lua importer can parse it itself. + */ +export async function fetchPassiveSkills( + accountName: string, + characterName: string, + realm: PoeRealm = "pc", + sessionId?: string +): Promise { + if (!accountName || !accountName.trim()) { + throw new Error("accountName is required"); + } + if (!characterName || !characterName.trim()) { + throw new Error("characterName is required"); + } + const url = `${BASE_URL}/character-window/get-passive-skills?accountName=${encodeAccountName( + accountName + )}&character=${encodeURIComponent(characterName)}&realm=${encodeURIComponent(realm)}`; + + const res = await fetchWithTimeout(url, { headers: buildHeaders(sessionId) }, "get-passive-skills"); + if (!res.ok) { + await throwForResponse(res, "get-passive-skills"); + } + return await res.text(); +} + +/** + * Fetch the raw items JSON body for a character. + * Returns the body as a string so the Lua importer can parse it itself. + */ +export async function fetchItems( + accountName: string, + characterName: string, + realm: PoeRealm = "pc", + sessionId?: string +): Promise { + if (!accountName || !accountName.trim()) { + throw new Error("accountName is required"); + } + if (!characterName || !characterName.trim()) { + throw new Error("characterName is required"); + } + const url = `${BASE_URL}/character-window/get-items?accountName=${encodeAccountName( + accountName + )}&character=${encodeURIComponent(characterName)}&realm=${encodeURIComponent(realm)}`; + + const res = await fetchWithTimeout(url, { headers: buildHeaders(sessionId) }, "get-items"); + if (!res.ok) { + await throwForResponse(res, "get-items"); + } + return await res.text(); +} diff --git a/tests/mocks/responses.mock.ts b/tests/mocks/responses.mock.ts index 593b826..8ea37a3 100644 --- a/tests/mocks/responses.mock.ts +++ b/tests/mocks/responses.mock.ts @@ -65,7 +65,7 @@ export const MOCK_RESPONSES = { }, add_item_text: { ok: true, - result: { + item: { id: 123, name: 'Steel Blade', slot: 'Weapon 1', @@ -74,7 +74,7 @@ export const MOCK_RESPONSES = { set_flask_active: { ok: true }, get_skills: { ok: true, - result: { + skills: { mainSocketGroup: 1, calcsSkillNumber: 1, groups: [ diff --git a/tests/unit/importHandlers.test.ts b/tests/unit/importHandlers.test.ts new file mode 100644 index 0000000..ac0373a --- /dev/null +++ b/tests/unit/importHandlers.test.ts @@ -0,0 +1,609 @@ +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { + handleListCharacters, + handleImportCharacter, + type ImportHandlerContext, +} from '../../src/handlers/importHandlers.js'; + +/** + * Minimal Response-like factory used to drive the global fetch mock. + */ +function makeResponse(opts: { + status: number; + body?: unknown; + text?: string; + statusText?: string; +}): Response { + const status = opts.status; + const ok = status >= 200 && status < 300; + const statusText = opts.statusText ?? (ok ? 'OK' : 'Error'); + const bodyText = + opts.text !== undefined + ? opts.text + : opts.body !== undefined + ? JSON.stringify(opts.body) + : ''; + return { + ok, + status, + statusText, + json: async () => { + if (opts.body !== undefined) return opts.body; + try { + return JSON.parse(bodyText); + } catch { + throw new Error('not json'); + } + }, + text: async () => bodyText, + } as unknown as Response; +} + +/** + * Tracks the call order of bridge methods (with timestamps) so tests can + * verify sequential vs concurrent invocation. + */ +interface CallLog { + method: string; + startedAt: number; + finishedAt?: number; + args: unknown[]; +} + +interface FakeBridgeOptions { + buildInfo?: unknown; // null/undefined = "no build loaded" + importPassiveTreeImpl?: () => Promise; + importItemsSkillsImpl?: () => Promise; + // Snapshot data — getStats etc. are called twice (before + after). + // Allow distinct values for each pass to make diff assertions meaningful. + statsBefore?: Record; + statsAfter?: Record; + itemsBefore?: unknown[]; + itemsAfter?: unknown[]; + skillsBefore?: unknown; + skillsAfter?: unknown; + treeBefore?: unknown; + treeAfter?: unknown; +} + +function createFakeBridge(opts: FakeBridgeOptions = {}) { + const log: CallLog[] = []; + + // Snapshot calls happen twice (once before, once after the imports). + // We need to alternate between the "before" and "after" payloads. + let statsCallCount = 0; + let itemsCallCount = 0; + let skillsCallCount = 0; + let treeCallCount = 0; + let buildInfoCallCount = 0; + + const record = async (method: string, args: unknown[], result: T): Promise => { + const entry: CallLog = { method, startedAt: Date.now(), args }; + log.push(entry); + // Yield to the microtask queue so concurrent calls (if any) interleave. + await Promise.resolve(); + entry.finishedAt = Date.now(); + return result; + }; + + const client = { + getBuildInfo: jest.fn(async () => { + buildInfoCallCount++; + const info = opts.buildInfo === undefined + ? { name: 'TestBuild', level: 1, className: 'Ranger', ascendClassName: '' } + : opts.buildInfo; + return record('getBuildInfo', [], info); + }), + getStats: jest.fn(async () => { + statsCallCount++; + const stats = statsCallCount === 1 + ? (opts.statsBefore ?? { Life: 100 }) + : (opts.statsAfter ?? { Life: 200 }); + return record('getStats', [], stats); + }), + getItems: jest.fn(async () => { + itemsCallCount++; + const items = itemsCallCount === 1 + ? (opts.itemsBefore ?? []) + : (opts.itemsAfter ?? []); + return record('getItems', [], items); + }), + getSkills: jest.fn(async () => { + skillsCallCount++; + const skills = skillsCallCount === 1 + ? (opts.skillsBefore ?? { groups: [] }) + : (opts.skillsAfter ?? { groups: [] }); + return record('getSkills', [], skills); + }), + getTree: jest.fn(async () => { + treeCallCount++; + const tree = treeCallCount === 1 + ? (opts.treeBefore ?? { nodes: [] }) + : (opts.treeAfter ?? { nodes: [] }); + return record('getTree', [], tree); + }), + importPassiveTree: jest.fn(async (params: unknown) => { + const entry: CallLog = { + method: 'importPassiveTree', + startedAt: Date.now(), + args: [params], + }; + log.push(entry); + const result = opts.importPassiveTreeImpl + ? await opts.importPassiveTreeImpl() + : { ok: true }; + entry.finishedAt = Date.now(); + return result; + }), + importItemsSkills: jest.fn(async (params: unknown) => { + const entry: CallLog = { + method: 'importItemsSkills', + startedAt: Date.now(), + args: [params], + }; + log.push(entry); + const result = opts.importItemsSkillsImpl + ? await opts.importItemsSkillsImpl() + : { ok: true }; + entry.finishedAt = Date.now(); + return result; + }), + }; + + return { client, log }; +} + +function makeContext(client: ReturnType['client']): ImportHandlerContext { + return { + getLuaClient: () => client as unknown as import('../../src/pobLuaBridge.js').PoBLuaApiClient, + ensureLuaClient: jest.fn(async () => undefined) as unknown as () => Promise, + }; +} + +describe('importHandlers', () => { + let fetchSpy: jest.SpiedFunction; + let originalSessionId: string | undefined; + let originalAccountName: string | undefined; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch') as jest.SpiedFunction; + originalSessionId = process.env.POE_SESSION_ID; + originalAccountName = process.env.POE_ACCOUNT_NAME; + delete process.env.POE_SESSION_ID; + delete process.env.POE_ACCOUNT_NAME; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + if (originalSessionId === undefined) { + delete process.env.POE_SESSION_ID; + } else { + process.env.POE_SESSION_ID = originalSessionId; + } + if (originalAccountName === undefined) { + delete process.env.POE_ACCOUNT_NAME; + } else { + process.env.POE_ACCOUNT_NAME = originalAccountName; + } + }); + + describe('handleListCharacters', () => { + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + it('errors when accountName is empty/whitespace and mentions discriminator', async () => { + await expect(handleListCharacters(ctx, '')).rejects.toThrow(/discriminator/); + await expect(handleListCharacters(ctx, ' ')).rejects.toThrow(/discriminator/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('uses POE_ACCOUNT_NAME when accountName is omitted', async () => { + process.env.POE_ACCOUNT_NAME = 'envAccount#1234'; + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'EnvHero', + class: 'Ranger', + league: 'Standard', + level: 90, + }, + ], + }) + ); + + const result = await handleListCharacters(ctx); + const text = result.content[0].text; + + expect(text).toContain('=== Characters on envAccount#1234 ==='); + expect(text).toContain('EnvHero'); + expect(String(fetchSpy.mock.calls[0][0])).toContain('envAccount%231234'); + }); + + it('returns "No characters found" with a POE_SESSION_ID tip on empty list', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + const result = await handleListCharacters(ctx, 'account#1234'); + + expect(result.content[0].text).toContain('No characters found'); + expect(result.content[0].text).toContain('POE_SESSION_ID'); + }); + + it('renders a markdown table with the expected columns', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'Char1', + class: 'Saboteur', + league: 'Standard', + level: 92, + realm: 'pc', + lastLoginTime: 1700000000, + pinnable: true, + }, + ], + }) + ); + + const result = await handleListCharacters(ctx, 'account#1234'); + const text = result.content[0].text; + + expect(text).toContain( + '| Name | Level | Class | Ascendancy | League | Realm | Last Login | Pinnable |' + ); + expect(text).toContain( + '|------|-------|-------|------------|--------|-------|------------|----------|' + ); + }); + + it('sorts characters by lastLoginTime descending', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { name: 'Old', class: 'Ranger', level: 50, lastLoginTime: 1000 }, + { name: 'Newest', class: 'Ranger', level: 90, lastLoginTime: 3000 }, + { name: 'Middle', class: 'Ranger', level: 70, lastLoginTime: 2000 }, + ], + }) + ); + + const text = (await handleListCharacters(ctx, 'account#1234')).content[0].text; + const idxNewest = text.indexOf('Newest'); + const idxMiddle = text.indexOf('Middle'); + const idxOld = text.indexOf('Old'); + + expect(idxNewest).toBeGreaterThan(0); + expect(idxMiddle).toBeGreaterThan(idxNewest); + expect(idxOld).toBeGreaterThan(idxMiddle); + }); + + it('resolves Saboteur to base class Shadow with ascendancy Saboteur', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'SabChar', + class: 'Saboteur', + league: 'Standard', + level: 92, + realm: 'pc', + }, + ], + }) + ); + + const text = (await handleListCharacters(ctx, 'account#1234')).content[0].text; + // The row should have Shadow in the Class column and Saboteur in the Ascendancy column. + expect(text).toMatch(/\| SabChar \| 92 \| Shadow \| Saboteur \|/); + }); + + it('shows base class with Ascendancy "None" for unascended characters', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'Fresh', + class: 'Ranger', + league: 'Standard', + level: 5, + realm: 'pc', + }, + ], + }) + ); + + const text = (await handleListCharacters(ctx, 'account#1234')).content[0].text; + expect(text).toMatch(/\| Fresh \| 5 \| Ranger \| None \|/); + }); + + it('shows Unknown for unknown ascendancy names (forward-compat edge case)', async () => { + // "Ancestral Commander" is hypothetical; the map won't have it. + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'NewClass', + class: 'Ancestral Commander', + league: 'Standard', + level: 50, + realm: 'pc', + }, + ], + }) + ); + + const text = (await handleListCharacters(ctx, 'account#1234')).content[0].text; + // Class column = Unknown, Ascendancy column = Unknown. + expect(text).toMatch(/\| NewClass \| 50 \| Unknown \| Unknown \|/); + }); + + it('handles missing/null fields gracefully (renders ?)', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 200, + body: [ + { + name: 'Minimal', + class: 'Ranger', + // level, league, realm, lastLoginTime, pinnable all missing + }, + ], + }) + ); + + const text = (await handleListCharacters(ctx, 'account#1234')).content[0].text; + // The row should contain ? placeholders for missing scalar fields. + // Pattern: | Minimal | ? | Ranger | None | ? | ? | ? | ? | + expect(text).toMatch( + /\| Minimal \| \? \| Ranger \| None \| \? \| \? \| \? \| \? \|/ + ); + }); + }); + + describe('handleImportCharacter', () => { + /** Stub the three HTTP endpoints in the order they are awaited (Promise.all order doesn't matter to a queue, mockResolvedValueOnce handles them by call order). */ + function stubImportFetches(opts: { + passiveBody?: string; + itemsBody?: string; + characters?: unknown[]; + } = {}) { + const passiveBody = opts.passiveBody ?? '{"hashes":[1,2,3]}'; + const itemsBody = opts.itemsBody ?? '{"items":[]}'; + const characters = opts.characters ?? [ + { name: 'Hero', class: 'Saboteur', league: 'Standard', level: 90 }, + ]; + + // Resolver based on URL — order in Promise.all isn't guaranteed. + fetchSpy.mockImplementation(async (input: any) => { + const url = String(input); + if (url.includes('get-passive-skills')) { + return makeResponse({ status: 200, text: passiveBody }); + } + if (url.includes('get-items')) { + return makeResponse({ status: 200, text: itemsBody }); + } + if (url.includes('get-characters')) { + return makeResponse({ status: 200, body: characters }); + } + return makeResponse({ status: 404, text: 'unknown route' }); + }); + } + + it('throws when accountName is empty', async () => { + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + await expect(handleImportCharacter(ctx, '', 'Hero')).rejects.toThrow( + /account_name is required/ + ); + }); + + it('uses POE_ACCOUNT_NAME when accountName is omitted', async () => { + process.env.POE_ACCOUNT_NAME = 'envAccount#1234'; + stubImportFetches(); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + const result = await handleImportCharacter(ctx, undefined, 'Hero'); + + expect(result.content[0].text).toContain('# Import: "Hero"'); + expect(String(fetchSpy.mock.calls[0][0])).toContain('envAccount%231234'); + }); + + it('throws when characterName is empty', async () => { + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + await expect(handleImportCharacter(ctx, 'account#1234', '')).rejects.toThrow( + /character_name is required/ + ); + }); + + it('throws "No build loaded" when getBuildInfo returns null', async () => { + const { client } = createFakeBridge({ buildInfo: null }); + const ctx = makeContext(client); + + await expect( + handleImportCharacter(ctx, 'account#1234', 'Hero') + ).rejects.toThrow(/No build loaded/); + }); + + it('throws "No build loaded" when getBuildInfo throws', async () => { + const { client } = createFakeBridge(); + // Override getBuildInfo to reject — production code wraps with .catch(() => null). + (client.getBuildInfo as jest.Mock).mockImplementation(async () => { + throw new Error('bridge crashed'); + }); + const ctx = makeContext(client); + + await expect( + handleImportCharacter(ctx, 'account#1234', 'Hero') + ).rejects.toThrow(/No build loaded/); + }); + + it('throws "Character ... not found" when name is not in the list', async () => { + stubImportFetches({ + characters: [{ name: 'OtherHero', class: 'Saboteur', level: 90 }], + }); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + await expect( + handleImportCharacter(ctx, 'account#1234', 'Hero') + ).rejects.toThrow(/Character "Hero" not found/); + }); + + it('CRITICAL: calls importPassiveTree before importItemsSkills sequentially', async () => { + stubImportFetches(); + + // Make importPassiveTree take measurable time so we can verify + // importItemsSkills truly waits. + let passiveResolveAt = 0; + let itemsStartAt = 0; + const { client, log } = createFakeBridge({ + importPassiveTreeImpl: async () => { + // Spin a few microtask ticks so any concurrent call would interleave. + await new Promise((r) => setTimeout(r, 10)); + passiveResolveAt = Date.now(); + return { ok: true }; + }, + importItemsSkillsImpl: async () => { + itemsStartAt = Date.now(); + return { ok: true }; + }, + }); + const ctx = makeContext(client); + + await handleImportCharacter(ctx, 'account#1234', 'Hero'); + + // importItemsSkills must have started AT OR AFTER importPassiveTree finished. + expect(itemsStartAt).toBeGreaterThanOrEqual(passiveResolveAt); + + // Verify call order in the log. + const ordering = log.map((l) => l.method); + const treeIdx = ordering.indexOf('importPassiveTree'); + const itemsIdx = ordering.indexOf('importItemsSkills'); + expect(treeIdx).toBeGreaterThan(-1); + expect(itemsIdx).toBeGreaterThan(treeIdx); + + // captureBuildSnapshot is called twice (before + after), so getStats should + // be invoked twice — once before importPassiveTree, once after importItemsSkills. + const statsCalls = log + .map((l, i) => ({ method: l.method, i })) + .filter((x) => x.method === 'getStats'); + expect(statsCalls.length).toBe(2); + expect(statsCalls[0].i).toBeLessThan(treeIdx); + expect(statsCalls[1].i).toBeGreaterThan(itemsIdx); + }); + + it('forwards clear_jewels, clear_items, clear_skills, ignore_weapon_swap flags', async () => { + stubImportFetches(); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + await handleImportCharacter(ctx, 'account#1234', 'Hero', 'pc', { + clearJewels: false, + clearItems: false, + clearSkills: false, + ignoreWeaponSwap: true, + }); + + expect(client.importPassiveTree).toHaveBeenCalledWith( + expect.objectContaining({ clear_jewels: false }) + ); + expect(client.importItemsSkills).toHaveBeenCalledWith( + expect.objectContaining({ + clear_items: false, + clear_skills: false, + ignore_weapon_swap: true, + }) + ); + }); + + it('uses defaults (clear*=true, ignoreWeaponSwap=false) when options is omitted', async () => { + stubImportFetches(); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + await handleImportCharacter(ctx, 'account#1234', 'Hero'); + + expect(client.importPassiveTree).toHaveBeenCalledWith( + expect.objectContaining({ clear_jewels: true }) + ); + expect(client.importItemsSkills).toHaveBeenCalledWith( + expect.objectContaining({ + clear_items: true, + clear_skills: true, + ignore_weapon_swap: false, + }) + ); + }); + + it('reports "build is unchanged — no rollback needed" when importPassiveTree fails', async () => { + stubImportFetches(); + const { client } = createFakeBridge({ + importPassiveTreeImpl: async () => { + throw new Error('bad json'); + }, + }); + const ctx = makeContext(client); + + await expect( + handleImportCharacter(ctx, 'account#1234', 'Hero') + ).rejects.toThrow(/build is unchanged.*no rollback needed/); + }); + + it('reports PARTIAL state and lua_reload_build hint when importItemsSkills fails after a successful tree import', async () => { + stubImportFetches(); + const { client } = createFakeBridge({ + importItemsSkillsImpl: async () => { + throw new Error('items boom'); + }, + }); + const ctx = makeContext(client); + + const promise = handleImportCharacter(ctx, 'account#1234', 'Hero'); + await expect(promise).rejects.toThrow(/PARTIAL state/); + await expect( + handleImportCharacter(ctx, 'account#1234', 'Hero') + ).rejects.toThrow(/lua_reload_build/); + }); + + it('output includes ## Stats, ## Items, ## Skills, ## Preserved sections', async () => { + stubImportFetches(); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + const result = await handleImportCharacter(ctx, 'account#1234', 'Hero'); + const text = result.content[0].text; + + expect(text).toContain('## Stats'); + expect(text).toContain('## Items'); + expect(text).toContain('## Skills'); + expect(text).toContain('## Preserved'); + }); + + it('output includes the character info line "Level () — ()"', async () => { + stubImportFetches({ + characters: [ + { name: 'Hero', class: 'Saboteur', league: 'Settlers', level: 92 }, + ], + }); + const { client } = createFakeBridge(); + const ctx = makeContext(client); + + const result = await handleImportCharacter(ctx, 'account#1234', 'Hero', 'pc'); + const text = result.content[0].text; + + // Format: "Level 92 Shadow (Saboteur) — Settlers (pc)" + expect(text).toContain('Level 92 Shadow (Saboteur) — Settlers (pc)'); + }); + }); +}); diff --git a/tests/unit/pobLuaBridge.test.ts b/tests/unit/pobLuaBridge.test.ts index dcb1833..13f317c 100644 --- a/tests/unit/pobLuaBridge.test.ts +++ b/tests/unit/pobLuaBridge.test.ts @@ -248,6 +248,59 @@ describe('PoBLuaApiClient', () => { }); }); + describe('character import helpers', () => { + beforeEach(async () => { + await client.start(); + mockProcess = mockSpawn.getLastProcess()!; + }); + + it('returns the passive tree import payload without the JSON-RPC envelope', async () => { + mockProcess.registerResponse('import_passive_tree', { + ok: true, + status: '^2Passive tree and jewels successfully imported.', + level: 92, + className: 'Shadow', + ascendClassName: 'Saboteur', + }); + + const result = await client.importPassiveTree({ + json: '{"hashes":[]}', + char_data: { name: 'Hero', level: 92, class: 'Saboteur', league: 'Standard' }, + }); + + expect(result).toEqual({ + status: '^2Passive tree and jewels successfully imported.', + level: 92, + className: 'Shadow', + ascendClassName: 'Saboteur', + }); + expect(result).not.toHaveProperty('ok'); + }); + + it('returns the items/skills import payload without the JSON-RPC envelope', async () => { + mockProcess.registerResponse('import_items_skills', { + ok: true, + status: '^2Items and skills successfully imported.', + level: 92, + character: { name: 'Hero', level: 92, class: 'Saboteur', league: 'Standard' }, + }); + + const result = await client.importItemsSkills({ + json: '{"items":[]}', + clear_items: true, + clear_skills: true, + ignore_weapon_swap: false, + }); + + expect(result).toEqual({ + status: '^2Items and skills successfully imported.', + level: 92, + character: { name: 'Hero', level: 92, class: 'Saboteur', league: 'Standard' }, + }); + expect(result).not.toHaveProperty('ok'); + }); + }); + describe('Phase 4: Item Methods', () => { beforeEach(async () => { await client.start(); diff --git a/tests/unit/poeCharacterApi.test.ts b/tests/unit/poeCharacterApi.test.ts new file mode 100644 index 0000000..64b588c --- /dev/null +++ b/tests/unit/poeCharacterApi.test.ts @@ -0,0 +1,289 @@ +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { + fetchCharacterList, + fetchPassiveSkills, + fetchItems, +} from '../../src/services/poeCharacterApi.js'; + +/** + * Build a minimal Response-like object that satisfies the production code's + * usage: `.ok`, `.status`, `.statusText`, `.json()`, `.text()`. + */ +function makeResponse(opts: { + status: number; + body?: unknown; + text?: string; + statusText?: string; +}): Response { + const status = opts.status; + const ok = status >= 200 && status < 300; + const statusText = opts.statusText ?? (ok ? 'OK' : 'Error'); + const bodyText = + opts.text !== undefined + ? opts.text + : opts.body !== undefined + ? JSON.stringify(opts.body) + : ''; + return { + ok, + status, + statusText, + json: async () => { + if (opts.body !== undefined) return opts.body; + try { + return JSON.parse(bodyText); + } catch { + throw new Error('not json'); + } + }, + text: async () => bodyText, + } as unknown as Response; +} + +describe('poeCharacterApi', () => { + let fetchSpy: jest.SpiedFunction; + let originalSessionId: string | undefined; + + beforeEach(() => { + fetchSpy = jest.spyOn(globalThis, 'fetch') as jest.SpiedFunction; + originalSessionId = process.env.POE_SESSION_ID; + delete process.env.POE_SESSION_ID; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + if (originalSessionId === undefined) { + delete process.env.POE_SESSION_ID; + } else { + process.env.POE_SESSION_ID = originalSessionId; + } + }); + + describe('fetchCharacterList', () => { + const SAMPLE_LIST = [ + { name: 'Char1', class: 'Saboteur', league: 'Standard', level: 92 }, + { name: 'Char2', class: 'Ranger', league: 'Standard', level: 80 }, + ]; + + it('returns parsed array on 200 with valid JSON', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: SAMPLE_LIST })); + + const result = await fetchCharacterList('account#1234'); + + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Char1'); + }); + + it('throws private-profile message on HTTP 403', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 403, text: 'Forbidden' })); + + await expect(fetchCharacterList('account#1234')).rejects.toThrow( + 'Profile is private. Set POE_SESSION_ID env var or make profile public.' + ); + }); + + it('throws character-not-found message on HTTP 404', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 404, text: 'Not Found' })); + + await expect(fetchCharacterList('account#1234')).rejects.toThrow(/Character not found/); + }); + + it('throws rate-limit message on HTTP 429', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 429, text: 'Too Many' })); + + await expect(fetchCharacterList('account#1234')).rejects.toThrow( + 'Rate limited by PoE API. Wait a few seconds and retry.' + ); + }); + + it('throws on non-array response body', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ status: 200, body: { error: 'not an array' } }) + ); + + await expect(fetchCharacterList('account#1234')).rejects.toThrow( + /expected array/ + ); + }); + + it('throws when accountName is empty or whitespace', async () => { + await expect(fetchCharacterList('')).rejects.toThrow('accountName is required'); + await expect(fetchCharacterList(' ')).rejects.toThrow('accountName is required'); + // No fetch call should have been made. + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('includes status and body snippet in generic non-2xx errors', async () => { + fetchSpy.mockResolvedValueOnce( + makeResponse({ + status: 500, + text: 'Internal Server Boom', + statusText: 'Internal Server Error', + }) + ); + + await expect(fetchCharacterList('account#1234')).rejects.toThrow( + /HTTP 500.*Internal Server Boom/ + ); + }); + + it('URL-encodes the # in account name as %23', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234'); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('accountName=account%231234'); + expect(url).not.toContain('account#1234'); + }); + + it('sends Cookie header when sessionId argument is provided', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234', 'pc', 'abc123sess'); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Cookie).toBe('POESESSID=abc123sess'); + }); + + it('sends Cookie header from POE_SESSION_ID env when sessionId arg is omitted', async () => { + process.env.POE_SESSION_ID = 'env-session-xyz'; + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234'); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Cookie).toBe('POESESSID=env-session-xyz'); + }); + + it('omits Cookie header when neither arg nor env is set', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234'); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers.Cookie).toBeUndefined(); + }); + + it('always sends User-Agent and Accept headers', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234'); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + const headers = init.headers as Record; + expect(headers['User-Agent']).toMatch(/^pob-mcp\//); + expect(headers.Accept).toBe('application/json'); + }); + + it('defaults the realm to pc when not specified', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, body: [] })); + + await fetchCharacterList('account#1234'); + + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toContain('realm=pc'); + }); + + it('aborts the request via AbortController when fetch never resolves', async () => { + jest.useFakeTimers(); + + // Capture the signal so we can verify abort propagates. + let capturedSignal: AbortSignal | undefined; + fetchSpy.mockImplementationOnce((_url: any, init?: any) => { + capturedSignal = init?.signal as AbortSignal; + return new Promise((_resolve, reject) => { + // Reject when aborted to mimic real fetch semantics. + capturedSignal?.addEventListener('abort', () => { + const err = new Error('aborted'); + (err as Error & { name: string }).name = 'AbortError'; + reject(err); + }); + }); + }); + + const promise = fetchCharacterList('account#1234'); + // Advance past the 15s timeout. + jest.advanceTimersByTime(15001); + + await expect(promise).rejects.toThrow(/timed out/); + expect(capturedSignal?.aborted).toBe(true); + + jest.useRealTimers(); + }); + }); + + describe('fetchPassiveSkills', () => { + it('returns the raw text body unparsed', async () => { + const raw = '{"hashes":[1,2,3],"items":[]}'; + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, text: raw })); + + const result = await fetchPassiveSkills('account#1234', 'CharName'); + + // Returned exactly as-is — not parsed. + expect(result).toBe(raw); + expect(typeof result).toBe('string'); + }); + + it('throws when accountName or characterName is empty', async () => { + await expect(fetchPassiveSkills('', 'Char')).rejects.toThrow( + 'accountName is required' + ); + await expect(fetchPassiveSkills('account#1234', '')).rejects.toThrow( + 'characterName is required' + ); + await expect(fetchPassiveSkills('account#1234', ' ')).rejects.toThrow( + 'characterName is required' + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('URL-encodes the character name (special chars / spaces)', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, text: '{}' })); + + await fetchPassiveSkills('account#1234', 'My Char #2'); + + const url = fetchSpy.mock.calls[0][0] as string; + // Spaces become %20 (encodeURIComponent), # becomes %23. + expect(url).toContain('character=My%20Char%20%232'); + }); + + it('maps HTTP 403 to private-profile error', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 403, text: 'Forbidden' })); + + await expect(fetchPassiveSkills('account#1234', 'Char')).rejects.toThrow( + /Profile is private/ + ); + }); + }); + + describe('fetchItems', () => { + it('returns the raw text body unparsed', async () => { + const raw = '{"items":[{"name":"x"}]}'; + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 200, text: raw })); + + const result = await fetchItems('account#1234', 'CharName'); + + expect(result).toBe(raw); + }); + + it('throws when characterName is empty', async () => { + await expect(fetchItems('account#1234', '')).rejects.toThrow( + 'characterName is required' + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('maps HTTP 429 to rate-limit error', async () => { + fetchSpy.mockResolvedValueOnce(makeResponse({ status: 429, text: 'rate limited' })); + + await expect(fetchItems('account#1234', 'Char')).rejects.toThrow( + /Rate limited by PoE API/ + ); + }); + }); +}); diff --git a/tests/unit/toolRouterImportFallback.test.ts b/tests/unit/toolRouterImportFallback.test.ts new file mode 100644 index 0000000..f991bc9 --- /dev/null +++ b/tests/unit/toolRouterImportFallback.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { routeToolCall } from '../../src/server/toolRouter.js'; + +function makeResponse(body: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; +} + +function makeDeps() { + const noopContext = {}; + return { + toolGate: { checkGate: jest.fn() }, + contextBuilder: { + buildHandlerContext: jest.fn(() => noopContext), + buildWatchContext: jest.fn(() => noopContext), + buildTreeContext: jest.fn(() => noopContext), + buildLuaContext: jest.fn(() => ({ + getLuaClient: () => null, + ensureLuaClient: async () => undefined, + })), + buildItemSkillContext: jest.fn(() => noopContext), + buildOptimizationContext: jest.fn(() => noopContext), + buildExportContext: jest.fn(() => noopContext), + buildSkillGemContext: jest.fn(() => noopContext), + }, + tradeClient: null, + statMapper: null, + recommendationEngine: null, + ninjaClient: {}, + getLuaClient: () => null, + ensureLuaClient: async () => undefined, + } as any; +} + +describe('toolRouter import account fallback', () => { + let fetchSpy: jest.SpiedFunction; + let originalAccountName: string | undefined; + + beforeEach(() => { + originalAccountName = process.env.POE_ACCOUNT_NAME; + process.env.POE_ACCOUNT_NAME = 'envAccount#1234'; + fetchSpy = jest.spyOn(globalThis, 'fetch') as jest.SpiedFunction; + fetchSpy.mockResolvedValue(makeResponse([])); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + if (originalAccountName === undefined) { + delete process.env.POE_ACCOUNT_NAME; + } else { + process.env.POE_ACCOUNT_NAME = originalAccountName; + } + }); + + it('does not reject lua_list_characters before the handler can use POE_ACCOUNT_NAME', async () => { + const result = await routeToolCall('lua_list_characters', {}, makeDeps()); + + expect(result.content[0].text).toContain('No characters found on account "envAccount#1234"'); + expect(String(fetchSpy.mock.calls[0][0])).toContain('envAccount%231234'); + }); +}); diff --git a/tests/unit/toolSchemas.test.ts b/tests/unit/toolSchemas.test.ts new file mode 100644 index 0000000..be3ef7a --- /dev/null +++ b/tests/unit/toolSchemas.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { getLuaToolSchemas, getToolSchemas } from '../../src/server/toolSchemas.js'; + +function namesOf(tools: Array<{ name: string }>): string[] { + return tools.map((tool) => tool.name); +} + +function schemaNamed(name: string) { + const schema = [...getToolSchemas(), ...getLuaToolSchemas()].find((tool) => tool.name === name); + if (!schema) throw new Error(`Missing schema: ${name}`); + return schema; +} + +describe('toolSchemas', () => { + it('exposes lua_list_characters without requiring Lua bridge schemas', () => { + const baseNames = namesOf(getToolSchemas()); + const luaNames = namesOf(getLuaToolSchemas()); + + expect(baseNames).toContain('lua_list_characters'); + expect(luaNames).not.toContain('lua_list_characters'); + }); + + it('allows lua_list_characters to use POE_ACCOUNT_NAME fallback', () => { + const schema = schemaNamed('lua_list_characters'); + + expect(schema.inputSchema.required || []).not.toContain('account_name'); + expect(schema.description).toContain('POE_ACCOUNT_NAME'); + expect(schema.inputSchema.properties.account_name.description).toContain('POE_ACCOUNT_NAME'); + }); + + it('allows lua_import_character to use POE_ACCOUNT_NAME fallback', () => { + const schema = schemaNamed('lua_import_character'); + + expect(schema.inputSchema.required).toEqual(['character_name']); + expect(schema.description).toContain('POE_ACCOUNT_NAME'); + expect(schema.inputSchema.properties.account_name.description).toContain('POE_ACCOUNT_NAME'); + }); + + it('does not register duplicate tool names when Lua schemas are added', () => { + const allNames = [...namesOf(getToolSchemas()), ...namesOf(getLuaToolSchemas())]; + const duplicateNames = allNames.filter((name, index) => allNames.indexOf(name) !== index); + + expect(duplicateNames).toEqual([]); + }); +});