feat: Add PlayerManagement GM interface and clean repository
- Add comprehensive PlayerManagement component for GMs - Add all GM API endpoints for player CRUD operations - Add comprehensive test suite with 14 tests - Clean up .gitignore to exclude logs, backups, core dumps - Update README with setup instructions and documentation - Add setup.sh script for easy project initialization This provides a complete GM interface for managing players without the large files that were blocking the previous push.
This commit is contained in:
52
.gitignore
vendored
52
.gitignore
vendored
@@ -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
|
||||
|
||||
98
README.md
98
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
|
||||
```
|
||||
|
||||
@@ -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;
|
||||
|
||||
43
setup.sh
Executable file
43
setup.sh
Executable file
@@ -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"
|
||||
@@ -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
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='players' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
|
||||
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'players' }); setTab('players')}}
|
||||
>
|
||||
Player Management
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${tab==='gmkit' ? 'bg-blue-600 text-white shadow-lg' : 'bg-slate-700/50 text-slate-200 hover:bg-slate-600/50'}`}
|
||||
onClick={()=>{logUserAction('navigation', 'Tab switch', { from: tab, to: 'gmkit' }); setTab('gmkit')}}
|
||||
@@ -311,7 +318,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
||||
{tab==='roller' ? <DeathwatchRoller /> : tab==='shop' ? <RequisitionShop authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='rules' ? <RulesTab authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='bestiary' ? (authedPlayer === 'gm' ? <BestiaryTab /> : <div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30"><h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2><p className="text-red-200">The Bestiary is only accessible to Game Masters. Please log in with a GM account.</p></div>) : tab==='players' ? <PlayerManagement authedPlayer={authedPlayer} sessionId={sessionId} /> : tab==='gmkit' ? <GMKit authedPlayer={authedPlayer} /> : <PlayerTab
|
||||
authedPlayer={authedPlayer}
|
||||
sessionId={sessionId}
|
||||
/>}
|
||||
|
||||
656
src/components/PlayerManagement.jsx
Normal file
656
src/components/PlayerManagement.jsx
Normal file
@@ -0,0 +1,656 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const RANK_ORDER = ['None','Respected','Distinguished','Famed','Hero'];
|
||||
|
||||
export default function PlayerManagement({ authedPlayer, sessionId }) {
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
|
||||
// Helper to build request headers
|
||||
function buildHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (sessionId) headers['x-session-id'] = sessionId;
|
||||
if (authedPlayer === 'gm') headers['x-gm-secret'] = 'bongo';
|
||||
return { ...headers, ...extra };
|
||||
}
|
||||
|
||||
function flash(msg) {
|
||||
setSaveMsg(msg);
|
||||
setTimeout(() => setSaveMsg(''), 3000);
|
||||
}
|
||||
|
||||
// Fetch players from the database
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/players', { headers: buildHeaders() });
|
||||
setPlayers(res.data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch players:', err);
|
||||
flash('Failed to fetch players');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, authedPlayer]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlayers();
|
||||
}, [fetchPlayers]);
|
||||
|
||||
if (authedPlayer !== 'gm') return (
|
||||
<div className="p-6 rounded-lg bg-red-900/20 border border-red-500/30">
|
||||
<h2 className="text-xl font-bold text-red-300 mb-2">Access Denied</h2>
|
||||
<p className="text-red-200">Player Management is only accessible to Game Masters. Please log in as a GM.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// GM action handlers
|
||||
async function gmSetXP(name, xp) {
|
||||
console.log('PlayerManagement: gmSetXP', { name, xp });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-xp',
|
||||
{ playerName: name, xp: parseInt(xp) },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetXP response', res.status, res.data);
|
||||
flash(`Set XP for ${name} to ${xp}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetXP failed', e.response?.data || e.message);
|
||||
flash(`Failed to set XP for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetXPSpent(name, xpSpent) {
|
||||
console.log('PlayerManagement: gmSetXPSpent', { name, xpSpent });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-xp-spent',
|
||||
{ playerName: name, xpSpent: parseInt(xpSpent) },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetXPSpent response', res.status, res.data);
|
||||
flash(`Set XP Spent for ${name} to ${xpSpent}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetXPSpent failed', e.response?.data || e.message);
|
||||
flash(`Failed to set XP Spent for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmAddOrUpdatePlayer(name, rp, pw) {
|
||||
console.log('PlayerManagement: gmAddOrUpdatePlayer', { name, rp, pwProvided: !!pw });
|
||||
try {
|
||||
const resPost = await axios.post('/api/players/gm/add-or-update',
|
||||
{ name, requisitionPoints: parseInt(rp), password: pw || '1234' },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmAddOrUpdatePlayer POST response', resPost.status, resPost.data);
|
||||
flash(`Added/Updated player ${name} with ${rp} RP`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmAddOrUpdatePlayer failed', e.response?.data || e.message);
|
||||
flash(`Failed to add/update player ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetRP(name, rp) {
|
||||
console.log('PlayerManagement: gmSetRP', { name, rp });
|
||||
try {
|
||||
const url = `/api/players/gm/set-rp`;
|
||||
const payload = { playerName: name, requisitionPoints: parseInt(rp) };
|
||||
const config = { headers: buildHeaders() };
|
||||
|
||||
console.log('PlayerManagement: Making request to', url, 'with payload', payload, 'and config', config);
|
||||
|
||||
const res = await axios.post(url, payload, config);
|
||||
console.log('PlayerManagement: gmSetRP response', res.status, res.data);
|
||||
|
||||
flash(`Set RP for ${name} to ${rp}`);
|
||||
|
||||
// Refresh players list to show updated RP
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetRP failed', e.response?.data || e.message);
|
||||
flash(`Failed to set RP for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmSetRenown(name, renown) {
|
||||
console.log('PlayerManagement: gmSetRenown', { name, renown });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/set-renown',
|
||||
{ playerName: name, renown },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmSetRenown response', res.status, res.data);
|
||||
flash(`Set Renown for ${name} to ${renown}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmSetRenown failed', e.response?.data || e.message);
|
||||
flash(`Failed to set Renown for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmResetPlayerPw(name, pw) {
|
||||
console.log('PlayerManagement: gmResetPlayerPw', { name, pwProvided: !!pw });
|
||||
try {
|
||||
const res = await axios.post('/api/players/gm/reset-password',
|
||||
{ playerName: name, newPassword: pw || '1234' },
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmResetPlayerPw response', res.status, res.data);
|
||||
flash(`Reset password for ${name}`);
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmResetPlayerPw failed', e.response?.data || e.message);
|
||||
flash(`Failed to reset password for ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function gmDeletePlayer(name) {
|
||||
if (!window.confirm(`Are you sure you want to delete player ${name}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.delete(`/api/players/gm/delete/${encodeURIComponent(name)}`,
|
||||
{ headers: buildHeaders() }
|
||||
);
|
||||
console.log('PlayerManagement: gmDeletePlayer response', res.status, res.data);
|
||||
flash(`Deleted player ${name}`);
|
||||
|
||||
// Refresh players list
|
||||
await fetchPlayers();
|
||||
} catch (e) {
|
||||
console.error('PlayerManagement: gmDeletePlayer failed', e.response?.data || e.message);
|
||||
flash(`Failed to delete player ${name}: ${e.response?.data?.message || e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Component for adding new players
|
||||
function GmAddPlayer({ onAdd }) {
|
||||
const [name, setName] = useState('');
|
||||
const [rp, setRp] = useState('50');
|
||||
const [pw, setPw] = useState('');
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
||||
<h4 className="text-lg font-medium text-white mb-3">Add New Player</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="text"
|
||||
placeholder="Player Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="number"
|
||||
placeholder="Requisition Points"
|
||||
value={rp}
|
||||
onChange={e => setRp(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-3 py-2 text-white placeholder-white/50 text-sm"
|
||||
type="password"
|
||||
placeholder="Password (default: 1234)"
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-green-600 hover:bg-green-500 px-4 py-2 text-white text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
if (name.trim()) {
|
||||
onAdd(name.trim(), rp, pw || '1234');
|
||||
setName('');
|
||||
setRp('50');
|
||||
setPw('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add Player
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={rp}
|
||||
onChange={e => setRp(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-blue-600 hover:bg-blue-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, rp)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={xp}
|
||||
onChange={e => setXp(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-purple-600 hover:bg-purple-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, xp)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
type="number"
|
||||
value={xpSpent}
|
||||
onChange={e => setXpSpent(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-indigo-600 hover:bg-indigo-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, xpSpent)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<select
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-sm flex-1 min-w-0"
|
||||
value={renown}
|
||||
onChange={e => setRenown(e.target.value)}
|
||||
>
|
||||
{RANK_ORDER.map(rank => (
|
||||
<option key={rank} value={rank} className="bg-slate-800">{rank}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="rounded bg-yellow-600 hover:bg-yellow-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => onSet(name, renown)}
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for resetting password
|
||||
function GmResetPW({ name, onReset }) {
|
||||
const [pw, setPw] = useState('');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white placeholder-white/50 text-sm flex-1 min-w-0"
|
||||
type="password"
|
||||
placeholder="New password (default: 1234)"
|
||||
value={pw}
|
||||
onChange={e => setPw(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="rounded bg-orange-600 hover:bg-orange-500 px-3 py-1 text-white text-xs font-medium transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
onReset(name, pw || '1234');
|
||||
setPw('');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk XP giving
|
||||
function BulkXPGiver({ onGive }) {
|
||||
const [amount, setAmount] = useState('100');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-purple-600/80 hover:bg-purple-600 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Give ${amount} XP to all ${players.length} players?`)) {
|
||||
onGive(parseInt(amount));
|
||||
flash(`Gave ${amount} XP to all players`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Give XP to All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk XP setting
|
||||
function BulkXPSetter({ onSet }) {
|
||||
const [amount, setAmount] = useState('1000');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-purple-500/80 hover:bg-purple-500 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Set all ${players.length} players to ${amount} XP?`)) {
|
||||
onSet(parseInt(amount));
|
||||
flash(`Set all players to ${amount} XP`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set All XP
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk RP giving
|
||||
function BulkRPGiver({ onGive }) {
|
||||
const [amount, setAmount] = useState('10');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Give ${amount} RP to all ${players.length} players?`)) {
|
||||
onGive(parseInt(amount));
|
||||
flash(`Gave ${amount} RP to all players`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Give RP to All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Component for bulk RP setting
|
||||
function BulkRPSetter({ onSet }) {
|
||||
const [amount, setAmount] = useState('50');
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="rounded border border-white/20 bg-white/10 px-2 py-1 text-white text-xs w-16"
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-500/80 hover:bg-blue-500 text-white transition-colors whitespace-nowrap"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Set all ${players.length} players to ${amount} RP?`)) {
|
||||
onSet(parseInt(amount));
|
||||
flash(`Set all players to ${amount} RP`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Set All RP
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/5 rounded-xl p-6 border border-white/10">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-white">Player Management</h2>
|
||||
<p className="text-sm text-slate-300 mb-6">Manage player accounts, requisition points, experience, and renown.</p>
|
||||
|
||||
{saveMsg && (
|
||||
<div className="mb-4 p-3 rounded bg-green-500/20 border border-green-500/30 text-green-300 text-sm">
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Player */}
|
||||
<GmAddPlayer onAdd={gmAddOrUpdatePlayer} />
|
||||
|
||||
{/* Players List */}
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Current Players ({players.length})</h3>
|
||||
|
||||
{players.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">
|
||||
No players found. Add some players above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{players.map(player => (
|
||||
<div key={player.name} className="bg-white/3 rounded-lg p-4 border border-white/5">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Player Info Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="rounded bg-red-600 hover:bg-red-500 px-3 py-1.5 text-white text-sm font-medium transition-colors"
|
||||
onClick={() => gmDeletePlayer(player.name)}
|
||||
>
|
||||
Delete Player
|
||||
</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}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Controls Section */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* RP Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Requisition Points</label>
|
||||
<GmSetRP
|
||||
name={player.name}
|
||||
currentRP={player.requisitionPoints}
|
||||
onSet={gmSetRP}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XP Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Experience Points</label>
|
||||
<GmSetXP
|
||||
name={player.name}
|
||||
currentXP={player.xp}
|
||||
onSet={gmSetXP}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* XP Spent Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">XP Spent</label>
|
||||
<GmSetXPSpent
|
||||
name={player.name}
|
||||
currentXPSpent={player.xpSpent}
|
||||
onSet={gmSetXPSpent}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Renown Management */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Renown Level</label>
|
||||
<GmSetRenown
|
||||
name={player.name}
|
||||
currentRenown={player.renown}
|
||||
onSet={gmSetRenown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Reset */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-xs font-medium text-slate-300 uppercase tracking-wide">Password Reset</label>
|
||||
<GmResetPW
|
||||
name={player.name}
|
||||
onReset={gmResetPlayerPw}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-6 p-4 bg-white/3 rounded-lg border border-white/5">
|
||||
<h4 className="text-sm font-medium text-white mb-3">Quick Actions</h4>
|
||||
|
||||
{/* Bulk XP Actions */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk XP Management</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkXPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentXP = player.xp || 0;
|
||||
gmSetXP(player.name, (currentXP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
<BulkXPSetter onSet={(amount) => {
|
||||
players.forEach(player => {
|
||||
gmSetXP(player.name, amount.toString());
|
||||
});
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk RP Actions */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Bulk RP Management</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BulkRPGiver onGive={(amount) => {
|
||||
players.forEach(player => {
|
||||
const currentRP = player.requisitionPoints || 0;
|
||||
gmSetRP(player.name, (currentRP + amount).toString());
|
||||
});
|
||||
}} />
|
||||
<BulkRPSetter onSet={(amount) => {
|
||||
players.forEach(player => {
|
||||
gmSetRP(player.name, amount.toString());
|
||||
});
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Quick Actions */}
|
||||
<div>
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wide mb-2">Other Actions</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="text-xs px-3 py-1 rounded bg-blue-600/80 hover:bg-blue-600 text-white transition-colors"
|
||||
onClick={fetchPlayers}
|
||||
>
|
||||
Refresh Players
|
||||
</button>
|
||||
<button
|
||||
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) {
|
||||
gmSetRP(player.name, '50');
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
Set Low RP Players to 50
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
src/tests/playerManagement.test.js
Normal file
433
src/tests/playerManagement.test.js
Normal file
@@ -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(<PlayerManagement authedPlayer="player1" sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
// 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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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(<PlayerManagement authedPlayer={mockAuthedPlayer} sessionId={mockSessionId} />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user