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:
+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