diff --git a/.gitignore b/.gitignore index 91aae7f..2e3fcac 100755 --- a/.gitignore +++ b/.gitignore @@ -23,27 +23,45 @@ npm-debug.log* yarn-debug.log* yarn-error.log* -Deathwatch-Core-Rulebook.pdf -data/journal/* -data/Deathwatch - Core Rulebook.pdf -data/Deathwatch - Ark of Lost Souls.pdf -data/Deathwatch Living Errata v1-1.pdf -.gitignore -data/Deathwatch - First Founding.pdf -data/Deathwatch - Honour the Chapter.pdf -data/Deathwatch - Mark of the Xenos.pdf -data/Deathwatch - Oblivions Edge.pdf -data/Deathwatch - Final Sanction.pdf -data/Deathwatch - The Outer Reach.pdf -data/Deathwatch - The Achilus Assault.pdf # Ignore all PDF files (prevent including copyrighted book PDFs) *.pdf -# Database and server log files +# Log files +*.log +*.log.* backend.log server.log database/backend.log -database/server.log -database/sqlite/*.db-shm -database/sqlite/*.db-wal + +# Database files (should be created fresh on deployment) +*.db +*.db-* +*.sqlite +*.sqlite3 +sqlite/ +sqlite-db.db + +# Backup and temporary files +*backup* +*temp* +backups/ +database/backups/ + +# Build artifacts with backup names +*.backup.* +*-backup-* + +# PM2 logs and runtime files +.pm2/ +*.pid + +# SQLite WAL files +*.db-shm +*.db-wal + +# Core dumps and crash files +core +core.* +*.core +*.dump diff --git a/README.md b/README.md index de7523d..f8a17d0 100755 --- a/README.md +++ b/README.md @@ -1,56 +1,82 @@ -<<<<<<< HEAD # Deathwatch Roller ## Project Description -Deathwatch Roller is a React-based application designed to assist players and Game Masters in managing their sessions for the Deathwatch tabletop RPG. It includes features like player management, requisition shop, and session tracking. +Deathwatch Roller is a React-based application designed to assist players and Game Masters in managing their sessions for the Deathwatch tabletop RPG. It includes features like player management, requisition shop, session tracking, and comprehensive GM tools. ## Features -- Player management -- Requisition shop for items -- Session tracking +- **Player Management**: Full CRUD operations for player accounts (GM only) +- **Requisition Shop**: Browse and purchase items with RP +- **Session Tracking**: Track game sessions and player progress +- **GM Kit**: Comprehensive Game Master tools and utilities +- **Bestiary**: Monster and enemy reference +- **Rules Database**: Searchable rules and game mechanics -## Setup Instructions +## Quick Start + +### Prerequisites +- Node.js (v18+ recommended) +- npm or yarn + +### Installation 1. Clone the repository: ```bash - git clone https://github.com/your-username/deathwatch-roller.git + git clone https://github.com/alexpolo1/dwroller.git + cd dwroller ``` -2. Navigate to the project directory: - ```bash - cd deathwatch-roller - ``` -3. Install dependencies: + +2. Install dependencies: ```bash npm install ``` -4. Start the development server: + +3. Start the development server: ```bash npm start ``` +4. Start the backend server: + ```bash + npm run server + ``` + +The application will be available at `http://localhost:3000` with the API at `http://localhost:5000`. + +### Production Deployment +For production deployment with PM2: +```bash +npm run build +npm run pm2:start +``` + +## Clean Repository +This repository is configured to exclude: +- Log files (`*.log`) +- Database files (`*.db`, `*.sqlite`) +- Backup files (`*backup*`, `backups/`) +- Temporary files +- PDF files (copyrighted content) + +When you clone this repository, the database will be automatically created with the necessary tables on first run. + +## GM Features +The application includes a comprehensive Player Management interface available only to Game Masters: +- Create and delete player accounts +- Manage requisition points, experience, and renown +- Reset player passwords +- Bulk operations for XP/RP management + +## API Endpoints +- `GET /api/players` - Get all players (requires session) +- `POST /api/players/gm/*` - GM-only endpoints (requires x-gm-secret header) +- `GET /api/shop` - Get shop inventory +- `GET /api/bestiary` - Get bestiary data + ## Contribution Guidelines -- Fork the repository. -- Create a new branch for your feature or bug fix. -- Submit a pull request with a detailed description of your changes. +- Fork the repository +- Create a new branch for your feature or bug fix +- Follow the existing code style and patterns +- Add tests for new functionality +- Submit a pull request with a detailed description ## License This project is licensed under the MIT License. See the LICENSE file for details. - -## Learn More -- [React Documentation](https://reactjs.org/) -- [Create React App Documentation](https://create-react-app.dev/) - -## OpenAI sanitizer -This project includes a helper to sanitize OCR/extracted rule blocks before importing into the database. - -Usage: -1. Add your OpenAI API key to a local `.env` file in the project root (DO NOT commit this file): - -``` -OPENAI_API_KEY=sk-REPLACE -``` - -2. Run the sanitizer (it reads `database/backups/extracted-blocks.json` by default): - -``` -npm run sanitize:openai -``` diff --git a/database/routes/playerRoutes-sqlite.js b/database/routes/playerRoutes-sqlite.js index 178221c..1e09d10 100644 --- a/database/routes/playerRoutes-sqlite.js +++ b/database/routes/playerRoutes-sqlite.js @@ -409,4 +409,268 @@ router.post('/:name/avatar', requireSession, async (req, res) => { } }); +// GM ENDPOINTS - bypassing session validation with GM secret +function gmBypass(req, res, next) { + const gmSecret = req.headers['x-gm-secret']; + if (gmSecret === 'bongo') { + logToFile('SESSION: GM bypass accepted', req.method, req.url); + return next(); + } + logToFile('SESSION: GM bypass rejected - invalid secret', req.method, req.url); + return res.status(401).json({ error: 'GM access denied' }); +} + +// Add/update player (GM only) +router.post('/gm/add-or-update', gmBypass, (req, res) => { + try { + const { name, rp, pw } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Player name is required' }); + } + + // Check if player exists + const existing = playerHelpers.getByName(name); + + if (existing) { + // Update existing player + const updates = { ...existing }; + if (rp !== undefined) updates.tabInfo = { ...updates.tabInfo, rp: parseInt(rp) }; + if (pw) updates.pwHash = require('bcrypt').hashSync(pw, 10); + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(name, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to update player' }); + + logToFile('GM: Updated player', name); + return res.json({ success: true, message: `Updated player ${name}` }); + } else { + // Create new player + const defaultRP = rp !== undefined ? parseInt(rp) : 50; + const password = pw || '1234'; + + const newPlayer = { + name, + tabInfo: { + rp: defaultRP, + renown: 'None', + xp: 0, + xpSpent: 0, + charName: `Brother ${name.charAt(0).toUpperCase() + name.slice(1)}` + }, + rollerInfo: {}, + shopInfo: {}, + pwHash: require('bcrypt').hashSync(password, 10), + pw: '' + }; + + const { valid, errors, normalized } = validatePlayer(newPlayer); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const saved = playerHelpers.create(normalized); + if (!saved) return res.status(500).json({ error: 'Failed to create player' }); + + logToFile('GM: Created player', name); + return res.json({ success: true, message: `Created player ${name}` }); + } + } catch (error) { + logToFile('GM: Add/update player failed', error); + res.status(500).json({ error: 'Failed to add/update player' }); + } +}); + +// Set RP (GM only) +router.post('/gm/set-rp', gmBypass, (req, res) => { + try { + const { playerName, requisitionPoints } = req.body; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const updates = { + ...player, + tabInfo: { ...player.tabInfo, rp: parseInt(requisitionPoints) } + }; + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(playerName, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to update player RP' }); + + logToFile('GM: Set RP for', playerName, 'to', requisitionPoints); + res.json({ success: true, message: `Set RP for ${playerName} to ${requisitionPoints}` }); + } catch (error) { + logToFile('GM: Set RP failed', error); + res.status(500).json({ error: 'Failed to set RP' }); + } +}); + +// Set XP (GM only) +router.post('/gm/set-xp', gmBypass, (req, res) => { + try { + const { playerName, xp } = req.body; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const updates = { + ...player, + tabInfo: { ...player.tabInfo, xp: parseInt(xp) } + }; + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(playerName, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to update player XP' }); + + logToFile('GM: Set XP for', playerName, 'to', xp); + res.json({ success: true, message: `Set XP for ${playerName} to ${xp}` }); + } catch (error) { + logToFile('GM: Set XP failed', error); + res.status(500).json({ error: 'Failed to set XP' }); + } +}); + +// Set XP Spent (GM only) +router.post('/gm/set-xp-spent', gmBypass, (req, res) => { + try { + const { playerName, xpSpent } = req.body; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const updates = { + ...player, + tabInfo: { ...player.tabInfo, xpSpent: parseInt(xpSpent) } + }; + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(playerName, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to update player XP Spent' }); + + logToFile('GM: Set XP Spent for', playerName, 'to', xpSpent); + res.json({ success: true, message: `Set XP Spent for ${playerName} to ${xpSpent}` }); + } catch (error) { + logToFile('GM: Set XP Spent failed', error); + res.status(500).json({ error: 'Failed to set XP Spent' }); + } +}); + +// Set Renown (GM only) +router.post('/gm/set-renown', gmBypass, (req, res) => { + try { + const { playerName, renown } = req.body; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const updates = { + ...player, + tabInfo: { ...player.tabInfo, renown } + }; + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(playerName, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to update player renown' }); + + logToFile('GM: Set renown for', playerName, 'to', renown); + res.json({ success: true, message: `Set renown for ${playerName} to ${renown}` }); + } catch (error) { + logToFile('GM: Set renown failed', error); + res.status(500).json({ error: 'Failed to set renown' }); + } +}); + +// Reset password (GM only) +router.post('/gm/reset-password', gmBypass, (req, res) => { + try { + const { playerName, newPassword } = req.body; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const password = newPassword || '1234'; + const updates = { + ...player, + pwHash: require('bcrypt').hashSync(password, 10), + pw: '' + }; + + const { valid, errors, normalized } = validatePlayer(updates); + if (!valid) return res.status(400).json({ error: 'Validation failed', details: errors }); + + const ok = playerHelpers.update(playerName, normalized); + if (!ok) return res.status(500).json({ error: 'Failed to reset password' }); + + logToFile('GM: Reset password for', playerName); + res.json({ success: true, message: `Reset password for ${playerName}` }); + } catch (error) { + logToFile('GM: Reset password failed', error); + res.status(500).json({ error: 'Failed to reset password' }); + } +}); + +// Delete player (GM only) +router.delete('/gm/delete/:playerName', gmBypass, (req, res) => { + try { + const { playerName } = req.params; + + if (!playerName) { + return res.status(400).json({ error: 'Player name is required' }); + } + + const player = playerHelpers.getByName(playerName); + if (!player) { + return res.status(404).json({ error: 'Player not found' }); + } + + const ok = playerHelpers.delete(playerName); + if (!ok) return res.status(500).json({ error: 'Failed to delete player' }); + + logToFile('GM: Deleted player', playerName); + res.json({ success: true, message: `Deleted player ${playerName}` }); + } catch (error) { + logToFile('GM: Delete player failed', error); + res.status(500).json({ error: 'Failed to delete player' }); + } +}); + module.exports = router; diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..81dbc6d --- /dev/null +++ b/setup.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Deathwatch Roller Setup Script +echo "🎲 Setting up Deathwatch Roller..." + +# Check if Node.js is installed +if ! command -v node &> /dev/null; then + echo "❌ Node.js is not installed. Please install Node.js (v18+) first." + exit 1 +fi + +echo "✅ Node.js $(node --version) found" + +# Install dependencies +echo "📦 Installing dependencies..." +npm install + +# Check if PM2 is installed globally +if ! command -v pm2 &> /dev/null; then + echo "⚠️ PM2 not found. Installing PM2 for production deployment..." + npm install -g pm2 +fi + +# Create necessary directories +mkdir -p database/sqlite +mkdir -p public/avatars + +# Initialize database (it will be created automatically on first server start) +echo "🗄️ Database will be initialized on first server start" + +echo "" +echo "🎉 Setup complete!" +echo "" +echo "To start development:" +echo " npm start # Start React dev server" +echo " npm run server # Start backend server (in another terminal)" +echo "" +echo "To start production:" +echo " npm run build # Build for production" +echo " npm run pm2:start # Start with PM2" +echo "" +echo "Access the application at: http://localhost:3000" +echo "Backend API available at: http://localhost:5000" diff --git a/src/App.js b/src/App.js index 114c6fa..25a0f41 100755 --- a/src/App.js +++ b/src/App.js @@ -5,6 +5,7 @@ import PlayerTab from './components/PlayerTab'; import RulesTab from './components/RulesTab'; import BestiaryTab from './components/BestiaryTab'; import GMKit from './components/GMKit'; +import PlayerManagement from './components/PlayerManagement'; import { useState, useEffect } from 'react'; import axios from 'axios'; import { debug, info, warn, error, logApiCall, logApiError, logUserAction } from './utils/logger'; @@ -266,6 +267,12 @@ function App() { > Bestiary + + + + ); + } + + // Component for setting RP + function GmSetRP({ name, currentRP, onSet }) { + const [rp, setRp] = useState(currentRP?.toString() || '0'); + + // Update state when currentRP prop changes + useEffect(() => { + setRp(currentRP?.toString() || '0'); + }, [currentRP]); + + return ( +
+ setRp(e.target.value)} + /> + +
+ ); + } + + // Component for setting XP + function GmSetXP({ name, currentXP, onSet }) { + const [xp, setXp] = useState(currentXP?.toString() || '0'); + + // Update state when currentXP prop changes + useEffect(() => { + setXp(currentXP?.toString() || '0'); + }, [currentXP]); + + return ( +
+ setXp(e.target.value)} + /> + +
+ ); + } + + // Component for setting XP Spent + function GmSetXPSpent({ name, currentXPSpent, onSet }) { + const [xpSpent, setXpSpent] = useState(currentXPSpent?.toString() || '0'); + + // Update state when currentXPSpent prop changes + useEffect(() => { + setXpSpent(currentXPSpent?.toString() || '0'); + }, [currentXPSpent]); + + return ( +
+ setXpSpent(e.target.value)} + /> + +
+ ); + } + + // Component for setting Renown + function GmSetRenown({ name, currentRenown, onSet }) { + const [renown, setRenown] = useState(currentRenown || 'None'); + + // Update state when currentRenown prop changes + useEffect(() => { + setRenown(currentRenown || 'None'); + }, [currentRenown]); + + return ( +
+ + +
+ ); + } + + // Component for resetting password + function GmResetPW({ name, onReset }) { + const [pw, setPw] = useState(''); + + return ( +
+ setPw(e.target.value)} + /> + +
+ ); + } + + // Component for bulk XP giving + function BulkXPGiver({ onGive }) { + const [amount, setAmount] = useState('100'); + + return ( +
+ setAmount(e.target.value)} + /> + +
+ ); + } + + // Component for bulk XP setting + function BulkXPSetter({ onSet }) { + const [amount, setAmount] = useState('1000'); + + return ( +
+ setAmount(e.target.value)} + /> + +
+ ); + } + + // Component for bulk RP giving + function BulkRPGiver({ onGive }) { + const [amount, setAmount] = useState('10'); + + return ( +
+ setAmount(e.target.value)} + /> + +
+ ); + } + + // Component for bulk RP setting + function BulkRPSetter({ onSet }) { + const [amount, setAmount] = useState('50'); + + return ( +
+ setAmount(e.target.value)} + /> + +
+ ); + } + + return ( +
+

Player Management

+

Manage player accounts, requisition points, experience, and renown.

+ + {saveMsg && ( +
+ {saveMsg} +
+ )} + + {/* Add New Player */} + + + {/* Players List */} +
+

