const mysql = require('mysql2/promise'); const fs = require('fs'); const path = require('path'); const dotenv = require('dotenv'); dotenv.config({ path: path.join(__dirname, '.env') }); dotenv.config({ path: path.join(__dirname, '..', '.env') }); function requireEnv(name) { const value = process.env[name]; if (!value) { throw new Error(`${name} must be set in database/.env or .env`); } return value; } // Simple DB logger to backend.log const backendLogPath = path.join(__dirname, 'backend.log'); function logToFile(...args) { try { const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; fs.appendFileSync(backendLogPath, msg, { encoding: 'utf8' }); } catch (err) { console.error('Failed to write backend log', err); } } // Database configuration const dbConfig = { host: process.env.DB_HOST || '192.168.1.113', user: process.env.DB_USER || 'deathwatch', password: requireEnv('DB_PASSWORD'), database: process.env.DB_NAME || 'deathwatch', port: Number(process.env.DB_PORT || 3307), waitForConnections: true, connectionLimit: 10, queueLimit: 0 }; // Create connection pool const pool = mysql.createPool(dbConfig); const ignoreDuplicateColumn = (error) => { if (error.code !== 'ER_DUP_FIELDNAME') throw error; }; const addColumnIfMissing = async (connection, table, definition) => { try { await connection.execute(`ALTER TABLE ${table} ADD COLUMN ${definition}`); } catch (error) { ignoreDuplicateColumn(error); } }; // Create tables const createTables = async () => { try { const connection = await pool.getConnection(); // Players table await connection.execute(` CREATE TABLE IF NOT EXISTS players ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) UNIQUE NOT NULL, roller_info JSON DEFAULT ('{}'), shop_info JSON DEFAULT ('{}'), tab_info JSON DEFAULT ('{}'), pw VARCHAR(255) DEFAULT '', pw_hash VARCHAR(255) DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) `); // Sessions table await connection.execute(` CREATE TABLE IF NOT EXISTS sessions ( id INT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(255) UNIQUE NOT NULL, data JSON DEFAULT ('{}'), expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) `); // Rules staging table await connection.execute(` CREATE TABLE IF NOT EXISTS rules_staging ( id INT AUTO_INCREMENT PRIMARY KEY, title TEXT, content TEXT, category VARCHAR(255), page VARCHAR(255), original_json TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); // Rules table await connection.execute(` CREATE TABLE IF NOT EXISTS rules ( id INT AUTO_INCREMENT PRIMARY KEY, rule_id VARCHAR(255) UNIQUE, title VARCHAR(500), content TEXT, summary TEXT, examples TEXT, page INT, page_end INT, source VARCHAR(255), source_abbr VARCHAR(50), category VARCHAR(100), tags TEXT, aliases TEXT, related_rules TEXT, source_method VARCHAR(50), confidence DECIMAL(4,2), midgame_priority INT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `); await addColumnIfMissing(connection, 'rules', 'summary TEXT'); await addColumnIfMissing(connection, 'rules', 'examples TEXT'); await addColumnIfMissing(connection, 'rules', 'page_end INT'); await addColumnIfMissing(connection, 'rules', 'tags TEXT'); await addColumnIfMissing(connection, 'rules', 'aliases TEXT'); await addColumnIfMissing(connection, 'rules', 'related_rules TEXT'); await addColumnIfMissing(connection, 'rules', 'source_method VARCHAR(50)'); await addColumnIfMissing(connection, 'rules', 'confidence DECIMAL(4,2)'); await addColumnIfMissing(connection, 'rules', 'midgame_priority INT DEFAULT 0'); // Missions table await connection.execute(` CREATE TABLE IF NOT EXISTS missions ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(500) NOT NULL DEFAULT 'Untitled Mission', theme VARCHAR(100) NOT NULL DEFAULT 'tyranid', scene_count INT NOT NULL DEFAULT 4, enemy_count INT NOT NULL DEFAULT 3, threat_level VARCHAR(50) NOT NULL DEFAULT 'Medium', player_count INT NOT NULL DEFAULT 3, scenes JSON DEFAULT ('[]'), gm_player VARCHAR(255), is_active TINYINT(1) NOT NULL DEFAULT 0, current_scene INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) `); // Simulation runs table await connection.execute(` CREATE TABLE IF NOT EXISTS simulations ( id INT AUTO_INCREMENT PRIMARY KEY, mission_id INT, mission_name VARCHAR(500), players JSON DEFAULT ('[]'), result VARCHAR(50), xp_earned INT DEFAULT 0, total_rounds INT DEFAULT 0, total_rolls INT DEFAULT 0, success_rate INT DEFAULT 0, combat_success_rate INT DEFAULT 0, puzzle_success_rate INT DEFAULT 0, scene_results JSON DEFAULT ('[]'), player_cards JSON DEFAULT ('{}'), roll_feed JSON DEFAULT ('[]'), story_hooks JSON DEFAULT ('[]'), findings JSON DEFAULT ('[]'), enemy_profile VARCHAR(50) NOT NULL DEFAULT 'balanced', run_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_simulations_run_date (run_date), INDEX idx_simulations_mission_id (mission_id) ) `); // Shared mission roll log for GM visibility await connection.execute(` CREATE TABLE IF NOT EXISTS mission_rolls ( id INT AUTO_INCREMENT PRIMARY KEY, mission_id INT, player_name VARCHAR(255), roll_type VARCHAR(50) NOT NULL DEFAULT 'check', scene_index INT NOT NULL DEFAULT 0, label VARCHAR(500), payload JSON DEFAULT ('{}'), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_mission_rolls_mission_created (mission_id, created_at) ) `); // Bestiary table. The active bestiary is stored here first; JSON files are // kept as fallback/mirror for local development when MariaDB is unavailable. await connection.execute(` CREATE TABLE IF NOT EXISTS bestiary ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, book VARCHAR(255), page VARCHAR(50), pdf VARCHAR(255), stats TEXT, profile TEXT, snippet TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_bestiary_name (name), INDEX idx_bestiary_book_page (book, page) ) `); await addColumnIfMissing(connection, 'bestiary', 'updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); // Error reports table - logged-in users can submit error reports await connection.execute(` CREATE TABLE IF NOT EXISTS error_reports ( id INT AUTO_INCREMENT PRIMARY KEY, player_name VARCHAR(255) NOT NULL, category VARCHAR(100) NOT NULL DEFAULT 'bug', title VARCHAR(500) NOT NULL, description TEXT NOT NULL, page_url VARCHAR(1000) DEFAULT '', status VARCHAR(50) NOT NULL DEFAULT 'open', resolved_by VARCHAR(255) DEFAULT '', resolved_note TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, resolved_at TIMESTAMP NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_error_reports_status (status), INDEX idx_error_reports_player (player_name) ) `); try { await connection.execute(`ALTER TABLE missions ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 0`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE missions ADD COLUMN current_scene INT NOT NULL DEFAULT 0`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE simulations ADD COLUMN difficulty_level INT NOT NULL DEFAULT 2`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE simulations ADD COLUMN enemy_profile VARCHAR(50) NOT NULL DEFAULT 'balanced'`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE simulations ADD COLUMN combat_success_rate INT DEFAULT 0`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE simulations ADD COLUMN puzzle_success_rate INT DEFAULT 0`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } try { await connection.execute(`ALTER TABLE simulations ADD COLUMN story_hooks JSON DEFAULT ('[]')`); } catch (error) { if (error.code !== 'ER_DUP_FIELDNAME') throw error; } await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_created_at ON missions(created_at)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_missions_theme ON missions(theme)`); // Create indexes await connection.execute(`CREATE INDEX IF NOT EXISTS idx_players_name ON players(name)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_rules_staging_category ON rules_staging(category)`); await connection.execute(`CREATE INDEX IF NOT EXISTS idx_rules_category ON rules(category)`); connection.release(); console.log('MariaDB tables created successfully'); logToFile('MariaDB: Tables created successfully'); // Seed rules from JSON if table is empty await seedRulesIfEmpty(); } catch (error) { console.error('Error creating MariaDB tables:', error); logToFile('MariaDB: Error creating tables', error); throw error; } }; // Seed rules table from rules-database.json if empty const seedRulesIfEmpty = async () => { try { const [countRows] = await pool.execute('SELECT COUNT(*) AS cnt FROM rules'); if (countRows[0].cnt > 0) return; const dbJsonPath = path.join(__dirname, 'rules', 'rules-database.json'); if (!fs.existsSync(dbJsonPath)) { logToFile('MariaDB: rules-database.json not found, skipping seed'); return; } const data = JSON.parse(fs.readFileSync(dbJsonPath, 'utf8')); const rules = data.rules || []; if (!rules.length) return; const connection = await pool.getConnection(); for (const rule of rules) { await connection.execute( `INSERT IGNORE INTO rules (rule_id, title, content, summary, examples, page, page_end, source, source_abbr, category, tags, aliases, related_rules, source_method, confidence, midgame_priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ rule.rule_id || rule.id || null, rule.title || '', rule.content || '', rule.summary || '', rule.examples || '', rule.page || null, rule.pageEnd || rule.page_end || null, rule.source || null, rule.sourceAbbr || rule.source_abbr || null, rule.category || null, JSON.stringify(rule.tags || []), JSON.stringify(rule.aliases || []), JSON.stringify(rule.relatedRules || rule.related_rules || []), rule.sourceMethod || rule.source_method || null, rule.confidence == null ? null : Number(rule.confidence), rule.midgamePriority || rule.midgame_priority || 0 ] ); } connection.release(); console.log(`MariaDB: Seeded ${rules.length} rules from rules-database.json`); logToFile(`MariaDB: Seeded ${rules.length} rules from rules-database.json`); } catch (error) { console.error('Error seeding rules:', error); logToFile('MariaDB: Error seeding rules', error); } }; // Player helpers const playerHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM players ORDER BY name'); return rows.map(row => ({ id: row.id, name: row.name, rollerInfo: typeof row.roller_info === 'string' ? JSON.parse(row.roller_info) : row.roller_info, shopInfo: typeof row.shop_info === 'string' ? JSON.parse(row.shop_info) : row.shop_info, tabInfo: typeof row.tab_info === 'string' ? JSON.parse(row.tab_info) : row.tab_info, pw: row.pw || '', pwHash: row.pw_hash || '', requisitionPoints: row.requisition_points || 0, renownLevel: row.renown_level || 'None', createdAt: row.created_at, updatedAt: row.updated_at, _id: row.id })); } catch (error) { logToFile('MariaDB: Error getting all players', error); return []; } }, getByName: async (name) => { try { const [rows] = await pool.execute('SELECT * FROM players WHERE name = ?', [name]); if (rows.length === 0) return null; const row = rows[0]; return { id: row.id, name: row.name, rollerInfo: typeof row.roller_info === 'string' ? JSON.parse(row.roller_info) : row.roller_info, shopInfo: typeof row.shop_info === 'string' ? JSON.parse(row.shop_info) : row.shop_info, tabInfo: typeof row.tab_info === 'string' ? JSON.parse(row.tab_info) : row.tab_info, pw: row.pw || '', pwHash: row.pw_hash || '', requisitionPoints: row.requisition_points || 0, renownLevel: row.renown_level || 'None', createdAt: row.created_at, updatedAt: row.updated_at, _id: row.id }; } catch (error) { logToFile('MariaDB: Error getting player by name', name, error); return null; } }, create: async (playerData) => { try { const { name, rollerInfo = {}, shopInfo = {}, tabInfo = {}, pw = '', pwHash = '' } = playerData; const [result] = await pool.execute( 'INSERT INTO players (name, roller_info, shop_info, tab_info, pw, pw_hash) VALUES (?, ?, ?, ?, ?, ?)', [name, JSON.stringify(rollerInfo), JSON.stringify(shopInfo), JSON.stringify(tabInfo), pw, pwHash] ); logToFile('MariaDB: Created player', name); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating player', playerData.name, error); return null; } }, update: async (name, playerData) => { try { const { rollerInfo, shopInfo, tabInfo, pw, pwHash } = playerData; const [result] = await pool.execute( 'UPDATE players SET roller_info = ?, shop_info = ?, tab_info = ?, pw = ?, pw_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE name = ?', [ JSON.stringify(rollerInfo || {}), JSON.stringify(shopInfo || {}), JSON.stringify(tabInfo || {}), pw || '', pwHash || '', name ] ); logToFile('MariaDB: Updated player', name); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error updating player', name, error); return false; } }, delete: async (name) => { try { const [result] = await pool.execute('DELETE FROM players WHERE name = ?', [name]); logToFile('MariaDB: Deleted player', name); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting player', name, error); return false; } } }; // Session helpers const sessionHelpers = { create: async (sessionId, data = {}, expiresAt = null) => { try { const [result] = await pool.execute( 'INSERT INTO sessions (session_id, data, expires_at) VALUES (?, ?, ?)', [sessionId, JSON.stringify(data), expiresAt] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating session', sessionId, error); return null; } }, get: async (sessionId) => { try { const [rows] = await pool.execute('SELECT * FROM sessions WHERE session_id = ?', [sessionId]); if (rows.length === 0) return null; const row = rows[0]; return { ...row, data: typeof row.data === 'string' ? JSON.parse(row.data) : row.data }; } catch (error) { logToFile('MariaDB: Error getting session', sessionId, error); return null; } }, update: async (sessionId, data) => { try { const [result] = await pool.execute( 'UPDATE sessions SET data = ?, updated_at = CURRENT_TIMESTAMP WHERE session_id = ?', [JSON.stringify(data), sessionId] ); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error updating session', sessionId, error); return false; } }, delete: async (sessionId) => { try { const [result] = await pool.execute('DELETE FROM sessions WHERE session_id = ?', [sessionId]); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting session', sessionId, error); return false; } }, cleanup: async () => { try { const [result] = await pool.execute('DELETE FROM sessions WHERE expires_at < NOW()'); logToFile('MariaDB: Cleaned up expired sessions', result.affectedRows); return result.affectedRows; } catch (error) { logToFile('MariaDB: Error cleaning up sessions', error); return 0; } } }; // Rules helpers const rulesHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM rules ORDER BY id'); return rows; } catch (error) { logToFile('MariaDB: Error getting all rules', error); return []; } }, create: async (rule) => { try { const [result] = await pool.execute( `INSERT INTO rules (rule_id, title, content, summary, examples, page, page_end, source, source_abbr, category, tags, aliases, related_rules, source_method, confidence, midgame_priority) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ rule.rule_id || null, rule.title, rule.content, rule.summary || '', rule.examples || '', rule.page || null, rule.page_end || rule.pageEnd || null, rule.source || null, rule.source_abbr || rule.sourceAbbr || null, rule.category || null, JSON.stringify(rule.tags || []), JSON.stringify(rule.aliases || []), JSON.stringify(rule.related_rules || rule.relatedRules || []), rule.source_method || rule.sourceMethod || null, rule.confidence == null ? null : Number(rule.confidence), rule.midgame_priority || rule.midgamePriority || 0 ] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating rule', error); return null; } }, delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM rules WHERE id = ?', [id]); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting rule', id, error); return false; } } }; // Rules staging helpers const stagingHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM rules_staging ORDER BY id'); return rows; } catch (error) { logToFile('MariaDB: Error getting all staging rules', error); return []; } }, create: async (rule) => { try { const [result] = await pool.execute( 'INSERT INTO rules_staging (title, content, category, page, original_json) VALUES (?, ?, ?, ?, ?)', [rule.title, rule.content, rule.category || null, rule.page || null, rule.original_json || null] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating staging rule', error); return null; } }, delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM rules_staging WHERE id = ?', [id]); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting staging rule', id, error); return false; } }, moveToRules: async (id) => { try { const connection = await pool.getConnection(); await connection.beginTransaction(); // Get the staging rule const [stagingRows] = await connection.execute('SELECT * FROM rules_staging WHERE id = ?', [id]); if (stagingRows.length === 0) { await connection.rollback(); connection.release(); return false; } const rule = stagingRows[0]; // Insert into rules await connection.execute( 'INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)', [rule.rule_id || null, rule.title, rule.content, rule.page || null, rule.source || null, rule.source_abbr || null, rule.category || null] ); // Delete from staging await connection.execute('DELETE FROM rules_staging WHERE id = ?', [id]); await connection.commit(); connection.release(); return true; } catch (error) { logToFile('MariaDB: Error moving staging rule to rules', id, error); return false; } } }; // Weapons helpers const weaponsHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM weapons ORDER BY id'); return rows; } catch (error) { logToFile('MariaDB: Error getting all weapons', error); return []; } } }; // Bestiary helpers const bestiaryHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM bestiary ORDER BY id'); return rows; } catch (error) { logToFile('MariaDB: Error getting all bestiary', error); return []; } }, upsert: async (entry) => { try { const name = entry.bestiaryName || entry.name; const book = entry.book || null; const page = entry.page == null ? null : String(entry.page); const pdf = entry.pdf || null; const stats = { ...(entry.stats || {}), type: entry.type || entry.stats?.type || '', affiliation: entry.affiliation || entry.stats?.affiliation || '', sourceUrl: entry.sourceUrl || entry.stats?.sourceUrl || '' }; const profile = entry.profile || stats.profile || {}; const snippet = entry.snippet || ''; const [existing] = await pool.execute( `SELECT id FROM bestiary WHERE name = ? AND COALESCE(book, '') = COALESCE(?, '') AND COALESCE(page, '') = COALESCE(?, '') LIMIT 1`, [name, book, page] ); if (existing.length) { await pool.execute( `UPDATE bestiary SET book = ?, page = ?, pdf = ?, stats = ?, profile = ?, snippet = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, [book, page, pdf, JSON.stringify(stats), JSON.stringify(profile), snippet, existing[0].id] ); return existing[0].id; } const [result] = await pool.execute( `INSERT INTO bestiary (name, book, page, pdf, stats, profile, snippet) VALUES (?, ?, ?, ?, ?, ?, ?)`, [name, book, page, pdf, JSON.stringify(stats), JSON.stringify(profile), snippet] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error upserting bestiary entry', entry && (entry.bestiaryName || entry.name), error); return null; } }, replaceAll: async (entries) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); await connection.execute('DELETE FROM bestiary'); for (const entry of entries) { const name = entry.bestiaryName || entry.name; const stats = { ...(entry.stats || {}), type: entry.type || entry.stats?.type || '', affiliation: entry.affiliation || entry.stats?.affiliation || '', sourceUrl: entry.sourceUrl || entry.stats?.sourceUrl || '' }; const profile = entry.profile || stats.profile || {}; await connection.execute( `INSERT INTO bestiary (name, book, page, pdf, stats, profile, snippet) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ name, entry.book || null, entry.page == null ? null : String(entry.page), entry.pdf || null, JSON.stringify(stats), JSON.stringify(profile), entry.snippet || '' ] ); } await connection.commit(); return entries.length; } catch (error) { await connection.rollback(); logToFile('MariaDB: Error replacing bestiary', error); throw error; } finally { connection.release(); } } }; // Mission helpers const missionHelpers = { getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM missions ORDER BY created_at DESC'); return rows.map(row => ({ ...row, is_active: Boolean(row.is_active), current_scene: row.current_scene || 0, scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes })); } catch (error) { logToFile('MariaDB: Error getting all missions', error); return []; } }, getById: async (id) => { try { const [rows] = await pool.execute('SELECT * FROM missions WHERE id = ?', [id]); if (rows.length === 0) return null; const row = rows[0]; return { ...row, is_active: Boolean(row.is_active), current_scene: row.current_scene || 0, scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes }; } catch (error) { logToFile('MariaDB: Error getting mission', id, error); return null; } }, create: async (missionData) => { try { const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes, gmPlayer } = missionData; const [result] = await pool.execute( 'INSERT INTO missions (name, theme, scene_count, enemy_count, threat_level, player_count, scenes, gm_player) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes || []), gmPlayer || null] ); logToFile('MariaDB: Created mission', name); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating mission', error); return null; } }, update: async (id, missionData) => { try { const { name, theme, sceneCount, enemyCount, threatLevel, playerCount, scenes } = missionData; const [result] = await pool.execute( 'UPDATE missions SET name = ?, theme = ?, scene_count = ?, enemy_count = ?, threat_level = ?, player_count = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [name, theme, sceneCount, enemyCount, threatLevel, playerCount, JSON.stringify(scenes), id] ); logToFile('MariaDB: Updated mission', id); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error updating mission', id, error); return false; } }, getActive: async () => { try { const [rows] = await pool.execute('SELECT * FROM missions WHERE is_active = 1 ORDER BY updated_at DESC LIMIT 1'); if (rows.length === 0) return null; const row = rows[0]; return { ...row, is_active: Boolean(row.is_active), current_scene: row.current_scene || 0, scenes: typeof row.scenes === 'string' ? JSON.parse(row.scenes) : row.scenes }; } catch (error) { logToFile('MariaDB: Error getting active mission', error); return null; } }, setActive: async (id) => { const connection = await pool.getConnection(); try { await connection.beginTransaction(); const [existing] = await connection.execute('SELECT id FROM missions WHERE id = ? LIMIT 1', [id]); if (existing.length === 0) { await connection.rollback(); return false; } await connection.execute('UPDATE missions SET is_active = 0'); const [result] = await connection.execute( 'UPDATE missions SET is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [id] ); await connection.commit(); logToFile('MariaDB: Set active mission', id); return result.affectedRows > 0; } catch (error) { await connection.rollback(); logToFile('MariaDB: Error setting active mission', id, error); return false; } finally { connection.release(); } }, updateProgress: async (id, progressData) => { try { const { currentScene, scenes } = progressData; const [result] = await pool.execute( 'UPDATE missions SET current_scene = ?, scenes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [Number.isInteger(currentScene) ? currentScene : 0, JSON.stringify(scenes || []), id] ); logToFile('MariaDB: Updated mission progress', id); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error updating mission progress', id, error); return false; } }, delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM missions WHERE id = ?', [id]); logToFile('MariaDB: Deleted mission', id); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting mission', id, error); return false; } } }; const missionRollHelpers = { create: async (rollData) => { try { let missionId = rollData.missionId || rollData.mission_id || null; let sceneIndex = Number.isInteger(rollData.sceneIndex) ? rollData.sceneIndex : Number(rollData.scene_index || 0); if (!missionId) { const active = await missionHelpers.getActive(); missionId = active?.id || null; sceneIndex = active?.current_scene || sceneIndex || 0; } const payload = rollData.payload && typeof rollData.payload === 'object' ? rollData.payload : {}; const [result] = await pool.execute( 'INSERT INTO mission_rolls (mission_id, player_name, roll_type, scene_index, label, payload) VALUES (?, ?, ?, ?, ?, ?)', [ missionId, rollData.playerName || rollData.player_name || 'unknown', rollData.rollType || rollData.roll_type || 'check', Number.isFinite(sceneIndex) ? sceneIndex : 0, rollData.label || payload.name || payload.weapon || null, JSON.stringify(payload), ] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating mission roll', error); return null; } }, getForMission: async (missionId, limit = 50) => { try { const boundedLimit = Math.max(1, Math.min(Number(limit) || 50, 500)); const [rows] = await pool.execute( `SELECT * FROM mission_rolls WHERE mission_id = ? ORDER BY created_at DESC LIMIT ${boundedLimit}`, [missionId] ); return rows.map(row => ({ ...row, payload: typeof row.payload === 'string' ? JSON.parse(row.payload || '{}') : row.payload, })); } catch (error) { logToFile('MariaDB: Error getting mission rolls', missionId, error); return []; } }, getActive: async (limit = 50) => { const active = await missionHelpers.getActive(); if (!active) return []; return missionRollHelpers.getForMission(active.id, limit); }, delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM mission_rolls WHERE id = ?', [id]); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting mission roll', id, error); return false; } }, clearForMission: async (missionId) => { try { const [result] = await pool.execute('DELETE FROM mission_rolls WHERE mission_id = ?', [missionId]); return result.affectedRows || 0; } catch (error) { logToFile('MariaDB: Error clearing mission rolls', missionId, error); return null; } }, clearActive: async () => { const active = await missionHelpers.getActive(); if (!active) return null; return missionRollHelpers.clearForMission(active.id); } }; const simulationHelpers = { save: async (data) => { try { const [result] = await pool.execute( `INSERT INTO simulations (mission_id, mission_name, players, result, xp_earned, total_rounds, total_rolls, success_rate, combat_success_rate, puzzle_success_rate, scene_results, player_cards, roll_feed, story_hooks, findings, difficulty_level, enemy_profile) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ data.mission_id || null, data.mission_name || 'Unknown Mission', JSON.stringify(data.players || []), data.result || 'UNKNOWN', data.xp_earned || 0, data.total_rounds || 0, data.total_rolls || 0, data.success_rate || 0, data.combat_success_rate || 0, data.puzzle_success_rate || 0, JSON.stringify(data.scene_results || []), JSON.stringify(data.player_cards || {}), JSON.stringify(data.roll_feed || []), JSON.stringify(data.story_hooks || []), JSON.stringify(data.findings || []), data.difficulty_level || 2, data.enemy_profile || 'balanced', ] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error saving simulation', error); return null; } }, getAll: async (limit = 50) => { try { const bounded = Math.max(1, Math.min(Number(limit) || 50, 200)); const [rows] = await pool.execute( `SELECT id, mission_id, mission_name, players, result, xp_earned, total_rounds, total_rolls, success_rate, combat_success_rate, puzzle_success_rate, findings, story_hooks, difficulty_level, enemy_profile, run_date FROM simulations ORDER BY run_date DESC LIMIT ${bounded}` ); return rows.map(r => ({ ...r, players: typeof r.players === 'string' ? JSON.parse(r.players || '[]') : r.players, findings: typeof r.findings === 'string' ? JSON.parse(r.findings || '[]') : r.findings, story_hooks: typeof r.story_hooks === 'string' ? JSON.parse(r.story_hooks || '[]') : (r.story_hooks || []), })); } catch (error) { logToFile('MariaDB: Error listing simulations', error); return []; } }, getById: async (id) => { try { const [rows] = await pool.execute('SELECT * FROM simulations WHERE id = ?', [id]); if (!rows.length) return null; const r = rows[0]; const parse = (v) => typeof v === 'string' ? JSON.parse(v || 'null') : v; return { ...r, players: parse(r.players), scene_results: parse(r.scene_results), player_cards: parse(r.player_cards), roll_feed: parse(r.roll_feed), story_hooks: parse(r.story_hooks) || [], findings: parse(r.findings), }; } catch (error) { logToFile('MariaDB: Error getting simulation', id, error); return null; } }, delete: async (id) => { try { const [result] = await pool.execute('DELETE FROM simulations WHERE id = ?', [id]); return result.affectedRows > 0; } catch (error) { logToFile('MariaDB: Error deleting simulation', id, error); return false; } }, }; // Error report helpers const errorReportHelpers = { create: async (data) => { try { const [result] = await pool.execute( 'INSERT INTO error_reports (player_name, category, title, description, page_url) VALUES (?, ?, ?, ?, ?)', [data.playerName, data.category || 'other', data.title, data.description, data.pageUrl || ''] ); return result.insertId; } catch (error) { logToFile('MariaDB: Error creating error report', error); throw error; } }, getAll: async () => { try { const [rows] = await pool.execute('SELECT * FROM error_reports ORDER BY created_at DESC'); return rows; } catch (error) { logToFile('MariaDB: Error fetching error reports', error); return []; } }, getByPlayer: async (playerName) => { try { const [rows] = await pool.execute( 'SELECT * FROM error_reports WHERE player_name = ? ORDER BY created_at DESC', [playerName] ); return rows; } catch (error) { logToFile('MariaDB: Error fetching player error reports', playerName, error); return []; } }, resolve: async (id, resolvedBy, resolvedNote) => { try { await pool.execute( 'UPDATE error_reports SET status = ?, resolved_by = ?, resolved_note = ?, resolved_at = NOW() WHERE id = ?', ['resolved', resolvedBy, resolvedNote || '', id] ); } catch (error) { logToFile('MariaDB: Error resolving error report', id, error); throw error; } }, updateStatus: async (id, status) => { try { await pool.execute( 'UPDATE error_reports SET status = ? WHERE id = ?', [status, id] ); } catch (error) { logToFile('MariaDB: Error updating error report status', id, error); throw error; } }, delete: async (id) => { try { await pool.execute('DELETE FROM error_reports WHERE id = ?', [id]); } catch (error) { logToFile('MariaDB: Error deleting error report', id, error); throw error; } }, getById: async (id) => { try { const [rows] = await pool.execute('SELECT * FROM error_reports WHERE id = ?', [id]); return rows[0] || null; } catch (error) { logToFile('MariaDB: Error fetching error report by id', id, error); return null; } } }; // Initialize database const initializationPromise = createTables().catch(error => { console.error('Failed to initialize MariaDB:', error); logToFile('MariaDB: Initialization failed; continuing with degraded helpers', error); }); // Export the connection pool and helpers module.exports = { pool, playerHelpers, sessionHelpers, rulesHelpers, stagingHelpers, weaponsHelpers, bestiaryHelpers, missionHelpers, missionRollHelpers, simulationHelpers, errorReportHelpers, initializationPromise, logToFile };