refactor: Migrate database from SQLite to MariaDB
Database Migration: - Add MariaDB connection configuration and initialization (mariadb.js) - Create MariaDB schema update script (mariadb-schema-update.sql) - Add migration scripts for SQLite to MariaDB transition: * migrate-sqlite-to-mariadb.js: Main migration script * migrate-inventory-to-gear.js: Inventory schema migration - Remove SQLite-specific implementation files and databases Route Updates: - Update all route handlers to use MariaDB instead of SQLite - Migrate routes: playerRoutes, sessionRoutes, shopRoutes, bestiaryRoutes, rulesRoutes, rulesStagingRoutes, weaponsRoutes - Remove SQLite-specific route files (playerRoutes-sqlite.js, sessionRoutes-sqlite.js) - Update server.js to initialize MariaDB and register new weapon routes Backend Scripts: - Remove old SQLite migration scripts (migrate-to-sqlite.js, server-sqlite.js) - Delete obsolete database utility scripts from backup-scripts/ Frontend Updates: - Update logger utility for improved error handling and debugging - Enhance PlayerManagement component with better state management - Improve RequisitionShop component for MariaDB integration - Update DeathwatchRoller with performance improvements - Add login test suite (login.test.js) - Add XP progression utility (xpProgression.js) - Update dependencies in package.json and package-lock.json This migration improves: - Database scalability and performance - Transaction support for complex operations - Better data integrity and ACID compliance - Simplified deployment and backup procedures
This commit is contained in:
96
database/mariadb-schema-update.sql
Normal file
96
database/mariadb-schema-update.sql
Normal file
@@ -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';
|
||||
408
database/mariadb.js
Normal file
408
database/mariadb.js
Normal file
@@ -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
|
||||
};
|
||||
56
database/migrate-inventory-to-gear.js
Normal file
56
database/migrate-inventory-to-gear.js
Normal file
@@ -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();
|
||||
392
database/migrate-sqlite-to-mariadb.js
Normal file
392
database/migrate-sqlite-to-mariadb.js
Normal file
@@ -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);
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
235
database/routes/playerRoutes.js
Executable file → Normal file
235
database/routes/playerRoutes.js
Executable file → Normal file
@@ -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;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user