You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

2898 lines
100 KiB

const WebSocket = require('ws');
const http = require('http');
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const crypto = require('crypto');
const webpush = require('web-push');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const HikeMapDB = require('./database');
require('dotenv').config();
// JWT configuration
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
const JWT_ACCESS_EXPIRY = process.env.JWT_ACCESS_EXPIRY || '15m';
const JWT_REFRESH_EXPIRY = process.env.JWT_REFRESH_EXPIRY || '7d';
// Parse expiry string to milliseconds
function parseExpiry(expiry) {
const match = expiry.match(/^(\d+)([smhd])$/);
if (!match) return 15 * 60 * 1000; // default 15 minutes
const value = parseInt(match[1]);
const unit = match[2];
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return 15 * 60 * 1000;
}
}
// Database instance
let db = null;
// Configure web-push with VAPID keys
if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
webpush.setVapidDetails(
process.env.VAPID_EMAIL || 'mailto:admin@maps.bibbit.duckdns.org',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
console.log('Push notifications configured');
} else {
console.log('Push notifications not configured - missing VAPID keys');
}
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
// Server session ID - changes on each restart, used to force client re-auth
const serverSessionId = require('crypto').randomBytes(16).toString('hex');
console.log(`Server session ID: ${serverSessionId.substring(0, 8)}...`);
// Middleware to parse JSON and raw body for KML
app.use(express.json());
app.use(express.text({ type: 'application/xml', limit: '10mb' }));
// Disable caching for API routes
app.use('/api', (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
next();
});
// Ensure skill icons directory exists
const skillsIconDir = path.join(__dirname, 'mapgameimgs', 'skills');
if (!fsSync.existsSync(skillsIconDir)) {
fsSync.mkdirSync(skillsIconDir, { recursive: true });
}
// Configure multer for skill icon uploads
const skillIconStorage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, skillsIconDir);
},
filename: (req, file, cb) => {
// Auto-rename to skill ID with original extension
const skillId = req.params.skillId || req.params.id;
const ext = path.extname(file.originalname).toLowerCase() || '.png';
cb(null, `${skillId}${ext}`);
}
});
const skillIconUpload = multer({
storage: skillIconStorage,
limits: { fileSize: 500 * 1024 }, // 500KB max
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only PNG, JPG, GIF, and WebP images are allowed'));
}
}
});
// Serve service-worker.js with no-cache (critical for updates)
app.get('/service-worker.js', (req, res) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
res.sendFile(path.join(__dirname, 'service-worker.js'));
});
// Serve HTML files with no-cache to ensure fresh content
app.get(['/', '/index.html', '/admin.html'], (req, res, next) => {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
next();
});
// Serve static files - prioritize data directory for default.kml
app.get('/default.kml', async (req, res) => {
try {
// Try to serve from data directory first (mounted volume)
const dataPath = path.join('/app/data', 'default.kml');
await fs.access(dataPath);
console.log('Serving default.kml from data directory');
res.sendFile(dataPath);
} catch (err) {
// Fall back to app directory
const appPath = path.join(__dirname, 'default.kml');
console.log('Serving default.kml from app directory');
res.sendFile(appPath);
}
});
// Serve .well-known directory for app verification
app.use('/.well-known', express.static(path.join(__dirname, '.well-known')));
// Serve monster images
app.use('/mapgameimgs', express.static(path.join(__dirname, 'mapgameimgs')));
// Serve game music
app.use('/mapgamemusic', express.static(path.join(__dirname, 'mapgamemusic')));
// Serve sound effects
app.use('/sfx', express.static(path.join(__dirname, 'sfx')));
// Serve other static files
app.use(express.static(path.join(__dirname)));
// Store connected users
const users = new Map();
// Store geocaches
let geocaches = [];
// Store push subscriptions
let pushSubscriptions = [];
// Geocache file path
const getGeocachePath = async () => {
let dataDir = __dirname;
try {
await fs.access('/app/data');
dataDir = '/app/data';
} catch (err) {
// Use local directory if /app/data doesn't exist
}
return path.join(dataDir, 'geocaches.json');
};
// Load geocaches from file
const loadGeocaches = async () => {
try {
const geocachePath = await getGeocachePath();
const data = await fs.readFile(geocachePath, 'utf8');
geocaches = JSON.parse(data);
console.log(`Loaded ${geocaches.length} geocaches from file`);
} catch (err) {
if (err.code === 'ENOENT') {
console.log('No geocaches file found, starting fresh');
} else {
console.error('Error loading geocaches:', err);
}
geocaches = [];
}
};
// Save geocaches to file
const saveGeocaches = async () => {
try {
const geocachePath = await getGeocachePath();
await fs.writeFile(geocachePath, JSON.stringify(geocaches, null, 2), 'utf8');
console.log(`Saved ${geocaches.length} geocaches to file`);
} catch (err) {
console.error('Error saving geocaches:', err);
}
};
// KML save endpoint
app.post('/save-kml', async (req, res) => {
try {
const kmlContent = req.body;
// Always use data directory when it exists (Docker), otherwise local
let dataDir = __dirname;
try {
await fs.access('/app/data');
dataDir = '/app/data';
console.log('Using data directory for save');
} catch (err) {
console.log('Using app directory for save');
}
const defaultKmlPath = path.join(dataDir, 'default.kml');
// Create backup with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(dataDir, `default.kml.backup.${timestamp}`);
try {
// Try to backup existing file
const existingContent = await fs.readFile(defaultKmlPath, 'utf8');
await fs.writeFile(backupPath, existingContent);
console.log(`Backed up existing KML to: ${backupPath}`);
} catch (err) {
// No existing file to backup, that's OK
console.log('No existing default.kml to backup');
}
// Write new content
await fs.writeFile(defaultKmlPath, kmlContent, 'utf8');
console.log('Saved new default.kml');
// Clean up old backups (keep only last 10)
const files = await fs.readdir(dataDir);
const backupFiles = files
.filter(f => f.startsWith('default.kml.backup.'))
.sort()
.reverse();
for (let i = 10; i < backupFiles.length; i++) {
await fs.unlink(path.join(dataDir, backupFiles[i]));
console.log(`Deleted old backup: ${backupFiles[i]}`);
}
res.json({
success: true,
message: 'Tracks saved to server successfully',
backup: backupPath.split('/').pop()
});
} catch (err) {
console.error('Error saving KML:', err);
res.status(500).send('Failed to save: ' + err.message);
}
});
// Push notification endpoints
app.get('/vapid-public-key', (req, res) => {
res.json({ publicKey: process.env.VAPID_PUBLIC_KEY || '' });
});
app.post('/subscribe', async (req, res) => {
try {
const subscription = req.body;
// Store subscription (in production, use a database)
const existingIndex = pushSubscriptions.findIndex(
sub => sub.endpoint === subscription.endpoint
);
if (existingIndex >= 0) {
pushSubscriptions[existingIndex] = subscription;
} else {
pushSubscriptions.push(subscription);
}
// Save subscriptions to file
try {
const dataDir = await getGeocachePath();
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2));
} catch (err) {
console.error('Error saving subscriptions:', err);
}
res.json({ success: true });
} catch (err) {
console.error('Subscription error:', err);
res.status(500).json({ error: 'Failed to subscribe' });
}
});
app.post('/unsubscribe', async (req, res) => {
try {
const { endpoint } = req.body;
pushSubscriptions = pushSubscriptions.filter(sub => sub.endpoint !== endpoint);
// Save updated subscriptions
try {
const dataDir = await getGeocachePath();
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
await fs.writeFile(subsPath, JSON.stringify(pushSubscriptions, null, 2));
} catch (err) {
console.error('Error saving subscriptions:', err);
}
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Failed to unsubscribe' });
}
});
// Send test notification to all users
app.post('/test-notification', async (req, res) => {
try {
const { message } = req.body;
if (!pushSubscriptions.length) {
return res.json({ success: false, message: 'No users have notifications enabled' });
}
const payload = {
title: '🔔 HikeMap Test Notification',
body: message || 'This is a test notification from HikeMap',
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
vibrate: [200, 100, 200],
data: {
type: 'test',
timestamp: Date.now()
}
};
// Send to all subscriptions
const results = await Promise.allSettled(
pushSubscriptions.map(subscription =>
webpush.sendNotification(subscription, JSON.stringify(payload))
.catch(err => {
if (err.statusCode === 410) {
// Subscription expired, remove it
pushSubscriptions = pushSubscriptions.filter(sub =>
sub.endpoint !== subscription.endpoint
);
}
throw err;
})
)
);
const successful = results.filter(r => r.status === 'fulfilled').length;
console.log(`Test notification sent to ${successful}/${pushSubscriptions.length} subscribers`);
res.json({ success: true, sent: successful, total: pushSubscriptions.length });
} catch (err) {
console.error('Error sending test notification:', err);
res.status(500).json({ error: 'Failed to send test notification' });
}
});
// Send notification to a specific user
app.post('/send-notification', async (req, res) => {
try {
const { title, body, type, userId } = req.body;
if (!pushSubscriptions.length) {
return res.json({ success: false, message: 'No subscriptions' });
}
const payload = {
title: title || 'HikeMap Notification',
body: body || '',
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
data: {
type: type,
timestamp: Date.now()
}
};
// Send to all subscriptions (in a real app, filter by userId)
const results = await Promise.allSettled(
pushSubscriptions.map(subscription =>
webpush.sendNotification(subscription, JSON.stringify(payload))
.catch(err => {
if (err.statusCode === 410) {
// Subscription expired, remove it
pushSubscriptions = pushSubscriptions.filter(sub =>
sub.endpoint !== subscription.endpoint
);
}
throw err;
})
)
);
const successful = results.filter(r => r.status === 'fulfilled').length;
console.log(`Sent notification to ${successful}/${pushSubscriptions.length} subscribers`);
res.json({ success: true, sent: successful });
} catch (err) {
console.error('Error sending notification:', err);
res.status(500).json({ error: 'Failed to send notification' });
}
});
// ============================================
// Authentication Middleware
// ============================================
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: JWT_ACCESS_EXPIRY }
);
const refreshToken = jwt.sign(
{ userId: user.id, type: 'refresh' },
JWT_SECRET,
{ expiresIn: JWT_REFRESH_EXPIRY }
);
return { accessToken, refreshToken };
}
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(403).json({ error: 'Invalid token' });
}
req.user = decoded;
next();
});
}
function optionalAuth(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (token) {
jwt.verify(token, JWT_SECRET, (err, decoded) => {
if (!err) {
req.user = decoded;
}
});
}
next();
}
// Admin-only middleware - requires valid auth AND admin status
function adminOnly(req, res, next) {
authenticateToken(req, res, () => {
const user = db.getUserById(req.user.userId);
if (!user || !user.is_admin) {
return res.status(403).json({ error: 'Admin access required' });
}
next();
});
}
// ============================================
// Authentication Endpoints
// ============================================
// Register new user
app.post('/api/register', async (req, res) => {
try {
const { username, email, password } = req.body;
// Validate input
if (!username || !email || !password) {
return res.status(400).json({ error: 'Username, email, and password are required' });
}
if (username.length < 3 || username.length > 20) {
return res.status(400).json({ error: 'Username must be 3-20 characters' });
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return res.status(400).json({ error: 'Username can only contain letters, numbers, and underscores' });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const user = await db.createUser(username, email, password);
const tokens = generateTokens(user);
// Store refresh token hash
const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt);
console.log(`New user registered: ${username}`);
res.status(201).json({
user: {
id: user.id,
username: user.username,
email: user.email,
total_points: 0,
finds_count: 0,
avatar_icon: 'account',
avatar_color: '#4CAF50'
},
...tokens
});
} catch (err) {
console.error('Registration error:', err);
if (err.message.includes('already exists')) {
return res.status(409).json({ error: err.message });
}
res.status(500).json({ error: 'Registration failed' });
}
});
// Login
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
const user = await db.validateUser(username, password);
if (!user) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const tokens = generateTokens(user);
// Store refresh token hash
const refreshTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
await db.storeRefreshToken(user.id, refreshTokenHash, expiresAt);
console.log(`User logged in: ${user.username}`);
res.json({
user: {
id: user.id,
username: user.username,
email: user.email,
total_points: user.total_points,
finds_count: user.finds_count,
avatar_icon: user.avatar_icon,
avatar_color: user.avatar_color,
is_admin: user.is_admin
},
...tokens
});
} catch (err) {
console.error('Login error:', err);
res.status(500).json({ error: 'Login failed' });
}
});
// Refresh access token
app.post('/api/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: 'Refresh token required' });
}
// Verify refresh token
let decoded;
try {
decoded = jwt.verify(refreshToken, JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
if (decoded.type !== 'refresh') {
return res.status(401).json({ error: 'Invalid token type' });
}
// Check if token exists in database
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const storedToken = db.getRefreshToken(tokenHash);
if (!storedToken) {
return res.status(401).json({ error: 'Refresh token not found or expired' });
}
// Get user
const user = db.getUserById(decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
// Generate new tokens
const tokens = generateTokens(user);
// Delete old refresh token and store new one
db.deleteRefreshToken(tokenHash);
const newTokenHash = crypto.createHash('sha256').update(tokens.refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + parseExpiry(JWT_REFRESH_EXPIRY)).toISOString();
await db.storeRefreshToken(user.id, newTokenHash, expiresAt);
res.json(tokens);
} catch (err) {
console.error('Token refresh error:', err);
res.status(500).json({ error: 'Token refresh failed' });
}
});
// Logout
app.post('/api/logout', authenticateToken, async (req, res) => {
try {
const { refreshToken } = req.body;
if (refreshToken) {
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
db.deleteRefreshToken(tokenHash);
}
res.json({ success: true });
} catch (err) {
console.error('Logout error:', err);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current user
app.get('/api/user/me', authenticateToken, (req, res) => {
try {
const user = db.getUserById(req.user.userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
} catch (err) {
console.error('Get user error:', err);
res.status(500).json({ error: 'Failed to get user' });
}
});
// Update user avatar
app.put('/api/user/avatar', authenticateToken, (req, res) => {
try {
const { icon, color } = req.body;
if (!icon || !color) {
return res.status(400).json({ error: 'Icon and color are required' });
}
db.updateUserAvatar(req.user.userId, icon, color);
res.json({ success: true });
} catch (err) {
console.error('Update avatar error:', err);
res.status(500).json({ error: 'Failed to update avatar' });
}
});
// ============================================
// Game Mechanics Endpoints
// ============================================
// Points configuration
const POINTS = {
BASE_FIND: 100,
FIRST_FINDER_BONUS: 50,
CLOSE_FIND_BONUS: 25, // < 2m accuracy
MESSAGE_BONUS: 10
};
// Calculate distance between two points (Haversine formula)
function calculateDistance(lat1, lng1, lat2, lng2) {
const R = 6371e3; // Earth radius in meters
const phi1 = lat1 * Math.PI / 180;
const phi2 = lat2 * Math.PI / 180;
const deltaPhi = (lat2 - lat1) * Math.PI / 180;
const deltaLambda = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Find (claim) a geocache
app.post('/api/geocaches/:id/find', authenticateToken, (req, res) => {
try {
const geocacheId = req.params.id;
const { lat, lng, accuracy } = req.body;
// Find the geocache
const geocache = geocaches.find(g => g.id === geocacheId);
if (!geocache) {
return res.status(404).json({ error: 'Geocache not found' });
}
// Check if user already found it
if (db.hasUserFoundGeocache(req.user.userId, geocacheId)) {
return res.status(409).json({ error: 'You have already found this geocache' });
}
// Validate GPS accuracy (reject if too inaccurate)
if (accuracy && accuracy > 50) {
return res.status(400).json({ error: 'GPS accuracy too low. Please wait for better signal.' });
}
// Validate distance (must be within 25m)
const distance = calculateDistance(lat, lng, geocache.lat, geocache.lng);
if (distance > 25) {
return res.status(400).json({
error: 'You are too far from the geocache',
distance: Math.round(distance)
});
}
// Calculate points
let points = POINTS.BASE_FIND;
const isFirstFinder = db.isFirstFinder(geocacheId);
if (isFirstFinder) {
points += POINTS.FIRST_FINDER_BONUS;
}
if (distance < 2) {
points += POINTS.CLOSE_FIND_BONUS;
}
// Record the find
const result = db.recordFind(req.user.userId, geocacheId, points, isFirstFinder);
// Get updated user
const user = db.getUserById(req.user.userId);
console.log(`User ${req.user.username} found geocache ${geocacheId} for ${points} points`);
res.json({
success: true,
points_earned: points,
is_first_finder: isFirstFinder,
total_points: user.total_points,
finds_count: user.finds_count
});
} catch (err) {
console.error('Find geocache error:', err);
if (err.message.includes('already found')) {
return res.status(409).json({ error: err.message });
}
res.status(500).json({ error: 'Failed to record find' });
}
});
// Get geocache finders
app.get('/api/geocaches/:id/finders', optionalAuth, (req, res) => {
try {
const geocacheId = req.params.id;
const finders = db.getGeocacheFinders(geocacheId);
res.json(finders);
} catch (err) {
console.error('Get finders error:', err);
res.status(500).json({ error: 'Failed to get finders' });
}
});
// Get leaderboard
app.get('/api/leaderboard', optionalAuth, (req, res) => {
try {
const period = req.query.period || 'all'; // 'all', 'weekly', 'monthly'
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
const leaderboard = db.getLeaderboard(period, limit);
res.json(leaderboard);
} catch (err) {
console.error('Get leaderboard error:', err);
res.status(500).json({ error: 'Failed to get leaderboard' });
}
});
// Get user's find history
app.get('/api/user/finds', authenticateToken, (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
const finds = db.getUserFinds(req.user.userId, limit);
res.json(finds);
} catch (err) {
console.error('Get finds error:', err);
res.status(500).json({ error: 'Failed to get finds' });
}
});
// Get RPG stats for current user
app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
try {
const stats = db.getRpgStats(req.user.userId);
console.log('GET /api/user/rpg-stats for user', req.user.userId, '- atk:', stats ? stats.atk : 'NO STATS');
if (stats) {
// Parse unlocked_skills from JSON string
let unlockedSkills = ['basic_attack'];
if (stats.unlocked_skills) {
try {
unlockedSkills = JSON.parse(stats.unlocked_skills);
} catch (e) {
console.error('Failed to parse unlocked_skills:', e);
}
}
// Parse active_skills from JSON string (default to unlockedSkills for migration)
let activeSkills = unlockedSkills; // Default: use unlocked skills for existing users
if (stats.active_skills) {
try {
activeSkills = JSON.parse(stats.active_skills);
} catch (e) {
console.error('Failed to parse active_skills:', e);
}
}
// Convert snake_case from DB to camelCase for client
res.json({
name: stats.character_name,
race: stats.race,
class: stats.class,
level: stats.level,
xp: stats.xp,
hp: stats.hp,
maxHp: stats.max_hp,
mp: stats.mp,
maxMp: stats.max_mp,
atk: stats.atk,
def: stats.def,
accuracy: stats.accuracy || 90,
dodge: stats.dodge || 10,
unlockedSkills: unlockedSkills,
activeSkills: activeSkills,
homeBaseLat: stats.home_base_lat,
homeBaseLng: stats.home_base_lng,
lastHomeSet: stats.last_home_set,
isDead: !!stats.is_dead,
homeBaseIcon: stats.home_base_icon || '00',
revealRadius: stats.reveal_radius || 800,
wanderRange: stats.wander_range || 200,
dataVersion: stats.data_version || 1
});
} else {
// No stats yet - return null so client creates defaults
res.json(null);
}
} catch (err) {
console.error('Get RPG stats error:', err);
res.status(500).json({ error: 'Failed to get RPG stats' });
}
});
// Check if user has a character
app.get('/api/user/has-character', authenticateToken, (req, res) => {
try {
const hasCharacter = db.hasCharacter(req.user.userId);
res.json({ hasCharacter });
} catch (err) {
console.error('Check character error:', err);
res.status(500).json({ error: 'Failed to check character' });
}
});
// Create a new character
app.post('/api/user/character', authenticateToken, (req, res) => {
try {
const characterData = req.body;
// Validate required fields
if (!characterData.name || characterData.name.length < 2 || characterData.name.length > 20) {
return res.status(400).json({ error: 'Character name must be 2-20 characters' });
}
if (!characterData.race) {
return res.status(400).json({ error: 'Race is required' });
}
if (!characterData.class) {
return res.status(400).json({ error: 'Class is required' });
}
// Only trail_runner is available for now
if (characterData.class !== 'trail_runner') {
return res.status(400).json({ error: 'This class is not available yet' });
}
db.createCharacter(req.user.userId, characterData);
// Clear any existing monsters when creating a new character
// This ensures new characters start fresh without monsters from previous playthroughs
db.clearMonsterEntourage(req.user.userId);
res.json({ success: true });
} catch (err) {
console.error('Create character error:', err);
res.status(500).json({ error: 'Failed to create character' });
}
});
// Save RPG stats for current user
app.put('/api/user/rpg-stats', authenticateToken, (req, res) => {
try {
const stats = req.body;
// Validate stats
if (!stats || typeof stats !== 'object') {
return res.status(400).json({ error: 'Invalid stats data' });
}
// Pass client's data version for checking
const clientVersion = stats.dataVersion || null;
const result = db.saveRpgStats(req.user.userId, stats, clientVersion);
if (result.success) {
res.json({ success: true, dataVersion: result.newVersion });
} else {
// Stale data - client needs to reload
console.log(`[STALE DATA] User ${req.user.userId} tried to save version ${clientVersion}, server has ${result.currentVersion}`);
res.status(409).json({
error: 'Data conflict - your data is out of date',
reason: result.reason,
currentVersion: result.currentVersion
});
}
} catch (err) {
console.error('Save RPG stats error:', err);
res.status(500).json({ error: 'Failed to save RPG stats' });
}
});
// Beacon endpoint for saving stats on page close (no response needed)
app.post('/api/user/rpg-stats-beacon', (req, res) => {
try {
const { token, stats } = req.body;
if (!token || !stats) {
return res.status(400).end();
}
// Verify token manually
let decoded;
try {
decoded = jwt.verify(token, JWT_SECRET);
} catch (err) {
return res.status(401).end();
}
// Use version checking to prevent stale data overwrites
const clientVersion = stats.dataVersion || null;
const result = db.saveRpgStats(decoded.userId, stats, clientVersion);
if (!result.success) {
console.log(`[BEACON STALE] User ${decoded.userId} beacon rejected: version ${clientVersion} < ${result.currentVersion}`);
}
res.status(200).end();
} catch (err) {
console.error('Beacon save error:', err);
res.status(500).end();
}
});
// Swap active skill (for skill loadout at home base)
app.post('/api/user/swap-skill', authenticateToken, (req, res) => {
try {
const { tier, newSkillId, currentActiveSkills, unlockedSkills } = req.body;
// Validate inputs
if (tier === undefined || !newSkillId) {
return res.status(400).json({ error: 'Tier and skill ID are required' });
}
// Validate skill is unlocked
if (!unlockedSkills || !unlockedSkills.includes(newSkillId)) {
return res.status(400).json({ error: 'Skill is not unlocked' });
}
// Build new active skills array
// Remove any existing skill from the same tier, add new skill
let newActiveSkills = currentActiveSkills ? [...currentActiveSkills] : ['basic_attack'];
// Filter out the old skill from this tier (client sends the old skill ID via tier mapping)
// Since we don't have skill tier info on server, trust client's currentActiveSkills
// and just ensure the new skill replaces the old one from same tier
// Add the new skill if not already present
if (!newActiveSkills.includes(newSkillId)) {
newActiveSkills.push(newSkillId);
}
// Save to database
const stats = db.getRpgStats(req.user.userId);
if (!stats) {
return res.status(404).json({ error: 'Character not found' });
}
// Parse existing data
let existingUnlocked = ['basic_attack'];
if (stats.unlocked_skills) {
try {
existingUnlocked = JSON.parse(stats.unlocked_skills);
} catch (e) {}
}
db.saveRpgStats(req.user.userId, {
...stats,
name: stats.character_name,
maxHp: stats.max_hp,
maxMp: stats.max_mp,
unlockedSkills: existingUnlocked,
activeSkills: newActiveSkills,
homeBaseLat: stats.home_base_lat,
homeBaseLng: stats.home_base_lng,
lastHomeSet: stats.last_home_set,
isDead: !!stats.is_dead
});
res.json({ success: true, activeSkills: newActiveSkills });
} catch (err) {
console.error('Swap skill error:', err);
res.status(500).json({ error: 'Failed to swap skill' });
}
});
// Check if user can set home base (once per day)
app.get('/api/user/can-set-home', authenticateToken, (req, res) => {
try {
const canSet = db.canSetHomeBase(req.user.userId);
res.json({ canSet });
} catch (err) {
console.error('Check home base error:', err);
res.status(500).json({ error: 'Failed to check home base availability' });
}
});
// Set home base location
app.post('/api/user/home-base', authenticateToken, (req, res) => {
try {
const { lat, lng } = req.body;
if (lat === undefined || lng === undefined) {
return res.status(400).json({ error: 'Latitude and longitude are required' });
}
// Check if user can set home base (once per day)
if (!db.canSetHomeBase(req.user.userId)) {
return res.status(400).json({ error: 'You can only set your home base once per day' });
}
db.setHomeBase(req.user.userId, lat, lng);
res.json({ success: true, homeBaseLat: lat, homeBaseLng: lng });
} catch (err) {
console.error('Set home base error:', err);
res.status(500).json({ error: 'Failed to set home base' });
}
});
// Get available homebase icons (auto-detected from mapgameimgs/bases directory)
app.get('/api/homebase-icons', (req, res) => {
try {
const fs = require('fs');
const basesDir = path.join(__dirname, 'mapgameimgs', 'bases');
// Read directory and find homebaseXX-100.png files
const files = fs.readdirSync(basesDir);
const iconPattern = /^homebase(\d+)-100\.png$/;
const icons = files
.map(file => {
const match = file.match(iconPattern);
if (match) {
return {
id: match[1],
filename: file,
preview: `/mapgameimgs/bases/${file}`, // Use 100px, CSS scales down
full: `/mapgameimgs/bases/${file}`
};
}
return null;
})
.filter(Boolean)
.sort((a, b) => a.id.localeCompare(b.id));
res.json(icons);
} catch (err) {
console.error('Get homebase icons error:', err);
res.status(500).json({ error: 'Failed to get homebase icons' });
}
});
// Get spawn settings (public - client needs these for spawn logic)
app.get('/api/spawn-settings', (req, res) => {
try {
const settings = {
spawnInterval: JSON.parse(db.getSetting('monsterSpawnInterval') || '20000'),
spawnChance: JSON.parse(db.getSetting('monsterSpawnChance') || '50'),
spawnDistance: JSON.parse(db.getSetting('monsterSpawnDistance') || '10'),
mpRegenDistance: JSON.parse(db.getSetting('mpRegenDistance') || '5'),
mpRegenAmount: JSON.parse(db.getSetting('mpRegenAmount') || '1'),
hpRegenInterval: JSON.parse(db.getSetting('hpRegenInterval') || '10000'),
hpRegenPercent: JSON.parse(db.getSetting('hpRegenPercent') || '1'),
homeHpMultiplier: JSON.parse(db.getSetting('homeHpMultiplier') || '3'),
homeRegenPercent: JSON.parse(db.getSetting('homeRegenPercent') || '5'),
homeBaseRadius: JSON.parse(db.getSetting('homeBaseRadius') || '20'),
inactivityTimeout: JSON.parse(db.getSetting('inactivityTimeout') || '600000'), // 10 minutes default
inactivityWarningTime: JSON.parse(db.getSetting('inactivityWarningTime') || '60000'), // 60 seconds default
combatIconScale: JSON.parse(db.getSetting('combatIconScale') || '0.7') // Scale for combat monster icons (0.3 - 1.5), default 70% for mobile
};
res.json(settings);
} catch (err) {
console.error('Get spawn settings error:', err);
res.status(500).json({ error: 'Failed to get spawn settings' });
}
});
// Update home base icon
app.put('/api/user/home-base/icon', authenticateToken, (req, res) => {
try {
const { iconId } = req.body;
if (!iconId) {
return res.status(400).json({ error: 'Icon ID is required' });
}
db.setHomeBaseIcon(req.user.userId, iconId);
res.json({ success: true, homeBaseIcon: iconId });
} catch (err) {
console.error('Set home base icon error:', err);
res.status(500).json({ error: 'Failed to set home base icon' });
}
});
// Get global map theme (public - no auth required)
app.get('/api/map-theme', (req, res) => {
try {
const themeJson = db.getSetting('mapTheme');
const theme = themeJson ? JSON.parse(themeJson) : null;
res.json({ theme });
} catch (err) {
console.error('Get map theme error:', err);
res.status(500).json({ error: 'Failed to get map theme' });
}
});
// Set global map theme (admin only)
app.put('/api/admin/map-theme', adminOnly, (req, res) => {
try {
const { theme } = req.body;
if (!theme) {
return res.status(400).json({ error: 'Theme data is required' });
}
db.setSetting('mapTheme', JSON.stringify(theme));
res.json({ success: true });
} catch (err) {
console.error('Set map theme error:', err);
res.status(500).json({ error: 'Failed to set map theme' });
}
});
// Handle player death
app.post('/api/user/death', authenticateToken, (req, res) => {
try {
const result = db.handlePlayerDeath(req.user.userId, 10); // 10% XP penalty
if (!result) {
return res.status(404).json({ error: 'Player stats not found' });
}
// Clear monster entourage
db.clearMonsterEntourage(req.user.userId);
res.json({
success: true,
xpLost: result.xpLost,
newXp: result.newXp
});
} catch (err) {
console.error('Handle death error:', err);
res.status(500).json({ error: 'Failed to handle death' });
}
});
// Respawn player at home base
app.post('/api/user/respawn', authenticateToken, (req, res) => {
try {
const stats = db.getRpgStats(req.user.userId);
if (!stats) {
return res.status(404).json({ error: 'Player stats not found' });
}
if (!stats.is_dead) {
return res.status(400).json({ error: 'Player is not dead' });
}
db.respawnPlayer(req.user.userId);
res.json({
success: true,
hp: stats.max_hp,
mp: stats.max_mp
});
} catch (err) {
console.error('Respawn error:', err);
res.status(500).json({ error: 'Failed to respawn' });
}
});
// ============================================
// Player Buff Endpoints
// ============================================
// Get all buffs for current user (with status info)
app.get('/api/user/buffs', authenticateToken, (req, res) => {
try {
const buffs = db.getPlayerBuffs(req.user.userId);
const now = Math.floor(Date.now() / 1000);
// Format buffs with status info
const formatted = buffs.map(b => {
const cooldownEnds = b.activated_at + (b.cooldown_hours * 3600);
return {
buffType: b.buff_type,
effectType: b.effect_type,
effectValue: b.effect_value,
activatedAt: b.activated_at,
expiresAt: b.expires_at,
cooldownHours: b.cooldown_hours,
isActive: b.expires_at > now,
isOnCooldown: cooldownEnds > now,
expiresIn: Math.max(0, b.expires_at - now),
cooldownEndsIn: Math.max(0, cooldownEnds - now)
};
});
res.json(formatted);
} catch (err) {
console.error('Get buffs error:', err);
res.status(500).json({ error: 'Failed to get buffs' });
}
});
// Get specific buff status (for checking before activation)
app.get('/api/user/buffs/:buffType', authenticateToken, (req, res) => {
try {
const buff = db.getBuffWithCooldown(req.user.userId, req.params.buffType);
if (!buff) {
// Never used - can activate
res.json({
buffType: req.params.buffType,
canActivate: true,
isActive: false,
isOnCooldown: false
});
} else {
res.json({
buffType: buff.buff_type,
effectType: buff.effect_type,
effectValue: buff.effect_value,
canActivate: !buff.isOnCooldown,
isActive: buff.isActive,
isOnCooldown: buff.isOnCooldown,
expiresIn: buff.expiresIn,
cooldownEndsIn: buff.cooldownEndsIn
});
}
} catch (err) {
console.error('Get buff status error:', err);
res.status(500).json({ error: 'Failed to get buff status' });
}
});
// Activate a buff (utility skill)
app.post('/api/user/buffs/activate', authenticateToken, (req, res) => {
try {
const { buffType } = req.body;
if (!buffType) {
return res.status(400).json({ error: 'Buff type is required' });
}
// Get buff configuration from database (utility skill's status_effect JSON)
const config = db.getUtilitySkillConfig(buffType);
if (!config) {
return res.status(400).json({ error: 'Unknown buff type or not a utility skill' });
}
// Check if buff can be activated (not on cooldown)
if (!db.canActivateBuff(req.user.userId, buffType)) {
const buff = db.getBuffWithCooldown(req.user.userId, buffType);
return res.status(400).json({
error: 'Buff is on cooldown',
cooldownEndsIn: buff.cooldownEndsIn
});
}
// Activate the buff using config from database
db.activateBuff(
req.user.userId,
buffType,
config.effectType,
config.effectValue,
config.durationHours,
config.cooldownHours
);
const buff = db.getBuffWithCooldown(req.user.userId, buffType);
console.log(`User ${req.user.username} activated ${buffType} buff`);
res.json({
success: true,
buffType: buffType,
effectType: config.effectType,
effectValue: config.effectValue,
expiresIn: buff.expiresIn,
cooldownEndsIn: buff.cooldownEndsIn
});
} catch (err) {
console.error('Activate buff error:', err);
res.status(500).json({ error: 'Failed to activate buff' });
}
});
// Get MP regen multiplier for current user (used by client for walking regen)
app.get('/api/user/mp-regen-multiplier', authenticateToken, (req, res) => {
try {
const multiplier = db.getBuffMultiplier(req.user.userId, 'mp_regen_multiplier');
res.json({ multiplier });
} catch (err) {
console.error('Get MP regen multiplier error:', err);
res.status(500).json({ error: 'Failed to get multiplier' });
}
});
// Get all monster types (public endpoint - needed for game rendering)
app.get('/api/monster-types', (req, res) => {
try {
const types = db.getAllMonsterTypes(true); // Only enabled monsters
// Convert snake_case to camelCase and parse JSON dialogues
const formatted = types.map(t => ({
id: t.id,
name: t.name,
icon: t.icon,
baseHp: t.base_hp,
baseAtk: t.base_atk,
baseDef: t.base_def,
baseMp: t.base_mp || 20,
xpReward: t.xp_reward,
accuracy: t.accuracy || 85,
dodge: t.dodge || 5,
minLevel: t.min_level || 1,
maxLevel: t.max_level || 99,
spawnWeight: t.spawn_weight || 100,
levelScale: {
hp: t.level_scale_hp,
atk: t.level_scale_atk,
def: t.level_scale_def,
mp: t.level_scale_mp || 5
},
dialogues: JSON.parse(t.dialogues),
// Animation overrides
attackAnimation: t.attack_animation || 'attack',
deathAnimation: t.death_animation || 'death',
idleAnimation: t.idle_animation || 'idle',
missAnimation: t.miss_animation || 'miss',
// Spawn location restriction
spawnLocation: t.spawn_location || 'anywhere'
}));
res.json(formatted);
} catch (err) {
console.error('Get monster types error:', err);
res.status(500).json({ error: 'Failed to get monster types' });
}
});
// ============================================
// Skills Endpoints (Public - needed for combat)
// ============================================
// Get all skills (public endpoint)
app.get('/api/skills', (req, res) => {
try {
const skills = db.getAllSkills(true); // Only enabled skills
// Convert snake_case to camelCase and parse JSON
const formatted = skills.map(s => ({
id: s.id,
name: s.name,
description: s.description,
type: s.type,
mpCost: s.mp_cost,
basePower: s.base_power,
accuracy: s.accuracy,
hitCount: s.hit_count,
target: s.target,
targeting_mode: s.targeting_mode || 'same_target',
statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null,
playerUsable: !!s.player_usable,
monsterUsable: !!s.monster_usable,
icon: s.icon || null
}));
res.json(formatted);
} catch (err) {
console.error('Get skills error:', err);
res.status(500).json({ error: 'Failed to get skills' });
}
});
// Get all class skill names and icons (public endpoint)
// Now reads from class_skills table (what admin panel edits) instead of legacy class_skill_names table
app.get('/api/class-skill-names', (req, res) => {
try {
const names = db.getAllClassSkillNamesFromClassSkills();
// Convert snake_case to camelCase
const formatted = names.map(n => ({
id: n.id,
skillId: n.skill_id,
classId: n.class_id,
customName: n.custom_name,
customDescription: n.custom_description,
customIcon: n.custom_icon || null
}));
res.json(formatted);
} catch (err) {
console.error('Get class skill names error:', err);
res.status(500).json({ error: 'Failed to get class skill names' });
}
});
// Get skills for a specific monster type (public endpoint)
app.get('/api/monster-types/:id/skills', (req, res) => {
try {
const skills = db.getMonsterTypeSkills(req.params.id);
// Convert snake_case to camelCase and parse JSON
const formatted = skills.map(s => ({
id: s.id,
skillId: s.skill_id,
monsterTypeId: s.monster_type_id,
weight: s.weight,
minLevel: s.min_level,
customName: s.custom_name,
customIcon: s.custom_icon || null,
animation: s.animation || null, // Skill animation override
// Include skill details - use custom_name if set, otherwise base name
name: s.custom_name || s.name,
baseName: s.name,
description: s.description,
type: s.type,
mpCost: s.mp_cost,
basePower: s.base_power,
accuracy: s.accuracy,
hitCount: s.hit_count,
target: s.target,
statusEffect: s.status_effect ? JSON.parse(s.status_effect) : null
}));
res.json(formatted);
} catch (err) {
console.error('Get monster skills error:', err);
res.status(500).json({ error: 'Failed to get monster skills' });
}
});
// Get monster entourage for current user
app.get('/api/user/monsters', authenticateToken, (req, res) => {
try {
// Prevent caching - user-specific data must always be fresh
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
console.log(`Loading monsters for user ${req.user.userId} (${req.user.username})`);
const monsters = db.getMonsterEntourage(req.user.userId);
console.log(`Found ${monsters.length} monsters for user ${req.user.userId}`);
// Convert snake_case to camelCase for client
const formatted = monsters.map(m => ({
id: m.id,
type: m.monster_type,
level: m.level,
hp: m.hp,
maxHp: m.max_hp,
atk: m.atk,
def: m.def,
position: m.position_lat && m.position_lng ? {
lat: m.position_lat,
lng: m.position_lng
} : null,
spawnTime: m.spawn_time,
lastDialogueTime: m.last_dialogue_time
}));
res.json(formatted);
} catch (err) {
console.error('Get monsters error:', err);
res.status(500).json({ error: 'Failed to get monsters' });
}
});
// Save monster entourage for current user
app.put('/api/user/monsters', authenticateToken, (req, res) => {
try {
const monsters = req.body;
if (!Array.isArray(monsters)) {
return res.status(400).json({ error: 'Invalid monsters data' });
}
db.saveMonsterEntourage(req.user.userId, monsters);
res.json({ success: true });
} catch (err) {
console.error('Save monsters error:', err);
res.status(500).json({ error: 'Failed to save monsters' });
}
});
// Remove a specific monster (after combat)
app.delete('/api/user/monsters/:monsterId', authenticateToken, (req, res) => {
try {
db.removeMonster(req.user.userId, req.params.monsterId);
res.json({ success: true });
} catch (err) {
console.error('Remove monster error:', err);
res.status(500).json({ error: 'Failed to remove monster' });
}
});
// ============================================
// Admin Endpoints
// ============================================
// Serve admin page
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'admin.html'));
});
// Get all monster types (admin - includes disabled)
app.get('/api/admin/monster-types', adminOnly, (req, res) => {
try {
const types = db.getAllMonsterTypes(false); // Include disabled
// Return with snake_case for frontend compatibility
const formatted = types.map(t => ({
id: t.id,
key: t.id, // Use id as key for compatibility
name: t.name,
icon: t.icon,
min_level: t.min_level || 1,
max_level: t.max_level || 5,
base_hp: t.base_hp,
base_atk: t.base_atk,
base_def: t.base_def,
base_mp: t.base_mp || 20,
base_xp: t.xp_reward,
spawn_weight: t.spawn_weight || 100,
level_scale_mp: t.level_scale_mp || 5,
dialogues: t.dialogues,
enabled: !!t.enabled,
created_at: t.created_at,
// Animation overrides
attack_animation: t.attack_animation || 'attack',
death_animation: t.death_animation || 'death',
idle_animation: t.idle_animation || 'idle',
// Spawn location restriction
spawn_location: t.spawn_location || 'anywhere'
}));
res.json({ monsterTypes: formatted });
} catch (err) {
console.error('Admin get monster types error:', err);
res.status(500).json({ error: 'Failed to get monster types' });
}
});
// Create monster type
app.post('/api/admin/monster-types', adminOnly, async (req, res) => {
try {
const data = req.body;
// Accept either 'id' or 'key' as the monster identifier
const monsterId = data.id || data.key;
if (!monsterId || !data.name) {
return res.status(400).json({ error: 'Missing required fields (key and name)' });
}
// Ensure id is set for the database function
data.id = monsterId;
db.createMonsterType(data);
// Copy default images for the new monster
const monstersDir = path.join(__dirname, 'mapgameimgs', 'monsters');
const sizes = ['50', '100'];
for (const size of sizes) {
const defaultImg = path.join(monstersDir, `default${size}.png`);
const newImg = path.join(monstersDir, `${monsterId}${size}.png`);
try {
// Only copy if the new image doesn't already exist
await fs.access(newImg);
} catch {
// File doesn't exist, copy the default
try {
await fs.copyFile(defaultImg, newImg);
console.log(`Created ${monsterId}${size}.png from default`);
} catch (copyErr) {
console.warn(`Could not copy default${size}.png:`, copyErr.message);
}
}
}
broadcastAdminChange('monster', { action: 'created' });
res.json({ success: true });
} catch (err) {
console.error('Admin create monster type error:', err);
res.status(500).json({ error: 'Failed to create monster type' });
}
});
// Update monster type
app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try {
const data = req.body;
db.updateMonsterType(req.params.id, data);
broadcastAdminChange('monster', { action: 'updated', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Admin update monster type error:', err);
res.status(500).json({ error: 'Failed to update monster type' });
}
});
// Toggle monster enabled status
app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => {
try {
const { enabled } = req.body;
db.toggleMonsterEnabled(req.params.id, enabled);
broadcastAdminChange('monster', { action: 'toggled', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Admin toggle monster error:', err);
res.status(500).json({ error: 'Failed to toggle monster' });
}
});
// Delete monster type
app.delete('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try {
db.deleteMonsterType(req.params.id);
broadcastAdminChange('monster', { action: 'deleted', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Admin delete monster type error:', err);
res.status(500).json({ error: 'Failed to delete monster type' });
}
});
// Get all users
app.get('/api/admin/users', adminOnly, (req, res) => {
try {
const users = db.getAllUsers();
// Return flat structure with snake_case for frontend compatibility
const formatted = users.map(u => ({
id: u.id,
username: u.username,
email: u.email,
created_at: u.created_at,
total_points: u.total_points,
finds_count: u.finds_count,
avatar_icon: u.avatar_icon,
avatar_color: u.avatar_color,
is_admin: !!u.is_admin,
character_name: u.character_name,
race: u.race,
class: u.class,
level: u.level || 1,
xp: u.xp || 0,
hp: u.hp || 0,
max_hp: u.max_hp || 0,
mp: u.mp || 0,
max_mp: u.max_mp || 0,
atk: u.atk || 0,
def: u.def || 0,
unlocked_skills: u.unlocked_skills
}));
// Debug: log user 2's atk value
const user2 = formatted.find(u => u.id === 2);
if (user2) console.log('GET /api/admin/users - user 2 atk:', user2.atk);
res.json({ users: formatted });
} catch (err) {
console.error('Admin get users error:', err);
res.status(500).json({ error: 'Failed to get users' });
}
});
// Update user RPG stats
app.put('/api/admin/users/:id', adminOnly, (req, res) => {
try {
const stats = req.body;
const targetUserId = parseInt(req.params.id);
console.log('Admin updating user', targetUserId, 'with stats:', JSON.stringify(stats));
const result = db.updateUserRpgStats(targetUserId, stats);
console.log('Update result:', result);
// Notify the user in real-time to refresh their stats
const notified = sendToAuthUser(targetUserId, { type: 'statsUpdated' });
console.log('User notified via WebSocket:', notified);
res.json({ success: true });
} catch (err) {
console.error('Admin update user error:', err);
res.status(500).json({ error: 'Failed to update user' });
}
});
// Toggle admin status
app.put('/api/admin/users/:id/admin', adminOnly, (req, res) => {
try {
const { isAdmin } = req.body;
db.setUserAdmin(req.params.id, isAdmin);
res.json({ success: true });
} catch (err) {
console.error('Admin toggle admin error:', err);
res.status(500).json({ error: 'Failed to toggle admin status' });
}
});
// Reset user progress
app.delete('/api/admin/users/:id/reset', adminOnly, (req, res) => {
try {
db.resetUserProgress(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin reset user error:', err);
res.status(500).json({ error: 'Failed to reset user progress' });
}
});
// Reset user home base
app.delete('/api/admin/users/:id/home-base', adminOnly, (req, res) => {
try {
const targetUserId = parseInt(req.params.id);
db.resetUserHomeBase(targetUserId);
// Notify the user in real-time to refresh their stats
sendToAuthUser(targetUserId, { type: 'statsUpdated' });
res.json({ success: true });
} catch (err) {
console.error('Admin reset home base error:', err);
res.status(500).json({ error: 'Failed to reset home base' });
}
});
// Delete user completely
app.delete('/api/admin/users/:id', adminOnly, (req, res) => {
try {
const targetUserId = parseInt(req.params.id);
// Prevent deleting yourself
if (targetUserId === req.user.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
// Force logout the user if they're connected
sendToAuthUser(targetUserId, { type: 'force_logout', reason: 'Account deleted by admin' });
// Delete the user and all their data
db.deleteUser(targetUserId);
console.log(`Admin ${req.user.username} deleted user ${targetUserId}`);
res.json({ success: true });
} catch (err) {
console.error('Admin delete user error:', err);
res.status(500).json({ error: 'Failed to delete user' });
}
});
// Get game settings
app.get('/api/admin/settings', adminOnly, (req, res) => {
try {
const settings = db.getAllSettings();
res.json({ settings });
} catch (err) {
console.error('Admin get settings error:', err);
res.status(500).json({ error: 'Failed to get settings' });
}
});
// Update game settings
app.put('/api/admin/settings', adminOnly, (req, res) => {
console.log('[SETTINGS] Admin settings update received');
try {
const settings = req.body;
console.log('[SETTINGS] Settings to save:', Object.keys(settings));
for (const [key, value] of Object.entries(settings)) {
db.setSetting(key, JSON.stringify(value));
}
// Broadcast settings update to all connected clients
console.log('[SETTINGS] Broadcasting settings update to all clients');
const clientCount = [...wss.clients].filter(c => c.readyState === 1).length;
console.log(`[SETTINGS] Active WebSocket clients: ${clientCount}`);
broadcast({
type: 'settings_updated',
settings: settings
}, null); // null = send to ALL clients including sender
res.json({ success: true });
} catch (err) {
console.error('Admin update settings error:', err);
res.status(500).json({ error: 'Failed to update settings' });
}
});
// ============================================
// Admin Skills Endpoints
// ============================================
// Get all skills (admin - includes disabled)
app.get('/api/admin/skills', adminOnly, (req, res) => {
try {
const skills = db.getAllSkills(false); // Include disabled
const formatted = skills.map(s => ({
id: s.id,
name: s.name,
description: s.description,
type: s.type,
mp_cost: s.mp_cost,
base_power: s.base_power,
accuracy: s.accuracy,
hit_count: s.hit_count,
target: s.target,
targeting_mode: s.targeting_mode || 'same_target',
status_effect: s.status_effect,
player_usable: !!s.player_usable,
monster_usable: !!s.monster_usable,
enabled: !!s.enabled,
icon: s.icon || null,
created_at: s.created_at
}));
res.json({ skills: formatted });
} catch (err) {
console.error('Admin get skills error:', err);
res.status(500).json({ error: 'Failed to get skills' });
}
});
// Create skill
app.post('/api/admin/skills', adminOnly, (req, res) => {
try {
const data = req.body;
if (!data.id || !data.name) {
return res.status(400).json({ error: 'Missing required fields (id and name)' });
}
db.createSkill(data);
broadcastAdminChange('skill', { action: 'created' });
res.json({ success: true });
} catch (err) {
console.error('Admin create skill error:', err);
res.status(500).json({ error: 'Failed to create skill' });
}
});
// Update skill
app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
try {
const data = req.body;
db.updateSkill(req.params.id, data);
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Admin update skill error:', err);
res.status(500).json({ error: 'Failed to update skill' });
}
});
// Delete skill
app.delete('/api/admin/skills/:id', adminOnly, (req, res) => {
try {
db.deleteSkill(req.params.id);
broadcastAdminChange('skill', { action: 'deleted', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Admin delete skill error:', err);
res.status(500).json({ error: 'Failed to delete skill' });
}
});
// Upload base skill icon
app.post('/api/admin/skills/:id/icon', adminOnly, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateSkillIcon(req.params.id, req.file.filename);
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Delete skill icon
app.delete('/api/admin/skills/:id/icon', adminOnly, async (req, res) => {
try {
const skill = db.getSkill(req.params.id);
if (skill && skill.icon) {
const iconPath = path.join(skillsIconDir, skill.icon);
await fs.unlink(iconPath).catch(() => {});
db.updateSkillIcon(req.params.id, null);
}
broadcastAdminChange('skill', { action: 'updated', id: req.params.id });
res.json({ success: true });
} catch (err) {
console.error('Delete skill icon error:', err);
res.status(500).json({ error: 'Failed to delete icon' });
}
});
// Upload class-specific skill icon override
app.post('/api/admin/class-skills/:classId/:skillId/icon', adminOnly, (req, res, next) => {
// Override filename for class-specific icon
req.params.id = `${req.params.classId}_${req.params.skillId}`;
next();
}, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateClassSkillIcon(req.params.classId, req.params.skillId, req.file.filename);
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload class skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Upload monster-specific skill icon override
app.post('/api/admin/monster-skills/:monsterTypeId/:skillId/icon', adminOnly, (req, res, next) => {
// Override filename for monster-specific icon
req.params.id = `${req.params.monsterTypeId}_${req.params.skillId}`;
next();
}, skillIconUpload.single('icon'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
db.updateMonsterSkillIcon(req.params.monsterTypeId, req.params.skillId, req.file.filename);
res.json({ success: true, icon: req.file.filename });
} catch (err) {
console.error('Upload monster skill icon error:', err);
res.status(500).json({ error: 'Failed to upload icon' });
}
});
// Get all class skill names (admin)
app.get('/api/admin/class-skill-names', adminOnly, (req, res) => {
try {
const names = db.getAllClassSkillNames();
const formatted = names.map(n => ({
id: n.id,
skill_id: n.skill_id,
class_id: n.class_id,
custom_name: n.custom_name,
custom_description: n.custom_description
}));
res.json({ classSkillNames: formatted });
} catch (err) {
console.error('Admin get class skill names error:', err);
res.status(500).json({ error: 'Failed to get class skill names' });
}
});
// Create class skill name
app.post('/api/admin/class-skill-names', adminOnly, (req, res) => {
try {
const data = req.body;
if (!data.skill_id || !data.class_id || !data.custom_name) {
return res.status(400).json({ error: 'Missing required fields' });
}
db.createClassSkillName(data);
res.json({ success: true });
} catch (err) {
console.error('Admin create class skill name error:', err);
res.status(500).json({ error: 'Failed to create class skill name' });
}
});
// Update class skill name
app.put('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
try {
const data = req.body;
db.updateClassSkillName(req.params.id, data);
res.json({ success: true });
} catch (err) {
console.error('Admin update class skill name error:', err);
res.status(500).json({ error: 'Failed to update class skill name' });
}
});
// Delete class skill name
app.delete('/api/admin/class-skill-names/:id', adminOnly, (req, res) => {
try {
db.deleteClassSkillName(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete class skill name error:', err);
res.status(500).json({ error: 'Failed to delete class skill name' });
}
});
// Get all monster skills (admin)
app.get('/api/admin/monster-skills', adminOnly, (req, res) => {
try {
const skills = db.getAllMonsterSkills();
const formatted = skills.map(s => ({
id: s.id,
monster_type_id: s.monster_type_id,
skill_id: s.skill_id,
weight: s.weight,
min_level: s.min_level,
custom_name: s.custom_name,
custom_icon: s.custom_icon || null,
animation: s.animation || null
}));
res.json({ monsterSkills: formatted });
} catch (err) {
console.error('Admin get monster skills error:', err);
res.status(500).json({ error: 'Failed to get monster skills' });
}
});
// Create monster skill assignment
app.post('/api/admin/monster-skills', adminOnly, (req, res) => {
try {
const data = req.body;
if (!data.monster_type_id || !data.skill_id) {
return res.status(400).json({ error: 'Missing required fields' });
}
db.createMonsterSkill(data);
res.json({ success: true });
} catch (err) {
console.error('Admin create monster skill error:', err);
res.status(500).json({ error: 'Failed to create monster skill' });
}
});
// Update monster skill assignment
app.put('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
try {
const data = req.body;
db.updateMonsterSkill(req.params.id, data);
res.json({ success: true });
} catch (err) {
console.error('Admin update monster skill error:', err);
res.status(500).json({ error: 'Failed to update monster skill' });
}
});
// Delete monster skill assignment
app.delete('/api/admin/monster-skills/:id', adminOnly, (req, res) => {
try {
db.deleteMonsterSkill(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete monster skill error:', err);
res.status(500).json({ error: 'Failed to delete monster skill' });
}
});
// =====================
// CLASS API ENDPOINTS
// =====================
// Get all classes (public - enabled only, for character creation)
app.get('/api/classes', (req, res) => {
try {
const classes = db.getAllClasses(true); // Enabled only
res.json(classes);
} catch (err) {
console.error('Get classes error:', err);
res.status(500).json({ error: 'Failed to get classes' });
}
});
// Get class skills (public - for character sheet display)
app.get('/api/classes/:id/skills', (req, res) => {
try {
const skills = db.getClassSkills(req.params.id);
res.json(skills);
} catch (err) {
console.error('Get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Get skill choices for a specific level (for level-up modal)
app.get('/api/classes/:id/skill-choices/:level', (req, res) => {
try {
const choices = db.getSkillChoicesForLevel(req.params.id, parseInt(req.params.level));
res.json(choices);
} catch (err) {
console.error('Get skill choices error:', err);
res.status(500).json({ error: 'Failed to get skill choices' });
}
});
// Admin: Get all classes (including disabled)
app.get('/api/admin/classes', adminOnly, (req, res) => {
try {
const classes = db.getAllClasses(false); // All classes
res.json(classes);
} catch (err) {
console.error('Admin get classes error:', err);
res.status(500).json({ error: 'Failed to get classes' });
}
});
// Admin: Get single class
app.get('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
const classData = db.getClass(req.params.id);
if (!classData) {
return res.status(404).json({ error: 'Class not found' });
}
res.json(classData);
} catch (err) {
console.error('Admin get class error:', err);
res.status(500).json({ error: 'Failed to get class' });
}
});
// Admin: Create class
app.post('/api/admin/classes', adminOnly, (req, res) => {
try {
db.createClass(req.body);
const newClass = db.getClass(req.body.id);
res.json(newClass);
} catch (err) {
console.error('Admin create class error:', err);
res.status(500).json({ error: 'Failed to create class' });
}
});
// Admin: Update class
app.put('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
db.updateClass(req.params.id, req.body);
const updated = db.getClass(req.params.id);
res.json(updated);
} catch (err) {
console.error('Admin update class error:', err);
res.status(500).json({ error: 'Failed to update class' });
}
});
// Admin: Delete class
app.delete('/api/admin/classes/:id', adminOnly, (req, res) => {
try {
db.deleteClass(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete class error:', err);
res.status(500).json({ error: 'Failed to delete class' });
}
});
// Admin: Toggle class enabled
app.put('/api/admin/classes/:id/toggle', adminOnly, (req, res) => {
try {
const classData = db.getClass(req.params.id);
if (!classData) {
return res.status(404).json({ error: 'Class not found' });
}
db.toggleClassEnabled(req.params.id, !classData.enabled);
res.json({ success: true, enabled: !classData.enabled });
} catch (err) {
console.error('Admin toggle class error:', err);
res.status(500).json({ error: 'Failed to toggle class' });
}
});
// =====================
// CLASS SKILLS API ENDPOINTS
// =====================
// Admin: Get all class skills
app.get('/api/admin/class-skills', adminOnly, (req, res) => {
try {
const classSkills = db.getAllClassSkills();
res.json(classSkills);
} catch (err) {
console.error('Admin get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Admin: Get skills for a specific class
app.get('/api/admin/class-skills/:classId', adminOnly, (req, res) => {
try {
const skills = db.getClassSkills(req.params.classId);
res.json(skills);
} catch (err) {
console.error('Admin get class skills error:', err);
res.status(500).json({ error: 'Failed to get class skills' });
}
});
// Admin: Create class skill
app.post('/api/admin/class-skills', adminOnly, (req, res) => {
try {
db.createClassSkill(req.body);
res.json({ success: true });
} catch (err) {
console.error('Admin create class skill error:', err);
res.status(500).json({ error: 'Failed to create class skill' });
}
});
// Admin: Update class skill
app.put('/api/admin/class-skills/:id', adminOnly, (req, res) => {
try {
db.updateClassSkill(req.params.id, req.body);
res.json({ success: true });
} catch (err) {
console.error('Admin update class skill error:', err);
res.status(500).json({ error: 'Failed to update class skill' });
}
});
// Admin: Delete class skill
app.delete('/api/admin/class-skills/:id', adminOnly, (req, res) => {
try {
db.deleteClassSkill(req.params.id);
res.json({ success: true });
} catch (err) {
console.error('Admin delete class skill error:', err);
res.status(500).json({ error: 'Failed to delete class skill' });
}
});
// =====================
// OSM TAGS ADMIN ENDPOINTS
// =====================
// Get all OSM tags (admin)
app.get('/api/admin/osm-tags', adminOnly, (req, res) => {
try {
const tags = db.getAllOsmTags(false);
res.json({ osmTags: tags });
} catch (err) {
console.error('Admin get OSM tags error:', err);
res.status(500).json({ error: 'Failed to fetch OSM tags' });
}
});
// Create OSM tag
app.post('/api/admin/osm-tags', adminOnly, (req, res) => {
try {
db.createOsmTag(req.body);
broadcastAdminChange('osm_tags');
res.json({ success: true });
} catch (err) {
console.error('Admin create OSM tag error:', err);
res.status(500).json({ error: 'Failed to create OSM tag' });
}
});
// Update OSM tag
app.put('/api/admin/osm-tags/:id', adminOnly, (req, res) => {
try {
db.updateOsmTag(req.params.id, req.body);
broadcastAdminChange('osm_tags');
res.json({ success: true });
} catch (err) {
console.error('Admin update OSM tag error:', err);
res.status(500).json({ error: 'Failed to update OSM tag' });
}
});
// Delete OSM tag
app.delete('/api/admin/osm-tags/:id', adminOnly, (req, res) => {
try {
db.deleteOsmTag(req.params.id);
broadcastAdminChange('osm_tags');
res.json({ success: true });
} catch (err) {
console.error('Admin delete OSM tag error:', err);
res.status(500).json({ error: 'Failed to delete OSM tag' });
}
});
// Get OSM tag settings
app.get('/api/admin/osm-tag-settings', adminOnly, (req, res) => {
try {
const settings = db.getAllOsmTagSettings();
res.json(settings);
} catch (err) {
console.error('Admin get OSM tag settings error:', err);
res.status(500).json({ error: 'Failed to fetch OSM tag settings' });
}
});
// Update OSM tag settings
app.put('/api/admin/osm-tag-settings', adminOnly, (req, res) => {
try {
for (const [key, value] of Object.entries(req.body)) {
db.setOsmTagSetting(key, value);
}
broadcastAdminChange('osm_tag_settings');
res.json({ success: true });
} catch (err) {
console.error('Admin update OSM tag settings error:', err);
res.status(500).json({ error: 'Failed to update OSM tag settings' });
}
});
// =====================
// PUBLIC OSM TAGS ENDPOINTS (for client)
// =====================
// Get enabled OSM tags (public)
app.get('/api/osm-tags', (req, res) => {
try {
const tags = db.getAllOsmTags(true);
res.json(tags);
} catch (err) {
console.error('Get OSM tags error:', err);
res.status(500).json({ error: 'Failed to fetch OSM tags' });
}
});
// Get OSM tag settings (public)
app.get('/api/osm-tag-settings', (req, res) => {
try {
const settings = db.getAllOsmTagSettings();
res.json(settings);
} catch (err) {
console.error('Get OSM tag settings error:', err);
res.status(500).json({ error: 'Failed to fetch OSM tag settings' });
}
});
// =====================
// KILL TRACKING ENDPOINTS
// =====================
// Record a monster kill
app.post('/api/user/monster-kill', authenticateToken, (req, res) => {
try {
const { monsterName } = req.body;
if (!monsterName) {
return res.status(400).json({ error: 'Monster name required' });
}
db.recordMonsterKill(req.user.id, monsterName);
res.json({ success: true });
} catch (err) {
console.error('Record monster kill error:', err);
res.status(500).json({ error: 'Failed to record kill' });
}
});
// Get user's kill stats
app.get('/api/user/kill-stats', authenticateToken, (req, res) => {
try {
const kills = db.getPlayerKillStats(req.user.id);
const total = db.getTotalPlayerKills(req.user.id);
res.json({ kills, total });
} catch (err) {
console.error('Get kill stats error:', err);
res.status(500).json({ error: 'Failed to fetch kill stats' });
}
});
// Get kill leaderboard
app.get('/api/leaderboard/kills', (req, res) => {
try {
const limit = parseInt(req.query.limit) || 50;
const topKillers = db.getTopKillers(limit);
res.json(topKillers);
} catch (err) {
console.error('Get kill leaderboard error:', err);
res.status(500).json({ error: 'Failed to fetch kill leaderboard' });
}
});
// =====================
// HOMEBASE DISCOVERY (Overpass API)
// =====================
// OSM tag to Overpass query mapping
const OSM_QUERY_MAP = {
'grocery': 'shop=supermarket',
'restaurant': 'amenity=restaurant',
'fastfood': 'amenity=fast_food',
'cafe': 'amenity=cafe',
'bar': 'amenity=bar',
'pharmacy': 'amenity=pharmacy',
'bank': 'amenity=bank',
'convenience': 'shop=convenience',
'park': 'leisure=park',
'gasstation': 'amenity=fuel'
};
// Discover nearby locations via Overpass API
app.post('/api/geocaches/discover-nearby', authenticateToken, async (req, res) => {
const { lat, lng, radiusMiles = 2 } = req.body;
if (!lat || !lng) {
return res.status(400).json({ error: 'Latitude and longitude required' });
}
const radiusMeters = radiusMiles * 1609.34;
try {
// Get enabled OSM tags
const enabledTags = db.getAllOsmTags(true);
// Load current geocaches
const geocachePath = await getGeocachePath();
let geocachesData;
try {
const data = await fs.readFile(geocachePath, 'utf8');
geocachesData = JSON.parse(data);
} catch (err) {
geocachesData = [];
}
let discovered = 0;
let added = 0;
const overpassUrl = 'https://overpass-api.de/api/interpreter';
for (const tag of enabledTags) {
const osmQuery = OSM_QUERY_MAP[tag.id];
if (!osmQuery) continue;
const query = `
[out:json][timeout:25];
(
node[${osmQuery}](around:${radiusMeters},${lat},${lng});
way[${osmQuery}](around:${radiusMeters},${lat},${lng});
);
out center;
`;
try {
const response = await fetch(overpassUrl, {
method: 'POST',
body: `data=${encodeURIComponent(query)}`,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (response.ok) {
const data = await response.json();
for (const element of data.elements) {
const elemLat = element.lat || element.center?.lat;
const elemLng = element.lon || element.center?.lon;
if (!elemLat || !elemLng) continue;
discovered++;
// Check if already exists (within 10m)
const existing = geocachesData.find(gc =>
Math.abs(gc.lat - elemLat) < 0.0001 &&
Math.abs(gc.lng - elemLng) < 0.0001
);
if (!existing) {
const newCache = {
id: `gc_osm_${element.id}`,
lat: elemLat,
lng: elemLng,
title: element.tags?.name || `${tag.id} location`,
icon: tag.icon || 'map-marker',
color: '#4CAF50',
tags: [tag.id],
messages: [],
createdAt: Date.now(),
autoDiscovered: true
};
geocachesData.push(newCache);
added++;
}
}
}
} catch (queryErr) {
console.error(`Overpass query failed for ${tag.id}:`, queryErr.message);
}
// Small delay between queries to be nice to Overpass API
await new Promise(resolve => setTimeout(resolve, 500));
}
// Save if we added any
if (added > 0) {
await fs.writeFile(geocachePath, JSON.stringify(geocachesData, null, 2));
// Update in-memory cache
geocaches = geocachesData;
console.log(`Discovery: Added ${added} new geocaches near (${lat}, ${lng})`);
}
res.json({ discovered, added });
} catch (err) {
console.error('Discovery error:', err);
res.status(500).json({ error: 'Failed to discover locations' });
}
});
// Function to send push notification to all subscribers
async function sendPushNotification(title, body, data = {}) {
const notification = {
title,
body,
icon: '/icon-192x192.png',
badge: '/icon-72x72.png',
data: {
...data,
timestamp: Date.now()
}
};
const promises = pushSubscriptions.map(subscription => {
return webpush.sendNotification(subscription, JSON.stringify(notification))
.catch(err => {
console.error('Push failed for:', subscription.endpoint, err.message);
// Remove failed subscriptions
if (err.statusCode === 410) {
pushSubscriptions = pushSubscriptions.filter(
sub => sub.endpoint !== subscription.endpoint
);
}
});
});
await Promise.all(promises);
console.log(`Sent push notification: ${title}`);
}
// Load push subscriptions on startup
async function loadPushSubscriptions() {
try {
const dataDir = await getGeocachePath();
const subsPath = path.join(path.dirname(dataDir), 'push-subscriptions.json');
const data = await fs.readFile(subsPath, 'utf8');
pushSubscriptions = JSON.parse(data);
console.log(`Loaded ${pushSubscriptions.length} push subscriptions`);
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('Error loading push subscriptions:', err);
}
}
}
// Generate random user ID
function generateUserId() {
return Math.random().toString(36).substring(7);
}
// Broadcast to all clients except sender
function broadcast(data, senderId) {
const message = JSON.stringify(data);
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN && client.userId !== senderId) {
client.send(message);
}
});
}
// Broadcast admin changes to all clients
function broadcastAdminChange(changeType, details = {}) {
let clientCount = 0;
wss.clients.forEach(c => { if (c.readyState === WebSocket.OPEN) clientCount++; });
console.log(`[ADMIN] Broadcasting ${changeType} change to ${clientCount} clients`);
broadcast({
type: 'admin_update',
changeType: changeType,
details: details,
timestamp: Date.now()
}, null);
}
// Map authenticated user IDs to WebSocket connections for targeted messages
const authUserConnections = new Map(); // authUserId (number) -> ws connection
// Send message to a specific authenticated user
function sendToAuthUser(authUserId, data) {
const ws = authUserConnections.get(authUserId);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
return true;
}
return false;
}
// Clean up disconnected user
function removeUser(userId) {
if (users.has(userId)) {
users.delete(userId);
broadcast({ type: 'userDisconnected', userId }, null);
console.log(`User ${userId} disconnected. Active users: ${users.size}`);
}
}
wss.on('connection', (ws) => {
const userId = generateUserId();
ws.userId = userId;
console.log(`User ${userId} connected. Active users: ${users.size + 1}`);
// Send user their ID, current visible users, and geocaches
const initMsg = {
type: 'init',
userId: userId,
serverSessionId: serverSessionId, // For detecting server restarts
users: Array.from(users.entries())
.filter(([id, data]) => data.visible !== false) // Only send visible users
.map(([id, data]) => ({
userId: id,
...data
}))
};
console.log(`Sending init message to ${userId}`);
ws.send(JSON.stringify(initMsg));
// Send all geocaches
if (geocaches.length > 0) {
console.log(`Sending ${geocaches.length} geocaches to ${userId}`);
ws.send(JSON.stringify({
type: 'geocachesInit',
geocaches: geocaches
}));
} else {
console.log('No geocaches to send');
}
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
// Handle client ping with pong response
if (data.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
return;
}
if (data.type === 'auth') {
// Check if client has a stale session (server restarted)
if (data.serverSessionId && data.serverSessionId !== serverSessionId) {
console.log(`[SESSION] User ${data.authUserId} has stale session ID, forcing logout`);
ws.send(JSON.stringify({
type: 'force_logout',
reason: 'Server restarted - please log in again'
}));
return;
}
// Register authenticated user's WebSocket connection
if (data.authUserId) {
// Check if this user already has an active connection (old tab)
const existingConnection = authUserConnections.get(data.authUserId);
console.log(`[SESSION] User ${data.authUserId} auth - existing connection:`, existingConnection ? 'yes' : 'no');
if (existingConnection && existingConnection !== ws) {
console.log(`[SESSION] Existing connection state: ${existingConnection.readyState} (OPEN=${WebSocket.OPEN})`);
if (existingConnection.readyState === WebSocket.OPEN) {
// Force logout the old tab
console.log(`[SESSION] Kicking old session for user ${data.authUserId}`);
try {
existingConnection.send(JSON.stringify({
type: 'force_logout',
reason: 'Another session has started'
}));
console.log(`[SESSION] Sent force_logout to old connection`);
} catch (e) {
console.error(`[SESSION] Failed to send force_logout:`, e);
}
// Close the old connection after a brief delay
setTimeout(() => {
if (existingConnection.readyState === WebSocket.OPEN) {
existingConnection.close(4000, 'Replaced by new session');
console.log(`[SESSION] Closed old connection`);
}
}, 1000);
}
}
ws.authUserId = data.authUserId;
authUserConnections.set(data.authUserId, ws);
console.log(`Auth user ${data.authUserId} registered on WebSocket ${userId}`);
}
} else if (data.type === 'location') {
// Store user location with icon info
users.set(userId, {
lat: data.lat,
lng: data.lng,
accuracy: data.accuracy,
icon: data.icon,
color: data.color,
visible: data.visible !== false, // default to true if not specified
timestamp: Date.now()
});
// Broadcast to other users (including visibility status)
broadcast({
type: 'userLocation',
userId: userId,
lat: data.lat,
lng: data.lng,
accuracy: data.accuracy,
icon: data.icon,
color: data.color,
visible: data.visible !== false
}, userId);
} else if (data.type === 'iconUpdate') {
// Update user's icon
const userData = users.get(userId) || {};
userData.icon = data.icon;
userData.color = data.color;
users.set(userId, userData);
// Broadcast icon update to other users if we have location and are visible
if (userData.lat && userData.lng && userData.visible !== false) {
broadcast({
type: 'userLocation',
userId: userId,
lat: userData.lat,
lng: userData.lng,
accuracy: userData.accuracy || 100,
icon: data.icon,
color: data.color,
visible: true
}, userId);
}
} else if (data.type === 'geocacheUpdate') {
// Handle geocache creation/update
if (data.geocache) {
console.log(`[GEOCACHE] Received update for cache ${data.geocache.id}, spawnRadius=${data.geocache.spawnRadius}, tags=${JSON.stringify(data.geocache.tags)}`);
const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id);
if (existingIndex >= 0) {
// Update existing geocache
console.log(`[GEOCACHE] Updating existing cache at index ${existingIndex}`);
geocaches[existingIndex] = data.geocache;
} else {
// Add new geocache
geocaches.push(data.geocache);
// Send push notification for new geocache
sendPushNotification(
'📍 New Geocache!',
'A new geocache has been placed nearby',
{
type: 'geocache',
geocacheId: data.geocache.id,
lat: data.geocache.lat,
lng: data.geocache.lng
}
);
}
// Save to file
saveGeocaches();
// Broadcast to all other users
broadcast({
type: 'geocacheUpdate',
geocache: data.geocache
}, userId);
}
} else if (data.type === 'geocacheDelete') {
// Handle geocache deletion
if (data.geocacheId) {
const index = geocaches.findIndex(g => g.id === data.geocacheId);
if (index > -1) {
geocaches.splice(index, 1);
// Save to file
saveGeocaches();
// Broadcast deletion to all other users
broadcast({
type: 'geocacheDelete',
geocacheId: data.geocacheId
}, userId);
}
}
}
} catch (err) {
console.error('Error processing message:', err);
}
});
ws.on('close', () => {
removeUser(userId);
// Clean up auth user mapping - but only if THIS connection is still the active one
// (don't remove if a newer connection replaced us)
if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) {
authUserConnections.delete(ws.authUserId);
console.log(`[SESSION] Removed auth mapping for user ${ws.authUserId} (connection closed)`);
}
});
ws.on('error', (err) => {
console.error(`WebSocket error for user ${userId}:`, err);
removeUser(userId);
// Same check - only remove if we're still the active connection
if (ws.authUserId && authUserConnections.get(ws.authUserId) === ws) {
authUserConnections.delete(ws.authUserId);
}
});
// Heartbeat to detect disconnected clients
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
});
// Periodic cleanup of stale connections
const heartbeatInterval = setInterval(() => {
wss.clients.forEach(ws => {
if (ws.isAlive === false) {
removeUser(ws.userId);
return ws.terminate();
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
const PORT = process.env.PORT || 8080;
server.listen(PORT, async () => {
console.log(`Server running on port ${PORT}`);
console.log(`Open http://localhost:${PORT} to view the map`);
// Initialize database
try {
let dbPath;
try {
await fs.access('/app/data');
dbPath = '/app/data/hikemap.db';
} catch (err) {
dbPath = path.join(__dirname, 'data', 'hikemap.db');
// Create data directory if it doesn't exist
await fs.mkdir(path.dirname(dbPath), { recursive: true });
}
db = new HikeMapDB(dbPath).init();
console.log('Database initialized');
// Clear all refresh tokens on startup (logs everyone out)
db.clearAllRefreshTokens();
// Seed default monsters if they don't exist
db.seedDefaultMonsters();
// Seed default skills if they don't exist
db.seedDefaultSkills();
// Seed default classes if they don't exist
db.seedDefaultClasses();
// Seed default game settings if they don't exist
db.seedDefaultSettings();
// Seed default OSM tags if they don't exist
db.seedDefaultOsmTags();
// Clean expired tokens periodically
setInterval(() => {
try {
db.cleanExpiredTokens();
} catch (err) {
console.error('Error cleaning expired tokens:', err);
}
}, 60 * 60 * 1000); // Every hour
} catch (err) {
console.error('Failed to initialize database:', err);
}
// Load geocaches on startup
await loadGeocaches();
// Load push subscriptions on startup
await loadPushSubscriptions();
});