Initial commit
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user