@ -765,6 +765,16 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
}
}
}
}
// 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
// Convert snake_case from DB to camelCase for client
res . json ( {
res . json ( {
name : stats . character_name ,
name : stats . character_name ,
@ -781,11 +791,13 @@ app.get('/api/user/rpg-stats', authenticateToken, (req, res) => {
accuracy : stats . accuracy || 90 ,
accuracy : stats . accuracy || 90 ,
dodge : stats . dodge || 10 ,
dodge : stats . dodge || 10 ,
unlockedSkills : unlockedSkills ,
unlockedSkills : unlockedSkills ,
activeSkills : activeSkills ,
homeBaseLat : stats . home_base_lat ,
homeBaseLat : stats . home_base_lat ,
homeBaseLng : stats . home_base_lng ,
homeBaseLng : stats . home_base_lng ,
lastHomeSet : stats . last_home_set ,
lastHomeSet : stats . last_home_set ,
isDead : ! ! stats . is_dead ,
isDead : ! ! stats . is_dead ,
homeBaseIcon : stats . home_base_icon || '00'
homeBaseIcon : stats . home_base_icon || '00' ,
dataVersion : stats . data_version || 1
} ) ;
} ) ;
} else {
} else {
// No stats yet - return null so client creates defaults
// No stats yet - return null so client creates defaults
@ -849,14 +861,121 @@ app.put('/api/user/rpg-stats', authenticateToken, (req, res) => {
return res . status ( 400 ) . json ( { error : 'Invalid stats data' } ) ;
return res . status ( 400 ) . json ( { error : 'Invalid stats data' } ) ;
}
}
db . saveRpgStats ( req . user . userId , stats ) ;
res . json ( { success : true } ) ;
// 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 ) {
} catch ( err ) {
console . error ( 'Save RPG stats error:' , err ) ;
console . error ( 'Save RPG stats error:' , err ) ;
res . status ( 500 ) . json ( { error : 'Failed to save RPG stats' } ) ;
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)
// Check if user can set home base (once per day)
app . get ( '/api/user/can-set-home' , authenticateToken , ( req , res ) => {
app . get ( '/api/user/can-set-home' , authenticateToken , ( req , res ) => {
try {
try {
@ -930,7 +1049,13 @@ app.get('/api/spawn-settings', (req, res) => {
spawnInterval : JSON . parse ( db . getSetting ( 'monsterSpawnInterval' ) || '20000' ) ,
spawnInterval : JSON . parse ( db . getSetting ( 'monsterSpawnInterval' ) || '20000' ) ,
spawnChance : JSON . parse ( db . getSetting ( 'monsterSpawnChance' ) || '50' ) ,
spawnChance : JSON . parse ( db . getSetting ( 'monsterSpawnChance' ) || '50' ) ,
spawnDistance : JSON . parse ( db . getSetting ( 'monsterSpawnDistance' ) || '10' ) ,
spawnDistance : JSON . parse ( db . getSetting ( 'monsterSpawnDistance' ) || '10' ) ,
mpRegenDistance : JSON . parse ( db . getSetting ( 'mpRegenDistance' ) || '5' )
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' )
} ;
} ;
res . json ( settings ) ;
res . json ( settings ) ;
} catch ( err ) {
} catch ( err ) {
@ -1002,6 +1127,144 @@ app.post('/api/user/respawn', authenticateToken, (req, res) => {
}
}
} ) ;
} ) ;
// ============================================
// 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' } ) ;
}
// Define buff configurations
const BUFF_CONFIGS = {
'second_wind' : {
effectType : 'mp_regen_multiplier' ,
effectValue : 2.0 , // Double MP regen
durationHours : 1 , // 1 hour
cooldownHours : 24 // 24 hour cooldown
}
// Add more buff types here as needed
} ;
const config = BUFF_CONFIGS [ buffType ] ;
if ( ! config ) {
return res . status ( 400 ) . json ( { error : 'Unknown buff type' } ) ;
}
// 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
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)
// Get all monster types (public endpoint - needed for game rendering)
app . get ( '/api/monster-types' , ( req , res ) => {
app . get ( '/api/monster-types' , ( req , res ) => {
try {
try {
@ -1239,6 +1502,7 @@ app.post('/api/admin/monster-types', adminOnly, async (req, res) => {
}
}
}
}
broadcastAdminChange ( 'monster' , { action : 'created' } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin create monster type error:' , err ) ;
console . error ( 'Admin create monster type error:' , err ) ;
@ -1251,6 +1515,7 @@ app.put('/api/admin/monster-types/:id', adminOnly, (req, res) => {
try {
try {
const data = req . body ;
const data = req . body ;
db . updateMonsterType ( req . params . id , data ) ;
db . updateMonsterType ( req . params . id , data ) ;
broadcastAdminChange ( 'monster' , { action : 'updated' , id : req . params . id } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin update monster type error:' , err ) ;
console . error ( 'Admin update monster type error:' , err ) ;
@ -1263,6 +1528,7 @@ app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => {
try {
try {
const { enabled } = req . body ;
const { enabled } = req . body ;
db . toggleMonsterEnabled ( req . params . id , enabled ) ;
db . toggleMonsterEnabled ( req . params . id , enabled ) ;
broadcastAdminChange ( 'monster' , { action : 'toggled' , id : req . params . id } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin toggle monster error:' , err ) ;
console . error ( 'Admin toggle monster error:' , err ) ;
@ -1274,6 +1540,7 @@ app.patch('/api/admin/monster-types/:id/enabled', adminOnly, (req, res) => {
app . delete ( '/api/admin/monster-types/:id' , adminOnly , ( req , res ) => {
app . delete ( '/api/admin/monster-types/:id' , adminOnly , ( req , res ) => {
try {
try {
db . deleteMonsterType ( req . params . id ) ;
db . deleteMonsterType ( req . params . id ) ;
broadcastAdminChange ( 'monster' , { action : 'deleted' , id : req . params . id } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin delete monster type error:' , err ) ;
console . error ( 'Admin delete monster type error:' , err ) ;
@ -1391,11 +1658,23 @@ app.get('/api/admin/settings', adminOnly, (req, res) => {
// Update game settings
// Update game settings
app . put ( '/api/admin/settings' , adminOnly , ( req , res ) => {
app . put ( '/api/admin/settings' , adminOnly , ( req , res ) => {
console . log ( '[SETTINGS] Admin settings update received' ) ;
try {
try {
const settings = req . body ;
const settings = req . body ;
console . log ( '[SETTINGS] Settings to save:' , Object . keys ( settings ) ) ;
for ( const [ key , value ] of Object . entries ( settings ) ) {
for ( const [ key , value ] of Object . entries ( settings ) ) {
db . setSetting ( key , JSON . stringify ( value ) ) ;
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 } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin update settings error:' , err ) ;
console . error ( 'Admin update settings error:' , err ) ;
@ -1442,6 +1721,7 @@ app.post('/api/admin/skills', adminOnly, (req, res) => {
return res . status ( 400 ) . json ( { error : 'Missing required fields (id and name)' } ) ;
return res . status ( 400 ) . json ( { error : 'Missing required fields (id and name)' } ) ;
}
}
db . createSkill ( data ) ;
db . createSkill ( data ) ;
broadcastAdminChange ( 'skill' , { action : 'created' } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin create skill error:' , err ) ;
console . error ( 'Admin create skill error:' , err ) ;
@ -1454,6 +1734,7 @@ app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
try {
try {
const data = req . body ;
const data = req . body ;
db . updateSkill ( req . params . id , data ) ;
db . updateSkill ( req . params . id , data ) ;
broadcastAdminChange ( 'skill' , { action : 'updated' , id : req . params . id } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin update skill error:' , err ) ;
console . error ( 'Admin update skill error:' , err ) ;
@ -1465,6 +1746,7 @@ app.put('/api/admin/skills/:id', adminOnly, (req, res) => {
app . delete ( '/api/admin/skills/:id' , adminOnly , ( req , res ) => {
app . delete ( '/api/admin/skills/:id' , adminOnly , ( req , res ) => {
try {
try {
db . deleteSkill ( req . params . id ) ;
db . deleteSkill ( req . params . id ) ;
broadcastAdminChange ( 'skill' , { action : 'deleted' , id : req . params . id } ) ;
res . json ( { success : true } ) ;
res . json ( { success : true } ) ;
} catch ( err ) {
} catch ( err ) {
console . error ( 'Admin delete skill error:' , err ) ;
console . error ( 'Admin delete skill error:' , err ) ;
@ -1816,6 +2098,19 @@ function broadcast(data, senderId) {
} ) ;
} ) ;
}
}
// 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
// Map authenticated user IDs to WebSocket connections for targeted messages
const authUserConnections = new Map ( ) ; // authUserId (number) -> ws connection
const authUserConnections = new Map ( ) ; // authUserId (number) -> ws connection
@ -1876,6 +2171,33 @@ wss.on('connection', (ws) => {
if ( data . type === 'auth' ) {
if ( data . type === 'auth' ) {
// Register authenticated user's WebSocket connection
// Register authenticated user's WebSocket connection
if ( data . authUserId ) {
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 ;
ws . authUserId = data . authUserId ;
authUserConnections . set ( data . authUserId , ws ) ;
authUserConnections . set ( data . authUserId , ws ) ;
console . log ( ` Auth user ${ data . authUserId } registered on WebSocket ${ userId } ` ) ;
console . log ( ` Auth user ${ data . authUserId } registered on WebSocket ${ userId } ` ) ;
@ -1981,16 +2303,19 @@ wss.on('connection', (ws) => {
ws . on ( 'close' , ( ) => {
ws . on ( 'close' , ( ) => {
removeUser ( userId ) ;
removeUser ( userId ) ;
// Clean up auth user mapping
if ( ws . authUserId ) {
// 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 ) ;
authUserConnections . delete ( ws . authUserId ) ;
console . log ( ` [SESSION] Removed auth mapping for user ${ ws . authUserId } (connection closed) ` ) ;
}
}
} ) ;
} ) ;
ws . on ( 'error' , ( err ) => {
ws . on ( 'error' , ( err ) => {
console . error ( ` WebSocket error for user ${ userId } : ` , err ) ;
console . error ( ` WebSocket error for user ${ userId } : ` , err ) ;
removeUser ( userId ) ;
removeUser ( userId ) ;
if ( ws . authUserId ) {
// Same check - only remove if we're still the active connection
if ( ws . authUserId && authUserConnections . get ( ws . authUserId ) === ws ) {
authUserConnections . delete ( ws . authUserId ) ;
authUserConnections . delete ( ws . authUserId ) ;
}
}
} ) ;
} ) ;