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:
@@ -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';
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
Executable → Regular
+130
-105
@@ -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;
|
||||
|
||||
+108
-51
@@ -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;
|
||||
+35
-38
@@ -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.
Generated
+205
-983
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -2,19 +2,20 @@
|
||||
"name": "deathwatch-roller",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:5000",
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"bcrypt": "^5.1.0",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"mysql2": "^3.14.4",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pdfjs-dist": "^3.9.179",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sqlite3": "^5.1.7",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
function fetchUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { 'User-Agent': 'dwroller-bot/1.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => data += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sanitizeText(s) {
|
||||
if (!s) return '';
|
||||
let t = String(s);
|
||||
t = t.replace(/Explore More/ig, '');
|
||||
t = t.replace(/Skip to content/ig, '');
|
||||
t = t.replace(/40k-?RPG-?FFG Wiki/ig, '');
|
||||
t = t.replace(/Explore Main Page/ig, '');
|
||||
t = t.replace(/^(Category:|Special:|Local sitemap).*/gi, '');
|
||||
t = t.replace(/\[\d+\]/g, '');
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
t = t.replace(/^This (article|page) .*/i, '');
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
function extractContentFromFandom(html) {
|
||||
const out = { paragraphs: [], sourceLines: [] };
|
||||
const m = html.match(/<div[^>]+class="mw-parser-output"[^>]*>([\s\S]*?)<div class="printfooter">/i);
|
||||
const block = m ? m[1] : html;
|
||||
|
||||
const pushText = (txt) => {
|
||||
if (!txt) return;
|
||||
let clean = txt.replace(/<[^>]+>/g, '').replace(/\[\d+\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return;
|
||||
if (/^Source[:\s]/i.test(clean)) { out.sourceLines.push(clean); return; }
|
||||
out.paragraphs.push(clean);
|
||||
};
|
||||
|
||||
const paraRe = /<p[^>]*>([\s\S]*?)<\/p>/ig;
|
||||
let p;
|
||||
while ((p = paraRe.exec(block)) !== null) pushText(p[1]);
|
||||
|
||||
if (out.paragraphs.length < 2) {
|
||||
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/ig;
|
||||
while ((p = liRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
if (out.paragraphs.length < 2) {
|
||||
const ddRe = /<dd[^>]*>([\s\S]*?)<\/dd>/ig;
|
||||
while ((p = ddRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
if (out.paragraphs.length < 2) {
|
||||
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/ig;
|
||||
while ((p = tdRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
|
||||
out.paragraphs = out.paragraphs.filter(p => p && p.length > 20 && !/(?:Explore|Skip to content|Advertisement)/i.test(p));
|
||||
out.paragraphs = Array.from(new Set(out.paragraphs));
|
||||
return out;
|
||||
}
|
||||
|
||||
function findUseText(paragraphs) {
|
||||
for (const p of paragraphs) {
|
||||
if (/^(Use|Usage)[:\s]/i.test(p) || /\bUse[:\s]/i.test(p)) return p;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mode = (process.argv[2] || 'preview').toLowerCase();
|
||||
if (!['preview','commit'].includes(mode)) { console.error('Mode must be preview or commit'); process.exit(1); }
|
||||
|
||||
const q = db.prepare("SELECT id, title, content FROM rules WHERE category = 'talents' AND (content IS NULL OR trim(content) = '' OR length(trim(content)) < 30) ORDER BY id");
|
||||
const rows = q.all();
|
||||
console.log('Found', rows.length, 'talents with missing/short content');
|
||||
const results = [];
|
||||
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const title = (r.title || '').replace(/\s*\(Talent\)\s*$/i,'').trim();
|
||||
const urlTitle = encodeURIComponent(title.replace(/ /g, '_'));
|
||||
const url = `https://40k-rpg-ffg.fandom.com/wiki/${urlTitle}`;
|
||||
console.log('Fetching', title);
|
||||
const res = await fetchUrl(url);
|
||||
if (res.status !== 200) {
|
||||
console.warn('Failed to fetch', title, 'status', res.status);
|
||||
results.push({ id: r.id, title: r.title, status: 'fetch_failed', statusCode: res.status });
|
||||
continue;
|
||||
}
|
||||
const block = extractContentFromFandom(res.body);
|
||||
const orig = block.paragraphs || [];
|
||||
const paragraphs = orig.map(p => sanitizeText(p)).filter(Boolean);
|
||||
const main = paragraphs.length ? paragraphs[0] : '';
|
||||
const use = findUseText(paragraphs) || '';
|
||||
const descParts = paragraphs.filter(p => p !== main && p !== use);
|
||||
const description = descParts.join('\n\n');
|
||||
const newContent = [main, description, use ? `Use: ${use.replace(/^Use[:\s]*/i,'')}` : ''].filter(Boolean).join('\n\n');
|
||||
|
||||
results.push({ id: r.id, title: r.title, fetchedTitle: title, url, oldContent: r.content || '', newContent: newContent || '', extracted_paragraphs: orig });
|
||||
|
||||
if (mode === 'commit' && newContent && newContent.trim().length > 20) {
|
||||
const upd = db.prepare('UPDATE rules SET content = ?, source = ?, source_abbr = ? WHERE id = ?');
|
||||
upd.run(newContent, 'fandom', 'FAN', r.id);
|
||||
console.log('Updated id', r.id, title);
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
} catch (e) {
|
||||
console.error('Error processing id', r.id, e && e.message ? e.message : e);
|
||||
results.push({ id: r.id, title: r.title, status: 'error', error: e && e.message });
|
||||
}
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g,'-');
|
||||
const out = { generatedAt: new Date().toISOString(), mode, count: results.length, items: results };
|
||||
const outPath = path.join('/tmp', `talents_fill_${mode}_${ts}.json`);
|
||||
fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
|
||||
console.log('Wrote report to', outPath);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err && err.stack ? err.stack : err); try{db.close()}catch(e){}; process.exit(1); });
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const { stagingHelpers } = require('../database/sqlite-db');
|
||||
|
||||
const input = process.argv[2] || 'database/backups/sanitized-rules-test.json';
|
||||
if (!fs.existsSync(input)) {
|
||||
console.error('Input not found', input);
|
||||
process.exit(1);
|
||||
}
|
||||
const data = JSON.parse(fs.readFileSync(input,'utf8'));
|
||||
if (!data || !Array.isArray(data.sanitized)) {
|
||||
console.error('Expected file with { sanitized: [...] }');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const item of data.sanitized) {
|
||||
try {
|
||||
stagingHelpers.insert(item);
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error('Failed insert staging', e && e.message);
|
||||
}
|
||||
}
|
||||
console.log('Imported to staging:', count);
|
||||
@@ -1,17 +0,0 @@
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
function listSkills(limit = 50) {
|
||||
try {
|
||||
const rows = db.prepare(`SELECT rule_id as id, title, content, page, source, source_abbr as sourceAbbr, category FROM rules WHERE category = ? ORDER BY id LIMIT ?`).all('skills', limit);
|
||||
console.log(`Found ${rows.length} skill rows (showing up to ${limit}):`);
|
||||
rows.forEach((r, i) => {
|
||||
console.log(`${i + 1}. ${r.id} | ${r.title} | page=${r.page} | source=${r.source} | sourceAbbr=${r.sourceAbbr}`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to query skills:', e);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
listSkills(200);
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const dbPath = path.join(__dirname, '../database/sqlite/deathwatch.db');
|
||||
const backupPath = dbPath + '.backup.' + Date.now();
|
||||
|
||||
function safeReadJSON(p) {
|
||||
if (!fs.existsSync(p)) return null;
|
||||
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (e) { console.error('JSON parse error', p, e.message); return null; }
|
||||
}
|
||||
|
||||
console.log('=== MIGRATE JSON DATA INTO SQLITE ===');
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
console.error('DB not found at', dbPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
console.log('Backup created:', backupPath);
|
||||
|
||||
const db = new Database(dbPath);
|
||||
|
||||
// Create consolidated tables
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS armour (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
req INTEGER DEFAULT 0,
|
||||
renown TEXT DEFAULT 'None',
|
||||
category TEXT,
|
||||
stats TEXT,
|
||||
source TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS weapons (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
req INTEGER DEFAULT 0,
|
||||
renown TEXT DEFAULT 'None',
|
||||
category TEXT,
|
||||
stats TEXT,
|
||||
source TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bestiary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
book TEXT,
|
||||
page TEXT,
|
||||
pdf TEXT,
|
||||
stats TEXT,
|
||||
profile TEXT,
|
||||
snippet TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id TEXT UNIQUE,
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
page INTEGER,
|
||||
source TEXT,
|
||||
source_abbr TEXT,
|
||||
category TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
const insertArmour = db.prepare(`INSERT OR IGNORE INTO armour (name, req, renown, category, stats, source) VALUES (?, ?, ?, ?, ?, ?)`);
|
||||
const insertWeapon = db.prepare(`INSERT OR IGNORE INTO weapons (name, req, renown, category, stats, source) VALUES (?, ?, ?, ?, ?, ?)`);
|
||||
const insertBestiary = db.prepare(`INSERT INTO bestiary (name, book, page, pdf, stats, profile, snippet) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insertRule = db.prepare(`INSERT OR IGNORE INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)`);
|
||||
|
||||
let totals = { armour:0, weapons:0, bestiary:0, rules:0 };
|
||||
|
||||
// Import armour
|
||||
const armourFile = path.join(__dirname, '../database/public/deathwatch-armor.json');
|
||||
const armourData = safeReadJSON(armourFile);
|
||||
if (armourData) {
|
||||
const categories = Object.keys(armourData);
|
||||
categories.forEach(cat => {
|
||||
const arr = armourData[cat];
|
||||
if (!Array.isArray(arr)) return;
|
||||
const insert = insertArmour;
|
||||
db.transaction(() => {
|
||||
for (const item of arr) {
|
||||
const stats = JSON.stringify(item.stats || {});
|
||||
const src = (item.stats && item.stats.source) || '';
|
||||
insert.run(item.name || '(unnamed)', item.req || 0, item.renown || 'None', item.category || cat, stats, src);
|
||||
totals.armour++;
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Import weapons
|
||||
const weaponsFile = path.join(__dirname, '../database/public/deathwatch-weapons-comprehensive.json');
|
||||
const weaponsData = safeReadJSON(weaponsFile);
|
||||
if (weaponsData) {
|
||||
// many weapon files use keys like rangedWeapons, meleeWeapons
|
||||
Object.keys(weaponsData).forEach(k => {
|
||||
const arr = weaponsData[k];
|
||||
if (!Array.isArray(arr)) return;
|
||||
db.transaction(() => {
|
||||
for (const w of arr) {
|
||||
const stats = JSON.stringify(w.stats || {});
|
||||
const src = (w.stats && w.stats.source) || '';
|
||||
insertWeapon.run(w.name || '(unnamed)', w.req || 0, w.renown || 'Any', w.category || k, stats, src);
|
||||
totals.weapons++;
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
// Import bestiary
|
||||
const bestiaryFile = path.join(__dirname, '../database/deathwatch-bestiary-extracted.json');
|
||||
const bestiaryData = safeReadJSON(bestiaryFile);
|
||||
if (bestiaryData && Array.isArray(bestiaryData.results)) {
|
||||
db.transaction(() => {
|
||||
for (const e of bestiaryData.results) {
|
||||
const stats = JSON.stringify(e.stats || {});
|
||||
const profile = JSON.stringify(e.profile || {});
|
||||
const name = e.bestiaryName || e.name || '(unnamed)';
|
||||
insertBestiary.run(name, e.book || '', e.page || '', e.pdf || '', stats, profile, e.stats && e.stats.snippet ? e.stats.snippet : '');
|
||||
totals.bestiary++;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Import rules
|
||||
const rulesFile = path.join(__dirname, '../database/rules/rules-database.json');
|
||||
const rulesData = safeReadJSON(rulesFile);
|
||||
if (rulesData && Array.isArray(rulesData.rules)) {
|
||||
db.transaction(() => {
|
||||
for (const r of rulesData.rules) {
|
||||
insertRule.run(r.id || null, r.title || '', r.content || '', r.page || null, r.source || '', r.sourceAbbr || '', r.category || 'general');
|
||||
totals.rules++;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
console.log('Import totals:', totals);
|
||||
|
||||
// Show row counts from DB for verification
|
||||
const counts = {
|
||||
armour: db.prepare('SELECT COUNT(*) as c FROM armour').get().c,
|
||||
weapons: db.prepare('SELECT COUNT(*) as c FROM weapons').get().c,
|
||||
bestiary: db.prepare('SELECT COUNT(*) as c FROM bestiary').get().c,
|
||||
rules: db.prepare('SELECT COUNT(*) as c FROM rules').get().c
|
||||
};
|
||||
|
||||
console.log('DB row counts:', counts);
|
||||
|
||||
// Print a small sample from each table
|
||||
console.log('\nSample armour:', db.prepare('SELECT name, category, stats FROM armour LIMIT 3').all());
|
||||
console.log('\nSample weapons:', db.prepare('SELECT name, category, stats FROM weapons LIMIT 3').all());
|
||||
console.log('\nSample bestiary:', db.prepare('SELECT name, book, snippet FROM bestiary LIMIT 3').all());
|
||||
console.log('\nSample rules:', db.prepare('SELECT rule_id, title FROM rules LIMIT 3').all());
|
||||
|
||||
db.close();
|
||||
console.log('\nMigration complete. DB backed up at', backupPath);
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { db, logToFile } = require('../database/sqlite-db');
|
||||
|
||||
function nowTs() { return new Date().toISOString().replace(/[:.]/g,'-'); }
|
||||
|
||||
function titleCase(str) {
|
||||
return str.toLowerCase().split(/\s+/).map(w => {
|
||||
if (!w) return '';
|
||||
return w[0].toUpperCase() + w.slice(1);
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
function cleanTitle(title) {
|
||||
if (!title) return title;
|
||||
const letters = title.replace(/[^A-Za-z]/g,'');
|
||||
const uppers = (title.match(/[A-Z]/g) || []).length;
|
||||
// if mostly uppercase, convert to title case
|
||||
if (letters && (uppers / letters.length) > 0.5) {
|
||||
return titleCase(title.replace(/\s+/g,' ').trim());
|
||||
}
|
||||
// otherwise trim
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
function cleanContent(text) {
|
||||
if (!text) return text;
|
||||
let s = String(text);
|
||||
s = s.replace(/\r\n/g, '\n');
|
||||
s = s.replace(/[ \t]+/g, ' ');
|
||||
// remove hyphenation at line breaks
|
||||
s = s.replace(/-\n\s*/g, '');
|
||||
// collapse more than 2 newlines into paragraph breaks
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
// join lines that look like soft-wrapped lines: a line break between
|
||||
// a non-punctuation end and a lowercase/digit start
|
||||
s = s.replace(/([^\.\!\?\:\;\"\'\)\]\}])\n(\s*[a-z0-9])/g, '$1 $2');
|
||||
// trim spaces at start/end of lines
|
||||
s = s.split('\n').map(l => l.trim()).join('\n');
|
||||
// collapse repeated spaces
|
||||
s = s.replace(/ {2,}/g, ' ');
|
||||
// trim overall
|
||||
s = s.trim();
|
||||
return s;
|
||||
}
|
||||
|
||||
function backupRules(rows) {
|
||||
const backupDir = path.join(__dirname, '..', 'database', 'backups');
|
||||
if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true });
|
||||
const file = path.join(backupDir, `rules-backup-${nowTs()}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify({ backedAt: new Date().toISOString(), count: rows.length, rows }, null, 2), 'utf8');
|
||||
return file;
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log('Backing up rules table and normalizing content...');
|
||||
const rows = db.prepare('SELECT id, title, content FROM rules').all();
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log('No rules found in DB. Exiting.');
|
||||
return;
|
||||
}
|
||||
const backupFile = backupRules(rows);
|
||||
console.log('Backup written to', backupFile);
|
||||
|
||||
const updateStmt = db.prepare('UPDATE rules SET title = ?, content = ? WHERE id = ?');
|
||||
let changed = 0;
|
||||
db.transaction(() => {
|
||||
for (const r of rows) {
|
||||
const cleanedTitle = cleanTitle(r.title || '');
|
||||
const cleanedContent = cleanContent(r.content || '');
|
||||
if ((cleanedTitle !== (r.title||'').trim()) || (cleanedContent !== (r.content||'').trim())) {
|
||||
updateStmt.run(cleanedTitle, cleanedContent, r.id);
|
||||
changed++;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
console.log(`Normalization complete. Rows updated: ${changed}`);
|
||||
logToFile('normalize-rules-db: completed', { updated: changed });
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,48 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
const outDir = path.join(__dirname, '..', 'database', 'backups');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
try {
|
||||
// Select rows to purge: any rule where source != 'csv-import' OR page contains 'p.1' (case-insensitive)
|
||||
const rowsToPurge = db.prepare("SELECT * FROM rules WHERE source != ? OR (page IS NOT NULL AND lower(page) LIKE '%p.1%')").all('csv-import');
|
||||
console.log('Found rows to purge:', rowsToPurge.length);
|
||||
const backupFile = path.join(outDir, `purge_non_csv_rules_backup_${ts}.json`);
|
||||
fs.writeFileSync(backupFile, JSON.stringify({ purgedAt: new Date().toISOString(), count: rowsToPurge.length, rows: rowsToPurge }, null, 2), 'utf8');
|
||||
console.log('Backup written to', backupFile);
|
||||
|
||||
if (rowsToPurge.length === 0) {
|
||||
console.log('Nothing to purge');
|
||||
db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Delete by id in transaction
|
||||
const del = db.prepare('DELETE FROM rules WHERE id = ?');
|
||||
db.transaction(() => {
|
||||
for (const r of rowsToPurge) {
|
||||
del.run(r.id);
|
||||
}
|
||||
})();
|
||||
|
||||
const remaining = db.prepare("SELECT source, COUNT(*) as c FROM rules GROUP BY source ORDER BY c DESC").all();
|
||||
console.log('Remaining rows by source:', remaining);
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) as c FROM rules').get().c;
|
||||
console.log('Total rules now in DB:', total);
|
||||
|
||||
const report = { purgedAt: new Date().toISOString(), purgedCount: rowsToPurge.length, remaining, total };
|
||||
fs.writeFileSync(path.join(outDir, `purge_non_csv_rules_report_${ts}.json`), JSON.stringify(report, null, 2), 'utf8');
|
||||
console.log('Purge report written');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error during purge:', e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log('Done');
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
function fetchUrl(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { headers: { 'User-Agent': 'dwroller-bot/1.0' } }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => data += c);
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: data }));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function extractContentFromFandom(html) {
|
||||
const out = { paragraphs: [], headings: [], sourceLines: [] };
|
||||
const m = html.match(/<div[^>]+class="mw-parser-output"[^>]*>([\s\S]*?)<div class="printfooter">/i);
|
||||
const block = m ? m[1] : html;
|
||||
|
||||
const pushText = (txt) => {
|
||||
if (!txt) return;
|
||||
let clean = txt.replace(/<[^>]+>/g, '')
|
||||
.replace(/\[\d+\]/g, '')
|
||||
.replace(/\s+/g, ' ').trim();
|
||||
if (!clean) return;
|
||||
if (/^Source[:\s]/i.test(clean)) {
|
||||
out.sourceLines.push(clean);
|
||||
return;
|
||||
}
|
||||
out.paragraphs.push(clean);
|
||||
};
|
||||
|
||||
const paraRe = /<p[^>]*>([\s\S]*?)<\/p>/ig;
|
||||
let p;
|
||||
while ((p = paraRe.exec(block)) !== null) pushText(p[1]);
|
||||
|
||||
if (out.paragraphs.length < 2) {
|
||||
const liRe = /<li[^>]*>([\s\S]*?)<\/li>/ig;
|
||||
while ((p = liRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
if (out.paragraphs.length < 2) {
|
||||
const ddRe = /<dd[^>]*>([\s\S]*?)<\/dd>/ig;
|
||||
while ((p = ddRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
if (out.paragraphs.length < 2) {
|
||||
const tdRe = /<td[^>]*>([\s\S]*?)<\/td>/ig;
|
||||
while ((p = tdRe.exec(block)) !== null) pushText(p[1]);
|
||||
}
|
||||
|
||||
const hRe = /<h[2-3][^>]*>([\s\S]*?)<\/h[2-3]>/ig;
|
||||
let h;
|
||||
while ((h = hRe.exec(block)) !== null) {
|
||||
const ht = h[1].replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (ht.length) out.headings.push(ht);
|
||||
}
|
||||
|
||||
const noiseRe = /(?:Explore|Skip to content|Advertisement|History|Main Page|Discuss|Community|Interactive Maps|Recently Changed|Explore More|All Pages|Pages|Recent Blog Posts|Recently Changed Pages|Explore Main Page)/i;
|
||||
out.paragraphs = out.paragraphs.filter(p => {
|
||||
if (noiseRe.test(p)) return false;
|
||||
if (p.length < 30) return false;
|
||||
if (/^\s*\w+(\s+\w+){0,2}\s*$/.test(p) && p.split(' ').length <= 3) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
out.paragraphs = Array.from(new Set(out.paragraphs));
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeText(s) {
|
||||
if (!s) return '';
|
||||
let t = String(s);
|
||||
t = t.replace(/Explore More/ig, '');
|
||||
t = t.replace(/Skip to content/ig, '');
|
||||
t = t.replace(/40k-?RPG-?FFG Wiki/ig, '');
|
||||
t = t.replace(/Explore Main Page/ig, '');
|
||||
t = t.replace(/^(Category:|Special:|Local sitemap).*/gi, '');
|
||||
t = t.replace(/\[\d+\]/g, '');
|
||||
t = t.replace(/\s+/g, ' ').trim();
|
||||
t = t.replace(/^This (article|page) .*/i, '');
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
async function repair({ commit = false } = {}) {
|
||||
// find problem rows: contain common nav noise OR very short content OR content starts with 'Source:' only
|
||||
const q = `SELECT title, content FROM rules WHERE category='skills' AND (content LIKE '%Explore More%' OR content LIKE '%Skip to content%' OR length(content) < 120 OR content LIKE 'Source:%' ) ORDER BY title`;
|
||||
const rows = db.prepare(q).all();
|
||||
console.log('Found', rows.length, 'skills to inspect');
|
||||
if (!rows.length) { db.close(); return; }
|
||||
|
||||
const results = [];
|
||||
for (const r of rows) {
|
||||
try {
|
||||
const title = r.title;
|
||||
const urlTitle = encodeURIComponent(title.replace(/ /g, '_'));
|
||||
const url = `https://40k-rpg-ffg.fandom.com/wiki/${urlTitle}`;
|
||||
console.log('Fetching', title);
|
||||
const res = await fetchUrl(url);
|
||||
if (res.status !== 200) {
|
||||
console.warn('Fetch failed', title, res.status);
|
||||
continue;
|
||||
}
|
||||
const block = extractContentFromFandom(res.body);
|
||||
const paras = block.paragraphs.map(sanitizeText).filter(Boolean);
|
||||
const use = paras.find(p => /^(Use|Usage)[:\s]/i.test(p)) || '';
|
||||
const descParts = paras.filter(p => p !== use);
|
||||
let newContent = '';
|
||||
if (descParts.length) {
|
||||
// prefer the longest paragraph as primary
|
||||
const primary = descParts.reduce((a,b)=> a.length>=b.length?a:b,'');
|
||||
const others = descParts.filter(p=>p!==primary);
|
||||
newContent = [primary, others.join('\n\n')].filter(Boolean).join('\n\n');
|
||||
}
|
||||
if (block.sourceLines && block.sourceLines.length) {
|
||||
newContent = (newContent ? newContent + '\n\n' : '') + block.sourceLines.join(' | ');
|
||||
}
|
||||
if (!newContent) {
|
||||
// nothing useful extracted, skip
|
||||
console.log('No useful content for', title);
|
||||
continue;
|
||||
}
|
||||
results.push({ title, old: r.content, newContent, url });
|
||||
} catch (e) {
|
||||
console.error('Error for', r.title, e && e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g,'-');
|
||||
const previewPath = path.join('/tmp', `repair_skills_preview_${ts}.json`);
|
||||
fs.writeFileSync(previewPath, JSON.stringify({ generatedAt: new Date().toISOString(), count: results.length, items: results }, null, 2), 'utf8');
|
||||
console.log('Wrote preview to', previewPath);
|
||||
|
||||
if (!commit) { db.close(); return; }
|
||||
|
||||
// apply updates
|
||||
const update = db.prepare('UPDATE rules SET content = ?, source = ?, source_abbr = ? WHERE title = ?');
|
||||
let changed = 0;
|
||||
db.transaction(() => {
|
||||
for (const it of results) {
|
||||
try {
|
||||
update.run(it.newContent, 'https://40k-rpg-ffg.fandom.com', 'fandom', it.title);
|
||||
changed++;
|
||||
} catch (e) {
|
||||
console.error('Failed update', it.title, e && e.message);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
const commitPath = path.join('/tmp', `repair_skills_committed_${ts}.json`);
|
||||
fs.writeFileSync(commitPath, JSON.stringify({ committedAt: new Date().toISOString(), changed, items: results.map(r=>({title:r.title})) }, null, 2), 'utf8');
|
||||
console.log('Committed', changed, 'rows. Details in', commitPath);
|
||||
db.close();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const commit = (process.argv[2] === '--commit');
|
||||
repair({ commit }).then(()=>process.exit(0)).catch(err=>{ console.error(err); db.close(); process.exit(1); });
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
function nowTs() { return new Date().toISOString().replace(/[:.]/g,'-'); }
|
||||
|
||||
function charEntropy(s) {
|
||||
if (!s || s.length === 0) return 0;
|
||||
const freq = {};
|
||||
for (const ch of s) freq[ch] = (freq[ch]||0) + 1;
|
||||
const len = s.length;
|
||||
let ent = 0;
|
||||
for (const k in freq) {
|
||||
const p = freq[k]/len;
|
||||
ent -= p * Math.log2(p);
|
||||
}
|
||||
return ent;
|
||||
}
|
||||
|
||||
function scoreText(text) {
|
||||
if (!text) return {score:0,metrics:{}};
|
||||
const s = String(text);
|
||||
const length = s.length;
|
||||
const letters = s.replace(/[^A-Za-z]/g,'');
|
||||
const upper = (s.match(/[A-Z]/g)||[]).length;
|
||||
const digits = (s.match(/[0-9]/g)||[]).length;
|
||||
const nonAlphaNum = (s.match(/[^A-Za-z0-9\s\.,;:\'"\-()\[\]\/\\]/g)||[]).length;
|
||||
const punctuation = (s.match(/[\.,;:\!\?\-\(\)\[\]"\']/g)||[]).length;
|
||||
const newlines = (s.match(/\n/g)||[]).length;
|
||||
const lines = s.split(/\n/);
|
||||
const shortLines = lines.filter(l => l.trim().length > 0 && l.trim().length < 40).length;
|
||||
const avgWordLen = (s.match(/\w+/g)||[]).reduce((a,w)=>a+w.length,0)/Math.max(1,(s.match(/\w+/g)||[]).length);
|
||||
const entropy = charEntropy(s);
|
||||
|
||||
const upperRatio = letters.length ? upper/letters.length : 0;
|
||||
const nonAlphaRatio = length ? nonAlphaNum/length : 0;
|
||||
const newlineDensity = length ? newlines/length : 0;
|
||||
const shortLineRatio = lines.length ? shortLines/lines.length : 0;
|
||||
const punctDensity = length ? punctuation/length : 0;
|
||||
|
||||
// Score: higher for uppercase-heavy, non-alpha junk, many newlines, many short lines, low avg word length, high entropy
|
||||
// Weights chosen empirically to bring noisy texts to the top.
|
||||
const score = (
|
||||
upperRatio * 2.5 +
|
||||
nonAlphaRatio * 4.0 +
|
||||
newlineDensity * 3.0 +
|
||||
shortLineRatio * 1.6 +
|
||||
(1/Math.max(1, avgWordLen)) * 1.2 +
|
||||
(entropy/6.0) * 1.0 +
|
||||
punctDensity * 0.8
|
||||
) * 100;
|
||||
|
||||
return { score, metrics: { length, upper, letters: letters.length, upperRatio, nonAlphaNum, nonAlphaRatio, newlines, newlineDensity, lines: lines.length, shortLines, shortLineRatio, avgWordLen, entropy, punctDensity } };
|
||||
}
|
||||
|
||||
function main() {
|
||||
const rows = db.prepare('SELECT id, title, page, source, content FROM rules').all();
|
||||
if (!rows || rows.length === 0) {
|
||||
console.log('No rules found');
|
||||
return;
|
||||
}
|
||||
const scored = rows.map(r => {
|
||||
const text = (r.title||'') + '\n' + (r.content||'');
|
||||
const res = scoreText(text);
|
||||
return { id: r.id, title: (r.title||'').trim(), page: r.page, source: r.source, score: Math.round(res.score*100)/100, metrics: res.metrics, snippet: (r.content||'').replace(/\n/g,' ').slice(0,240) };
|
||||
});
|
||||
scored.sort((a,b)=>b.score - a.score);
|
||||
|
||||
const top = scored.slice(0,40);
|
||||
const outDir = path.join(__dirname,'..','database','backups');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
const outFile = path.join(outDir, `rules-noise-report-${nowTs()}.json`);
|
||||
fs.writeFileSync(outFile, JSON.stringify({ generatedAt: new Date().toISOString(), count: scored.length, top }, null, 2), 'utf8');
|
||||
|
||||
console.log('Noise scoring complete. Total rules:', scored.length);
|
||||
console.log('Report written to', outFile);
|
||||
console.log('\nTop 25 noisy rules:');
|
||||
top.slice(0,25).forEach((r,i)=>{
|
||||
console.log(`${String(i+1).padStart(2,' ')}. id=${r.id} score=${r.score} title="${r.title}" page=${r.page} source=${r.source}`);
|
||||
console.log(' snippet:', r.snippet.replace(/\s+/g,' ').slice(0,200));
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,150 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/sqlite-db');
|
||||
|
||||
if (process.argv.length < 3) {
|
||||
console.error('Usage: node scripts/sync-skills-csv-to-db.js <csv-path>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const csvPath = process.argv[2];
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.error('CSV file not found:', csvPath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function parseCSV(content) {
|
||||
// Minimal RFC4180-ish parser supporting quoted fields and commas
|
||||
const lines = [];
|
||||
let cur = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const ch = content[i];
|
||||
const nxt = content[i + 1];
|
||||
if (ch === '"') {
|
||||
if (inQuotes && nxt === '"') { // escaped quote
|
||||
cur += '"';
|
||||
i++; // skip next
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '\n' && !inQuotes) {
|
||||
lines.push(cur);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (cur.length) lines.push(cur);
|
||||
|
||||
return lines.map(l => {
|
||||
const cols = [];
|
||||
let cell = '';
|
||||
let q = false;
|
||||
for (let i = 0; i < l.length; i++) {
|
||||
const ch = l[i];
|
||||
const nx = l[i + 1];
|
||||
if (ch === '"') {
|
||||
if (q && nx === '"') { cell += '"'; i++; continue; }
|
||||
q = !q; continue;
|
||||
}
|
||||
if (ch === ',' && !q) { cols.push(cell); cell = ''; continue; }
|
||||
cell += ch;
|
||||
}
|
||||
cols.push(cell);
|
||||
return cols.map(c => c.trim());
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(s) {
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
const outDir = path.join(__dirname, '..', 'database', 'backups');
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(csvPath, 'utf8');
|
||||
const rows = parseCSV(raw);
|
||||
if (rows.length < 2) {
|
||||
console.error('No CSV rows found');
|
||||
process.exit(1);
|
||||
}
|
||||
const headers = rows[0].map(h => h.toLowerCase());
|
||||
const data = rows.slice(1).map(r => {
|
||||
const obj = {};
|
||||
for (let i = 0; i < headers.length; i++) obj[headers[i]] = r[i] || '';
|
||||
return obj;
|
||||
}).filter(d => (d.name || '').trim());
|
||||
|
||||
// Backup existing skills rows
|
||||
const backupFile = path.join(outDir, `rules_skills_backup_${ts}.json`);
|
||||
const existing = db.prepare('SELECT * FROM rules WHERE category = ?').all('skills');
|
||||
fs.writeFileSync(backupFile, JSON.stringify({ backedAt: new Date().toISOString(), count: existing.length, rows: existing }, null, 2), 'utf8');
|
||||
console.log('Backup written to', backupFile, ' (rows:', existing.length, ')');
|
||||
|
||||
// Delete existing skill rows
|
||||
const del = db.prepare('DELETE FROM rules WHERE category = ?');
|
||||
const delRes = del.run('skills');
|
||||
console.log('Deleted rules where category=skills, changes:', delRes.changes);
|
||||
|
||||
// Prepare statements: delete any conflicting rule_id and insert
|
||||
const deleteById = db.prepare('DELETE FROM rules WHERE rule_id = ?');
|
||||
const insert = db.prepare('INSERT INTO rules (rule_id, title, content, page, source, source_abbr, category) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
|
||||
const inserted = [];
|
||||
db.transaction(() => {
|
||||
for (const r of data) {
|
||||
const name = (r.name || '').trim();
|
||||
const skill_text = (r.skill_text || '').trim();
|
||||
const skill_description = (r.skill_description || '').trim();
|
||||
const skill_use = (r.skill_use || '').trim();
|
||||
const content = [skill_text, skill_description, skill_use ? `Use: ${skill_use}` : ''].filter(Boolean).join('\n\n');
|
||||
const rule_id = slugify(name);
|
||||
// Remove any existing row with this rule_id (ensures 1:1 mapping to CSV)
|
||||
try {
|
||||
deleteById.run(rule_id);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
insert.run(rule_id, name, content || '', null, 'csv-import', 'CSV', 'skills');
|
||||
inserted.push(rule_id);
|
||||
}
|
||||
})();
|
||||
|
||||
const newCount = db.prepare('SELECT COUNT(*) as c FROM rules WHERE category = ?').get('skills').c;
|
||||
console.log('Inserted rows from CSV:', inserted.length, 'DB now has skills rows:', newCount);
|
||||
if (newCount !== inserted.length) {
|
||||
console.warn('Count mismatch: inserted', inserted.length, 'but DB count is', newCount);
|
||||
}
|
||||
|
||||
// Ensure only CSV-sourced skills exist (sanity check)
|
||||
const nonCsv = db.prepare("SELECT COUNT(*) as c FROM rules WHERE category = ? AND source != ?").get('skills', 'csv-import').c;
|
||||
console.log('Non-CSV skill rows remaining:', nonCsv);
|
||||
|
||||
// Output sample first 10
|
||||
const sample = db.prepare('SELECT rule_id, title FROM rules WHERE category = ? ORDER BY id LIMIT 10').all('skills');
|
||||
console.log('Sample rows:');
|
||||
sample.forEach((s, i) => console.log(`${i + 1}. ${s.rule_id} | ${s.title}`));
|
||||
|
||||
// final verification: write sync report
|
||||
const report = { syncedAt: new Date().toISOString(), csvRows: data.length, inserted: inserted.length, dbSkills: newCount };
|
||||
fs.writeFileSync(path.join(outDir, `rules_skills_sync_report_${ts}.json`), JSON.stringify(report, null, 2), 'utf8');
|
||||
console.log('Sync report written');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failure during sync:', e);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
console.log('Done');
|
||||
+38
-8
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import './App.css';
|
||||
import DeathwatchRoller from './components/DeathwatchRoller';
|
||||
import RequisitionShop from './components/RequisitionShop';
|
||||
@@ -36,6 +37,11 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch players on mount
|
||||
useEffect(() => {
|
||||
fetchPlayers();
|
||||
}, []);
|
||||
|
||||
// Validate session on mount/refresh
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
@@ -48,6 +54,18 @@ function App() {
|
||||
if (res.data && res.data.playerName) {
|
||||
setAuthedPlayer(res.data.playerName);
|
||||
localStorage.setItem('dw:shop:authedPlayer', JSON.stringify(res.data.playerName));
|
||||
|
||||
// Fetch full player data for the validated session
|
||||
try {
|
||||
const fullPlayerResponse = await axios.get(`/api/players/${res.data.playerName}`, {
|
||||
headers: { 'x-session-id': sessionId }
|
||||
});
|
||||
localStorage.setItem('dw:shop:playerData', JSON.stringify(fullPlayerResponse.data));
|
||||
info(`Session validation and player data fetch successful for: ${res.data.playerName}`, 'auth');
|
||||
} catch (playerFetchError) {
|
||||
warn(`Failed to fetch full player data during session validation: ${playerFetchError.message}`, 'auth');
|
||||
}
|
||||
|
||||
info(`Session validation successful for: ${res.data.playerName}`, 'auth');
|
||||
} else {
|
||||
warn('Session validation failed - invalid response', 'auth');
|
||||
@@ -57,7 +75,7 @@ function App() {
|
||||
localStorage.removeItem('dw:shop:sessionId');
|
||||
}
|
||||
} catch (err) {
|
||||
logApiError('POST', '/api/sessions/validate', err);
|
||||
logApiError('App', 'POST', '/api/sessions/validate', err);
|
||||
error(`Session validation error: ${err.message}`, 'auth');
|
||||
setAuthedPlayer('');
|
||||
setSessionId('');
|
||||
@@ -93,7 +111,19 @@ function App() {
|
||||
setSessionId(response.data.sessionId);
|
||||
localStorage.setItem('dw:shop:authedPlayer', JSON.stringify(response.data.player.name));
|
||||
localStorage.setItem('dw:shop:sessionId', JSON.stringify(response.data.sessionId));
|
||||
localStorage.setItem('dw:shop:playerData', JSON.stringify(response.data.player));
|
||||
|
||||
// Fetch full player data including tabInfo after successful login
|
||||
try {
|
||||
const fullPlayerResponse = await axios.get(`/api/players/${response.data.player.name}`, {
|
||||
headers: { 'x-session-id': response.data.sessionId }
|
||||
});
|
||||
localStorage.setItem('dw:shop:playerData', JSON.stringify(fullPlayerResponse.data));
|
||||
info(`Full player data loaded for: ${loginName}`, 'auth');
|
||||
} catch (playerFetchError) {
|
||||
warn(`Failed to fetch full player data: ${playerFetchError.message}`, 'auth');
|
||||
// Store minimal player data as fallback
|
||||
localStorage.setItem('dw:shop:playerData', JSON.stringify(response.data.player));
|
||||
}
|
||||
|
||||
info(`Login successful for user: ${loginName}`, 'auth');
|
||||
logUserAction('user', 'Login successful', { username: loginName });
|
||||
@@ -111,7 +141,7 @@ function App() {
|
||||
setTimeout(() => setLoginMsg(''), 5000);
|
||||
}
|
||||
} catch (err) {
|
||||
logApiError('POST', '/api/players/login', err);
|
||||
logApiError('App', 'POST', '/api/players/login', err);
|
||||
error(`Login error for user: ${loginName} - ${err.message}`, 'auth');
|
||||
setLoginMsg('Login failed. Please check your credentials and try again.');
|
||||
|
||||
@@ -176,16 +206,16 @@ function App() {
|
||||
}, []);
|
||||
|
||||
const appBackgroundStyle = {
|
||||
backgroundImage: `linear-gradient(rgba(2,6,23,0.65), rgba(15,23,42,0.65)), url('${appBackgroundUrl}')`,
|
||||
backgroundImage: `linear-gradient(rgba(2,6,23,0.75), rgba(15,23,42,0.75)), url('${appBackgroundUrl}')`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center top',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center, right center',
|
||||
backgroundSize: '25%, 25%',
|
||||
backgroundAttachment: 'fixed',
|
||||
backgroundBlendMode: 'overlay'
|
||||
backgroundBlendMode: 'multiply'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" style={appBackgroundStyle}>
|
||||
<div className="App min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" style={tab === 'players' ? appBackgroundStyle : {}}>
|
||||
{/* Persistent Header */}
|
||||
<div className="sticky top-0 z-50 backdrop-blur-md bg-slate-900/80 border-b border-white/10">
|
||||
<div className="mx-auto max-w-6xl px-6 py-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { useEffect, useMemo, useState } = require('react')
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
// Tooltip component for abbreviations
|
||||
function Tooltip({ children, text }) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
import { XPBar } from './XPBar';
|
||||
|
||||
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero'];
|
||||
|
||||
@@ -500,7 +501,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<div>
|
||||
<div className="font-medium text-white text-lg">{player.name}</div>
|
||||
<div className="text-sm text-slate-400">
|
||||
{player.renown || 'None'} • RP: {player.requisitionPoints || 0}
|
||||
{player.tabInfo?.renown || 'None'} • RP: {player.tabInfo?.rp || 0}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -511,19 +512,25 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Total XP:</span> {player.xp || 0}
|
||||
{/* Player Stats with XP Bar */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Character:</span> {player.tabInfo?.charName || 'Unnamed'}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Available XP:</span> <span className="text-green-400 font-medium">{(player.tabInfo?.xp || 0) - (player.tabInfo?.xpSpent || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">XP Spent:</span> {player.xpSpent || 0}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Available XP:</span> {(player.xp || 0) - (player.xpSpent || 0)}
|
||||
</div>
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Character:</span> {player.charName || 'Unnamed'}
|
||||
<div className="bg-slate-800/50 rounded p-3 border border-slate-700">
|
||||
<div className="text-xs font-medium text-slate-300 uppercase tracking-wide mb-2">Experience Progress</div>
|
||||
<XPBar
|
||||
currentXP={player.tabInfo?.xp || 0}
|
||||
xpSpent={player.tabInfo?.xpSpent || 0}
|
||||
thresholdXP={500}
|
||||
showLabel={true}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -535,7 +542,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Requisition Points</label>
|
||||
<GmSetRP
|
||||
name={player.name}
|
||||
currentRP={player.requisitionPoints}
|
||||
currentRP={player.tabInfo?.rp}
|
||||
onSet={gmSetRP}
|
||||
/>
|
||||
</div>
|
||||
@@ -545,7 +552,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Experience Points</label>
|
||||
<GmSetXP
|
||||
name={player.name}
|
||||
currentXP={player.xp}
|
||||
currentXP={player.tabInfo?.xp}
|
||||
onSet={gmSetXP}
|
||||
/>
|
||||
</div>
|
||||
@@ -555,7 +562,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">XP Spent</label>
|
||||
<GmSetXPSpent
|
||||
name={player.name}
|
||||
currentXPSpent={player.xpSpent}
|
||||
currentXPSpent={player.tabInfo?.xpSpent}
|
||||
onSet={gmSetXPSpent}
|
||||
/>
|
||||
</div>
|
||||
@@ -565,7 +572,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Renown Level</label>
|
||||
<GmSetRenown
|
||||
name={player.name}
|
||||
currentRenown={player.renown}
|
||||
currentRenown={player.tabInfo?.renown}
|
||||
onSet={gmSetRenown}
|
||||
/>
|
||||
</div>
|
||||
@@ -596,7 +603,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkXPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentXP = player.xp || 0;
|
||||
const currentXP = player.tabInfo?.xp || 0;
|
||||
gmSetXP(player.name, (currentXP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
@@ -614,7 +621,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkRPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentRP = player.requisitionPoints || 0;
|
||||
const currentRP = player.tabInfo?.rp || 0;
|
||||
gmSetRP(player.name, (currentRP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
@@ -640,7 +647,7 @@ export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
className="text-xs px-3 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white transition-colors"
|
||||
onClick={() => {
|
||||
players.forEach(player => {
|
||||
if ((player.requisitionPoints || 0) < 10) {
|
||||
if ((player.tabInfo?.rp || 0) < 10) {
|
||||
gmSetRP(player.name, '50');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,37 +59,40 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
}
|
||||
// Fetch shop items from the new database (public endpoint, no session needed)
|
||||
console.log('Fetching shop items');
|
||||
const itemsResponse = await axios.get('/api/shop/items');
|
||||
const itemsResponse = await axios.get('/api/shop');
|
||||
console.log('Fetched shop items:', itemsResponse.data);
|
||||
|
||||
if (!itemsResponse.data || itemsResponse.data.length === 0) {
|
||||
console.log('Warning: Shop items response was empty')
|
||||
if (!itemsResponse.data || !itemsResponse.data.items) {
|
||||
console.log('Warning: Shop items response was empty or missing items')
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// helper to safely get stats as object whether API returned string or object
|
||||
const parseStats = s => {
|
||||
if (!s) return {};
|
||||
if (typeof s === 'string') {
|
||||
try { return JSON.parse(s); } catch (e) { return { raw: s }; }
|
||||
// Flatten the categorized items into a single array
|
||||
const allItems = [];
|
||||
const itemsByCategory = itemsResponse.data.items;
|
||||
|
||||
for (const category in itemsByCategory) {
|
||||
if (Array.isArray(itemsByCategory[category])) {
|
||||
itemsByCategory[category].forEach(item => {
|
||||
// Only include items that have a cost > 0 (purchasable items)
|
||||
const reqCost = item.req || 0;
|
||||
if (reqCost > 0) {
|
||||
allItems.push({
|
||||
id: item.id || `${category}-${item.name}`,
|
||||
name: item.name,
|
||||
category: category,
|
||||
req: reqCost,
|
||||
renown: item.renown || 'Any',
|
||||
stats: item.stats || {},
|
||||
itemType: item.itemType || 'equipment'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const normalizedItems = itemsResponse.data.map(item => {
|
||||
const statsObj = parseStats(item.stats);
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
cost: item.requisition_cost,
|
||||
req: item.requisition_cost,
|
||||
renown: item.renown_requirement,
|
||||
desc: statsObj.description || '',
|
||||
stats: statsObj
|
||||
};
|
||||
});
|
||||
|
||||
setItems(normalizedItems);
|
||||
setItems(allItems);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
// If shop API fails, we can't load items. Keep players fallback behavior.
|
||||
@@ -104,18 +107,56 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
const p = players.find(p => p.name === authedPlayer);
|
||||
if (!p) return null;
|
||||
|
||||
// Support both old and new data structure
|
||||
// Old data is in tabInfo, new data is directly on the player object
|
||||
// Player data is stored in tabInfo structure
|
||||
const tabInfo = p.tabInfo || {};
|
||||
return {
|
||||
...p,
|
||||
id: p.id,
|
||||
requisition_points: p.requisition_points !== undefined ? p.requisition_points : Number(tabInfo.rp || 0),
|
||||
renown_level: p.renown_level || tabInfo.renown || 'None',
|
||||
// Keep gear from tabInfo for now, as we transition to using the inventory table
|
||||
requisition_points: Number(tabInfo.rp || 0),
|
||||
renown_level: tabInfo.renown || 'None',
|
||||
// Keep gear from tabInfo for inventory system
|
||||
gear: Array.isArray(tabInfo.gear) ? tabInfo.gear : []
|
||||
};
|
||||
}, [players, authedPlayer]);
|
||||
|
||||
// Helper function to check if an item can be purchased
|
||||
const canPurchaseItem = (item) => {
|
||||
if (!currentPlayer) return false;
|
||||
|
||||
const playerRP = currentPlayer.requisition_points || 0;
|
||||
const playerRenown = currentPlayer.renown_level || 'None';
|
||||
const requiredRenown = item.renown || 'Any';
|
||||
|
||||
const hasEnoughRP = playerRP >= item.req;
|
||||
const hasEnoughRenown = requiredRenown === 'Any' ||
|
||||
RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown);
|
||||
|
||||
return hasEnoughRP && hasEnoughRenown;
|
||||
};
|
||||
|
||||
// Helper function to get purchase button text and styling
|
||||
const getPurchaseButtonInfo = (item) => {
|
||||
if (!currentPlayer) return { text: 'Buy', disabled: true, className: 'px-3 py-1 rounded bg-slate-600 text-sm cursor-not-allowed' };
|
||||
|
||||
const playerRP = currentPlayer.requisition_points || 0;
|
||||
const playerRenown = currentPlayer.renown_level || 'None';
|
||||
const requiredRenown = item.renown || 'Any';
|
||||
|
||||
const hasEnoughRP = playerRP >= item.req;
|
||||
const hasEnoughRenown = requiredRenown === 'Any' ||
|
||||
RANK_ORDER.indexOf(playerRenown) >= RANK_ORDER.indexOf(requiredRenown);
|
||||
|
||||
if (!hasEnoughRP && !hasEnoughRenown) {
|
||||
return { text: 'Need RP & Renown', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' };
|
||||
} else if (!hasEnoughRP) {
|
||||
return { text: 'Need RP', disabled: true, className: 'px-3 py-1 rounded bg-red-600 text-xs cursor-not-allowed' };
|
||||
} else if (!hasEnoughRenown) {
|
||||
return { text: 'Need Renown', disabled: true, className: 'px-3 py-1 rounded bg-orange-600 text-xs cursor-not-allowed' };
|
||||
} else {
|
||||
return { text: 'Buy', disabled: false, className: 'px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 text-sm' };
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = useMemo(()=>{
|
||||
const q = search.trim().toLowerCase()
|
||||
return items.filter(i => {
|
||||
@@ -138,8 +179,8 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
|
||||
// Check if player has enough RP
|
||||
const currentRp = currentPlayer.requisition_points || 0;
|
||||
if (currentRp < item.cost) {
|
||||
setErrorMsg(`Not enough Requisition Points. Need ${item.cost} RP but only have ${currentRp} RP.`);
|
||||
if (currentRp < item.req) {
|
||||
setErrorMsg(`Not enough Requisition Points. Need ${item.req} RP but only have ${currentRp} RP.`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -161,7 +202,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
await axios.post(
|
||||
'/api/shop/purchase',
|
||||
{
|
||||
playerId: currentPlayer.id,
|
||||
playerId: currentPlayer.name, // Use player name instead of id
|
||||
itemId: item.id,
|
||||
quantity: 1
|
||||
},
|
||||
@@ -174,7 +215,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
});
|
||||
setPlayers(response.data);
|
||||
|
||||
setErrorMsg(`Successfully purchased ${item.name} for ${item.cost} RP`);
|
||||
setErrorMsg(`Successfully purchased ${item.name} for ${item.req} RP`);
|
||||
} catch (error) {
|
||||
console.error('Failed to purchase item:', error);
|
||||
setErrorMsg(error.response?.data?.error || 'Failed to make purchase');
|
||||
@@ -245,10 +286,37 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
<div key={item.id} className="p-3 rounded-lg bg-slate-800/50 border border-white/10">
|
||||
<div className="font-semibold text-white">{item.name}</div>
|
||||
<div className="text-xs text-slate-300 mb-2">{item.category}</div>
|
||||
<div className="text-sm text-slate-200 mb-3">{item.desc}</div>
|
||||
|
||||
{/* Display item stats */}
|
||||
{item.stats && Object.keys(item.stats).length > 0 && (
|
||||
<div className="text-sm text-slate-200 mb-3 space-y-1">
|
||||
{item.stats.damage && (
|
||||
<div className="text-xs"><span className="text-slate-400">Damage:</span> {item.stats.damage}</div>
|
||||
)}
|
||||
{item.stats.class && (
|
||||
<div className="text-xs"><span className="text-slate-400">Class:</span> {item.stats.class}</div>
|
||||
)}
|
||||
{item.stats.type && (
|
||||
<div className="text-xs"><span className="text-slate-400">Type:</span> {item.stats.type}</div>
|
||||
)}
|
||||
{item.stats.protection && (
|
||||
<div className="text-xs">
|
||||
<span className="text-slate-400">Protection:</span>
|
||||
{' '}Head: {item.stats.protection.head},
|
||||
Arms: {item.stats.protection.arms},
|
||||
Body: {item.stats.protection.body},
|
||||
Legs: {item.stats.protection.legs}
|
||||
</div>
|
||||
)}
|
||||
{item.stats.source && (
|
||||
<div className="text-xs text-slate-500">Source: {item.stats.source}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<div className="text-xs text-slate-400">Cost: {item.cost} RP</div>
|
||||
<div className="text-xs text-slate-400">Cost: {item.req} RP</div>
|
||||
<div className={`text-xs px-2 py-1 rounded ${renownClass(item.renown)}`}>
|
||||
{item.renown}
|
||||
</div>
|
||||
@@ -256,10 +324,10 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
{currentPlayer && (
|
||||
<button
|
||||
onClick={() => purchaseItem(item)}
|
||||
disabled={!currentPlayer || (currentPlayer.requisition_points || 0) < item.cost}
|
||||
className="px-3 py-1 rounded bg-blue-600 hover:bg-blue-500 disabled:bg-slate-600 disabled:cursor-not-allowed text-sm"
|
||||
disabled={getPurchaseButtonInfo(item).disabled}
|
||||
className={getPurchaseButtonInfo(item).className}
|
||||
>
|
||||
Buy
|
||||
{getPurchaseButtonInfo(item).text}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -267,17 +335,7 @@ export default function RequisitionShop({ authedPlayer, sessionId }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* GM Panel moved to PlayerTab - GM controls (add/set RP/renown/reset PW) available in PlayerTab when GM is logged in */}
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<div className="p-3 rounded bg-amber-900/20 border border-amber-500/30">
|
||||
<div className="text-amber-300 font-semibold mb-2">
|
||||
GM Panel
|
||||
</div>
|
||||
<div className="text-sm text-amber-200">
|
||||
GM controls have been moved to the PlayerTab. When logged in as GM, you can add players, set RP and renown, reset passwords, and manage items.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import axios from 'axios';
|
||||
import App from '../App';
|
||||
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios;
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
removeItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
global.localStorage = localStorageMock;
|
||||
|
||||
describe('Login Functionality', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
|
||||
// Mock axios to handle different endpoints
|
||||
mockedAxios.get.mockImplementation((url) => {
|
||||
if (url === '/api/players/names') {
|
||||
return Promise.resolve({
|
||||
data: [
|
||||
{ name: 'gm' },
|
||||
{ name: 'anders' },
|
||||
{ name: 'phillip' }
|
||||
]
|
||||
});
|
||||
}
|
||||
// For other GET requests (like /api/players/{name})
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
name: 'gm',
|
||||
tabInfo: {
|
||||
rp: 100,
|
||||
xp: 1000,
|
||||
xpSpent: 200,
|
||||
renown: 'Respected',
|
||||
charName: 'Game Master'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Default POST mock - will be overridden per test with mockResolvedValueOnce
|
||||
mockedAxios.post.mockImplementation((url, data) => {
|
||||
if (url === '/api/players/login') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
success: true,
|
||||
sessionId: 'default_session_id',
|
||||
player: { name: data.name }
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
test('login with GM account', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
success: true,
|
||||
sessionId: 'session_gm_12345',
|
||||
player: { name: 'gm' }
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Wait for players list to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/gm/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find login inputs
|
||||
const nameInput = screen.getByPlaceholderText(/player name|username/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const loginButton = screen.getByRole('button', { name: /login|enter/i });
|
||||
|
||||
// Fill in login form with GM credentials
|
||||
fireEvent.change(nameInput, { target: { value: 'gm' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'bongo' } });
|
||||
|
||||
// Click login button
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
// Verify login was called with correct parameters
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
'/api/players/login',
|
||||
{
|
||||
name: 'gm',
|
||||
password: 'bongo'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Verify success message appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/login successful/i)).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('login with invalid password fails', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: 'Invalid password' }
|
||||
},
|
||||
message: 'Request failed with status code 401'
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/gm/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/player name|username/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const loginButton = screen.getByRole('button', { name: /login|enter/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'gm' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'wrong_password' } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/login failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('login with non-existent player fails', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 404,
|
||||
data: { error: 'Player not found' }
|
||||
},
|
||||
message: 'Request failed with status code 404'
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/gm/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText(/player name|username/i);
|
||||
const passwordInput = screen.getByPlaceholderText(/password/i);
|
||||
const loginButton = screen.getByRole('button', { name: /login|enter/i });
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'nonexistent' } });
|
||||
fireEvent.change(passwordInput, { target: { value: '1234' } });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/login failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,13 @@ describe('PlayerManagement Component', () => {
|
||||
const mockPlayers = [
|
||||
{
|
||||
name: 'TestPlayer',
|
||||
requisitionPoints: 50,
|
||||
xp: 1000,
|
||||
xpSpent: 200,
|
||||
renown: 'Respected',
|
||||
charName: 'Brother Testicus'
|
||||
tabInfo: {
|
||||
rp: 50,
|
||||
xp: 1000,
|
||||
xpSpent: 200,
|
||||
renown: 'Respected',
|
||||
charName: 'Brother Testicus'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -77,11 +79,20 @@ describe('PlayerManagement Component', () => {
|
||||
|
||||
// Check if player is displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
expect(screen.getByText('Respected • RP: 50')).toBeInTheDocument();
|
||||
expect(screen.getByText('1000')).toBeInTheDocument(); // Total XP
|
||||
expect(screen.getByText('200')).toBeInTheDocument(); // XP Spent
|
||||
expect(screen.getByText('800')).toBeInTheDocument(); // Available XP
|
||||
const playerName = screen.getByText('TestPlayer');
|
||||
expect(playerName).toBeInTheDocument();
|
||||
|
||||
// Check that all expected data is rendered somewhere in the document
|
||||
const container = screen.getByText(/Player Management/);
|
||||
const documentText = container.closest('body').textContent;
|
||||
|
||||
expect(documentText).toContain('TestPlayer');
|
||||
expect(documentText).toContain('Total XP:');
|
||||
expect(documentText).toContain('1000');
|
||||
expect(documentText).toContain('XP Spent:');
|
||||
expect(documentText).toContain('200');
|
||||
expect(documentText).toContain('Available XP:');
|
||||
expect(documentText).toContain('800');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,14 +140,13 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find RP management section and use current RP value (50)
|
||||
// Find RP input by label
|
||||
const rpInputs = screen.getAllByDisplayValue('50');
|
||||
const rpInput = rpInputs.find(input => input.type === 'number');
|
||||
const rpSetButtons = screen.getAllByText('Set');
|
||||
const rpSetButton = rpSetButtons[0]; // First Set button should be for RP
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(rpSetButton);
|
||||
expect(rpInputs.length).toBeGreaterThan(0);
|
||||
|
||||
// Find all Set buttons and click the first one (RP)
|
||||
const setButtons = screen.getAllByText('Set');
|
||||
fireEvent.click(setButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
@@ -163,14 +173,13 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find XP management section and use current XP value (1000)
|
||||
// Find XP input by its value
|
||||
const xpInputs = screen.getAllByDisplayValue('1000');
|
||||
const xpInput = xpInputs.find(input => input.type === 'number');
|
||||
const xpSetButtons = screen.getAllByText('Set');
|
||||
const xpSetButton = xpSetButtons[1]; // Second Set button should be for XP
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(xpSetButton);
|
||||
expect(xpInputs.length).toBeGreaterThan(0);
|
||||
|
||||
// Find all Set buttons and click the second one (XP)
|
||||
const setButtons = screen.getAllByText('Set');
|
||||
fireEvent.click(setButtons[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
@@ -197,14 +206,13 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find XP Spent management section and use current value (200)
|
||||
// Find XP Spent input by its value
|
||||
const xpSpentInputs = screen.getAllByDisplayValue('200');
|
||||
const xpSpentInput = xpSpentInputs.find(input => input.type === 'number');
|
||||
const xpSpentSetButtons = screen.getAllByText('Set');
|
||||
const xpSpentSetButton = xpSpentSetButtons[2]; // Third Set button should be for XP Spent
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(xpSpentSetButton);
|
||||
expect(xpSpentInputs.length).toBeGreaterThan(0);
|
||||
|
||||
// Find all Set buttons and click the third one (XP Spent)
|
||||
const setButtons = screen.getAllByText('Set');
|
||||
fireEvent.click(setButtons[2]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
@@ -231,13 +239,12 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find renown dropdown and use current value (Respected)
|
||||
// Find renown dropdown by its value
|
||||
const renownSelect = screen.getByDisplayValue('Respected');
|
||||
const renownSetButtons = screen.getAllByText('Set');
|
||||
const renownSetButton = renownSetButtons[3]; // Fourth Set button should be for Renown
|
||||
|
||||
// Click set button with current value
|
||||
fireEvent.click(renownSetButton);
|
||||
|
||||
// Find all Set buttons and click the fourth one (Renown)
|
||||
const setButtons = screen.getAllByText('Set');
|
||||
fireEvent.click(setButtons[3]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
@@ -325,11 +332,14 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find bulk XP giver
|
||||
const bulkXPInput = screen.getByDisplayValue('100');
|
||||
// Find bulk XP input and button
|
||||
const bulkXPInputs = screen.getAllByDisplayValue('100');
|
||||
expect(bulkXPInputs.length).toBeGreaterThan(0);
|
||||
|
||||
const giveXPButton = screen.getByText('Give XP to All');
|
||||
|
||||
fireEvent.change(bulkXPInput, { target: { value: '250' } });
|
||||
|
||||
// Change the value and click
|
||||
fireEvent.change(bulkXPInputs[0], { target: { value: '250' } });
|
||||
fireEvent.click(giveXPButton);
|
||||
|
||||
// Check confirmation
|
||||
@@ -360,11 +370,13 @@ describe('PlayerManagement Component', () => {
|
||||
expect(screen.getByText('TestPlayer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find bulk RP giver
|
||||
const bulkRPInput = screen.getByDisplayValue('10');
|
||||
// Find bulk RP input and button
|
||||
const bulkRPInputs = screen.getAllByDisplayValue('10');
|
||||
expect(bulkRPInputs.length).toBeGreaterThan(0);
|
||||
|
||||
const giveRPButton = screen.getByText('Give RP to All');
|
||||
|
||||
fireEvent.change(bulkRPInput, { target: { value: '25' } });
|
||||
fireEvent.change(bulkRPInputs[0], { target: { value: '25' } });
|
||||
fireEvent.click(giveRPButton);
|
||||
|
||||
// Check confirmation
|
||||
@@ -402,12 +414,10 @@ describe('PlayerManagement Component', () => {
|
||||
|
||||
// Try to set RP and expect error handling
|
||||
const rpInputs = screen.getAllByDisplayValue('50');
|
||||
const rpInput = rpInputs.find(input => input.type === 'number');
|
||||
const rpSetButtons = screen.getAllByText('Set');
|
||||
const rpSetButton = rpSetButtons[0];
|
||||
const setButtons = screen.getAllByText('Set');
|
||||
|
||||
fireEvent.change(rpInput, { target: { value: '100' } });
|
||||
fireEvent.click(rpSetButton);
|
||||
fireEvent.change(rpInputs[0], { target: { value: '100' } });
|
||||
fireEvent.click(setButtons[0]);
|
||||
|
||||
// Should show error message
|
||||
await waitFor(() => {
|
||||
|
||||
+3
-3
@@ -173,9 +173,9 @@ export const logApiCall = (component, method, url, data) => {
|
||||
|
||||
export const logApiError = (component, method, url, error) => {
|
||||
logger.error(component, `API ${method} ${url} failed`, {
|
||||
message: error.message,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
message: error?.message || String(error) || 'Unknown error',
|
||||
status: error?.response?.status,
|
||||
data: error?.response?.data
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* XP PROGRESSION RULES FOR DEATHWATCH
|
||||
*
|
||||
* Accelerated progression for infrequent play sessions (monthly or less)
|
||||
* Based on Deathwatch Core Rulebook
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard Deathwatch XP Costs (from core rulebook)
|
||||
*/
|
||||
export const XP_COSTS = {
|
||||
// Skill improvements
|
||||
SKILL_BASIC_TRAINING: 100, // Learn a skill
|
||||
SKILL_PLUS_10: 200, // +10 modifier
|
||||
SKILL_PLUS_20: 300, // +20 modifier
|
||||
SKILL_PLUS_30: 400, // +30 modifier
|
||||
|
||||
// Characteristic increases (costs vary by characteristic)
|
||||
CHARACTERISTIC: {
|
||||
strength: 500,
|
||||
toughness: 500,
|
||||
ballistic: 500,
|
||||
agility: 500,
|
||||
intelligence: 500,
|
||||
perception: 500,
|
||||
fellowship: 500,
|
||||
willpower: 500,
|
||||
},
|
||||
|
||||
// Talent costs
|
||||
TALENT_BASIC: 200,
|
||||
TALENT_ADVANCED: 300,
|
||||
|
||||
// Psychic powers
|
||||
PSYCHIC_POWER: 300,
|
||||
};
|
||||
|
||||
/**
|
||||
* ACCELERATED PROGRESSION RULES
|
||||
* For Game Masters running monthly or infrequent sessions
|
||||
*
|
||||
* Problem: In standard Deathwatch, characters advance very slowly.
|
||||
* With monthly sessions, a character might take 2+ years to get 1 characteristic increase.
|
||||
*
|
||||
* Solution: Multiply base XP values by acceleration factor
|
||||
*/
|
||||
|
||||
export const ACCELERATION_TIERS = {
|
||||
STANDARD: {
|
||||
factor: 1.0,
|
||||
description: 'Standard Deathwatch progression',
|
||||
recommendation: 'Weekly+ sessions',
|
||||
rationale: 'Players meet frequently, can take long-term advancement goals',
|
||||
},
|
||||
|
||||
MONTHLY: {
|
||||
factor: 2.5,
|
||||
description: 'Accelerated for monthly sessions',
|
||||
recommendation: 'Sessions ~2-4 times per month',
|
||||
rationale: 'Approximately 2.5x XP awards to make meaningful progress between longer gaps',
|
||||
examples: {
|
||||
perSession: '250-500 XP per 4-hour session',
|
||||
perMonth: '500-2000 XP per month',
|
||||
charIncrease: 'Every 2-3 sessions instead of 5-8',
|
||||
}
|
||||
},
|
||||
|
||||
BIWEEKLY: {
|
||||
factor: 1.5,
|
||||
description: 'Accelerated for biweekly sessions',
|
||||
recommendation: 'Sessions every 2 weeks',
|
||||
rationale: 'Slight acceleration for semi-regular play',
|
||||
examples: {
|
||||
perSession: '150-300 XP per 4-hour session',
|
||||
perMonth: '300-600 XP per month',
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* RECOMMENDED XP AWARDS BY SESSION
|
||||
*
|
||||
* Award XP at the end of each session based on accomplishments
|
||||
*/
|
||||
export const SESSION_XP_AWARDS = {
|
||||
// Base award (all players get this for showing up and participating)
|
||||
BASE: { standard: 100, monthly: 250 },
|
||||
|
||||
// Mission/Objective completion
|
||||
OBJECTIVE_COMPLETED: { standard: 200, monthly: 500 },
|
||||
OBJECTIVE_PARTIALLY: { standard: 100, monthly: 250 },
|
||||
|
||||
// Individual accomplishments
|
||||
EXCEPTIONAL_ROLEPLAY: { standard: 50, monthly: 125 },
|
||||
CREATIVE_SOLUTION: { standard: 100, monthly: 250 },
|
||||
TACTICAL_VICTORY: { standard: 100, monthly: 250 },
|
||||
SURVIVED_MAJOR_THREAT: { standard: 100, monthly: 250 },
|
||||
|
||||
// Penalties (rare, for severely poor play)
|
||||
CHARACTER_DEATH: { standard: -50, monthly: -125 },
|
||||
FRIENDLY_FIRE_INCIDENT: { standard: -25, monthly: -60 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate total XP award for session
|
||||
*/
|
||||
export function calculateSessionXP(awards = [], accelerationFactor = 1.0) {
|
||||
return awards.reduce((total, award) => total + award, 0) * accelerationFactor;
|
||||
}
|
||||
|
||||
/**
|
||||
* XP to spend guide for new players
|
||||
*/
|
||||
export const XP_SPENDING_GUIDE = {
|
||||
'Quick Improvements (50-200 XP)': [
|
||||
'Increase single skill by +10 (costs vary)',
|
||||
'Basic Talent prerequisite requirements',
|
||||
],
|
||||
'Medium Improvements (200-500 XP)': [
|
||||
'New skill training + advancement',
|
||||
'Talent acquisition',
|
||||
'Start working toward Characteristic increase',
|
||||
],
|
||||
'Major Improvements (500+ XP)': [
|
||||
'Characteristic increase (+1 to a characteristic)',
|
||||
'Advanced talents',
|
||||
'Psychic power acquisition',
|
||||
'Multiple skill increases',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Level thresholds for visual progression
|
||||
* Used by XP bar component
|
||||
*/
|
||||
export const XP_LEVEL_THRESHOLDS = {
|
||||
description: 'Every 500 XP represents one "level" for visual progression',
|
||||
visual: {
|
||||
0: 'Level 0 - Fresh recruit',
|
||||
500: 'Level 1 - Blooded warrior',
|
||||
1000: 'Level 2 - Proven combatant',
|
||||
1500: 'Level 3 - Experienced marine',
|
||||
2000: 'Level 4 - Hardened veteran',
|
||||
2500: 'Level 5 - Master of arms',
|
||||
3000: 'Level 6 - Chapter legend',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* EXAMPLE: How to accelerate XP for monthly sessions
|
||||
*
|
||||
* BEFORE:
|
||||
* - Player gets 100 XP for completing a mission
|
||||
* - To get 500 XP for a characteristic increase = 5 months
|
||||
*
|
||||
* AFTER (with 2.5x acceleration):
|
||||
* - Player gets 250 XP for completing the same mission (100 * 2.5)
|
||||
* - To get 500 XP for a characteristic increase = 2 months
|
||||
* - More meaningful character progression visible between sessions
|
||||
*/
|
||||
|
||||
/**
|
||||
* IMPLEMENTATION GUIDE:
|
||||
*
|
||||
* 1. In GM Kit, show which acceleration tier is active
|
||||
* 2. When awarding XP, multiply by the acceleration factor
|
||||
* 3. XP bar updates in real-time to show progress
|
||||
* 4. Players can see they're making progress toward meaningful upgrades
|
||||
*
|
||||
* Example in GM action:
|
||||
* const factor = ACCELERATION_TIERS.MONTHLY.factor;
|
||||
* const xpToAward = 300 * factor; // 750 XP
|
||||
* gmSetXP(playerName, currentXP + xpToAward);
|
||||
*/
|
||||
|
||||
export default {
|
||||
XP_COSTS,
|
||||
ACCELERATION_TIERS,
|
||||
SESSION_XP_AWARDS,
|
||||
calculateSessionXP,
|
||||
XP_SPENDING_GUIDE,
|
||||
XP_LEVEL_THRESHOLDS,
|
||||
};
|
||||
Reference in New Issue
Block a user