eve-mentor-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/combat.js ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Combat coaching: ammo/damage-type advice and mechanical fit analysis.
3
+ * Damage tables follow EVE University's canonical NPC damage reference
4
+ * (https://wiki.eveuniversity.org/NPC_damage_types).
5
+ */
6
+ import { esiFetch, getType, resolveNames } from "./esi.js";
7
+ const AMMO_BY_DAMAGE = {
8
+ EM: {
9
+ projectile: "EMP S/M/L",
10
+ missile: "Mjolnir missiles",
11
+ drones: "Amarr drones (Acolyte/Infiltrator/Praetor)",
12
+ laser: "any crystal (lasers always deal EM+Thermal)",
13
+ hybrid: "not available — hybrids only deal Kinetic+Thermal",
14
+ },
15
+ Thermal: {
16
+ projectile: "Phased Plasma S/M/L",
17
+ missile: "Inferno missiles",
18
+ drones: "Gallente drones (Hobgoblin/Hammerhead/Ogre)",
19
+ laser: "any crystal (lasers always deal EM+Thermal)",
20
+ hybrid: "any charge (hybrids always deal Kinetic+Thermal)",
21
+ },
22
+ Kinetic: {
23
+ projectile: "Titanium Sabot S/M/L",
24
+ missile: "Scourge missiles",
25
+ drones: "Caldari drones (Hornet/Vespa/Wasp)",
26
+ laser: "not available — lasers only deal EM+Thermal",
27
+ hybrid: "any charge (hybrids always deal Kinetic+Thermal)",
28
+ },
29
+ Explosive: {
30
+ projectile: "Fusion S/M/L",
31
+ missile: "Nova missiles",
32
+ drones: "Minmatar drones (Warrior/Valkyrie/Berserker)",
33
+ laser: "not available — lasers only deal EM+Thermal",
34
+ hybrid: "not available — hybrids only deal Kinetic+Thermal",
35
+ },
36
+ };
37
+ function advice(target, shootDamageTypes, incomingDamageTypes) {
38
+ const primary = shootDamageTypes[0];
39
+ return {
40
+ target,
41
+ shootDamageTypes,
42
+ incomingDamageTypes,
43
+ tankAgainst: incomingDamageTypes.join(" + "),
44
+ ammoExamples: AMMO_BY_DAMAGE[primary] ?? {},
45
+ };
46
+ }
47
+ const DAMAGE_ADVICE = {
48
+ guristas: advice("Guristas", ["Kinetic", "Thermal"], ["Kinetic", "Thermal"]),
49
+ serpentis: advice("Serpentis", ["Thermal", "Kinetic"], ["Thermal", "Kinetic"]),
50
+ "angel cartel": advice("Angel Cartel", ["Explosive", "Kinetic"], ["Explosive", "Kinetic"]),
51
+ "blood raiders": advice("Blood Raiders", ["EM", "Thermal"], ["EM", "Thermal"]),
52
+ sansha: advice("Sansha's Nation", ["EM", "Thermal"], ["EM", "Thermal"]),
53
+ "rogue drones": advice("Rogue Drones", ["EM", "Thermal"], ["Explosive", "Kinetic"]),
54
+ triglavian: advice("Triglavian Collective", ["Explosive", "Thermal"], ["Explosive", "Thermal"]),
55
+ "shield-tanked player": advice("Shield-tanked player ship", ["EM", "Thermal"], ["anything — match your tank to their guns"]),
56
+ "armor-tanked player": advice("Armor-tanked player ship", ["Explosive", "Kinetic"], ["anything — match your tank to their guns"]),
57
+ };
58
+ /** Damage-type advice for a target faction or tank type. */
59
+ export function adviseAmmo(target) {
60
+ const normalized = target.toLowerCase();
61
+ const match = Object.keys(DAMAGE_ADVICE).find((key) => normalized.includes(key) || key.includes(normalized));
62
+ if (!match) {
63
+ return {
64
+ error: `No damage data for "${target}".`,
65
+ knownTargets: Object.values(DAMAGE_ADVICE).map((entry) => entry.target),
66
+ };
67
+ }
68
+ return DAMAGE_ADVICE[match];
69
+ }
70
+ // ---------- Fit analysis ----------
71
+ const groupNameCache = new Map();
72
+ async function getGroupName(groupId) {
73
+ const cached = groupNameCache.get(groupId);
74
+ if (cached)
75
+ return cached;
76
+ const { data } = await esiFetch(`/universe/groups/${groupId}/`);
77
+ groupNameCache.set(groupId, data.name);
78
+ return data.name;
79
+ }
80
+ const TURRET_GROUPS = ["Projectile Weapon", "Hybrid Weapon", "Energy Weapon"];
81
+ const DAMAGE_MOD_TO_WEAPON = {
82
+ Gyrostabilizer: "Projectile Weapon",
83
+ "Magnetic Field Stabilizer": "Hybrid Weapon",
84
+ "Heat Sink": "Energy Weapon",
85
+ "Ballistic Control system": "Missile Launcher",
86
+ };
87
+ async function classifyModules(moduleNames) {
88
+ const unique = [...new Set(moduleNames)];
89
+ const resolved = await resolveNames(unique);
90
+ const types = resolved.inventory_types ?? [];
91
+ return Promise.all(types.map(async (entry) => {
92
+ const type = await getType(entry.id);
93
+ return { name: entry.name, group: await getGroupName(type.group_id) };
94
+ }));
95
+ }
96
+ function findRedFlags(modules) {
97
+ const flags = [];
98
+ const groups = modules.map((m) => m.group);
99
+ const turretClasses = TURRET_GROUPS.filter((turret) => groups.includes(turret));
100
+ const hasMissiles = groups.some((g) => g.includes("Missile Launcher"));
101
+ const weaponSystemCount = turretClasses.length + (hasMissiles ? 1 : 0);
102
+ if (weaponSystemCount > 1) {
103
+ flags.push(`MIXED WEAPON SYSTEMS (${[...turretClasses, ...(hasMissiles ? ["Missiles"] : [])].join(", ")}): ` +
104
+ "ships bonus exactly one weapon system; the others waste slots, fitting room, and ammo logistics. Pick the hull's bonused system.");
105
+ }
106
+ const hasShieldTank = groups.some((g) => g.includes("Shield"));
107
+ const hasArmorTank = groups.some((g) => g.includes("Armor") || g.includes("Hull"));
108
+ if (hasShieldTank && hasArmorTank) {
109
+ flags.push("MIXED TANK (shield and armor modules together): pick one. Splitting tank halves your effective HP and wastes slots that should hold damage or utility.");
110
+ }
111
+ const hasPropulsion = groups.some((g) => g.includes("Propulsion"));
112
+ if (!hasPropulsion) {
113
+ flags.push("NO PROPULSION MODULE (afterburner/microwarpdrive): speed controls range, and range controls every fight. Almost every fit wants one.");
114
+ }
115
+ for (const [damageMod, weaponGroup] of Object.entries(DAMAGE_MOD_TO_WEAPON)) {
116
+ const hasDamageMod = modules.some((m) => m.group === damageMod);
117
+ const hasMatchingWeapon = groups.some((g) => g.includes(weaponGroup));
118
+ if (hasDamageMod && !hasMatchingWeapon) {
119
+ flags.push(`${damageMod.toUpperCase()} FITTED WITHOUT ${weaponGroup.toUpperCase()}S: this damage module boosts a weapon type the fit doesn't carry — it does nothing.`);
120
+ }
121
+ }
122
+ return flags;
123
+ }
124
+ /**
125
+ * Mechanical fit check: classify modules by group and detect classic
126
+ * newbie mistakes. Interpretation/coaching is the AI client's job.
127
+ */
128
+ export async function analyzeFit(moduleNames) {
129
+ const classified = await classifyModules(moduleNames);
130
+ const resolvedNames = new Set(classified.map((m) => m.name.toLowerCase()));
131
+ const unresolvedNames = [...new Set(moduleNames)].filter((name) => !resolvedNames.has(name.toLowerCase()));
132
+ return {
133
+ modules: classified,
134
+ redFlags: findRedFlags(classified),
135
+ unresolvedNames,
136
+ };
137
+ }
package/dist/esi.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Thin client for EVE's public ESI API.
3
+ * Docs: https://esi.evetech.net/ui/
4
+ */
5
+ const ESI_BASE = "https://esi.evetech.net/latest";
6
+ const USER_AGENT = "eve-mentor-mcp/0.1.0 (https://github.com/henryjrobinson/eve-mentor-mcp)";
7
+ export const JITA_REGION_ID = 10000002; // The Forge
8
+ export const JITA_44_STATION_ID = 60003760; // Jita IV - Moon 4 - Caldari Navy Assembly Plant
9
+ export class EsiError extends Error {
10
+ status;
11
+ path;
12
+ constructor(status, path, message) {
13
+ super(`ESI ${status} on ${path}: ${message}`);
14
+ this.status = status;
15
+ this.path = path;
16
+ this.name = "EsiError";
17
+ }
18
+ }
19
+ // ESI gives every client an error budget (default 100 per minute); blowing it
20
+ // gets you temporarily banned. Pause when the budget runs low.
21
+ const ERROR_BUDGET_FLOOR = 20;
22
+ let errorBudgetPauseUntil = 0;
23
+ // Unauthenticated GET responses carry an Expires header; honoring it keeps us
24
+ // from re-downloading heavy endpoints (system kills/jumps, market orders).
25
+ const responseCache = new Map();
26
+ function sleep(ms) {
27
+ return new Promise((resolve) => setTimeout(resolve, ms));
28
+ }
29
+ export async function esiFetch(path, options = {}) {
30
+ const isCacheable = (options.method ?? "GET") === "GET" && !options.token;
31
+ if (isCacheable) {
32
+ const cached = responseCache.get(path);
33
+ if (cached && cached.expiresAt > Date.now()) {
34
+ return { data: cached.data, pages: cached.pages };
35
+ }
36
+ }
37
+ if (errorBudgetPauseUntil > Date.now()) {
38
+ await sleep(errorBudgetPauseUntil - Date.now());
39
+ }
40
+ const response = await fetch(`${ESI_BASE}${path}`, {
41
+ method: options.method ?? "GET",
42
+ headers: {
43
+ "User-Agent": USER_AGENT,
44
+ "Accept": "application/json",
45
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
46
+ ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
47
+ },
48
+ body: options.body ? JSON.stringify(options.body) : undefined,
49
+ });
50
+ const budgetRemaining = Number(response.headers.get("x-esi-error-limit-remain") ?? "100");
51
+ if (budgetRemaining < ERROR_BUDGET_FLOOR) {
52
+ const resetSeconds = Number(response.headers.get("x-esi-error-limit-reset") ?? "10");
53
+ errorBudgetPauseUntil = Date.now() + resetSeconds * 1000;
54
+ }
55
+ if (!response.ok) {
56
+ const text = await response.text();
57
+ throw new EsiError(response.status, path, text.slice(0, 300));
58
+ }
59
+ const pages = Number(response.headers.get("x-pages") ?? "1");
60
+ const data = (await response.json());
61
+ if (isCacheable) {
62
+ const expiresHeader = response.headers.get("expires");
63
+ const expiresAt = expiresHeader ? new Date(expiresHeader).getTime() : 0;
64
+ if (expiresAt > Date.now()) {
65
+ responseCache.set(path, { data, pages, expiresAt });
66
+ }
67
+ }
68
+ return { data, pages };
69
+ }
70
+ /** Resolve exact names (items, systems, characters...) to IDs. */
71
+ export async function resolveNames(names) {
72
+ const { data } = await esiFetch("/universe/ids/", {
73
+ method: "POST",
74
+ body: names,
75
+ });
76
+ return data;
77
+ }
78
+ const idNameCache = new Map();
79
+ /** Resolve IDs of mixed categories back to names (cached). */
80
+ export async function namesForIds(ids) {
81
+ const unique = [...new Set(ids)].filter((id) => id > 0);
82
+ const missing = unique.filter((id) => !idNameCache.has(id));
83
+ // ESI accepts max 1000 ids per call
84
+ for (let i = 0; i < missing.length; i += 1000) {
85
+ const batch = missing.slice(i, i + 1000);
86
+ try {
87
+ const { data } = await esiFetch("/universe/names/", { method: "POST", body: batch });
88
+ for (const entry of data)
89
+ idNameCache.set(entry.id, entry.name);
90
+ }
91
+ catch {
92
+ // Some IDs (e.g. deleted characters) are unresolvable; leave them unnamed.
93
+ }
94
+ }
95
+ const result = new Map();
96
+ for (const id of unique) {
97
+ result.set(id, idNameCache.get(id) ?? `unknown-${id}`);
98
+ }
99
+ return result;
100
+ }
101
+ const typeCache = new Map();
102
+ export async function getType(typeId) {
103
+ const cached = typeCache.get(typeId);
104
+ if (cached)
105
+ return cached;
106
+ const { data } = await esiFetch(`/universe/types/${typeId}/`);
107
+ typeCache.set(typeId, data);
108
+ return data;
109
+ }
110
+ export async function getSystem(systemId) {
111
+ const { data } = await esiFetch(`/universe/systems/${systemId}/`);
112
+ return data;
113
+ }
114
+ /** Kills and jumps across all systems in the last hour. */
115
+ export async function getSystemActivity(systemId) {
116
+ const [kills, jumps] = await Promise.all([
117
+ esiFetch("/universe/system_kills/"),
118
+ esiFetch("/universe/system_jumps/"),
119
+ ]);
120
+ const killEntry = kills.data.find((k) => k.system_id === systemId);
121
+ const jumpEntry = jumps.data.find((j) => j.system_id === systemId);
122
+ return {
123
+ shipKillsLastHour: killEntry?.ship_kills ?? 0,
124
+ podKillsLastHour: killEntry?.pod_kills ?? 0,
125
+ npcKillsLastHour: killEntry?.npc_kills ?? 0,
126
+ jumpsLastHour: jumpEntry?.ship_jumps ?? 0,
127
+ };
128
+ }
129
+ /** Best buy/sell prices for a type in The Forge, split out for Jita 4-4. */
130
+ export async function getJitaPrices(typeId) {
131
+ const first = await esiFetch(`/markets/${JITA_REGION_ID}/orders/?type_id=${typeId}&order_type=all&page=1`);
132
+ let orders = first.data;
133
+ if (first.pages > 1) {
134
+ const restPages = Array.from({ length: first.pages - 1 }, (_, i) => i + 2);
135
+ const rest = await Promise.all(restPages.map((page) => esiFetch(`/markets/${JITA_REGION_ID}/orders/?type_id=${typeId}&order_type=all&page=${page}`)));
136
+ orders = orders.concat(...rest.map((r) => r.data));
137
+ }
138
+ const atJita = orders.filter((o) => o.location_id === JITA_44_STATION_ID);
139
+ const sells = atJita.filter((o) => !o.is_buy_order);
140
+ const buys = atJita.filter((o) => o.is_buy_order);
141
+ return {
142
+ bestSell: sells.length ? Math.min(...sells.map((o) => o.price)) : null,
143
+ bestBuy: buys.length ? Math.max(...buys.map((o) => o.price)) : null,
144
+ sellVolume: sells.reduce((sum, o) => sum + o.volume_remain, 0),
145
+ buyVolume: buys.reduce((sum, o) => sum + o.volume_remain, 0),
146
+ };
147
+ }
148
+ export async function getKillmail(killmailId, hash) {
149
+ const { data } = await esiFetch(`/killmails/${killmailId}/${hash}/`);
150
+ return data;
151
+ }
152
+ /** Map a killmail item flag to a human-readable fitting slot. */
153
+ export function slotForFlag(flag) {
154
+ if (flag >= 11 && flag <= 18)
155
+ return "low slot";
156
+ if (flag >= 19 && flag <= 26)
157
+ return "mid slot";
158
+ if (flag >= 27 && flag <= 34)
159
+ return "high slot";
160
+ if (flag >= 92 && flag <= 94)
161
+ return "rig";
162
+ if (flag >= 125 && flag <= 128)
163
+ return "subsystem";
164
+ if (flag === 87)
165
+ return "drone bay";
166
+ if (flag === 5)
167
+ return "cargo";
168
+ return "other";
169
+ }
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * eve-mentor-mcp — MCP server that turns Claude into an EVE Online mentor.
4
+ * Public tools work immediately; character tools need EVE SSO login.
5
+ */
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { z } from "zod";
9
+ import { authStatus, login } from "./auth.js";
10
+ import { getAssetsSummary, getCharacterSheet, getSkillQueue, getTopSkills } from "./character.js";
11
+ import { getJitaPrices, getSystem, getSystemActivity, getType, resolveNames, } from "./esi.js";
12
+ import { getRecentLosses } from "./zkill.js";
13
+ import { flightPlan } from "./skills.js";
14
+ import { CAREER_PATHS } from "./careers.js";
15
+ import { adviseAmmo, analyzeFit } from "./combat.js";
16
+ import { defineJargon } from "./jargon.js";
17
+ import { getPayGuide } from "./payguide.js";
18
+ const server = new McpServer({ name: "eve-mentor", version: "0.3.0" }, {
19
+ instructions: "You are mentoring an EVE Online player. EVE's data changes with every patch: do NOT " +
20
+ "estimate prices, skill requirements, fits, or danger from training knowledge — only values " +
21
+ "returned by these tools are authoritative. If a tool can answer it, call the tool. " +
22
+ "Your job is to turn the returned data into clear, honest coaching for someone still learning the game.",
23
+ });
24
+ function asResult(data) {
25
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
26
+ }
27
+ function asError(error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ return { content: [{ type: "text", text: message }], isError: true };
30
+ }
31
+ // ---------- Public tools ----------
32
+ server.tool("lookup_item", "Look up any EVE item, ship, or module by exact name. Returns its description and Jita market prices.", { name: z.string().describe('Exact in-game name, e.g. "Rifter" or "Damage Control II"') }, async ({ name }) => {
33
+ try {
34
+ const ids = await resolveNames([name]);
35
+ const type = ids.inventory_types?.[0];
36
+ if (!type) {
37
+ return asError(`No item named "${name}" found. Names must be exact.`);
38
+ }
39
+ const [info, prices] = await Promise.all([getType(type.id), getJitaPrices(type.id)]);
40
+ return asResult({
41
+ name: info.name,
42
+ typeId: info.type_id,
43
+ description: info.description.replace(/<[^>]+>/g, ""),
44
+ jitaMarket: prices,
45
+ });
46
+ }
47
+ catch (error) {
48
+ return asError(error);
49
+ }
50
+ });
51
+ server.tool("system_intel", "Danger report for a solar system: security status, ship/pod kills and traffic in the last hour.", { system_name: z.string().describe('Exact system name, e.g. "Jita" or "Tama"') }, async ({ system_name }) => {
52
+ try {
53
+ const ids = await resolveNames([system_name]);
54
+ const match = ids.systems?.[0];
55
+ if (!match) {
56
+ return asError(`No system named "${system_name}" found. Names must be exact.`);
57
+ }
58
+ const [system, activity] = await Promise.all([
59
+ getSystem(match.id),
60
+ getSystemActivity(match.id),
61
+ ]);
62
+ const security = Number(system.security_status.toFixed(1));
63
+ return asResult({
64
+ system: system.name,
65
+ securityStatus: security,
66
+ securityClass: security >= 0.5 ? "high-sec" : security > 0 ? "low-sec" : "null-sec",
67
+ lastHour: activity,
68
+ });
69
+ }
70
+ catch (error) {
71
+ return asError(error);
72
+ }
73
+ });
74
+ server.tool("recent_losses", "A character's recent ship losses from zKillboard with full fit detail — the raw material for explaining why they died. Works for any character name, no login needed.", {
75
+ character_name: z.string().describe("Exact character name"),
76
+ limit: z.number().int().min(1).max(10).default(3).describe("How many losses (1-10)"),
77
+ }, async ({ character_name, limit }) => {
78
+ try {
79
+ const losses = await getRecentLosses(character_name, limit);
80
+ if (losses.length === 0) {
81
+ return asResult({ message: `${character_name} has no recorded losses on zKillboard.` });
82
+ }
83
+ return asResult(losses);
84
+ }
85
+ catch (error) {
86
+ return asError(error);
87
+ }
88
+ });
89
+ server.tool("can_i_fly", "What does it take to fly/use a ship or module? Returns the full recursive skill prerequisite tree, an ordered training plan, total skillpoints, and estimated training time. If a character is logged in, the plan is diffed against their actual trained skills.", { item_name: z.string().describe('Exact ship or module name, e.g. "Vexor" or "Damage Control II"') }, async ({ item_name }) => {
90
+ try {
91
+ const ids = await resolveNames([item_name]);
92
+ const type = ids.inventory_types?.[0];
93
+ if (!type) {
94
+ return asError(`No item named "${item_name}" found. Names must be exact.`);
95
+ }
96
+ return asResult(await flightPlan(type.id, type.name));
97
+ }
98
+ catch (error) {
99
+ return asError(error);
100
+ }
101
+ });
102
+ server.tool("ammo_advisor", "Which damage type to shoot (and tank) against a target: NPC pirate factions (Guristas, Serpentis, Angels, Blood Raiders, Sansha, Rogue Drones, Triglavians) or shield/armor-tanked players. Includes concrete ammo names per weapon system.", { target: z.string().describe('Faction or tank type, e.g. "Guristas" or "armor-tanked player"') }, async ({ target }) => {
103
+ try {
104
+ return asResult(adviseAmmo(target));
105
+ }
106
+ catch (error) {
107
+ return asError(error);
108
+ }
109
+ });
110
+ server.tool("analyze_fit", "Mechanical fit check: classifies each module by type and detects classic mistakes (mixed weapon systems, mixed shield+armor tank, no propulsion, damage mods without matching weapons). Feed it module names from a loss report or a pasted fit, then explain the findings to the player.", {
111
+ module_names: z
112
+ .array(z.string())
113
+ .min(1)
114
+ .describe("Module names exactly as they appear in the fit/killmail"),
115
+ }, async ({ module_names }) => {
116
+ try {
117
+ return asResult(await analyzeFit(module_names));
118
+ }
119
+ catch (error) {
120
+ return asError(error);
121
+ }
122
+ });
123
+ server.tool("career_test", "Data for the EVE career 'sorting hat'. Returns all recognized career paths with traits (social/risk/income/activity, who it appeals to, first ship, first steps). To use: interview the player about what they enjoy (solo vs group, risk appetite, building vs fighting vs exploring, active vs idle, how much structure they want), THEN call this and match their answers to 2-3 paths. Recommend concrete first steps, not just labels.", {}, async () => {
124
+ try {
125
+ return asResult(CAREER_PATHS);
126
+ }
127
+ catch (error) {
128
+ return asError(error);
129
+ }
130
+ });
131
+ server.tool("jargon", "EVE slang glossary. Pass a term to define it, or no term to list everything known. Use whenever the player hits a word like 'krab', 'tidi', 'doctrine', or 'asset safety'.", { term: z.string().optional().describe("The slang term to define; omit to list all terms") }, async ({ term }) => {
132
+ try {
133
+ return asResult(defineJargon(term));
134
+ }
135
+ catch (error) {
136
+ return asError(error);
137
+ }
138
+ });
139
+ server.tool("cheapest_way_to_play", "EVE's pay-vs-play economy explained with live numbers: Alpha (free) vs Omega (subscribed), how PLEX lets you buy game time with ISK instead of money, today's actual Jita price for an Omega month, and an honest grind-hours-vs-cash recommendation.", {}, async () => {
140
+ try {
141
+ return asResult(await getPayGuide());
142
+ }
143
+ catch (error) {
144
+ return asError(error);
145
+ }
146
+ });
147
+ server.tool("sitrep", "Session-start situation report in one call: login state, character overview (skillpoints/wallet/location/ship), what's training, and the most recent loss. Call this at the start of a mentoring session to orient.", {}, async () => {
148
+ const report = {};
149
+ const auth = await authStatus();
150
+ report.auth = auth;
151
+ if (auth.loggedIn && auth.characterName) {
152
+ const [sheet, queue, losses] = await Promise.allSettled([
153
+ getCharacterSheet(),
154
+ getSkillQueue(),
155
+ getRecentLosses(auth.characterName, 1),
156
+ ]);
157
+ report.character = sheet.status === "fulfilled" ? sheet.value : `unavailable: ${sheet.reason}`;
158
+ report.skillQueue = queue.status === "fulfilled" ? queue.value.slice(0, 5) : `unavailable: ${queue.reason}`;
159
+ report.lastLoss = losses.status === "fulfilled" ? (losses.value[0] ?? "no recorded losses") : `unavailable: ${losses.reason}`;
160
+ }
161
+ else {
162
+ report.note = "Not logged in — use eve_login for a personalized sitrep, or ask for the player's character name to pull public loss history.";
163
+ }
164
+ return asResult(report);
165
+ });
166
+ // ---------- Auth + character tools ----------
167
+ server.tool("eve_login", "Start the EVE Online login flow. Opens a browser for SSO; the user must complete it within 3 minutes.", {}, async () => {
168
+ try {
169
+ const result = await login();
170
+ return asResult({ loggedIn: true, ...result });
171
+ }
172
+ catch (error) {
173
+ return asError(error);
174
+ }
175
+ });
176
+ server.tool("eve_auth_status", "Check whether an EVE character is currently logged in.", {}, async () => {
177
+ try {
178
+ return asResult(await authStatus());
179
+ }
180
+ catch (error) {
181
+ return asError(error);
182
+ }
183
+ });
184
+ server.tool("character_sheet", "The logged-in character's overview: total skillpoints, wallet, current location, and ship.", {}, async () => {
185
+ try {
186
+ return asResult(await getCharacterSheet());
187
+ }
188
+ catch (error) {
189
+ return asError(error);
190
+ }
191
+ });
192
+ server.tool("skill_queue", "The logged-in character's current skill training queue.", {}, async () => {
193
+ try {
194
+ return asResult(await getSkillQueue());
195
+ }
196
+ catch (error) {
197
+ return asError(error);
198
+ }
199
+ });
200
+ server.tool("my_assets", "Everything the logged-in character owns, grouped by location. Flags asset-safety wraps and structures the character can no longer dock at — use this to find stuff stranded from years ago.", {}, async () => {
201
+ try {
202
+ return asResult(await getAssetsSummary());
203
+ }
204
+ catch (error) {
205
+ return asError(error);
206
+ }
207
+ });
208
+ server.tool("top_skills", "The logged-in character's highest-trained skills — useful for judging what they can fly well.", { limit: z.number().int().min(1).max(50).default(15).describe("How many skills (1-50)") }, async ({ limit }) => {
209
+ try {
210
+ return asResult(await getTopSkills(limit));
211
+ }
212
+ catch (error) {
213
+ return asError(error);
214
+ }
215
+ });
216
+ const transport = new StdioServerTransport();
217
+ await server.connect(transport);
package/dist/jargon.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * EVE slang glossary — the jargon wall is a researched top-10 new-player
3
+ * pain point and no other tool addresses it. Definitions follow EVE
4
+ * University wiki usage.
5
+ */
6
+ export const GLOSSARY = {
7
+ ratting: "Killing NPC pirates ('rats') in belts or anomalies for bounty ISK.",
8
+ krab: "Someone farming PvE income (ratting/mining) instead of fighting; 'krabbing' is doing that. Mildly derogatory, mostly affectionate.",
9
+ dscan: "Directional scanner (Alt+D): shows ships/structures within 14.3 AU. Spamming it is how you see hunters coming.",
10
+ gank: "Killing a much weaker or unprepared target, often suicide-attacking in high-sec where CONCORD will kill you back.",
11
+ gatecamp: "Players parked at a stargate killing whatever jumps through. Check zKillboard before flying low-sec pipes.",
12
+ pod: "Your escape capsule after your ship dies. Getting 'podded' means losing that too — you wake up in a clone.",
13
+ "pod express": "Getting podded on purpose (or accepting it) as free instant travel back to your home station.",
14
+ plex: "Two meanings: PLEX the item (30 days of game time, buyable/sellable for ISK) and 'plexing' (capturing faction-warfare complexes).",
15
+ doctrine: "A fleet's standardized ship fitting. 'Doctrine ships' are what your alliance tells you to fly so logi can keep you alive.",
16
+ fc: "Fleet Commander — calls targets and movement. 'FC, what do?' is the eternal question.",
17
+ srp: "Ship Replacement Program — your alliance reimburses ships lost on sanctioned fleets. Makes fleet PvP nearly free.",
18
+ hisec: "High security space (0.5–1.0): CONCORD punishes attackers. Not safe, just policed.",
19
+ lowsec: "Low security (0.1–0.4): no CONCORD response, gate guns only. Faction warfare and pirate country.",
20
+ nullsec: "0.0 space: no law at all, player alliances own it. Big fleets, big riches, bubbles.",
21
+ jspace: "Wormhole space (J-numbered systems): no local chat, no stargates, mapped only by scanning.",
22
+ local: "The chat channel listing everyone in your system — null-sec's primary intel tool. Watch it like a smoke detector.",
23
+ blob: "An overwhelmingly larger fleet. 'Getting blobbed' = losing to numbers, not skill.",
24
+ kite: "Fighting at range while staying faster than the enemy can close. Opposite of brawling.",
25
+ brawl: "Close-range, high-damage slugging match. Webs and scrams decide it.",
26
+ tackle: "Holding an enemy ship in place with warp disruption so it can't escape. The newbie fleet job that gets you on every killmail.",
27
+ point: "Warp Disruptor (1 warp-strength block, longer range). 'Point!' in fleet chat means 'I have them tackled.'",
28
+ scram: "Warp Scrambler (2 strength, short range, also shuts off microwarpdrives).",
29
+ web: "Stasis Webifier — slows the target so your guns track and your scram range holds.",
30
+ neut: "Energy Neutralizer — drains the target's capacitor so their guns/reps/prop die.",
31
+ dps: "Damage per second. Also shorthand for the damage-dealing ships in a fleet (vs tackle/logi).",
32
+ tank: "Your defenses. Buffer tank = raw HP, active tank = repairing yourself. Pick shield OR armor, never both.",
33
+ logi: "Logistics ships — fleet healers that remote-repair friendlies. Always blamed, never thanked.",
34
+ alpha: "Two meanings: an Alpha clone (free-to-play account) or alpha strike (one massive volley before the target can react).",
35
+ omega: "A subscribed account: all skills available, double training speed.",
36
+ cyno: "Cynosural field — a beacon capital ships jump to. Lighting one in the wrong place is how supercarriers die.",
37
+ capital: "The big ships: carriers, dreadnoughts, supercarriers, titans. Null-sec endgame, terrible newbie purchases.",
38
+ bait: "A deliberately vulnerable-looking ship with friends one jump away. If it looks too easy, it is.",
39
+ awox: "Attacking your own corpmates from inside the corp. Why corps are paranoid about recruits.",
40
+ tidi: "Time dilation — the server slowing time (down to 10%) so thousand-player battles can compute.",
41
+ jita: "The main trade hub (Jita 4-4 station). Everything is for sale; undocking there is the most dangerous trip in high-sec.",
42
+ "asset safety": "When you lose access to a player structure, your stuff moves to a recoverable holding system (delivered to an NPC station for a fee). Your items are never deleted.",
43
+ concord: "The high-sec police. They don't prevent crime — they punish it after your ship is already dead.",
44
+ "sec status": "Your personal security rating. Goes down for attacking players in hisec/lowsec; below -5 you're freely attackable everywhere.",
45
+ standings: "NPC factions' opinion of you. Gates which mission agents will talk to you.",
46
+ lp: "Loyalty Points — mission/FW currency spent in corp stores for faction gear. Often worth more than the ISK reward.",
47
+ abyssals: "Abyssal Deadspace — timed instanced PvE dungeons entered with filaments. Die inside or run out of time and you lose everything.",
48
+ filament: "Consumable that teleports you into abyssal space or across the universe (Pochven/null filaments).",
49
+ "jump clone": "A second body you can jump into across the map (with cooldown) — lets you keep an expensive-implant clone safe at home.",
50
+ align: "Pointing your ship at a warp target so warp engages instantly. 'Align out' = be ready to flee.",
51
+ bubble: "Warp disruption field in null-sec that stops everyone in it from warping — no targeting needed. Why null travel kills newbies.",
52
+ bm: "Bookmark — a saved location in space. Safe spots, undock points, and tactical perches are all bookmarks.",
53
+ };
54
+ export function defineJargon(term) {
55
+ if (!term) {
56
+ return { knownTerms: Object.keys(GLOSSARY).sort() };
57
+ }
58
+ const normalized = term.toLowerCase().trim();
59
+ const exact = GLOSSARY[normalized];
60
+ if (exact)
61
+ return { term: normalized, definition: exact };
62
+ const partial = Object.entries(GLOSSARY).filter(([key]) => key.includes(normalized) || normalized.includes(key));
63
+ if (partial.length > 0) {
64
+ return Object.fromEntries(partial);
65
+ }
66
+ return {
67
+ error: `"${term}" isn't in the glossary yet.`,
68
+ knownTerms: Object.keys(GLOSSARY).sort(),
69
+ };
70
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The pay-vs-play guide: EVE's Alpha/Omega/PLEX economy explained with live
3
+ * numbers. PLEX is an in-game item tradeable for ISK, so subscription time
4
+ * can be bought with play instead of money — the math changes monthly.
5
+ */
6
+ import { esiFetch } from "./esi.js";
7
+ const PLEX_TYPE_ID = 44992;
8
+ const PLEX_PER_OMEGA_MONTH = 500;
9
+ const STATIC_GUIDE = {
10
+ alpha: {
11
+ cost: "Free, forever.",
12
+ whatYouGet: "All four empires' Tech 1 ships up through battleships, a limited skill set, and normal access to the same single universe as everyone else.",
13
+ limits: "Skill training stops at the Alpha set (~5M skillpoints of free training; more via injectors), trains at half speed, no Tech 2 ships, one logged-in character at a time.",
14
+ honestTake: "Alpha is a real game, not a demo — exploration, faction warfare, and trading all work. It is the right way to find out if EVE sticks before paying anything.",
15
+ },
16
+ omega: {
17
+ cost: "A subscription — paid with real money OR with PLEX bought from other players with ISK.",
18
+ whatYouGet: "Every ship and skill in the game, double training speed, multiple training queues per account.",
19
+ },
20
+ plexExplained: "PLEX is game time as a tradeable item: someone with more money than time buys PLEX with cash and sells it on the market for ISK; someone with more time than money earns ISK and buys that PLEX for game time. Both players get what they want, and CCP gets paid either way. It also means every ship in EVE has a real-world price shadow — which is why big battles make gaming news with dollar figures.",
21
+ cheapestPaths: [
22
+ "1. Start Alpha. Pay nothing until the game has actually hooked you.",
23
+ "2. If it hooks you, pay cash for the first Omega month or two — your early hours are worth more spent learning than grinding for PLEX.",
24
+ "3. Once your income is real (see the live math below for the bar), decide each month: grind the PLEX price in ISK, or pay cash and spend those hours having fun.",
25
+ "4. Watch for starter-pack sales — discounted Omega+skillpoint bundles for new/returning accounts are common and usually the best cash value.",
26
+ ],
27
+ trap: "Buying PLEX with cash to sell for ISK to buy ships ('whaling') is legal but is how people spend hundreds of dollars learning to lose ships. Earn the cheap ships first.",
28
+ };
29
+ /** Live cost of an Omega month in ISK, with honest grind-hours context. */
30
+ export async function getPayGuide() {
31
+ let liveMath;
32
+ try {
33
+ // PLEX trades on a special global market, not regional order books, so use
34
+ // ESI's universe-wide average price.
35
+ const { data } = await esiFetch("/markets/prices/");
36
+ const plexPrice = data.find((entry) => entry.type_id === PLEX_TYPE_ID)?.average_price;
37
+ if (plexPrice) {
38
+ const omegaMonthIsk = Math.round(plexPrice * PLEX_PER_OMEGA_MONTH);
39
+ liveMath = {
40
+ plexAveragePrice: Math.round(plexPrice),
41
+ omegaMonthInIsk: omegaMonthIsk,
42
+ omegaMonthReadable: `${(omegaMonthIsk / 1e9).toFixed(2)} billion ISK for ${PLEX_PER_OMEGA_MONTH} PLEX`,
43
+ grindContext: `At rough new-player income estimates (10-40M ISK/hour from exploration or L3 missions), ` +
44
+ `that's ~${Math.round(omegaMonthIsk / 40e6)}-${Math.round(omegaMonthIsk / 10e6)} hours of grinding per month. ` +
45
+ "If that number is bigger than your monthly play time, pay cash and spend your hours on fun.",
46
+ };
47
+ }
48
+ else {
49
+ liveMath = { error: "PLEX price missing from ESI market prices." };
50
+ }
51
+ }
52
+ catch (error) {
53
+ liveMath = {
54
+ error: `Live PLEX price unavailable: ${error instanceof Error ? error.message : String(error)}`,
55
+ };
56
+ }
57
+ return { ...STATIC_GUIDE, liveMath };
58
+ }