Initial commit

This commit is contained in:
Allen
2026-04-30 02:14:25 +00:00
commit 7090e6f01d
243 changed files with 115122 additions and 0 deletions
+410
View File
@@ -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;