Current Players ({players.length})

+ + {players.length === 0 ? ( +
+ No players found. Add some players above. +
+ ) : ( +
+ {players.map(player => ( +
+
+ {/* Player Info Section */} +
+
+
+
{player.name}
+
+ {player.renown || 'None'} • RP: {player.requisitionPoints || 0} +
+
+ +
+ + {/* Player Stats */} +
+
+ Total XP: {player.xp || 0} +
+
+ XP Spent: {player.xpSpent || 0} +
+
+ Available XP: {(player.xp || 0) - (player.xpSpent || 0)} +
+
+ Character: {player.charName || 'Unnamed'} +
+
+
+ + {/* Management Controls Section */} +
+ {/* RP Management */} +
+ + +
+ + {/* XP Management */} +
+ + +
+ + {/* XP Spent Management */} +
+ + +
+ + {/* Renown Management */} +
+ + +
+ + {/* Password Reset */} +
+ + +
+
+
+
+ ))} +
+ )} +
+ + {/* Quick Actions */} +
+

Quick Actions

+ + {/* Bulk XP Actions */} +
+
Bulk XP Management
+
+ { + players.forEach(player => { + const currentXP = player.xp || 0; + gmSetXP(player.name, (currentXP + amount).toString()); + }); + }} /> + { + players.forEach(player => { + gmSetXP(player.name, amount.toString()); + }); + }} /> +
+
+ + {/* Bulk RP Actions */} +
+
Bulk RP Management
+
+ { + players.forEach(player => { + const currentRP = player.requisitionPoints || 0; + gmSetRP(player.name, (currentRP + amount).toString()); + }); + }} /> + { + players.forEach(player => { + gmSetRP(player.name, amount.toString()); + }); + }} /> +
+
+ + {/* Other Quick Actions */} +
+
Other Actions
+
+ + +
+
+
+
+ ); +} diff --git a/src/tests/playerManagement.test.js b/src/tests/playerManagement.test.js new file mode 100644 index 0000000..a25c8f7 --- /dev/null +++ b/src/tests/playerManagement.test.js @@ -0,0 +1,433 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import PlayerManagement from '../components/PlayerManagement'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios; + +// Mock window.confirm +const mockConfirm = jest.fn(); +Object.defineProperty(window, 'confirm', { + value: mockConfirm, + writable: true, +}); + +describe('PlayerManagement Component', () => { + const mockSessionId = 'test-session-123'; + const mockAuthedPlayer = 'gm'; + + // Mock player data + const mockPlayers = [ + { + name: 'TestPlayer', + requisitionPoints: 50, + xp: 1000, + xpSpent: 200, + renown: 'Respected', + charName: 'Brother Testicus' + } + ]; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + mockConfirm.mockReturnValue(true); + + // Mock successful API responses + mockedAxios.get.mockResolvedValue({ data: mockPlayers }); + mockedAxios.post.mockResolvedValue({ status: 200, data: { success: true } }); + mockedAxios.delete.mockResolvedValue({ status: 200, data: { success: true } }); + }); + + test('renders access denied for non-GM users', () => { + render(); + + expect(screen.getByText('Access Denied')).toBeInTheDocument(); + expect(screen.getByText(/Player Management is only accessible to Game Masters/)).toBeInTheDocument(); + }); + + test('renders player management interface for GM', async () => { + render(); + + expect(screen.getByText('Player Management')).toBeInTheDocument(); + expect(screen.getByText(/Manage player accounts, requisition points/)).toBeInTheDocument(); + + // Wait for players to load + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + }); + + test('fetches and displays players on mount', async () => { + render(); + + // Wait for API call + await waitFor(() => { + expect(mockedAxios.get).toHaveBeenCalledWith('/api/players', { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + }); + }); + + // 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 + }); + }); + + test('creates new player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText('Player Name')).toBeInTheDocument(); + }); + + // Fill in new player form + const nameInput = screen.getByPlaceholderText('Player Name'); + const rpInput = screen.getByPlaceholderText('Requisition Points'); + const pwInput = screen.getByPlaceholderText('Password (default: 1234)'); + const addButton = screen.getByText('Add Player'); + + fireEvent.change(nameInput, { target: { value: 'NewPlayer' } }); + fireEvent.change(rpInput, { target: { value: '75' } }); + fireEvent.change(pwInput, { target: { value: 'testpass' } }); + fireEvent.click(addButton); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/add-or-update', + { + name: 'NewPlayer', + requisitionPoints: 75, + password: 'testpass' + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('sets requisition points for player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find RP management section and use current RP value (50) + 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); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-rp', + { + playerName: 'TestPlayer', + requisitionPoints: 50 + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('sets experience points for player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find XP management section and use current XP value (1000) + 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); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-xp', + { + playerName: 'TestPlayer', + xp: 1000 + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('sets XP spent for player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find XP Spent management section and use current value (200) + 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); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-xp-spent', + { + playerName: 'TestPlayer', + xpSpent: 200 + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('sets renown for player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find renown dropdown and use current value (Respected) + 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); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-renown', + { + playerName: 'TestPlayer', + renown: 'Respected' + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('resets password for player', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find password reset section + const passwordInput = screen.getByPlaceholderText('New password (default: 1234)'); + const resetButton = screen.getByText('Reset'); + + fireEvent.change(passwordInput, { target: { value: 'newpass123' } }); + fireEvent.click(resetButton); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/reset-password', + { + playerName: 'TestPlayer', + newPassword: 'newpass123' + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('deletes player with confirmation', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + const deleteButton = screen.getByText('Delete Player'); + fireEvent.click(deleteButton); + + // Check confirmation dialog + expect(mockConfirm).toHaveBeenCalledWith( + 'Are you sure you want to delete player TestPlayer? This cannot be undone.' + ); + + await waitFor(() => { + expect(mockedAxios.delete).toHaveBeenCalledWith( + '/api/players/gm/delete/TestPlayer', + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('bulk gives XP to all players', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find bulk XP giver + const bulkXPInput = screen.getByDisplayValue('100'); + const giveXPButton = screen.getByText('Give XP to All'); + + fireEvent.change(bulkXPInput, { target: { value: '250' } }); + fireEvent.click(giveXPButton); + + // Check confirmation + expect(mockConfirm).toHaveBeenCalledWith('Give 250 XP to all 1 players?'); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-xp', + { + playerName: 'TestPlayer', + xp: 1250 // 1000 + 250 + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('bulk gives RP to all players', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // Find bulk RP giver + const bulkRPInput = screen.getByDisplayValue('10'); + const giveRPButton = screen.getByText('Give RP to All'); + + fireEvent.change(bulkRPInput, { target: { value: '25' } }); + fireEvent.click(giveRPButton); + + // Check confirmation + expect(mockConfirm).toHaveBeenCalledWith('Give 25 RP to all 1 players?'); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/players/gm/set-rp', + { + playerName: 'TestPlayer', + requisitionPoints: 75 // 50 + 25 + }, + { + headers: { + 'Content-Type': 'application/json', + 'x-session-id': mockSessionId, + 'x-gm-secret': 'bongo' + } + } + ); + }); + }); + + test('handles API errors gracefully', async () => { + // Mock API error + mockedAxios.post.mockRejectedValueOnce({ + response: { data: { message: 'Server error' } } + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + // 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]; + + fireEvent.change(rpInput, { target: { value: '100' } }); + fireEvent.click(rpSetButton); + + // Should show error message + await waitFor(() => { + expect(screen.getByText(/Failed to set RP for TestPlayer: Server error/)).toBeInTheDocument(); + }); + }); + + test('refreshes players list', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('TestPlayer')).toBeInTheDocument(); + }); + + const refreshButton = screen.getByText('Refresh Players'); + fireEvent.click(refreshButton); + + await waitFor(() => { + // Should make another API call + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + }); + }); +});