universalis-mcp-server 0.1.0 → 0.1.1

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/README.md CHANGED
@@ -16,7 +16,13 @@ All tools support `response_format` as `markdown` or `json`.
16
16
  ### Item lookup (XIVAPI)
17
17
 
18
18
  - `universalis_resolve_items_by_name`: Search items by name with `partial` or `exact` matching.
19
+ - `universalis_resolve_items_by_names`: Resolve multiple item names in one search query.
19
20
  - `universalis_get_item_by_id`: Fetch a single item row by ID.
21
+ - `universalis_get_items_by_ids`: Fetch multiple item rows by ID in one request.
22
+
23
+ ### Workflows
24
+
25
+ - `universalis_rank_items_by_profitability`: Resolve names, fetch aggregated data, and rank by demand and profit.
20
26
 
21
27
  ### Reference data (Universalis)
22
28
 
@@ -0,0 +1,347 @@
1
+ {
2
+ "generated_at": "2026-01-11T19:43:16.262Z",
3
+ "xivapi_version": "309a1e90d28b6bc6",
4
+ "categories": {
5
+ "combat": {
6
+ "I": [
7
+ "Aurelia Kiss Materia I",
8
+ "Battledance Materia I",
9
+ "Bison Hoof Materia I",
10
+ "Cactuar Foot Materia I",
11
+ "Chocobo Down Materia I",
12
+ "Coeurl Eye Materia I",
13
+ "Dexterity Materia I",
14
+ "Earth Materia I",
15
+ "Fire Materia I",
16
+ "Funguar Shriek Materia I",
17
+ "Heavens' Eye Materia I",
18
+ "Ice Materia I",
19
+ "Intelligence Materia I",
20
+ "Lightning Materia I",
21
+ "Mind Materia I",
22
+ "Piety Materia I",
23
+ "Quickarm Materia I",
24
+ "Quicktongue Materia I",
25
+ "Savage Aim Materia I",
26
+ "Savage Might Materia I",
27
+ "Strength Materia I",
28
+ "Treant Root Materia I",
29
+ "Vitality Materia I",
30
+ "Water Materia I",
31
+ "Wind Materia I"
32
+ ],
33
+ "II": [
34
+ "Battledance Materia II",
35
+ "Dexterity Materia II",
36
+ "Earth Materia II",
37
+ "Fire Materia II",
38
+ "Heavens' Eye Materia II",
39
+ "Ice Materia II",
40
+ "Intelligence Materia II",
41
+ "Lightning Materia II",
42
+ "Mind Materia II",
43
+ "Piety Materia II",
44
+ "Quickarm Materia II",
45
+ "Quicktongue Materia II",
46
+ "Savage Aim Materia II",
47
+ "Savage Might Materia II",
48
+ "Strength Materia II",
49
+ "Vitality Materia II",
50
+ "Water Materia II",
51
+ "Wind Materia II"
52
+ ],
53
+ "III": [
54
+ "Battledance Materia III",
55
+ "Dexterity Materia III",
56
+ "Earth Materia III",
57
+ "Fire Materia III",
58
+ "Heavens' Eye Materia III",
59
+ "Ice Materia III",
60
+ "Intelligence Materia III",
61
+ "Lightning Materia III",
62
+ "Mind Materia III",
63
+ "Piety Materia III",
64
+ "Quickarm Materia III",
65
+ "Quicktongue Materia III",
66
+ "Savage Aim Materia III",
67
+ "Savage Might Materia III",
68
+ "Strength Materia III",
69
+ "Vitality Materia III",
70
+ "Water Materia III",
71
+ "Wind Materia III"
72
+ ],
73
+ "IV": [
74
+ "Battledance Materia IV",
75
+ "Dexterity Materia IV",
76
+ "Earth Materia IV",
77
+ "Fire Materia IV",
78
+ "Heavens' Eye Materia IV",
79
+ "Ice Materia IV",
80
+ "Intelligence Materia IV",
81
+ "Lightning Materia IV",
82
+ "Mind Materia IV",
83
+ "Piety Materia IV",
84
+ "Quickarm Materia IV",
85
+ "Quicktongue Materia IV",
86
+ "Savage Aim Materia IV",
87
+ "Savage Might Materia IV",
88
+ "Strength Materia IV",
89
+ "Vitality Materia IV",
90
+ "Water Materia IV",
91
+ "Wind Materia IV"
92
+ ],
93
+ "V": [
94
+ "Battledance Materia V",
95
+ "Dexterity Materia V",
96
+ "Earth Materia V",
97
+ "Fire Materia V",
98
+ "Heavens' Eye Materia V",
99
+ "Ice Materia V",
100
+ "Intelligence Materia V",
101
+ "Lightning Materia V",
102
+ "Mind Materia V",
103
+ "Piety Materia V",
104
+ "Quickarm Materia V",
105
+ "Quicktongue Materia V",
106
+ "Savage Aim Materia V",
107
+ "Savage Might Materia V",
108
+ "Strength Materia V",
109
+ "Vitality Materia V",
110
+ "Water Materia V",
111
+ "Wind Materia V"
112
+ ],
113
+ "VI": [
114
+ "Battledance Materia VI",
115
+ "Dexterity Materia VI",
116
+ "Earth Materia VI",
117
+ "Fire Materia VI",
118
+ "Heavens' Eye Materia VI",
119
+ "Ice Materia VI",
120
+ "Intelligence Materia VI",
121
+ "Lightning Materia VI",
122
+ "Mind Materia VI",
123
+ "Piety Materia VI",
124
+ "Quickarm Materia VI",
125
+ "Quicktongue Materia VI",
126
+ "Savage Aim Materia VI",
127
+ "Savage Might Materia VI",
128
+ "Strength Materia VI",
129
+ "Vitality Materia VI",
130
+ "Water Materia VI",
131
+ "Wind Materia VI"
132
+ ],
133
+ "VII": [
134
+ "Battledance Materia VII",
135
+ "Heavens' Eye Materia VII",
136
+ "Piety Materia VII",
137
+ "Quickarm Materia VII",
138
+ "Quicktongue Materia VII",
139
+ "Savage Aim Materia VII",
140
+ "Savage Might Materia VII"
141
+ ],
142
+ "VIII": [
143
+ "Battledance Materia VIII",
144
+ "Heavens' Eye Materia VIII",
145
+ "Piety Materia VIII",
146
+ "Quickarm Materia VIII",
147
+ "Quicktongue Materia VIII",
148
+ "Savage Aim Materia VIII",
149
+ "Savage Might Materia VIII"
150
+ ],
151
+ "IX": [
152
+ "Battledance Materia IX",
153
+ "Heavens' Eye Materia IX",
154
+ "Piety Materia IX",
155
+ "Quickarm Materia IX",
156
+ "Quicktongue Materia IX",
157
+ "Savage Aim Materia IX",
158
+ "Savage Might Materia IX"
159
+ ],
160
+ "X": [
161
+ "Battledance Materia X",
162
+ "Heavens' Eye Materia X",
163
+ "Piety Materia X",
164
+ "Quickarm Materia X",
165
+ "Quicktongue Materia X",
166
+ "Savage Aim Materia X",
167
+ "Savage Might Materia X"
168
+ ],
169
+ "XI": [
170
+ "Battledance Materia XI",
171
+ "Heavens' Eye Materia XI",
172
+ "Piety Materia XI",
173
+ "Quickarm Materia XI",
174
+ "Quicktongue Materia XI",
175
+ "Savage Aim Materia XI",
176
+ "Savage Might Materia XI"
177
+ ],
178
+ "XII": [
179
+ "Battledance Materia XII",
180
+ "Heavens' Eye Materia XII",
181
+ "Piety Materia XII",
182
+ "Quickarm Materia XII",
183
+ "Quicktongue Materia XII",
184
+ "Savage Aim Materia XII",
185
+ "Savage Might Materia XII"
186
+ ]
187
+ },
188
+ "crafting": {
189
+ "I": [
190
+ "Craftsman's Command Materia I",
191
+ "Craftsman's Competence Materia I",
192
+ "Craftsman's Cunning Materia I"
193
+ ],
194
+ "II": [
195
+ "Craftsman's Command Materia II",
196
+ "Craftsman's Competence Materia II",
197
+ "Craftsman's Cunning Materia II"
198
+ ],
199
+ "III": [
200
+ "Craftsman's Command Materia III",
201
+ "Craftsman's Competence Materia III",
202
+ "Craftsman's Cunning Materia III"
203
+ ],
204
+ "IV": [
205
+ "Craftsman's Command Materia IV",
206
+ "Craftsman's Competence Materia IV",
207
+ "Craftsman's Cunning Materia IV"
208
+ ],
209
+ "V": [
210
+ "Craftsman's Command Materia V",
211
+ "Craftsman's Competence Materia V",
212
+ "Craftsman's Cunning Materia V"
213
+ ],
214
+ "VI": [
215
+ "Craftsman's Command Materia VI",
216
+ "Craftsman's Competence Materia VI",
217
+ "Craftsman's Cunning Materia VI"
218
+ ],
219
+ "VII": [
220
+ "Craftsman's Command Materia VII",
221
+ "Craftsman's Competence Materia VII",
222
+ "Craftsman's Cunning Materia VII"
223
+ ],
224
+ "VIII": [
225
+ "Craftsman's Command Materia VIII",
226
+ "Craftsman's Competence Materia VIII",
227
+ "Craftsman's Cunning Materia VIII"
228
+ ],
229
+ "IX": [
230
+ "Craftsman's Command Materia IX",
231
+ "Craftsman's Competence Materia IX",
232
+ "Craftsman's Cunning Materia IX"
233
+ ],
234
+ "X": [
235
+ "Craftsman's Command Materia X",
236
+ "Craftsman's Competence Materia X",
237
+ "Craftsman's Cunning Materia X"
238
+ ],
239
+ "XI": [
240
+ "Craftsman's Command Materia XI",
241
+ "Craftsman's Competence Materia XI",
242
+ "Craftsman's Cunning Materia XI"
243
+ ],
244
+ "XII": [
245
+ "Craftsman's Command Materia XII",
246
+ "Craftsman's Competence Materia XII",
247
+ "Craftsman's Cunning Materia XII"
248
+ ]
249
+ },
250
+ "gathering": {
251
+ "I": [
252
+ "Gatherer's Grasp Materia I",
253
+ "Gatherer's Guerdon Materia I",
254
+ "Gatherer's Guile Materia I"
255
+ ],
256
+ "II": [
257
+ "Gatherer's Grasp Materia II",
258
+ "Gatherer's Guerdon Materia II",
259
+ "Gatherer's Guile Materia II"
260
+ ],
261
+ "III": [
262
+ "Gatherer's Grasp Materia III",
263
+ "Gatherer's Guerdon Materia III",
264
+ "Gatherer's Guile Materia III"
265
+ ],
266
+ "IV": [
267
+ "Gatherer's Grasp Materia IV",
268
+ "Gatherer's Guerdon Materia IV",
269
+ "Gatherer's Guile Materia IV"
270
+ ],
271
+ "V": [
272
+ "Gatherer's Grasp Materia V",
273
+ "Gatherer's Guerdon Materia V",
274
+ "Gatherer's Guile Materia V"
275
+ ],
276
+ "VI": [
277
+ "Gatherer's Grasp Materia VI",
278
+ "Gatherer's Guerdon Materia VI",
279
+ "Gatherer's Guile Materia VI"
280
+ ],
281
+ "VII": [
282
+ "Gatherer's Grasp Materia VII",
283
+ "Gatherer's Guerdon Materia VII",
284
+ "Gatherer's Guile Materia VII"
285
+ ],
286
+ "VIII": [
287
+ "Gatherer's Grasp Materia VIII",
288
+ "Gatherer's Guerdon Materia VIII",
289
+ "Gatherer's Guile Materia VIII"
290
+ ],
291
+ "IX": [
292
+ "Gatherer's Grasp Materia IX",
293
+ "Gatherer's Guerdon Materia IX",
294
+ "Gatherer's Guile Materia IX"
295
+ ],
296
+ "X": [
297
+ "Gatherer's Grasp Materia X",
298
+ "Gatherer's Guerdon Materia X",
299
+ "Gatherer's Guile Materia X"
300
+ ],
301
+ "XI": [
302
+ "Gatherer's Grasp Materia XI",
303
+ "Gatherer's Guerdon Materia XI",
304
+ "Gatherer's Guile Materia XI"
305
+ ],
306
+ "XII": [
307
+ "Gatherer's Grasp Materia XII",
308
+ "Gatherer's Guerdon Materia XII",
309
+ "Gatherer's Guile Materia XII"
310
+ ]
311
+ }
312
+ },
313
+ "base_param_category": {
314
+ "Strength": "combat",
315
+ "Vitality": "combat",
316
+ "Dexterity": "combat",
317
+ "Intelligence": "combat",
318
+ "Mind": "combat",
319
+ "Piety": "combat",
320
+ "Fire Resistance": "combat",
321
+ "Ice Resistance": "combat",
322
+ "Wind Resistance": "combat",
323
+ "Earth Resistance": "combat",
324
+ "Lightning Resistance": "combat",
325
+ "Water Resistance": "combat",
326
+ "Direct Hit Rate": "combat",
327
+ "Critical Hit": "combat",
328
+ "Determination": "combat",
329
+ "Tenacity": "combat",
330
+ "Gathering": "gathering",
331
+ "Perception": "gathering",
332
+ "GP": "gathering",
333
+ "Craftsmanship": "crafting",
334
+ "CP": "crafting",
335
+ "Control": "crafting",
336
+ "Skill Speed": "combat",
337
+ "Spell Speed": "combat",
338
+ "Slow Resistance": "combat",
339
+ "Silence Resistance": "combat",
340
+ "Blind Resistance": "combat",
341
+ "Poison Resistance": "combat",
342
+ "Stun Resistance": "combat",
343
+ "Sleep Resistance": "combat",
344
+ "Bind Resistance": "combat",
345
+ "Heavy Resistance": "combat"
346
+ }
347
+ }
package/dist/constants.js CHANGED
@@ -3,6 +3,7 @@ export const XIVAPI_BASE_URL = process.env.XIVAPI_BASE_URL ?? "https://v2.xivapi
3
3
  export const DEFAULT_UNIVERSALIS_TIMEOUT_MS = 30000;
