const express = require('express'); const { body } = require('express-validator'); const Games = require('../models/games'); const Users = require('../models/users'); const { Plays, HighScores, Ratings, DeveloperEarnings } = require('../models/plays'); const Favorites = require('../models/favorites'); const Achievements = require('../models/achievements'); const XP = require('../models/xp'); const { pool } = require('../models/db'); const { authenticate, authenticateOrGuest, requireNonGuest } = require('../middleware/auth'); const validate = require('../middleware/validate'); const router = express.Router(); // Start play session - NO TOKEN DEDUCTION HERE // Tokens are only charged after 1 minute of play // Accepts both authenticated users AND guests (via fingerprint) router.post('/start', authenticateOrGuest, validate([ body('gameId').isInt().withMessage('Valid game ID required'), body('fingerprint').optional().isString().isLength({ min: 10, max: 255 }) ]), async (req, res, next) => { try { const { gameId } = req.body; const game = await Games.findById(gameId); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.status !== 'published') { return res.status(400).json({ error: 'Game is not available to play' }); } // Check if this is the creator playing their own game const isCreatorPlay = game.creator_id === req.user.id; // Only check tokens if not playing own game if (!isCreatorPlay) { const tokenBalance = await Users.getTokenBalance(req.user.id); if (tokenBalance < 1) { return res.status(402).json({ error: 'Not enough tokens. Come back tomorrow for 50 free tokens!', tokensBalance: tokenBalance }); } } // Create play session const ipAddress = req.ip || req.connection.remoteAddress; const play = await Plays.create({ playerId: req.user.id, gameId: gameId, deviceFingerprint: req.user.device_fingerprint, ipAddress: ipAddress, isCreatorPlay }); // Increment game play count (only for non-creator plays) if (!isCreatorPlay) { await Games.incrementPlayCount(gameId); } const tokenBalance = await Users.getTokenBalance(req.user.id); // Prevent caching of game code responses res.set({ 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Pragma': 'no-cache', 'Expires': '0' }); res.json({ message: isCreatorPlay ? 'Playing your own game (no tokens charged)' : 'Play session started. Token will be charged after 1 minute of play.', sessionId: play.id, tokensBalance: tokenBalance, gameCode: game.game_code, codeVersion: game.code_version || 1, isCreatorPlay, isGuest: req.user.is_guest, // If auto-created guest, include user info for frontend ...(req.isAutoGuest && { guestUser: { id: req.user.id, username: req.user.username, tokensBalance: tokenBalance, isGuest: true } }) }); } catch (error) { next(error); } } ); // Finish play session router.post('/finish', authenticateOrGuest, validate([ body('sessionId').isInt().withMessage('Valid session ID required'), body('score').optional().isInt({ min: 0 }), body('durationSeconds').optional().isInt({ min: 0 }), body('levelsCompleted').optional().isInt({ min: 0 }) ]), async (req, res, next) => { try { const { sessionId, score, durationSeconds, levelsCompleted } = req.body; const play = await Plays.findById(sessionId); if (!play) { return res.status(404).json({ error: 'Play session not found' }); } if (play.player_id !== req.user.id) { return res.status(403).json({ error: 'Not your play session' }); } if (play.ended_at) { return res.status(400).json({ error: 'Session already finished' }); } const actualDuration = durationSeconds || Math.floor((Date.now() - new Date(play.started_at).getTime()) / 1000); // Token handling let tokensCharged = 0; let newBalance = await Users.getTokenBalance(req.user.id); let xpResult = null; const earnedAchievements = []; // Only charge tokens for non-creator plays if (!play.is_creator_play && actualDuration >= 60 && !play.token_spent) { const minutesPlayed = Math.floor(actualDuration / 60); tokensCharged = Math.min(minutesPlayed, newBalance); if (tokensCharged > 0) { newBalance = await Users.updateTokens(req.user.id, -tokensCharged); // Credit the game creator and award XP const game = await Games.findById(play.game_id); if (game && game.creator_id !== req.user.id) { await Users.updateTokens(game.creator_id, tokensCharged); await Games.addTokensEarned(game.id, tokensCharged); await DeveloperEarnings.create({ developerId: game.creator_id, gameId: game.id, tokensEarned: tokensCharged, playId: sessionId, source: 'play' }); // Award XP to creator for plays await XP.award(game.creator_id, XP.XP_VALUES.GAME_PLAYED_BY_OTHERS, 'game_played', game.id); } } } // Award XP to player (not for guests) if (!play.is_creator_play && !req.user.is_guest) { xpResult = await XP.award(req.user.id, XP.XP_VALUES.PLAY_GAME, 'played_game', play.game_id); } // Finish the play session await Plays.finish(sessionId, { score, durationSeconds: actualDuration, levelsCompleted, tokenSpent: tokensCharged > 0 }); // Update high score if provided (guests can get high scores but no XP/achievements) let isNewHighScore = false; if (score !== undefined && !play.is_creator_play) { const highScoreResult = await HighScores.upsert({ gameId: play.game_id, playerId: req.user.id, score }); isNewHighScore = highScoreResult.isNew; // Check if #1 on leaderboard (achievements only for non-guests) if (isNewHighScore && !req.user.is_guest) { const topScore = await pool.query( 'SELECT player_id FROM high_scores WHERE game_id = $1 ORDER BY score DESC LIMIT 1', [play.game_id] ); if (topScore.rows.length > 0 && topScore.rows[0].player_id === req.user.id) { const a = await Achievements.award(req.user.id, 'high_scorer'); if (a) earnedAchievements.push(a); // Bonus XP for high score await XP.award(req.user.id, XP.XP_VALUES.GET_HIGH_SCORE, 'high_score', play.game_id); } } } // Check games played achievements (not for guests) if (!play.is_creator_play && !req.user.is_guest) { const playCountResult = await pool.query( 'SELECT COUNT(DISTINCT game_id) as count FROM plays WHERE player_id = $1', [req.user.id] ); const gamesPlayed = parseInt(playCountResult.rows[0].count); const gamesAchievements = await Achievements.checkAndAward(req.user.id, 'games_played', gamesPlayed); earnedAchievements.push(...gamesAchievements); } res.json({ message: tokensCharged > 0 ? `Play session completed. ${tokensCharged} token(s) charged.` : 'Play session completed.', score, durationSeconds: actualDuration, tokensCharged, tokensBalance: newBalance, isNewHighScore, xp: xpResult, achievements: earnedAchievements.map(a => ({ code: a.code, name: a.name, icon: a.icon, xpReward: a.xp_reward })) }); } catch (error) { next(error); } } ); // Rate game (guests can rate too) router.post('/rate', authenticateOrGuest, validate([ body('gameId').isInt().withMessage('Valid game ID required'), body('rating').isInt({ min: 1, max: 5 }).withMessage('Rating must be 1-5'), body('fingerprint').optional().isString().isLength({ min: 10, max: 255 }) ]), async (req, res, next) => { try { const { gameId, rating } = req.body; const game = await Games.findById(gameId); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.status !== 'published') { return res.status(400).json({ error: 'Cannot rate unpublished games' }); } // Can't rate own game if (game.creator_id === req.user.id) { return res.status(400).json({ error: 'Cannot rate your own game' }); } // Upsert rating await Ratings.upsert({ gameId, userId: req.user.id, rating }); // Award XP to creator for 5-star ratings (only from non-guest users) if (rating === 5 && !req.user.is_guest) { await XP.award(game.creator_id, XP.XP_VALUES.GAME_FIVE_STAR, 'five_star_rating', gameId); } res.json({ message: 'Rating saved', rating, isGuest: req.user.is_guest }); } catch (error) { next(error); } } ); // Toggle favorite (requires signed up user, not guest) router.post('/favorite', authenticate, requireNonGuest, validate([ body('gameId').isInt().withMessage('Valid game ID required') ]), async (req, res, next) => { try { const { gameId } = req.body; const game = await Games.findById(gameId); if (!game) { return res.status(404).json({ error: 'Game not found' }); } if (game.status !== 'published') { return res.status(400).json({ error: 'Cannot favorite unpublished games' }); } const result = await Favorites.toggle(req.user.id, gameId); // Award XP to creator when favorited if (result.favorited && game.creator_id !== req.user.id) { await XP.award(game.creator_id, XP.XP_VALUES.GAME_FAVORITED, 'game_favorited', gameId); // Track for developer earnings await DeveloperEarnings.create({ developerId: game.creator_id, gameId, tokensEarned: 0, source: 'favorite' }); } // Check favorites achievement const favoritesCount = await Favorites.getCount(req.user.id); const achievements = await Achievements.checkAndAward(req.user.id, 'favorites_count', favoritesCount); res.json({ message: result.favorited ? 'Added to favorites' : 'Removed from favorites', favorited: result.favorited, achievements: achievements.map(a => ({ code: a.code, name: a.name, icon: a.icon })) }); } catch (error) { next(error); } } ); // Report game with optional comment and AI feedback router.post('/report', authenticate, validate([ body('gameId').isInt().withMessage('Valid game ID required'), body('reason').isIn([ 'wont_load', 'crashes', 'broken', 'inappropriate', 'stolen', 'other' ]).withMessage('Invalid reason'), body('comment').optional().trim().isLength({ max: 1000 }) ]), async (req, res, next) => { try { const { gameId, reason, comment } = req.body; const game = await Games.findById(gameId); if (!game) { return res.status(404).json({ error: 'Game not found' }); } // Check for existing report from this user const existingReport = await pool.query( 'SELECT id FROM game_reports WHERE game_id = $1 AND reporter_id = $2', [gameId, req.user.id] ); if (existingReport.rows.length > 0) { return res.status(400).json({ error: 'You have already reported this game' }); } // Build description for admin const reasonLabels = { wont_load: "Won't Load", crashes: "Game Crashes", broken: "Broken/Unplayable", inappropriate: "Inappropriate Content", stolen: "Stolen/Copied", other: "Other Issue" }; const description = `${reasonLabels[reason] || reason}${comment ? ': ' + comment : ''}`; await pool.query( `INSERT INTO game_reports (game_id, reporter_id, reason, comment, description) VALUES ($1, $2, $3, $4, $5)`, [gameId, req.user.id, reason, comment || null, description] ); // Generate AI feedback for the reporter let aiFeedback = null; try { const Anthropic = require('@anthropic-ai/sdk'); const client = new Anthropic({ apiKey: process.env.CLAUDE_API_KEY, timeout: 15000 }); const aiResult = await client.messages.create({ model: 'claude-haiku-4-5-20251001', max_tokens: 150, messages: [{ role: 'user', content: `A kid reported an issue with a game called "${game.title}". Reason: ${reasonLabels[reason]}. ${comment ? 'Comment: ' + comment : ''}. Write a short, friendly 1-2 sentence acknowledgment for the reporter (age 8-16). Be encouraging and let them know we'll look into it. Don't use the word "sorry".` }] }); aiFeedback = aiResult.content[0]?.text || null; } catch (aiErr) { // AI feedback is optional - don't fail the report console.error('AI feedback generation failed:', aiErr.message); } res.json({ message: 'Report submitted. Thank you for helping keep GamerComp safe!', aiFeedback }); } catch (error) { next(error); } } ); module.exports = router;