/** * Galaxy Strike Online — ContentLoader * Single source of truth for all game content. * Reads from data/gso/ JSON files at startup; never hardcodes data. * * Usage: * const loader = new ContentLoader(); * await loader.load(); * const skills = loader.getAllSkills(); * const recipes = loader.getAllRecipes(); * // etc. */ const fs = require('fs'); const path = require('path'); const DATA_ROOT = path.join(__dirname, '..', 'data', 'gso'); class ContentLoader { constructor() { // Raw maps — populated by load() this.skills = new Map(); // id -> skill this.enemies = new Map(); // id -> enemy this.dungeons = new Map(); // id -> dungeon this.recipes = new Map(); // id -> recipe this.items = new Map(); // id -> item this.quests = new Map(); // id -> quest this.craftingTabs = []; this.questCategories = new Map(); // category -> category index this._loaded = false; } // ───────────────────────────────────────────────────────────────── // Public bootstrap // ───────────────────────────────────────────────────────────────── async load() { if (this._loaded) return; console.log('[CONTENT LOADER] Loading all game content from data/gso/…'); this._loadSkills(); this._loadEnemies(); this._loadDungeons(); this._loadItems(); this._loadRecipes(); this._loadQuests(); this._loaded = true; console.log('[CONTENT LOADER] Done.'); console.log(` Skills: ${this.skills.size}`); console.log(` Enemies: ${this.enemies.size}`); console.log(` Dungeons: ${this.dungeons.size}`); console.log(` Items: ${this.items.size}`); console.log(` Recipes: ${this.recipes.size}`); console.log(` Quests: ${this.quests.size}`); } // ───────────────────────────────────────────────────────────────── // Skills // ───────────────────────────────────────────────────────────────── _loadSkills() { const root = path.join(DATA_ROOT, 'skills'); for (const cat of this._subdirs(root)) { for (const file of this._jsonFiles(path.join(root, cat))) { const skill = this._readJson(path.join(root, cat, file)); if (skill && skill.id) { this.skills.set(skill.id, skill); } } } } getAllSkills() { return Array.from(this.skills.values()); } getSkill(id) { return this.skills.get(id) || null; } getSkillsByCategory(cat) { return this.getAllSkills().filter(s => s.category === cat); } // ───────────────────────────────────────────────────────────────── // Enemies // ───────────────────────────────────────────────────────────────── _loadEnemies() { const root = path.join(DATA_ROOT, 'enemies'); for (const file of this._jsonFiles(root)) { const enemy = this._readJson(path.join(root, file)); if (enemy && enemy.id) this.enemies.set(enemy.id, enemy); } // Also load from dungeons/enemies.json (datapack array format) const bulkPath = path.join(DATA_ROOT, 'dungeons', 'enemies.json'); try { const bulk = this._readJson(bulkPath); if (Array.isArray(bulk)) { for (const e of bulk) { if (e && e.id && !this.enemies.has(e.id)) this.enemies.set(e.id, e); } } } catch (_) {} } getAllEnemies() { return Array.from(this.enemies.values()); } getEnemy(id) { return this.enemies.get(id) || null; } getEnemiesByRarity(r) { return this.getAllEnemies().filter(e => e.rarity === r); } // ───────────────────────────────────────────────────────────────── // Dungeons // ───────────────────────────────────────────────────────────────── _loadDungeons() { const root = path.join(DATA_ROOT, 'dungeons'); for (const file of this._jsonFiles(root)) { const dungeon = this._readJson(path.join(root, file)); if (dungeon && dungeon.id) this.dungeons.set(dungeon.id, dungeon); } } getAllDungeons() { return Array.from(this.dungeons.values()); } getDungeon(id) { return this.dungeons.get(id) || null; } getDungeonsByDifficulty(d) { return this.getAllDungeons().filter(dn => dn.difficulty === d); } getDungeonsGroupedByDifficulty() { const order = ['easy','medium','hard','extreme']; const out = {}; for (const dn of this.getAllDungeons()) { if (!out[dn.difficulty]) out[dn.difficulty] = []; out[dn.difficulty].push(dn); } return out; } getAvailableDungeons(playerLevel = 1) { return this.getAllDungeons().filter(d => d.minLevel <= playerLevel); } // ───────────────────────────────────────────────────────────────── // Items — multi-type, multi-subdir // ───────────────────────────────────────────────────────────────── _loadItems() { const root = path.join(DATA_ROOT, 'items'); this._walkJsonDir(root, (filePath) => { const data = this._readJson(filePath); if (!data || !data.templates) return; // Each file has { templates: { : { ...itemData } } } for (const [type, itemData] of Object.entries(data.templates)) { if (itemData && itemData.id) { this.items.set(itemData.id, { ...itemData, type }); } } }); } getAllItems() { return Array.from(this.items.values()); } getItem(id) { return this.items.get(id) || null; } getItemsByType(type) { return this.getAllItems().filter(i => i.type === type); } getShopItems() { return this.getAllItems().filter(i => Array.isArray(i.categories) && i.categories.includes('shop') ); } getShopItemsByCategory() { const out = {}; for (const item of this.getShopItems()) { const t = item.type; if (!out[t]) out[t] = []; out[t].push(item); } return out; } // ───────────────────────────────────────────────────────────────── // Recipes // ───────────────────────────────────────────────────────────────── _loadRecipes() { const root = path.join(DATA_ROOT, 'recipes'); // crafting_tabs.json const tabsFile = path.join(root, 'crafting_tabs.json'); if (fs.existsSync(tabsFile)) { this.craftingTabs = this._readJson(tabsFile)?.craftingTypes || []; } // One JSON per recipe, grouped into subdirs this._walkJsonDir(root, (filePath) => { if (filePath.endsWith('crafting_tabs.json')) return; if (filePath.endsWith('manifest.json')) return; const data = this._readJson(filePath); if (!data || !data.recipe) return; // Derive a stable ID from the craft.id field or the path const id = data.craft?.id || data.path?.replace(/\//g, ':') || path.basename(filePath, '.json'); // Normalise inputs → array format expected by client const inputs = data.recipe.inputs || {}; const materials = Object.entries(inputs).map(([itemId, qty]) => ({ id: itemId, quantity: qty })); // Output item id const outputObj = data.recipe.output || {}; const outputId = Object.keys(outputObj)[0] || null; const outputQty = outputId ? outputObj[outputId] : 0; // Time — any *_time_seconds key const timeKey = Object.keys(data.recipe).find(k => k.endsWith('_time_seconds')); const craftingTime = timeKey ? (data.recipe[timeKey] * 1000) : 5000; this.recipes.set(id, { id, path: data.path || '', name: outputId ? this._itemName(outputId) : id, type: data.craft?.type || 'misc', category: data.craft?.type || 'misc', subCategory: data.craft?.subCategory || 'misc', craftId: data.craft?.id || id, materials, output: outputId, outputQty, craftingTime, experience: Math.round(craftingTime / 200), // scaled default requirements: {}, unlocked: false, rawRecipe: data.recipe }); }); } getAllRecipes() { return Array.from(this.recipes.values()); } getRecipe(id) { return this.recipes.get(id) || null; } getRecipesByType(type) { return this.getAllRecipes().filter(r => r.type === type); } getRecipesByCategory(cat) { return this.getAllRecipes().filter(r => r.category === cat); } getCraftingTabs() { return this.craftingTabs; } // ───────────────────────────────────────────────────────────────── // Quests // ───────────────────────────────────────────────────────────────── _loadQuests() { const root = path.join(DATA_ROOT, 'quests'); for (const cat of this._subdirs(root)) { for (const file of this._jsonFiles(path.join(root, cat))) { const quest = this._readJson(path.join(root, cat, file)); if (quest && quest.id) this.quests.set(quest.id, quest); } } // Category index files const catRoot = path.join(DATA_ROOT, 'category', 'quests'); if (fs.existsSync(catRoot)) { for (const file of this._jsonFiles(catRoot)) { const cat = this._readJson(path.join(catRoot, file)); if (cat && cat.tabId) this.questCategories.set(cat.tabId, cat); } } } getAllQuests() { return Array.from(this.quests.values()); } getQuest(id) { return this.quests.get(id) || null; } getQuestsByCategory(cat) { return this.getAllQuests().filter(q => q.category === cat); } getQuestCategories() { return Array.from(this.questCategories.values()); } getQuestsGroupedByCategory() { const out = {}; for (const q of this.getAllQuests()) { if (!out[q.category]) out[q.category] = []; out[q.category].push(q); } return out; } // ───────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────── _readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (e) { console.warn(`[CONTENT LOADER] Failed to read ${filePath}:`, e.message); return null; } } _subdirs(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir).filter(f => { try { return fs.statSync(path.join(dir, f)).isDirectory(); } catch { return false; } }); } _jsonFiles(dir) { if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir).filter(f => f.endsWith('.json')); } /** Recursively walk a directory and call cb(filePath) for every .json file */ _walkJsonDir(dir, cb) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir)) { const full = path.join(dir, entry); const stat = fs.statSync(full); if (stat.isDirectory()) { this._walkJsonDir(full, cb); } else if (entry.endsWith('.json') && !entry.endsWith('manifest.json')) { cb(full); } } } /** Best-effort human name from an item id (snake_case → Title Case) */ _itemName(id) { return id.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } } module.exports = ContentLoader;