chore: initial commit – SDFH CLI and section-typed data
This commit is contained in:
parent
dc24490f3d
commit
8ed3fc64e2
4 changed files with 53069 additions and 0 deletions
29
README.md
Normal file
29
README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
Stella Deus Fuse Helper (SDFH)
|
||||||
|
|
||||||
|
Interactive CLI to browse Stella Deus items and their fusion recipes using section-typed data.
|
||||||
|
|
||||||
|
What’s included
|
||||||
|
- sdfh.js: single-file CLI (pure JS)
|
||||||
|
- sdfh_item_data.json: supplied data (items include name, rank, section, type, optional stats, and recipes)
|
||||||
|
|
||||||
|
How to run
|
||||||
|
- node sdfh.js
|
||||||
|
|
||||||
|
How to search
|
||||||
|
- Type-only rank: "katana r7", "axe r6", "hat r3"
|
||||||
|
- Exact item name: "battle axe"
|
||||||
|
- Item name + rank: "battle axe r7", "golden apple r5"
|
||||||
|
- Suggestions: top 5 by default; add "--full" to show more
|
||||||
|
|
||||||
|
Rules
|
||||||
|
- Types come from guide sections (e.g., 1.6 knife, 4.2 glove, 5.2 scroll, 5.3 rankup)
|
||||||
|
- No aliasing or cross-type mapping
|
||||||
|
- Rank queries use explicit types or exact item names only
|
||||||
|
|
||||||
|
Output
|
||||||
|
- Shows item header, optional stats, and an aligned ASCII table of all fusion recipes that create the item
|
||||||
|
|
||||||
|
License
|
||||||
|
MIT
|
||||||
|
|
||||||
|
|
||||||
217
rebuild-item-data.js
Normal file
217
rebuild-item-data.js
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const INPUT_HTML = path.resolve('/home/chaiman/dev/ai/stella deus/Stella Deus_ The Gate of Eternity - Fusion FAQ - PlayStation 2 - By Gundam4fun - GameFAQs.html');
|
||||||
|
const OUTPUT_JSON = path.resolve('/home/chaiman/dev/ai/stella deus/sdfh_item_data.json');
|
||||||
|
|
||||||
|
function readHtml(file) {
|
||||||
|
return fs.readFileSync(file, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPre(html) {
|
||||||
|
const preRegex = /<pre[^>]*>([\s\S]*?)<\/pre>/gi;
|
||||||
|
const pieces = [];
|
||||||
|
let m;
|
||||||
|
while ((m = preRegex.exec(html)) !== null) pieces.push(m[1]);
|
||||||
|
if (pieces.length === 0) return html.replace(/<[^>]*>/g, '');
|
||||||
|
return pieces.map((s) => s.replace(/<[^>]*>/g, '')).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroupTypeMap(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const groupTitleRe = /^\s*(\d+\.\d+)\s*-\s*(.+?)\s*$/;
|
||||||
|
const mapTitleToType = (title) => {
|
||||||
|
const t = String(title || '').toLowerCase();
|
||||||
|
const words = t.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
const hasWord = (w) => words.includes(w);
|
||||||
|
const anyWord = (...ws) => ws.some((w) => hasWord(w));
|
||||||
|
if (anyWord('katana','katanas')) return 'katana';
|
||||||
|
if (anyWord('sword','swords')) return 'sword';
|
||||||
|
if (anyWord('bow','bows')) return 'bow';
|
||||||
|
if (anyWord('axe','axes')) return 'axe';
|
||||||
|
if (anyWord('spear','spears')) return 'spear';
|
||||||
|
if (anyWord('knife','knives')) return 'knife';
|
||||||
|
if (anyWord('gauntlet','gauntlets','glove','gloves','mittens')) return 'glove';
|
||||||
|
if (anyWord('shoe','shoes','boot','boots')) return 'shoe';
|
||||||
|
if (anyWord('staff','staves','rod','rods','wand','wands')) return 'staff';
|
||||||
|
if (anyWord('agryrion')) return 'agryrion';
|
||||||
|
if (anyWord('helmet','helmets')) return 'helmet';
|
||||||
|
if (anyWord('hat','hats')) return 'hat';
|
||||||
|
if (anyWord('robe','robes')) return 'robe';
|
||||||
|
if (anyWord('armor','armors','armour','mail','mails')) return 'armor';
|
||||||
|
if (anyWord('shield','shields')) return 'shield';
|
||||||
|
if (anyWord('ring','rings')) return 'ring';
|
||||||
|
if (anyWord('amulet','amulets')) return 'amulet';
|
||||||
|
if (anyWord('accessory','accessories')) return 'accessory';
|
||||||
|
if (anyWord('scroll','scrolls')) return 'scroll';
|
||||||
|
if (anyWord('class','change') || t.includes('rank up')) return 'rankup';
|
||||||
|
if (anyWord('recovery')) return 'recovery';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const groupToType = new Map();
|
||||||
|
for (const raw of lines) {
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
if (/\(\s*Rank\s*\d+\s*\)/i.test(line)) continue; // skip item headers
|
||||||
|
const m = line.match(groupTitleRe);
|
||||||
|
if (!m) continue;
|
||||||
|
const group = m[1];
|
||||||
|
const title = m[2];
|
||||||
|
const type = mapTitleToType(title);
|
||||||
|
if (type && !groupToType.has(group)) groupToType.set(group, type);
|
||||||
|
}
|
||||||
|
return groupToType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeWeaponType(section) {
|
||||||
|
// section: e.g., 1.6.8 → group 1.6
|
||||||
|
const parts = section.split('.').map((x) => parseInt(x, 10));
|
||||||
|
if (parts[0] !== 1) return undefined;
|
||||||
|
const minor = parts[1];
|
||||||
|
switch (minor) {
|
||||||
|
case 1: return 'katana';
|
||||||
|
case 2: return 'sword';
|
||||||
|
case 3: return 'bow';
|
||||||
|
case 4: return 'axe';
|
||||||
|
case 5: return 'spear';
|
||||||
|
case 6: return 'knife';
|
||||||
|
case 7: return 'glove';
|
||||||
|
case 8: return 'staff';
|
||||||
|
case 9: return 'agryrion';
|
||||||
|
default: return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNonWeaponType(section) {
|
||||||
|
// Map other groups where the TOC defines a clear category
|
||||||
|
// Example given: 5.2 - Scroll → all 5.2.* are scrolls
|
||||||
|
const m = section.match(/^(\d+\.\d+)\./);
|
||||||
|
if (!m) return undefined;
|
||||||
|
const group = m[1];
|
||||||
|
if (group === '5.2') return 'scroll';
|
||||||
|
if (group === '4.2') return 'glove'; // Gauntlets
|
||||||
|
if (group === '4.3') return 'shoe'; // Shoes
|
||||||
|
if (group === '5.3') return 'rankup'; // Class change items
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatsLine(line) {
|
||||||
|
const stats = {};
|
||||||
|
const inner = line.replace(/^\s*\|\s*/, '').replace(/\s*\|\s*$/, '').trim();
|
||||||
|
const parts = inner.split('/');
|
||||||
|
for (const p of parts) {
|
||||||
|
const m = p.trim().match(/([A-Z][A-Z0-9 ]+)\s+(-?\d+)/i);
|
||||||
|
if (m) stats[m[1].trim()] = parseInt(m[2], 10);
|
||||||
|
}
|
||||||
|
return Object.keys(stats).length ? stats : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGuide(text) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const items = [];
|
||||||
|
let current = null;
|
||||||
|
let inStats = false;
|
||||||
|
const headerRe = /^\s*(\d+\.\d+\.\d+)\s*-\s*(.+?)\s*\(\s*Rank\s*(\d+)\s*\)\s*(?:-\s*(.*))?$/i;
|
||||||
|
const groupTypeMap = buildGroupTypeMap(text);
|
||||||
|
// Manual additions for known groups if not present in titles
|
||||||
|
if (!groupTypeMap.has('4.1')) groupTypeMap.set('4.1', 'shield'); // 4.1 - Shields (example)
|
||||||
|
|
||||||
|
function push() {
|
||||||
|
if (!current) return;
|
||||||
|
if (!current.recipes) current.recipes = [];
|
||||||
|
if (current.description && !current.description.trim()) delete current.description;
|
||||||
|
if (current.stats && !Object.keys(current.stats).length) delete current.stats;
|
||||||
|
items.push(current);
|
||||||
|
current = null;
|
||||||
|
inStats = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const raw = lines[i];
|
||||||
|
const line = raw.trimEnd();
|
||||||
|
|
||||||
|
const h = line.match(headerRe);
|
||||||
|
if (h) {
|
||||||
|
push();
|
||||||
|
const section = h[1];
|
||||||
|
const name = h[2].trim();
|
||||||
|
const rank = parseInt(h[3], 10);
|
||||||
|
const desc = (h[4] || '').trim();
|
||||||
|
const groupKey = (section.match(/^(\d+\.\d+)\./) || [])[1];
|
||||||
|
// Prefer hard-coded weapon mapping for 1.X, because TOC labels are inconsistent
|
||||||
|
const isWeapon = /^1\./.test(section);
|
||||||
|
const type = (isWeapon ? computeWeaponType(section) : (computeNonWeaponType(section) || groupTypeMap.get(groupKey))) || undefined;
|
||||||
|
// Mark rank-up by description or by being in 5.3 group
|
||||||
|
const rankUp = /rank\s*up\s*item/i.test(desc) || groupKey === '5.3' || type === 'rankup';
|
||||||
|
const rankUpFor = [];
|
||||||
|
const forMatch = desc.match(/for\s*:\s*([^]+)$/i);
|
||||||
|
if (forMatch) {
|
||||||
|
for (const part of forMatch[1].split(/[\/,]/)) {
|
||||||
|
const t = part.trim();
|
||||||
|
if (t) rankUpFor.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = { name, rank, description: desc || undefined, stats: undefined, recipes: [], type, rankUp: rankUp || undefined, rankUpFor: rankUpFor.length ? rankUpFor : undefined, section };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current) continue;
|
||||||
|
|
||||||
|
if (/^[oO0\-=_]{5,}$/.test(line.replace(/\s+/g, ''))) { inStats = true; continue; }
|
||||||
|
if (inStats && line.includes('|')) {
|
||||||
|
const st = parseStatsLine(line);
|
||||||
|
if (st) current.stats = { ...(current.stats || {}), ...st };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inStats && line.trim() === '') { inStats = false; continue; }
|
||||||
|
|
||||||
|
if (line.includes('+') && !line.includes('=')) {
|
||||||
|
const parts = line.split('+');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const left = parts[0].trim();
|
||||||
|
const right = parts[1].trim();
|
||||||
|
if (/(\d+\.\d+\.\d+\s*-)/.test(left) || /(\d+\.\d+\.\d+\s*-)/.test(right)) continue;
|
||||||
|
const rankM1 = left.match(/\(\s*R(?:ank)?\s*(\d+)\s*\)/i);
|
||||||
|
const rankM2 = right.match(/\(\s*R(?:ank)?\s*(\d+)\s*\)/i);
|
||||||
|
if (!rankM1 || !rankM2) continue;
|
||||||
|
const name1 = left.replace(/\(\s*R(?:ank)?\s*\d+\s*\)/ig, '').trim();
|
||||||
|
const name2 = right.replace(/\(\s*R(?:ank)?\s*\d+\s*\)/ig, '').trim();
|
||||||
|
current.recipes.push({ ingredients: [ { name: name1, rank: parseInt(rankM1[1],10) }, { name: name2, rank: parseInt(rankM2[1],10) } ] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push();
|
||||||
|
|
||||||
|
// Post-fix: derive types for non-weapon items with heuristics only if missing
|
||||||
|
const inferOther = (name) => {
|
||||||
|
const n = (name || '').toLowerCase();
|
||||||
|
const hasAny = (arr) => arr.some((w) => n.includes(w));
|
||||||
|
if (hasAny(['helmet','hat','turban','mask','hachimaki','hachigane'])) return 'helmet';
|
||||||
|
if (hasAny(['robe','garb','shawl'])) return 'robe';
|
||||||
|
if (hasAny(['mail','armor','armour','plate'])) return 'armor';
|
||||||
|
if (hasAny(['shield'])) return 'shield';
|
||||||
|
if (hasAny(['ring','charm','amulet','talisman','anklet','earrings'])) return 'accessory';
|
||||||
|
if (hasAny(['boots','shoes'])) return 'shoe';
|
||||||
|
if (hasAny(['potion','elixir','antidote','holy water','whistle'])) return 'recovery';
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
for (const it of items) {
|
||||||
|
if (!it.type) {
|
||||||
|
const t = inferOther(it.name);
|
||||||
|
if (t) it.type = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const html = readHtml(INPUT_HTML);
|
||||||
|
const text = extractPre(html).replace(/\r\n/g, '\n');
|
||||||
|
const items = parseGuide(text);
|
||||||
|
const out = { items };
|
||||||
|
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(out, null, 2), 'utf8');
|
||||||
|
console.log(`Rebuilt ${items.length} items → ${OUTPUT_JSON}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
|
||||||
347
sdfh.js
Normal file
347
sdfh.js
Normal file
|
|
@ -0,0 +1,347 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Stella Deus Fuse Helper (SDFH) - single-file CLI
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// ---------------- Core search utilities ----------------
|
||||||
|
|
||||||
|
function normalize(text) {
|
||||||
|
return String(text)
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenize(text) {
|
||||||
|
return normalize(text).split(' ').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshtein(a, b) {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
if (m === 0) return n;
|
||||||
|
if (n === 0) return m;
|
||||||
|
const dp = new Array(n + 1);
|
||||||
|
for (let j = 0; j <= n; j += 1) dp[j] = j;
|
||||||
|
for (let i = 1; i <= m; i += 1) {
|
||||||
|
let prev = i - 1;
|
||||||
|
dp[0] = i;
|
||||||
|
for (let j = 1; j <= n; j += 1) {
|
||||||
|
const temp = dp[j];
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
dp[j] = Math.min(dp[j] + 1, dp[j - 1] + 1, prev + cost);
|
||||||
|
prev = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dp[n];
|
||||||
|
}
|
||||||
|
|
||||||
|
function osaDistance(a, b) {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
if (m === 0) return n;
|
||||||
|
if (n === 0) return m;
|
||||||
|
const d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||||
|
for (let i = 0; i <= m; i += 1) d[i][0] = i;
|
||||||
|
for (let j = 0; j <= n; j += 1) d[0][j] = j;
|
||||||
|
for (let i = 1; i <= m; i += 1) {
|
||||||
|
for (let j = 1; j <= n; j += 1) {
|
||||||
|
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
d[i][j] = Math.min(
|
||||||
|
d[i - 1][j] + 1,
|
||||||
|
d[i][j - 1] + 1,
|
||||||
|
d[i - 1][j - 1] + cost
|
||||||
|
);
|
||||||
|
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
||||||
|
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d[m][n];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndex(items) {
|
||||||
|
const index = items.map((it, idx) => {
|
||||||
|
const normalizedName = normalize(it.name || '');
|
||||||
|
const tokens = tokenize(it.name || '');
|
||||||
|
return { idx, item: it, normalizedName, tokens };
|
||||||
|
});
|
||||||
|
const typeRankMap = new Map();
|
||||||
|
for (const it of items) {
|
||||||
|
const type = (it.type || '').toLowerCase();
|
||||||
|
if (!type) continue;
|
||||||
|
if (!typeRankMap.has(type)) typeRankMap.set(type, new Map());
|
||||||
|
const rankMap = typeRankMap.get(type);
|
||||||
|
if (!rankMap.has(it.rank)) rankMap.set(it.rank, []);
|
||||||
|
rankMap.get(it.rank).push(it);
|
||||||
|
}
|
||||||
|
index.typeRankMap = typeRankMap;
|
||||||
|
// name -> item for quick lookup
|
||||||
|
index.nameMap = new Map();
|
||||||
|
for (const it of items) {
|
||||||
|
index.nameMap.set(normalize(it.name || ''), it);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRankQuery(raw) {
|
||||||
|
const s = String(raw);
|
||||||
|
let m = s.match(/\b(?:rank\s*(\d+)|r\s*(\d+))\b/i);
|
||||||
|
if (m) return parseInt(m[1] || m[2], 10);
|
||||||
|
m = s.trim().match(/(\d+)\s*$/);
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreCandidate(entry, query, queryTokens) {
|
||||||
|
const name = entry.normalizedName;
|
||||||
|
if (name === query) return 1000;
|
||||||
|
let score = 0;
|
||||||
|
if (name.startsWith(query)) score += 400;
|
||||||
|
if (name.includes(query)) score += 220;
|
||||||
|
for (const qt of queryTokens) {
|
||||||
|
for (const tok of entry.tokens) {
|
||||||
|
if (tok.startsWith(qt)) score += 40;
|
||||||
|
if (tok === qt) score += 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (query.length > 0) {
|
||||||
|
const dist = Math.min(osaDistance(name, query), levenshtein(name, query));
|
||||||
|
const maxLen = Math.max(name.length, query.length) || 1;
|
||||||
|
const sim = 1 - dist / maxLen;
|
||||||
|
score += Math.floor(sim * 220);
|
||||||
|
}
|
||||||
|
for (const qt of queryTokens) {
|
||||||
|
let best = 9999;
|
||||||
|
for (const tok of entry.tokens) {
|
||||||
|
const d = Math.min(osaDistance(tok, qt), levenshtein(tok, qt));
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
if (best < 9999) {
|
||||||
|
const maxLen = Math.max(qt.length, (entry.tokens[0] || '').length, 1);
|
||||||
|
const sim = 1 - Math.min(best / maxLen, 1);
|
||||||
|
score += Math.floor(sim * 120);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(index, rawQuery, limit = 5) {
|
||||||
|
const query = normalize(rawQuery);
|
||||||
|
const queryTokens = tokenize(rawQuery);
|
||||||
|
if (!query) return { exact: [], suggestions: [] };
|
||||||
|
|
||||||
|
const requestedRank = parseRankQuery(rawQuery);
|
||||||
|
const nameOnlyQuery = normalize(String(rawQuery).replace(/\b(?:rank\s*\d+|r\s*\d+)\b/ig, '').trim());
|
||||||
|
const nameOnlyTokens = tokenize(String(rawQuery).replace(/\b(?:rank\s*\d+|r\s*\d+)\b/ig, ''));
|
||||||
|
|
||||||
|
const typeBoost = (() => {
|
||||||
|
const tokens = nameOnlyTokens.map((t) => t.toLowerCase());
|
||||||
|
const knownTypes = new Set(Array.from(index.typeRankMap.keys()));
|
||||||
|
// Exact token match to a known type
|
||||||
|
for (const tok of tokens) {
|
||||||
|
if (knownTypes.has(tok)) return tok;
|
||||||
|
}
|
||||||
|
// Fuzzy: pick the closest known type only if distance <= 2
|
||||||
|
let best = null;
|
||||||
|
let bestD = Infinity;
|
||||||
|
for (const tok of tokens) {
|
||||||
|
for (const t of knownTypes) {
|
||||||
|
const d = Math.min(osaDistance(tok, t), levenshtein(tok, t));
|
||||||
|
if (d < bestD) {
|
||||||
|
bestD = d;
|
||||||
|
best = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestD <= 2 ? best : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
let inferredType = typeBoost;
|
||||||
|
// Prefer full item name as family proxy when rank is specified (e.g., "battle axe r6")
|
||||||
|
const proxyName = nameOnlyQuery;
|
||||||
|
const proxyNorm = normalize(proxyName || '');
|
||||||
|
const exactItEarly = proxyNorm ? index.nameMap.get(proxyNorm) : null;
|
||||||
|
if (exactItEarly && requestedRank) {
|
||||||
|
inferredType = (exactItEarly.type && exactItEarly.type.toLowerCase()) || inferredType;
|
||||||
|
}
|
||||||
|
// Do not infer type from partial name tokens when rank is present. Only use explicit type word or exact item name.
|
||||||
|
|
||||||
|
// If exact item name found and rank asked, use that item's type only (no inference)
|
||||||
|
if (!inferredType && exactItEarly && requestedRank) {
|
||||||
|
inferredType = (exactItEarly.type && exactItEarly.type.toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedRank) {
|
||||||
|
if (inferredType) {
|
||||||
|
const pool = index.filter((e) => (e.item.type || '').toLowerCase() === inferredType && e.item.rank === requestedRank);
|
||||||
|
const direct = pool.map((e) => ({ item: e.item, score: 999 }));
|
||||||
|
if (direct.length > 0) return { exact: [], suggestions: direct.slice(0, limit) };
|
||||||
|
}
|
||||||
|
// Rank was requested but we couldn't determine a type from either an explicit type word
|
||||||
|
// or an exact full item name. Do not guess across all items; report no matches.
|
||||||
|
return { exact: [], suggestions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = index
|
||||||
|
.filter((e) => e.normalizedName === query)
|
||||||
|
.map((e) => e.item)
|
||||||
|
.filter((it) => (requestedRank ? it.rank === requestedRank : true));
|
||||||
|
if (exact.length > 0) return { exact, suggestions: [] };
|
||||||
|
|
||||||
|
const scored = index
|
||||||
|
.map((e) => ({ e, s: scoreCandidate(e, nameOnlyQuery || query, nameOnlyTokens.length ? nameOnlyTokens : queryTokens) }))
|
||||||
|
.map((x) => ({ item: x.e.item, score: x.s + (typeBoost && x.e.item.type === typeBoost ? 120 : 0) }))
|
||||||
|
.filter((x) => (requestedRank ? x.item.rank === requestedRank : true))
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return { exact: [], suggestions: scored };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeItem(item) {
|
||||||
|
const lines = [];
|
||||||
|
const typeLabel = item.type ? ` [${item.type}]` : '';
|
||||||
|
lines.push(`${item.name} (Rank ${item.rank})${typeLabel}`);
|
||||||
|
const desc = item.description || '';
|
||||||
|
const descHasRankUp = /rank\s*up\s*item/i.test(desc);
|
||||||
|
if (desc) lines.push(desc);
|
||||||
|
if (item.rankUp && !descHasRankUp) {
|
||||||
|
if (item.rankUpFor && item.rankUpFor.length) lines.push(`Rank Up Item for: ${item.rankUpFor.join(' / ')}`);
|
||||||
|
else lines.push('Rank Up Item');
|
||||||
|
}
|
||||||
|
if (item.stats && Object.keys(item.stats).length > 0) {
|
||||||
|
const statPairs = Object.keys(item.stats)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}: ${item.stats[k]}`);
|
||||||
|
lines.push(`Stats: ${statPairs.join(', ')}`);
|
||||||
|
}
|
||||||
|
const numRecipes = Array.isArray(item.recipes) ? item.recipes.length : 0;
|
||||||
|
lines.push(`Recipes to create: ${numRecipes}`);
|
||||||
|
if (numRecipes > 0) {
|
||||||
|
const sorted = [...item.recipes].sort((a, b) => {
|
||||||
|
const ra = (a.ingredients || []).map((x) => x.rank || 0);
|
||||||
|
const rb = (b.ingredients || []).map((x) => x.rank || 0);
|
||||||
|
const maxA = Math.max(...ra, 0);
|
||||||
|
const maxB = Math.max(...rb, 0);
|
||||||
|
if (maxA !== maxB) return maxA - maxB;
|
||||||
|
const sumA = ra.reduce((p, c) => p + c, 0);
|
||||||
|
const sumB = rb.reduce((p, c) => p + c, 0);
|
||||||
|
if (sumA !== sumB) return sumA - sumB;
|
||||||
|
const aStr = `${a.ingredients[0].name} + ${a.ingredients[1].name}`;
|
||||||
|
const bStr = `${b.ingredients[0].name} + ${b.ingredients[1].name}`;
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
});
|
||||||
|
const fmt = (ing) => `${ing.name}${ing.rank ? ` (R${ing.rank})` : ''}`;
|
||||||
|
const col1 = 'Ingredient 1';
|
||||||
|
const col2 = 'Ingredient 2';
|
||||||
|
const rows = sorted.map((r, i) => [String(i + 1), fmt(r.ingredients[0]), fmt(r.ingredients[1])]);
|
||||||
|
const w0 = Math.max(1, '#'.length, ...rows.map((r) => r[0].length));
|
||||||
|
const w1 = Math.max(col1.length, ...rows.map((r) => r[1].length));
|
||||||
|
const w2 = Math.max(col2.length, ...rows.map((r) => r[2].length));
|
||||||
|
const hr = `+${'-'.repeat(w0 + 2)}+${'-'.repeat(w1 + 2)}+${'-'.repeat(w2 + 2)}+`;
|
||||||
|
const header = `| ${'#'.padEnd(w0)} | ${col1.padEnd(w1)} | ${col2.padEnd(w2)} |`;
|
||||||
|
lines.push(hr);
|
||||||
|
lines.push(header);
|
||||||
|
lines.push(hr);
|
||||||
|
for (const r of rows) lines.push(`| ${r[0].padEnd(w0)} | ${r[1].padEnd(w1)} | ${r[2].padEnd(w2)} |`);
|
||||||
|
lines.push(hr);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- CLI ----------------
|
||||||
|
|
||||||
|
function loadData(dataFile) {
|
||||||
|
if (!fs.existsSync(dataFile)) {
|
||||||
|
console.error(`Data file not found: ${dataFile}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(dataFile, 'utf8'));
|
||||||
|
if (!raw || !Array.isArray(raw.items)) {
|
||||||
|
console.error('Invalid data format: expected { items: [] }');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const items = raw.items.map((it) => ({ ...it }));
|
||||||
|
// Do not infer or override types here. Trust the data file.
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log('Commands:');
|
||||||
|
console.log(' help Show this help');
|
||||||
|
console.log(' quit / exit Exit the app');
|
||||||
|
console.log(' <query> Search items by name (partial match, typo-tolerant)');
|
||||||
|
console.log(' Filters: append "r8" or "rank 8" or trailing number to constrain rank; prefix type, e.g., "katana r7"');
|
||||||
|
console.log(' --full Show up to 50 suggestions instead of top 5');
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const DATA_FILE = path.resolve(__dirname, 'sdfh_item_data.json');
|
||||||
|
const items = loadData(DATA_FILE);
|
||||||
|
const index = buildIndex(items);
|
||||||
|
console.log(`Loaded ${items.length} items from ${path.basename(DATA_FILE)}`);
|
||||||
|
console.log('Type a name to search (partial allowed). Type "help" for help.');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
prompt: '> ',
|
||||||
|
historySize: 1000,
|
||||||
|
terminal: true,
|
||||||
|
});
|
||||||
|
rl.prompt();
|
||||||
|
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
const input = line.trim();
|
||||||
|
if (!input) {
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.toLowerCase() === 'help') {
|
||||||
|
printHelp();
|
||||||
|
rl.prompt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input.toLowerCase() === 'quit' || input.toLowerCase() === 'exit') {
|
||||||
|
rl.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFull = /\s--full\b/i.test(input);
|
||||||
|
const queryOnly = input.replace(/\s--full\b/i, '').trim();
|
||||||
|
const result = search(index, queryOnly, showFull ? 50 : 5);
|
||||||
|
if (result.exact.length > 0) {
|
||||||
|
console.log(summarizeItem(result.exact[0]));
|
||||||
|
} else if (result.suggestions.length > 0) {
|
||||||
|
if (result.suggestions.length === 1) {
|
||||||
|
console.log(summarizeItem(result.suggestions[0].item));
|
||||||
|
} else {
|
||||||
|
console.log('No exact match. Did you mean:');
|
||||||
|
result.suggestions.forEach((s, i) => {
|
||||||
|
const t = s.item.type ? ` [${s.item.type}]` : '';
|
||||||
|
console.log(` ${i + 1}. ${s.item.name} (Rank ${s.item.rank})${t}`);
|
||||||
|
});
|
||||||
|
console.log('Enter full name to see details.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No matches.');
|
||||||
|
}
|
||||||
|
rl.prompt();
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.on('close', () => {
|
||||||
|
console.log('Bye.');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
52476
sdfh_item_data.json
Normal file
52476
sdfh_item_data.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue