diff --git a/README.md b/README.md index 9942217..31de0d0 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,79 @@ Stella Deus Fuse Helper (SDFH) -Interactive CLI to browse Stella Deus items and their fusion recipes using section-typed data. +Interactive CLI to browse items and compute fusion plans with cost analysis. 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) +- sdfh.js: single-file CLI +- sdfh_item_data.json: item database -How to run +Run - node sdfh.js +- Or use the SEA executables in dist/ (no Node required) -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 +Search examples +- katana r7 +- battle axe +- battle axe r7 +- golden apple r5 --full -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 +What does r7 mean? +- r7 (or rank 7) is a rank filter. It limits matches to items of rank 7. +- With a type word (e.g., "katana r7"), it finds all items of that type and rank. +- With an exact item name + rank (e.g., "battle axe r7"), it searches within that item’s family/type. -Output -- Shows item header, optional stats, and an aligned ASCII table of all fusion recipes that create the item +Types vs item names +- You can search by type/category (e.g., "katana", "axe", "bow", "glove", "robe", etc.) to browse groups. +- You can search by exact item name (e.g., "Battle Axe"). +- For rank filters: + - Type + rank is required unless you give an exact full item name. + - Examples: + - OK: "axe r5" → all axes of rank 5 + - OK: "battle axe r5" → rank 5 items in the Battle Axe line/type + - Not OK: "battle r5" (ambiguous; add a type or full name) + +Partial and typos +- The search is typo-tolerant and token-aware. "bttle axe" or "btl ax" can still find "Battle Axe". +- Suggestions show the top matches; add --full to see more. + +Fusion and pricing +- golden apple --fuse Show default recipe’s fusion tree +- golden apple --fuse 2 Use recipe index 2 +- golden apple --fuse --depth 3 Resolve to leaves with rank <= 3 +- golden apple --fuse --store 3 Stop at purchasable leaves in store 3; show prices and total +- golden apple --fuse --depth 3 --store 3 Treat non-buyable leaves as owned; price the rest + +Options +- --full Show up to 50 suggestions instead of 5 +- --fuse [N] Enter fusion mode; optional N is recipe index (1-based) +- --recipe [N] Alias of --fuse [N] +- --depth N Alias of --fuse-rank N; leaves must be rank <= N +- --fuse-rank N Leaves must be rank <= N +- --store N Price analysis at store level N (1..5) + +Notes +- Types derive from guide sections; rank filters require explicit type or exact item name +- Data file is discovered beside the executable or in data/, then SEA-embedded asset + +Platform notes +- macOS first run (ad‑hoc signing and quarantine removal): + - For Apple Silicon use `sdfh-sea-macos-arm64`, for Intel use `sdfh-sea-macos-x64`. + - Then run: + ```bash + xattr -p com.apple.quarantine ./sdfh-sea-macos-arm64 || true + xattr -dr com.apple.quarantine ./sdfh-sea-macos-arm64 + codesign --force --sign - ./sdfh-sea-macos-arm64 + ``` + - If Finder blocks: System Settings → Privacy & Security → Allow Anyway, then Control‑click → Open. +- Windows on Wine: ensure Wine prefix reports Windows 10+ via winecfg so the exe launches by double‑click. License MIT +Acknowledgments +- Data and fusion references are derived from community guides on GameFAQs: + - Dragen’s Fusion/Item FAQ: [link](https://gamefaqs.gamespot.com/ps2/921280-stella-deus-the-gate-of-eternity/faqs/36685) + - Gundam4fun’s Fusion FAQ: [link](https://gamefaqs.gamespot.com/ps2/921280-stella-deus-the-gate-of-eternity/faqs/37029) +- While those guides are copyrighted, the factual data about in-game items and recipes is available to anyone who plays the game. +- Many thanks to the authors for their meticulous work collecting and presenting the data. This project acknowledges that processing the raw facts into a comprehensive, searchable format and building this fuse-helper app is a separate effort built on top of their contributions. + diff --git a/assets/sdfh-logo.svg b/assets/sdfh-logo.svg new file mode 100644 index 0000000..4eefb23 --- /dev/null +++ b/assets/sdfh-logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + SDFH + + + + diff --git a/sdfh.js b/sdfh.js index a3f705d..05d6116 100644 --- a/sdfh.js +++ b/sdfh.js @@ -220,11 +220,14 @@ function parseFlags(input) { m = p.match(/^--recipe=(\d+)$/i); if (m) { flags.doFuse = true; flags.recipeIndex = parseInt(m[1], 10); continue; } if (/^--recipe$/i.test(p) && parts[i + 1] && /^\d+$/.test(parts[i + 1])) { flags.doFuse = true; flags.recipeIndex = parseInt(parts[i + 1], 10); i += 1; continue; } - const mRank = p.match(/^--(?:fuse-rank)=(\d+)$/i)?.[1] || (p.match(/^--fuse-rank$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); + let mTmp = p.match(/^--(?:fuse-rank)=(\d+)$/i); + const mRank = (mTmp && mTmp[1]) || (p.match(/^--fuse-rank$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); if (mRank) { flags.fuseRankLimit = parseInt(mRank, 10); continue; } - const mDepthAsRank = p.match(/^--(?:depth)=(\d+)$/i)?.[1] || (p.match(/^--depth$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); + mTmp = p.match(/^--(?:depth)=(\d+)$/i); + const mDepthAsRank = (mTmp && mTmp[1]) || (p.match(/^--depth$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); if (mDepthAsRank) { flags.fuseRankLimit = parseInt(mDepthAsRank, 10); continue; } - const mStore = p.match(/^--store=(\d+)$/i)?.[1] || (p.match(/^--store$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); + mTmp = p.match(/^--store=(\d+)$/i); + const mStore = (mTmp && mTmp[1]) || (p.match(/^--store$/i) && parts[i + 1] && /^\d+$/.test(parts[i + 1]) ? parts[++i] : null); if (mStore) { flags.storeLevel = Math.max(1, Math.min(5, parseInt(mStore, 10))); continue; } kept.push(p); } @@ -528,35 +531,68 @@ function summarizeItem(item) { function sortRecipes(recipes) { return [...(recipes || [])].sort((a, b) => { - const ra = (a.ingredients || []).map((x) => x.rank || 0); - const rb = (b.ingredients || []).map((x) => x.rank || 0); + const ia = (a.ingredients || []); + const ib = (b.ingredients || []); + const ra = ia.map((x) => x && x.rank ? x.rank : 0); + const rb = ib.map((x) => x && x.rank ? 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 || ''}`; + const aStr = `${(ia[0] && ia[0].name) ? ia[0].name : ''} + ${(ia[1] && ia[1].name) ? ia[1].name : ''}`; + const bStr = `${(ib[0] && ib[0].name) ? ib[0].name : ''} + ${(ib[1] && ib[1].name) ? ib[1].name : ''}`; return aStr.localeCompare(bStr); }); } // ---------------- CLI ---------------- -function loadData(dataFile) { - if (!fs.existsSync(dataFile)) { - console.error(`Data file not found: ${dataFile}`); - process.exit(1); +function loadDataAuto() { + function tryReadJSON(p) { + try { + const txt = fs.readFileSync(p, 'utf8'); + const obj = JSON.parse(txt); + if (obj && Array.isArray(obj.items)) return { items: obj.items.map((it) => ({ ...it })), source: p }; + } catch (e) {} + return null; } - const raw = JSON.parse(fs.readFileSync(dataFile, 'utf8')); - if (!raw || !Array.isArray(raw.items)) { - console.error('Invalid data format: expected { items: [] }'); - process.exit(1); + function tryReadSEAAsset(key) { + try { + const sea = require('node:sea'); + if (sea && typeof sea.isSea === 'function' && sea.isSea()) { + const txt = sea.getAsset(key, 'utf8'); + if (txt) { + const obj = JSON.parse(txt); + if (obj && Array.isArray(obj.items)) return { items: obj.items.map((it) => ({ ...it })), source: 'embedded-asset' }; + } + } + } catch (e) {} + return null; } - const items = raw.items.map((it) => ({ ...it })); - // Do not infer or override types here. Trust the data file. - return items; + const execDir = (process && process.execPath) ? path.dirname(process.execPath) : __dirname; + const candidates = [ + path.resolve(execDir, 'sdfh_item_data.json'), + path.resolve(execDir, 'data', 'sdfh_item_data.json'), + path.resolve(__dirname, 'sdfh_item_data.json'), + path.resolve(__dirname, 'data', 'sdfh_item_data.json'), + ]; + for (let i = 0; i < candidates.length; i += 1) { + const found = tryReadJSON(candidates[i]); + if (found) return found; + } + // Fallbacks: try SEA asset first, then bundled require + const fromSEA = tryReadSEAAsset('sdfh_item_data.json'); + if (fromSEA) return fromSEA; + try { + const embedded = require('./sdfh_item_data.json'); + if (embedded && Array.isArray(embedded.items)) { + return { items: embedded.items.map((it) => ({ ...it })), source: 'embedded-require' }; + } + } catch (e) {} + console.error('Data file not found: tried', candidates.join(', ')); + process.exit(1); } function printHelp() { @@ -564,15 +600,34 @@ function printHelp() { console.log(' help Show this help'); console.log(' quit / exit Exit the app'); console.log(' 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'); + console.log(''); + console.log('Filters:'); + console.log(' - Append rank: "r8" or "rank 8" or trailing number (e.g., "katana r7")'); + console.log(' - Exact item name: "battle axe"; name + rank: "battle axe r7"'); + console.log(''); + console.log('Options:'); + console.log(' --full Show up to 50 suggestions instead of top 5'); + console.log(' --fuse [N] Enter fusion mode. Optional N selects recipe index (1-based)'); + console.log(' --recipe [N] Same as --fuse [N]'); + console.log(' --depth N Alias of --fuse-rank N; leaves must be rank <= N'); + console.log(' --fuse-rank N Leaves must be rank <= N'); + console.log(' --store N Price analysis at store level N (1..5).'); + console.log(' With --store, totals table shows Price and Total price.'); + console.log(' With --depth and --store, items without prices are treated as owned'); + console.log(''); + console.log('Examples:'); + console.log(' golden apple --fuse'); + console.log(' golden apple --fuse 2'); + console.log(' golden apple --fuse --depth 3'); + console.log(' golden apple --fuse --store 3'); + console.log(' golden apple --fuse --depth 3 --store 3'); } function start() { - const DATA_FILE = path.resolve(__dirname, 'sdfh_item_data.json'); - const items = loadData(DATA_FILE); + const loaded = loadDataAuto(); + const items = loaded.items; const index = buildIndex(items); - console.log(`Loaded ${items.length} items from ${path.basename(DATA_FILE)}`); + console.log(`Loaded ${items.length} items from ${path.basename(loaded && loaded.source ? loaded.source : 'sdfh_item_data.json')}`); console.log('Type a name to search (partial allowed). Type "help" for help.'); const rl = readline.createInterface({