4
4
  export const DEFAULT_XIVAPI_TIMEOUT_MS = 30000;
5
5
  export const DEFAULT_TIMEOUT_MS = 30000;
6
+ export const DEFAULT_MATERIA_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
6
7
  export const CHARACTER_LIMIT = 25000;
7
8
  export const DEFAULT_PAGE_LIMIT = 20;
8
9
  export const MAX_PAGE_LIMIT = 100;
package/dist/index.js CHANGED
@@ -2,11 +2,13 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import pkg from "../package.json" with { type: "json" };
5
+ import { SERVER_INSTRUCTIONS } from "./instructions.js";
5
6
  import { createClients } from "./services/clients.js";
6
7
  import { registerLookupTools } from "./tools/lookup.js";
7
8
  import { registerMarketTools } from "./tools/market.js";
8
9
  import { registerReferenceTools } from "./tools/reference.js";
9
10
  import { registerStatsTools } from "./tools/stats.js";
11
+ import { registerWorkflowTools } from "./tools/workflows.js";
10
12
  function toNumber(value) {
11
13
  if (!value)
12
14
  return undefined;
@@ -17,6 +19,8 @@ async function main() {
17
19
  const server = new McpServer({
18
20
  name: "universalis-mcp-server",
19
21
  version: pkg.version,
22
+ }, {
23
+ instructions: SERVER_INSTRUCTIONS,
20
24
  });
21
25
  const userAgent = process.env.UNIVERSALIS_MCP_USER_AGENT ?? `universalis-mcp-server/${pkg.version}`;
22
26
  const clients = createClients({
@@ -30,6 +34,21 @@ async function main() {
30
34
  registerReferenceTools(server, clients);
31
35
  registerStatsTools(server, clients);
32
36
  registerLookupTools(server, clients);
37
+ registerWorkflowTools(server, clients);
38
+ server.registerPrompt("universalis_usage_guide", {
39
+ title: "Universalis Usage Guide",
40
+ description: "Quick guidance on which tools to use and when.",
41
+ }, async () => ({
42
+ messages: [
43
+ {
44
+ role: "user",
45
+ content: {
46
+ type: "text",
47
+ text: SERVER_INSTRUCTIONS,
48
+ },
49
+ },
50
+ ],
51
+ }));
33
52
  const transport = new StdioServerTransport();
34
53
  await server.connect(transport);
35
54
  console.error("universalis-mcp-server running on stdio");
@@ -0,0 +1,36 @@
1
+ export const SERVER_INSTRUCTIONS = `Use this server to analyze FFXIV market data via Universalis and item data via XIVAPI.
2
+
3
+ Tool guide:
4
+ - universalis_rank_items_by_profitability: best for "most profitable to farm" questions with cost inputs; ranks by demand and profit (defaults to best HQ/NQ price, can filter unmarketable items, and includes supply metrics).
5
+ - universalis_resolve_items_by_names: bulk name-to-ID resolution in one query; prefer over per-item search.
6
+ - universalis_resolve_items_by_name: single-item name search.
7
+ - universalis_get_item_by_id: fetch one item row by ID from XIVAPI.
8
+ - universalis_get_items_by_ids: fetch multiple item rows by ID in one request.
9
+
10
+ - universalis_get_aggregated_prices: cached summary stats (min listing, recent purchase, averages, velocity). Use for most analysis.
11
+ - universalis_get_current_listings: listings + recent history when the user needs live listings or example sales.
12
+ - universalis_get_sales_history: detailed sales history when trend analysis or filters are required.
13
+
14
+ - universalis_list_worlds: list and validate worlds.
15
+ - universalis_list_data_centers: list and validate data centers.
16
+ - universalis_get_tax_rates: current tax rates for a world.
17
+ - universalis_list_marketable_items: marketable item IDs (paginate).
18
+ - universalis_get_list: retrieve a Universalis list by ID.
19
+ - universalis_get_content: content metadata by ID (best-effort endpoint).
20
+
21
+ - universalis_get_most_recent_updates: most recently updated items for a world or DC.
22
+ - universalis_get_least_recent_updates: least recently updated items for a world or DC.
23
+ - universalis_get_recent_updates: legacy list of recently updated items (no world/DC info).
24
+ - universalis_get_upload_counts_by_source: upload counts by client app.
25
+ - universalis_get_upload_counts_by_world: upload counts by world.
26
+ - universalis_get_upload_history: daily upload totals for the last 30 days.
27
+
28
+ Notes:
29
+ - world_dc_region accepts a world name/ID, data center name, or region.
30
+ - response_format defaults to markdown; use json for structured processing.
31
+ - Materia categories like "Combat Materia VII", "Crafting Materia VII", and "Gathering Materia VII" are expanded to their specific item names; grade is required.
32
+ - Bulk resolve and profitability tools can fall back to partial matches for unresolved exact inputs; check match_type for "fallback_partial".
33
+ - Materia expansion uses cached XIVAPI data; refresh is controlled by MATERIA_CACHE_TTL_MS and MATERIA_REFRESH.
34
+ - Profitability tool options: marketable_only (default true), min_velocity threshold, include_supply (default true).
35
+ - price_variant defaults to "best".
36
+ `;
@@ -0,0 +1,189 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { DEFAULT_MATERIA_CACHE_TTL_MS } from "../constants.js";
5
+ import { extractMateriaGrade, parseMateriaCategoryInput } from "../utils/materia.js";
6
+ const DEFAULT_MATERIA_FIELDS = "BaseParam.Name,Item[].Name";
7
+ const DEFAULT_MATERIA_LIMIT = 200;
8
+ const craftingParams = new Set(["Craftsmanship", "Control", "CP"]);
9
+ const gatheringParams = new Set(["Gathering", "Perception", "GP"]);
10
+ const defaultIndex = {
11
+ generated_at: new Date(0).toISOString(),
12
+ categories: {
13
+ combat: {},
14
+ crafting: {},
15
+ gathering: {},
16
+ },
17
+ };
18
+ const dataPath = (() => {
19
+ if (process.env.MATERIA_DATA_PATH) {
20
+ return path.resolve(process.env.MATERIA_DATA_PATH);
21
+ }
22
+ const currentDir = path.dirname(fileURLToPath(import.meta.url));
23
+ return path.resolve(currentDir, "../../data/materia.json");
24
+ })();
25
+ let cachedIndex = null;
26
+ let refreshPromise = null;
27
+ function parseTtlMs() {
28
+ const raw = process.env.MATERIA_CACHE_TTL_MS;
29
+ if (!raw)
30
+ return DEFAULT_MATERIA_CACHE_TTL_MS;
31
+ const parsed = Number(raw);
32
+ return Number.isNaN(parsed) ? DEFAULT_MATERIA_CACHE_TTL_MS : parsed;
33
+ }
34
+ function isRefreshEnabled() {
35
+ const raw = process.env.MATERIA_REFRESH;
36
+ if (!raw)
37
+ return true;
38
+ return raw.toLowerCase() !== "false";
39
+ }
40
+ function isStale(index) {
41
+ if (!index?.generated_at)
42
+ return true;
43
+ const generatedAt = Date.parse(index.generated_at);
44
+ if (Number.isNaN(generatedAt))
45
+ return true;
46
+ return Date.now() - generatedAt > parseTtlMs();
47
+ }
48
+ function categorizeBaseParam(name) {
49
+ if (craftingParams.has(name))
50
+ return "crafting";
51
+ if (gatheringParams.has(name))
52
+ return "gathering";
53
+ return "combat";
54
+ }
55
+ async function loadMateriaIndexFromDisk() {
56
+ try {
57
+ const raw = await readFile(dataPath, "utf8");
58
+ const parsed = JSON.parse(raw);
59
+ if (!parsed?.categories)
60
+ return null;
61
+ return parsed;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ async function saveMateriaIndex(index) {
68
+ const payload = JSON.stringify(index, null, 2);
69
+ await mkdir(path.dirname(dataPath), { recursive: true });
70
+ await writeFile(dataPath, `${payload}\n`, "utf8");
71
+ }
72
+ async function fetchMateriaIndex(xivapi) {
73
+ const categories = {
74
+ combat: {},
75
+ crafting: {},
76
+ gathering: {},
77
+ };
78
+ const baseParamCategory = {};
79
+ let after;
80
+ let version;
81
+ while (true) {
82
+ const data = await xivapi.getSheetRows("Materia", {
83
+ limit: DEFAULT_MATERIA_LIMIT,
84
+ after,
85
+ fields: DEFAULT_MATERIA_FIELDS,
86
+ });
87
+ if (!data || !Array.isArray(data.rows) || data.rows.length === 0) {
88
+ if (!version && typeof data?.version === "string") {
89
+ version = data.version;
90
+ }
91
+ break;
92
+ }
93
+ if (!version && typeof data.version === "string") {
94
+ version = data.version;
95
+ }
96
+ for (const row of data.rows) {
97
+ const baseParamName = row.fields?.BaseParam?.fields?.Name;
98
+ if (!baseParamName)
99
+ continue;
100
+ const category = categorizeBaseParam(baseParamName);
101
+ baseParamCategory[baseParamName] = category;
102
+ const items = row.fields?.Item ?? [];
103
+ for (const item of items) {
104
+ const itemName = item.fields?.Name;
105
+ if (!itemName)
106
+ continue;
107
+ const grade = extractMateriaGrade(itemName);
108
+ if (!grade)
109
+ continue;
110
+ const gradeBucket = categories[category][grade] ?? [];
111
+ if (!gradeBucket.includes(itemName)) {
112
+ gradeBucket.push(itemName);
113
+ }
114
+ categories[category][grade] = gradeBucket;
115
+ }
116
+ }
117
+ const lastRow = data.rows[data.rows.length - 1];
118
+ const lastId = lastRow?.row_id;
119
+ if (!lastId || data.rows.length < DEFAULT_MATERIA_LIMIT) {
120
+ break;
121
+ }
122
+ if (after === lastId) {
123
+ break;
124
+ }
125
+ after = lastId;
126
+ }
127
+ for (const category of Object.keys(categories)) {
128
+ for (const grade of Object.keys(categories[category])) {
129
+ categories[category][grade] = categories[category][grade].sort();
130
+ }
131
+ }
132
+ return {
133
+ generated_at: new Date().toISOString(),
134
+ ...(version ? { xivapi_version: version } : {}),
135
+ categories,
136
+ base_param_category: baseParamCategory,
137
+ };
138
+ }
139
+ async function refreshIndex(xivapi, waitForResult) {
140
+ if (!isRefreshEnabled()) {
141
+ cachedIndex = cachedIndex ?? defaultIndex;
142
+ return cachedIndex;
143
+ }
144
+ if (refreshPromise) {
145
+ return waitForResult ? refreshPromise : cachedIndex ?? defaultIndex;
146
+ }
147
+ refreshPromise = (async () => {
148
+ try {
149
+ const index = await fetchMateriaIndex(xivapi);
150
+ cachedIndex = index;
151
+ await saveMateriaIndex(index);
152
+ return index;
153
+ }
154
+ catch (error) {
155
+ cachedIndex = cachedIndex ?? defaultIndex;
156
+ return cachedIndex;
157
+ }
158
+ finally {
159
+ refreshPromise = null;
160
+ }
161
+ })();
162
+ return waitForResult ? refreshPromise : cachedIndex ?? defaultIndex;
163
+ }
164
+ async function getMateriaIndex(xivapi) {
165
+ if (!cachedIndex) {
166
+ cachedIndex = (await loadMateriaIndexFromDisk()) ?? null;
167
+ }
168
+ if (!cachedIndex) {
169
+ return refreshIndex(xivapi, true);
170
+ }
171
+ if (isStale(cachedIndex)) {
172
+ void refreshIndex(xivapi, false);
173
+ }
174
+ return cachedIndex;
175
+ }
176
+ export async function expandMateriaCategory(input, xivapi) {
177
+ const parsed = parseMateriaCategoryInput(input);
178
+ if (!parsed)
179
+ return null;
180
+ const index = await getMateriaIndex(xivapi);
181
+ const expandedNames = parsed.grade
182
+ ? index.categories[parsed.category]?.[parsed.grade] ?? []
183
+ : [];
184
+ return {
185
+ category: parsed.category,
186
+ grade: parsed.grade,
187
+ expandedNames,
188
+ };
189
+ }