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