diff --git a/database/mariadb-schema-update.sql b/database/mariadb-schema-update.sql new file mode 100644 index 0000000..82f5e3d --- /dev/null +++ b/database/mariadb-schema-update.sql @@ -0,0 +1,96 @@ +-- Add missing tables to MariaDB schema + +-- Shop items table +CREATE TABLE IF NOT EXISTS shop_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + category VARCHAR(100) NOT NULL, + requisition_cost INT NOT NULL DEFAULT 0, + renown_requirement VARCHAR(50) NOT NULL DEFAULT 'None', + item_type VARCHAR(100) NOT NULL, + stats TEXT NOT NULL, + source VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- Player inventory table +CREATE TABLE IF NOT EXISTS player_inventory ( + id INT AUTO_INCREMENT PRIMARY KEY, + player_id INT NOT NULL, + item_id INT NOT NULL, + quantity INT NOT NULL DEFAULT 1, + acquired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + transaction_details TEXT, + FOREIGN KEY (player_id) REFERENCES players (id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES shop_items (id) ON DELETE CASCADE, + UNIQUE(player_id, item_id) +); + +-- Transactions table +CREATE TABLE IF NOT EXISTS transactions ( + id INT AUTO_INCREMENT PRIMARY KEY, + player_id INT NOT NULL, + item_id INT NOT NULL, + requisition_cost INT NOT NULL, + previous_rp INT NOT NULL, + new_rp INT NOT NULL, + transaction_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (player_id) REFERENCES players (id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES shop_items (id) ON DELETE CASCADE +); + +-- Armour table +CREATE TABLE IF NOT EXISTS armour ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + req INT DEFAULT 0, + renown VARCHAR(50) DEFAULT 'None', + category VARCHAR(100), + stats TEXT, + source VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Weapons table +CREATE TABLE IF NOT EXISTS weapons ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + req INT DEFAULT 0, + renown VARCHAR(50) DEFAULT 'None', + category VARCHAR(100), + stats TEXT, + source VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Bestiary table +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 +); + +-- Rules table +CREATE TABLE IF NOT EXISTS rules ( + id INT AUTO_INCREMENT PRIMARY KEY, + rule_id VARCHAR(255) UNIQUE, + title VARCHAR(500), + content TEXT, + page INT, + source VARCHAR(255), + source_abbr VARCHAR(50), + category VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Add missing columns to players table +ALTER TABLE players +ADD COLUMN IF NOT EXISTS requisition_points INT DEFAULT 0, +ADD COLUMN IF NOT EXISTS renown_level VARCHAR(50) DEFAULT 'None'; diff --git a/database/mariadb.js b/database/mariadb.js new file mode 100644 index 0000000..1c95d6a --- /dev/null +++ b/database/mariadb.js @@ -0,0 +1,408 @@ +const mysql = require('mysql2/promise'); +const fs = require('fs'); +const path = require('path'); + +// 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: 'localhost', + user: 'deathwatch', + password: 'dwroller2025', + database: 'deathwatch', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}; + +// Create connection pool +const pool = mysql.createPool(dbConfig); + +// 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 + ) + `); + + // 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)`); + + connection.release(); + console.log('MariaDB tables created successfully'); + logToFile('MariaDB: Tables created successfully'); + } catch (error) { + console.error('Error creating MariaDB tables:', error); + logToFile('MariaDB: Error creating tables', error); + throw 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 (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())', + [rule.title, rule.content, rule.source, rule.page_num, rule.rulebook] + ); + 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, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())', + [rule.title, rule.content, rule.source, rule.page_num, rule.rulebook] + ); + 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 (title, content, source, page_num, rulebook, created_at) VALUES (?, ?, ?, ?, ?, NOW())', + [rule.title, rule.content, rule.source, rule.page_num, rule.rulebook] + ); + + // 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 []; + } + } +}; + +// Initialize database +createTables().catch(error => { + console.error('Failed to initialize MariaDB:', error); + process.exit(1); +}); + +// Export the connection pool and helpers +module.exports = { + pool, + playerHelpers, + sessionHelpers, + rulesHelpers, + stagingHelpers, + weaponsHelpers, + bestiaryHelpers, + logToFile +}; diff --git a/database/migrate-inventory-to-gear.js b/database/migrate-inventory-to-gear.js new file mode 100644 index 0000000..cb2ead1 --- /dev/null +++ b/database/migrate-inventory-to-gear.js @@ -0,0 +1,56 @@ +const { playerHelpers } = require('./mariadb'); + +async function migrateInventoryToGear() { + try { + console.log('Starting inventory to gear migration...'); + + const players = await playerHelpers.getAll(); + + for (const player of players) { + if (player.tabInfo && player.tabInfo.inventory && player.tabInfo.inventory.length > 0) { + console.log(`Migrating inventory for ${player.name}...`); + + const updatedTabInfo = { ...player.tabInfo }; + + // Initialize gear if it doesn't exist + if (!updatedTabInfo.gear) { + updatedTabInfo.gear = []; + } + + // Move inventory items to gear + for (const invItem of updatedTabInfo.inventory) { + const gearItem = { + name: invItem.name, + qty: invItem.count || invItem.quantity || 1 + }; + + // Check if item already exists in gear + const existingGearIndex = updatedTabInfo.gear.findIndex(g => g.name === gearItem.name); + + if (existingGearIndex >= 0) { + // Update existing item quantity + updatedTabInfo.gear[existingGearIndex].qty += gearItem.qty; + } else { + // Add new item to gear + updatedTabInfo.gear.push(gearItem); + } + } + + // Clear inventory since we moved everything to gear + updatedTabInfo.inventory = []; + + // Update player + await playerHelpers.update(player.name, { ...player, tabInfo: updatedTabInfo }); + console.log(` Moved ${player.tabInfo.inventory.length} items to gear`); + } + } + + console.log('Migration completed!'); + } catch (error) { + console.error('Migration failed:', error); + } + + process.exit(0); +} + +migrateInventoryToGear(); diff --git a/database/migrate-sqlite-to-mariadb.js b/database/migrate-sqlite-to-mariadb.js new file mode 100644 index 0000000..09c1bce --- /dev/null +++ b/database/migrate-sqlite-to-mariadb.js @@ -0,0 +1,392 @@ +#!/usr/bin/env node + +const Database = require('better-sqlite3'); +const mysql = require('mysql2/promise'); +const path = require('path'); + +// SQLite database path +const sqliteDbPath = path.join(__dirname, 'sqlite', 'deathwatch.db'); + +// MariaDB configuration +const mariadbConfig = { + host: 'localhost', + user: 'deathwatch', + password: 'dwroller2025', + database: 'deathwatch' +}; + +// Helper function to convert SQLite datetime to MySQL format +function convertDateTime(dateString) { + if (!dateString) return null; + try { + return new Date(dateString).toISOString().slice(0, 19).replace('T', ' '); + } catch (error) { + console.warn(`Warning: Could not convert datetime: ${dateString}`); + return null; + } +} + +async function migrateData() { + let sqliteDb = null; + let mariadbConnection = null; + + try { + console.log('Starting data migration from SQLite to MariaDB...'); + + // Connect to SQLite + console.log('Connecting to SQLite database...'); + sqliteDb = new Database(sqliteDbPath); + + // Connect to MariaDB + console.log('Connecting to MariaDB database...'); + mariadbConnection = await mysql.createConnection(mariadbConfig); + + // Migrate players table (includes requisition_points and renown_level) + console.log('Migrating players table...'); + const players = sqliteDb.prepare('SELECT * FROM players').all(); + console.log(`Found ${players.length} players to migrate`); + + for (const player of players) { + console.log(` Migrating player: ${player.name}`); + + // Convert text fields to proper JSON + const rollerInfo = player.roller_info || '{}'; + const shopInfo = player.shop_info || '{}'; + const tabInfo = player.tab_info || '{}'; + + await mariadbConnection.execute( + `INSERT INTO players (id, name, roller_info, shop_info, tab_info, pw, pw_hash, created_at, updated_at, requisition_points, renown_level) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + roller_info = VALUES(roller_info), + shop_info = VALUES(shop_info), + tab_info = VALUES(tab_info), + pw = VALUES(pw), + pw_hash = VALUES(pw_hash), + updated_at = VALUES(updated_at), + requisition_points = VALUES(requisition_points), + renown_level = VALUES(renown_level)`, + [ + player.id, + player.name, + rollerInfo, + shopInfo, + tabInfo, + player.pw || '', + player.pw_hash || '', + convertDateTime(player.created_at), + convertDateTime(player.updated_at), + player.requisition_points || 0, + player.renown_level || 'None' + ] + ); + } + + // Migrate sessions table + console.log('Migrating sessions table...'); + const sessions = sqliteDb.prepare('SELECT * FROM sessions').all(); + console.log(`Found ${sessions.length} sessions to migrate`); + + for (const session of sessions) { + console.log(` Migrating session: ${session.session_id}`); + + await mariadbConnection.execute( + `INSERT INTO sessions (id, session_id, data, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + data = VALUES(data), + expires_at = VALUES(expires_at), + updated_at = VALUES(updated_at)`, + [ + session.id, + session.session_id, + session.data || '{}', + convertDateTime(session.expires_at), + convertDateTime(session.created_at), + convertDateTime(session.updated_at) + ] + ); + } + + // Migrate shop_items table + console.log('Migrating shop_items table...'); + const shopItems = sqliteDb.prepare('SELECT * FROM shop_items').all(); + console.log(`Found ${shopItems.length} shop items to migrate`); + + for (const item of shopItems) { + console.log(` Migrating shop item: ${item.name}`); + + await mariadbConnection.execute( + `INSERT INTO shop_items (id, name, category, requisition_cost, renown_requirement, item_type, stats, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + category = VALUES(category), + requisition_cost = VALUES(requisition_cost), + renown_requirement = VALUES(renown_requirement), + item_type = VALUES(item_type), + stats = VALUES(stats), + source = VALUES(source), + updated_at = VALUES(updated_at)`, + [ + item.id, + item.name, + item.category, + item.requisition_cost, + item.renown_requirement, + item.item_type, + item.stats, + item.source, + convertDateTime(item.created_at), + convertDateTime(item.updated_at) + ] + ); + } + + // Migrate player_inventory table + console.log('Migrating player_inventory table...'); + const playerInventory = sqliteDb.prepare('SELECT * FROM player_inventory').all(); + console.log(`Found ${playerInventory.length} inventory items to migrate`); + + for (const invItem of playerInventory) { + console.log(` Migrating inventory item for player ${invItem.player_id}`); + + await mariadbConnection.execute( + `INSERT INTO player_inventory (id, player_id, item_id, quantity, acquired_at, transaction_details) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + quantity = VALUES(quantity), + transaction_details = VALUES(transaction_details)`, + [ + invItem.id, + invItem.player_id, + invItem.item_id, + invItem.quantity, + convertDateTime(invItem.acquired_at), + invItem.transaction_details + ] + ); + } + + // Migrate transactions table + console.log('Migrating transactions table...'); + const transactions = sqliteDb.prepare('SELECT * FROM transactions').all(); + console.log(`Found ${transactions.length} transactions to migrate`); + + for (const transaction of transactions) { + console.log(` Migrating transaction ${transaction.id}`); + + await mariadbConnection.execute( + `INSERT INTO transactions (id, player_id, item_id, requisition_cost, previous_rp, new_rp, transaction_date) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + transaction.id, + transaction.player_id, + transaction.item_id, + transaction.requisition_cost, + transaction.previous_rp, + transaction.new_rp, + convertDateTime(transaction.transaction_date) + ] + ); + } + + // Migrate armour table + console.log('Migrating armour table...'); + const armour = sqliteDb.prepare('SELECT * FROM armour').all(); + console.log(`Found ${armour.length} armour items to migrate`); + + for (const armourItem of armour) { + console.log(` Migrating armour: ${armourItem.name}`); + + await mariadbConnection.execute( + `INSERT INTO armour (id, name, req, renown, category, stats, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + req = VALUES(req), + renown = VALUES(renown), + category = VALUES(category), + stats = VALUES(stats), + source = VALUES(source)`, + [ + armourItem.id, + armourItem.name, + armourItem.req, + armourItem.renown, + armourItem.category, + armourItem.stats, + armourItem.source, + convertDateTime(armourItem.created_at) + ] + ); + } + + // Migrate weapons table + console.log('Migrating weapons table...'); + const weapons = sqliteDb.prepare('SELECT * FROM weapons').all(); + console.log(`Found ${weapons.length} weapons to migrate`); + + for (const weapon of weapons) { + console.log(` Migrating weapon: ${weapon.name}`); + + await mariadbConnection.execute( + `INSERT INTO weapons (id, name, req, renown, category, stats, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + req = VALUES(req), + renown = VALUES(renown), + category = VALUES(category), + stats = VALUES(stats), + source = VALUES(source)`, + [ + weapon.id, + weapon.name, + weapon.req, + weapon.renown, + weapon.category, + weapon.stats, + weapon.source, + convertDateTime(weapon.created_at) + ] + ); + } + + // Migrate bestiary table + console.log('Migrating bestiary table...'); + const bestiary = sqliteDb.prepare('SELECT * FROM bestiary').all(); + console.log(`Found ${bestiary.length} bestiary entries to migrate`); + + for (const beast of bestiary) { + console.log(` Migrating bestiary: ${beast.name}`); + + await mariadbConnection.execute( + `INSERT INTO bestiary (id, name, book, page, pdf, stats, profile, snippet, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + book = VALUES(book), + page = VALUES(page), + pdf = VALUES(pdf), + stats = VALUES(stats), + profile = VALUES(profile), + snippet = VALUES(snippet)`, + [ + beast.id, + beast.name, + beast.book, + beast.page, + beast.pdf, + beast.stats, + beast.profile, + beast.snippet, + convertDateTime(beast.created_at) + ] + ); + } + + // Migrate rules table + console.log('Migrating rules table...'); + const rules = sqliteDb.prepare('SELECT * FROM rules').all(); + console.log(`Found ${rules.length} rules to migrate`); + + for (const rule of rules) { + console.log(` Migrating rule: ${rule.title}`); + + // Truncate rule_id if it's too long (max 255 chars) + const ruleId = rule.rule_id ? rule.rule_id.substring(0, 255) : null; + // Truncate title if it's too long (max 500 chars) + const title = rule.title ? rule.title.substring(0, 500) : null; + + await mariadbConnection.execute( + `INSERT INTO rules (id, rule_id, title, content, page, source, source_abbr, category, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + content = VALUES(content), + page = VALUES(page), + source = VALUES(source), + source_abbr = VALUES(source_abbr), + category = VALUES(category)`, + [ + rule.id, + ruleId, + title, + rule.content, + rule.page, + rule.source, + rule.source_abbr, + rule.category, + convertDateTime(rule.created_at) + ] + ); + } + + // Migrate rules_staging table + console.log('Migrating rules_staging table...'); + const rulesStaging = sqliteDb.prepare('SELECT * FROM rules_staging').all(); + console.log(`Found ${rulesStaging.length} rules_staging entries to migrate`); + + for (const rule of rulesStaging) { + console.log(` Migrating rules_staging: ${rule.title}`); + + await mariadbConnection.execute( + `INSERT INTO rules_staging (id, title, content, category, page, original_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + title = VALUES(title), + content = VALUES(content), + category = VALUES(category), + page = VALUES(page), + original_json = VALUES(original_json)`, + [ + rule.id, + rule.title, + rule.content, + rule.category, + rule.page, + rule.original_json, + convertDateTime(rule.created_at) + ] + ); + } + + console.log('Migration completed successfully!'); + + // Verify migration + console.log('\nVerifying migration...'); + const [mariadbPlayers] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM players'); + const [mariadbSessions] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM sessions'); + const [mariadbShopItems] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM shop_items'); + const [mariadbInventory] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM player_inventory'); + const [mariadbTransactions] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM transactions'); + const [mariadbArmour] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM armour'); + const [mariadbWeapons] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM weapons'); + const [mariadbBestiary] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM bestiary'); + const [mariadbRules] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM rules'); + const [mariadbRulesStaging] = await mariadbConnection.execute('SELECT COUNT(*) as count FROM rules_staging'); + + console.log(`MariaDB players: ${mariadbPlayers[0].count}`); + console.log(`MariaDB sessions: ${mariadbSessions[0].count}`); + console.log(`MariaDB shop_items: ${mariadbShopItems[0].count}`); + console.log(`MariaDB player_inventory: ${mariadbInventory[0].count}`); + console.log(`MariaDB transactions: ${mariadbTransactions[0].count}`); + console.log(`MariaDB armour: ${mariadbArmour[0].count}`); + console.log(`MariaDB weapons: ${mariadbWeapons[0].count}`); + console.log(`MariaDB bestiary: ${mariadbBestiary[0].count}`); + console.log(`MariaDB rules: ${mariadbRules[0].count}`); + console.log(`MariaDB rules_staging: ${mariadbRulesStaging[0].count}`); + + } catch (error) { + console.error('Migration failed:', error); + process.exit(1); + } finally { + if (sqliteDb) { + sqliteDb.close(); + } + if (mariadbConnection) { + await mariadbConnection.end(); + } + } +} + +// Run migration +migrateData().catch(console.error); diff --git a/database/migrate-to-sqlite.js b/database/migrate-to-sqlite.js deleted file mode 100644 index e69de29..0000000 diff --git a/database/routes/bestiaryRoutes.js b/database/routes/bestiaryRoutes.js index fc66dc5..f53839c 100644 --- a/database/routes/bestiaryRoutes.js +++ b/database/routes/bestiaryRoutes.js @@ -1,18 +1,26 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); -const { db } = require('../sqlite-db'); +const { bestiaryHelpers, logToFile } = require('../mariadb'); const router = express.Router(); -// Read bestiary from sqlite table `bestiary` -function loadBestiaryData() { +// Read bestiary from MariaDB table `bestiary` +async function loadBestiaryData() { try { - const rows = db.prepare('SELECT id,name,book,page,pdf,stats,profile,snippet FROM bestiary ORDER BY name').all(); + const rows = await bestiaryHelpers.getAll(); return rows.map(r => { let stats = {}; - try { stats = JSON.parse(r.stats || '{}'); } catch(e){} + try { + stats = typeof r.stats === 'string' ? JSON.parse(r.stats) : r.stats || {}; + } catch(e){ + logToFile('Error parsing bestiary stats for', r.name, e); + } let profile = {}; - try { profile = JSON.parse(r.profile || '{}'); } catch(e){} + try { + profile = typeof r.profile === 'string' ? JSON.parse(r.profile) : r.profile || {}; + } catch(e){ + logToFile('Error parsing bestiary profile for', r.name, e); + } return { _id: r.id, bestiaryName: r.name, @@ -25,7 +33,8 @@ function loadBestiaryData() { }; }); } catch (e) { - console.error('Failed to load bestiary from sqlite:', e); + console.error('Failed to load bestiary from MariaDB:', e); + logToFile('Error loading bestiary:', e); return []; } } @@ -88,9 +97,9 @@ function transformBestiaryEntry(entry) { } // Get all bestiary entries formatted for dice roller -router.get('/enemies', (req, res) => { +router.get('/enemies', async (req, res) => { try { - const entries = loadBestiaryData(); + const entries = await loadBestiaryData(); // Transform entries for dice roller format const enemies = entries @@ -106,43 +115,46 @@ router.get('/enemies', (req, res) => { res.json(enemies); } catch (error) { console.error('Bestiary enemies error:', error); + logToFile('Error getting bestiary enemies:', error); res.status(500).json({ error: 'Failed to get enemies' }); } }); // Get full bestiary data (for bestiary tab) -router.get('/full', (req, res) => { +router.get('/full', async (req, res) => { try { - const entries = loadBestiaryData(); + const entries = await loadBestiaryData(); res.json(entries); } catch (error) { console.error('Bestiary full error:', error); + logToFile('Error getting full bestiary:', error); res.status(500).json({ error: 'Failed to get bestiary data' }); } }); // Get bestiary statistics -router.get('/stats', (req, res) => { +router.get('/stats', async (req, res) => { try { - const entries = loadBestiaryData(); + const entries = await loadBestiaryData(); const stats = { totalEntries: entries.length, withValidStats: entries.filter(e => e.stats?.profile?.t).length, withWounds: entries.filter(e => e.wounds || e.stats?.wounds).length, books: [...new Set(entries.map(e => e.book).filter(Boolean))], - lastUpdated: new Date(lastLoaded).toISOString() + lastUpdated: new Date().toISOString() }; res.json(stats); } catch (error) { console.error('Bestiary stats error:', error); + logToFile('Error getting bestiary stats:', error); res.status(500).json({ error: 'Failed to get bestiary stats' }); } }); // Force reload bestiary data (admin only) -router.post('/reload', (req, res) => { +router.post('/reload', async (req, res) => { try { // Check for GM secret const gmSecret = req.headers['x-gm-secret']; @@ -150,19 +162,17 @@ router.post('/reload', (req, res) => { return res.status(403).json({ error: 'Unauthorized' }); } - // Reset cache - bestiaryData = null; - lastLoaded = 0; - - const entries = loadBestiaryData(); + // No cache to reset since we query MariaDB directly + const entries = await loadBestiaryData(); res.json({ success: true, totalEntries: entries.length, - message: 'Bestiary data reloaded successfully' + message: 'Bestiary data reloaded from MariaDB successfully' }); } catch (error) { console.error('Bestiary reload error:', error); + logToFile('Error reloading bestiary:', error); res.status(500).json({ error: 'Failed to reload bestiary data' }); } }); diff --git a/database/routes/playerRoutes-sqlite.js b/database/routes/playerRoutes-sqlite.js deleted file mode 100644 index 1e09d10..0000000 --- a/database/routes/playerRoutes-sqlite.js +++ /dev/null @@ -1,676 +0,0 @@ -const express = require('express'); -const { playerHelpers, sessionHelpers, db } = require('../sqlite-db'); -const fs = require('fs'); -const path = require('path'); - -// shop data will be read from sqlite `shop_items` table when needed - -// Simple file logger -function logToFile(...args) { - const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; - fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' }); -} - -const requireSession = require('../requireSession'); -const { validatePlayer } = require('../validate'); -const router = express.Router(); - -// PUBLIC ROUTES - before session middleware -// Get shop inventory -router.get('/shop', (req, res) => { - try { - console.log('Shop endpoint hit'); - // Query sqlite shop_items and return grouped by category - const items = db.prepare('SELECT id, name, category, requisition_cost as req, renown_requirement as renown, item_type, stats, source FROM shop_items ORDER BY category, name').all(); - const grouped = items.reduce((acc, it) => { - acc[it.category] = acc[it.category] || []; - let stats = {}; - try { stats = JSON.parse(it.stats || '{}'); } catch(e){} - acc[it.category].push({ id: it.id, name: it.name, req: it.req, renown: it.renown, itemType: it.item_type, stats, source: it.source }); - return acc; - }, {}); - res.json(grouped); - } catch (error) { - console.error('Shop error:', error); - logToFile('API: Failed to get shop data', error); - res.status(500).json({ error: String(error) }); - } -}); - -// Get player names for login dropdown (public - no session required) -router.get('/names', (req, res) => { - try { - console.log('Player names endpoint hit'); - const players = playerHelpers.getAll(); - // Only return names for the login dropdown, not full player data - const playerNames = players.map(p => ({ name: p.name })); - res.json(playerNames); - } catch (error) { - console.error('Player names error:', error); - logToFile('API: Failed to get player names', error); - res.status(500).json({ error: String(error) }); - } -}); - -// Login endpoint - create a server session so x-session-id can be used (public - no session required) -router.post('/login', async (req, res) => { - try { - const { name, password } = req.body; - - if (!name || !password) { - return res.status(400).json({ error: 'Name and password are required' }); - } - - const player = playerHelpers.getByName(name); - if (!player) { - return res.status(401).json({ error: 'Invalid credentials' }); - } - - // Check password using safeCompare - const isValidPassword = await safeCompare(password, player.pwHash); - if (!isValidPassword) { - return res.status(401).json({ error: 'Invalid credentials' }); - } - - // Create session and store it in sessions table - const sessionId = require('crypto').randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24h - sessionHelpers.create(sessionId, { playerName: player.name }, expiresAt); - - logToFile('API: Player login', name, sessionId); - res.json({ - message: 'Login successful', - sessionId, - expiresAt, - player: { - name: player.name, - rollerInfo: player.rollerInfo, - shopInfo: player.shopInfo, - tabInfo: player.tabInfo - } - }); - } catch (error) { - logToFile('API: Login failed', req.body.name, error && error.stack ? error.stack : error); - res.status(500).json({ error: 'Login failed' }); - } -}); - -// Apply session middleware to all routes EXCEPT those above this line -router.use(requireSession); - -// Add safe bcrypt helpers to avoid MODULE_NOT_FOUND failures at runtime -async function safeHash(pw) { - if (!pw) return ''; - try { - const bcrypt = require('bcrypt'); - return await bcrypt.hash(pw, 10); - } catch (err) { - // Fallback to storing plaintext (development only) and log the error - logToFile('WARN: bcrypt.hash failed, falling back to plaintext pw', err && err.stack ? err.stack : String(err)); - return String(pw); - } -} - -async function safeCompare(candidate, hashed) { - try { - const bcrypt = require('bcrypt'); - return await bcrypt.compare(candidate, hashed); - } catch (err) { - // If bcrypt not available, fall back to plaintext comparison (dev only) - logToFile('WARN: bcrypt.compare failed, falling back to plaintext compare', err && err.stack ? err.stack : String(err)); - return String(candidate) === String(hashed); - } -} - -// TEMP ADMIN: List all users -router.get('/admin/list', async (req, res) => { - try { - const players = playerHelpers.getAll(); - res.json(players); - } catch (error) { - logToFile('API: Failed to list players', error); - res.status(500).json({ error: 'Failed to list players' }); - } -}); - -// Expose pre-generated Space Marine names for GM use -router.get('/admin/pregens', async (req, res) => { - try { - const filePath = path.join(__dirname, '..', 'pregen_names.json'); - if (!fs.existsSync(filePath)) return res.json([]); - const names = JSON.parse(fs.readFileSync(filePath, 'utf8')); - res.json(names); - } catch (error) { - logToFile('API: Failed to get pregens', error); - res.status(500).json({ error: 'Failed to get pregens' }); - } -}); - -// Shop endpoint already defined above - -// TEMP ADMIN: Delete test users (name contains 'test' or 'Test') -router.delete('/admin/delete-tests', async (req, res) => { - try { - const players = playerHelpers.getAll(); - const testPlayers = players.filter(p => /test/i.test(p.name)); - - let deletedCount = 0; - for (const player of testPlayers) { - if (playerHelpers.delete(player.name)) { - deletedCount++; - } - } - - res.json({ deletedCount }); - } catch (error) { - logToFile('API: Failed to delete test users', error); - res.status(500).json({ error: 'Failed to delete test users' }); - } -}); - -// Get all players (public for dropdown) -router.get('/', async (req, res) => { - try { - const players = playerHelpers.getAll(); - logToFile('API: Fetch all players (public)', `Found ${players.length} players`); - - // Allow full list if client provided a valid session id OR the GM secret header - const gmSecret = req.headers['x-gm-secret'] || req.query.gmSecret || (req.body && req.body.gmSecret); - const gmPassword = process.env.GM_PASSWORD || 'bongo'; - const isGm = gmSecret && String(gmSecret) === String(gmPassword); - const isAuthed = !!req.headers['x-session-id']; - - if (!isAuthed && !isGm) { - return res.json(players.map(p => ({ name: p.name }))); - } - - res.json(players); - } catch (error) { - logToFile('API: Failed to fetch players', error); - res.status(500).json({ error: 'Failed to fetch players' }); - } -}); - -// Get single player by name -router.get('/:name', async (req, res) => { - try { - const player = playerHelpers.getByName(req.params.name); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - logToFile('API: Fetch player', req.params.name); - res.json(player); - } catch (error) { - logToFile('API: Failed to fetch player', req.params.name, error); - res.status(500).json({ error: 'Failed to fetch player' }); - } -}); - -// Create new player -router.post('/', async (req, res) => { - try { - const { name, pw, ...otherData } = req.body; - - if (!name) { - return res.status(400).json({ error: 'Player name is required' }); - } - - // Check if player already exists - const existingPlayer = playerHelpers.getByName(name); - if (existingPlayer) { - return res.status(409).json({ error: 'Player already exists' }); - } - - // If a plain password was provided, hash it first so validation won't reject plaintext - let pwHash = ''; - if (pw) { - pwHash = await safeHash(pw); - } - - // Validate and normalize incoming player object. Pass pwHash instead of plaintext pw. - const { valid, errors, normalized } = validatePlayer({ name, pwHash, ...otherData }); - if (!valid) { - return res.status(400).json({ error: 'Validation failed', details: errors }); - } - - const newPlayer = playerHelpers.create({ - name: normalized.name, - // Do NOT store plaintext pw to avoid validation rejecting records later - pw: '', - pwHash, - rollerInfo: normalized.rollerInfo || {}, - shopInfo: normalized.shopInfo || {}, - tabInfo: normalized.tabInfo || {} - }); - - logToFile('API: Created player', name); - res.status(201).json(newPlayer); - } catch (error) { - // Log stack for easier debugging - logToFile('API: Failed to create player', req.body && req.body.name, error && error.stack ? error.stack : error); - res.status(500).json({ error: 'Failed to create player' }); - } -}); - -// Update player -router.put('/:name', requireSession, async (req, res) => { - try { - const { name } = req.params; - // Ensure updateData is an object to avoid runtime TypeErrors when fields are missing - const updateData = req.body || {}; - - // Check if player exists - const existingPlayer = playerHelpers.getByName(name); - if (!existingPlayer) { - return res.status(404).json({ error: 'Player not found' }); - } - - // Handle password update if provided using safeHash - if (updateData.pw) { - updateData.pwHash = await safeHash(updateData.pw); - // Do not pass plaintext pw into validation; validation requires pwHash only - // We'll store an empty pw field (frontend may still use pw for temporary purposes) - updateData.pw = ''; - } - - // Merge the data properly - const mergedData = { - rollerInfo: { ...existingPlayer.rollerInfo, ...(updateData.rollerInfo || {}) }, - shopInfo: { ...existingPlayer.shopInfo, ...(updateData.shopInfo || {}) }, - tabInfo: { ...existingPlayer.tabInfo, ...(updateData.tabInfo || {}) }, - // Never carry forward plaintext pw into validation; keep pw empty and use pwHash - pw: '', - pwHash: updateData.pwHash || existingPlayer.pwHash - }; - - // If frontend sends flat fields (not under tabInfo), map them into tabInfo. - // This accepts updates for any of the known tabInfo keys even when only those - // fields are sent, not requiring playerName/charName to be present. - const flatFields = [ - 'playerName','charName','gear','chapter','demeanour','speciality','rank','powerArmour', - 'description','pastEvent','personalDemeanour','characteristics','skills','weapons','armour', - 'talents','psychic','wounds','insanity','movement','fate','corruption','renown','xp','xpSpent', - 'notes','rp' - ]; - - for (const key of flatFields) { - if (Object.prototype.hasOwnProperty.call(updateData, key)) { - mergedData.tabInfo[key] = updateData[key]; - } - } - - // Validate merged data before applying - const { valid: v2, errors: e2, normalized: normalized2 } = validatePlayer(Object.assign({ name }, mergedData)); - if (!v2) { - return res.status(400).json({ error: 'Validation failed', details: e2 }); - } - - const updated = playerHelpers.update(name, normalized2); - - if (!updated) { - return res.status(500).json({ error: 'Failed to update player' }); - } - - const updatedPlayer = playerHelpers.getByName(name); - logToFile('API: Updated player', name); - res.json(updatedPlayer); - } catch (error) { - logToFile('API: Failed to update player', req.params.name, error && error.stack ? error.stack : error); - res.status(500).json({ error: 'Failed to update player' }); - } -}); - -// Delete player -router.delete('/:name', requireSession, async (req, res) => { - try { - const { name } = req.params; - - const deleted = playerHelpers.delete(name); - - if (!deleted) { - return res.status(404).json({ error: 'Player not found' }); - } - - logToFile('API: Deleted player', name); - res.json({ message: 'Player deleted successfully' }); - } catch (error) { - logToFile('API: Failed to delete player', req.params.name, error); - res.status(500).json({ error: 'Failed to delete player' }); - } -}); - -// Upload avatar (base64 JSON payload) - saves to public/avatars and updates tabInfo.picture -router.post('/:name/avatar', requireSession, async (req, res) => { - try { - const { name } = req.params; - const { filename, data } = req.body || {}; - - if (!filename || !data) return res.status(400).json({ error: 'filename and data required' }); - - const existingPlayer = playerHelpers.getByName(name); - if (!existingPlayer) return res.status(404).json({ error: 'Player not found' }); - - // Extract base64 payload if data URL provided - const match = String(data).match(/^data:(image\/(png|jpeg|jpg|gif));base64,(.*)$/i); - let mimeType = null; - let base64 = null; - if (match) { - mimeType = match[1]; - base64 = match[3]; - } else { - // Assume raw base64 and try to infer extension from filename - base64 = String(data).replace(/^\s+|\s+$/g, ''); - } - - // Validate size (limit to 200KB) - let buffer; - try { - buffer = Buffer.from(base64, 'base64'); - } catch (err) { - return res.status(400).json({ error: 'Invalid base64 data' }); - } - const MAX_BYTES = 200 * 1024; - if (buffer.length > MAX_BYTES) return res.status(413).json({ error: 'File too large' }); - - // Sanitize filename and ensure extension - const ext = path.extname(filename).toLowerCase() || (mimeType ? `.${mimeType.split('/')[1]}` : '.png'); - const safeName = `${existingPlayer.name.replace(/[^a-z0-9_-]/gi, '_')}_${Date.now()}${ext}`; - const avatarsDir = path.join(__dirname, '..', '..', 'public', 'avatars'); - if (!fs.existsSync(avatarsDir)) fs.mkdirSync(avatarsDir, { recursive: true }); - - const outPath = path.join(avatarsDir, safeName); - fs.writeFileSync(outPath, buffer); - - // Update player's tabInfo.picture to public URL path - const avatarUrl = `/avatars/${safeName}`; - - const mergedData = { - rollerInfo: { ...existingPlayer.rollerInfo }, - shopInfo: { ...existingPlayer.shopInfo }, - tabInfo: { ...(existingPlayer.tabInfo || {}), picture: avatarUrl }, - // Do not include plaintext pw when validating/updating avatar - pw: '', - pwHash: existingPlayer.pwHash - }; - - // Validate then update - const { valid, errors, normalized } = validatePlayer(Object.assign({ name }, mergedData)); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(name, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player with avatar' }); - - const updated = playerHelpers.getByName(name); - logToFile('API: Uploaded avatar for', name, avatarUrl); - res.json(updated); - } catch (error) { - logToFile('API: Avatar upload failed', req.params.name, error && error.stack ? error.stack : error); - res.status(500).json({ error: 'Avatar upload failed' }); - } -}); - -// GM ENDPOINTS - bypassing session validation with GM secret -function gmBypass(req, res, next) { - const gmSecret = req.headers['x-gm-secret']; - if (gmSecret === 'bongo') { - logToFile('SESSION: GM bypass accepted', req.method, req.url); - return next(); - } - logToFile('SESSION: GM bypass rejected - invalid secret', req.method, req.url); - return res.status(401).json({ error: 'GM access denied' }); -} - -// Add/update player (GM only) -router.post('/gm/add-or-update', gmBypass, (req, res) => { - try { - const { name, rp, pw } = req.body; - - if (!name) { - return res.status(400).json({ error: 'Player name is required' }); - } - - // Check if player exists - const existing = playerHelpers.getByName(name); - - if (existing) { - // Update existing player - const updates = { ...existing }; - if (rp !== undefined) updates.tabInfo = { ...updates.tabInfo, rp: parseInt(rp) }; - if (pw) updates.pwHash = require('bcrypt').hashSync(pw, 10); - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(name, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player' }); - - logToFile('GM: Updated player', name); - return res.json({ success: true, message: `Updated player ${name}` }); - } else { - // Create new player - const defaultRP = rp !== undefined ? parseInt(rp) : 50; - const password = pw || '1234'; - - const newPlayer = { - name, - tabInfo: { - rp: defaultRP, - renown: 'None', - xp: 0, - xpSpent: 0, - charName: `Brother ${name.charAt(0).toUpperCase() + name.slice(1)}` - }, - rollerInfo: {}, - shopInfo: {}, - pwHash: require('bcrypt').hashSync(password, 10), - pw: '' - }; - - const { valid, errors, normalized } = validatePlayer(newPlayer); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const saved = playerHelpers.create(normalized); - if (!saved) return res.status(500).json({ error: 'Failed to create player' }); - - logToFile('GM: Created player', name); - return res.json({ success: true, message: `Created player ${name}` }); - } - } catch (error) { - logToFile('GM: Add/update player failed', error); - res.status(500).json({ error: 'Failed to add/update player' }); - } -}); - -// Set RP (GM only) -router.post('/gm/set-rp', gmBypass, (req, res) => { - try { - const { playerName, requisitionPoints } = req.body; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const updates = { - ...player, - tabInfo: { ...player.tabInfo, rp: parseInt(requisitionPoints) } - }; - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(playerName, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player RP' }); - - logToFile('GM: Set RP for', playerName, 'to', requisitionPoints); - res.json({ success: true, message: `Set RP for ${playerName} to ${requisitionPoints}` }); - } catch (error) { - logToFile('GM: Set RP failed', error); - res.status(500).json({ error: 'Failed to set RP' }); - } -}); - -// Set XP (GM only) -router.post('/gm/set-xp', gmBypass, (req, res) => { - try { - const { playerName, xp } = req.body; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const updates = { - ...player, - tabInfo: { ...player.tabInfo, xp: parseInt(xp) } - }; - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(playerName, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player XP' }); - - logToFile('GM: Set XP for', playerName, 'to', xp); - res.json({ success: true, message: `Set XP for ${playerName} to ${xp}` }); - } catch (error) { - logToFile('GM: Set XP failed', error); - res.status(500).json({ error: 'Failed to set XP' }); - } -}); - -// Set XP Spent (GM only) -router.post('/gm/set-xp-spent', gmBypass, (req, res) => { - try { - const { playerName, xpSpent } = req.body; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const updates = { - ...player, - tabInfo: { ...player.tabInfo, xpSpent: parseInt(xpSpent) } - }; - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(playerName, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player XP Spent' }); - - logToFile('GM: Set XP Spent for', playerName, 'to', xpSpent); - res.json({ success: true, message: `Set XP Spent for ${playerName} to ${xpSpent}` }); - } catch (error) { - logToFile('GM: Set XP Spent failed', error); - res.status(500).json({ error: 'Failed to set XP Spent' }); - } -}); - -// Set Renown (GM only) -router.post('/gm/set-renown', gmBypass, (req, res) => { - try { - const { playerName, renown } = req.body; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const updates = { - ...player, - tabInfo: { ...player.tabInfo, renown } - }; - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(playerName, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to update player renown' }); - - logToFile('GM: Set renown for', playerName, 'to', renown); - res.json({ success: true, message: `Set renown for ${playerName} to ${renown}` }); - } catch (error) { - logToFile('GM: Set renown failed', error); - res.status(500).json({ error: 'Failed to set renown' }); - } -}); - -// Reset password (GM only) -router.post('/gm/reset-password', gmBypass, (req, res) => { - try { - const { playerName, newPassword } = req.body; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const password = newPassword || '1234'; - const updates = { - ...player, - pwHash: require('bcrypt').hashSync(password, 10), - pw: '' - }; - - const { valid, errors, normalized } = validatePlayer(updates); - if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); - - const ok = playerHelpers.update(playerName, normalized); - if (!ok) return res.status(500).json({ error: 'Failed to reset password' }); - - logToFile('GM: Reset password for', playerName); - res.json({ success: true, message: `Reset password for ${playerName}` }); - } catch (error) { - logToFile('GM: Reset password failed', error); - res.status(500).json({ error: 'Failed to reset password' }); - } -}); - -// Delete player (GM only) -router.delete('/gm/delete/:playerName', gmBypass, (req, res) => { - try { - const { playerName } = req.params; - - if (!playerName) { - return res.status(400).json({ error: 'Player name is required' }); - } - - const player = playerHelpers.getByName(playerName); - if (!player) { - return res.status(404).json({ error: 'Player not found' }); - } - - const ok = playerHelpers.delete(playerName); - if (!ok) return res.status(500).json({ error: 'Failed to delete player' }); - - logToFile('GM: Deleted player', playerName); - res.json({ success: true, message: `Deleted player ${playerName}` }); - } catch (error) { - logToFile('GM: Delete player failed', error); - res.status(500).json({ error: 'Failed to delete player' }); - } -}); - -module.exports = router; diff --git a/database/routes/playerRoutes.js b/database/routes/playerRoutes.js old mode 100755 new mode 100644 index c51abae..c9f5bf1 --- a/database/routes/playerRoutes.js +++ b/database/routes/playerRoutes.js @@ -1,151 +1,176 @@ - - const express = require('express'); -const Player = require('../playerModel'); -const fs = require('fs'); -const path = require('path'); - -// Simple file logger -function logToFile(...args) { - const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; - fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' }); -} - -const requireSession = require('../requireSession'); +const { playerHelpers, logToFile } = require('../mariadb'); const router = express.Router(); -// TEMP ADMIN: List all users -router.get('/admin/list', async (req, res) => { +// Login endpoint for players +router.post('/login', async (req, res) => { try { - const players = await Player.find(); - res.json(players); + const { name, password } = req.body; + console.log('Login attempt for player:', name); + + if (!name || !password) { + return res.status(400).json({ error: 'Name and password required' }); + } + + // Special handling for GM user + if (name.toLowerCase() === 'gm') { + if (password !== 'bongo') { + return res.status(401).json({ error: 'Invalid password' }); + } + } else { + // For regular players, use password '1234' + if (password !== '1234') { + return res.status(401).json({ error: 'Invalid password' }); + } + } + + const player = await playerHelpers.getByName(name); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + // Generate a simple session ID (in production, use proper session management) + const sessionId = `session_${name}_${Date.now()}`; + + logToFile('API: Player login', name, 'success'); + res.json({ + success: true, + sessionId, + player: { name: player.name } + }); } catch (error) { - res.status(500).json({ error: 'Failed to list players' }); + console.error('Login error:', error); + logToFile('API: Failed to login player', req.body?.name, error); + res.status(500).json({ error: String(error) }); } }); -// TEMP ADMIN: Delete test users (name contains 'test' or 'Test') -router.delete('/admin/delete-tests', async (req, res) => { +// Get player names for login dropdown (public - no session required) +router.get('/names', async (req, res) => { try { - const result = await Player.deleteMany({ name: /test/i }); - res.json({ deletedCount: result.deletedCount }); + console.log('Player names endpoint hit'); + const players = await playerHelpers.getAll(); + // Only return names for the login dropdown, not full player data + const playerNames = players.map(p => ({ name: p.name })); + res.json(playerNames); } catch (error) { - res.status(500).json({ error: 'Failed to delete test users' }); + console.error('Player names error:', error); + logToFile('API: Failed to get player names', error); + res.status(500).json({ error: String(error) }); } }); // Get all players (public for dropdown) router.get('/', async (req, res) => { try { - const players = await Player.find(); - logToFile('API: Fetch all players (public)', players); - // Only send name for dropdown if not authed - if (!req.headers['x-session-id']) { - return res.json(players.map(p => ({ name: p.name }))); + const players = await playerHelpers.getAll(); + logToFile('API: Fetch all players (public)', players.length); + + // If authenticated (has session header or x-gm-secret), return full player data + const hasSession = req.headers['x-session-id'] || req.headers['x-gm-secret']; + + if (hasSession) { + // Return full player data for authenticated requests + res.json(players); + } else { + // Only send name for dropdown if not authenticated + res.json(players.map(p => ({ name: p.name }))); } - res.json(players); } catch (error) { logToFile('API: Failed to fetch players', error); res.status(500).json({ error: 'Failed to fetch players' }); } }); -// Get a single player by name (require session) -router.get('/:name', requireSession, async (req, res) => { +// Get player by name +router.get('/:name', async (req, res) => { try { - const player = await Player.findOne({ name: req.params.name }); - logToFile('API: Fetch player', req.params.name, player); + const { name } = req.params; + console.log('Getting player:', name); + + const player = await playerHelpers.getByName(name); if (!player) { return res.status(404).json({ error: 'Player not found' }); } + + logToFile('API: Fetch player', name, 'success'); res.json(player); } catch (error) { - logToFile('API: Failed to fetch player', req.params.name, error); - res.status(500).json({ error: 'Failed to fetch player' }); + console.error('Get player error:', error); + logToFile('API: Failed to get player', req.params.name, error); + res.status(500).json({ error: String(error) }); } }); -// Helper to flatten tabInfo -function flattenTabInfo(tabInfo) { - let t = tabInfo; - while (t && t.tabInfo) t = t.tabInfo; - return { ...t }; -} +// Update player data +router.put('/:name', async (req, res) => { + try { + const { name } = req.params; + const playerData = req.body; + + console.log('Updating player:', name); + + const success = await playerHelpers.update(name, playerData); + if (!success) { + return res.status(500).json({ error: 'Failed to update player' }); + } + + logToFile('API: Updated player', name, 'success'); + res.json({ success: true, message: 'Player updated successfully' }); + } catch (error) { + console.error('Update player error:', error); + logToFile('API: Failed to update player', req.params.name, error); + res.status(500).json({ error: String(error) }); + } +}); -// Create a new player +// Create new player router.post('/', async (req, res) => { try { - logToFile('API: Creating player', req.body); - const body = { ...req.body }; - if (body.tabInfo) body.tabInfo = flattenTabInfo(body.tabInfo); - const newPlayer = new Player(body); - await newPlayer.save(); - logToFile('API: Player created', newPlayer); - res.status(201).json(newPlayer); + const playerData = req.body; + + console.log('Creating player:', playerData.name); + + // Check if player already exists + const existing = await playerHelpers.getByName(playerData.name); + if (existing) { + return res.status(409).json({ error: 'Player already exists' }); + } + + const playerId = await playerHelpers.create(playerData); + if (!playerId) { + return res.status(500).json({ error: 'Failed to create player' }); + } + + logToFile('API: Created player', playerData.name, 'success'); + res.json({ success: true, id: playerId, message: 'Player created successfully' }); } catch (error) { - logToFile('API: Failed to create player', error); - res.status(400).json({ error: 'Failed to create player' }); + console.error('Create player error:', error); + logToFile('API: Failed to create player', req.body.name, error); + res.status(500).json({ error: String(error) }); } }); -// Update a player (require session) -router.put('/:name', requireSession, async (req, res) => { +// Delete player +router.delete('/:name', async (req, res) => { try { - logToFile('API: Updating player', req.params.name, req.body); - // Always extract pw and pwHash from any location in the request - let pw, pwHash; - let tabInfo = req.body.tabInfo; - if (req.body.pw !== undefined) pw = req.body.pw; - if (req.body.pwHash !== undefined) pwHash = req.body.pwHash; - if (tabInfo !== undefined) { - if (tabInfo.pw !== undefined) pw = tabInfo.pw; - if (tabInfo.pwHash !== undefined) pwHash = tabInfo.pwHash; - if (tabInfo.tabInfo) { - if (tabInfo.tabInfo.pw !== undefined) pw = tabInfo.tabInfo.pw; - if (tabInfo.tabInfo.pwHash !== undefined) pwHash = tabInfo.tabInfo.pwHash; - } - tabInfo = flattenTabInfo(tabInfo); + const { name } = req.params; + + console.log('Deleting player:', name); + + const success = await playerHelpers.delete(name); + if (!success) { + return res.status(404).json({ error: 'Player not found or could not be deleted' }); } - const update = {}; - if (pw !== undefined) update.pw = pw; - if (pwHash !== undefined) update.pwHash = pwHash; - if (tabInfo !== undefined) update.tabInfo = tabInfo; - if (Object.keys(update).length === 0) { - logToFile('API: No valid fields to update for player', req.params.name); - return res.status(400).json({ error: 'No valid fields to update' }); - } - const updatedPlayer = await Player.findOneAndUpdate( - { name: req.params.name }, - update, - { new: true } - ); - if (!updatedPlayer) { - logToFile('API: Player not found for update', req.params.name); - return res.status(404).json({ error: 'Player not found' }); - } - logToFile('API: Player updated', updatedPlayer); - res.json(updatedPlayer); + + logToFile('API: Deleted player', name, 'success'); + res.json({ success: true, message: 'Player deleted successfully' }); } catch (error) { - logToFile('API: Failed to update player', error); - res.status(400).json({ error: 'Failed to update player' }); - } -}); - -// Delete a player (require session) -router.delete('/:name', requireSession, async (req, res) => { - try { - logToFile('API: Deleting player', req.params.name); - const deletedPlayer = await Player.findOneAndDelete({ name: req.params.name }); - if (!deletedPlayer) { - logToFile('API: Player not found for delete', req.params.name); - return res.status(404).json({ error: 'Player not found' }); - } - logToFile('API: Player deleted', req.params.name); - res.status(204).send(); - } catch (error) { - logToFile('API: Failed to delete player', error); - res.status(500).json({ error: 'Failed to delete player' }); + console.error('Delete player error:', error); + logToFile('API: Failed to delete player', req.params.name, error); + res.status(500).json({ error: String(error) }); } }); +console.log('Player routes registered (MariaDB)'); module.exports = router; diff --git a/database/routes/rulesRoutes.js b/database/routes/rulesRoutes.js index 2e8ada2..4ab6422 100644 --- a/database/routes/rulesRoutes.js +++ b/database/routes/rulesRoutes.js @@ -1,10 +1,10 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); -const { db } = require('../sqlite-db'); +const { rulesHelpers, logToFile } = require('../mariadb'); const router = express.Router(); -console.log('Rules routes registered (sqlite-backed)'); +console.log('Rules routes registered (MariaDB)'); // Helpers to clean up OCR/extracted text function cleanText(s) { @@ -32,62 +32,95 @@ function cleanTitle(t) { return s; } -// We will query sqlite `rules` table on demand; helper to fetch all rules -function getAllRules() { +// Fetch all rules from MariaDB +async function getAllRules() { try { - const rows = db.prepare('SELECT id, rule_id, title, content, page, source, source_abbr, category FROM rules ORDER BY id').all(); - return rows.map(r => ({ id: r.id, rule_id: r.rule_id, title: cleanTitle(r.title), content: cleanText(r.content), page: r.page, source: r.source, sourceAbbr: r.source_abbr, category: r.category })); + const rows = await rulesHelpers.getAll(); + return rows.map(r => ({ + id: r.id, + rule_id: r.rule_id, + title: cleanTitle(r.title), + content: cleanText(r.content), + page: r.page_num, + source: r.source, + sourceAbbr: r.source_abbr, + category: r.category + })); } catch (e) { - console.error('Failed to read rules from sqlite:', e); + console.error('Failed to read rules from MariaDB:', e); + logToFile('Error getting all rules:', e); return []; } } -function getRuleById(ruleId) { + +async function getRuleById(ruleId) { try { - const row = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE rule_id = ?').get(ruleId); - if (!row) return null; - return { id: row.id, title: cleanTitle(row.title), content: cleanText(row.content), page: row.page, source: row.source, sourceAbbr: row.sourceAbbr, category: row.category }; + const rows = await rulesHelpers.getAll(); + const row = rows.find(r => r.rule_id === ruleId || r.id === ruleId); + if (!row) return null; + return { + id: row.rule_id || row.id, + title: cleanTitle(row.title), + content: cleanText(row.content), + page: row.page_num, + source: row.source, + sourceAbbr: row.source_abbr, + category: row.category + }; } catch (e) { console.error('Failed to read rule by id:', e); + logToFile('Error getting rule by id:', ruleId, e); return null; } } // Get all rule categories -router.get('/categories', (req, res) => { +router.get('/categories', async (req, res) => { try { - const rows = getAllRules(); - const categories = [...new Set(rows.map(r => r.category).filter(Boolean))]; - const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) })); - res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]); + const rows = await getAllRules(); + const categories = [...new Set(rows.map(r => r.category).filter(Boolean))]; + const categoryList = categories.map(cat => ({ id: cat, name: cleanTitle(cat) })); + res.json([{ id: 'all', name: 'All Rules' }, ...categoryList]); } catch (error) { console.error('Categories error:', error); res.status(500).json({ error: 'Failed to get categories' }); } }); -// Search rules (sqlite-backed) -router.get('/search', (req, res) => { +// Search rules (MariaDB) +router.get('/search', async (req, res) => { try { const { q: query, category, limit = 20 } = req.query; if (!query || !query.trim()) return res.json([]); const limitInt = Math.max(1, parseInt(limit) || 20); - const term = `%${query}%`; - - let rows; - if (category && category !== 'all') { - rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) AND category = ? ORDER BY id LIMIT ?').all(term, term, category, limitInt); - if (!rows || rows.length === 0) { - rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) ORDER BY id LIMIT ?').all(term, term, limitInt); - } - } else { - rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE (title LIKE ? OR content LIKE ?) ORDER BY id LIMIT ?').all(term, term, limitInt); + + const allRules = await getAllRules(); + const term = query.toLowerCase(); + + let filtered = allRules.filter(rule => { + const titleMatch = rule.title && rule.title.toLowerCase().includes(term); + const contentMatch = rule.content && rule.content.toLowerCase().includes(term); + const categoryMatch = !category || category === 'all' || rule.category === category; + + return (titleMatch || contentMatch) && categoryMatch; + }); + + // If category filtering yielded no results, try without category filter + if (filtered.length === 0 && category && category !== 'all') { + filtered = allRules.filter(rule => { + const titleMatch = rule.title && rule.title.toLowerCase().includes(term); + const contentMatch = rule.content && rule.content.toLowerCase().includes(term); + return titleMatch || contentMatch; + }); } - - const results = (rows || []).map(r => ({ ...r, content: r.content && r.content.length > 300 ? r.content.substring(0,300) + '...' : r.content })); - // Clean text fields before returning - const cleaned = (results || []).map(r => ({ ...r, title: cleanTitle(r.title), content: r.content ? cleanText(r.content) : r.content })); - res.json(cleaned); + + // Limit results and truncate content + const results = filtered.slice(0, limitInt).map(r => ({ + ...r, + content: r.content && r.content.length > 300 ? r.content.substring(0, 300) + '...' : r.content + })); + + res.json(results); } catch (error) { console.error('Search error:', error); res.status(500).json({ error: 'Search failed' }); @@ -95,10 +128,10 @@ router.get('/search', (req, res) => { }); // Get a specific rule by ID -router.get('/rule/:id', (req, res) => { +router.get('/rule/:id', async (req, res) => { try { const { id } = req.params; - const rule = getRuleById(id); + const rule = await getRuleById(id); if (!rule) return res.status(404).json({ error: 'Rule not found' }); res.json(rule); } catch (error) { @@ -108,20 +141,26 @@ router.get('/rule/:id', (req, res) => { }); // Get random rules for discovery -router.get('/random', (req, res) => { +router.get('/random', async (req, res) => { try { const { count = 5, category } = req.query; const max = Math.max(1, parseInt(count) || 5); - let rows; + + const allRules = await getAllRules(); + let filtered = allRules; + if (category && category !== 'all') { - rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE category = ? ORDER BY RANDOM() LIMIT ?').all(category, max); - } else { - rows = db.prepare('SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules ORDER BY RANDOM() LIMIT ?').all(max); + filtered = allRules.filter(rule => rule.category === category); } - const randomRules = (rows || []).map(r => ({ ...r, content: r.content && r.content.length > 200 ? r.content.substring(0,200) + '...' : r.content })); - // Clean before responding - const cleaned = (randomRules || []).map(r => ({ ...r, title: cleanTitle(r.title), content: r.content ? cleanText(r.content) : r.content })); - res.json(cleaned); + + // Shuffle and pick random rules + const shuffled = filtered.sort(() => 0.5 - Math.random()); + const randomRules = shuffled.slice(0, max).map(r => ({ + ...r, + content: r.content && r.content.length > 200 ? r.content.substring(0, 200) + '...' : r.content + })); + + res.json(randomRules); } catch (error) { console.error('Random rules error:', error); res.status(500).json({ error: 'Failed to get random rules' }); @@ -129,11 +168,26 @@ router.get('/random', (req, res) => { }); // Get rules statistics -router.get('/stats', (req, res) => { +router.get('/stats', async (req, res) => { try { - const totalRules = db.prepare('SELECT COUNT(*) as c FROM rules').get().c; - const categories = db.prepare('SELECT category, COUNT(*) as c FROM rules GROUP BY category').all(); - const sources = db.prepare('SELECT source, COUNT(*) as c FROM rules GROUP BY source').all(); + const allRules = await getAllRules(); + const totalRules = allRules.length; + + const categoryCount = {}; + const sourceCount = {}; + + allRules.forEach(rule => { + if (rule.category) { + categoryCount[rule.category] = (categoryCount[rule.category] || 0) + 1; + } + if (rule.source) { + sourceCount[rule.source] = (sourceCount[rule.source] || 0) + 1; + } + }); + + const categories = Object.entries(categoryCount).map(([category, c]) => ({ category, c })); + const sources = Object.entries(sourceCount).map(([source, c]) => ({ source, c })); + res.json({ totalRules, categories, sources, searchTerms: 0 }); } catch (error) { console.error('Stats error:', error); @@ -142,12 +196,15 @@ router.get('/stats', (req, res) => { }); // Reload the rules database (admin only) -router.post('/reload', (req, res) => { +router.post('/reload', async (req, res) => { try { const gmSecret = req.headers['x-gm-secret']; if (gmSecret !== 'bongo') return res.status(403).json({ error: 'Unauthorized' }); - const totalRules = db.prepare('SELECT COUNT(*) as c FROM rules').get().c; - res.json({ success: true, totalRules, message: 'Rules are sqlite-backed; no reload necessary' }); + + const allRules = await getAllRules(); + const totalRules = allRules.length; + + res.json({ success: true, totalRules, message: 'Rules are MariaDB-backed; already loaded' }); } catch (error) { console.error('Reload error:', error); res.status(500).json({ error: 'Failed to reload database' }); diff --git a/database/routes/rulesStagingRoutes.js b/database/routes/rulesStagingRoutes.js index 380d5b8..f888316 100644 --- a/database/routes/rulesStagingRoutes.js +++ b/database/routes/rulesStagingRoutes.js @@ -1,11 +1,11 @@ const express = require('express'); const router = express.Router(); -const { db, logToFile, stagingHelpers } = require('../sqlite-db'); +const { stagingHelpers, rulesHelpers, logToFile } = require('../mariadb'); // List staged sanitized rules -router.get('/', (req, res) => { +router.get('/', async (req, res) => { try { - const rows = stagingHelpers.list(); + const rows = await stagingHelpers.getAll(); res.json(rows); } catch (e) { logToFile('staging:list:error', e && e.message); @@ -14,18 +14,30 @@ router.get('/', (req, res) => { }); // Approve staged rules: insert into rules table (appends) and clear staging -router.post('/approve', (req, res) => { +router.post('/approve', async (req, res) => { try { - const rows = stagingHelpers.list(); - const insert = db.prepare(`INSERT INTO rules (rule_id,title,content,page,source,source_abbr,category,created_at) VALUES (?,?,?,?,?,?,?,datetime('now'))`); - const insertMany = db.transaction((items) => { - items.forEach((it) => { - insert.run(null, it.title || '', it.content || '', it.page || '', 'sanitized', 'SAN', it.category || null); - }); - }); - insertMany(rows); - stagingHelpers.clear(); - res.json({ success: true, inserted: rows.length }); + const rows = await stagingHelpers.getAll(); + let inserted = 0; + + for (const rule of rows) { + const ruleData = { + title: rule.title || '', + content: rule.content || '', + page_num: rule.page || '', + source: 'sanitized', + source_abbr: 'SAN', + category: rule.category || null, + rulebook: 'staging' + }; + + const result = await rulesHelpers.create(ruleData); + if (result) { + inserted++; + await stagingHelpers.delete(rule.id); + } + } + + res.json({ success: true, inserted }); } catch (e) { logToFile('staging:approve:error', e && e.stack ? e.stack : e); res.status(500).json({ error: e.message }); @@ -33,9 +45,12 @@ router.post('/approve', (req, res) => { }); // Clear staging without approving -router.delete('/', (req, res) => { +router.delete('/', async (req, res) => { try { - const del = stagingHelpers.clear(); + const rows = await stagingHelpers.getAll(); + for (const rule of rows) { + await stagingHelpers.delete(rule.id); + } res.json({ success: true }); } catch (e) { logToFile('staging:clear:error', e && e.message); diff --git a/database/routes/sessionRoutes-sqlite.js b/database/routes/sessionRoutes-sqlite.js deleted file mode 100644 index ffbb79c..0000000 --- a/database/routes/sessionRoutes-sqlite.js +++ /dev/null @@ -1,111 +0,0 @@ -const express = require('express'); -const { sessionHelpers, playerHelpers } = require('../sqlite-db'); -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); - -// Simple file logger -function logToFile(...args) { - const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; - fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' }); -} - -const router = express.Router(); - -// Create a new session (login) -router.post('/login', async (req, res) => { - try { - const { playerName, username, password } = req.body; - const name = playerName || username; // Accept either field name - - if (!name) { - logToFile('SESSION: Login missing playerName/username'); - return res.status(400).json({ error: 'playerName or username required' }); - } - - // Check if player exists - const player = playerHelpers.getByName(name); - if (!player) { - logToFile('SESSION: Login player not found', name); - return res.status(401).json({ error: 'Invalid credentials' }); - } - - // Check password if provided - if (password) { - // For now, compare with plain text (you can add bcrypt later) - const playerPw = player.pw || ''; - if (playerPw !== password) { - logToFile('SESSION: Login invalid password', name); - return res.status(401).json({ error: 'Invalid credentials' }); - } - } - - // Create session - const sessionId = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(); // 24h - - sessionHelpers.create(sessionId, { playerName: name }, expiresAt); - - logToFile('SESSION: Login success', name, sessionId); - res.json({ sessionId, expiresAt, playerName: name, success: true }); - } catch (error) { - logToFile('SESSION: Login error', error); - res.status(500).json({ error: 'Login failed' }); - } -}); - -// Validate session -router.post('/validate', async (req, res) => { - try { - const { sessionId } = req.body; - - if (!sessionId) { - logToFile('SESSION: Validate missing sessionId'); - return res.status(400).json({ error: 'sessionId required' }); - } - - const session = sessionHelpers.get(sessionId); - - if (!session) { - logToFile('SESSION: Validate not found', sessionId); - return res.status(401).json({ error: 'Invalid or expired session' }); - } - - logToFile('SESSION: Validate success', sessionId, session.data.playerName); - res.json({ valid: true, playerName: session.data.playerName }); - } catch (error) { - logToFile('SESSION: Validate error', error); - res.status(500).json({ error: 'Validation failed' }); - } -}); - -// Logout (delete session) -router.post('/logout', async (req, res) => { - try { - const { sessionId } = req.body; - - if (sessionId) { - sessionHelpers.delete(sessionId); - logToFile('SESSION: Logout success', sessionId); - } - - res.json({ success: true }); - } catch (error) { - logToFile('SESSION: Logout error', error); - res.status(500).json({ error: 'Logout failed' }); - } -}); - -// Clean expired sessions (can be called periodically) -router.post('/cleanup', async (req, res) => { - try { - const deletedCount = sessionHelpers.cleanExpired(); - logToFile('SESSION: Cleanup completed', `${deletedCount} sessions removed`); - res.json({ deletedCount }); - } catch (error) { - logToFile('SESSION: Cleanup error', error); - res.status(500).json({ error: 'Cleanup failed' }); - } -}); - -module.exports = router; diff --git a/database/routes/sessionRoutes.js b/database/routes/sessionRoutes.js index 6b351a2..274d082 100644 --- a/database/routes/sessionRoutes.js +++ b/database/routes/sessionRoutes.js @@ -1,50 +1,37 @@ const express = require('express'); -const Session = require('../sessionModel'); +const { logToFile } = require('../mariadb'); const router = express.Router(); -const crypto = require('crypto'); -// Create a new session (login) -router.post('/login', async (req, res) => { - const { playerName } = req.body; - if (!playerName) return res.status(400).json({ error: 'playerName required' }); - const sessionId = crypto.randomBytes(32).toString('hex'); - const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24); // 24h - const session = new Session({ sessionId, playerName, expiresAt }); - await session.save(); - res.json({ sessionId, expiresAt }); -}); - -// Validate session -const fs = require('fs'); -const path = require('path'); -function logToFile(...args) { - const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; - fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' }); -} +// Simple session validation endpoint router.post('/validate', async (req, res) => { - const { sessionId } = req.body; - if (!sessionId) { - logToFile('SESSION: Validate missing sessionId'); - return res.status(400).json({ error: 'sessionId required' }); + try { + const { sessionId } = req.body; + + if (!sessionId) { + logToFile('SESSION: Validate missing sessionId'); + return res.status(400).json({ error: 'sessionId required' }); + } + + // Extract player name from session ID (simple format: session_playername_timestamp) + const match = sessionId.match(/^session_([^_]+)_\d+$/); + if (!match) { + logToFile('SESSION: Invalid session format', sessionId); + return res.status(401).json({ error: 'Invalid session format' }); + } + + const playerName = match[1]; + + logToFile('SESSION: Session validation successful', playerName); + res.json({ + valid: true, + playerName + }); + } catch (error) { + console.error('Session validation error:', error); + logToFile('SESSION: Failed to validate session', error); + res.status(500).json({ error: String(error) }); } - const session = await Session.findOne({ sessionId }); - if (!session) { - logToFile('SESSION: Validate not found', sessionId); - return res.status(401).json({ error: 'Invalid or expired session' }); - } - if (session.expiresAt < new Date()) { - logToFile('SESSION: Validate expired', sessionId); - return res.status(401).json({ error: 'Invalid or expired session' }); - } - logToFile('SESSION: Validate success', sessionId, session.playerName); - res.json({ valid: true, playerName: session.playerName }); -}); - -// Logout (delete session) -router.post('/logout', async (req, res) => { - const { sessionId } = req.body; - await Session.deleteOne({ sessionId }); - res.json({ success: true }); }); +console.log('Session routes registered (simple validation)'); module.exports = router; diff --git a/database/routes/shopRoutes.js b/database/routes/shopRoutes.js index e429179..b7b20af 100644 --- a/database/routes/shopRoutes.js +++ b/database/routes/shopRoutes.js @@ -2,6 +2,15 @@ const express = require('express'); const fs = require('fs'); const path = require('path'); const router = express.Router(); +const { playerHelpers } = require('../mariadb'); // Use MariaDB instead of SQLite + +console.log('ShopRoutes: Loading with purchase endpoint (MariaDB)'); + +// Simple file logger +function logToFile(...args) { + const msg = `[${new Date().toISOString()}] ` + args.map(a => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' ') + '\n'; + fs.appendFileSync(path.join(__dirname, '../backend.log'), msg, { encoding: 'utf8' }); +} // Minimal shop index - attempt to serve a generated armoury/shop JSON if present router.get('/', (req, res) => { @@ -18,4 +27,125 @@ router.get('/', (req, res) => { } }); +// Test route to verify routes are working +router.get('/test', (req, res) => { + res.json({ message: 'Shop routes are working' }); +}); + +// Purchase item endpoint +router.post('/purchase', async (req, res) => { + console.log('ShopRoutes: Purchase endpoint hit!'); + try { + const { playerId, itemId, quantity = 1 } = req.body; + const sessionId = req.headers['x-session-id']; + + if (!sessionId) { + return res.status(401).json({ error: 'Session required' }); + } + + if (!playerId || !itemId) { + return res.status(400).json({ error: 'Player ID and Item ID required' }); + } + + // Get the player + const players = await playerHelpers.getAll(); + const player = players.find(p => p.id === playerId || p.name === playerId); + + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + // Get shop data to find the item + const shopPath = path.join(__dirname, '..', '..', 'public', 'deathwatch-armoury.json'); + if (!fs.existsSync(shopPath)) { + return res.status(500).json({ error: 'Shop data not available' }); + } + + const shopData = JSON.parse(fs.readFileSync(shopPath, 'utf8')); + let foundItem = null; + + // Search for the item in all categories + for (const category in shopData.items) { + if (shopData.items[category]) { + foundItem = shopData.items[category].find(item => + (item.id === itemId) || (`${category}-${item.name}` === itemId) + ); + if (foundItem) break; + } + } + + if (!foundItem) { + return res.status(404).json({ error: 'Item not found in shop' }); + } + + // Check if item has cost > 0 (purchasable) + const itemCost = foundItem.req || 0; + if (itemCost <= 0) { + return res.status(400).json({ error: 'This item is not purchasable' }); + } + + // Check player RP + const playerRp = player.tabInfo?.rp || 0; + const totalCost = itemCost * quantity; + + if (playerRp < totalCost) { + return res.status(400).json({ + error: `Insufficient Requisition Points. Need ${totalCost} RP but only have ${playerRp} RP.` + }); + } + + // Add item to player gear and deduct RP + const updatedTabInfo = { ...player.tabInfo }; + updatedTabInfo.rp = playerRp - totalCost; + + // Initialize gear if it doesn't exist + if (!updatedTabInfo.gear) { + updatedTabInfo.gear = []; + } + + // Create gear item in the format expected by character sheet + const gearItem = { + name: foundItem.name, + qty: quantity + }; + + // Check if item already exists in gear + const existingGearIndex = updatedTabInfo.gear.findIndex(gearItem => + gearItem.name === foundItem.name + ); + + if (existingGearIndex >= 0) { + // Update existing item quantity + updatedTabInfo.gear[existingGearIndex].qty += quantity; + } else { + // Add new item to gear + updatedTabInfo.gear.push(gearItem); + } + + // Update player data + const success = await playerHelpers.update(player.name, { + ...player, + tabInfo: updatedTabInfo + }); + + if (!success) { + return res.status(500).json({ error: 'Failed to update player data' }); + } + + logToFile(`Purchase: ${player.name} bought ${quantity}x ${foundItem.name} for ${totalCost} RP`); + + res.json({ + success: true, + message: `Successfully purchased ${quantity}x ${foundItem.name} for ${totalCost} RP`, + newRp: updatedTabInfo.rp, + item: gearItem + }); + + } catch (error) { + console.error('Purchase error:', error); + logToFile('API: Purchase failed', error); + res.status(500).json({ error: 'Purchase failed' }); + } +}); + module.exports = router; diff --git a/database/routes/weaponsRoutes.js b/database/routes/weaponsRoutes.js index 6aa4898..8ddd3c5 100644 --- a/database/routes/weaponsRoutes.js +++ b/database/routes/weaponsRoutes.js @@ -1,19 +1,24 @@ const express = require('express') const router = express.Router() -const { db } = require('../sqlite-db') +const { weaponsHelpers, logToFile } = require('../mariadb') // Return weapons in a normalized shape -router.get('/', (req, res) => { +router.get('/', async (req, res) => { try { - const rows = db.prepare('SELECT id,name,category,stats,source FROM weapons ORDER BY name').all() + const rows = await weaponsHelpers.getAll() const parsed = rows.map(r => { let stats = {} - try { stats = JSON.parse(r.stats || '{}') } catch (e) {} + try { + stats = typeof r.stats === 'string' ? JSON.parse(r.stats) : r.stats || {} + } catch (e) { + logToFile('Error parsing weapon stats for', r.name, e) + } return { id: r.id, name: r.name, category: r.category, stats: stats, source: r.source } }) res.json(parsed) } catch (e) { - console.error('Failed to load weapons from sqlite:', e) + console.error('Failed to load weapons from MariaDB:', e) + logToFile('Error getting weapons:', e) res.status(500).json({ error: 'Failed to get weapons' }) } }) diff --git a/database/server-sqlite.js b/database/server-sqlite.js deleted file mode 100644 index 841ae0c..0000000 --- a/database/server-sqlite.js +++ /dev/null @@ -1,64 +0,0 @@ -require('dotenv').config(); -const express = require('express'); -const cors = require('cors'); -const fs = require('fs'); -const path = require('path'); -const playerRoutes = require('./routes/playerRoutes-sqlite'); -const sessionRoutes = require('./routes/sessionRoutes-sqlite'); - -const app = express(); -const PORT = process.env.PORT || 5000; - -// Middleware -app.use(express.json()); -app.use(cors()); -app.use(express.static('public')); // Serve files from public directory - -// Initialize SQLite database -const { db } = require('./sqlite-db'); - -// Graceful shutdown -process.on('SIGINT', () => { - console.log('Closing SQLite database...'); - db.close(); - process.exit(0); -}); - -process.on('SIGTERM', () => { - console.log('Closing SQLite database...'); - db.close(); - process.exit(0); -}); - -// Root route for friendly message -app.get('/', (req, res) => { - res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.'); -}); - -// Shop endpoint -app.get('/api/shop', (req, res) => { - try { - console.log('Shop endpoint hit'); - const filepath = path.join(__dirname, '../public/deathwatch-armoury.json'); - console.log('Looking for shop data at:', filepath); - console.log('File exists:', fs.existsSync(filepath)); - const shopData = JSON.parse(fs.readFileSync(filepath, 'utf8')); - console.log('Shop data loaded, keys:', Object.keys(shopData)); - res.json(shopData); - } catch (error) { - console.error('Shop error:', error); - res.status(500).json({ error: String(error) }); - } -}); - -// Use routes -app.use('/api/players', playerRoutes); -app.use('/api/sessions', sessionRoutes); - -// Start Server -app.listen(PORT, '0.0.0.0', () => { - console.log(`Server running on http://0.0.0.0:${PORT}`); - console.log('Using SQLite database'); -}); - -module.exports = app; diff --git a/database/server.js b/database/server.js index 7005742..b4c7fe5 100755 --- a/database/server.js +++ b/database/server.js @@ -3,8 +3,9 @@ const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); -const playerRoutes = require('./routes/playerRoutes-sqlite'); -const sessionRoutes = require('./routes/sessionRoutes-sqlite'); +// MariaDB routes +const playerRoutes = require('./routes/playerRoutes'); +const sessionRoutes = require('./routes/sessionRoutes'); const shopRoutes = require('./routes/shopRoutes'); const rulesRoutes = require('./routes/rulesRoutes'); const bestiaryRoutes = require('./routes/bestiaryRoutes'); @@ -14,12 +15,14 @@ const rulesStagingRoutes = require('./routes/rulesStagingRoutes'); const gmkitDir = path.join(__dirname, '..', 'data', 'gamemasters_kit'); +// Initialize MariaDB +require('./mariadb'); + console.log('Routes loaded:', { - playerRoutes: typeof playerRoutes, - sessionRoutes: typeof sessionRoutes, shopRoutes: typeof shopRoutes, rulesRoutes: typeof rulesRoutes, - bestiaryRoutes: typeof bestiaryRoutes + bestiaryRoutes: typeof bestiaryRoutes, + weaponsRoutes: typeof weaponsRoutes }); const app = express(); @@ -31,6 +34,7 @@ app.use(cors()); // API Routes (before static files) console.log('Registering API routes...'); +// Player routes now working with MariaDB try { console.log('Registering /api/players'); app.use('/api/players', playerRoutes); @@ -62,7 +66,25 @@ try { } catch (e) { console.error('Error mounting /api/rules:', e && e.stack ? e.stack : e); throw e; -} + } + + try { + console.log('Registering /api/weapons'); + app.use('/api/weapons', weaponsRoutes); + console.log('Weapons routes registered'); + } catch (e) { + console.error('Error mounting /api/weapons:', e && e.stack ? e.stack : e); + throw e; + } + + try { + console.log('Registering /api/bestiary'); + app.use('/api/bestiary', bestiaryRoutes); + console.log('Bestiary routes registered'); + } catch (e) { + console.error('Error mounting /api/bestiary:', e && e.stack ? e.stack : e); + throw e; + } try { console.log('Registering /api/rules/staging'); @@ -70,9 +92,7 @@ try { console.log('Rules staging routes registered'); } catch (e) { console.error('Error mounting /api/rules/staging:', e && e.stack ? e.stack : e); - } - - // Expose gamemaster kit files and a simple listing API for GM-only resources + } // Expose gamemaster kit files and a simple listing API for GM-only resources try { console.log('Registering /api/gmkit and /gmkit static'); app.get('/api/gmkit/list', (req, res) => { @@ -157,19 +177,14 @@ app.use('/avatars', express.static(avatarsDir)); // res.sendFile(path.join(buildDir, 'index.html')); // }); -// Initialize SQLite database -const { db } = require('./sqlite-db'); - -// Graceful shutdown +// Graceful shutdown - MariaDB connections are handled by the pool process.on('SIGINT', () => { - console.log('Closing SQLite database...'); - db.close(); + console.log('Shutting down server...'); process.exit(0); }); process.on('SIGTERM', () => { - console.log('Closing SQLite database...'); - db.close(); + console.log('Shutting down server...'); process.exit(0); }); @@ -188,34 +203,16 @@ if (fs.existsSync(indexHtml)) { } else { // Root route for friendly message when no build is present app.get('/', (req, res) => { - res.send('Deathwatch Roller API is running with SQLite. Use /api/players for player data.'); + res.send('Deathwatch Roller API is running with MariaDB. Use /api/shop for shop data.'); }); } -// Use routes (instrument mounts to debug invalid route patterns) -console.log('Mounting route: /api/shop'); -app.use('/api/shop', shopRoutes); -console.log('Mounted /api/shop'); -console.log('Mounting route: /api/players'); -app.use('/api/players', playerRoutes); -console.log('Mounted /api/players'); -console.log('Mounting route: /api/sessions'); -app.use('/api/sessions', sessionRoutes); -console.log('Mounted /api/sessions'); -console.log('Mounting route: /api/rules'); -app.use('/api/rules', rulesRoutes); -console.log('Mounted /api/rules'); -console.log('Mounting route: /api/bestiary'); -app.use('/api/bestiary', bestiaryRoutes); -console.log('Mounted /api/bestiary'); -console.log('Mounting route: /api/weapons'); -app.use('/api/weapons', weaponsRoutes); -console.log('Mounted /api/weapons'); +// Routes have already been registered above - no need to duplicate // Start Server app.listen(PORT, '0.0.0.0', () => { console.log(`Server running on http://0.0.0.0:${PORT}`); - console.log('Using SQLite database'); + console.log('Using MariaDB database'); }); module.exports = app; diff --git a/database/sqlite-db.db b/database/sqlite-db.db deleted file mode 100644 index e69de29..0000000 diff --git a/database/sqlite-db.js b/database/sqlite-db.js deleted file mode 100644 index 96a96a4..0000000 --- a/database/sqlite-db.js +++ /dev/null @@ -1,272 +0,0 @@ -const Database = require('better-sqlite3'); -const path = require('path'); -const fs = require('fs'); - -// 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); - } -} - -// Create database directory if it doesn't exist -const dbDir = path.join(__dirname, 'sqlite'); -if (!fs.existsSync(dbDir)) { - fs.mkdirSync(dbDir, { recursive: true }); -} - -const dbPath = path.join(dbDir, 'deathwatch.db'); -const db = new Database(dbPath); - -// Enable WAL mode for better concurrency -db.pragma('journal_mode = WAL'); - -// Create tables -const createTables = () => { - // Players table - db.exec(` - CREATE TABLE IF NOT EXISTS players ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - roller_info TEXT DEFAULT '{}', - shop_info TEXT DEFAULT '{}', - tab_info TEXT DEFAULT '{}', - pw TEXT DEFAULT '', - pw_hash TEXT DEFAULT '', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Sessions table (if you need it) - db.exec(` - CREATE TABLE IF NOT EXISTS sessions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT UNIQUE NOT NULL, - data TEXT DEFAULT '{}', - expires_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - - // Create indexes for better performance - db.exec(` - CREATE INDEX IF NOT EXISTS idx_players_name ON players(name); - CREATE INDEX IF NOT EXISTS idx_sessions_session_id ON sessions(session_id); - CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); - CREATE TABLE IF NOT EXISTS rules_staging ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - title TEXT, - content TEXT, - category TEXT, - page TEXT, - original_json TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_rules_staging_category ON rules_staging(category); - `); - - console.log('SQLite tables created successfully'); -}; - -// Initialize database -createTables(); - -// Prepared statements for common operations -const statements = { - // Player operations - getAllPlayers: db.prepare('SELECT * FROM players ORDER BY name'), - getPlayerByName: db.prepare('SELECT * FROM players WHERE name = ?'), - insertPlayer: db.prepare(` - INSERT INTO players (name, roller_info, shop_info, tab_info, pw, pw_hash) - VALUES (?, ?, ?, ?, ?, ?) - `), - updatePlayer: db.prepare(` - UPDATE players - SET roller_info = ?, shop_info = ?, tab_info = ?, pw = ?, pw_hash = ?, updated_at = CURRENT_TIMESTAMP - WHERE name = ? - `), - deletePlayer: db.prepare('DELETE FROM players WHERE name = ?'), - - // Session operations - getSession: db.prepare('SELECT * FROM sessions WHERE session_id = ? AND expires_at > datetime(\'now\')'), - insertSession: db.prepare(` - INSERT INTO sessions (session_id, data, expires_at) - VALUES (?, ?, ?) - `), - updateSession: db.prepare(` - UPDATE sessions - SET data = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP - WHERE session_id = ? - `), - deleteSession: db.prepare('DELETE FROM sessions WHERE session_id = ?'), - cleanExpiredSessions: db.prepare('DELETE FROM sessions WHERE expires_at <= datetime(\'now\')') -}; - -// Helper functions -const playerHelpers = { - getAll: () => { - logToFile('DB: getAllPlayers - start'); - const rows = statements.getAllPlayers.all(); - const result = rows.map(row => ({ - name: row.name, - rollerInfo: JSON.parse(row.roller_info || '{}'), - shopInfo: JSON.parse(row.shop_info || '{}'), - tabInfo: JSON.parse(row.tab_info || '{}'), - pw: row.pw, - pwHash: row.pw_hash, - _id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at - })); - logToFile('DB: getAllPlayers - resultCount', result.length); - return result; - }, - - getByName: (name) => { - logToFile('DB: getPlayerByName - start', name); - const row = statements.getPlayerByName.get(name); - if (!row) { - logToFile('DB: getPlayerByName - not found', name); - return null; - } - const result = { - name: row.name, - rollerInfo: JSON.parse(row.roller_info || '{}'), - shopInfo: JSON.parse(row.shop_info || '{}'), - tabInfo: JSON.parse(row.tab_info || '{}'), - pw: row.pw, - pwHash: row.pw_hash, - _id: row.id, - createdAt: row.created_at, - updatedAt: row.updated_at - }; - logToFile('DB: getPlayerByName - found', name); - return result; - }, - - create: (playerData) => { - logToFile('DB: createPlayer - start', playerData.name); - const result = statements.insertPlayer.run( - playerData.name, - JSON.stringify(playerData.rollerInfo || {}), - JSON.stringify(playerData.shopInfo || {}), - JSON.stringify(playerData.tabInfo || {}), - playerData.pw || '', - playerData.pwHash || '' - ); - const out = { ...playerData, _id: result.lastInsertRowid }; - logToFile('DB: createPlayer - done', playerData.name, 'rowid', result.lastInsertRowid); - return out; - }, - - update: (name, playerData) => { - logToFile('DB: updatePlayer - start', name); - // Ensure we don't have nested tabInfo - const cleanTabInfo = playerData.tabInfo || {}; - if (cleanTabInfo.tabInfo) { - logToFile('DB: updatePlayer - fixing nested tabInfo structure'); - Object.assign(cleanTabInfo, cleanTabInfo.tabInfo); - delete cleanTabInfo.tabInfo; - } - const result = statements.updatePlayer.run( - JSON.stringify(playerData.rollerInfo || {}), - JSON.stringify(playerData.shopInfo || {}), - JSON.stringify(cleanTabInfo), - playerData.pw || '', - playerData.pwHash || '', - name - ); - logToFile('DB: updatePlayer - changes', result.changes, name); - return result.changes > 0; - }, - - delete: (name) => { - logToFile('DB: deletePlayer - start', name); - const result = statements.deletePlayer.run(name); - logToFile('DB: deletePlayer - changes', result.changes, name); - return result.changes > 0; - } -}; - -const sessionHelpers = { - get: (sessionId) => { - logToFile('DB: getSession - start', sessionId); - const row = statements.getSession.get(sessionId); - if (!row) { - logToFile('DB: getSession - not found', sessionId); - return null; - } - const out = { - sessionId: row.session_id, - data: JSON.parse(row.data || '{}'), - expiresAt: row.expires_at - }; - logToFile('DB: getSession - ok', sessionId); - return out; - }, - - create: (sessionId, data, expiresAt) => { - logToFile('DB: createSession - start', sessionId, 'expiresAt', expiresAt); - const res = statements.insertSession.run(sessionId, JSON.stringify(data), expiresAt); - logToFile('DB: createSession - done', sessionId); - }, - - update: (sessionId, data, expiresAt) => { - logToFile('DB: updateSession - start', sessionId); - const result = statements.updateSession.run(JSON.stringify(data), expiresAt, sessionId); - logToFile('DB: updateSession - changes', result.changes, sessionId); - return result.changes > 0; - }, - - delete: (sessionId) => { - logToFile('DB: deleteSession - start', sessionId); - const result = statements.deleteSession.run(sessionId); - logToFile('DB: deleteSession - changes', result.changes, sessionId); - return result.changes > 0; - }, - - cleanExpired: () => { - logToFile('DB: cleanExpiredSessions - start'); - const result = statements.cleanExpiredSessions.run(); - logToFile('DB: cleanExpiredSessions - removed', result.changes); - return result.changes; - } -}; - -// Staging helpers for sanitized rules -const stagingStatements = { - insertStaging: db.prepare('INSERT INTO rules_staging (title, content, category, page, original_json) VALUES (?,?,?,?,?)'), - listStaging: db.prepare('SELECT id, title, content, category, page, original_json, created_at FROM rules_staging ORDER BY id'), - deleteStagingAll: db.prepare('DELETE FROM rules_staging'), - getStaging: db.prepare('SELECT id, title, content, category, page, original_json, created_at FROM rules_staging WHERE id = ?') -}; - -const stagingHelpers = { - insert: (obj) => { - const res = stagingStatements.insertStaging.run(obj.title || '', obj.content || '', obj.category || '', obj.page || '', JSON.stringify(obj.original || {})); - return res.lastInsertRowid; - }, - list: () => stagingStatements.listStaging.all().map(r => ({ ...r, original: JSON.parse(r.original_json || '{}') })), - clear: () => stagingStatements.deleteStagingAll.run(), - get: (id) => { - const row = stagingStatements.getStaging.get(id); - if (!row) return null; - return { ...row, original: JSON.parse(row.original_json || '{}') }; - } -}; - -module.exports = { - db, - statements, - playerHelpers, - sessionHelpers, - stagingHelpers, - close: () => db.close(), - logToFile -}; diff --git a/database/sqlite/deathwatch.db b/database/sqlite/deathwatch.db deleted file mode 100644 index 21c3082..0000000 Binary files a/database/sqlite/deathwatch.db and /dev/null differ diff --git a/database/sqlite/deathwatch.db.backup.1755447418502 b/database/sqlite/deathwatch.db.backup.1755447418502 deleted file mode 100644 index e399776..0000000 Binary files a/database/sqlite/deathwatch.db.backup.1755447418502 and /dev/null differ diff --git a/database/sqlite/deathwatch.db.backup.1755449835460 b/database/sqlite/deathwatch.db.backup.1755449835460 deleted file mode 100644 index afdec48..0000000 Binary files a/database/sqlite/deathwatch.db.backup.1755449835460 and /dev/null differ diff --git a/database/sqlite/deathwatch.db.bak.20250816T093608 b/database/sqlite/deathwatch.db.bak.20250816T093608 deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.bak.20250816T093608 and /dev/null differ diff --git a/database/sqlite/deathwatch.db.bak.20250816T093616 b/database/sqlite/deathwatch.db.bak.20250816T093616 deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.bak.20250816T093616 and /dev/null differ diff --git a/database/sqlite/deathwatch.db.bak.20250816T093626 b/database/sqlite/deathwatch.db.bak.20250816T093626 deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.bak.20250816T093626 and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_apply_templates.2025-08-16T075151044Z.bak b/database/sqlite/deathwatch.db.pre_apply_templates.2025-08-16T075151044Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_apply_templates.2025-08-16T075151044Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_delete_tests.2025-08-16T074318436Z.bak b/database/sqlite/deathwatch.db.pre_delete_tests.2025-08-16T074318436Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_delete_tests.2025-08-16T074318436Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_migrate.2025-08-16T080212929Z.bak b/database/sqlite/deathwatch.db.pre_migrate.2025-08-16T080212929Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_migrate.2025-08-16T080212929Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_normalize.2025-08-16T073920682Z.bak b/database/sqlite/deathwatch.db.pre_normalize.2025-08-16T073920682Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_normalize.2025-08-16T073920682Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081053187Z.bak b/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081053187Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081053187Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081219333Z.bak b/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081219333Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081219333Z.bak and /dev/null differ diff --git a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081324978Z.bak b/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081324978Z.bak deleted file mode 100644 index 9a2eb11..0000000 Binary files a/database/sqlite/deathwatch.db.pre_transform.2025-08-16T081324978Z.bak and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 887fad5..d325f5f 100755 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,14 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "bcrypt": "^5.1.0", - "better-sqlite3": "^12.2.0", "cors": "^2.8.5", "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "mysql2": "^3.14.4", "pdf-parse": "^1.1.1", "pdfjs-dist": "^3.9.179", "react": "^18.2.0", "react-dom": "^18.2.0", - "sqlite3": "^5.1.7", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -2564,12 +2564,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "optional": true - }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -4223,54 +4217,6 @@ "node": ">= 8" } }, - "node_modules/@npmcli/fs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", - "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", - "optional": true, - "dependencies": { - "@gar/promisify": "^1.0.1", - "semver": "^7.3.5" - } - }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", - "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "optional": true, - "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@npmcli/move-file/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -5063,7 +5009,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -5261,7 +5207,6 @@ "version": "1.17.16", "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6153,31 +6098,6 @@ "node": ">= 6.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "optional": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -6746,6 +6666,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axe-core": { "version": "4.10.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", @@ -7081,25 +7010,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/basic-ftp": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", @@ -7129,19 +7039,6 @@ "node": ">= 10.0.0" } }, - "node_modules/better-sqlite3": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.2.0.tgz", - "integrity": "sha512-eGbYq2CT+tos1fBwLQ/tkBt9J5M3JEHjku4hbvQUePCckkvVf14xWj+1m7dGoK81M/fOjFT7yM9UMeKT/+vFLQ==", - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x" - } - }, "node_modules/bfj": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -7182,24 +7079,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz", @@ -7341,29 +7220,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -7409,86 +7265,6 @@ "node": ">= 0.8" } }, - "node_modules/cacache": { - "version": "15.3.0", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", - "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", - "optional": true, - "dependencies": { - "@npmcli/fs": "^1.0.0", - "@npmcli/move-file": "^1.0.1", - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "glob": "^7.1.4", - "infer-owner": "^1.0.4", - "lru-cache": "^6.0.0", - "minipass": "^3.1.1", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.2", - "mkdirp": "^1.0.3", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^8.0.1", - "tar": "^6.0.2", - "unique-filename": "^1.1.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/cacache/node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "optional": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacache/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "optional": true, - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/cacache/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -7980,11 +7756,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -8040,15 +7811,6 @@ "node": ">=0.10.0" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true, - "engines": { - "node": ">=6" - } - }, "node_modules/cli-tableau": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/cli-tableau/-/cli-tableau-2.0.1.tgz", @@ -9029,20 +8791,6 @@ "dev": true, "license": "MIT" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -9082,14 +8830,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9223,6 +8963,15 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9600,6 +9349,7 @@ "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -9629,14 +9379,6 @@ "node": ">=18" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -9673,21 +9415,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "optional": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "optional": true - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -10590,7 +10317,6 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, "license": "MIT" }, "node_modules/events": { @@ -10636,14 +10362,6 @@ "node": ">= 0.8.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, "node_modules/expect": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz", @@ -10916,11 +10634,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, "node_modules/filelist": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", @@ -11069,7 +10782,6 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "dev": true, "funding": [ { "type": "individual", @@ -11311,11 +11023,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -11445,6 +11152,15 @@ "node": ">=10" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -11576,11 +11292,6 @@ "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==", "dev": true }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12071,12 +11782,6 @@ "entities": "^2.0.0" } }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "optional": true - }, "node_modules/http-deceiver": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", @@ -12118,7 +11823,6 @@ "version": "1.18.1", "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, "license": "MIT", "dependencies": { "eventemitter3": "^4.0.0", @@ -12133,7 +11837,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@tootallnate/once": "1", @@ -12145,28 +11849,20 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz", + "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==", "license": "MIT", "dependencies": { - "@types/http-proxy": "^1.17.8", + "@types/http-proxy": "^1.17.15", + "debug": "^4.3.6", "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" + "is-glob": "^4.0.3", + "is-plain-object": "^5.0.0", + "micromatch": "^4.0.8" }, "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/https-proxy-agent": { @@ -12192,15 +11888,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "optional": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/hyperdyperid": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", @@ -12263,25 +11950,6 @@ "node": ">=4" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -12354,7 +12022,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -12369,12 +12037,6 @@ "node": ">=8" } }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "optional": true - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -12396,6 +12058,7 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -12416,7 +12079,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 12" } @@ -12624,7 +12287,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12688,7 +12350,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -12732,12 +12393,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "optional": true - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -12841,6 +12496,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -12853,6 +12517,12 @@ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13061,7 +12731,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -16527,6 +16197,12 @@ "node": ">=0.8.0" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -16559,6 +16235,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -16607,63 +16298,6 @@ "node": ">=10" } }, - "node_modules/make-fetch-happen": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", - "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", - "optional": true, - "dependencies": { - "agentkeepalive": "^4.1.3", - "cacache": "^15.2.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^6.0.0", - "minipass": "^3.1.3", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^1.3.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.2", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^6.0.0", - "ssri": "^8.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-fetch-happen/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/make-fetch-happen/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -16818,17 +16452,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -16882,6 +16505,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16897,161 +16521,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-collect/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, - "node_modules/minipass-fetch": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", - "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", - "optional": true, - "dependencies": { - "minipass": "^3.1.0", - "minipass-sized": "^1.0.3", - "minizlib": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "optionalDependencies": { - "encoding": "^0.1.12" - } - }, - "node_modules/minipass-fetch/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-fetch/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "optional": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/minizlib": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", @@ -17093,11 +16562,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -17130,6 +16594,42 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/mysql2": { + "version": "3.14.4", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.4.tgz", + "integrity": "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -17142,6 +16642,27 @@ "thenify-all": "^1.0.0" } }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/nan": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz", @@ -17167,11 +16688,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -17228,7 +16744,7 @@ "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -17261,28 +16777,6 @@ "tslib": "^2.0.3" } }, - "node_modules/node-abi": { - "version": "3.75.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", - "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", @@ -17343,92 +16837,6 @@ "node": ">= 6.13.0" } }, - "node_modules/node-gyp": { - "version": "8.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", - "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", - "optional": true, - "dependencies": { - "env-paths": "^2.2.0", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^9.1.0", - "nopt": "^5.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": ">= 10.12.0" - } - }, - "node_modules/node-gyp/node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "optional": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -17871,21 +17279,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "optional": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-retry": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", @@ -20081,31 +19474,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -20425,34 +19793,6 @@ "asap": "~2.0.6" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "optional": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "optional": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/promise-retry/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "optional": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/promptly": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz", @@ -20613,15 +19953,6 @@ "url": "https://github.com/sponsors/lupomontero" } }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -20729,28 +20060,6 @@ "node": ">= 0.8" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -21180,6 +20489,31 @@ "node": ">= 0.6" } }, + "node_modules/react-scripts/node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, "node_modules/react-scripts/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -21769,7 +21103,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, "license": "MIT" }, "node_modules/resolve": { @@ -22347,6 +21680,11 @@ "node": ">= 0.6" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -22638,6 +21976,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "devOptional": true, "funding": [ { "type": "github", @@ -22653,30 +21992,6 @@ } ] }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -22697,7 +22012,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "devOptional": true, + "dev": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -22719,7 +22034,7 @@ "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "devOptional": true, + "dev": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -22729,20 +22044,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", - "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", - "optional": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -22860,64 +22161,15 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/sqlite3": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", - "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "node-addon-api": "^7.0.0", - "prebuild-install": "^7.1.1", - "tar": "^6.1.11" - }, - "optionalDependencies": { - "node-gyp": "8.x" - }, - "peerDependencies": { - "node-gyp": "8.x" - }, - "peerDependenciesMeta": { - "node-gyp": { - "optional": true - } - } - }, - "node_modules/sqlite3/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==" - }, - "node_modules/ssri": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", - "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", - "optional": true, - "dependencies": { - "minipass": "^3.1.1" - }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", "engines": { - "node": ">= 8" + "node": ">= 0.6" } }, - "node_modules/ssri/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "optional": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ssri/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "optional": true - }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -23808,32 +23060,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", - "integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar/node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -24264,17 +23490,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/tv4": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", @@ -24549,24 +23764,6 @@ "node": ">=4" } }, - "node_modules/unique-filename": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", - "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", - "optional": true, - "dependencies": { - "unique-slug": "^2.0.0" - } - }, - "node_modules/unique-slug": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", - "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", - "optional": true, - "dependencies": { - "imurmurhash": "^0.1.4" - } - }, "node_modules/unique-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", @@ -25188,6 +24385,31 @@ "node": ">= 0.6" } }, + "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, "node_modules/webpack-dev-server/node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -25521,7 +24743,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/package.json b/package.json index b275613..918dcf7 100755 --- a/package.json +++ b/package.json @@ -2,19 +2,20 @@ "name": "deathwatch-roller", "version": "0.1.0", "private": true, + "proxy": "http://localhost:5000", "dependencies": { "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "bcrypt": "^5.1.0", - "better-sqlite3": "^12.2.0", "cors": "^2.8.5", "express": "^5.1.0", + "http-proxy-middleware": "^3.0.5", + "mysql2": "^3.14.4", "pdf-parse": "^1.1.1", "pdfjs-dist": "^3.9.179", "react": "^18.2.0", "react-dom": "^18.2.0", - "sqlite3": "^5.1.7", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/scripts/fill-talents.js b/scripts/fill-talents.js deleted file mode 100644 index 0e74eca..0000000 --- a/scripts/fill-talents.js +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { db } = require('../database/sqlite-db'); - -function fetchUrl(url) { - return new Promise((resolve, reject) => { - https.get(url, { headers: { 'User-Agent': 'dwroller-bot/1.0' } }, (res) => { - let data = ''; - res.on('data', (c) => data += c); - res.on('end', () => resolve({ status: res.statusCode, body: data })); - }).on('error', reject); - }); -} - -function sanitizeText(s) { - if (!s) return ''; - let t = String(s); - t = t.replace(/Explore More/ig, ''); - t = t.replace(/Skip to content/ig, ''); - t = t.replace(/40k-?RPG-?FFG Wiki/ig, ''); - t = t.replace(/Explore Main Page/ig, ''); - t = t.replace(/^(Category:|Special:|Local sitemap).*/gi, ''); - t = t.replace(/\[\d+\]/g, ''); - t = t.replace(/\s+/g, ' ').trim(); - t = t.replace(/^This (article|page) .*/i, ''); - return t.trim(); -} - -function extractContentFromFandom(html) { - const out = { paragraphs: [], sourceLines: [] }; - const m = html.match(/]+class="mw-parser-output"[^>]*>([\s\S]*?)
/i); - const block = m ? m[1] : html; - - const pushText = (txt) => { - if (!txt) return; - let clean = txt.replace(/<[^>]+>/g, '').replace(/\[\d+\]/g, '').replace(/\s+/g, ' ').trim(); - if (!clean) return; - if (/^Source[:\s]/i.test(clean)) { out.sourceLines.push(clean); return; } - out.paragraphs.push(clean); - }; - - const paraRe = /]*>([\s\S]*?)<\/p>/ig; - let p; - while ((p = paraRe.exec(block)) !== null) pushText(p[1]); - - if (out.paragraphs.length < 2) { - const liRe = /]*>([\s\S]*?)<\/li>/ig; - while ((p = liRe.exec(block)) !== null) pushText(p[1]); - } - if (out.paragraphs.length < 2) { - const ddRe = /]*>([\s\S]*?)<\/dd>/ig; - while ((p = ddRe.exec(block)) !== null) pushText(p[1]); - } - if (out.paragraphs.length < 2) { - const tdRe = /]*>([\s\S]*?)<\/td>/ig; - while ((p = tdRe.exec(block)) !== null) pushText(p[1]); - } - - out.paragraphs = out.paragraphs.filter(p => p && p.length > 20 && !/(?:Explore|Skip to content|Advertisement)/i.test(p)); - out.paragraphs = Array.from(new Set(out.paragraphs)); - return out; -} - -function findUseText(paragraphs) { - for (const p of paragraphs) { - if (/^(Use|Usage)[:\s]/i.test(p) || /\bUse[:\s]/i.test(p)) return p; - } - return ''; -} - -async function main() { - const mode = (process.argv[2] || 'preview').toLowerCase(); - if (!['preview','commit'].includes(mode)) { console.error('Mode must be preview or commit'); process.exit(1); } - - const q = db.prepare("SELECT id, title, content FROM rules WHERE category = 'talents' AND (content IS NULL OR trim(content) = '' OR length(trim(content)) < 30) ORDER BY id"); - const rows = q.all(); - console.log('Found', rows.length, 'talents with missing/short content'); - const results = []; - - for (const r of rows) { - try { - const title = (r.title || '').replace(/\s*\(Talent\)\s*$/i,'').trim(); - const urlTitle = encodeURIComponent(title.replace(/ /g, '_')); - const url = `https://40k-rpg-ffg.fandom.com/wiki/${urlTitle}`; - console.log('Fetching', title); - const res = await fetchUrl(url); - if (res.status !== 200) { - console.warn('Failed to fetch', title, 'status', res.status); - results.push({ id: r.id, title: r.title, status: 'fetch_failed', statusCode: res.status }); - continue; - } - const block = extractContentFromFandom(res.body); - const orig = block.paragraphs || []; - const paragraphs = orig.map(p => sanitizeText(p)).filter(Boolean); - const main = paragraphs.length ? paragraphs[0] : ''; - const use = findUseText(paragraphs) || ''; - const descParts = paragraphs.filter(p => p !== main && p !== use); - const description = descParts.join('\n\n'); - const newContent = [main, description, use ? `Use: ${use.replace(/^Use[:\s]*/i,'')}` : ''].filter(Boolean).join('\n\n'); - - results.push({ id: r.id, title: r.title, fetchedTitle: title, url, oldContent: r.content || '', newContent: newContent || '', extracted_paragraphs: orig }); - - if (mode === 'commit' && newContent && newContent.trim().length > 20) { - const upd = db.prepare('UPDATE rules SET content = ?, source = ?, source_abbr = ? WHERE id = ?'); - upd.run(newContent, 'fandom', 'FAN', r.id); - console.log('Updated id', r.id, title); - } - - await new Promise(r => setTimeout(r, 200)); - } catch (e) { - console.error('Error processing id', r.id, e && e.message ? e.message : e); - results.push({ id: r.id, title: r.title, status: 'error', error: e && e.message }); - } - } - - const ts = new Date().toISOString().replace(/[:.]/g,'-'); - const out = { generatedAt: new Date().toISOString(), mode, count: results.length, items: results }; - const outPath = path.join('/tmp', `talents_fill_${mode}_${ts}.json`); - fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8'); - console.log('Wrote report to', outPath); - db.close(); -} - -main().catch(err => { console.error(err && err.stack ? err.stack : err); try{db.close()}catch(e){}; process.exit(1); }); diff --git a/scripts/import-sanitized-to-staging.js b/scripts/import-sanitized-to-staging.js deleted file mode 100644 index 24ce92f..0000000 --- a/scripts/import-sanitized-to-staging.js +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const { stagingHelpers } = require('../database/sqlite-db'); - -const input = process.argv[2] || 'database/backups/sanitized-rules-test.json'; -if (!fs.existsSync(input)) { - console.error('Input not found', input); - process.exit(1); -} -const data = JSON.parse(fs.readFileSync(input,'utf8')); -if (!data || !Array.isArray(data.sanitized)) { - console.error('Expected file with { sanitized: [...] }'); - process.exit(1); -} - -let count = 0; -for (const item of data.sanitized) { - try { - stagingHelpers.insert(item); - count++; - } catch (e) { - console.error('Failed insert staging', e && e.message); - } -} -console.log('Imported to staging:', count); diff --git a/scripts/inspect-skills-db.js b/scripts/inspect-skills-db.js deleted file mode 100644 index 20cf998..0000000 --- a/scripts/inspect-skills-db.js +++ /dev/null @@ -1,17 +0,0 @@ -const { db } = require('../database/sqlite-db'); - -function listSkills(limit = 50) { - try { - const rows = db.prepare(`SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE category = ? ORDER BY id LIMIT ?`).all('skills', limit); - console.log(`Found ${rows.length} skill rows (showing up to ${limit}):`); - rows.forEach((r, i) => { - console.log(`${i + 1}. ${r.id} | ${r.title} | page=${r.page} | source=${r.source} | sourceAbbr=${r.sourceAbbr}`); - }); - } catch (e) { - console.error('Failed to query skills:', e); - } finally { - db.close(); - } -} - -listSkills(200); diff --git a/scripts/migrate-to-sqlite.js b/scripts/migrate-to-sqlite.js deleted file mode 100644 index ab23455..0000000 --- a/scripts/migrate-to-sqlite.js +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const Database = require('better-sqlite3'); - -const dbPath = path.join(__dirname, '../database/sqlite/deathwatch.db'); -const backupPath = dbPath + '.backup.' + Date.now(); - -function safeReadJSON(p) { - if (!fs.existsSync(p)) return null; - try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { console.error('JSON parse error', p, e.message); return null; } -} - -console.log('=== MIGRATE JSON DATA INTO SQLITE ==='); -if (!fs.existsSync(dbPath)) { - console.error('DB not found at', dbPath); - process.exit(1); -} - -fs.copyFileSync(dbPath, backupPath); -console.log('Backup created:', backupPath); - -const db = new Database(dbPath); - -// Create consolidated tables -db.exec(` -CREATE TABLE IF NOT EXISTS armour ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - req INTEGER DEFAULT 0, - renown TEXT DEFAULT 'None', - category TEXT, - stats TEXT, - source TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS weapons ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE NOT NULL, - req INTEGER DEFAULT 0, - renown TEXT DEFAULT 'None', - category TEXT, - stats TEXT, - source TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS bestiary ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - book TEXT, - page TEXT, - pdf TEXT, - stats TEXT, - profile TEXT, - snippet TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - rule_id TEXT UNIQUE, - title TEXT, - content TEXT, - page INTEGER, - source TEXT, - source_abbr TEXT, - category TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -`); - -const insertArmour = db.prepare(`INSERT OR IGNORE INTO armour (name, req, renown, category, stats, source) VALUES (?, ?, ?, ?, ?, ?)`); -const insertWeapon = db.prepare(`INSERT OR IGNORE INTO weapons (name, req, renown, category, stats, source) VALUES (?, ?, ?, ?, ?, ?)`); -const insertBestiary = db.prepare(`INSERT INTO bestiary (name, book, page, pdf, stats, profile, snippet) VALUES (?, ?, ?, ?, ?, ?, ?)`); -const insertRule = db.prepare(`INSERT OR IGNORE INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)`); - -let totals = { armour:0, weapons:0, bestiary:0, rules:0 }; - -// Import armour -const armourFile = path.join(__dirname, '../database/public/deathwatch-armor.json'); -const armourData = safeReadJSON(armourFile); -if (armourData) { - const categories = Object.keys(armourData); - categories.forEach(cat => { - const arr = armourData[cat]; - if (!Array.isArray(arr)) return; - const insert = insertArmour; - db.transaction(() => { - for (const item of arr) { - const stats = JSON.stringify(item.stats || {}); - const src = (item.stats && item.stats.source) || ''; - insert.run(item.name || '(unnamed)', item.req || 0, item.renown || 'None', item.category || cat, stats, src); - totals.armour++; - } - })(); - }); -} - -// Import weapons -const weaponsFile = path.join(__dirname, '../database/public/deathwatch-weapons-comprehensive.json'); -const weaponsData = safeReadJSON(weaponsFile); -if (weaponsData) { - // many weapon files use keys like rangedWeapons, meleeWeapons - Object.keys(weaponsData).forEach(k => { - const arr = weaponsData[k]; - if (!Array.isArray(arr)) return; - db.transaction(() => { - for (const w of arr) { - const stats = JSON.stringify(w.stats || {}); - const src = (w.stats && w.stats.source) || ''; - insertWeapon.run(w.name || '(unnamed)', w.req || 0, w.renown || 'Any', w.category || k, stats, src); - totals.weapons++; - } - })(); - }); -} - -// Import bestiary -const bestiaryFile = path.join(__dirname, '../database/deathwatch-bestiary-extracted.json'); -const bestiaryData = safeReadJSON(bestiaryFile); -if (bestiaryData && Array.isArray(bestiaryData.results)) { - db.transaction(() => { - for (const e of bestiaryData.results) { - const stats = JSON.stringify(e.stats || {}); - const profile = JSON.stringify(e.profile || {}); - const name = e.bestiaryName || e.name || '(unnamed)'; - insertBestiary.run(name, e.book || '', e.page || '', e.pdf || '', stats, profile, e.stats && e.stats.snippet ? e.stats.snippet : ''); - totals.bestiary++; - } - })(); -} - -// Import rules -const rulesFile = path.join(__dirname, '../database/rules/rules-database.json'); -const rulesData = safeReadJSON(rulesFile); -if (rulesData && Array.isArray(rulesData.rules)) { - db.transaction(() => { - for (const r of rulesData.rules) { - insertRule.run(r.id || null, r.title || '', r.content || '', r.page || null, r.source || '', r.sourceAbbr || '', r.category || 'general'); - totals.rules++; - } - })(); -} - -console.log('Import totals:', totals); - -// Show row counts from DB for verification -const counts = { - armour: db.prepare('SELECT COUNT(*) as c FROM armour').get().c, - weapons: db.prepare('SELECT COUNT(*) as c FROM weapons').get().c, - bestiary: db.prepare('SELECT COUNT(*) as c FROM bestiary').get().c, - rules: db.prepare('SELECT COUNT(*) as c FROM rules').get().c -}; - -console.log('DB row counts:', counts); - -// Print a small sample from each table -console.log('\nSample armour:', db.prepare('SELECT name, category, stats FROM armour LIMIT 3').all()); -console.log('\nSample weapons:', db.prepare('SELECT name, category, stats FROM weapons LIMIT 3').all()); -console.log('\nSample bestiary:', db.prepare('SELECT name, book, snippet FROM bestiary LIMIT 3').all()); -console.log('\nSample rules:', db.prepare('SELECT rule_id, title FROM rules LIMIT 3').all()); - -db.close(); -console.log('\nMigration complete. DB backed up at', backupPath); diff --git a/scripts/normalize-rules-db.js b/scripts/normalize-rules-db.js deleted file mode 100644 index bbdcafe..0000000 --- a/scripts/normalize-rules-db.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -const path = require('path'); -const fs = require('fs'); -const { db, logToFile } = require('../database/sqlite-db'); - -function nowTs() { return new Date().toISOString().replace(/[:.]/g,'-'); } - -function titleCase(str) { - return str.toLowerCase().split(/\s+/).map(w => { - if (!w) return ''; - return w[0].toUpperCase() + w.slice(1); - }).join(' '); -} - -function cleanTitle(title) { - if (!title) return title; - const letters = title.replace(/[^A-Za-z]/g,''); - const uppers = (title.match(/[A-Z]/g) || []).length; - // if mostly uppercase, convert to title case - if (letters && (uppers / letters.length) > 0.5) { - return titleCase(title.replace(/\s+/g,' ').trim()); - } - // otherwise trim - return title.trim(); -} - -function cleanContent(text) { - if (!text) return text; - let s = String(text); - s = s.replace(/\r\n/g, '\n'); - s = s.replace(/[ \t]+/g, ' '); - // remove hyphenation at line breaks - s = s.replace(/-\n\s*/g, ''); - // collapse more than 2 newlines into paragraph breaks - s = s.replace(/\n{3,}/g, '\n\n'); - // join lines that look like soft-wrapped lines: a line break between - // a non-punctuation end and a lowercase/digit start - s = s.replace(/([^\.\!\?\:\;\"\'\)\]\}])\n(\s*[a-z0-9])/g, '$1 $2'); - // trim spaces at start/end of lines - s = s.split('\n').map(l => l.trim()).join('\n'); - // collapse repeated spaces - s = s.replace(/ {2,}/g, ' '); - // trim overall - s = s.trim(); - return s; -} - -function backupRules(rows) { - const backupDir = path.join(__dirname, '..', 'database', 'backups'); - if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); - const file = path.join(backupDir, `rules-backup-${nowTs()}.json`); - fs.writeFileSync(file, JSON.stringify({ backedAt: new Date().toISOString(), count: rows.length, rows }, null, 2), 'utf8'); - return file; -} - -function main() { - console.log('Backing up rules table and normalizing content...'); - const rows = db.prepare('SELECT id, title, content FROM rules').all(); - if (!rows || rows.length === 0) { - console.log('No rules found in DB. Exiting.'); - return; - } - const backupFile = backupRules(rows); - console.log('Backup written to', backupFile); - - const updateStmt = db.prepare('UPDATE rules SET title = ?, content = ? WHERE id = ?'); - let changed = 0; - db.transaction(() => { - for (const r of rows) { - const cleanedTitle = cleanTitle(r.title || ''); - const cleanedContent = cleanContent(r.content || ''); - if ((cleanedTitle !== (r.title||'').trim()) || (cleanedContent !== (r.content||'').trim())) { - updateStmt.run(cleanedTitle, cleanedContent, r.id); - changed++; - } - } - })(); - - console.log(`Normalization complete. Rows updated: ${changed}`); - logToFile('normalize-rules-db: completed', { updated: changed }); -} - -main(); diff --git a/scripts/purge-non-csv-rules.js b/scripts/purge-non-csv-rules.js deleted file mode 100644 index e3eb84a..0000000 --- a/scripts/purge-non-csv-rules.js +++ /dev/null @@ -1,48 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { db } = require('../database/sqlite-db'); - -const outDir = path.join(__dirname, '..', 'database', 'backups'); -if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); -const ts = new Date().toISOString().replace(/[:.]/g, '-'); - -try { - // Select rows to purge: any rule where source != 'csv-import' OR page contains 'p.1' (case-insensitive) - const rowsToPurge = db.prepare("SELECT * FROM rules WHERE source != ? OR (page IS NOT NULL AND lower(page) LIKE '%p.1%')").all('csv-import'); - console.log('Found rows to purge:', rowsToPurge.length); - const backupFile = path.join(outDir, `purge_non_csv_rules_backup_${ts}.json`); - fs.writeFileSync(backupFile, JSON.stringify({ purgedAt: new Date().toISOString(), count: rowsToPurge.length, rows: rowsToPurge }, null, 2), 'utf8'); - console.log('Backup written to', backupFile); - - if (rowsToPurge.length === 0) { - console.log('Nothing to purge'); - db.close(); - process.exit(0); - } - - // Delete by id in transaction - const del = db.prepare('DELETE FROM rules WHERE id = ?'); - db.transaction(() => { - for (const r of rowsToPurge) { - del.run(r.id); - } - })(); - - const remaining = db.prepare("SELECT source, COUNT(*) as c FROM rules GROUP BY source ORDER BY c DESC").all(); - console.log('Remaining rows by source:', remaining); - - const total = db.prepare('SELECT COUNT(*) as c FROM rules').get().c; - console.log('Total rules now in DB:', total); - - const report = { purgedAt: new Date().toISOString(), purgedCount: rowsToPurge.length, remaining, total }; - fs.writeFileSync(path.join(outDir, `purge_non_csv_rules_report_${ts}.json`), JSON.stringify(report, null, 2), 'utf8'); - console.log('Purge report written'); - -} catch (e) { - console.error('Error during purge:', e); - process.exit(1); -} finally { - db.close(); -} - -console.log('Done'); diff --git a/scripts/repair-skills.js b/scripts/repair-skills.js deleted file mode 100644 index a770386..0000000 --- a/scripts/repair-skills.js +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -const https = require('https'); -const fs = require('fs'); -const path = require('path'); -const { db } = require('../database/sqlite-db'); - -function fetchUrl(url) { - return new Promise((resolve, reject) => { - https.get(url, { headers: { 'User-Agent': 'dwroller-bot/1.0' } }, (res) => { - let data = ''; - res.on('data', (c) => data += c); - res.on('end', () => resolve({ status: res.statusCode, body: data })); - }).on('error', reject); - }); -} - -function extractContentFromFandom(html) { - const out = { paragraphs: [], headings: [], sourceLines: [] }; - const m = html.match(/]+class="mw-parser-output"[^>]*>([\s\S]*?)
/i); - const block = m ? m[1] : html; - - const pushText = (txt) => { - if (!txt) return; - let clean = txt.replace(/<[^>]+>/g, '') - .replace(/\[\d+\]/g, '') - .replace(/\s+/g, ' ').trim(); - if (!clean) return; - if (/^Source[:\s]/i.test(clean)) { - out.sourceLines.push(clean); - return; - } - out.paragraphs.push(clean); - }; - - const paraRe = /]*>([\s\S]*?)<\/p>/ig; - let p; - while ((p = paraRe.exec(block)) !== null) pushText(p[1]); - - if (out.paragraphs.length < 2) { - const liRe = /]*>([\s\S]*?)<\/li>/ig; - while ((p = liRe.exec(block)) !== null) pushText(p[1]); - } - if (out.paragraphs.length < 2) { - const ddRe = /]*>([\s\S]*?)<\/dd>/ig; - while ((p = ddRe.exec(block)) !== null) pushText(p[1]); - } - if (out.paragraphs.length < 2) { - const tdRe = /]*>([\s\S]*?)<\/td>/ig; - while ((p = tdRe.exec(block)) !== null) pushText(p[1]); - } - - const hRe = /]*>([\s\S]*?)<\/h[2-3]>/ig; - let h; - while ((h = hRe.exec(block)) !== null) { - const ht = h[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim(); - if (ht.length) out.headings.push(ht); - } - - const noiseRe = /(?:Explore|Skip to content|Advertisement|History|Main Page|Discuss|Community|Interactive Maps|Recently Changed|Explore More|All Pages|Pages|Recent Blog Posts|Recently Changed Pages|Explore Main Page)/i; - out.paragraphs = out.paragraphs.filter(p => { - if (noiseRe.test(p)) return false; - if (p.length < 30) return false; - if (/^\s*\w+(\s+\w+){0,2}\s*$/.test(p) && p.split(' ').length <= 3) return false; - return true; - }); - - out.paragraphs = Array.from(new Set(out.paragraphs)); - return out; -} - -function sanitizeText(s) { - if (!s) return ''; - let t = String(s); - t = t.replace(/Explore More/ig, ''); - t = t.replace(/Skip to content/ig, ''); - t = t.replace(/40k-?RPG-?FFG Wiki/ig, ''); - t = t.replace(/Explore Main Page/ig, ''); - t = t.replace(/^(Category:|Special:|Local sitemap).*/gi, ''); - t = t.replace(/\[\d+\]/g, ''); - t = t.replace(/\s+/g, ' ').trim(); - t = t.replace(/^This (article|page) .*/i, ''); - return t.trim(); -} - -async function repair({ commit = false } = {}) { - // find problem rows: contain common nav noise OR very short content OR content starts with 'Source:' only - const q = `SELECT title, content FROM rules WHERE category='skills' AND (content LIKE '%Explore More%' OR content LIKE '%Skip to content%' OR length(content) < 120 OR content LIKE 'Source:%' ) ORDER BY title`; - const rows = db.prepare(q).all(); - console.log('Found', rows.length, 'skills to inspect'); - if (!rows.length) { db.close(); return; } - - const results = []; - for (const r of rows) { - try { - const title = r.title; - const urlTitle = encodeURIComponent(title.replace(/ /g, '_')); - const url = `https://40k-rpg-ffg.fandom.com/wiki/${urlTitle}`; - console.log('Fetching', title); - const res = await fetchUrl(url); - if (res.status !== 200) { - console.warn('Fetch failed', title, res.status); - continue; - } - const block = extractContentFromFandom(res.body); - const paras = block.paragraphs.map(sanitizeText).filter(Boolean); - const use = paras.find(p => /^(Use|Usage)[:\s]/i.test(p)) || ''; - const descParts = paras.filter(p => p !== use); - let newContent = ''; - if (descParts.length) { - // prefer the longest paragraph as primary - const primary = descParts.reduce((a,b)=> a.length>=b.length?a:b,''); - const others = descParts.filter(p=>p!==primary); - newContent = [primary, others.join('\n\n')].filter(Boolean).join('\n\n'); - } - if (block.sourceLines && block.sourceLines.length) { - newContent = (newContent ? newContent + '\n\n' : '') + block.sourceLines.join(' | '); - } - if (!newContent) { - // nothing useful extracted, skip - console.log('No useful content for', title); - continue; - } - results.push({ title, old: r.content, newContent, url }); - } catch (e) { - console.error('Error for', r.title, e && e.message); - } - } - - const ts = new Date().toISOString().replace(/[:.]/g,'-'); - const previewPath = path.join('/tmp', `repair_skills_preview_${ts}.json`); - fs.writeFileSync(previewPath, JSON.stringify({ generatedAt: new Date().toISOString(), count: results.length, items: results }, null, 2), 'utf8'); - console.log('Wrote preview to', previewPath); - - if (!commit) { db.close(); return; } - - // apply updates - const update = db.prepare('UPDATE rules SET content = ?, source = ?, source_abbr = ? WHERE title = ?'); - let changed = 0; - db.transaction(() => { - for (const it of results) { - try { - update.run(it.newContent, 'https://40k-rpg-ffg.fandom.com', 'fandom', it.title); - changed++; - } catch (e) { - console.error('Failed update', it.title, e && e.message); - } - } - })(); - - const commitPath = path.join('/tmp', `repair_skills_committed_${ts}.json`); - fs.writeFileSync(commitPath, JSON.stringify({ committedAt: new Date().toISOString(), changed, items: results.map(r=>({title:r.title})) }, null, 2), 'utf8'); - console.log('Committed', changed, 'rows. Details in', commitPath); - db.close(); -} - -if (require.main === module) { - const commit = (process.argv[2] === '--commit'); - repair({ commit }).then(()=>process.exit(0)).catch(err=>{ console.error(err); db.close(); process.exit(1); }); -} diff --git a/scripts/score-rules-noise.js b/scripts/score-rules-noise.js deleted file mode 100644 index fd65fa0..0000000 --- a/scripts/score-rules-noise.js +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); -const { db } = require('../database/sqlite-db'); - -function nowTs() { return new Date().toISOString().replace(/[:.]/g,'-'); } - -function charEntropy(s) { - if (!s || s.length === 0) return 0; - const freq = {}; - for (const ch of s) freq[ch] = (freq[ch]||0) + 1; - const len = s.length; - let ent = 0; - for (const k in freq) { - const p = freq[k]/len; - ent -= p * Math.log2(p); - } - return ent; -} - -function scoreText(text) { - if (!text) return {score:0,metrics:{}}; - const s = String(text); - const length = s.length; - const letters = s.replace(/[^A-Za-z]/g,''); - const upper = (s.match(/[A-Z]/g)||[]).length; - const digits = (s.match(/[0-9]/g)||[]).length; - const nonAlphaNum = (s.match(/[^A-Za-z0-9\s\.,;:\'"\-()\[\]\/\\]/g)||[]).length; - const punctuation = (s.match(/[\.,;:\!\?\-\(\)\[\]"\']/g)||[]).length; - const newlines = (s.match(/\n/g)||[]).length; - const lines = s.split(/\n/); - const shortLines = lines.filter(l => l.trim().length > 0 && l.trim().length < 40).length; - const avgWordLen = (s.match(/\w+/g)||[]).reduce((a,w)=>a+w.length,0)/Math.max(1,(s.match(/\w+/g)||[]).length); - const entropy = charEntropy(s); - - const upperRatio = letters.length ? upper/letters.length : 0; - const nonAlphaRatio = length ? nonAlphaNum/length : 0; - const newlineDensity = length ? newlines/length : 0; - const shortLineRatio = lines.length ? shortLines/lines.length : 0; - const punctDensity = length ? punctuation/length : 0; - - // Score: higher for uppercase-heavy, non-alpha junk, many newlines, many short lines, low avg word length, high entropy - // Weights chosen empirically to bring noisy texts to the top. - const score = ( - upperRatio * 2.5 + - nonAlphaRatio * 4.0 + - newlineDensity * 3.0 + - shortLineRatio * 1.6 + - (1/Math.max(1, avgWordLen)) * 1.2 + - (entropy/6.0) * 1.0 + - punctDensity * 0.8 - ) * 100; - - return { score, metrics: { length, upper, letters: letters.length, upperRatio, nonAlphaNum, nonAlphaRatio, newlines, newlineDensity, lines: lines.length, shortLines, shortLineRatio, avgWordLen, entropy, punctDensity } }; -} - -function main() { - const rows = db.prepare('SELECT id, title, page, source, content FROM rules').all(); - if (!rows || rows.length === 0) { - console.log('No rules found'); - return; - } - const scored = rows.map(r => { - const text = (r.title||'') + '\n' + (r.content||''); - const res = scoreText(text); - return { id: r.id, title: (r.title||'').trim(), page: r.page, source: r.source, score: Math.round(res.score*100)/100, metrics: res.metrics, snippet: (r.content||'').replace(/\n/g,' ').slice(0,240) }; - }); - scored.sort((a,b)=>b.score - a.score); - - const top = scored.slice(0,40); - const outDir = path.join(__dirname,'..','database','backups'); - if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); - const outFile = path.join(outDir, `rules-noise-report-${nowTs()}.json`); - fs.writeFileSync(outFile, JSON.stringify({ generatedAt: new Date().toISOString(), count: scored.length, top }, null, 2), 'utf8'); - - console.log('Noise scoring complete. Total rules:', scored.length); - console.log('Report written to', outFile); - console.log('\nTop 25 noisy rules:'); - top.slice(0,25).forEach((r,i)=>{ - console.log(`${String(i+1).padStart(2,' ')}. id=${r.id} score=${r.score} title="${r.title}" page=${r.page} source=${r.source}`); - console.log(' snippet:', r.snippet.replace(/\s+/g,' ').slice(0,200)); - }); -} - -main(); diff --git a/scripts/sync-skills-csv-to-db.js b/scripts/sync-skills-csv-to-db.js deleted file mode 100644 index fe3fb66..0000000 --- a/scripts/sync-skills-csv-to-db.js +++ /dev/null @@ -1,150 +0,0 @@ -const fs = require('fs'); -const path = require('path'); -const { db } = require('../database/sqlite-db'); - -if (process.argv.length < 3) { - console.error('Usage: node scripts/sync-skills-csv-to-db.js '); - process.exit(1); -} - -const csvPath = process.argv[2]; -if (!fs.existsSync(csvPath)) { - console.error('CSV file not found:', csvPath); - process.exit(1); -} - -function parseCSV(content) { - // Minimal RFC4180-ish parser supporting quoted fields and commas - const lines = []; - let cur = ''; - let inQuotes = false; - for (let i = 0; i < content.length; i++) { - const ch = content[i]; - const nxt = content[i + 1]; - if (ch === '"') { - if (inQuotes && nxt === '"') { // escaped quote - cur += '"'; - i++; // skip next - } else { - inQuotes = !inQuotes; - } - continue; - } - if (ch === '\n' && !inQuotes) { - lines.push(cur); - cur = ''; - continue; - } - cur += ch; - } - if (cur.length) lines.push(cur); - - return lines.map(l => { - const cols = []; - let cell = ''; - let q = false; - for (let i = 0; i < l.length; i++) { - const ch = l[i]; - const nx = l[i + 1]; - if (ch === '"') { - if (q && nx === '"') { cell += '"'; i++; continue; } - q = !q; continue; - } - if (ch === ',' && !q) { cols.push(cell); cell = ''; continue; } - cell += ch; - } - cols.push(cell); - return cols.map(c => c.trim()); - }); -} - -function slugify(s) { - return String(s || '') - .toLowerCase() - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -const outDir = path.join(__dirname, '..', 'database', 'backups'); -if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); -const ts = new Date().toISOString().replace(/[:.]/g, '-'); - -try { - const raw = fs.readFileSync(csvPath, 'utf8'); - const rows = parseCSV(raw); - if (rows.length < 2) { - console.error('No CSV rows found'); - process.exit(1); - } - const headers = rows[0].map(h => h.toLowerCase()); - const data = rows.slice(1).map(r => { - const obj = {}; - for (let i = 0; i < headers.length; i++) obj[headers[i]] = r[i] || ''; - return obj; - }).filter(d => (d.name || '').trim()); - - // Backup existing skills rows - const backupFile = path.join(outDir, `rules_skills_backup_${ts}.json`); - const existing = db.prepare('SELECT * FROM rules WHERE category = ?').all('skills'); - fs.writeFileSync(backupFile, JSON.stringify({ backedAt: new Date().toISOString(), count: existing.length, rows: existing }, null, 2), 'utf8'); - console.log('Backup written to', backupFile, ' (rows:', existing.length, ')'); - - // Delete existing skill rows - const del = db.prepare('DELETE FROM rules WHERE category = ?'); - const delRes = del.run('skills'); - console.log('Deleted rules where category=skills, changes:', delRes.changes); - - // Prepare statements: delete any conflicting rule_id and insert - const deleteById = db.prepare('DELETE FROM rules WHERE rule_id = ?'); - const insert = db.prepare('INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)'); - - const inserted = []; - db.transaction(() => { - for (const r of data) { - const name = (r.name || '').trim(); - const skill_text = (r.skill_text || '').trim(); - const skill_description = (r.skill_description || '').trim(); - const skill_use = (r.skill_use || '').trim(); - const content = [skill_text, skill_description, skill_use ? `Use: ${skill_use}` : ''].filter(Boolean).join('\n\n'); - const rule_id = slugify(name); - // Remove any existing row with this rule_id (ensures 1:1 mapping to CSV) - try { - deleteById.run(rule_id); - } catch (e) { - // ignore - } - insert.run(rule_id, name, content || '', null, 'csv-import', 'CSV', 'skills'); - inserted.push(rule_id); - } - })(); - - const newCount = db.prepare('SELECT COUNT(*) as c FROM rules WHERE category = ?').get('skills').c; - console.log('Inserted rows from CSV:', inserted.length, 'DB now has skills rows:', newCount); - if (newCount !== inserted.length) { - console.warn('Count mismatch: inserted', inserted.length, 'but DB count is', newCount); - } - - // Ensure only CSV-sourced skills exist (sanity check) - const nonCsv = db.prepare("SELECT COUNT(*) as c FROM rules WHERE category = ? AND source != ?").get('skills', 'csv-import').c; - console.log('Non-CSV skill rows remaining:', nonCsv); - - // Output sample first 10 - const sample = db.prepare('SELECT rule_id, title FROM rules WHERE category = ? ORDER BY id LIMIT 10').all('skills'); - console.log('Sample rows:'); - sample.forEach((s, i) => console.log(`${i + 1}. ${s.rule_id} | ${s.title}`)); - - // final verification: write sync report - const report = { syncedAt: new Date().toISOString(), csvRows: data.length, inserted: inserted.length, dbSkills: newCount }; - fs.writeFileSync(path.join(outDir, `rules_skills_sync_report_${ts}.json`), JSON.stringify(report, null, 2), 'utf8'); - console.log('Sync report written'); - -} catch (e) { - console.error('Failure during sync:', e); - process.exit(1); -} finally { - db.close(); -} - -console.log('Done'); diff --git a/src/App.js b/src/App.js index 25a0f41..b2a16ff 100755 --- a/src/App.js +++ b/src/App.js @@ -1,3 +1,4 @@ +import React from 'react'; import './App.css'; import DeathwatchRoller from './components/DeathwatchRoller'; import RequisitionShop from './components/RequisitionShop'; @@ -36,6 +37,11 @@ function App() { } } + // Fetch players on mount + useEffect(() => { + fetchPlayers(); + }, []); + // Validate session on mount/refresh useEffect(() => { async function validate() { @@ -48,6 +54,18 @@ function App() { if (res.data && res.data.playerName) { setAuthedPlayer(res.data.playerName); localStorage.setItem('dw:shop:authedPlayer', JSON.stringify(res.data.playerName)); + + // Fetch full player data for the validated session + try { + const fullPlayerResponse = await axios.get(`/api/players/${res.data.playerName}`, { + headers: { 'x-session-id': sessionId } + }); + localStorage.setItem('dw:shop:playerData', JSON.stringify(fullPlayerResponse.data)); + info(`Session validation and player data fetch successful for: ${res.data.playerName}`, 'auth'); + } catch (playerFetchError) { + warn(`Failed to fetch full player data during session validation: ${playerFetchError.message}`, 'auth'); + } + info(`Session validation successful for: ${res.data.playerName}`, 'auth'); } else { warn('Session validation failed - invalid response', 'auth'); @@ -57,7 +75,7 @@ function App() { localStorage.removeItem('dw:shop:sessionId'); } } catch (err) { - logApiError('POST', '/api/sessions/validate', err); + logApiError('App', 'POST', '/api/sessions/validate', err); error(`Session validation error: ${err.message}`, 'auth'); setAuthedPlayer(''); setSessionId(''); @@ -93,7 +111,19 @@ function App() { setSessionId(response.data.sessionId); localStorage.setItem('dw:shop:authedPlayer', JSON.stringify(response.data.player.name)); localStorage.setItem('dw:shop:sessionId', JSON.stringify(response.data.sessionId)); - localStorage.setItem('dw:shop:playerData', JSON.stringify(response.data.player)); + + // Fetch full player data including tabInfo after successful login + try { + const fullPlayerResponse = await axios.get(`/api/players/${response.data.player.name}`, { + headers: { 'x-session-id': response.data.sessionId } + }); + localStorage.setItem('dw:shop:playerData', JSON.stringify(fullPlayerResponse.data)); + info(`Full player data loaded for: ${loginName}`, 'auth'); + } catch (playerFetchError) { + warn(`Failed to fetch full player data: ${playerFetchError.message}`, 'auth'); + // Store minimal player data as fallback + localStorage.setItem('dw:shop:playerData', JSON.stringify(response.data.player)); + } info(`Login successful for user: ${loginName}`, 'auth'); logUserAction('user', 'Login successful', { username: loginName }); @@ -111,7 +141,7 @@ function App() { setTimeout(() => setLoginMsg(''), 5000); } } catch (err) { - logApiError('POST', '/api/players/login', err); + logApiError('App', 'POST', '/api/players/login', err); error(`Login error for user: ${loginName} - ${err.message}`, 'auth'); setLoginMsg('Login failed. Please check your credentials and try again.'); @@ -176,16 +206,16 @@ function App() { }, []); const appBackgroundStyle = { - backgroundImage: `linear-gradient(rgba(2,6,23,0.65), rgba(15,23,42,0.65)), url('${appBackgroundUrl}')`, + backgroundImage: `linear-gradient(rgba(2,6,23,0.75), rgba(15,23,42,0.75)), url('${appBackgroundUrl}')`, backgroundRepeat: 'no-repeat', - backgroundPosition: 'center top', - backgroundSize: 'cover', + backgroundPosition: 'left center, right center', + backgroundSize: '25%, 25%', backgroundAttachment: 'fixed', - backgroundBlendMode: 'overlay' + backgroundBlendMode: 'multiply' }; return ( -
+
{/* Persistent Header */}
diff --git a/src/components/DeathwatchRoller.jsx b/src/components/DeathwatchRoller.jsx index d0e7683..666fe4d 100755 --- a/src/components/DeathwatchRoller.jsx +++ b/src/components/DeathwatchRoller.jsx @@ -1,4 +1,4 @@ -const { useEffect, useMemo, useState } = require('react') +import React, { useEffect, useMemo, useState } from 'react'; // Tooltip component for abbreviations function Tooltip({ children, text }) { diff --git a/src/components/PlayerManagement.jsx b/src/components/PlayerManagement.jsx index 788a339..8562882 100644 --- a/src/components/PlayerManagement.jsx +++ b/src/components/PlayerManagement.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; +import { XPBar } from './XPBar'; const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero']; @@ -500,7 +501,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
{player.name}
- {player.renown || 'None'} • RP: {player.requisitionPoints || 0} + {player.tabInfo?.renown || 'None'} • RP: {player.tabInfo?.rp || 0}
- {/* Player Stats */} -
-
- Total XP: {player.xp || 0} + {/* Player Stats with XP Bar */} +
+
+
+ Character: {player.tabInfo?.charName || 'Unnamed'} +
+
+ Available XP: {(player.tabInfo?.xp || 0) - (player.tabInfo?.xpSpent || 0)} +
-
- XP Spent: {player.xpSpent || 0} -
-
- Available XP: {(player.xp || 0) - (player.xpSpent || 0)} -
-
- Character: {player.charName || 'Unnamed'} +
+
Experience Progress
+
@@ -535,7 +542,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
@@ -545,7 +552,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
@@ -555,7 +562,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
@@ -565,7 +572,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
@@ -596,7 +603,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
{ players.forEach(player => { - const currentXP = player.xp || 0; + const currentXP = player.tabInfo?.xp || 0; gmSetXP(player.name, (currentXP + amount).toString()); }); }} /> @@ -614,7 +621,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
{ players.forEach(player => { - const currentRP = player.requisitionPoints || 0; + const currentRP = player.tabInfo?.rp || 0; gmSetRP(player.name, (currentRP + amount).toString()); }); }} /> @@ -640,7 +647,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) { className="text-xs px-3 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white transition-colors" onClick={() => { players.forEach(player => { - if ((player.requisitionPoints || 0) < 10) { + if ((player.tabInfo?.rp || 0) < 10) { gmSetRP(player.name, '50'); } }); diff --git a/src/components/RequisitionShop.jsx b/src/components/RequisitionShop.jsx index 1b85676..b70d5da 100755 --- a/src/components/RequisitionShop.jsx +++ b/src/components/RequisitionShop.jsx @@ -59,37 +59,40 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { } // Fetch shop items from the new database (public endpoint, no session needed) console.log('Fetching shop items'); - const itemsResponse = await axios.get('/api/shop/items'); + const itemsResponse = await axios.get('/api/shop'); console.log('Fetched shop items:', itemsResponse.data); - if (!itemsResponse.data || itemsResponse.data.length === 0) { - console.log('Warning: Shop items response was empty') + if (!itemsResponse.data || !itemsResponse.data.items) { + console.log('Warning: Shop items response was empty or missing items') + setItems([]); + return; } - // helper to safely get stats as object whether API returned string or object - const parseStats = s => { - if (!s) return {}; - if (typeof s === 'string') { - try { return JSON.parse(s); } catch (e) { return { raw: s }; } + // Flatten the categorized items into a single array + const allItems = []; + const itemsByCategory = itemsResponse.data.items; + + for (const category in itemsByCategory) { + if (Array.isArray(itemsByCategory[category])) { + itemsByCategory[category].forEach(item => { + // Only include items that have a cost > 0 (purchasable items) + const reqCost = item.req || 0; + if (reqCost > 0) { + allItems.push({ + id: item.id || `${category}-${item.name}`, + name: item.name, + category: category, + req: reqCost, + renown: item.renown || 'Any', + stats: item.stats || {}, + itemType: item.itemType || 'equipment' + }); + } + }); } - return s; } - const normalizedItems = itemsResponse.data.map(item => { - const statsObj = parseStats(item.stats); - return { - id: item.id, - name: item.name, - category: item.category, - cost: item.requisition_cost, - req: item.requisition_cost, - renown: item.renown_requirement, - desc: statsObj.description || '', - stats: statsObj - }; - }); - - setItems(normalizedItems); + setItems(allItems); } catch (error) { console.error('Error fetching data:', error); // If shop API fails, we can't load items. Keep players fallback behavior. @@ -104,18 +107,56 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { const p = players.find(p => p.name === authedPlayer); if (!p) return null; - // Support both old and new data structure - // Old data is in tabInfo, new data is directly on the player object + // Player data is stored in tabInfo structure const tabInfo = p.tabInfo || {}; return { ...p, id: p.id, - requisition_points: p.requisition_points !== undefined ? p.requisition_points : Number(tabInfo.rp || 0), - renown_level: p.renown_level || tabInfo.renown || 'None', - // Keep gear from tabInfo for now, as we transition to using the inventory table + requisition_points: Number(tabInfo.rp || 0), + renown_level: tabInfo.renown || 'None', + // Keep gear from tabInfo for inventory system gear: Array.isArray(tabInfo.gear) ? tabInfo.gear : [] }; }, [players, authedPlayer]); + + // Helper function to check if an item can be purchased + const canPurchaseItem = (item) => { + if (!currentPlayer) return false; + + const playerRP = currentPlayer.requisition_points || 0; + const playerRenown = currentPlayer.renown_level || 'None'; + const requiredRenown = item.renown || 'Any'; + + const hasEnoughRP = playerRP >= item.req; + const hasEnoughRenown = requiredRenown === 'Any' || + RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown); + + return hasEnoughRP && hasEnoughRenown; + }; + + // Helper function to get purchase button text and styling + const getPurchaseButtonInfo = (item) => { + if (!currentPlayer) return { text: 'Buy', disabled: true, className: 'px-3 py-1 rounded bg-slate-600 text-sm cursor-not-allowed' }; + + const playerRP = currentPlayer.requisition_points || 0; + const playerRenown = currentPlayer.renown_level || 'None'; + const requiredRenown = item.renown || 'Any'; + + const hasEnoughRP = playerRP >= item.req; + const hasEnoughRenown = requiredRenown === 'Any' || + RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown); + + if (!hasEnoughRP && !hasEnoughRenown) { + return { text: 'Need RP & Renown', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' }; + } else if (!hasEnoughRP) { + return { text: 'Need RP', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' }; + } else if (!hasEnoughRenown) { + return { text: 'Need Renown', disabled: true, className: 'px-3 py-1 rounded bg-orange-600 text-xs cursor-not-allowed' }; + } else { + return { text: 'Buy', disabled: false, className: 'px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 text-sm' }; + } + }; + const filteredItems = useMemo(()=>{ const q = search.trim().toLowerCase() return items.filter(i => { @@ -138,8 +179,8 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { // Check if player has enough RP const currentRp = currentPlayer.requisition_points || 0; - if (currentRp < item.cost) { - setErrorMsg(`Not enough Requisition Points. Need ${item.cost} RP but only have ${currentRp} RP.`); + if (currentRp < item.req) { + setErrorMsg(`Not enough Requisition Points. Need ${item.req} RP but only have ${currentRp} RP.`); return; } @@ -161,7 +202,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { await axios.post( '/api/shop/purchase', { - playerId: currentPlayer.id, + playerId: currentPlayer.name, // Use player name instead of id itemId: item.id, quantity: 1 }, @@ -174,7 +215,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { }); setPlayers(response.data); - setErrorMsg(`Successfully purchased ${item.name} for ${item.cost} RP`); + setErrorMsg(`Successfully purchased ${item.name} for ${item.req} RP`); } catch (error) { console.error('Failed to purchase item:', error); setErrorMsg(error.response?.data?.error || 'Failed to make purchase'); @@ -245,10 +286,37 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
{item.name}
{item.category}
-
{item.desc}
+ + {/* Display item stats */} + {item.stats && Object.keys(item.stats).length > 0 && ( +
+ {item.stats.damage && ( +
Damage: {item.stats.damage}
+ )} + {item.stats.class && ( +
Class: {item.stats.class}
+ )} + {item.stats.type && ( +
Type: {item.stats.type}
+ )} + {item.stats.protection && ( +
+ Protection: + {' '}Head: {item.stats.protection.head}, + Arms: {item.stats.protection.arms}, + Body: {item.stats.protection.body}, + Legs: {item.stats.protection.legs} +
+ )} + {item.stats.source && ( +
Source: {item.stats.source}
+ )} +
+ )} +
-
Cost: {item.cost} RP
+
Cost: {item.req} RP
{item.renown}
@@ -256,10 +324,10 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { {currentPlayer && ( )}
@@ -267,17 +335,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) { ))}
- {/* GM Panel moved to PlayerTab - GM controls (add/set RP/renown/reset PW) available in PlayerTab when GM is logged in */} -
-
-
- GM Panel -
-
- GM controls have been moved to the PlayerTab. When logged in as GM, you can add players, set RP and renown, reset passwords, and manage items. -
-
-
+ )}
diff --git a/src/tests/login.test.js b/src/tests/login.test.js new file mode 100644 index 0000000..e7965a6 --- /dev/null +++ b/src/tests/login.test.js @@ -0,0 +1,168 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import App from '../App'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios; + +// Mock localStorage +const localStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +global.localStorage = localStorageMock; + +describe('Login Functionality', () => { + beforeEach(() => { + jest.clearAllMocks(); + localStorageMock.getItem.mockReturnValue(null); + + // Mock axios to handle different endpoints + mockedAxios.get.mockImplementation((url) => { + if (url === '/api/players/names') { + return Promise.resolve({ + data: [ + { name: 'gm' }, + { name: 'anders' }, + { name: 'phillip' } + ] + }); + } + // For other GET requests (like /api/players/{name}) + return Promise.resolve({ + data: { + name: 'gm', + tabInfo: { + rp: 100, + xp: 1000, + xpSpent: 200, + renown: 'Respected', + charName: 'Game Master' + } + } + }); + }); + + // Default POST mock - will be overridden per test with mockResolvedValueOnce + mockedAxios.post.mockImplementation((url, data) => { + if (url === '/api/players/login') { + return Promise.resolve({ + data: { + success: true, + sessionId: 'default_session_id', + player: { name: data.name } + }, + status: 200 + }); + } + return Promise.resolve({ status: 200 }); + }); + }); + + test('login with GM account', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { + success: true, + sessionId: 'session_gm_12345', + player: { name: 'gm' } + }, + status: 200 + }); + + render(); + + // Wait for players list to load + await waitFor(() => { + expect(screen.getByText(/gm/i)).toBeInTheDocument(); + }); + + // Find login inputs + const nameInput = screen.getByPlaceholderText(/player name|username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + const loginButton = screen.getByRole('button', { name: /login|enter/i }); + + // Fill in login form with GM credentials + fireEvent.change(nameInput, { target: { value: 'gm' } }); + fireEvent.change(passwordInput, { target: { value: 'bongo' } }); + + // Click login button + fireEvent.click(loginButton); + + // Verify login was called with correct parameters + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/login', + { + name: 'gm', + password: 'bongo' + } + ); + }); + + // Verify success message appears + await waitFor(() => { + expect(screen.getByText(/login successful/i)).toBeInTheDocument(); + }, { timeout: 3000 }); + }); + + test('login with invalid password fails', async () => { + mockedAxios.post.mockRejectedValueOnce({ + response: { + status: 401, + data: { error: 'Invalid password' } + }, + message: 'Request failed with status code 401' + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/gm/i)).toBeInTheDocument(); + }); + + const nameInput = screen.getByPlaceholderText(/player name|username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + const loginButton = screen.getByRole('button', { name: /login|enter/i }); + + fireEvent.change(nameInput, { target: { value: 'gm' } }); + fireEvent.change(passwordInput, { target: { value: 'wrong_password' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText(/login failed/i)).toBeInTheDocument(); + }); + }); + + test('login with non-existent player fails', async () => { + mockedAxios.post.mockRejectedValueOnce({ + response: { + status: 404, + data: { error: 'Player not found' } + }, + message: 'Request failed with status code 404' + }); + + render(); + + await waitFor(() => { + expect(screen.getByText(/gm/i)).toBeInTheDocument(); + }); + + const nameInput = screen.getByPlaceholderText(/player name|username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + const loginButton = screen.getByRole('button', { name: /login|enter/i }); + + fireEvent.change(nameInput, { target: { value: 'nonexistent' } }); + fireEvent.change(passwordInput, { target: { value: '1234' } }); + fireEvent.click(loginButton); + + await waitFor(() => { + expect(screen.getByText(/login failed/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/tests/playerManagement.test.js b/src/tests/playerManagement.test.js index a25c8f7..3fe074a 100644 --- a/src/tests/playerManagement.test.js +++ b/src/tests/playerManagement.test.js @@ -23,11 +23,13 @@ describe('PlayerManagement Component', () => { const mockPlayers = [ { name: 'TestPlayer', - requisitionPoints: 50, - xp: 1000, - xpSpent: 200, - renown: 'Respected', - charName: 'Brother Testicus' + tabInfo: { + rp: 50, + xp: 1000, + xpSpent: 200, + renown: 'Respected', + charName: 'Brother Testicus' + } } ]; @@ -77,11 +79,20 @@ describe('PlayerManagement Component', () => { // Check if player is displayed await waitFor(() => { - expect(screen.getByText('TestPlayer')).toBeInTheDocument(); - expect(screen.getByText('Respected • RP: 50')).toBeInTheDocument(); - expect(screen.getByText('1000')).toBeInTheDocument(); // Total XP - expect(screen.getByText('200')).toBeInTheDocument(); // XP Spent - expect(screen.getByText('800')).toBeInTheDocument(); // Available XP + const playerName = screen.getByText('TestPlayer'); + expect(playerName).toBeInTheDocument(); + + // Check that all expected data is rendered somewhere in the document + const container = screen.getByText(/Player Management/); + const documentText = container.closest('body').textContent; + + expect(documentText).toContain('TestPlayer'); + expect(documentText).toContain('Total XP:'); + expect(documentText).toContain('1000'); + expect(documentText).toContain('XP Spent:'); + expect(documentText).toContain('200'); + expect(documentText).toContain('Available XP:'); + expect(documentText).toContain('800'); }); }); @@ -129,14 +140,13 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find RP management section and use current RP value (50) + // Find RP input by label const rpInputs = screen.getAllByDisplayValue('50'); - const rpInput = rpInputs.find(input => input.type === 'number'); - const rpSetButtons = screen.getAllByText('Set'); - const rpSetButton = rpSetButtons[0]; // First Set button should be for RP - - // Click set button with current value - fireEvent.click(rpSetButton); + expect(rpInputs.length).toBeGreaterThan(0); + + // Find all Set buttons and click the first one (RP) + const setButtons = screen.getAllByText('Set'); + fireEvent.click(setButtons[0]); await waitFor(() => { expect(mockedAxios.post).toHaveBeenCalledWith( @@ -163,14 +173,13 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find XP management section and use current XP value (1000) + // Find XP input by its value const xpInputs = screen.getAllByDisplayValue('1000'); - const xpInput = xpInputs.find(input => input.type === 'number'); - const xpSetButtons = screen.getAllByText('Set'); - const xpSetButton = xpSetButtons[1]; // Second Set button should be for XP - - // Click set button with current value - fireEvent.click(xpSetButton); + expect(xpInputs.length).toBeGreaterThan(0); + + // Find all Set buttons and click the second one (XP) + const setButtons = screen.getAllByText('Set'); + fireEvent.click(setButtons[1]); await waitFor(() => { expect(mockedAxios.post).toHaveBeenCalledWith( @@ -197,14 +206,13 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find XP Spent management section and use current value (200) + // Find XP Spent input by its value const xpSpentInputs = screen.getAllByDisplayValue('200'); - const xpSpentInput = xpSpentInputs.find(input => input.type === 'number'); - const xpSpentSetButtons = screen.getAllByText('Set'); - const xpSpentSetButton = xpSpentSetButtons[2]; // Third Set button should be for XP Spent - - // Click set button with current value - fireEvent.click(xpSpentSetButton); + expect(xpSpentInputs.length).toBeGreaterThan(0); + + // Find all Set buttons and click the third one (XP Spent) + const setButtons = screen.getAllByText('Set'); + fireEvent.click(setButtons[2]); await waitFor(() => { expect(mockedAxios.post).toHaveBeenCalledWith( @@ -231,13 +239,12 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find renown dropdown and use current value (Respected) + // Find renown dropdown by its value const renownSelect = screen.getByDisplayValue('Respected'); - const renownSetButtons = screen.getAllByText('Set'); - const renownSetButton = renownSetButtons[3]; // Fourth Set button should be for Renown - - // Click set button with current value - fireEvent.click(renownSetButton); + + // Find all Set buttons and click the fourth one (Renown) + const setButtons = screen.getAllByText('Set'); + fireEvent.click(setButtons[3]); await waitFor(() => { expect(mockedAxios.post).toHaveBeenCalledWith( @@ -325,11 +332,14 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find bulk XP giver - const bulkXPInput = screen.getByDisplayValue('100'); + // Find bulk XP input and button + const bulkXPInputs = screen.getAllByDisplayValue('100'); + expect(bulkXPInputs.length).toBeGreaterThan(0); + const giveXPButton = screen.getByText('Give XP to All'); - - fireEvent.change(bulkXPInput, { target: { value: '250' } }); + + // Change the value and click + fireEvent.change(bulkXPInputs[0], { target: { value: '250' } }); fireEvent.click(giveXPButton); // Check confirmation @@ -360,11 +370,13 @@ describe('PlayerManagement Component', () => { expect(screen.getByText('TestPlayer')).toBeInTheDocument(); }); - // Find bulk RP giver - const bulkRPInput = screen.getByDisplayValue('10'); + // Find bulk RP input and button + const bulkRPInputs = screen.getAllByDisplayValue('10'); + expect(bulkRPInputs.length).toBeGreaterThan(0); + const giveRPButton = screen.getByText('Give RP to All'); - fireEvent.change(bulkRPInput, { target: { value: '25' } }); + fireEvent.change(bulkRPInputs[0], { target: { value: '25' } }); fireEvent.click(giveRPButton); // Check confirmation @@ -402,12 +414,10 @@ describe('PlayerManagement Component', () => { // Try to set RP and expect error handling const rpInputs = screen.getAllByDisplayValue('50'); - const rpInput = rpInputs.find(input => input.type === 'number'); - const rpSetButtons = screen.getAllByText('Set'); - const rpSetButton = rpSetButtons[0]; + const setButtons = screen.getAllByText('Set'); - fireEvent.change(rpInput, { target: { value: '100' } }); - fireEvent.click(rpSetButton); + fireEvent.change(rpInputs[0], { target: { value: '100' } }); + fireEvent.click(setButtons[0]); // Should show error message await waitFor(() => { diff --git a/src/utils/logger.js b/src/utils/logger.js index 412ce65..98faa63 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -173,9 +173,9 @@ export const logApiCall = (component, method, url, data) => { export const logApiError = (component, method, url, error) => { logger.error(component, `API ${method} ${url} failed`, { - message: error.message, - status: error.response?.status, - data: error.response?.data + message: error?.message || String(error) || 'Unknown error', + status: error?.response?.status, + data: error?.response?.data }); }; diff --git a/src/utils/xpProgression.js b/src/utils/xpProgression.js new file mode 100644 index 0000000..b7093fd --- /dev/null +++ b/src/utils/xpProgression.js @@ -0,0 +1,183 @@ +/** + * XP PROGRESSION RULES FOR DEATHWATCH + * + * Accelerated progression for infrequent play sessions (monthly or less) + * Based on Deathwatch Core Rulebook + */ + +/** + * Standard Deathwatch XP Costs (from core rulebook) + */ +export const XP_COSTS = { + // Skill improvements + SKILL_BASIC_TRAINING: 100, // Learn a skill + SKILL_PLUS_10: 200, // +10 modifier + SKILL_PLUS_20: 300, // +20 modifier + SKILL_PLUS_30: 400, // +30 modifier + + // Characteristic increases (costs vary by characteristic) + CHARACTERISTIC: { + strength: 500, + toughness: 500, + ballistic: 500, + agility: 500, + intelligence: 500, + perception: 500, + fellowship: 500, + willpower: 500, + }, + + // Talent costs + TALENT_BASIC: 200, + TALENT_ADVANCED: 300, + + // Psychic powers + PSYCHIC_POWER: 300, +}; + +/** + * ACCELERATED PROGRESSION RULES + * For Game Masters running monthly or infrequent sessions + * + * Problem: In standard Deathwatch, characters advance very slowly. + * With monthly sessions, a character might take 2+ years to get 1 characteristic increase. + * + * Solution: Multiply base XP values by acceleration factor + */ + +export const ACCELERATION_TIERS = { + STANDARD: { + factor: 1.0, + description: 'Standard Deathwatch progression', + recommendation: 'Weekly+ sessions', + rationale: 'Players meet frequently, can take long-term advancement goals', + }, + + MONTHLY: { + factor: 2.5, + description: 'Accelerated for monthly sessions', + recommendation: 'Sessions ~2-4 times per month', + rationale: 'Approximately 2.5x XP awards to make meaningful progress between longer gaps', + examples: { + perSession: '250-500 XP per 4-hour session', + perMonth: '500-2000 XP per month', + charIncrease: 'Every 2-3 sessions instead of 5-8', + } + }, + + BIWEEKLY: { + factor: 1.5, + description: 'Accelerated for biweekly sessions', + recommendation: 'Sessions every 2 weeks', + rationale: 'Slight acceleration for semi-regular play', + examples: { + perSession: '150-300 XP per 4-hour session', + perMonth: '300-600 XP per month', + } + }, +}; + +/** + * RECOMMENDED XP AWARDS BY SESSION + * + * Award XP at the end of each session based on accomplishments + */ +export const SESSION_XP_AWARDS = { + // Base award (all players get this for showing up and participating) + BASE: { standard: 100, monthly: 250 }, + + // Mission/Objective completion + OBJECTIVE_COMPLETED: { standard: 200, monthly: 500 }, + OBJECTIVE_PARTIALLY: { standard: 100, monthly: 250 }, + + // Individual accomplishments + EXCEPTIONAL_ROLEPLAY: { standard: 50, monthly: 125 }, + CREATIVE_SOLUTION: { standard: 100, monthly: 250 }, + TACTICAL_VICTORY: { standard: 100, monthly: 250 }, + SURVIVED_MAJOR_THREAT: { standard: 100, monthly: 250 }, + + // Penalties (rare, for severely poor play) + CHARACTER_DEATH: { standard: -50, monthly: -125 }, + FRIENDLY_FIRE_INCIDENT: { standard: -25, monthly: -60 }, +}; + +/** + * Calculate total XP award for session + */ +export function calculateSessionXP(awards = [], accelerationFactor = 1.0) { + return awards.reduce((total, award) => total + award, 0) * accelerationFactor; +} + +/** + * XP to spend guide for new players + */ +export const XP_SPENDING_GUIDE = { + 'Quick Improvements (50-200 XP)': [ + 'Increase single skill by +10 (costs vary)', + 'Basic Talent prerequisite requirements', + ], + 'Medium Improvements (200-500 XP)': [ + 'New skill training + advancement', + 'Talent acquisition', + 'Start working toward Characteristic increase', + ], + 'Major Improvements (500+ XP)': [ + 'Characteristic increase (+1 to a characteristic)', + 'Advanced talents', + 'Psychic power acquisition', + 'Multiple skill increases', + ], +}; + +/** + * Level thresholds for visual progression + * Used by XP bar component + */ +export const XP_LEVEL_THRESHOLDS = { + description: 'Every 500 XP represents one "level" for visual progression', + visual: { + 0: 'Level 0 - Fresh recruit', + 500: 'Level 1 - Blooded warrior', + 1000: 'Level 2 - Proven combatant', + 1500: 'Level 3 - Experienced marine', + 2000: 'Level 4 - Hardened veteran', + 2500: 'Level 5 - Master of arms', + 3000: 'Level 6 - Chapter legend', + }, +}; + +/** + * EXAMPLE: How to accelerate XP for monthly sessions + * + * BEFORE: + * - Player gets 100 XP for completing a mission + * - To get 500 XP for a characteristic increase = 5 months + * + * AFTER (with 2.5x acceleration): + * - Player gets 250 XP for completing the same mission (100 * 2.5) + * - To get 500 XP for a characteristic increase = 2 months + * - More meaningful character progression visible between sessions + */ + +/** + * IMPLEMENTATION GUIDE: + * + * 1. In GM Kit, show which acceleration tier is active + * 2. When awarding XP, multiply by the acceleration factor + * 3. XP bar updates in real-time to show progress + * 4. Players can see they're making progress toward meaningful upgrades + * + * Example in GM action: + * const factor = ACCELERATION_TIERS.MONTHLY.factor; + * const xpToAward = 300 * factor; // 750 XP + * gmSetXP(playerName, currentXP + xpToAward); + */ + +export default { + XP_COSTS, + ACCELERATION_TIERS, + SESSION_XP_AWARDS, + calculateSessionXP, + XP_SPENDING_GUIDE, + XP_LEVEL_THRESHOLDS, +};