Initial commit
This commit is contained in:
Generated
+2550
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "gamercomp-api",
|
||||
"version": "1.0.0",
|
||||
"description": "GamerComp.com Game Arcade API",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pg": "^8.11.3",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^9.0.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
require('dotenv').config();
|
||||
const { pool } = require('../src/models/db');
|
||||
const { generateGame } = require('../src/utils/claude');
|
||||
|
||||
const ADMIN_USER_ID = 2; // arcade_master
|
||||
|
||||
const classicGames = [
|
||||
{
|
||||
title: "Snake",
|
||||
prompt: `Create a classic Snake game where the player controls a snake that grows longer each time it eats food.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- The snake moves continuously in the current direction
|
||||
- Arrow keys (desktop) or swipe gestures (mobile) change direction
|
||||
- A food item (apple or dot) appears randomly on the screen
|
||||
- When the snake eats food, it grows by one segment and score increases by 10
|
||||
- The game ends if the snake hits the wall or its own body
|
||||
- Snake should start with 3 segments moving to the right
|
||||
- Speed should gradually increase every 50 points
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Use a grid-based playing field with a subtle grid pattern
|
||||
- Snake head should be slightly different color than body (dark green head, lighter green body)
|
||||
- Food should be red and visually distinct (like an apple)
|
||||
- Display score prominently in the top-left corner
|
||||
- Show a "Game Over" screen with final score and "Play Again" button
|
||||
|
||||
CONTROLS:
|
||||
- Desktop: Arrow keys for direction
|
||||
- Mobile: Swipe in any direction to change snake direction
|
||||
- Prevent 180-degree turns (can't go directly backward)`
|
||||
},
|
||||
{
|
||||
title: "Tetris",
|
||||
prompt: `Create a Tetris-style falling block puzzle game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Seven different tetromino shapes (I, O, T, S, Z, J, L) fall from the top
|
||||
- Player can move pieces left/right and rotate them
|
||||
- Pieces fall automatically at a steady pace
|
||||
- When a horizontal line is completely filled, it clears and awards points
|
||||
- Clearing multiple lines at once awards bonus points (1 line=100, 2=300, 3=500, 4=800)
|
||||
- Game ends when pieces stack to the top
|
||||
- Show the next piece that will appear
|
||||
|
||||
VISUAL DESIGN:
|
||||
- 10 columns wide, 20 rows tall playing field
|
||||
- Each tetromino type has a distinct bright color
|
||||
- Ghost piece shows where current piece will land
|
||||
- Clear lines with a satisfying flash animation
|
||||
- Display score, level, and lines cleared
|
||||
- Level increases every 10 lines, increasing fall speed
|
||||
|
||||
CONTROLS:
|
||||
- Left/Right arrows or A/D: Move piece horizontally
|
||||
- Up arrow or W: Rotate piece clockwise
|
||||
- Down arrow or S: Soft drop (faster fall)
|
||||
- Space: Hard drop (instant drop)
|
||||
- Mobile: On-screen buttons for all actions`
|
||||
},
|
||||
{
|
||||
title: "Pong",
|
||||
prompt: `Create a classic Pong game - the original video game tennis.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Two paddles on opposite sides of the screen (left and right)
|
||||
- A ball bounces between them
|
||||
- Players score when the ball passes the opponent's paddle
|
||||
- Ball speed increases slightly after each paddle hit
|
||||
- Ball angle changes based on where it hits the paddle (edges = sharper angles)
|
||||
- First player to 11 points wins
|
||||
- Ball resets to center after each point, launching toward the player who was scored on
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Black background with white elements (classic arcade look)
|
||||
- Dotted line down the center of the screen
|
||||
- Paddles are simple white rectangles
|
||||
- Ball is a small white square
|
||||
- Large score display at top for both players
|
||||
- "Player 1 Wins!" or "Player 2 Wins!" message at game end
|
||||
|
||||
CONTROLS:
|
||||
- Player 1 (left): W/S keys to move up/down
|
||||
- Player 2 (right): Up/Down arrow keys
|
||||
- Mobile: Touch left side to control left paddle, right side for right paddle
|
||||
- Alternatively, make Player 2 a simple AI opponent`
|
||||
},
|
||||
{
|
||||
title: "Breakout",
|
||||
prompt: `Create a Breakout/Brick Breaker arcade game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Rows of colored bricks at the top of the screen
|
||||
- Player controls a paddle at the bottom
|
||||
- Ball bounces around, breaking bricks on contact
|
||||
- Different colored bricks may take multiple hits (green=1, yellow=2, orange=3, red=4)
|
||||
- Some bricks drop power-ups: wider paddle, multi-ball, slow ball
|
||||
- Player has 3 lives, loses one if ball falls below paddle
|
||||
- Level complete when all bricks are destroyed
|
||||
- Ball angle changes based on where it hits the paddle
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Colorful brick arrangement (rainbow pattern from top to bottom)
|
||||
- Bricks should visibly crack or change color when damaged
|
||||
- Particle effects when bricks break
|
||||
- Paddle has a metallic/shiny appearance
|
||||
- Display score at top, lives as small ball icons
|
||||
- Power-ups are falling capsules with distinct colors
|
||||
|
||||
CONTROLS:
|
||||
- Mouse movement or Left/Right arrows move the paddle
|
||||
- Click or Space to launch the ball from paddle at game start
|
||||
- Mobile: Touch and drag to move paddle`
|
||||
},
|
||||
{
|
||||
title: "Space Invaders",
|
||||
prompt: `Create the classic Space Invaders alien shooting game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Grid of alien invaders (5 rows of 11) march left and right, dropping down each time they hit an edge
|
||||
- Player ship at bottom can move left/right and shoot upward
|
||||
- Aliens occasionally drop bombs downward
|
||||
- Player has 3 lives, loses one if hit by bomb
|
||||
- Killing aliens awards points (bottom row=10, increasing by 10 per row up)
|
||||
- Mystery UFO occasionally flies across the top (bonus 50-300 points)
|
||||
- Aliens speed up as fewer remain
|
||||
- Game over if aliens reach the bottom
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Classic pixel-art style aliens (3 different designs for variety)
|
||||
- Aliens animate between 2 frames as they move
|
||||
- Green player ship with white bullets
|
||||
- Four destructible shields/bunkers that erode when hit
|
||||
- Black space background with stars
|
||||
- Score and high score at top, lives shown as ship icons
|
||||
|
||||
CONTROLS:
|
||||
- Left/Right arrows or A/D to move ship
|
||||
- Space bar to shoot (limit: one bullet on screen at a time for authenticity, or allow 3)
|
||||
- Mobile: Touch left/right sides to move, tap center to shoot`
|
||||
},
|
||||
{
|
||||
title: "Pac-Man",
|
||||
prompt: `Create a Pac-Man style maze chase game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Player controls Pac-Man through a maze eating dots
|
||||
- Four ghosts chase Pac-Man with different behaviors
|
||||
- Small dots = 10 points, large power pellets = 50 points
|
||||
- Power pellets make ghosts vulnerable (blue) for 8 seconds - eating them = 200, 400, 800, 1600 points
|
||||
- Player has 3 lives, loses one if touched by a non-vulnerable ghost
|
||||
- Level complete when all dots are eaten
|
||||
- Tunnels on sides wrap around to opposite side
|
||||
- Ghosts get faster with each level
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Classic maze layout with blue walls
|
||||
- Pac-Man is yellow circle with animated chomping mouth
|
||||
- Four colorful ghosts: red (Blinky), pink (Pinky), cyan (Inky), orange (Clyde)
|
||||
- Ghosts have cute eyes that look in their direction of travel
|
||||
- Dots are small, power pellets are large and flashing
|
||||
- Display score, high score, and lives
|
||||
|
||||
CONTROLS:
|
||||
- Arrow keys for direction (Pac-Man turns at next intersection)
|
||||
- Mobile: Swipe in direction to turn
|
||||
- Pac-Man continues moving in current direction until wall`
|
||||
},
|
||||
{
|
||||
title: "Flappy Bird",
|
||||
prompt: `Create a Flappy Bird-style endless flying game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Bird automatically falls due to gravity
|
||||
- Tapping/clicking makes the bird flap and rise
|
||||
- Pipes (or obstacles) scroll from right to left with gaps to fly through
|
||||
- Each pipe passed = 1 point
|
||||
- Game over if bird hits pipe or ground/ceiling
|
||||
- Pipe gap size stays constant, but spacing between pipes can vary
|
||||
- Bird rotates based on velocity (nose up when rising, nose down when falling)
|
||||
- High score is saved and displayed
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Cute, simple bird with flapping wing animation
|
||||
- Colorful green pipes coming from top and bottom
|
||||
- Scrolling background with clouds and simple scenery
|
||||
- Ground at bottom with scrolling texture
|
||||
- Large score display in center-top during play
|
||||
- Game over screen shows score vs best score with "Tap to Retry"
|
||||
|
||||
CONTROLS:
|
||||
- Spacebar, click, or tap anywhere to flap
|
||||
- That's it - one-button gameplay!
|
||||
- Bird should feel floaty with satisfying physics`
|
||||
},
|
||||
{
|
||||
title: "2048",
|
||||
prompt: `Create the 2048 sliding number puzzle game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- 4x4 grid of tiles
|
||||
- Tiles contain powers of 2 (2, 4, 8, 16... up to 2048+)
|
||||
- Swiping/arrow key slides ALL tiles in that direction
|
||||
- When two tiles with same number collide, they merge into their sum
|
||||
- After each move, a new tile (2 or 4) appears in a random empty spot
|
||||
- Goal: Create a 2048 tile (though game continues after)
|
||||
- Game over when no more moves are possible
|
||||
- Score increases by the value of each merged tile
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Clean, modern design with rounded tile corners
|
||||
- Each number has a distinct background color (2=light, higher=darker/warmer)
|
||||
- Smooth sliding and merging animations
|
||||
- Tiles should pop/scale when they appear and merge
|
||||
- Display current score and best score
|
||||
- Large, clear numbers on tiles
|
||||
- "You Win!" celebration when reaching 2048, with option to continue
|
||||
|
||||
CONTROLS:
|
||||
- Arrow keys to slide all tiles in a direction
|
||||
- Mobile: Swipe gestures in any direction
|
||||
- Consider adding "Undo" button for last move`
|
||||
},
|
||||
{
|
||||
title: "Minesweeper",
|
||||
prompt: `Create the classic Minesweeper puzzle game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Grid of covered cells (start with 10x10 with 15 mines for beginner)
|
||||
- Left-click reveals a cell
|
||||
- Numbers show how many adjacent cells (including diagonals) contain mines
|
||||
- Empty cells auto-reveal adjacent empty cells (flood fill)
|
||||
- Right-click places a flag to mark suspected mines
|
||||
- First click is always safe (never a mine)
|
||||
- Win by revealing all non-mine cells
|
||||
- Lose by clicking a mine (reveal all mines, show X on wrong flags)
|
||||
- Timer starts on first click
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Clean grid with beveled 3D-style unrevealed cells
|
||||
- Revealed cells are flat with clear numbers
|
||||
- Numbers colored by value (1=blue, 2=green, 3=red, etc.)
|
||||
- Mines shown as black circles/bombs when revealed
|
||||
- Flags are red
|
||||
- Smiley face button to restart (changes expression: smile, surprised on click, dead on loss, cool on win)
|
||||
- Display mine count remaining and timer
|
||||
|
||||
CONTROLS:
|
||||
- Left-click to reveal cell
|
||||
- Right-click to toggle flag
|
||||
- Mobile: Tap to reveal, long-press to flag
|
||||
- Include toggle button for flag mode on mobile`
|
||||
},
|
||||
{
|
||||
title: "Asteroids",
|
||||
prompt: `Create the classic Asteroids space shooter game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Player ship in center of screen can rotate and thrust
|
||||
- Ship wraps around screen edges (appears on opposite side)
|
||||
- Shoot bullets to destroy asteroids
|
||||
- Large asteroids split into 2 medium, medium split into 2 small
|
||||
- Points: Large=20, Medium=50, Small=100
|
||||
- Player has 3 lives, loses one if hit by asteroid
|
||||
- Ship has brief invincibility after respawning
|
||||
- UFOs occasionally appear and shoot at player (Small=1000pts, Large=200pts)
|
||||
- Hyperspace: random teleport as escape (risky - might teleport into asteroid)
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Vector graphics style (white lines on black background)
|
||||
- Ship is a simple triangle that rotates smoothly
|
||||
- Asteroids are irregular polygons that rotate as they drift
|
||||
- Bullets are small dots/lines
|
||||
- Thrust shows small particles behind ship
|
||||
- Ship explosion is satisfying particle burst
|
||||
- Display score and lives (ship icons)
|
||||
|
||||
CONTROLS:
|
||||
- Left/Right arrows or A/D to rotate ship
|
||||
- Up arrow or W to thrust forward
|
||||
- Space to shoot
|
||||
- Shift or X for hyperspace
|
||||
- Mobile: On-screen joystick for rotation/thrust, fire button`
|
||||
},
|
||||
{
|
||||
title: "Frogger",
|
||||
prompt: `Create a Frogger-style road and river crossing game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Frog starts at bottom, must reach one of 5 home bases at top
|
||||
- Bottom half: Cross road with cars/trucks moving at different speeds
|
||||
- Top half: Cross river by jumping on logs and turtles
|
||||
- Falling in water or getting hit by vehicle = lose a life
|
||||
- Turtles occasionally submerge (warning animation first)
|
||||
- Bonus items appear on logs (flies for points, female frog for bonus)
|
||||
- Time limit per frog (30 seconds) - bonus points for time remaining
|
||||
- Fill all 5 home bases to complete level
|
||||
- Each level, traffic and logs move faster
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Colorful top-down view
|
||||
- Cute pixel-art frog that faces direction of last move
|
||||
- Road section: gray with lane markings, colorful vehicles
|
||||
- River section: blue water, brown logs, green turtles
|
||||
- Safe zones: green grass/lily pads
|
||||
- Home bases at top with flowers when empty, frog when filled
|
||||
- Display score, time bar, and lives
|
||||
|
||||
CONTROLS:
|
||||
- Arrow keys for hopping in four directions
|
||||
- Each press = one hop (grid-based movement)
|
||||
- Mobile: Swipe in direction to hop, or on-screen d-pad`
|
||||
},
|
||||
{
|
||||
title: "Simon Says",
|
||||
prompt: `Create a Simon Says memory pattern game.
|
||||
|
||||
GAMEPLAY MECHANICS:
|
||||
- Four colored buttons arranged in quadrants (red, blue, green, yellow)
|
||||
- Computer plays a sequence by lighting up buttons with sounds
|
||||
- Player must repeat the sequence by clicking buttons in same order
|
||||
- Sequence grows by one each successful round
|
||||
- Wrong button = game over, show final score (rounds completed)
|
||||
- Speed increases gradually as sequence gets longer
|
||||
- Each color has a distinct tone/sound
|
||||
- High score is saved and displayed
|
||||
|
||||
VISUAL DESIGN:
|
||||
- Large circular or rounded square device
|
||||
- Four bright colored quadrants that light up when active (glow effect)
|
||||
- Center shows current round number
|
||||
- Buttons dim when inactive, bright when pressed/shown
|
||||
- "Simon Says" title at top
|
||||
- Satisfying button press animations (press down effect)
|
||||
- Game over screen with score and "Play Again"
|
||||
- Consider a "strict mode" toggle (one mistake = restart from beginning vs. one mistake = retry same sequence)
|
||||
|
||||
CONTROLS:
|
||||
- Click/tap on colored buttons to repeat sequence
|
||||
- Each button should have hover effect on desktop
|
||||
- Consider keyboard shortcuts (1,2,3,4 or Q,W,A,S for the four colors)
|
||||
- "Start" button to begin new game`
|
||||
}
|
||||
];
|
||||
|
||||
async function createGame(gameData, index) {
|
||||
console.log(`\n[${index + 1}/12] Generating: ${gameData.title}...`);
|
||||
|
||||
try {
|
||||
const result = await generateGame(gameData.prompt);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(` Failed to generate ${gameData.title}: ${result.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Insert into database with prompt
|
||||
const dbResult = await pool.query(
|
||||
`INSERT INTO games (creator_id, title, description, game_code, prompt, status, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'published', NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
ADMIN_USER_ID,
|
||||
gameData.title,
|
||||
gameData.prompt.split('\n\n')[0], // Use first paragraph as description
|
||||
result.code,
|
||||
gameData.prompt
|
||||
]
|
||||
);
|
||||
|
||||
console.log(` Created: ${gameData.title} (ID: ${dbResult.rows[0].id})`);
|
||||
return dbResult.rows[0].id;
|
||||
} catch (error) {
|
||||
console.error(` Error creating ${gameData.title}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Creating 12 classic games with detailed prompts...\n');
|
||||
console.log('Using Claude Sonnet 4 for high-quality game generation.');
|
||||
console.log('This will take several minutes...\n');
|
||||
|
||||
const createdGames = [];
|
||||
|
||||
for (let i = 0; i < classicGames.length; i++) {
|
||||
const gameId = await createGame(classicGames[i], i);
|
||||
if (gameId) {
|
||||
createdGames.push({ title: classicGames[i].title, id: gameId });
|
||||
}
|
||||
|
||||
// Small delay between API calls
|
||||
if (i < classicGames.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('SUMMARY');
|
||||
console.log('========================================');
|
||||
console.log(`Successfully created: ${createdGames.length}/12 games\n`);
|
||||
|
||||
createdGames.forEach(g => {
|
||||
console.log(` - ${g.title} (ID: ${g.id})`);
|
||||
});
|
||||
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,724 @@
|
||||
require('dotenv').config();
|
||||
const { pool } = require('../src/models/db');
|
||||
const { generateGame } = require('../src/utils/claude');
|
||||
|
||||
const games = [
|
||||
{
|
||||
id: 14,
|
||||
title: "Snake",
|
||||
prompt: `Classic Snake game - VERY EASY for 8-year-olds. Must be playable for 1+ minute before getting hard.
|
||||
|
||||
IMPORTANT - NO AUDIO TOGGLE BUTTONS:
|
||||
- Do NOT add any sound toggle buttons to the game
|
||||
- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
|
||||
- Just expose these functions, don't create UI for them
|
||||
|
||||
CRITICAL - CANVAS SETUP:
|
||||
\`\`\`javascript
|
||||
const canvas = document.createElement('canvas');
|
||||
document.body.appendChild(canvas);
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.background = '#111';
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
const ctx = canvas.getContext('2d');
|
||||
\`\`\`
|
||||
|
||||
GAME SPEED - VERY IMPORTANT:
|
||||
- Initial update interval: 280ms (VERY SLOW - easy for kids)
|
||||
- Only speed up after score >= 100 (about 10 apples)
|
||||
- Minimum interval: 150ms (never faster)
|
||||
- Speed formula: Math.max(150, 280 - Math.floor(score / 20) * 10)
|
||||
|
||||
GAME MECHANICS:
|
||||
- Grid: 15x15 cells (large cells, easy to see)
|
||||
- Snake starts with 3 segments in center, moving RIGHT
|
||||
- WRAP AROUND edges (no wall death - forgiving)
|
||||
- Game over ONLY on self-collision
|
||||
- Apple = +10 points
|
||||
|
||||
CONTROLS:
|
||||
- Arrow keys for desktop
|
||||
- Swipe detection (touchstart/touchend position difference)
|
||||
- Semi-transparent D-pad at bottom (70px buttons)
|
||||
|
||||
PAUSE SUPPORT:
|
||||
\`\`\`javascript
|
||||
let paused = false;
|
||||
window.pauseGame = () => { paused = true; };
|
||||
window.resumeGame = () => { paused = false; };
|
||||
// In game loop: if (paused) return;
|
||||
\`\`\`
|
||||
|
||||
NES-STYLE MUSIC - Multi-channel chiptune:
|
||||
\`\`\`javascript
|
||||
let audioCtx, musicEnabled = true, sfxEnabled = true;
|
||||
let musicPlaying = false;
|
||||
|
||||
function initAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
// Two-channel NES music: melody + bass
|
||||
const melody = [
|
||||
{note: 392, dur: 0.15}, {note: 440, dur: 0.15}, {note: 494, dur: 0.15}, {note: 523, dur: 0.3},
|
||||
{note: 494, dur: 0.15}, {note: 440, dur: 0.15}, {note: 392, dur: 0.3}, {note: 330, dur: 0.3}
|
||||
];
|
||||
const bass = [196, 196, 220, 220, 196, 196, 165, 165];
|
||||
let melodyIdx = 0, bassIdx = 0;
|
||||
|
||||
function playMelody() {
|
||||
if (!musicEnabled || !audioCtx || paused) return;
|
||||
const n = melody[melodyIdx];
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = n.note;
|
||||
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + n.dur);
|
||||
osc.connect(gain).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + n.dur);
|
||||
melodyIdx = (melodyIdx + 1) % melody.length;
|
||||
}
|
||||
|
||||
function playBass() {
|
||||
if (!musicEnabled || !audioCtx || paused) return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = bass[bassIdx];
|
||||
gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);
|
||||
osc.connect(gain).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.2);
|
||||
bassIdx = (bassIdx + 1) % bass.length;
|
||||
}
|
||||
|
||||
let melodyInt, bassInt;
|
||||
function startMusic() {
|
||||
if (musicPlaying) return;
|
||||
musicPlaying = true;
|
||||
melodyInt = setInterval(playMelody, 200);
|
||||
bassInt = setInterval(playBass, 400);
|
||||
}
|
||||
function stopMusic() {
|
||||
musicPlaying = false;
|
||||
clearInterval(melodyInt);
|
||||
clearInterval(bassInt);
|
||||
}
|
||||
|
||||
window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
|
||||
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
||||
\`\`\`
|
||||
|
||||
SOUND EFFECTS (when sfxEnabled):
|
||||
- Eat: 880Hz square, 50ms
|
||||
- Game over: 220Hz descending to 110Hz, 400ms
|
||||
|
||||
VISUALS:
|
||||
- Dark background (#111)
|
||||
- Bright green snake (#22c55e) with darker head (#16a34a)
|
||||
- Red apple (#ef4444)
|
||||
- White score at top center
|
||||
- D-pad: semi-transparent white arrows on dark circles`
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
title: "Space Invaders",
|
||||
prompt: `Space Invaders - VERY EASY for 8-year-olds. Must be playable for 1+ minute.
|
||||
|
||||
IMPORTANT - NO AUDIO TOGGLE BUTTONS:
|
||||
- Do NOT add any sound toggle buttons to the game
|
||||
- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
|
||||
- Just expose these functions, don't create UI for them
|
||||
|
||||
CRITICAL - CANVAS SETUP:
|
||||
\`\`\`javascript
|
||||
const canvas = document.createElement('canvas');
|
||||
document.body.appendChild(canvas);
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.background = '#000';
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
const ctx = canvas.getContext('2d');
|
||||
\`\`\`
|
||||
|
||||
ALIEN INITIALIZATION - CRITICAL (do this AFTER canvas is sized):
|
||||
\`\`\`javascript
|
||||
let aliens = [];
|
||||
let player = { x: 0, y: 0, width: 50, height: 30 };
|
||||
let gameStarted = false;
|
||||
|
||||
function initGame() {
|
||||
// Player at bottom
|
||||
player.x = canvas.width / 2;
|
||||
player.y = canvas.height - 70;
|
||||
|
||||
// Create aliens - 3 rows x 6 columns
|
||||
aliens = [];
|
||||
const startX = canvas.width * 0.2;
|
||||
const startY = 80;
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let col = 0; col < 6; col++) {
|
||||
aliens.push({
|
||||
x: startX + col * 60,
|
||||
y: startY + row * 50,
|
||||
alive: true,
|
||||
width: 40,
|
||||
height: 30
|
||||
});
|
||||
}
|
||||
}
|
||||
alienDir = 1;
|
||||
alienSpeed = 0.3; // VERY slow
|
||||
gameStarted = true;
|
||||
}
|
||||
|
||||
// Call initGame when first interaction happens
|
||||
canvas.addEventListener('click', () => { if (!gameStarted) { initAudio(); initGame(); } });
|
||||
canvas.addEventListener('touchstart', () => { if (!gameStarted) { initAudio(); initGame(); } });
|
||||
\`\`\`
|
||||
|
||||
ALIEN MOVEMENT - MUST move sideways, NOT fall:
|
||||
\`\`\`javascript
|
||||
let alienDir = 1;
|
||||
let alienSpeed = 0.3;
|
||||
|
||||
function updateAliens() {
|
||||
if (!gameStarted || paused) return;
|
||||
|
||||
let hitEdge = false;
|
||||
let minX = canvas.width, maxX = 0;
|
||||
|
||||
aliens.forEach(a => {
|
||||
if (!a.alive) return;
|
||||
a.x += alienDir * alienSpeed;
|
||||
minX = Math.min(minX, a.x);
|
||||
maxX = Math.max(maxX, a.x + a.width);
|
||||
});
|
||||
|
||||
if (minX < 20 || maxX > canvas.width - 20) {
|
||||
hitEdge = true;
|
||||
}
|
||||
|
||||
if (hitEdge) {
|
||||
alienDir *= -1;
|
||||
aliens.forEach(a => { if (a.alive) a.y += 20; });
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
MOBILE CONTROLS - Slide to move, tap to fire:
|
||||
\`\`\`javascript
|
||||
let touchStartX = 0;
|
||||
let touchMoved = false;
|
||||
let lastTouchX = 0;
|
||||
|
||||
canvas.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
if (!gameStarted) { initAudio(); initGame(); return; }
|
||||
touchStartX = e.touches[0].clientX;
|
||||
lastTouchX = touchStartX;
|
||||
touchMoved = false;
|
||||
});
|
||||
|
||||
canvas.addEventListener('touchmove', (e) => {
|
||||
e.preventDefault();
|
||||
const touchX = e.touches[0].clientX;
|
||||
const dx = touchX - lastTouchX;
|
||||
if (Math.abs(touchX - touchStartX) > 10) touchMoved = true;
|
||||
player.x = Math.max(25, Math.min(canvas.width - 25, player.x + dx));
|
||||
lastTouchX = touchX;
|
||||
});
|
||||
|
||||
canvas.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
if (!touchMoved && gameStarted) {
|
||||
fireBullet();
|
||||
}
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
DESKTOP: Arrow keys to move, Space to fire
|
||||
|
||||
PAUSE SUPPORT:
|
||||
\`\`\`javascript
|
||||
let paused = false;
|
||||
window.pauseGame = () => { paused = true; };
|
||||
window.resumeGame = () => { paused = false; };
|
||||
\`\`\`
|
||||
|
||||
DIFFICULTY:
|
||||
- Aliens shoot VERY rarely: Math.random() < 0.001 per frame per alien
|
||||
- Player has 5 lives
|
||||
- Bullets move slowly (4px/frame)
|
||||
- 10 points per alien
|
||||
|
||||
NES-STYLE MUSIC - Space march:
|
||||
\`\`\`javascript
|
||||
let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
|
||||
|
||||
function initAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
const marchNotes = [82, 98, 82, 98]; // E2, G2 pattern
|
||||
let marchIdx = 0, marchInt;
|
||||
|
||||
function playMarch() {
|
||||
if (!musicEnabled || !audioCtx || paused) return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = marchNotes[marchIdx];
|
||||
gain.gain.setValueAtTime(0.12, audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.08);
|
||||
osc.connect(gain).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.08);
|
||||
marchIdx = (marchIdx + 1) % marchNotes.length;
|
||||
|
||||
// Also play higher accent every 4 notes
|
||||
if (marchIdx === 0) {
|
||||
const osc2 = audioCtx.createOscillator();
|
||||
const gain2 = audioCtx.createGain();
|
||||
osc2.type = 'square';
|
||||
osc2.frequency.value = 165;
|
||||
gain2.gain.setValueAtTime(0.06, audioCtx.currentTime);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.05);
|
||||
osc2.connect(gain2).connect(audioCtx.destination);
|
||||
osc2.start();
|
||||
osc2.stop(audioCtx.currentTime + 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
if (musicPlaying) return;
|
||||
musicPlaying = true;
|
||||
marchInt = setInterval(playMarch, 500);
|
||||
}
|
||||
function stopMusic() {
|
||||
musicPlaying = false;
|
||||
clearInterval(marchInt);
|
||||
}
|
||||
|
||||
window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
|
||||
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
||||
\`\`\`
|
||||
|
||||
SOUND EFFECTS:
|
||||
- Fire: 1200Hz, 30ms square
|
||||
- Alien hit: noise burst 50ms
|
||||
- Player hit: 80Hz, 200ms
|
||||
|
||||
VISUALS:
|
||||
- Black background
|
||||
- Green triangular ship at bottom
|
||||
- Colorful aliens (red/yellow/cyan by row)
|
||||
- Show "TAP TO START" before game begins
|
||||
- Lives as small ships bottom-left
|
||||
- Score top-left`
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: "Flappy Bird",
|
||||
prompt: `Flappy Bird - VERY EASY for 8-year-olds. Playable for 1+ minute before hard.
|
||||
|
||||
IMPORTANT - NO AUDIO TOGGLE BUTTONS:
|
||||
- Do NOT add any sound toggle buttons to the game
|
||||
- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
|
||||
- Just expose these functions, don't create UI for them
|
||||
|
||||
CRITICAL - CANVAS SETUP:
|
||||
\`\`\`javascript
|
||||
const canvas = document.createElement('canvas');
|
||||
document.body.appendChild(canvas);
|
||||
document.body.style.margin = '0';
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
function resize() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
const ctx = canvas.getContext('2d');
|
||||
\`\`\`
|
||||
|
||||
PROGRESSIVE DIFFICULTY - VERY GRADUAL:
|
||||
\`\`\`javascript
|
||||
function getGapSize() {
|
||||
// Start HUGE (50% of screen), shrink SLOWLY
|
||||
const baseGap = canvas.height * 0.5;
|
||||
const minGap = canvas.height * 0.25;
|
||||
// Only shrink after 60 seconds worth of points (about 30 pipes)
|
||||
const reduction = Math.min(score, 30) * 5;
|
||||
return Math.max(minGap, baseGap - reduction);
|
||||
}
|
||||
|
||||
function getPipeSpeed() {
|
||||
// Start slow (2), max out at 4
|
||||
return 2 + Math.min(score * 0.05, 2);
|
||||
}
|
||||
|
||||
function getGravity() {
|
||||
return 0.35; // Gentle gravity throughout
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
GAME MECHANICS:
|
||||
- Bird at x = canvas.width * 0.3
|
||||
- Gravity: 0.35 per frame (gentle)
|
||||
- Flap: velocity = -7 (strong boost)
|
||||
- Pipes spawn every 180 frames
|
||||
- Gap position: random but never too close to top/bottom
|
||||
- Forgiving hitbox: 5px smaller than visual on each side
|
||||
|
||||
CONTROLS:
|
||||
\`\`\`javascript
|
||||
let gameOver = false, gameStarted = false;
|
||||
let bird = { x: 0, y: 0, vy: 0, radius: 18 };
|
||||
|
||||
function flap() {
|
||||
initAudio();
|
||||
if (gameOver) { resetGame(); return; }
|
||||
if (!gameStarted) { gameStarted = true; startMusic(); }
|
||||
bird.vy = -7;
|
||||
if (sfxEnabled) playFlap();
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', flap);
|
||||
canvas.addEventListener('touchstart', (e) => { e.preventDefault(); flap(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' || e.key === ' ') { e.preventDefault(); flap(); }
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
PAUSE SUPPORT:
|
||||
\`\`\`javascript
|
||||
let paused = false;
|
||||
window.pauseGame = () => { paused = true; stopMusic(); };
|
||||
window.resumeGame = () => { paused = false; if (gameStarted && !gameOver) startMusic(); };
|
||||
\`\`\`
|
||||
|
||||
NES-STYLE MUSIC - Happy chiptune:
|
||||
\`\`\`javascript
|
||||
let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
|
||||
|
||||
function initAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
// Happy melody in C major
|
||||
const melody = [
|
||||
{n: 523, d: 0.12}, {n: 587, d: 0.12}, {n: 659, d: 0.12}, {n: 784, d: 0.24},
|
||||
{n: 659, d: 0.12}, {n: 587, d: 0.12}, {n: 523, d: 0.24}, {n: 440, d: 0.12},
|
||||
{n: 523, d: 0.12}, {n: 587, d: 0.12}, {n: 523, d: 0.24}
|
||||
];
|
||||
const bass = [262, 262, 330, 330, 262, 262, 220, 220, 262, 262, 262];
|
||||
let mIdx = 0, melodyInt, bassInt;
|
||||
|
||||
function playMelodyNote() {
|
||||
if (!musicEnabled || !audioCtx || paused) return;
|
||||
const m = melody[mIdx];
|
||||
const osc = audioCtx.createOscillator();
|
||||
const g = audioCtx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = m.n;
|
||||
g.gain.setValueAtTime(0.08, audioCtx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + m.d * 0.9);
|
||||
osc.connect(g).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + m.d);
|
||||
mIdx = (mIdx + 1) % melody.length;
|
||||
}
|
||||
|
||||
function playBassNote() {
|
||||
if (!musicEnabled || !audioCtx || paused) return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const g = audioCtx.createGain();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = bass[mIdx % bass.length];
|
||||
g.gain.setValueAtTime(0.06, audioCtx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);
|
||||
osc.connect(g).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.15);
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
if (musicPlaying) return;
|
||||
musicPlaying = true;
|
||||
melodyInt = setInterval(playMelodyNote, 150);
|
||||
bassInt = setInterval(playBassNote, 300);
|
||||
}
|
||||
function stopMusic() {
|
||||
musicPlaying = false;
|
||||
clearInterval(melodyInt);
|
||||
clearInterval(bassInt);
|
||||
}
|
||||
|
||||
window.setMusicEnabled = (v) => { musicEnabled = v; if(gameStarted && !gameOver) v ? startMusic() : stopMusic(); };
|
||||
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
||||
|
||||
function playFlap() {
|
||||
if (!sfxEnabled || !audioCtx) return;
|
||||
// Quick noise whoosh
|
||||
const bufferSize = audioCtx.sampleRate * 0.03;
|
||||
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) data[i] = (Math.random() * 2 - 1) * (1 - i/bufferSize);
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const g = audioCtx.createGain();
|
||||
g.gain.value = 0.15;
|
||||
src.connect(g).connect(audioCtx.destination);
|
||||
src.start();
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
VISUALS:
|
||||
- Sky blue gradient (#87CEEB to #E0F4FF)
|
||||
- Yellow bird (circle) with orange beak, white eye
|
||||
- Green pipes (#22c55e) with darker caps (#16a34a)
|
||||
- Brown ground (#8B4513) at bottom, 40px tall
|
||||
- Large white score at top center
|
||||
- "Tap to Start" text before game
|
||||
- "Game Over - Tap to Retry" when dead`
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: "Minesweeper",
|
||||
prompt: `Minesweeper - EASY for 8-year-olds. DOM-based (not canvas).
|
||||
|
||||
IMPORTANT - NO AUDIO TOGGLE BUTTONS:
|
||||
- Do NOT add any sound toggle buttons to the game
|
||||
- Parent page controls audio via window.setMusicEnabled() and window.setSfxEnabled()
|
||||
- Just expose these functions, don't create UI for them
|
||||
|
||||
LAYOUT SETUP:
|
||||
\`\`\`javascript
|
||||
document.body.style.cssText = 'margin:0;background:#1e293b;min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:15px;box-sizing:border-box;font-family:system-ui,sans-serif;';
|
||||
|
||||
const GRID_SIZE = 8;
|
||||
const MINES = 8;
|
||||
const cellSize = Math.min(48, Math.floor((Math.min(window.innerWidth, window.innerHeight) - 60) / GRID_SIZE));
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;gap:15px;margin-bottom:12px;align-items:center;color:white;';
|
||||
document.body.appendChild(header);
|
||||
|
||||
// Mine counter
|
||||
const mineCount = document.createElement('span');
|
||||
mineCount.style.cssText = 'font-size:1.2rem;font-weight:bold;min-width:40px;';
|
||||
mineCount.textContent = '💣 ' + MINES;
|
||||
header.appendChild(mineCount);
|
||||
|
||||
// Reset button
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.style.cssText = 'font-size:1.5rem;padding:5px 15px;border:none;border-radius:8px;cursor:pointer;background:#fbbf24;';
|
||||
resetBtn.textContent = '😊';
|
||||
resetBtn.onclick = resetGame;
|
||||
header.appendChild(resetBtn);
|
||||
|
||||
// Timer
|
||||
const timerEl = document.createElement('span');
|
||||
timerEl.style.cssText = 'font-size:1.2rem;font-weight:bold;min-width:50px;';
|
||||
timerEl.textContent = '⏱ 0';
|
||||
header.appendChild(timerEl);
|
||||
|
||||
// Grid
|
||||
const grid = document.createElement('div');
|
||||
grid.style.cssText = \`display:grid;grid-template-columns:repeat(\${GRID_SIZE},\${cellSize}px);gap:3px;background:#334155;padding:10px;border-radius:10px;\`;
|
||||
document.body.appendChild(grid);
|
||||
\`\`\`
|
||||
|
||||
CELL CREATION:
|
||||
\`\`\`javascript
|
||||
const cells = [];
|
||||
const cellData = []; // {mine, revealed, flagged, adjacentMines}
|
||||
|
||||
function createCells() {
|
||||
grid.innerHTML = '';
|
||||
cells.length = 0;
|
||||
cellData.length = 0;
|
||||
|
||||
for (let i = 0; i < GRID_SIZE * GRID_SIZE; i++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.style.cssText = \`
|
||||
width:\${cellSize}px;height:\${cellSize}px;
|
||||
background:#64748b;border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
font-size:\${cellSize * 0.55}px;font-weight:bold;
|
||||
cursor:pointer;user-select:none;
|
||||
transition:background 0.1s;
|
||||
\`;
|
||||
|
||||
cellData.push({ mine: false, revealed: false, flagged: false, adjacentMines: 0 });
|
||||
|
||||
// MOBILE LONG PRESS FOR FLAG
|
||||
let pressTimer = null;
|
||||
let longPressed = false;
|
||||
|
||||
cell.addEventListener('touchstart', (e) => {
|
||||
e.preventDefault();
|
||||
longPressed = false;
|
||||
cell.style.background = '#475569';
|
||||
pressTimer = setTimeout(() => {
|
||||
longPressed = true;
|
||||
toggleFlag(i);
|
||||
cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
|
||||
}, 400);
|
||||
});
|
||||
|
||||
cell.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
clearTimeout(pressTimer);
|
||||
cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
|
||||
if (!longPressed && !gameOver) {
|
||||
revealCell(i);
|
||||
}
|
||||
});
|
||||
|
||||
cell.addEventListener('touchmove', () => {
|
||||
clearTimeout(pressTimer);
|
||||
cell.style.background = cellData[i].revealed ? '#e2e8f0' : '#64748b';
|
||||
});
|
||||
|
||||
// Desktop
|
||||
cell.addEventListener('click', () => revealCell(i));
|
||||
cell.addEventListener('contextmenu', (e) => { e.preventDefault(); toggleFlag(i); });
|
||||
|
||||
cells.push(cell);
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
GAME LOGIC:
|
||||
- First click + 8 neighbors always safe (place mines after)
|
||||
- Flood fill for empty cells
|
||||
- Numbers colored: 1=blue, 2=green, 3=red, 4=purple, 5=maroon, 6=teal
|
||||
|
||||
PAUSE SUPPORT:
|
||||
\`\`\`javascript
|
||||
let paused = false;
|
||||
window.pauseGame = () => { paused = true; stopMusic(); };
|
||||
window.resumeGame = () => { paused = false; startMusic(); };
|
||||
\`\`\`
|
||||
|
||||
NES-STYLE MUSIC - Calm puzzle:
|
||||
\`\`\`javascript
|
||||
let audioCtx, musicEnabled = true, sfxEnabled = true, musicPlaying = false;
|
||||
|
||||
function initAudio() {
|
||||
if (audioCtx) return;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
|
||||
// Calm arpeggio pattern
|
||||
const notes = [262, 330, 392, 330, 262, 294, 349, 294];
|
||||
let noteIdx = 0, musicInt;
|
||||
|
||||
function playNote() {
|
||||
if (!musicEnabled || !audioCtx || paused || gameOver) return;
|
||||
const osc = audioCtx.createOscillator();
|
||||
const g = audioCtx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = notes[noteIdx];
|
||||
g.gain.setValueAtTime(0.07, audioCtx.currentTime);
|
||||
g.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.25);
|
||||
osc.connect(g).connect(audioCtx.destination);
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.25);
|
||||
noteIdx = (noteIdx + 1) % notes.length;
|
||||
}
|
||||
|
||||
function startMusic() {
|
||||
if (musicPlaying) return;
|
||||
musicPlaying = true;
|
||||
musicInt = setInterval(playNote, 350);
|
||||
}
|
||||
function stopMusic() {
|
||||
musicPlaying = false;
|
||||
clearInterval(musicInt);
|
||||
}
|
||||
|
||||
window.setMusicEnabled = (v) => { musicEnabled = v; v ? startMusic() : stopMusic(); };
|
||||
window.setSfxEnabled = (v) => { sfxEnabled = v; };
|
||||
\`\`\`
|
||||
|
||||
SOUND EFFECTS:
|
||||
- Reveal: 600Hz click, 20ms
|
||||
- Flag: 300Hz, 40ms
|
||||
- Mine: noise explosion 200ms
|
||||
- Win: ascending C-E-G-C arpeggio
|
||||
|
||||
VISUALS:
|
||||
- Dark slate background
|
||||
- Gray unrevealed (#64748b)
|
||||
- Light revealed (#e2e8f0)
|
||||
- 🚩 for flags, 💣 for mines
|
||||
- 😊 normal, 😎 win, 😵 lose for reset button`
|
||||
}
|
||||
];
|
||||
|
||||
async function regenerateGame(g) {
|
||||
console.log(`\nRegenerating: ${g.title}...`);
|
||||
try {
|
||||
const result = await generateGame(g.prompt);
|
||||
if (!result.success) {
|
||||
console.log(` FAILED: ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
await pool.query('UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3', [result.code, g.prompt, g.id]);
|
||||
console.log(` SUCCESS`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.log(` ERROR: ${e.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(50));
|
||||
console.log('REGENERATING 4 GAMES - v3');
|
||||
console.log('Slow snake, fixed space invaders, better music');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
let ok = 0;
|
||||
for (const g of games) {
|
||||
if (await regenerateGame(g)) ok++;
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`COMPLETE: ${ok}/4 successful`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
await pool.end();
|
||||
process.exit(ok === 4 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
require('dotenv').config();
|
||||
const { pool } = require('../src/models/db');
|
||||
const { generateGame } = require('../src/utils/claude');
|
||||
|
||||
// Games that need fixing (Pong ID:16 and Minesweeper ID:22 work well)
|
||||
const gamesToFix = [
|
||||
{
|
||||
id: 14,
|
||||
title: "Snake",
|
||||
prompt: `Create a classic Snake game. The snake starts in the center moving right.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Snake moves continuously in current direction
|
||||
- Game starts immediately when page loads
|
||||
- Use requestAnimationFrame for smooth animation
|
||||
|
||||
CONTROLS (implement ALL):
|
||||
- Desktop: Arrow keys change direction (prevent 180° turns)
|
||||
- Mobile: Touch/swipe detection - swipe in any direction to turn the snake
|
||||
- Add visible on-screen arrow buttons for mobile (semi-transparent, positioned at bottom)
|
||||
|
||||
GAMEPLAY:
|
||||
- Snake is green segments on a dark grid background
|
||||
- Red apple appears randomly, eating it grows snake by 1 and adds 10 points
|
||||
- Game over when hitting walls or self
|
||||
- Show score in top-left corner
|
||||
- Game over screen shows "Game Over! Score: X" with "Tap to Restart" button
|
||||
- Speed increases slightly every 50 points
|
||||
|
||||
MOBILE ON-SCREEN CONTROLS:
|
||||
Add a d-pad style control at the bottom of the screen:
|
||||
- Four arrow buttons (up, down, left, right) arranged in a cross pattern
|
||||
- Buttons should be 50px, semi-transparent white with dark arrows
|
||||
- Position fixed at bottom center of game area
|
||||
|
||||
Keep game area square and centered. Use canvas for rendering.`
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
title: "Tetris",
|
||||
prompt: `Create a Tetris falling block puzzle game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Pieces fall automatically at steady pace
|
||||
- Game starts immediately when page loads
|
||||
- Use requestAnimationFrame for game loop
|
||||
|
||||
CONTROLS (implement ALL):
|
||||
- Desktop: Left/Right arrows move piece, Up arrow rotates, Down arrow soft drop, Space hard drop
|
||||
- Mobile: Add visible touch buttons at bottom of screen
|
||||
|
||||
MOBILE CONTROLS (REQUIRED):
|
||||
Create a control panel at bottom with these buttons:
|
||||
- Left arrow button (move left)
|
||||
- Right arrow button (move right)
|
||||
- Rotate button (clockwise rotation)
|
||||
- Down button (soft drop)
|
||||
- Drop button (hard drop)
|
||||
Buttons: 45px size, semi-transparent, arranged in a row
|
||||
|
||||
GAMEPLAY:
|
||||
- 10 columns, 20 rows playing field
|
||||
- 7 tetromino shapes (I, O, T, S, Z, J, L) with different colors
|
||||
- Completed lines clear with flash effect
|
||||
- Score: 1 line=100, 2=300, 3=500, 4=800 points
|
||||
- Show next piece preview on the side
|
||||
- Game over when pieces reach top
|
||||
- Speed increases every level (10 lines = 1 level)
|
||||
|
||||
Use canvas rendering. Game area should be centered with controls below.`
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
title: "Breakout",
|
||||
prompt: `Create a Breakout/Brick Breaker arcade game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Ball bounces continuously once launched
|
||||
- Paddle follows mouse/touch position smoothly
|
||||
- Game starts with ball on paddle, click/tap to launch
|
||||
|
||||
CONTROLS:
|
||||
- Desktop: Mouse movement controls paddle position, click to launch ball
|
||||
- Mobile: Touch and drag anywhere to move paddle, tap to launch ball
|
||||
- Paddle should follow finger/mouse X position instantly
|
||||
|
||||
GAMEPLAY:
|
||||
- 8 rows of colored bricks at top (rainbow colors)
|
||||
- White paddle at bottom, ball is white circle
|
||||
- Ball bounces off walls, paddle, and bricks
|
||||
- Each brick hit = 10 points, brick disappears
|
||||
- Ball angle changes based on where it hits paddle (edges = sharper angle)
|
||||
- Lose a life if ball falls below paddle (3 lives total)
|
||||
- Win when all bricks destroyed
|
||||
- Show score and lives at top
|
||||
|
||||
VISUAL:
|
||||
- Black background
|
||||
- Colorful bricks in rows (red, orange, yellow, green, blue, purple)
|
||||
- Paddle is white rectangle, rounded corners
|
||||
- Ball leaves a short trail effect
|
||||
- Bricks flash when hit before disappearing
|
||||
|
||||
Use canvas. No on-screen buttons needed - paddle follows touch/mouse directly.`
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
title: "Space Invaders",
|
||||
prompt: `Create a Space Invaders shooting game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Aliens move and player can shoot immediately
|
||||
- Smooth 60fps animation with requestAnimationFrame
|
||||
|
||||
CONTROLS (implement ALL):
|
||||
- Desktop: Left/Right arrows move ship, Space to shoot
|
||||
- Mobile: Touch left half of screen to move left, right half to move right
|
||||
- Mobile: Tap center area or double-tap anywhere to shoot
|
||||
- Add visible Left/Right buttons at bottom corners and Fire button in center
|
||||
|
||||
MOBILE CONTROLS (REQUIRED):
|
||||
- Left button: bottom-left corner, 60px, arrow pointing left
|
||||
- Right button: bottom-right corner, 60px, arrow pointing right
|
||||
- Fire button: bottom-center, 70px, red circle with "FIRE" text
|
||||
All buttons semi-transparent
|
||||
|
||||
GAMEPLAY:
|
||||
- 5 rows of 8 aliens that move side-to-side and drop down
|
||||
- Player ship at bottom can move and shoot upward
|
||||
- One bullet on screen at a time
|
||||
- Aliens occasionally drop bombs
|
||||
- 3 lives, hit by bomb = lose life
|
||||
- Bottom row aliens = 10pts, increasing 10pts per row up
|
||||
- Aliens speed up as fewer remain
|
||||
- Wave complete when all aliens destroyed
|
||||
|
||||
VISUAL:
|
||||
- Black space background with twinkling stars
|
||||
- Green player ship (triangle)
|
||||
- Aliens are simple pixel-art style (different shapes per row)
|
||||
- White bullets, red alien bombs
|
||||
|
||||
Use canvas rendering.`
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
title: "Pac-Man",
|
||||
prompt: `Create a simplified Pac-Man maze game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Pac-Man moves continuously in set direction
|
||||
- Simple maze (not full Pac-Man complexity)
|
||||
- Smooth animation at 60fps
|
||||
|
||||
CONTROLS (implement ALL):
|
||||
- Desktop: Arrow keys set movement direction
|
||||
- Mobile: Swipe in direction to change Pac-Man's direction
|
||||
- Add on-screen d-pad for mobile (arrow buttons in cross pattern)
|
||||
|
||||
MOBILE CONTROLS (REQUIRED):
|
||||
D-pad at bottom center:
|
||||
- Up, Down, Left, Right buttons arranged in cross
|
||||
- Each button 50px, semi-transparent
|
||||
- Pac-Man turns at next opportunity when button pressed
|
||||
|
||||
GAMEPLAY:
|
||||
- Simple maze with blue walls (use a basic grid maze, 15x15)
|
||||
- Yellow dots throughout maze (10 points each)
|
||||
- 4 large power pellets in corners (50 points, makes ghosts vulnerable)
|
||||
- 4 colored ghosts that chase Pac-Man
|
||||
- When powered up (8 seconds), ghosts turn blue and can be eaten (200 pts)
|
||||
- Lose life if touched by non-blue ghost (3 lives)
|
||||
- Level complete when all dots eaten
|
||||
|
||||
VISUAL:
|
||||
- Black background, blue maze walls
|
||||
- Yellow Pac-Man with chomping animation
|
||||
- Ghosts: Red, Pink, Cyan, Orange with googly eyes
|
||||
- Dots are small white circles, power pellets are large and flashing
|
||||
|
||||
Use canvas. Keep maze simple for playability.`
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: "Flappy Bird",
|
||||
prompt: `Create a Flappy Bird style game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Bird flaps on ANY input (tap, click, spacebar, any key)
|
||||
- Gravity pulls bird down constantly
|
||||
- Game starts with "Tap to Start" message
|
||||
|
||||
CONTROLS (SIMPLE):
|
||||
- ANY tap, click, touch, or key press = bird flaps upward
|
||||
- That's the only control needed
|
||||
- Make sure touchstart event is captured on the entire game canvas
|
||||
|
||||
GAMEPLAY:
|
||||
- Bird on left side of screen, pipes scroll from right
|
||||
- Pipes have gap in middle to fly through
|
||||
- Each pipe passed = 1 point
|
||||
- Game over if bird hits pipe, ground, or ceiling
|
||||
- Bird rotates based on velocity (nose up when rising, down when falling)
|
||||
- After game over, show score and "Tap to Restart"
|
||||
|
||||
PHYSICS:
|
||||
- Gravity: bird falls at accelerating rate
|
||||
- Flap: gives instant upward velocity
|
||||
- Bird should feel "floaty" but responsive
|
||||
- Pipe gap should be generous (bird height * 4)
|
||||
|
||||
VISUAL:
|
||||
- Light blue sky background with simple clouds
|
||||
- Green pipes from top and bottom
|
||||
- Yellow/orange bird with wing flap animation
|
||||
- Ground at bottom with scrolling grass texture
|
||||
- Large score number at top center
|
||||
|
||||
Use canvas. Entire canvas should respond to touch events.`
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
title: "2048",
|
||||
prompt: `Create the 2048 sliding puzzle game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Tiles slide and merge correctly
|
||||
- Touch swipe detection must work reliably
|
||||
- Game should feel smooth and responsive
|
||||
|
||||
CONTROLS:
|
||||
- Desktop: Arrow keys slide all tiles in that direction
|
||||
- Mobile: Swipe gestures (detect swipe direction on touchend)
|
||||
- Swipe detection: track touchstart and touchend positions, determine direction
|
||||
|
||||
SWIPE IMPLEMENTATION:
|
||||
- On touchstart: record start X,Y
|
||||
- On touchend: calculate delta X,Y
|
||||
- If |deltaX| > |deltaY| and |deltaX| > 30px: horizontal swipe
|
||||
- If |deltaY| > |deltaX| and |deltaY| > 30px: vertical swipe
|
||||
- Determine direction based on positive/negative delta
|
||||
|
||||
GAMEPLAY:
|
||||
- 4x4 grid of tiles
|
||||
- Start with two "2" tiles in random positions
|
||||
- Sliding merges matching adjacent tiles (2+2=4, 4+4=8, etc)
|
||||
- After each move, new tile (90% chance of 2, 10% chance of 4) appears
|
||||
- Score increases by merged tile value
|
||||
- Game over when no moves possible
|
||||
- Win message at 2048 but allow continuing
|
||||
|
||||
VISUAL:
|
||||
- Clean, modern design with rounded tiles
|
||||
- Each number has distinct background color:
|
||||
- 2: #eee4da, 4: #ede0c8, 8: #f2b179, 16: #f59563
|
||||
- 32: #f67c5f, 64: #f65e3b, 128+: #edcf72 to #edc22e
|
||||
- Tiles animate when sliding and merging (CSS transitions)
|
||||
- Large, bold numbers on tiles
|
||||
- Score display at top
|
||||
|
||||
Use div-based grid with CSS transitions for smooth animations.`
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
title: "Asteroids",
|
||||
prompt: `Create a classic Asteroids space shooter.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Ship rotates and thrusts with momentum-based physics
|
||||
- Screen wrapping (go off one edge, appear on opposite)
|
||||
- Smooth 60fps gameplay
|
||||
|
||||
CONTROLS (implement ALL):
|
||||
- Desktop: Left/Right rotate ship, Up thrusts forward, Space shoots
|
||||
- Mobile: On-screen virtual joystick or buttons
|
||||
|
||||
MOBILE CONTROLS (REQUIRED):
|
||||
Left side: Rotation buttons (two buttons: rotate left, rotate right)
|
||||
Right side: Action buttons (Thrust button, Fire button)
|
||||
All buttons 50-60px, semi-transparent, positioned at bottom
|
||||
|
||||
GAMEPLAY:
|
||||
- Ship starts in center, 3 lives
|
||||
- Large asteroids split into 2 medium, medium into 2 small
|
||||
- Points: Large=20, Medium=50, Small=100
|
||||
- Ship has inertia - keeps drifting after thrust stops
|
||||
- Bullets travel straight and wrap around screen
|
||||
- Collision with asteroid = lose life, brief invincibility after respawn
|
||||
- Clear all asteroids = next wave with more asteroids
|
||||
|
||||
PHYSICS:
|
||||
- Ship rotation is instant
|
||||
- Thrust adds velocity in facing direction
|
||||
- Small friction to eventually slow down
|
||||
- Asteroids drift at random angles and speeds
|
||||
|
||||
VISUAL:
|
||||
- Black background with small white stars
|
||||
- Ship is white triangle outline (vector style)
|
||||
- Asteroids are irregular polygon outlines
|
||||
- Bullets are small white dots
|
||||
- Explosion particles when things are destroyed
|
||||
|
||||
Use canvas with vector-style graphics (lines, not filled shapes).`
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
title: "Frogger",
|
||||
prompt: `Create a Frogger road-crossing game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Grid-based hop movement (one square per input)
|
||||
- Cars and logs move continuously
|
||||
- Simple but playable level design
|
||||
|
||||
CONTROLS:
|
||||
- Desktop: Arrow keys to hop in that direction
|
||||
- Mobile: On-screen d-pad buttons OR swipe to hop
|
||||
|
||||
MOBILE CONTROLS (REQUIRED):
|
||||
D-pad at bottom:
|
||||
- Up/Down/Left/Right buttons in cross pattern
|
||||
- Each button 55px, semi-transparent
|
||||
- Each tap = one hop in that direction
|
||||
|
||||
GAMEPLAY:
|
||||
- Frog starts at bottom, goal is to reach top
|
||||
- Bottom half: Road with cars/trucks moving left and right
|
||||
- Top half: River with logs and turtles moving left and right
|
||||
- Hit by car = lose life
|
||||
- Fall in water (miss a log) = lose life
|
||||
- Ride logs/turtles to cross river
|
||||
- 5 home spots at top to fill
|
||||
- Fill all 5 = level complete
|
||||
- 3 lives, timer countdown (30 seconds per frog)
|
||||
|
||||
LEVEL DESIGN:
|
||||
- Row 1 (bottom): Safe starting grass
|
||||
- Rows 2-4: Roads with vehicles (alternate directions)
|
||||
- Row 5: Safe middle grass
|
||||
- Rows 6-8: River with logs/turtles
|
||||
- Row 9 (top): Home bases with lily pads
|
||||
|
||||
VISUAL:
|
||||
- Green frog (top-down view)
|
||||
- Gray roads with lane markings
|
||||
- Blue water
|
||||
- Brown logs, green turtles
|
||||
- Colorful cars/trucks
|
||||
- Green grass safe zones
|
||||
|
||||
Use canvas, grid-based movement.`
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
title: "Simon Says",
|
||||
prompt: `Create a Simon Says memory pattern game.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
- Game MUST work on both desktop and mobile
|
||||
- Buttons must be large and easy to tap on mobile
|
||||
- Clear visual and audio-like feedback when buttons activate
|
||||
- Pattern plays automatically, then waits for player input
|
||||
|
||||
CONTROLS:
|
||||
- Desktop: Click the colored buttons OR use keys 1,2,3,4
|
||||
- Mobile: Tap the colored buttons (make them big!)
|
||||
|
||||
GAMEPLAY:
|
||||
- Four large colored buttons (Red, Blue, Green, Yellow)
|
||||
- Arranged in 2x2 grid or quadrants of a circle
|
||||
- Computer shows pattern by lighting up buttons in sequence
|
||||
- Player must repeat the pattern by pressing buttons
|
||||
- Each round adds one more to the sequence
|
||||
- Wrong button = game over, show final score (rounds completed)
|
||||
- Display current round number
|
||||
|
||||
BUTTON BEHAVIOR:
|
||||
- When showing pattern: button lights up brightly for 0.5s, 0.3s gap between
|
||||
- When player presses: button lights up while pressed
|
||||
- Each button should have distinct appearance when lit vs unlit
|
||||
- Consider adding different tones/sounds for each button (optional)
|
||||
|
||||
VISUAL:
|
||||
- Large, touchable buttons (at least 120px each on mobile)
|
||||
- Distinct colors: Red (#ff4444), Blue (#4444ff), Green (#44ff44), Yellow (#ffff44)
|
||||
- Buttons dim when inactive, bright when active
|
||||
- Center area shows round number and "Watch..." or "Your turn!" message
|
||||
- Dark background to make colors pop
|
||||
- "Start Game" button initially, "Play Again" after game over
|
||||
|
||||
LAYOUT:
|
||||
- Buttons should fill most of the screen
|
||||
- 2x2 grid with small gaps
|
||||
- Score/round display above or below buttons
|
||||
- Make sure buttons are finger-friendly size
|
||||
|
||||
Use div-based buttons with CSS for the glow effects. No canvas needed.`
|
||||
}
|
||||
];
|
||||
|
||||
async function fixGame(gameData) {
|
||||
console.log(`\nRegenerating: ${gameData.title} (ID: ${gameData.id})...`);
|
||||
|
||||
try {
|
||||
const result = await generateGame(gameData.prompt);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(` Failed: ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the game in database
|
||||
await pool.query(
|
||||
`UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3`,
|
||||
[result.code, gameData.prompt, gameData.id]
|
||||
);
|
||||
|
||||
console.log(` Success: ${gameData.title} updated`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` Error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Fixing games with improved controls...');
|
||||
console.log(`Games to fix: ${gamesToFix.length}`);
|
||||
console.log('Using Claude Sonnet 4 for generation.\n');
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
for (const game of gamesToFix) {
|
||||
const success = await fixGame(game);
|
||||
if (success) successCount++;
|
||||
|
||||
// Delay between API calls
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log(`COMPLETE: ${successCount}/${gamesToFix.length} games fixed`);
|
||||
console.log('========================================');
|
||||
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
require('dotenv').config();
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
});
|
||||
|
||||
const snakeCode = `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Snake</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:100vh;font-family:Arial,sans-serif}#score{color:#4CAF50;font-size:24px;margin-bottom:10px}canvas{background:#16213e;border-radius:10px;touch-action:none}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:#fff;padding:30px;border-radius:10px;text-align:center;display:none;z-index:100}#gameOver button{margin-top:15px;padding:10px 30px;font-size:18px;background:#4CAF50;color:#fff;border:none;border-radius:5px;cursor:pointer}#controls{margin-top:10px;color:rgba(255,255,255,0.6);font-size:12px}#startBtn{margin-top:15px;padding:15px 40px;font-size:20px;background:#4CAF50;color:#fff;border:none;border-radius:10px;cursor:pointer;display:none}</style></head>
|
||||
<body><div id="score">Score: 0</div><canvas id="game"></canvas><button id="startBtn">TAP TO START</button><div id="controls">Arrow keys or swipe to move</div><div id="gameOver"><h2>Game Over!</h2><p>Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>
|
||||
const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),scoreEl=document.getElementById('score'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore'),startBtn=document.getElementById('startBtn');
|
||||
let size=Math.min(400,window.innerWidth-20),gridSize=20,tileCount=size/gridSize;
|
||||
canvas.width=canvas.height=size;
|
||||
let snake=[{x:10,y:10}],food={x:15,y:15},dx=0,dy=0,score=0,gameActive=false,speed=120,lastMove=0;
|
||||
window.gameScore=0;
|
||||
|
||||
function placeFood(){
|
||||
food.x=Math.floor(Math.random()*tileCount);
|
||||
food.y=Math.floor(Math.random()*tileCount);
|
||||
// Make sure food doesn't spawn on snake
|
||||
for(let seg of snake){
|
||||
if(seg.x===food.x&&seg.y===food.y){
|
||||
placeFood();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function draw(){
|
||||
ctx.fillStyle='#16213e';
|
||||
ctx.fillRect(0,0,size,size);
|
||||
// Draw grid
|
||||
ctx.strokeStyle='#1e2a4a';
|
||||
for(let i=0;i<tileCount;i++){
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i*gridSize,0);
|
||||
ctx.lineTo(i*gridSize,size);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0,i*gridSize);
|
||||
ctx.lineTo(size,i*gridSize);
|
||||
ctx.stroke();
|
||||
}
|
||||
// Draw snake
|
||||
snake.forEach((seg,i)=>{
|
||||
ctx.fillStyle=i===0?'#8BC34A':'#4CAF50';
|
||||
ctx.fillRect(seg.x*gridSize+1,seg.y*gridSize+1,gridSize-2,gridSize-2);
|
||||
if(i===0){
|
||||
// Draw eyes
|
||||
ctx.fillStyle='#fff';
|
||||
ctx.fillRect(seg.x*gridSize+5,seg.y*gridSize+5,4,4);
|
||||
ctx.fillRect(seg.x*gridSize+gridSize-9,seg.y*gridSize+5,4,4);
|
||||
}
|
||||
});
|
||||
// Draw food
|
||||
ctx.fillStyle='#e74c3c';
|
||||
ctx.beginPath();
|
||||
ctx.arc(food.x*gridSize+gridSize/2,food.y*gridSize+gridSize/2,gridSize/2-2,0,Math.PI*2);
|
||||
ctx.fill();
|
||||
// Draw start message if not started
|
||||
if(!gameActive&&dx===0&&dy===0){
|
||||
ctx.fillStyle='rgba(0,0,0,0.7)';
|
||||
ctx.fillRect(0,size/2-40,size,80);
|
||||
ctx.fillStyle='#fff';
|
||||
ctx.font='20px Arial';
|
||||
ctx.textAlign='center';
|
||||
ctx.fillText('Press arrow key or swipe to start!',size/2,size/2+7);
|
||||
}
|
||||
}
|
||||
|
||||
function update(timestamp){
|
||||
if(!gameActive){
|
||||
draw();
|
||||
requestAnimationFrame(update);
|
||||
return;
|
||||
}
|
||||
if(timestamp-lastMove<speed){
|
||||
requestAnimationFrame(update);
|
||||
return;
|
||||
}
|
||||
lastMove=timestamp;
|
||||
if(dx===0&&dy===0){
|
||||
draw();
|
||||
requestAnimationFrame(update);
|
||||
return;
|
||||
}
|
||||
const head={x:snake[0].x+dx,y:snake[0].y+dy};
|
||||
// Wall collision
|
||||
if(head.x<0||head.x>=tileCount||head.y<0||head.y>=tileCount){
|
||||
endGame();
|
||||
return;
|
||||
}
|
||||
// Self collision
|
||||
for(let seg of snake){
|
||||
if(seg.x===head.x&&seg.y===head.y){
|
||||
endGame();
|
||||
return;
|
||||
}
|
||||
}
|
||||
snake.unshift(head);
|
||||
// Food collision
|
||||
if(head.x===food.x&&head.y===food.y){
|
||||
score+=10;
|
||||
window.gameScore=score;
|
||||
scoreEl.textContent='Score: '+score;
|
||||
placeFood();
|
||||
speed=Math.max(60,speed-3);
|
||||
}else{
|
||||
snake.pop();
|
||||
}
|
||||
draw();
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
function endGame(){
|
||||
gameActive=false;
|
||||
finalScoreEl.textContent=score;
|
||||
gameOverEl.style.display='block';
|
||||
}
|
||||
|
||||
function restart(){
|
||||
snake=[{x:10,y:10}];
|
||||
placeFood();
|
||||
dx=dy=0;
|
||||
score=0;
|
||||
speed=120;
|
||||
window.gameScore=0;
|
||||
scoreEl.textContent='Score: 0';
|
||||
gameOverEl.style.display='none';
|
||||
gameActive=true;
|
||||
}
|
||||
|
||||
function setDirection(newDx,newDy){
|
||||
// Prevent reversing
|
||||
if(snake.length>1){
|
||||
if(newDx===-dx&&dx!==0)return;
|
||||
if(newDy===-dy&&dy!==0)return;
|
||||
}
|
||||
dx=newDx;
|
||||
dy=newDy;
|
||||
if(!gameActive){
|
||||
gameActive=true;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown',e=>{
|
||||
e.preventDefault();
|
||||
if(e.key==='ArrowUp')setDirection(0,-1);
|
||||
if(e.key==='ArrowDown')setDirection(0,1);
|
||||
if(e.key==='ArrowLeft')setDirection(-1,0);
|
||||
if(e.key==='ArrowRight')setDirection(1,0);
|
||||
});
|
||||
|
||||
let touchStartX=0,touchStartY=0;
|
||||
canvas.addEventListener('touchstart',e=>{
|
||||
e.preventDefault();
|
||||
touchStartX=e.touches[0].clientX;
|
||||
touchStartY=e.touches[0].clientY;
|
||||
},{passive:false});
|
||||
|
||||
canvas.addEventListener('touchend',e=>{
|
||||
e.preventDefault();
|
||||
const diffX=e.changedTouches[0].clientX-touchStartX;
|
||||
const diffY=e.changedTouches[0].clientY-touchStartY;
|
||||
if(Math.abs(diffX)>Math.abs(diffY)){
|
||||
if(diffX>30)setDirection(1,0);
|
||||
else if(diffX<-30)setDirection(-1,0);
|
||||
}else{
|
||||
if(diffY>30)setDirection(0,1);
|
||||
else if(diffY<-30)setDirection(0,-1);
|
||||
}
|
||||
},{passive:false});
|
||||
|
||||
// Initial draw
|
||||
draw();
|
||||
requestAnimationFrame(update);
|
||||
</script></body></html>`;
|
||||
|
||||
async function fix() {
|
||||
try {
|
||||
await pool.query(
|
||||
"UPDATE games SET game_code = $1 WHERE title = 'Snake Classic'",
|
||||
[snakeCode]
|
||||
);
|
||||
console.log('Snake game fixed!');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
fix().catch(console.error);
|
||||
Executable
+333
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix touch handling in all existing games
|
||||
*
|
||||
* Problem: Games restart on every touch during gameplay because the canvas
|
||||
* has a touchstart/click listener that calls startGame() unconditionally.
|
||||
*
|
||||
* Solution: Modify touch/click handlers to only restart when gameState === 'gameOver'
|
||||
*
|
||||
* Usage:
|
||||
* node fix-touch-handling.js # Fix all games
|
||||
* node fix-touch-handling.js --dry-run # Preview changes without saving
|
||||
* node fix-touch-handling.js --game 14 # Fix specific game by ID
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'game_arcade',
|
||||
user: process.env.DB_USER || 'gamearc',
|
||||
password: process.env.DB_PASSWORD
|
||||
});
|
||||
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
timeout: 120 * 1000 // 120 seconds for larger games
|
||||
});
|
||||
|
||||
const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
|
||||
|
||||
const FIX_PROMPT = `You are fixing touch handling in an HTML5 canvas game for mobile. The issues are:
|
||||
|
||||
**Problems**:
|
||||
1. Game may restart on every touch during gameplay (unplayable on mobile)
|
||||
2. After game over, tapping immediately starts AND plays (no pause to see score)
|
||||
3. Both touchstart and click events may fire (double-tap issue)
|
||||
|
||||
**Required Changes**:
|
||||
|
||||
1. **Use a 3-state system**: 'ready' → 'playing' → 'gameOver' → 'ready'
|
||||
- 'ready': Shows "Tap to Start", waits for tap
|
||||
- 'playing': Game is active, taps control the game
|
||||
- 'gameOver': Shows score + "Tap to Play Again", tap goes to 'ready' (NOT directly to playing)
|
||||
|
||||
2. **Fix touch handler to prevent double-fire**:
|
||||
\`\`\`javascript
|
||||
let lastTapTime = 0;
|
||||
function handleTap(e) {
|
||||
if (e.type === 'touchstart') e.preventDefault();
|
||||
const now = Date.now();
|
||||
if (now - lastTapTime < 100) return; // Debounce
|
||||
lastTapTime = now;
|
||||
// ... rest of tap logic
|
||||
}
|
||||
canvas.addEventListener('touchstart', handleTap, { passive: false });
|
||||
canvas.addEventListener('click', handleTap);
|
||||
\`\`\`
|
||||
|
||||
3. **State transitions on tap**:
|
||||
- If gameState === 'ready': set gameState = 'playing', start game
|
||||
- If gameState === 'playing': perform game action (flap, jump, etc.)
|
||||
- If gameState === 'gameOver': set gameState = 'ready', reset game (but DON'T start yet)
|
||||
|
||||
4. **Update draw function** to show appropriate messages:
|
||||
- ready: "Tap to Start!"
|
||||
- gameOver: "Game Over! Score: X" + "Tap to Continue"
|
||||
|
||||
**IMPORTANT**:
|
||||
- Keep all existing game mechanics intact
|
||||
- Keep all existing visual styles
|
||||
- Only change the state management and tap handling
|
||||
- Return the complete fixed HTML code
|
||||
|
||||
Return ONLY the complete HTML code, no explanations.`;
|
||||
|
||||
async function fixGameCode(gameCode, gameTitle) {
|
||||
try {
|
||||
const response = await anthropic.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 16000,
|
||||
system: FIX_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Fix this game "${gameTitle}":\n\n${gameCode}`
|
||||
}]
|
||||
});
|
||||
|
||||
let fixedCode = response.content[0].text.trim();
|
||||
|
||||
// Remove markdown code blocks if present
|
||||
if (fixedCode.startsWith('```html')) {
|
||||
fixedCode = fixedCode.slice(7);
|
||||
} else if (fixedCode.startsWith('```')) {
|
||||
fixedCode = fixedCode.slice(3);
|
||||
}
|
||||
if (fixedCode.endsWith('```')) {
|
||||
fixedCode = fixedCode.slice(0, -3);
|
||||
}
|
||||
|
||||
return fixedCode.trim();
|
||||
} catch (error) {
|
||||
console.error(` Error fixing game: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGameVersion(gameId, oldCode, newCode, changeDescription) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Get current version number
|
||||
const versionResult = await client.query(
|
||||
'SELECT COALESCE(MAX(version_number), 0) as max_version FROM game_versions WHERE game_id = $1',
|
||||
[gameId]
|
||||
);
|
||||
const newVersion = versionResult.rows[0].max_version + 1;
|
||||
|
||||
// Save version history
|
||||
await client.query(
|
||||
`INSERT INTO game_versions (game_id, version_number, game_code, change_type, change_description)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[gameId, newVersion, oldCode, 'fix', changeDescription]
|
||||
);
|
||||
|
||||
// Update game code
|
||||
await client.query(
|
||||
'UPDATE games SET game_code = $1, code_updated_at = NOW() WHERE id = $2',
|
||||
[newCode, gameId]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return newVersion;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeGame(gameCode) {
|
||||
// Thorough heuristic check for touch issues
|
||||
const issues = [];
|
||||
|
||||
// Look for canvas touch/click handlers
|
||||
const canvasClickMatch = gameCode.match(/canvas\.(addEventListener\s*\(\s*['"](?:click|touchstart|pointerdown)['"]|onclick\s*=|ontouchstart\s*=)/gi);
|
||||
const hasCanvasTouch = canvasClickMatch && canvasClickMatch.length > 0;
|
||||
|
||||
// Check for proper 3-state system (ready/playing/gameOver)
|
||||
const hasThreeStates = /gameState\s*===?\s*['"]ready['"]/i.test(gameCode);
|
||||
|
||||
// Check for gameState or gameOver variable
|
||||
const hasGameState = /(?:let|var|const)\s+gameState\s*=/i.test(gameCode);
|
||||
const hasGameOver = /(?:let|var|const)\s+(?:gameOver|isGameOver|game_over)\s*=/i.test(gameCode);
|
||||
|
||||
// Check for tap debouncing
|
||||
const hasTapDebounce = /lastTapTime|tapDebounce|Date\.now\(\)\s*-/i.test(gameCode);
|
||||
|
||||
// Check if restart/reset functions check game state before executing
|
||||
const hasConditionalRestart = /if\s*\(\s*(?:gameState\s*===?\s*['"](?:gameOver|over|ended)['"]|gameOver\s*(?:===?\s*true)?|isGameOver)/i.test(gameCode);
|
||||
|
||||
// For games where canvas touch is the game mechanic (like Flappy Bird),
|
||||
// check if game over transitions to 'ready' (not directly back to playing)
|
||||
const hasFlap = /function\s+flap\s*\(/i.test(gameCode);
|
||||
const flapChecksGameOver = /function\s+flap[\s\S]{0,200}if\s*\(\s*gameOver\s*\)/i.test(gameCode);
|
||||
|
||||
// Check if resetGame goes to 'ready' state instead of immediately allowing play
|
||||
const resetGoesToReady = /resetGame[\s\S]{0,300}gameState\s*=\s*['"]ready['"]/i.test(gameCode);
|
||||
|
||||
// Games using d-pad buttons should NOT have canvas click for restart during play
|
||||
const hasDpad = /class\s*=\s*['"].*dpad/i.test(gameCode);
|
||||
const hasButtonControls = /getElementById\s*\(\s*['"](?:leftBtn|rightBtn|upBtn|downBtn|actionBtn|shootBtn)/i.test(gameCode);
|
||||
|
||||
// Check for problematic patterns
|
||||
if (hasCanvasTouch) {
|
||||
// Missing 3-state system means game may feel "restarty"
|
||||
if (!hasThreeStates && hasFlap) {
|
||||
issues.push('Flappy-style game missing 3-state system (ready/playing/gameOver)');
|
||||
}
|
||||
|
||||
// No tap debouncing
|
||||
if (!hasTapDebounce) {
|
||||
issues.push('No tap debouncing (may double-fire on mobile)');
|
||||
}
|
||||
|
||||
// If using d-pad/buttons AND canvas touch handler exists, check if it's properly guarded
|
||||
if ((hasDpad || hasButtonControls) && !hasConditionalRestart) {
|
||||
issues.push('Uses button controls but canvas touch handler may not check game state');
|
||||
}
|
||||
|
||||
// If game uses flap mechanic, check it's guarded
|
||||
if (hasFlap && !flapChecksGameOver) {
|
||||
issues.push('Flap function may not check gameOver state');
|
||||
}
|
||||
}
|
||||
|
||||
// If no game state tracking at all, that's a problem
|
||||
if (hasCanvasTouch && !hasGameState && !hasGameOver) {
|
||||
issues.push('Has canvas touch but no gameState/gameOver tracking');
|
||||
}
|
||||
|
||||
return {
|
||||
needsFix: issues.length > 0,
|
||||
issues,
|
||||
details: { hasCanvasTouch, hasGameState, hasGameOver, hasThreeStates, hasTapDebounce, hasConditionalRestart, hasDpad, hasButtonControls }
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const dryRun = args.includes('--dry-run');
|
||||
const gameIdArg = args.find(a => a.startsWith('--game'));
|
||||
const specificGameId = gameIdArg ? parseInt(args[args.indexOf(gameIdArg) + 1] || args[args.indexOf('--game') + 1]) : null;
|
||||
|
||||
console.log('='.repeat(60));
|
||||
console.log(' Touch Handling Fix Script');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`Mode: ${dryRun ? 'DRY RUN (no changes will be saved)' : 'LIVE (changes will be saved)'}`);
|
||||
console.log();
|
||||
|
||||
try {
|
||||
// Get games to fix
|
||||
let query = `
|
||||
SELECT id, title, game_code, status
|
||||
FROM games
|
||||
WHERE status IN ('published', 'testing', 'pending_review')
|
||||
AND game_code IS NOT NULL
|
||||
AND LENGTH(game_code) > 0
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (specificGameId) {
|
||||
query += ' AND id = $1';
|
||||
params.push(specificGameId);
|
||||
}
|
||||
|
||||
query += ' ORDER BY id';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
const games = result.rows;
|
||||
|
||||
console.log(`Found ${games.length} games to process\n`);
|
||||
|
||||
let fixed = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const game of games) {
|
||||
console.log(`[${game.id}] ${game.title} (${game.status})`);
|
||||
|
||||
// Analyze if fix is needed
|
||||
const analysis = await analyzeGame(game.game_code);
|
||||
|
||||
// Debug output
|
||||
if (args.includes('--verbose')) {
|
||||
console.log(' Details:', JSON.stringify(analysis.details, null, 2));
|
||||
}
|
||||
|
||||
if (!analysis.needsFix) {
|
||||
console.log(' ✓ Already has proper touch handling');
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(' Issues found:');
|
||||
analysis.issues.forEach(issue => console.log(` - ${issue}`));
|
||||
|
||||
// Fix the game
|
||||
console.log(' Fixing with Haiku...');
|
||||
const fixedCode = await fixGameCode(game.game_code, game.title);
|
||||
|
||||
if (!fixedCode) {
|
||||
console.log(' ✗ Failed to fix');
|
||||
errors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if code actually changed
|
||||
if (fixedCode === game.game_code) {
|
||||
console.log(' ⊘ No changes needed');
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sizeDiff = fixedCode.length - game.game_code.length;
|
||||
console.log(` Code size: ${game.game_code.length} → ${fixedCode.length} (${sizeDiff >= 0 ? '+' : ''}${sizeDiff} bytes)`);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(' [DRY RUN] Would save changes');
|
||||
fixed++;
|
||||
} else {
|
||||
// Save the fix
|
||||
const version = await saveGameVersion(
|
||||
game.id,
|
||||
game.game_code,
|
||||
fixedCode,
|
||||
'Fixed touch handling: game only restarts on tap after game over'
|
||||
);
|
||||
console.log(` ✓ Fixed and saved as version ${version}`);
|
||||
fixed++;
|
||||
}
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log(' Summary');
|
||||
console.log('='.repeat(60));
|
||||
console.log(` Fixed: ${fixed}`);
|
||||
console.log(` Skipped: ${skipped}`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
console.log(` Total: ${games.length}`);
|
||||
|
||||
if (dryRun && fixed > 0) {
|
||||
console.log('\nRun without --dry-run to apply fixes.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Script failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,127 @@
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(30) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE,
|
||||
password_hash VARCHAR(255),
|
||||
display_name VARCHAR(50),
|
||||
is_guest BOOLEAN DEFAULT false,
|
||||
device_fingerprint VARCHAR(255),
|
||||
tokens_balance INTEGER DEFAULT 5,
|
||||
total_tokens_earned INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_login TIMESTAMP,
|
||||
is_banned BOOLEAN DEFAULT false,
|
||||
role VARCHAR(20) DEFAULT 'player'
|
||||
);
|
||||
|
||||
-- Games table
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id SERIAL PRIMARY KEY,
|
||||
creator_id INTEGER REFERENCES users(id),
|
||||
title VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
game_code TEXT NOT NULL,
|
||||
thumbnail_url VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
play_count INTEGER DEFAULT 0,
|
||||
total_ratings INTEGER DEFAULT 0,
|
||||
rating_sum INTEGER DEFAULT 0,
|
||||
tokens_earned INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
published_at TIMESTAMP,
|
||||
last_played TIMESTAMP,
|
||||
is_featured BOOLEAN DEFAULT false,
|
||||
rejection_reason TEXT
|
||||
);
|
||||
|
||||
-- Play sessions
|
||||
CREATE TABLE IF NOT EXISTS plays (
|
||||
id SERIAL PRIMARY KEY,
|
||||
player_id INTEGER REFERENCES users(id),
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
started_at TIMESTAMP DEFAULT NOW(),
|
||||
ended_at TIMESTAMP,
|
||||
score INTEGER,
|
||||
duration_seconds INTEGER,
|
||||
token_spent BOOLEAN DEFAULT false,
|
||||
device_fingerprint VARCHAR(255),
|
||||
ip_address INET
|
||||
);
|
||||
|
||||
-- High scores
|
||||
CREATE TABLE IF NOT EXISTS high_scores (
|
||||
id SERIAL PRIMARY KEY,
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
player_id INTEGER REFERENCES users(id),
|
||||
score INTEGER NOT NULL,
|
||||
achieved_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(game_id, player_id)
|
||||
);
|
||||
|
||||
-- Developer earnings
|
||||
CREATE TABLE IF NOT EXISTS developer_earnings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
developer_id INTEGER REFERENCES users(id),
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
tokens_earned INTEGER NOT NULL,
|
||||
play_id INTEGER REFERENCES plays(id),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Guest sessions (for fingerprint tracking)
|
||||
CREATE TABLE IF NOT EXISTS guest_sessions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
device_fingerprint VARCHAR(255) NOT NULL,
|
||||
user_agent TEXT,
|
||||
ip_address INET,
|
||||
tokens_granted_today INTEGER DEFAULT 5,
|
||||
last_token_grant DATE DEFAULT CURRENT_DATE,
|
||||
total_plays INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
last_seen TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Game reports (moderation)
|
||||
CREATE TABLE IF NOT EXISTS game_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
reporter_id INTEGER REFERENCES users(id),
|
||||
reason VARCHAR(50),
|
||||
description TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
reviewed_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Suspicious activity log
|
||||
CREATE TABLE IF NOT EXISTS suspicious_activity_log (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
activity_type VARCHAR(50),
|
||||
details JSONB,
|
||||
ip_address INET,
|
||||
device_fingerprint VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ratings table (to track one rating per user per game)
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
game_id INTEGER REFERENCES games(id),
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(game_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_games_status ON games(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_creator ON games(creator_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plays_player ON plays(player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_plays_game ON plays(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_high_scores_game ON high_scores(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_guest_fingerprint ON guest_sessions(device_fingerprint);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_published ON games(published_at) WHERE status = 'published';
|
||||
CREATE INDEX IF NOT EXISTS idx_games_play_count ON games(play_count DESC) WHERE status = 'published';
|
||||
@@ -0,0 +1,314 @@
|
||||
-- GamerComp Full Platform Migration
|
||||
-- Run with: psql -U gamearc -d game_arcade -f 002_full_platform.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ============================================
|
||||
-- UPDATE EXISTING TABLES
|
||||
-- ============================================
|
||||
|
||||
-- Add missing columns to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS xp_total INTEGER DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS creator_level INTEGER DEFAULT 1;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS creator_badge VARCHAR(50) DEFAULT 'Newbie';
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS daily_login_streak INTEGER DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS weekly_creation_streak INTEGER DEFAULT 0;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_date DATE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_creation_date DATE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_verified BOOLEAN DEFAULT false;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_data JSONB;
|
||||
|
||||
-- Add missing columns to games table
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS creation_prompt TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS prompt_answers JSONB;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS game_style VARCHAR(50);
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS difficulty_tags VARCHAR(50)[];
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS theme_tags VARCHAR(50)[];
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS accessibility_features VARCHAR(50)[];
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS is_mobile_friendly BOOLEAN DEFAULT true;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS favorite_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS remix_count INTEGER DEFAULT 0;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS remixed_from_id INTEGER REFERENCES games(id);
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS allow_remix BOOLEAN DEFAULT true;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS creator_played BOOLEAN DEFAULT false;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS creator_play_duration INTEGER DEFAULT 0;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS ai_review_passed BOOLEAN DEFAULT false;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS ai_review_notes TEXT;
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS license VARCHAR(20) DEFAULT 'GPL-3.0';
|
||||
ALTER TABLE games ADD COLUMN IF NOT EXISTS thumbnail_data TEXT;
|
||||
|
||||
-- Add missing columns to plays table
|
||||
ALTER TABLE plays ADD COLUMN IF NOT EXISTS levels_completed INTEGER DEFAULT 0;
|
||||
ALTER TABLE plays ADD COLUMN IF NOT EXISTS difficulty_played VARCHAR(20);
|
||||
ALTER TABLE plays ADD COLUMN IF NOT EXISTS is_creator_play BOOLEAN DEFAULT false;
|
||||
|
||||
-- Update high_scores table
|
||||
ALTER TABLE high_scores ADD COLUMN IF NOT EXISTS difficulty VARCHAR(20);
|
||||
|
||||
-- ============================================
|
||||
-- CREATE NEW TABLES
|
||||
-- ============================================
|
||||
|
||||
-- FAVORITES
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, game_id)
|
||||
);
|
||||
|
||||
-- FOLLOWS (follow creators)
|
||||
CREATE TABLE IF NOT EXISTS follows (
|
||||
id SERIAL PRIMARY KEY,
|
||||
follower_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
following_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(follower_id, following_id),
|
||||
CHECK (follower_id != following_id)
|
||||
);
|
||||
|
||||
-- NOTIFICATIONS
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(100),
|
||||
message TEXT,
|
||||
link VARCHAR(255),
|
||||
is_read BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ACHIEVEMENTS
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
code VARCHAR(50) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
icon VARCHAR(50),
|
||||
xp_reward INTEGER DEFAULT 0,
|
||||
category VARCHAR(50)
|
||||
);
|
||||
|
||||
-- USER ACHIEVEMENTS
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
achievement_id INTEGER REFERENCES achievements(id) ON DELETE CASCADE,
|
||||
earned_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(user_id, achievement_id)
|
||||
);
|
||||
|
||||
-- COLLECTIONS (user playlists)
|
||||
CREATE TABLE IF NOT EXISTS collections (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
is_public BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- COLLECTION GAMES
|
||||
CREATE TABLE IF NOT EXISTS collection_games (
|
||||
id SERIAL PRIMARY KEY,
|
||||
collection_id INTEGER REFERENCES collections(id) ON DELETE CASCADE,
|
||||
game_id INTEGER REFERENCES games(id) ON DELETE CASCADE,
|
||||
added_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(collection_id, game_id)
|
||||
);
|
||||
|
||||
-- XP TRANSACTIONS (track all XP gains)
|
||||
CREATE TABLE IF NOT EXISTS xp_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
amount INTEGER NOT NULL,
|
||||
reason VARCHAR(100),
|
||||
reference_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- WEEKLY THEMES
|
||||
CREATE TABLE IF NOT EXISTS weekly_themes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
bonus_xp_multiplier DECIMAL(3,2) DEFAULT 1.5,
|
||||
is_active BOOLEAN DEFAULT false
|
||||
);
|
||||
|
||||
-- CLASSROOMS (teacher mode)
|
||||
CREATE TABLE IF NOT EXISTS classrooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
teacher_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
join_code VARCHAR(10) UNIQUE NOT NULL,
|
||||
is_public_publishing_allowed BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- CLASSROOM STUDENTS
|
||||
CREATE TABLE IF NOT EXISTS classroom_students (
|
||||
id SERIAL PRIMARY KEY,
|
||||
classroom_id INTEGER REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
student_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(classroom_id, student_id)
|
||||
);
|
||||
|
||||
-- CLASSROOM ASSIGNMENTS
|
||||
CREATE TABLE IF NOT EXISTS classroom_assignments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
classroom_id INTEGER REFERENCES classrooms(id) ON DELETE CASCADE,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
required_game_style VARCHAR(50),
|
||||
due_date TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- CREATE INDEXES
|
||||
-- ============================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_games_style ON games(game_style);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_difficulty ON games USING GIN(difficulty_tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_games_themes ON games USING GIN(theme_tags);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_user ON favorites(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_game ON favorites(game_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_follows_follower ON follows(follower_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_follows_following ON follows(following_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id) WHERE is_read = false;
|
||||
CREATE INDEX IF NOT EXISTS idx_user_achievements_user ON user_achievements(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_xp_transactions_user ON xp_transactions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classrooms_teacher ON classrooms(teacher_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_students_classroom ON classroom_students(classroom_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_classroom_students_student ON classroom_students(student_id);
|
||||
|
||||
-- ============================================
|
||||
-- SEED ACHIEVEMENTS
|
||||
-- ============================================
|
||||
|
||||
INSERT INTO achievements (code, name, description, icon, xp_reward, category) VALUES
|
||||
-- Creator achievements
|
||||
('first_game', 'First Game', 'Published your first game', '🎮', 100, 'creator'),
|
||||
('ten_games', 'Prolific Creator', 'Published 10 games', '🏭', 500, 'creator'),
|
||||
('hundred_plays', 'Getting Popular', 'One of your games reached 100 plays', '📈', 200, 'creator'),
|
||||
('thousand_plays', 'Viral Hit', 'One of your games reached 1000 plays', '🔥', 1000, 'creator'),
|
||||
('five_stars', 'Five Star Developer', 'Got a 5-star average rating on a game', '⭐', 300, 'creator'),
|
||||
('remixed', 'Inspirational', 'Someone remixed your game', '🔄', 150, 'creator'),
|
||||
('ten_remixes', 'Remix Master', '10 games were remixed from yours', '👑', 750, 'creator'),
|
||||
|
||||
-- Player achievements
|
||||
('first_play', 'Player One', 'Played your first game', '🕹️', 25, 'player'),
|
||||
('fifty_plays', 'Dedicated Gamer', 'Played 50 games', '🎯', 200, 'player'),
|
||||
('high_scorer', 'High Scorer', 'Got #1 on any leaderboard', '🏆', 300, 'player'),
|
||||
('all_styles', 'Genre Explorer', 'Played every game style', '🗺️', 400, 'player'),
|
||||
|
||||
-- Social achievements
|
||||
('first_follow', 'Fan Club', 'Followed your first creator', '👥', 25, 'social'),
|
||||
('ten_favorites', 'Collector', 'Favorited 10 games', '❤️', 100, 'social'),
|
||||
('first_collection', 'Curator', 'Created your first collection', '📚', 50, 'social'),
|
||||
|
||||
-- Prompt engineering achievements
|
||||
('no_edits', 'Prompt Pro', 'Created a game that needed zero edits', '✨', 250, 'creator'),
|
||||
('detailed_prompt', 'Detail Detective', 'Used 5+ specific details in your prompt', '🔍', 100, 'creator'),
|
||||
('accessibility', 'Inclusive Designer', 'Added accessibility features to a game', '♿', 200, 'creator'),
|
||||
|
||||
-- Streak achievements
|
||||
('week_streak', 'Week Warrior', '7-day login streak', '📅', 150, 'special'),
|
||||
('month_streak', 'Monthly Master', '30-day login streak', '🗓️', 500, 'special'),
|
||||
('creation_streak', 'Creative Machine', 'Created games 4 weeks in a row', '🏃', 400, 'special'),
|
||||
|
||||
-- Special
|
||||
('beta_tester', 'Beta Tester', 'Joined during beta', '🧪', 500, 'special'),
|
||||
('bug_reporter', 'Bug Hunter', 'Reported 5 bugs that were fixed', '🐛', 200, 'special')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- ============================================
|
||||
-- CREATE HELPER FUNCTIONS
|
||||
-- ============================================
|
||||
|
||||
-- Function to calculate creator level from XP
|
||||
CREATE OR REPLACE FUNCTION get_creator_level(xp INTEGER)
|
||||
RETURNS INTEGER AS $$
|
||||
BEGIN
|
||||
RETURN CASE
|
||||
WHEN xp >= 10000 THEN 10
|
||||
WHEN xp >= 6000 THEN 9
|
||||
WHEN xp >= 4000 THEN 8
|
||||
WHEN xp >= 2500 THEN 7
|
||||
WHEN xp >= 1500 THEN 6
|
||||
WHEN xp >= 1000 THEN 5
|
||||
WHEN xp >= 600 THEN 4
|
||||
WHEN xp >= 300 THEN 3
|
||||
WHEN xp >= 100 THEN 2
|
||||
ELSE 1
|
||||
END;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Function to get creator badge from level
|
||||
CREATE OR REPLACE FUNCTION get_creator_badge(level INTEGER)
|
||||
RETURNS VARCHAR AS $$
|
||||
BEGIN
|
||||
RETURN CASE level
|
||||
WHEN 10 THEN 'Grandmaster'
|
||||
WHEN 9 THEN 'Legend'
|
||||
WHEN 8 THEN 'Master'
|
||||
WHEN 7 THEN 'Expert'
|
||||
WHEN 6 THEN 'Pro'
|
||||
WHEN 5 THEN 'Game Dev'
|
||||
WHEN 4 THEN 'Creator'
|
||||
WHEN 3 THEN 'Apprentice'
|
||||
WHEN 2 THEN 'Beginner'
|
||||
ELSE 'Newbie'
|
||||
END;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Function to award XP and update level
|
||||
CREATE OR REPLACE FUNCTION award_xp(p_user_id INTEGER, p_amount INTEGER, p_reason VARCHAR, p_reference_id INTEGER DEFAULT NULL)
|
||||
RETURNS TABLE(new_xp INTEGER, new_level INTEGER, new_badge VARCHAR, leveled_up BOOLEAN) AS $$
|
||||
DECLARE
|
||||
old_level INTEGER;
|
||||
current_xp INTEGER;
|
||||
calculated_level INTEGER;
|
||||
calculated_badge VARCHAR;
|
||||
BEGIN
|
||||
-- Get current XP and level
|
||||
SELECT xp_total, creator_level INTO current_xp, old_level FROM users WHERE id = p_user_id;
|
||||
|
||||
-- Calculate new XP
|
||||
current_xp := COALESCE(current_xp, 0) + p_amount;
|
||||
|
||||
-- Calculate new level and badge
|
||||
calculated_level := get_creator_level(current_xp);
|
||||
calculated_badge := get_creator_badge(calculated_level);
|
||||
|
||||
-- Update user
|
||||
UPDATE users
|
||||
SET xp_total = current_xp,
|
||||
creator_level = calculated_level,
|
||||
creator_badge = calculated_badge
|
||||
WHERE id = p_user_id;
|
||||
|
||||
-- Log XP transaction
|
||||
INSERT INTO xp_transactions (user_id, amount, reason, reference_id)
|
||||
VALUES (p_user_id, p_amount, p_reason, p_reference_id);
|
||||
|
||||
-- Return results
|
||||
RETURN QUERY SELECT current_xp, calculated_level, calculated_badge, (calculated_level > COALESCE(old_level, 1));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Show summary
|
||||
SELECT 'Migration complete!' as status;
|
||||
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Migration 003: Onboarding Flow
|
||||
-- Adds onboarding tracking to users and new onboarding achievements
|
||||
|
||||
-- Add onboarding columns to users table
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarding_complete BOOLEAN DEFAULT false;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS onboarding_step INTEGER DEFAULT 0;
|
||||
|
||||
-- Create index for finding users who haven't completed onboarding
|
||||
CREATE INDEX IF NOT EXISTS idx_users_onboarding ON users(onboarding_complete) WHERE onboarding_complete = false;
|
||||
|
||||
-- Insert onboarding achievements
|
||||
INSERT INTO achievements (code, name, description, icon, xp_reward, category) VALUES
|
||||
('welcome_tour', 'Welcome Tour', 'Completed the welcome tutorial', '🎉', 50, 'onboarding'),
|
||||
('first_arcade_visit', 'Explorer', 'Visited the arcade for the first time', '🗺️', 25, 'onboarding'),
|
||||
('profile_customized', 'Personal Touch', 'Customized your profile', '✨', 50, 'onboarding'),
|
||||
('onboarding_complete', 'Ready to Play!', 'Completed all getting started steps', '🚀', 100, 'onboarding')
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gamearc;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gamearc;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Migration 004: Admin Audit Logs
|
||||
-- Tracks all admin actions for accountability and auditing
|
||||
|
||||
-- Create audit_logs table
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
admin_id INTEGER NOT NULL REFERENCES users(id),
|
||||
action VARCHAR(100) NOT NULL,
|
||||
target_type VARCHAR(50) NOT NULL, -- 'game', 'user', 'report'
|
||||
target_id INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45), -- Supports IPv6
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create indexes for common queries
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_admin_id ON audit_logs(admin_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO gamearc;
|
||||
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO gamearc;
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Publish sample games under the "Arcade Master" account
|
||||
* Run with: node scripts/publish-sample-games.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { Pool } = require('pg');
|
||||
const bcrypt = require('bcrypt');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
});
|
||||
|
||||
const ARCADE_MASTER = {
|
||||
username: 'ArcadeMaster',
|
||||
email: 'arcademaster@gamercomp.com',
|
||||
password: 'ArcadeMaster2024!',
|
||||
displayName: 'Arcade Master'
|
||||
};
|
||||
|
||||
const GAMES_DIR = path.join(__dirname, '../../games/samples');
|
||||
|
||||
// Game metadata (title, description, prompt for each game)
|
||||
const GAME_METADATA = {
|
||||
'brick-breaker.html': {
|
||||
title: 'Brick Breaker',
|
||||
description: 'Classic paddle and ball game! Move your paddle to bounce the ball and break all the bricks. Easy to learn, fun to play!',
|
||||
prompt: 'Create a classic brick breaker game with a wide paddle, slow ball, and 5 lives. Make it easy for kids to play.'
|
||||
},
|
||||
'pong.html': {
|
||||
title: 'Pong',
|
||||
description: 'The classic 2-player game! Play against the computer and try to score 7 points first. The computer makes mistakes so you can win!',
|
||||
prompt: 'Create a single-player Pong game vs AI. The AI should make mistakes sometimes so kids can win.'
|
||||
},
|
||||
'dino-runner.html': {
|
||||
title: 'Dino Runner',
|
||||
description: 'Run as far as you can! Tap or press space to jump over cacti. The game starts slow so you can learn the timing.',
|
||||
prompt: 'Create a Chrome Dino style endless runner. Start very slow with obstacles far apart.'
|
||||
},
|
||||
'asteroids.html': {
|
||||
title: 'Asteroids',
|
||||
description: 'Classic space shooter! Rotate your ship and blast the asteroids. You have shields that protect you when you get hit.',
|
||||
prompt: 'Create an Asteroids game with a large ship, slow asteroids, and invincibility after being hit.'
|
||||
},
|
||||
'whack-a-mole.html': {
|
||||
title: 'Whack-a-Mole',
|
||||
description: 'Tap the moles when they pop up! They stay visible for a long time so you can catch them. 60 seconds to get the high score!',
|
||||
prompt: 'Create a whack-a-mole game with a 3x3 grid. Moles should stay up for 2+ seconds.'
|
||||
},
|
||||
'block-drop.html': {
|
||||
title: 'Block Drop',
|
||||
description: 'Stack falling blocks to complete lines! Pieces fall slowly so you have time to think. A ghost piece shows where blocks will land.',
|
||||
prompt: 'Create a Tetris-style game with very slow falling pieces and helpful ghost piece preview.'
|
||||
},
|
||||
'twenty-forty-eight.html': {
|
||||
title: '2048',
|
||||
description: 'Swipe to combine numbers! 2+2=4, 4+4=8, and so on. No time limit - take as long as you need to plan your moves.',
|
||||
prompt: 'Create the 2048 puzzle game with no time pressure and clear directional arrows.'
|
||||
},
|
||||
'memory-match.html': {
|
||||
title: 'Memory Match',
|
||||
description: 'Find all the matching pairs! Flip cards to reveal pictures and remember where they are. Only 6 pairs to start.',
|
||||
prompt: 'Create a memory matching game with a small 3x4 grid (6 pairs) and cards that stay flipped for 1.5 seconds.'
|
||||
},
|
||||
'connect-four.html': {
|
||||
title: 'Connect Four',
|
||||
description: 'Drop your pieces to connect 4 in a row! Play against a friendly computer that lets you learn the game.',
|
||||
prompt: 'Create Connect Four vs AI. The AI should play randomly most of the time so kids can win.'
|
||||
},
|
||||
'lights-out.html': {
|
||||
title: 'Lights Out',
|
||||
description: 'Turn off all the lights to win! Clicking a light toggles it and its neighbors. Includes a helpful tutorial!',
|
||||
prompt: 'Create Lights Out puzzle with an easy 3x3 grid, interactive tutorial, and hover highlights.'
|
||||
},
|
||||
'geometry-runner.html': {
|
||||
title: 'Geometry Runner',
|
||||
description: 'Jump over obstacles in this rhythm-style runner! Starts very slow with obstacles far apart so you can learn the timing.',
|
||||
prompt: 'Create a Geometry Dash style runner that starts very slow with generous jump timing.'
|
||||
},
|
||||
'lunar-lander.html': {
|
||||
title: 'Lunar Lander',
|
||||
description: 'Land your spaceship safely on the moon! You have lots of fuel and the landing pad is big. Low gravity makes it easier.',
|
||||
prompt: 'Create a Lunar Lander with generous fuel, low gravity, and a large landing pad.'
|
||||
},
|
||||
'fruit-slicer.html': {
|
||||
title: 'Fruit Slicer',
|
||||
description: 'Swipe through the fruit to slice it! Fruit moves slowly and stays on screen longer. Watch out for bombs!',
|
||||
prompt: 'Create a Fruit Ninja style game with big slow fruit and rare bombs at the start.'
|
||||
},
|
||||
'maze-chomper.html': {
|
||||
title: 'Maze Chomper',
|
||||
description: 'Eat all the dots while avoiding ghosts! You move much faster than the ghosts, and power pellets last a long time.',
|
||||
prompt: 'Create a Pac-Man style game where the player is 3x faster than ghosts and power-ups last 10 seconds.'
|
||||
},
|
||||
'platformer.html': {
|
||||
title: 'Platformer',
|
||||
description: 'Jump and run to reach the flag! 5 lives, slow enemies, and big platforms make this adventure fun for everyone.',
|
||||
prompt: 'Create a simple platformer with 5 lives, slow enemies, and generous jump mechanics.'
|
||||
},
|
||||
'bridge-builder.html': {
|
||||
title: 'Bridge Builder',
|
||||
description: 'Build a bridge to get the car across! Strong beams and a light car make building easy. Includes a helpful tutorial.',
|
||||
prompt: 'Create a bridge building physics game with strong materials, light car, and visual tutorial.'
|
||||
}
|
||||
};
|
||||
|
||||
async function getOrCreateArcadeMaster() {
|
||||
// Check if user exists
|
||||
let result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[ARCADE_MASTER.username]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
console.log(`Found existing Arcade Master account (ID: ${result.rows[0].id})`);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const passwordHash = await bcrypt.hash(ARCADE_MASTER.password, 10);
|
||||
result = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, tokens_balance, role)
|
||||
VALUES ($1, $2, $3, $4, 9999, 'developer')
|
||||
RETURNING *`,
|
||||
[ARCADE_MASTER.username, ARCADE_MASTER.email, passwordHash, ARCADE_MASTER.displayName]
|
||||
);
|
||||
|
||||
console.log(`Created Arcade Master account (ID: ${result.rows[0].id})`);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function publishGame(creatorId, filename, gameCode) {
|
||||
const metadata = GAME_METADATA[filename];
|
||||
if (!metadata) {
|
||||
console.log(` Skipping ${filename} - no metadata defined`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if game already exists
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM games WHERE title = $1 AND creator_id = $2',
|
||||
[metadata.title, creatorId]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update existing game
|
||||
await pool.query(
|
||||
`UPDATE games SET game_code = $1, description = $2, prompt = $3 WHERE id = $4`,
|
||||
[gameCode, metadata.description, metadata.prompt, existing.rows[0].id]
|
||||
);
|
||||
console.log(` Updated: ${metadata.title} (ID: ${existing.rows[0].id})`);
|
||||
return existing.rows[0].id;
|
||||
}
|
||||
|
||||
// Insert new game as published
|
||||
const result = await pool.query(
|
||||
`INSERT INTO games (creator_id, title, description, game_code, prompt, status, published_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'published', NOW())
|
||||
RETURNING id`,
|
||||
[creatorId, metadata.title, metadata.description, gameCode, metadata.prompt]
|
||||
);
|
||||
|
||||
console.log(` Published: ${metadata.title} (ID: ${result.rows[0].id})`);
|
||||
return result.rows[0].id;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
console.log('=== Publishing Sample Games ===\n');
|
||||
|
||||
// Get or create Arcade Master account
|
||||
const arcadeMaster = await getOrCreateArcadeMaster();
|
||||
console.log('');
|
||||
|
||||
// Read all game files
|
||||
const files = fs.readdirSync(GAMES_DIR).filter(f => f.endsWith('.html'));
|
||||
console.log(`Found ${files.length} game files in ${GAMES_DIR}\n`);
|
||||
|
||||
let published = 0;
|
||||
for (const filename of files) {
|
||||
const filepath = path.join(GAMES_DIR, filename);
|
||||
const gameCode = fs.readFileSync(filepath, 'utf8');
|
||||
|
||||
const gameId = await publishGame(arcadeMaster.id, filename, gameCode);
|
||||
if (gameId) published++;
|
||||
}
|
||||
|
||||
console.log(`\n=== Complete! ===`);
|
||||
console.log(`Published ${published} games under ${ARCADE_MASTER.username}`);
|
||||
console.log(`\nArcade Master credentials:`);
|
||||
console.log(` Username: ${ARCADE_MASTER.username}`);
|
||||
console.log(` Password: ${ARCADE_MASTER.password}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,278 @@
|
||||
require('dotenv').config();
|
||||
const { pool } = require('../src/models/db');
|
||||
const { generateGame } = require('../src/utils/claude');
|
||||
|
||||
const allGames = [
|
||||
{
|
||||
id: 14,
|
||||
title: "Snake",
|
||||
prompt: `Classic Snake game - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Swipe anywhere to change direction
|
||||
- Also add semi-transparent d-pad buttons at bottom (up/down/left/right)
|
||||
|
||||
GAMEPLAY:
|
||||
- Snake starts moving right automatically
|
||||
- Eating apple grows snake, adds 10 points
|
||||
- Game over on wall/self collision
|
||||
- Speed increases every 5 apples
|
||||
|
||||
SOUNDS: Eat sound (high beep), game over (low tone), background beat`
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
title: "Tetris",
|
||||
prompt: `Tetris falling blocks - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Tap left half to move left, right half to move right
|
||||
- Swipe down for drop
|
||||
- Tap piece or swipe up to rotate
|
||||
- Add visible buttons at bottom: [←] [→] [↻] [↓]
|
||||
|
||||
GAMEPLAY:
|
||||
- 7 tetromino shapes, auto-falling
|
||||
- Clear lines for points (100/300/500/800)
|
||||
- Show next piece
|
||||
- Speed increases each level
|
||||
|
||||
SOUNDS: Move (click), rotate (whoosh), line clear (ding), game over`
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
title: "Pong",
|
||||
prompt: `Pong paddle game - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Touch and drag on YOUR side to move your paddle
|
||||
- Single player vs AI opponent
|
||||
|
||||
GAMEPLAY:
|
||||
- Ball bounces between paddles
|
||||
- First to 5 points wins
|
||||
- Ball speeds up each hit
|
||||
- AI difficulty increases with score
|
||||
|
||||
SOUNDS: Paddle hit (pop), wall bounce (tick), score (ding), win/lose`
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
title: "Breakout",
|
||||
prompt: `Breakout brick breaker - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Touch anywhere and drag to move paddle
|
||||
- Paddle follows finger X position
|
||||
- Tap to launch ball from paddle
|
||||
|
||||
GAMEPLAY:
|
||||
- Colorful brick rows at top
|
||||
- Ball bounces off paddle to break bricks
|
||||
- Each brick = 10 points
|
||||
- 3 lives, lose one when ball falls
|
||||
|
||||
SOUNDS: Brick break (pop), paddle hit (thud), ball launch (whoosh), life lost`
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
title: "Space Invaders",
|
||||
prompt: `Space Invaders shooter - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Large [←] and [→] buttons at bottom corners
|
||||
- Large [FIRE] button at bottom center
|
||||
- Touch zones: left third moves left, right third moves right, center fires
|
||||
|
||||
GAMEPLAY:
|
||||
- Alien grid moves and descends
|
||||
- Shoot aliens for points (10-50 per row)
|
||||
- Aliens shoot back randomly
|
||||
- 3 lives, clear all aliens to win wave
|
||||
|
||||
SOUNDS: Shoot (pew), alien hit (explosion), player hit (boom), march beat`
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
title: "Pac-Man",
|
||||
prompt: `Pac-Man maze game - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Swipe in direction to change Pac-Man direction
|
||||
- Also add d-pad overlay at bottom of screen
|
||||
|
||||
GAMEPLAY:
|
||||
- Simple 15x15 maze
|
||||
- Eat dots (10pts) and power pellets (50pts)
|
||||
- 4 ghosts chase, turn blue when powered
|
||||
- Eat blue ghosts for bonus (200pts)
|
||||
- 3 lives
|
||||
|
||||
SOUNDS: Wakka-wakka eating, power pellet sound, ghost eaten, death`
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: "Flappy Bird",
|
||||
prompt: `Flappy Bird - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- TAP ANYWHERE on screen to flap
|
||||
- That's the only control - dead simple
|
||||
- Entire game canvas is the tap zone
|
||||
|
||||
GAMEPLAY:
|
||||
- Bird falls with gravity
|
||||
- Tap to flap up
|
||||
- Pass through pipe gaps
|
||||
- Each pipe = 1 point
|
||||
- Game over on any collision
|
||||
|
||||
SOUNDS: Flap (whoosh), score (ding), hit (thud), background wing beat`
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
title: "2048",
|
||||
prompt: `2048 number puzzle - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- SWIPE in any direction to slide tiles
|
||||
- Must detect swipe vs tap
|
||||
- Minimum swipe distance: 30px
|
||||
|
||||
GAMEPLAY:
|
||||
- 4x4 grid
|
||||
- Swipe merges matching numbers
|
||||
- New tile after each move
|
||||
- Win at 2048, game over when stuck
|
||||
|
||||
SOUNDS: Slide (swoosh), merge (pop - higher pitch for bigger numbers), game over`
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: "Minesweeper",
|
||||
prompt: `Minesweeper - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- TAP to reveal cell
|
||||
- LONG PRESS (0.5s) to flag/unflag
|
||||
- Cells must be large enough to tap (40px minimum)
|
||||
|
||||
GAMEPLAY:
|
||||
- 9x9 grid, 10 mines
|
||||
- Numbers show adjacent mines
|
||||
- First tap always safe
|
||||
- Flag all mines to win
|
||||
|
||||
SOUNDS: Reveal (click), flag (flag sound), mine (explosion), win (fanfare)`
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
title: "Asteroids",
|
||||
prompt: `Asteroids space shooter - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Left side: two buttons for rotate left/right
|
||||
- Right side: THRUST button and FIRE button
|
||||
- Virtual joystick alternative: drag to rotate, tap to thrust
|
||||
|
||||
GAMEPLAY:
|
||||
- Ship in center with momentum physics
|
||||
- Shoot asteroids to break them
|
||||
- Large→Medium→Small
|
||||
- Screen wrapping
|
||||
- 3 lives
|
||||
|
||||
SOUNDS: Thrust (rumble), shoot (pew), asteroid break (boom), ship explode`
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
title: "Frogger",
|
||||
prompt: `Frogger road crossing - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- D-pad at bottom: up/down/left/right buttons
|
||||
- Or SWIPE in direction to hop
|
||||
- Each input = one grid hop
|
||||
|
||||
GAMEPLAY:
|
||||
- Cross road (avoid cars) then river (ride logs)
|
||||
- Grid-based movement
|
||||
- 5 home spots to fill
|
||||
- 30 second timer per frog
|
||||
- 3 lives
|
||||
|
||||
SOUNDS: Hop (boing), car hit (splat), water splash, home safe (ding)`
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
title: "Simon Says",
|
||||
prompt: `Simon Says memory game - mobile-first with sound.
|
||||
|
||||
MOBILE CONTROLS:
|
||||
- Four LARGE colored buttons (2x2 grid)
|
||||
- Each button at least 100px
|
||||
- Tap to select color
|
||||
|
||||
GAMEPLAY:
|
||||
- Watch pattern light up
|
||||
- Repeat by tapping buttons
|
||||
- Pattern grows each round
|
||||
- Wrong tap = game over
|
||||
|
||||
SOUNDS: Each color has unique tone (do, re, mi, fa), error (buzz), round complete (fanfare)`
|
||||
}
|
||||
];
|
||||
|
||||
async function regenerateGame(gameData) {
|
||||
console.log(`\nRegenerating: ${gameData.title} (ID: ${gameData.id})...`);
|
||||
|
||||
try {
|
||||
const result = await generateGame(gameData.prompt);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(` FAILED: ${result.error}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE games SET game_code = $1, prompt = $2 WHERE id = $3`,
|
||||
[result.code, gameData.prompt, gameData.id]
|
||||
);
|
||||
|
||||
console.log(` SUCCESS: ${gameData.title}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` ERROR: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('='.repeat(50));
|
||||
console.log('REGENERATING ALL GAMES');
|
||||
console.log('Mobile-first + Sound Effects + Music');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const game of allGames) {
|
||||
const ok = await regenerateGame(game);
|
||||
if (ok) success++; else failed++;
|
||||
|
||||
// Delay between API calls
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log(`COMPLETE: ${success} success, ${failed} failed`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
await pool.end();
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Rollback the touch handling fixes - restore original game code
|
||||
*/
|
||||
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '../.env') });
|
||||
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'game_arcade',
|
||||
user: process.env.DB_USER || 'gamearc',
|
||||
password: process.env.DB_PASSWORD
|
||||
});
|
||||
|
||||
async function main() {
|
||||
console.log('Rolling back touch handling fixes...\n');
|
||||
|
||||
// Find all games that were "fixed" and have a version to restore from
|
||||
const result = await pool.query(`
|
||||
SELECT gv.game_id, gv.game_code, g.title
|
||||
FROM game_versions gv
|
||||
JOIN games g ON g.id = gv.game_id
|
||||
WHERE gv.change_type = 'fix'
|
||||
AND gv.change_description LIKE '%touch handling%'
|
||||
ORDER BY gv.game_id
|
||||
`);
|
||||
|
||||
console.log(`Found ${result.rows.length} games to rollback\n`);
|
||||
|
||||
for (const row of result.rows) {
|
||||
console.log(`[${row.game_id}] ${row.title}`);
|
||||
|
||||
// Restore the original code
|
||||
await pool.query(
|
||||
'UPDATE games SET game_code = $1, code_updated_at = NOW() WHERE id = $2',
|
||||
[row.game_code, row.game_id]
|
||||
);
|
||||
|
||||
// Delete the fix version from history
|
||||
await pool.query(
|
||||
`DELETE FROM game_versions
|
||||
WHERE game_id = $1
|
||||
AND change_type = 'fix'
|
||||
AND change_description LIKE '%touch handling%'`,
|
||||
[row.game_id]
|
||||
);
|
||||
|
||||
console.log(' ✓ Restored original code');
|
||||
}
|
||||
|
||||
console.log('\nRollback complete!');
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
require('dotenv').config();
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
});
|
||||
|
||||
const games = [
|
||||
{
|
||||
title: "Star Catcher",
|
||||
description: "Catch falling stars while avoiding meteors! Move left and right to collect as many stars as you can.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Star Catcher</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;display:flex;justify-content:center;align-items:center;min-height:100vh;font-family:Arial,sans-serif}#game{position:relative;background:linear-gradient(to bottom,#16213e,#0f3460);border-radius:10px;overflow:hidden;touch-action:none}#score{position:absolute;top:10px;left:10px;color:#fff;font-size:20px;z-index:10}#player{position:absolute;bottom:20px;width:60px;height:60px;font-size:50px;text-align:center;transition:left 0.1s}.item{position:absolute;font-size:30px;text-align:center}#gameOver{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.8);color:#fff;padding:30px;border-radius:10px;text-align:center;display:none}#gameOver button{margin-top:15px;padding:10px 30px;font-size:18px;background:#4CAF50;color:#fff;border:none;border-radius:5px;cursor:pointer}#instructions{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,0.7);font-size:12px}</style></head>
|
||||
<body><div id="game"><div id="score">Score: 0</div><div id="player">🧺</div><div id="gameOver"><h2>Game Over!</h2><p>Final Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div><div id="instructions">← → Arrow keys or touch to move</div></div>
|
||||
<script>const game=document.getElementById('game'),player=document.getElementById('player'),scoreEl=document.getElementById('score'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');let width=Math.min(400,window.innerWidth-20),height=500,playerX=width/2-30,score=0,gameActive=true,items=[],speed=3;window.gameScore=0;game.style.width=width+'px';game.style.height=height+'px';player.style.left=playerX+'px';function createItem(){if(!gameActive)return;const item=document.createElement('div');item.className='item';item.textContent=Math.random()>0.3?'⭐':'☄️';item.dataset.type=item.textContent==='⭐'?'star':'meteor';item.style.left=Math.random()*(width-30)+'px';item.style.top='-30px';game.appendChild(item);items.push({el:item,y:-30,x:parseFloat(item.style.left)})}function update(){if(!gameActive)return;items.forEach((item,i)=>{item.y+=speed;item.el.style.top=item.y+'px';if(item.y>height){item.el.remove();items.splice(i,1)}else if(item.y>height-100&&item.y<height-20){const itemX=item.x,itemW=30;if(playerX<itemX+itemW&&playerX+60>itemX){if(item.el.dataset.type==='star'){score+=10;window.gameScore=score;scoreEl.textContent='Score: '+score;item.el.remove();items.splice(i,1)}else{endGame()}}}});speed=3+Math.floor(score/50)*0.5;requestAnimationFrame(update)}function endGame(){gameActive=false;finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){items.forEach(i=>i.el.remove());items=[];score=0;window.gameScore=0;speed=3;scoreEl.textContent='Score: 0';gameOverEl.style.display='none';gameActive=true;update()}document.addEventListener('keydown',e=>{if(e.key==='ArrowLeft')playerX=Math.max(0,playerX-20);if(e.key==='ArrowRight')playerX=Math.min(width-60,playerX+20);player.style.left=playerX+'px'});game.addEventListener('touchmove',e=>{e.preventDefault();const touch=e.touches[0],rect=game.getBoundingClientRect();playerX=Math.max(0,Math.min(width-60,touch.clientX-rect.left-30));player.style.left=playerX+'px'});setInterval(createItem,1000);update();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Balloon Pop Frenzy",
|
||||
description: "Pop as many balloons as you can before they float away! Click or tap the balloons to pop them and score points.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Balloon Pop</title>
|
||||
<style>*{margin:0;padding:0}body{background:linear-gradient(to bottom,#87CEEB,#98FB98);min-height:100vh;font-family:Arial,sans-serif;overflow:hidden}#game{position:relative;width:100%;height:100vh;max-width:500px;margin:0 auto}#score{position:fixed;top:10px;left:50%;transform:translateX(-50%);background:rgba(255,255,255,0.9);padding:10px 20px;border-radius:20px;font-size:20px;font-weight:bold;color:#333;z-index:100}.balloon{position:absolute;font-size:50px;cursor:pointer;transition:transform 0.1s;user-select:none;animation:float 0.5s ease-out}.balloon:hover{transform:scale(1.1)}.balloon:active{transform:scale(0.9)}@keyframes float{from{opacity:0;transform:scale(0.5)}to{opacity:1;transform:scale(1)}}.pop{animation:pop 0.3s ease-out forwards}@keyframes pop{to{transform:scale(1.5);opacity:0}}#timer{position:fixed;top:10px;right:20px;background:rgba(255,255,255,0.9);padding:10px 15px;border-radius:20px;font-size:18px;color:#e74c3c}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:40px;border-radius:20px;text-align:center;box-shadow:0 10px 30px rgba(0,0,0,0.2);display:none;z-index:200}#gameOver h2{color:#333;margin-bottom:10px}#gameOver p{font-size:24px;color:#4CAF50;margin-bottom:20px}#gameOver button{padding:15px 40px;font-size:18px;background:#4CAF50;color:white;border:none;border-radius:10px;cursor:pointer}</style></head>
|
||||
<body><div id="game"><div id="score">Score: 0</div><div id="timer">Time: 30</div><div id="gameOver"><h2>Time's Up!</h2><p>Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div></div>
|
||||
<script>const game=document.getElementById('game'),scoreEl=document.getElementById('score'),timerEl=document.getElementById('timer'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');let score=0,timeLeft=30,gameActive=true,balloonInterval,timerInterval;window.gameScore=0;const colors=['🎈','🔴','🟡','🟢','🔵','🟣','🟠'];function createBalloon(){if(!gameActive)return;const balloon=document.createElement('div');balloon.className='balloon';balloon.textContent=colors[Math.floor(Math.random()*colors.length)];balloon.style.left=Math.random()*(window.innerWidth-60)+'px';balloon.style.bottom='-60px';game.appendChild(balloon);let y=-60,speed=2+Math.random()*2;function rise(){if(!gameActive){balloon.remove();return}y+=speed;balloon.style.bottom=y+'px';if(y>window.innerHeight+60){balloon.remove()}else{requestAnimationFrame(rise)}}rise();balloon.onclick=()=>{if(!gameActive)return;balloon.classList.add('pop');score+=10;window.gameScore=score;scoreEl.textContent='Score: '+score;setTimeout(()=>balloon.remove(),300)}}function startGame(){gameActive=true;score=0;timeLeft=30;window.gameScore=0;scoreEl.textContent='Score: 0';timerEl.textContent='Time: 30';gameOverEl.style.display='none';balloonInterval=setInterval(createBalloon,600);timerInterval=setInterval(()=>{timeLeft--;timerEl.textContent='Time: '+timeLeft;if(timeLeft<=0)endGame()},1000)}function endGame(){gameActive=false;clearInterval(balloonInterval);clearInterval(timerInterval);finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){document.querySelectorAll('.balloon').forEach(b=>b.remove());startGame()}startGame();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Snake Classic",
|
||||
description: "The classic snake game! Eat the apples to grow longer but don't hit the walls or yourself. Use arrow keys or swipe to control.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Snake</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;display:flex;flex-direction:column;justify-content:center;align-items:center;min-height:100vh;font-family:Arial,sans-serif}#score{color:#4CAF50;font-size:24px;margin-bottom:10px}canvas{background:#16213e;border-radius:10px;touch-action:none}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:#fff;padding:30px;border-radius:10px;text-align:center;display:none}#gameOver button{margin-top:15px;padding:10px 30px;font-size:18px;background:#4CAF50;color:#fff;border:none;border-radius:5px;cursor:pointer}#controls{margin-top:10px;color:rgba(255,255,255,0.6);font-size:12px}</style></head>
|
||||
<body><div id="score">Score: 0</div><canvas id="game"></canvas><div id="controls">Arrow keys or swipe to move</div><div id="gameOver"><h2>Game Over!</h2><p>Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),scoreEl=document.getElementById('score'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');let size=Math.min(400,window.innerWidth-20),gridSize=20,tileCount=size/gridSize;canvas.width=canvas.height=size;let snake=[{x:10,y:10}],food={x:15,y:15},dx=0,dy=0,score=0,gameActive=true,speed=100;window.gameScore=0;function draw(){if(!gameActive)return;ctx.fillStyle='#16213e';ctx.fillRect(0,0,size,size);ctx.fillStyle='#4CAF50';snake.forEach((seg,i)=>{ctx.fillStyle=i===0?'#8BC34A':'#4CAF50';ctx.fillRect(seg.x*gridSize+1,seg.y*gridSize+1,gridSize-2,gridSize-2)});ctx.fillStyle='#e74c3c';ctx.font=gridSize-4+'px Arial';ctx.fillText('🍎',food.x*gridSize+2,food.y*gridSize+gridSize-4)}function update(){if(!gameActive||dx===0&&dy===0)return;const head={x:snake[0].x+dx,y:snake[0].y+dy};if(head.x<0||head.x>=tileCount||head.y<0||head.y>=tileCount||snake.some(s=>s.x===head.x&&s.y===head.y)){endGame();return}snake.unshift(head);if(head.x===food.x&&head.y===food.y){score+=10;window.gameScore=score;scoreEl.textContent='Score: '+score;food={x:Math.floor(Math.random()*tileCount),y:Math.floor(Math.random()*tileCount)};speed=Math.max(50,speed-2)}else{snake.pop()}}function endGame(){gameActive=false;finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){snake=[{x:10,y:10}];food={x:15,y:15};dx=dy=0;score=0;speed=100;window.gameScore=0;scoreEl.textContent='Score: 0';gameOverEl.style.display='none';gameActive=true}function gameLoop(){update();draw();setTimeout(()=>requestAnimationFrame(gameLoop),speed)}document.addEventListener('keydown',e=>{if(e.key==='ArrowUp'&&dy!==1){dx=0;dy=-1}if(e.key==='ArrowDown'&&dy!==-1){dx=0;dy=1}if(e.key==='ArrowLeft'&&dx!==1){dx=-1;dy=0}if(e.key==='ArrowRight'&&dx!==-1){dx=1;dy=0}});let touchStartX,touchStartY;canvas.addEventListener('touchstart',e=>{touchStartX=e.touches[0].clientX;touchStartY=e.touches[0].clientY});canvas.addEventListener('touchend',e=>{const diffX=e.changedTouches[0].clientX-touchStartX,diffY=e.changedTouches[0].clientY-touchStartY;if(Math.abs(diffX)>Math.abs(diffY)){if(diffX>0&&dx!==−1){dx=1;dy=0}else if(diffX<0&&dx!==1){dx=-1;dy=0}}else{if(diffY>0&&dy!==-1){dx=0;dy=1}else if(diffY<0&&dy!==1){dx=0;dy=-1}}});gameLoop();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Memory Match",
|
||||
description: "Test your memory! Flip cards to find matching pairs. Complete the game with as few moves as possible.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Memory Match</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:linear-gradient(135deg,#667eea,#764ba2);min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px;font-family:Arial,sans-serif}h1{color:white;margin-bottom:10px}#stats{color:white;margin-bottom:20px;font-size:18px}#grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;max-width:400px}.card{width:80px;height:80px;background:white;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:40px;cursor:pointer;transition:transform 0.3s,background 0.3s;user-select:none}.card:hover{transform:scale(1.05)}.card.hidden{background:linear-gradient(135deg,#4CAF50,#45a049)}.card.hidden::after{content:'?';font-size:30px;color:white}.card.matched{background:#98FB98;transform:scale(0.95)}.card.wrong{animation:shake 0.3s}@keyframes shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:40px;border-radius:20px;text-align:center;display:none;z-index:100}#gameOver h2{color:#4CAF50;margin-bottom:10px}#gameOver button{margin-top:15px;padding:12px 30px;font-size:16px;background:#4CAF50;color:white;border:none;border-radius:10px;cursor:pointer}</style></head>
|
||||
<body><h1>Memory Match</h1><div id="stats">Moves: 0 | Pairs: 0/8</div><div id="grid"></div><div id="gameOver"><h2>You Win!</h2><p>Completed in <span id="finalMoves">0</span> moves</p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const grid=document.getElementById('grid'),statsEl=document.getElementById('stats'),gameOverEl=document.getElementById('gameOver'),finalMovesEl=document.getElementById('finalMoves');const emojis=['🎮','🎯','🎪','🎨','🎭','🎪','🎸','🎺'];let cards=[],flipped=[],matched=0,moves=0,canFlip=true;window.gameScore=0;function shuffle(arr){for(let i=arr.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));[arr[i],arr[j]]=[arr[j],arr[i]]}return arr}function createCards(){const pairs=[...emojis,...emojis];shuffle(pairs);grid.innerHTML='';cards=pairs.map((emoji,i)=>{const card=document.createElement('div');card.className='card hidden';card.dataset.emoji=emoji;card.dataset.index=i;card.onclick=()=>flipCard(card);grid.appendChild(card);return card})}function flipCard(card){if(!canFlip||flipped.includes(card)||card.classList.contains('matched'))return;card.classList.remove('hidden');card.textContent=card.dataset.emoji;flipped.push(card);if(flipped.length===2){moves++;canFlip=false;checkMatch()}}function checkMatch(){const[a,b]=flipped;if(a.dataset.emoji===b.dataset.emoji){a.classList.add('matched');b.classList.add('matched');matched++;window.gameScore=matched*10;updateStats();flipped=[];canFlip=true;if(matched===8)endGame()}else{a.classList.add('wrong');b.classList.add('wrong');setTimeout(()=>{a.classList.add('hidden');a.classList.remove('wrong');a.textContent='';b.classList.add('hidden');b.classList.remove('wrong');b.textContent='';flipped=[];canFlip=true},800)}updateStats()}function updateStats(){statsEl.textContent='Moves: '+moves+' | Pairs: '+matched+'/8'}function endGame(){finalMovesEl.textContent=moves;gameOverEl.style.display='block'}function restart(){matched=moves=0;window.gameScore=0;flipped=[];canFlip=true;gameOverEl.style.display='none';updateStats();createCards()}createCards();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Whack-a-Mole",
|
||||
description: "Quick! Whack the moles before they hide! Test your reflexes in this classic arcade game.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Whack-a-Mole</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:linear-gradient(to bottom,#87CEEB,#90EE90);min-height:100vh;display:flex;flex-direction:column;align-items:center;padding:20px;font-family:Arial,sans-serif}h1{color:#333;margin-bottom:10px}#stats{display:flex;gap:30px;margin-bottom:20px;font-size:20px;color:#333}#grid{display:grid;grid-template-columns:repeat(3,1fr);gap:15px}.hole{width:100px;height:100px;background:#8B4513;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative;box-shadow:inset 0 -10px 20px rgba(0,0,0,0.3)}.mole{font-size:60px;opacity:0;transform:translateY(20px);transition:all 0.2s}.mole.up{opacity:1;transform:translateY(-10px)}.hole:active .mole.up{transform:scale(0.8)}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:40px;border-radius:20px;text-align:center;display:none;box-shadow:0 10px 30px rgba(0,0,0,0.3)}#gameOver button{margin-top:15px;padding:12px 30px;font-size:16px;background:#4CAF50;color:white;border:none;border-radius:10px;cursor:pointer}</style></head>
|
||||
<body><h1>Whack-a-Mole!</h1><div id="stats"><span>Score: <span id="score">0</span></span><span>Time: <span id="timer">30</span></span></div><div id="grid"></div><div id="gameOver"><h2>Time's Up!</h2><p>Final Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const grid=document.getElementById('grid'),scoreEl=document.getElementById('score'),timerEl=document.getElementById('timer'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');let score=0,timeLeft=30,gameActive=true,moleTimeout,gameInterval;window.gameScore=0;for(let i=0;i<9;i++){const hole=document.createElement('div');hole.className='hole';hole.innerHTML='<span class="mole">🐹</span>';hole.onclick=()=>whack(hole);grid.appendChild(hole)}const holes=[...document.querySelectorAll('.hole')],moles=[...document.querySelectorAll('.mole')];function showMole(){if(!gameActive)return;moles.forEach(m=>m.classList.remove('up'));const idx=Math.floor(Math.random()*9);moles[idx].classList.add('up');moleTimeout=setTimeout(showMole,Math.max(400,1000-score*10))}function whack(hole){const mole=hole.querySelector('.mole');if(mole.classList.contains('up')){mole.classList.remove('up');score+=10;window.gameScore=score;scoreEl.textContent=score}}function startGame(){score=0;timeLeft=30;gameActive=true;window.gameScore=0;scoreEl.textContent='0';timerEl.textContent='30';gameOverEl.style.display='none';showMole();gameInterval=setInterval(()=>{timeLeft--;timerEl.textContent=timeLeft;if(timeLeft<=0)endGame()},1000)}function endGame(){gameActive=false;clearTimeout(moleTimeout);clearInterval(gameInterval);moles.forEach(m=>m.classList.remove('up'));finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){startGame()}startGame();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Color Match",
|
||||
description: "Match the color to the word! But be careful - the colors are tricky. How fast can you react?",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Color Match</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:#1a1a2e;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;font-family:Arial,sans-serif;color:white}#stats{display:flex;gap:40px;margin-bottom:30px;font-size:20px}#word{font-size:60px;font-weight:bold;margin-bottom:10px;text-transform:uppercase}#question{font-size:18px;margin-bottom:30px;color:#ccc}.buttons{display:flex;gap:15px;flex-wrap:wrap;justify-content:center}button{padding:15px 35px;font-size:18px;border:none;border-radius:10px;cursor:pointer;transition:transform 0.1s}button:hover{transform:scale(1.05)}button:active{transform:scale(0.95)}#result{margin-top:20px;font-size:24px;height:30px}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;color:#333;padding:40px;border-radius:20px;text-align:center;display:none}#gameOver h2{color:#4CAF50}#gameOver button{margin-top:15px;background:#4CAF50;color:white}</style></head>
|
||||
<body><div id="stats"><span>Score: <span id="score">0</span></span><span>Time: <span id="timer">30</span></span></div><div id="word">RED</div><div id="question">Does the COLOR match the WORD?</div><div class="buttons"><button id="yesBtn" style="background:#4CAF50;color:white" onclick="answer(true)">YES</button><button id="noBtn" style="background:#e74c3c;color:white" onclick="answer(false)">NO</button></div><div id="result"></div><div id="gameOver"><h2>Game Over!</h2><p>Final Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const wordEl=document.getElementById('word'),scoreEl=document.getElementById('score'),timerEl=document.getElementById('timer'),resultEl=document.getElementById('result'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');const colors=[{name:'RED',hex:'#e74c3c'},{name:'BLUE',hex:'#3498db'},{name:'GREEN',hex:'#2ecc71'},{name:'YELLOW',hex:'#f1c40f'},{name:'PURPLE',hex:'#9b59b6'},{name:'ORANGE',hex:'#e67e22'}];let score=0,timeLeft=30,currentMatch,gameActive=true;window.gameScore=0;function newRound(){const wordColor=colors[Math.floor(Math.random()*colors.length)];const textColor=colors[Math.floor(Math.random()*colors.length)];currentMatch=wordColor.name===textColor.name;wordEl.textContent=wordColor.name;wordEl.style.color=textColor.hex}function answer(yes){if(!gameActive)return;if(yes===currentMatch){score+=10;window.gameScore=score;scoreEl.textContent=score;resultEl.textContent='✓ Correct!';resultEl.style.color='#4CAF50'}else{score=Math.max(0,score-5);window.gameScore=score;scoreEl.textContent=score;resultEl.textContent='✗ Wrong!';resultEl.style.color='#e74c3c'}setTimeout(()=>{resultEl.textContent='';newRound()},500)}function startGame(){score=0;timeLeft=30;gameActive=true;window.gameScore=0;scoreEl.textContent='0';timerEl.textContent='30';gameOverEl.style.display='none';newRound();const timer=setInterval(()=>{timeLeft--;timerEl.textContent=timeLeft;if(timeLeft<=0){gameActive=false;clearInterval(timer);finalScoreEl.textContent=score;gameOverEl.style.display='block'}},1000)}function restart(){startGame()}startGame();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Fruit Ninja",
|
||||
description: "Slice the fruits as they fly across the screen! Avoid the bombs or it's game over.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fruit Ninja</title>
|
||||
<style>*{margin:0;padding:0}body{background:linear-gradient(to bottom,#2c3e50,#1a252f);min-height:100vh;overflow:hidden;font-family:Arial,sans-serif}#game{position:relative;width:100%;height:100vh;touch-action:none}#score{position:fixed;top:20px;left:20px;color:white;font-size:24px;z-index:100}#lives{position:fixed;top:20px;right:20px;color:white;font-size:24px}.fruit{position:absolute;font-size:50px;cursor:crosshair;user-select:none;transition:opacity 0.2s}.sliced{opacity:0;transform:scale(1.5)rotate(180deg);transition:all 0.3s}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:white;padding:40px;border-radius:20px;text-align:center;display:none;z-index:200}#gameOver button{margin-top:15px;padding:12px 30px;font-size:16px;background:#e74c3c;color:white;border:none;border-radius:10px;cursor:pointer}</style></head>
|
||||
<body><div id="game"><div id="score">Score: 0</div><div id="lives">❤️❤️❤️</div><div id="gameOver"><h2>Game Over!</h2><p>Final Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div></div>
|
||||
<script>const game=document.getElementById('game'),scoreEl=document.getElementById('score'),livesEl=document.getElementById('lives'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore');const fruits=['🍎','🍊','🍋','🍇','🍉','🍓','🥝','🍑'];let score=0,lives=3,gameActive=true;window.gameScore=0;function createFruit(){if(!gameActive)return;const fruit=document.createElement('div');fruit.className='fruit';const isBomb=Math.random()<0.15;fruit.textContent=isBomb?'💣':fruits[Math.floor(Math.random()*fruits.length)];fruit.dataset.bomb=isBomb;const startX=Math.random()*window.innerWidth;fruit.style.left=startX+'px';fruit.style.bottom='-60px';game.appendChild(fruit);const angle=(Math.random()-0.5)*0.5,speed=15+Math.random()*5;let x=startX,y=-60,vy=speed,vx=angle*10,gravity=0.3;function animate(){if(!gameActive){fruit.remove();return}vy-=gravity;y+=vy;x+=vx;fruit.style.bottom=y+'px';fruit.style.left=x+'px';if(y<-100){fruit.remove();if(!fruit.dataset.sliced&&!isBomb){lives--;updateLives();if(lives<=0)endGame()}}else{requestAnimationFrame(animate)}}animate();fruit.onmousedown=fruit.ontouchstart=(e)=>{e.preventDefault();if(!gameActive)return;fruit.dataset.sliced='true';fruit.classList.add('sliced');if(isBomb){lives=0;endGame()}else{score+=10;window.gameScore=score;scoreEl.textContent='Score: '+score}setTimeout(()=>fruit.remove(),300)}}function updateLives(){livesEl.textContent='❤️'.repeat(lives)}function endGame(){gameActive=false;finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){score=0;lives=3;gameActive=true;window.gameScore=0;scoreEl.textContent='Score: 0';updateLives();gameOverEl.style.display='none';document.querySelectorAll('.fruit').forEach(f=>f.remove())}setInterval(createFruit,800);restart();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Math Blaster",
|
||||
description: "Solve math problems as fast as you can! Each correct answer earns points, but wrong answers cost time.",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Math Blaster</title>
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{background:linear-gradient(135deg,#667eea,#764ba2);min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;font-family:Arial,sans-serif;color:white}#stats{display:flex;gap:40px;margin-bottom:30px;font-size:22px}#problem{font-size:48px;margin-bottom:30px;background:rgba(255,255,255,0.1);padding:20px 40px;border-radius:15px}#answer{font-size:32px;padding:15px 30px;border:none;border-radius:10px;text-align:center;width:150px;outline:none}#result{margin-top:20px;font-size:28px;height:40px}.answers{display:flex;gap:15px;flex-wrap:wrap;justify-content:center}.answer-btn{padding:15px 30px;font-size:24px;border:none;border-radius:10px;cursor:pointer;background:rgba(255,255,255,0.9);color:#333;min-width:80px;transition:transform 0.1s}.answer-btn:hover{transform:scale(1.1)}.answer-btn.correct{background:#4CAF50;color:white}.answer-btn.wrong{background:#e74c3c;color:white}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;color:#333;padding:40px;border-radius:20px;text-align:center;display:none}#gameOver h2{color:#4CAF50}#gameOver button{margin-top:15px;padding:12px 30px;font-size:16px;background:#4CAF50;color:white;border:none;border-radius:10px;cursor:pointer}</style></head>
|
||||
<body><div id="stats"><span>Score: <span id="score">0</span></span><span>Time: <span id="timer">60</span></span></div><div id="problem">5 + 3 = ?</div><div class="answers" id="answers"></div><div id="result"></div><div id="gameOver"><h2>Time's Up!</h2><p>Final Score: <span id="finalScore">0</span></p><p>Problems Solved: <span id="solved">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const problemEl=document.getElementById('problem'),answersEl=document.getElementById('answers'),scoreEl=document.getElementById('score'),timerEl=document.getElementById('timer'),resultEl=document.getElementById('result'),gameOverEl=document.getElementById('gameOver'),finalScoreEl=document.getElementById('finalScore'),solvedEl=document.getElementById('solved');let score=0,timeLeft=60,solved=0,correctAnswer,gameActive=true,timer;window.gameScore=0;function newProblem(){const ops=['+','-','×'];const op=ops[Math.floor(Math.random()*ops.length)];let a,b,answer;if(op==='+'){a=Math.floor(Math.random()*20)+1;b=Math.floor(Math.random()*20)+1;answer=a+b}else if(op==='-'){a=Math.floor(Math.random()*20)+10;b=Math.floor(Math.random()*a)+1;answer=a-b}else{a=Math.floor(Math.random()*12)+1;b=Math.floor(Math.random()*12)+1;answer=a*b}correctAnswer=answer;problemEl.textContent=a+' '+op+' '+b+' = ?';const answers=[answer];while(answers.length<4){const wrong=answer+Math.floor(Math.random()*11)-5;if(wrong!==answer&&wrong>0&&!answers.includes(wrong))answers.push(wrong)}answers.sort(()=>Math.random()-0.5);answersEl.innerHTML='';answers.forEach(ans=>{const btn=document.createElement('button');btn.className='answer-btn';btn.textContent=ans;btn.onclick=()=>checkAnswer(ans,btn);answersEl.appendChild(btn)})}function checkAnswer(ans,btn){if(!gameActive)return;if(ans===correctAnswer){btn.classList.add('correct');score+=10;solved++;window.gameScore=score;scoreEl.textContent=score;resultEl.textContent='✓ Correct!';resultEl.style.color='#4CAF50'}else{btn.classList.add('wrong');timeLeft=Math.max(0,timeLeft-3);timerEl.textContent=timeLeft;resultEl.textContent='✗ -3 seconds!';resultEl.style.color='#e74c3c'}setTimeout(()=>{resultEl.textContent='';newProblem()},600)}function startGame(){score=0;timeLeft=60;solved=0;gameActive=true;window.gameScore=0;scoreEl.textContent='0';timerEl.textContent='60';gameOverEl.style.display='none';newProblem();timer=setInterval(()=>{timeLeft--;timerEl.textContent=timeLeft;if(timeLeft<=0){gameActive=false;clearInterval(timer);finalScoreEl.textContent=score;solvedEl.textContent=solved;gameOverEl.style.display='block'}},1000)}function restart(){startGame()}startGame();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Brick Breaker",
|
||||
description: "Classic brick breaking action! Bounce the ball to destroy all bricks. Don't let the ball fall!",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Brick Breaker</title>
|
||||
<style>*{margin:0;padding:0}body{background:#1a1a2e;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;font-family:Arial,sans-serif}#score{color:white;font-size:20px;margin-bottom:10px}canvas{background:#16213e;border-radius:10px;touch-action:none}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.9);color:white;padding:30px;border-radius:15px;text-align:center;display:none}#gameOver button{margin-top:15px;padding:10px 30px;font-size:16px;background:#4CAF50;color:white;border:none;border-radius:5px;cursor:pointer}#controls{color:rgba(255,255,255,0.6);margin-top:10px;font-size:12px}</style></head>
|
||||
<body><div id="score">Score: 0 | Lives: 3</div><canvas id="game"></canvas><div id="controls">← → or touch to move</div><div id="gameOver"><h2 id="endTitle">Game Over</h2><p>Score: <span id="finalScore">0</span></p><button onclick="restart()">Play Again</button></div>
|
||||
<script>const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),scoreEl=document.getElementById('score'),gameOverEl=document.getElementById('gameOver'),endTitleEl=document.getElementById('endTitle'),finalScoreEl=document.getElementById('finalScore');let W=Math.min(400,window.innerWidth-20),H=500;canvas.width=W;canvas.height=H;const paddleW=80,paddleH=12,ballR=8,brickRows=5,brickCols=8,brickW=(W-20)/brickCols,brickH=20;let paddleX=(W-paddleW)/2,ballX=W/2,ballY=H-50,dx=4,dy=-4,score=0,lives=3,bricks=[],gameActive=true;window.gameScore=0;function createBricks(){bricks=[];const colors=['#e74c3c','#e67e22','#f1c40f','#2ecc71','#3498db'];for(let r=0;r<brickRows;r++){for(let c=0;c<brickCols;c++){bricks.push({x:10+c*brickW,y:30+r*(brickH+5),w:brickW-4,h:brickH,color:colors[r],alive:true})}}}function draw(){ctx.clearRect(0,0,W,H);ctx.fillStyle='#4CAF50';ctx.fillRect(paddleX,H-paddleH-10,paddleW,paddleH);ctx.beginPath();ctx.arc(ballX,ballY,ballR,0,Math.PI*2);ctx.fillStyle='white';ctx.fill();bricks.forEach(b=>{if(b.alive){ctx.fillStyle=b.color;ctx.fillRect(b.x,b.y,b.w,b.h)}})}function update(){if(!gameActive)return;ballX+=dx;ballY+=dy;if(ballX<ballR||ballX>W-ballR)dx=-dx;if(ballY<ballR)dy=-dy;if(ballY>H-paddleH-10-ballR&&ballX>paddleX&&ballX<paddleX+paddleW){dy=-dy;dx+=(ballX-(paddleX+paddleW/2))*0.1}if(ballY>H){lives--;if(lives<=0){endGame(false)}else{ballX=W/2;ballY=H-50;dx=4;dy=-4}updateScore()}bricks.forEach(b=>{if(b.alive&&ballX>b.x&&ballX<b.x+b.w&&ballY>b.y&&ballY<b.y+b.h){b.alive=false;dy=-dy;score+=10;window.gameScore=score;updateScore();if(bricks.every(br=>!br.alive))endGame(true)}})}function updateScore(){scoreEl.textContent='Score: '+score+' | Lives: '+lives}function endGame(won){gameActive=false;endTitleEl.textContent=won?'You Win!':'Game Over';finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){score=0;lives=3;window.gameScore=0;paddleX=(W-paddleW)/2;ballX=W/2;ballY=H-50;dx=4;dy=-4;gameActive=true;createBricks();updateScore();gameOverEl.style.display='none'}function gameLoop(){update();draw();requestAnimationFrame(gameLoop)}document.addEventListener('keydown',e=>{if(e.key==='ArrowLeft')paddleX=Math.max(0,paddleX-20);if(e.key==='ArrowRight')paddleX=Math.min(W-paddleW,paddleX+20)});canvas.addEventListener('touchmove',e=>{e.preventDefault();const rect=canvas.getBoundingClientRect();paddleX=Math.max(0,Math.min(W-paddleW,e.touches[0].clientX-rect.left-paddleW/2))});createBricks();gameLoop();</script></body></html>`
|
||||
},
|
||||
{
|
||||
title: "Space Invaders",
|
||||
description: "Defend Earth from alien invaders! Shoot them down before they reach the bottom. Classic arcade action!",
|
||||
code: `<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Space Invaders</title>
|
||||
<style>*{margin:0;padding:0}body{background:#000;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;font-family:Arial,sans-serif}#score{color:#0f0;font-size:20px;margin-bottom:10px;font-family:monospace}canvas{background:#000;border:2px solid #0f0}#gameOver{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,50,0,0.95);color:#0f0;padding:30px;border:2px solid #0f0;text-align:center;display:none;font-family:monospace}#gameOver button{margin-top:15px;padding:10px 30px;font-size:16px;background:#0f0;color:#000;border:none;cursor:pointer;font-family:monospace}#controls{color:#0f0;margin-top:10px;font-size:12px;font-family:monospace}</style></head>
|
||||
<body><div id="score">SCORE: 0 | LIVES: 3</div><canvas id="game"></canvas><div id="controls">← → MOVE | SPACE FIRE | TAP TO SHOOT</div><div id="gameOver"><h2 id="endTitle">GAME OVER</h2><p>SCORE: <span id="finalScore">0</span></p><button onclick="restart()">PLAY AGAIN</button></div>
|
||||
<script>const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),scoreEl=document.getElementById('score'),gameOverEl=document.getElementById('gameOver'),endTitleEl=document.getElementById('endTitle'),finalScoreEl=document.getElementById('finalScore');let W=Math.min(400,window.innerWidth-20),H=500;canvas.width=W;canvas.height=H;const playerW=40,playerH=20;let playerX=W/2-playerW/2,bullets=[],enemies=[],enemyBullets=[],score=0,lives=3,gameActive=true,enemyDir=1,enemySpeed=1;window.gameScore=0;function createEnemies(){enemies=[];for(let r=0;r<4;r++){for(let c=0;c<8;c++){enemies.push({x:30+c*40,y:50+r*35,w:30,h:20,alive:true})}}}function draw(){ctx.fillStyle='#000';ctx.fillRect(0,0,W,H);ctx.fillStyle='#0f0';ctx.fillRect(playerX,H-playerH-10,playerW,playerH);ctx.fillStyle='#0f0';bullets.forEach(b=>{ctx.fillRect(b.x-2,b.y,4,10)});ctx.fillStyle='#fff';enemies.forEach(e=>{if(e.alive){ctx.font='25px Arial';ctx.fillText('👾',e.x,e.y+20)}});ctx.fillStyle='#f00';enemyBullets.forEach(b=>{ctx.fillRect(b.x-2,b.y,4,10)})}function update(){if(!gameActive)return;bullets.forEach((b,i)=>{b.y-=8;if(b.y<0)bullets.splice(i,1)});enemyBullets.forEach((b,i)=>{b.y+=5;if(b.y>H)enemyBullets.splice(i,1);if(b.y>H-playerH-10&&b.x>playerX&&b.x<playerX+playerW){lives--;enemyBullets.splice(i,1);if(lives<=0)endGame(false);updateScore()}});let hitEdge=false;enemies.forEach(e=>{if(e.alive){e.x+=enemyDir*enemySpeed;if(e.x<10||e.x>W-40)hitEdge=true;if(e.y>H-60)endGame(false)}});if(hitEdge){enemyDir*=-1;enemies.forEach(e=>e.y+=20)}if(Math.random()<0.01){const shooters=enemies.filter(e=>e.alive);if(shooters.length){const e=shooters[Math.floor(Math.random()*shooters.length)];enemyBullets.push({x:e.x+15,y:e.y+20})}}bullets.forEach((b,bi)=>{enemies.forEach(e=>{if(e.alive&&b.x>e.x&&b.x<e.x+e.w&&b.y>e.y&&b.y<e.y+e.h){e.alive=false;bullets.splice(bi,1);score+=10;window.gameScore=score;updateScore();if(enemies.every(en=>!en.alive))endGame(true)}})});enemySpeed=1+Math.floor(score/100)*0.2}function updateScore(){scoreEl.textContent='SCORE: '+score+' | LIVES: '+lives}function shoot(){if(gameActive)bullets.push({x:playerX+playerW/2,y:H-playerH-15})}function endGame(won){gameActive=false;endTitleEl.textContent=won?'YOU WIN!':'GAME OVER';finalScoreEl.textContent=score;gameOverEl.style.display='block'}function restart(){score=0;lives=3;window.gameScore=0;bullets=[];enemyBullets=[];enemyDir=1;enemySpeed=1;playerX=W/2-playerW/2;gameActive=true;createEnemies();updateScore();gameOverEl.style.display='none'}function gameLoop(){update();draw();requestAnimationFrame(gameLoop)}document.addEventListener('keydown',e=>{if(e.key==='ArrowLeft')playerX=Math.max(0,playerX-15);if(e.key==='ArrowRight')playerX=Math.min(W-playerW,playerX+15);if(e.key===' ')shoot()});canvas.addEventListener('touchstart',e=>{const rect=canvas.getBoundingClientRect(),tx=e.touches[0].clientX-rect.left;if(tx<W/3)playerX=Math.max(0,playerX-20);else if(tx>W*2/3)playerX=Math.min(W-playerW,playerX+20);else shoot()});createEnemies();gameLoop();</script></body></html>`
|
||||
}
|
||||
];
|
||||
|
||||
async function insertGames() {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Get admin user ID
|
||||
const adminResult = await client.query(
|
||||
"SELECT id FROM users WHERE role = 'admin' LIMIT 1"
|
||||
);
|
||||
|
||||
if (adminResult.rows.length === 0) {
|
||||
console.error('No admin user found!');
|
||||
return;
|
||||
}
|
||||
|
||||
const adminId = adminResult.rows[0].id;
|
||||
console.log('Using admin ID:', adminId);
|
||||
|
||||
for (const game of games) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO games (creator_id, title, description, game_code, status, published_at, play_count)
|
||||
VALUES ($1, $2, $3, $4, 'published', NOW(), $5)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id, title`,
|
||||
[adminId, game.title, game.description, game.code, Math.floor(Math.random() * 100) + 10]
|
||||
);
|
||||
|
||||
if (result.rows[0]) {
|
||||
console.log('Created game:', result.rows[0].title);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All games created successfully!');
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
insertGames().catch(console.error);
|
||||
@@ -0,0 +1,116 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const gamesRoutes = require('./routes/games');
|
||||
const playRoutes = require('./routes/play');
|
||||
const arcadeRoutes = require('./routes/arcade');
|
||||
const developerRoutes = require('./routes/developer');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const usersRoutes = require('./routes/users');
|
||||
const collectionsRoutes = require('./routes/collections');
|
||||
const creationRoutes = require('./routes/creation');
|
||||
const shareRoutes = require('./routes/share');
|
||||
const themesRoutes = require('./routes/themes');
|
||||
const classroomsRoutes = require('./routes/classrooms');
|
||||
const leaderboardRoutes = require('./routes/leaderboard');
|
||||
const contactRoutes = require('./routes/contact');
|
||||
const bugsRoutes = require('./routes/bugs');
|
||||
const localaiRoutes = require('./routes/localai');
|
||||
const pushRoutes = require('./routes/push');
|
||||
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { pool } = require('./models/db');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Trust proxy (behind nginx)
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.NODE_ENV === 'production'
|
||||
? process.env.CORS_ORIGIN
|
||||
: ['http://localhost:3000', 'http://localhost:5173'],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
|
||||
message: { error: 'Too many requests, please try again later.' }
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Stricter rate limit for auth endpoints
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 60000,
|
||||
max: 5,
|
||||
message: { error: 'Too many authentication attempts, please try again later.' }
|
||||
});
|
||||
|
||||
// Body parsing
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Request logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
console.log(`${req.method} ${req.path} ${res.statusCode} ${duration}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/games', gamesRoutes);
|
||||
app.use('/api/play', playRoutes);
|
||||
app.use('/api/arcade', arcadeRoutes);
|
||||
app.use('/api/developer', developerRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/collections', collectionsRoutes);
|
||||
app.use('/api/creation', creationRoutes);
|
||||
app.use('/api/share', shareRoutes);
|
||||
app.use('/api/themes', themesRoutes);
|
||||
app.use('/api/classrooms', classroomsRoutes);
|
||||
app.use('/api/leaderboard', leaderboardRoutes);
|
||||
app.use('/api/contact', contactRoutes);
|
||||
app.use('/api/bugs', bugsRoutes);
|
||||
app.use('/api/localai', localaiRoutes);
|
||||
app.use('/api/push', pushRoutes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down gracefully');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`GamerComp API running on port ${PORT}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,169 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Users = require('../models/users');
|
||||
const { GuestSessions } = require('../models/plays');
|
||||
const { generateUsername } = require('../utils/usernames');
|
||||
|
||||
const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
const user = await Users.findById(decoded.userId);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.is_banned) {
|
||||
return res.status(403).json({ error: 'Account is banned' });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await Users.findById(decoded.userId);
|
||||
|
||||
if (user && !user.is_banned) {
|
||||
req.user = user;
|
||||
}
|
||||
next();
|
||||
} catch {
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
const requireRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
if (!roles.includes(req.user.role)) {
|
||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Middleware that authenticates user OR auto-creates a guest from fingerprint
|
||||
const authenticateOrGuest = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// If token provided, try to authenticate normally
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await Users.findById(decoded.userId);
|
||||
if (user && !user.is_banned) {
|
||||
req.user = user;
|
||||
return next();
|
||||
}
|
||||
} catch {
|
||||
// Token invalid, continue to guest flow
|
||||
}
|
||||
}
|
||||
|
||||
// No valid token - check for fingerprint to create/find guest
|
||||
const fingerprint = req.body.fingerprint || req.headers['x-device-fingerprint'];
|
||||
|
||||
if (!fingerprint || fingerprint.length < 10) {
|
||||
return res.status(401).json({
|
||||
error: 'Authentication required',
|
||||
requiresLogin: true,
|
||||
message: 'Please sign in or allow guest access'
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create guest user
|
||||
const ipAddress = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || 'unknown';
|
||||
|
||||
// Check for existing guest with this fingerprint
|
||||
let user = await Users.findByFingerprint(fingerprint);
|
||||
|
||||
if (!user) {
|
||||
// Create new guest user
|
||||
const username = await generateUsername();
|
||||
user = await Users.create({
|
||||
username,
|
||||
email: null,
|
||||
password: null,
|
||||
isGuest: true,
|
||||
deviceFingerprint: fingerprint
|
||||
});
|
||||
// Set initial tokens for guest
|
||||
await Users.updateTokens(user.id, 5);
|
||||
user = await Users.findById(user.id);
|
||||
}
|
||||
|
||||
// Track/update guest session
|
||||
await GuestSessions.findOrCreate(fingerprint, user.id, userAgent, ipAddress);
|
||||
|
||||
// Refresh daily tokens for guests (5 per day)
|
||||
const session = await GuestSessions.checkAndResetDailyTokens(fingerprint, 5);
|
||||
if (session.tokensGranted > 0) {
|
||||
await Users.updateTokens(user.id, session.tokensGranted);
|
||||
user = await Users.findById(user.id); // Refresh user data
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
req.isAutoGuest = true; // Flag that this was auto-created
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to require non-guest user
|
||||
const requireNonGuest = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
if (req.user.is_guest) {
|
||||
return res.status(403).json({
|
||||
error: 'Sign up required',
|
||||
requiresSignup: true,
|
||||
message: 'Sign up free to access this feature!'
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign({ userId }, process.env.JWT_SECRET, {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticate,
|
||||
optionalAuth,
|
||||
requireRole,
|
||||
generateToken,
|
||||
authenticateOrGuest,
|
||||
requireNonGuest
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
// Validation errors from express-validator
|
||||
if (err.array && typeof err.array === 'function') {
|
||||
return res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: err.array()
|
||||
});
|
||||
}
|
||||
|
||||
// PostgreSQL errors
|
||||
if (err.code === '23505') {
|
||||
return res.status(409).json({ error: 'Resource already exists' });
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
return res.status(400).json({ error: 'Referenced resource not found' });
|
||||
}
|
||||
|
||||
// JWT errors are handled in auth middleware
|
||||
|
||||
// Default error
|
||||
const statusCode = err.statusCode || 500;
|
||||
const message = process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message;
|
||||
|
||||
res.status(statusCode).json({ error: message });
|
||||
};
|
||||
|
||||
module.exports = errorHandler;
|
||||
@@ -0,0 +1,22 @@
|
||||
const { validationResult } = require('express-validator');
|
||||
|
||||
const validate = (validations) => {
|
||||
return async (req, res, next) => {
|
||||
for (const validation of validations) {
|
||||
const result = await validation.run(req);
|
||||
if (!result.isEmpty()) break;
|
||||
}
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (errors.isEmpty()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
res.status(400).json({
|
||||
error: 'Validation failed',
|
||||
details: errors.array().map(e => ({ field: e.path, message: e.msg }))
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = validate;
|
||||
@@ -0,0 +1,145 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Achievements = {
|
||||
async getAll() {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM achievements ORDER BY category, xp_reward'
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getByCode(code) {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM achievements WHERE code = $1',
|
||||
[code]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getUserAchievements(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT a.*, ua.earned_at
|
||||
FROM achievements a
|
||||
LEFT JOIN user_achievements ua ON a.id = ua.achievement_id AND ua.user_id = $1
|
||||
ORDER BY a.category, a.xp_reward`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Alias for getting all achievements with user's earned status
|
||||
async getAllWithUserStatus(userId) {
|
||||
return this.getUserAchievements(userId);
|
||||
},
|
||||
|
||||
async getUserEarnedAchievements(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT a.*, ua.earned_at
|
||||
FROM user_achievements ua
|
||||
JOIN achievements a ON ua.achievement_id = a.id
|
||||
WHERE ua.user_id = $1
|
||||
ORDER BY ua.earned_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async award(userId, achievementCode) {
|
||||
const achievement = await this.getByCode(achievementCode);
|
||||
if (!achievement) return null;
|
||||
|
||||
// Check if already earned
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM user_achievements WHERE user_id = $1 AND achievement_id = $2',
|
||||
[userId, achievement.id]
|
||||
);
|
||||
if (existing.rows.length > 0) return null;
|
||||
|
||||
// Award achievement
|
||||
await pool.query(
|
||||
'INSERT INTO user_achievements (user_id, achievement_id) VALUES ($1, $2)',
|
||||
[userId, achievement.id]
|
||||
);
|
||||
|
||||
// Award XP
|
||||
if (achievement.xp_reward > 0) {
|
||||
await pool.query(
|
||||
'SELECT * FROM award_xp($1, $2, $3, $4)',
|
||||
[userId, achievement.xp_reward, `achievement:${achievementCode}`, achievement.id]
|
||||
);
|
||||
}
|
||||
|
||||
return achievement;
|
||||
},
|
||||
|
||||
async checkAndAward(userId, checkType, value) {
|
||||
const awarded = [];
|
||||
|
||||
switch (checkType) {
|
||||
case 'games_published':
|
||||
if (value === 1) {
|
||||
const a = await this.award(userId, 'first_game');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
if (value === 10) {
|
||||
const a = await this.award(userId, 'ten_games');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'game_plays':
|
||||
if (value >= 100) {
|
||||
const a = await this.award(userId, 'hundred_plays');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
if (value >= 1000) {
|
||||
const a = await this.award(userId, 'thousand_plays');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'games_played':
|
||||
if (value === 1) {
|
||||
const a = await this.award(userId, 'first_play');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
if (value === 50) {
|
||||
const a = await this.award(userId, 'fifty_plays');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'favorites_count':
|
||||
if (value === 10) {
|
||||
const a = await this.award(userId, 'ten_favorites');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'first_follow':
|
||||
const a = await this.award(userId, 'first_follow');
|
||||
if (a) awarded.push(a);
|
||||
break;
|
||||
|
||||
case 'first_collection':
|
||||
const b = await this.award(userId, 'first_collection');
|
||||
if (b) awarded.push(b);
|
||||
break;
|
||||
|
||||
case 'login_streak':
|
||||
if (value === 7) {
|
||||
const a = await this.award(userId, 'week_streak');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
if (value === 30) {
|
||||
const a = await this.award(userId, 'month_streak');
|
||||
if (a) awarded.push(a);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return awarded;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Achievements;
|
||||
@@ -0,0 +1,52 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const AuditLog = {
|
||||
async create(adminId, action, targetType, targetId, details, ipAddress) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO audit_logs (admin_id, action, target_type, target_id, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[adminId, action, targetType, targetId, JSON.stringify(details), ipAddress]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getRecent(limit = 50) {
|
||||
const result = await pool.query(
|
||||
`SELECT al.*, u.username as admin_username
|
||||
FROM audit_logs al
|
||||
JOIN users u ON al.admin_id = u.id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getByAdmin(adminId, limit = 50) {
|
||||
const result = await pool.query(
|
||||
`SELECT al.*, u.username as admin_username
|
||||
FROM audit_logs al
|
||||
JOIN users u ON al.admin_id = u.id
|
||||
WHERE al.admin_id = $1
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT $2`,
|
||||
[adminId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getByTarget(targetType, targetId) {
|
||||
const result = await pool.query(
|
||||
`SELECT al.*, u.username as admin_username
|
||||
FROM audit_logs al
|
||||
JOIN users u ON al.admin_id = u.id
|
||||
WHERE al.target_type = $1 AND al.target_id = $2
|
||||
ORDER BY al.created_at DESC`,
|
||||
[targetType, targetId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = AuditLog;
|
||||
@@ -0,0 +1,299 @@
|
||||
const { pool } = require('./db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
function generateJoinCode() {
|
||||
return crypto.randomBytes(3).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
const Classrooms = {
|
||||
// Create a new classroom
|
||||
async create(teacherId, name, isPublicPublishingAllowed = false) {
|
||||
let joinCode = generateJoinCode();
|
||||
|
||||
// Ensure unique join code
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM classrooms WHERE join_code = $1',
|
||||
[joinCode]
|
||||
);
|
||||
if (existing.rows.length === 0) break;
|
||||
joinCode = generateJoinCode();
|
||||
attempts++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO classrooms (teacher_id, name, join_code, is_public_publishing_allowed)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[teacherId, name, joinCode, isPublicPublishingAllowed]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get classroom by ID
|
||||
async findById(id) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name,
|
||||
(SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count
|
||||
FROM classrooms c
|
||||
JOIN users u ON c.teacher_id = u.id
|
||||
WHERE c.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get classroom by join code
|
||||
async findByJoinCode(joinCode) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name
|
||||
FROM classrooms c
|
||||
JOIN users u ON c.teacher_id = u.id
|
||||
WHERE c.join_code = $1`,
|
||||
[joinCode.toUpperCase()]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get all classrooms for a teacher
|
||||
async getByTeacher(teacherId) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*,
|
||||
(SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count,
|
||||
(SELECT COUNT(*) FROM classroom_assignments WHERE classroom_id = c.id) as assignment_count
|
||||
FROM classrooms c
|
||||
WHERE c.teacher_id = $1
|
||||
ORDER BY c.created_at DESC`,
|
||||
[teacherId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Get all classrooms a student is enrolled in
|
||||
async getByStudent(studentId) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*, u.username as teacher_username, u.display_name as teacher_display_name,
|
||||
cs.joined_at,
|
||||
(SELECT COUNT(*) FROM classroom_students WHERE classroom_id = c.id) as student_count
|
||||
FROM classroom_students cs
|
||||
JOIN classrooms c ON cs.classroom_id = c.id
|
||||
JOIN users u ON c.teacher_id = u.id
|
||||
WHERE cs.student_id = $1
|
||||
ORDER BY cs.joined_at DESC`,
|
||||
[studentId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Update classroom
|
||||
async update(id, teacherId, { name, isPublicPublishingAllowed }) {
|
||||
const result = await pool.query(
|
||||
`UPDATE classrooms
|
||||
SET name = COALESCE($3, name),
|
||||
is_public_publishing_allowed = COALESCE($4, is_public_publishing_allowed)
|
||||
WHERE id = $1 AND teacher_id = $2
|
||||
RETURNING *`,
|
||||
[id, teacherId, name, isPublicPublishingAllowed]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Delete classroom
|
||||
async delete(id, teacherId) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM classrooms WHERE id = $1 AND teacher_id = $2 RETURNING id',
|
||||
[id, teacherId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Regenerate join code
|
||||
async regenerateJoinCode(id, teacherId) {
|
||||
const joinCode = generateJoinCode();
|
||||
const result = await pool.query(
|
||||
'UPDATE classrooms SET join_code = $3 WHERE id = $1 AND teacher_id = $2 RETURNING *',
|
||||
[id, teacherId, joinCode]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Add student to classroom
|
||||
async addStudent(classroomId, studentId) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO classroom_students (classroom_id, student_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (classroom_id, student_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[classroomId, studentId]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
if (error.code === '23505') { // Unique violation
|
||||
return null; // Already enrolled
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Remove student from classroom
|
||||
async removeStudent(classroomId, studentId, teacherId) {
|
||||
// Verify teacher owns classroom
|
||||
const classroom = await this.findById(classroomId);
|
||||
if (!classroom || classroom.teacher_id !== teacherId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM classroom_students WHERE classroom_id = $1 AND student_id = $2 RETURNING *',
|
||||
[classroomId, studentId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get students in a classroom
|
||||
async getStudents(classroomId) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.xp_total, u.creator_level, u.creator_badge,
|
||||
cs.joined_at,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
|
||||
(SELECT COUNT(*) FROM plays WHERE player_id = u.id) as games_played
|
||||
FROM classroom_students cs
|
||||
JOIN users u ON cs.student_id = u.id
|
||||
WHERE cs.classroom_id = $1
|
||||
ORDER BY u.username`,
|
||||
[classroomId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Check if user is student in classroom
|
||||
async isStudent(classroomId, studentId) {
|
||||
const result = await pool.query(
|
||||
'SELECT id FROM classroom_students WHERE classroom_id = $1 AND student_id = $2',
|
||||
[classroomId, studentId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
},
|
||||
|
||||
// Check if user is teacher of classroom
|
||||
async isTeacher(classroomId, teacherId) {
|
||||
const result = await pool.query(
|
||||
'SELECT id FROM classrooms WHERE id = $1 AND teacher_id = $2',
|
||||
[classroomId, teacherId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
},
|
||||
|
||||
// Create assignment
|
||||
async createAssignment(classroomId, { title, description, requiredGameStyle, dueDate }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO classroom_assignments (classroom_id, title, description, required_game_style, due_date)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[classroomId, title, description, requiredGameStyle, dueDate]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get assignments for a classroom
|
||||
async getAssignments(classroomId) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM classroom_assignments
|
||||
WHERE classroom_id = $1
|
||||
ORDER BY due_date ASC NULLS LAST, created_at DESC`,
|
||||
[classroomId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Get assignment by ID
|
||||
async getAssignment(assignmentId) {
|
||||
const result = await pool.query(
|
||||
`SELECT a.*, c.teacher_id, c.name as classroom_name
|
||||
FROM classroom_assignments a
|
||||
JOIN classrooms c ON a.classroom_id = c.id
|
||||
WHERE a.id = $1`,
|
||||
[assignmentId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Delete assignment
|
||||
async deleteAssignment(assignmentId, teacherId) {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM classroom_assignments a
|
||||
USING classrooms c
|
||||
WHERE a.id = $1 AND a.classroom_id = c.id AND c.teacher_id = $2
|
||||
RETURNING a.id`,
|
||||
[assignmentId, teacherId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get student progress in classroom
|
||||
async getStudentProgress(classroomId, studentId) {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
u.xp_total, u.creator_level, u.creator_badge,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = $2 AND status = 'published') as games_created,
|
||||
(SELECT COUNT(*) FROM plays WHERE player_id = $2) as total_plays,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = $2) as games_played_by_others
|
||||
FROM users u
|
||||
WHERE u.id = $2`,
|
||||
[classroomId, studentId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Get classroom leaderboard
|
||||
async getLeaderboard(classroomId) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.xp_total, u.creator_level, u.creator_badge,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
|
||||
RANK() OVER (ORDER BY u.xp_total DESC) as rank
|
||||
FROM classroom_students cs
|
||||
JOIN users u ON cs.student_id = u.id
|
||||
WHERE cs.classroom_id = $1
|
||||
ORDER BY u.xp_total DESC
|
||||
LIMIT 50`,
|
||||
[classroomId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
// Get detailed student progress for teacher console
|
||||
async getDetailedStudentProgress(classroomId) {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.xp_total,
|
||||
u.creator_level as level,
|
||||
u.creator_badge as badge,
|
||||
cs.joined_at,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
|
||||
(SELECT COUNT(*) FROM plays WHERE player_id = u.id) as games_played,
|
||||
(SELECT COUNT(*) FROM user_achievements WHERE user_id = u.id) as achievement_count,
|
||||
(SELECT COALESCE(SUM(xp_transactions.amount), 0)
|
||||
FROM xp_transactions
|
||||
WHERE user_id = u.id
|
||||
AND created_at >= NOW() - INTERVAL '7 days') as xp_this_week,
|
||||
(SELECT COUNT(*) FROM games
|
||||
WHERE creator_id = u.id
|
||||
AND status = 'published'
|
||||
AND created_at >= NOW() - INTERVAL '7 days') as games_this_week,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays_on_games
|
||||
FROM classroom_students cs
|
||||
JOIN users u ON cs.student_id = u.id
|
||||
WHERE cs.classroom_id = $1
|
||||
ORDER BY u.xp_total DESC`,
|
||||
[classroomId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Classrooms;
|
||||
@@ -0,0 +1,135 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Collections = {
|
||||
async create({ userId, name, description, isPublic = true }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO collections (user_id, name, description, is_public)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[userId, name, description, isPublic]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(collectionId) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*, u.username as owner_username, u.display_name as owner_display_name,
|
||||
(SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
|
||||
FROM collections c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = $1`,
|
||||
[collectionId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async update(collectionId, userId, { name, description, isPublic }) {
|
||||
const result = await pool.query(
|
||||
`UPDATE collections
|
||||
SET name = COALESCE($3, name),
|
||||
description = COALESCE($4, description),
|
||||
is_public = COALESCE($5, is_public)
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *`,
|
||||
[collectionId, userId, name, description, isPublic]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async delete(collectionId, userId) {
|
||||
await pool.query(
|
||||
'DELETE FROM collections WHERE id = $1 AND user_id = $2',
|
||||
[collectionId, userId]
|
||||
);
|
||||
},
|
||||
|
||||
async getUserCollections(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*,
|
||||
(SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
|
||||
FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
ORDER BY c.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getPublicCollections(limit = 20, offset = 0) {
|
||||
const result = await pool.query(
|
||||
`SELECT c.*, u.username as owner_username, u.display_name as owner_display_name,
|
||||
(SELECT COUNT(*) FROM collection_games WHERE collection_id = c.id) as game_count
|
||||
FROM collections c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.is_public = true
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async addGame(collectionId, gameId, userId) {
|
||||
// Verify ownership
|
||||
const collection = await pool.query(
|
||||
'SELECT id FROM collections WHERE id = $1 AND user_id = $2',
|
||||
[collectionId, userId]
|
||||
);
|
||||
if (collection.rows.length === 0) {
|
||||
throw new Error('Collection not found or not owned by user');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO collection_games (collection_id, game_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (collection_id, game_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[collectionId, gameId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async removeGame(collectionId, gameId, userId) {
|
||||
// Verify ownership
|
||||
const collection = await pool.query(
|
||||
'SELECT id FROM collections WHERE id = $1 AND user_id = $2',
|
||||
[collectionId, userId]
|
||||
);
|
||||
if (collection.rows.length === 0) {
|
||||
throw new Error('Collection not found or not owned by user');
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'DELETE FROM collection_games WHERE collection_id = $1 AND game_id = $2',
|
||||
[collectionId, gameId]
|
||||
);
|
||||
},
|
||||
|
||||
async getGames(collectionId, includePrivate = false) {
|
||||
const collection = await this.findById(collectionId);
|
||||
if (!collection) return [];
|
||||
if (!collection.is_public && !includePrivate) return [];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT g.*, u.username as creator_username, u.display_name as creator_display_name,
|
||||
cg.added_at
|
||||
FROM collection_games cg
|
||||
JOIN games g ON cg.game_id = g.id
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE cg.collection_id = $1 AND g.status = 'published'
|
||||
ORDER BY cg.added_at DESC`,
|
||||
[collectionId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getCount(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM collections WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Collections;
|
||||
@@ -0,0 +1,24 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
database: process.env.DB_NAME,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
// Test connection on startup
|
||||
pool.query('SELECT NOW()')
|
||||
.then(() => console.log('Database connected'))
|
||||
.catch(err => console.error('Database connection error:', err));
|
||||
|
||||
module.exports = { pool };
|
||||
@@ -0,0 +1,96 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Favorites = {
|
||||
async add(userId, gameId) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO favorites (user_id, game_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, game_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[userId, gameId]
|
||||
);
|
||||
|
||||
// Update game favorite count
|
||||
if (result.rows.length > 0) {
|
||||
await pool.query(
|
||||
'UPDATE games SET favorite_count = favorite_count + 1 WHERE id = $1',
|
||||
[gameId]
|
||||
);
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async remove(userId, gameId) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM favorites WHERE user_id = $1 AND game_id = $2 RETURNING *',
|
||||
[userId, gameId]
|
||||
);
|
||||
|
||||
// Update game favorite count
|
||||
if (result.rows.length > 0) {
|
||||
await pool.query(
|
||||
'UPDATE games SET favorite_count = GREATEST(0, favorite_count - 1) WHERE id = $1',
|
||||
[gameId]
|
||||
);
|
||||
}
|
||||
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async toggle(userId, gameId) {
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM favorites WHERE user_id = $1 AND game_id = $2',
|
||||
[userId, gameId]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
await this.remove(userId, gameId);
|
||||
return { favorited: false };
|
||||
} else {
|
||||
await this.add(userId, gameId);
|
||||
return { favorited: true };
|
||||
}
|
||||
},
|
||||
|
||||
async isFavorited(userId, gameId) {
|
||||
const result = await pool.query(
|
||||
'SELECT id FROM favorites WHERE user_id = $1 AND game_id = $2',
|
||||
[userId, gameId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
},
|
||||
|
||||
async getUserFavorites(userId, limit = 50, offset = 0) {
|
||||
const result = await pool.query(
|
||||
`SELECT g.*, u.username as creator_username, u.display_name as creator_display_name,
|
||||
f.created_at as favorited_at
|
||||
FROM favorites f
|
||||
JOIN games g ON f.game_id = g.id
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE f.user_id = $1 AND g.status = 'published'
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getCount(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM favorites WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
},
|
||||
|
||||
async getUserFavoriteIds(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT game_id FROM favorites WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map(r => r.game_id);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Favorites;
|
||||
@@ -0,0 +1,80 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Follows = {
|
||||
async follow(followerId, followingId) {
|
||||
if (followerId === followingId) {
|
||||
throw new Error('Cannot follow yourself');
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO follows (follower_id, following_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (follower_id, following_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[followerId, followingId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async unfollow(followerId, followingId) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM follows WHERE follower_id = $1 AND following_id = $2 RETURNING *',
|
||||
[followerId, followingId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async isFollowing(followerId, followingId) {
|
||||
const result = await pool.query(
|
||||
'SELECT id FROM follows WHERE follower_id = $1 AND following_id = $2',
|
||||
[followerId, followingId]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
},
|
||||
|
||||
async getFollowers(userId, limit = 50, offset = 0) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.avatar_data,
|
||||
u.creator_level, u.creator_badge, f.created_at as followed_at
|
||||
FROM follows f
|
||||
JOIN users u ON f.follower_id = u.id
|
||||
WHERE f.following_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getFollowing(userId, limit = 50, offset = 0) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.avatar_data,
|
||||
u.creator_level, u.creator_badge, f.created_at as followed_at
|
||||
FROM follows f
|
||||
JOIN users u ON f.following_id = u.id
|
||||
WHERE f.follower_id = $1
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[userId, limit, offset]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getFollowerCount(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM follows WHERE following_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
},
|
||||
|
||||
async getFollowingCount(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM follows WHERE follower_id = $1',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Follows;
|
||||
@@ -0,0 +1,67 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const GameVersions = {
|
||||
async saveVersion(gameId, gameCode, changeType, description = null, costData = {}) {
|
||||
// Get next version number
|
||||
const versionResult = await pool.query(
|
||||
'SELECT COALESCE(MAX(version_number), 0) + 1 as next_version FROM game_versions WHERE game_id = $1',
|
||||
[gameId]
|
||||
);
|
||||
const versionNumber = versionResult.rows[0].next_version;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO game_versions (game_id, version_number, game_code, change_type, change_description,
|
||||
claude_input_tokens, claude_output_tokens, api_cost_cents)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, version_number, change_type, change_description, created_at`,
|
||||
[
|
||||
gameId,
|
||||
versionNumber,
|
||||
gameCode,
|
||||
changeType,
|
||||
description,
|
||||
costData.inputTokens || 0,
|
||||
costData.outputTokens || 0,
|
||||
costData.apiCostCents || 0
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getVersions(gameId) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, version_number, change_type, change_description,
|
||||
LENGTH(game_code) as code_length,
|
||||
claude_input_tokens, claude_output_tokens, api_cost_cents,
|
||||
created_at
|
||||
FROM game_versions
|
||||
WHERE game_id = $1
|
||||
ORDER BY version_number DESC`,
|
||||
[gameId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getVersion(gameId, versionNumber) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM game_versions
|
||||
WHERE game_id = $1 AND version_number = $2`,
|
||||
[gameId, versionNumber]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
},
|
||||
|
||||
async getLatestVersion(gameId) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM game_versions
|
||||
WHERE game_id = $1
|
||||
ORDER BY version_number DESC
|
||||
LIMIT 1`,
|
||||
[gameId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = GameVersions;
|
||||
@@ -0,0 +1,166 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Games = {
|
||||
async create({ creatorId, title, description, gameCode, prompt }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO games (creator_id, title, description, game_code, prompt)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[creatorId, title, description, gameCode, prompt]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(id) {
|
||||
const result = await pool.query(
|
||||
`SELECT g.*, g.prompt, u.username as creator_username, u.display_name as creator_display_name
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByCreator(creatorId) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM games WHERE creator_id = $1 ORDER BY created_at DESC`,
|
||||
[creatorId]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async update(id, { title, description, gameCode }) {
|
||||
const result = await pool.query(
|
||||
`UPDATE games SET
|
||||
title = COALESCE($2, title),
|
||||
description = COALESCE($3, description),
|
||||
game_code = COALESCE($4, game_code)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, title, description, gameCode]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async submit(id) {
|
||||
const result = await pool.query(
|
||||
`UPDATE games SET status = 'pending_review' WHERE id = $1 AND status = 'draft' RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async approve(id) {
|
||||
const result = await pool.query(
|
||||
`UPDATE games SET status = 'published', published_at = NOW() WHERE id = $1 RETURNING *`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async reject(id, reason) {
|
||||
const result = await pool.query(
|
||||
`UPDATE games SET status = 'rejected', rejection_reason = $2 WHERE id = $1 RETURNING *`,
|
||||
[id, reason]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await pool.query(`DELETE FROM games WHERE id = $1`, [id]);
|
||||
},
|
||||
|
||||
async incrementPlayCount(id) {
|
||||
await pool.query(
|
||||
`UPDATE games SET play_count = play_count + 1, last_played = NOW() WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
},
|
||||
|
||||
async addRating(gameId, rating) {
|
||||
await pool.query(
|
||||
`UPDATE games SET total_ratings = total_ratings + 1, rating_sum = rating_sum + $2 WHERE id = $1`,
|
||||
[gameId, rating]
|
||||
);
|
||||
},
|
||||
|
||||
async addTokensEarned(id, tokens) {
|
||||
await pool.query(
|
||||
`UPDATE games SET tokens_earned = tokens_earned + $2 WHERE id = $1`,
|
||||
[id, tokens]
|
||||
);
|
||||
},
|
||||
|
||||
async getPublished({ sort = 'trending', page = 1, limit = 20, search = '', difficulty = '' }) {
|
||||
const offset = (page - 1) * limit;
|
||||
let orderBy = 'play_count DESC';
|
||||
|
||||
switch (sort) {
|
||||
case 'new':
|
||||
orderBy = 'published_at DESC';
|
||||
break;
|
||||
case 'top-rated':
|
||||
orderBy = 'CASE WHEN total_ratings > 0 THEN rating_sum::float / total_ratings ELSE 0 END DESC';
|
||||
break;
|
||||
case 'trending':
|
||||
default:
|
||||
orderBy = 'play_count DESC, published_at DESC';
|
||||
}
|
||||
|
||||
const params = [limit, offset];
|
||||
const clauses = [];
|
||||
|
||||
if (search) {
|
||||
params.push(`%${search}%`);
|
||||
clauses.push(`(title ILIKE $${params.length} OR description ILIKE $${params.length})`);
|
||||
}
|
||||
|
||||
if (difficulty) {
|
||||
params.push(difficulty);
|
||||
clauses.push(`$${params.length} = ANY(difficulty_tags)`);
|
||||
}
|
||||
|
||||
const whereClause = clauses.length > 0 ? 'AND ' + clauses.join(' AND ') : '';
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT g.id, g.title, g.description, g.thumbnail_url, g.thumbnail_data, g.play_count,
|
||||
g.total_ratings, g.rating_sum, g.published_at, g.is_featured,
|
||||
g.game_code, g.prompt, g.creator_id, g.favorite_count,
|
||||
g.difficulty_tags, g.game_style,
|
||||
u.username as creator_username, u.display_name as creator_display_name,
|
||||
CASE WHEN g.total_ratings > 0 THEN g.rating_sum::float / g.total_ratings ELSE 0 END as average_rating
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.status = 'published' ${whereClause}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getPendingReview() {
|
||||
const result = await pool.query(
|
||||
`SELECT g.*, u.username as creator_username
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.status = 'pending_review'
|
||||
ORDER BY g.created_at ASC`
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getByUser(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, description, thumbnail_url, play_count, total_ratings, rating_sum, status, published_at
|
||||
FROM games
|
||||
WHERE creator_id = $1 AND status = 'published'
|
||||
ORDER BY published_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Games;
|
||||
@@ -0,0 +1,121 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Notifications = {
|
||||
async create({ userId, type, title, message, link }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO notifications (user_id, type, title, message, link)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[userId, type, title, message, link]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getForUser(userId, limit = 50, unreadOnly = false) {
|
||||
const whereClause = unreadOnly ? 'AND is_read = false' : '';
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM notifications
|
||||
WHERE user_id = $1 ${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async markAsRead(notificationId, userId) {
|
||||
const result = await pool.query(
|
||||
`UPDATE notifications
|
||||
SET is_read = true
|
||||
WHERE id = $1 AND user_id = $2
|
||||
RETURNING *`,
|
||||
[notificationId, userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async markAllAsRead(userId) {
|
||||
await pool.query(
|
||||
'UPDATE notifications SET is_read = true WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
},
|
||||
|
||||
async getUnreadCount(userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM notifications WHERE user_id = $1 AND is_read = false',
|
||||
[userId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
},
|
||||
|
||||
async delete(notificationId, userId) {
|
||||
await pool.query(
|
||||
'DELETE FROM notifications WHERE id = $1 AND user_id = $2',
|
||||
[notificationId, userId]
|
||||
);
|
||||
},
|
||||
|
||||
// Helper methods to create specific notification types
|
||||
async notifyNewFollower(userId, followerUsername) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'new_follower',
|
||||
title: 'New Follower!',
|
||||
message: `${followerUsername} started following you`,
|
||||
link: `/creator/${followerUsername}`
|
||||
});
|
||||
},
|
||||
|
||||
async notifyGameMilestone(userId, gameTitle, milestone) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'milestone',
|
||||
title: `${milestone} plays!`,
|
||||
message: `Your game "${gameTitle}" reached ${milestone} plays!`,
|
||||
link: null
|
||||
});
|
||||
},
|
||||
|
||||
async notifyAchievement(userId, achievementName, achievementIcon) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'achievement',
|
||||
title: 'Achievement Unlocked!',
|
||||
message: `${achievementIcon} ${achievementName}`,
|
||||
link: '/achievements'
|
||||
});
|
||||
},
|
||||
|
||||
async notifyGameRemixed(userId, gameTitle, remixerUsername) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'remix',
|
||||
title: 'Your game was remixed!',
|
||||
message: `${remixerUsername} remixed "${gameTitle}"`,
|
||||
link: `/creator/${remixerUsername}`
|
||||
});
|
||||
},
|
||||
|
||||
async notifyLevelUp(userId, newLevel, newBadge) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'level_up',
|
||||
title: 'Level Up!',
|
||||
message: `You're now Level ${newLevel}: ${newBadge}`,
|
||||
link: '/profile'
|
||||
});
|
||||
},
|
||||
|
||||
async notifyGameReady(userId, gameTitle, gameId) {
|
||||
return this.create({
|
||||
userId,
|
||||
type: 'game_ready',
|
||||
title: 'Your Game is Ready!',
|
||||
message: `"${gameTitle}" has been generated and is ready to play!`,
|
||||
link: `/play/${gameId}?newGame=true`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Notifications;
|
||||
@@ -0,0 +1,192 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Plays = {
|
||||
async create({ playerId, gameId, deviceFingerprint, ipAddress, isCreatorPlay = false }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO plays (player_id, game_id, device_fingerprint, ip_address, is_creator_play)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[playerId, gameId, deviceFingerprint, ipAddress, isCreatorPlay]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async finish(id, { score, durationSeconds, levelsCompleted = 0, tokenSpent = false }) {
|
||||
const result = await pool.query(
|
||||
`UPDATE plays SET ended_at = NOW(), score = $2, duration_seconds = $3,
|
||||
levels_completed = $4, token_spent = $5
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, score, durationSeconds, levelsCompleted, tokenSpent]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(id) {
|
||||
const result = await pool.query('SELECT * FROM plays WHERE id = $1', [id]);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getPlayerPlaysToday(playerId) {
|
||||
const result = await pool.query(
|
||||
`SELECT COUNT(*) FROM plays
|
||||
WHERE player_id = $1 AND started_at::date = CURRENT_DATE`,
|
||||
[playerId]
|
||||
);
|
||||
return parseInt(result.rows[0].count);
|
||||
}
|
||||
};
|
||||
|
||||
const HighScores = {
|
||||
async upsert({ gameId, playerId, score, difficulty = null }) {
|
||||
// Check existing score
|
||||
const existing = await pool.query(
|
||||
'SELECT score FROM high_scores WHERE game_id = $1 AND player_id = $2',
|
||||
[gameId, playerId]
|
||||
);
|
||||
const previousScore = existing.rows[0]?.score || 0;
|
||||
const isNew = score > previousScore;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO high_scores (game_id, player_id, score, difficulty)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (game_id, player_id)
|
||||
DO UPDATE SET score = GREATEST(high_scores.score, EXCLUDED.score),
|
||||
difficulty = COALESCE(EXCLUDED.difficulty, high_scores.difficulty),
|
||||
achieved_at = CASE WHEN EXCLUDED.score > high_scores.score THEN NOW() ELSE high_scores.achieved_at END
|
||||
RETURNING *`,
|
||||
[gameId, playerId, score, difficulty]
|
||||
);
|
||||
return { ...result.rows[0], isNew };
|
||||
},
|
||||
|
||||
async getTopScores(gameId, limit = 50) {
|
||||
const result = await pool.query(
|
||||
`SELECT hs.score, hs.achieved_at, u.username, u.display_name
|
||||
FROM high_scores hs
|
||||
JOIN users u ON hs.player_id = u.id
|
||||
WHERE hs.game_id = $1
|
||||
ORDER BY hs.score DESC
|
||||
LIMIT $2`,
|
||||
[gameId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
};
|
||||
|
||||
const Ratings = {
|
||||
async upsert({ gameId, userId, rating }) {
|
||||
// Check if user already rated
|
||||
const existing = await pool.query(
|
||||
'SELECT id, rating FROM ratings WHERE game_id = $1 AND user_id = $2',
|
||||
[gameId, userId]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
// Update existing rating - adjust game stats
|
||||
const oldRating = existing.rows[0].rating;
|
||||
await pool.query(
|
||||
'UPDATE ratings SET rating = $3 WHERE game_id = $1 AND user_id = $2',
|
||||
[gameId, userId, rating]
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE games SET rating_sum = rating_sum - $2 + $3 WHERE id = $1',
|
||||
[gameId, oldRating, rating]
|
||||
);
|
||||
} else {
|
||||
// New rating
|
||||
await pool.query(
|
||||
'INSERT INTO ratings (game_id, user_id, rating) VALUES ($1, $2, $3)',
|
||||
[gameId, userId, rating]
|
||||
);
|
||||
await pool.query(
|
||||
'UPDATE games SET total_ratings = total_ratings + 1, rating_sum = rating_sum + $2 WHERE id = $1',
|
||||
[gameId, rating]
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async getUserRating(gameId, userId) {
|
||||
const result = await pool.query(
|
||||
'SELECT rating FROM ratings WHERE game_id = $1 AND user_id = $2',
|
||||
[gameId, userId]
|
||||
);
|
||||
return result.rows[0]?.rating;
|
||||
}
|
||||
};
|
||||
|
||||
const DeveloperEarnings = {
|
||||
async create({ developerId, gameId, tokensEarned, playId = null, source = 'play' }) {
|
||||
await pool.query(
|
||||
`INSERT INTO developer_earnings (developer_id, game_id, tokens_earned, play_id, source)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[developerId, gameId, tokensEarned, playId, source]
|
||||
);
|
||||
},
|
||||
|
||||
async getByDeveloper(developerId) {
|
||||
const result = await pool.query(
|
||||
`SELECT SUM(tokens_earned) as total_earned,
|
||||
COUNT(*) as total_plays_monetized
|
||||
FROM developer_earnings
|
||||
WHERE developer_id = $1`,
|
||||
[developerId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
};
|
||||
|
||||
const GuestSessions = {
|
||||
async findOrCreate(fingerprint, userId, userAgent, ipAddress) {
|
||||
// Sanitize IP address - must be valid inet or null
|
||||
const sanitizedIp = ipAddress && ipAddress !== 'unknown' && /^[\d.:a-fA-F]+$/.test(ipAddress)
|
||||
? ipAddress
|
||||
: null;
|
||||
|
||||
// Check if exists
|
||||
let result = await pool.query(
|
||||
'SELECT * FROM guest_sessions WHERE device_fingerprint = $1',
|
||||
[fingerprint]
|
||||
);
|
||||
|
||||
if (result.rows[0]) {
|
||||
// Update last seen and user_id (in case guest was just created)
|
||||
await pool.query(
|
||||
'UPDATE guest_sessions SET last_seen = NOW(), user_agent = $1, ip_address = COALESCE($2, ip_address), user_id = COALESCE($3, user_id) WHERE id = $4',
|
||||
[userAgent, sanitizedIp, userId, result.rows[0].id]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// Create new
|
||||
result = await pool.query(
|
||||
`INSERT INTO guest_sessions (device_fingerprint, user_id, user_agent, ip_address)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[fingerprint, userId, userAgent, sanitizedIp]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async checkAndResetDailyTokens(fingerprint, dailyTokens = 5) {
|
||||
// Check if tokens need to be reset for today
|
||||
const result = await pool.query(
|
||||
`UPDATE guest_sessions
|
||||
SET tokens_granted_today = CASE
|
||||
WHEN last_token_grant IS NULL OR last_token_grant < CURRENT_DATE THEN $2
|
||||
ELSE tokens_granted_today
|
||||
END,
|
||||
last_token_grant = CURRENT_DATE
|
||||
WHERE device_fingerprint = $1
|
||||
RETURNING tokens_granted_today,
|
||||
CASE WHEN last_token_grant IS NULL OR last_token_grant < CURRENT_DATE THEN $2 ELSE 0 END as tokens_granted`,
|
||||
[fingerprint, dailyTokens]
|
||||
);
|
||||
return {
|
||||
tokensGrantedToday: result.rows[0]?.tokens_granted_today ?? 0,
|
||||
tokensGranted: result.rows[0]?.tokens_granted ?? 0
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { Plays, HighScores, Ratings, DeveloperEarnings, GuestSessions };
|
||||
@@ -0,0 +1,95 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
const Themes = {
|
||||
async getCurrentTheme() {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM weekly_themes
|
||||
WHERE start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
|
||||
AND is_active = true
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1`
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getUpcomingThemes(limit = 4) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM weekly_themes
|
||||
WHERE start_date > CURRENT_DATE
|
||||
ORDER BY start_date ASC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getPastThemes(limit = 8) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM weekly_themes
|
||||
WHERE end_date < CURRENT_DATE
|
||||
ORDER BY end_date DESC
|
||||
LIMIT $1`,
|
||||
[limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM weekly_themes WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async create({ name, description, startDate, endDate, bonusXpMultiplier = 1.5 }) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO weekly_themes (name, description, start_date, end_date, bonus_xp_multiplier, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, false)
|
||||
RETURNING *`,
|
||||
[name, description, startDate, endDate, bonusXpMultiplier]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async activate(id) {
|
||||
const result = await pool.query(
|
||||
'UPDATE weekly_themes SET is_active = true WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async deactivate(id) {
|
||||
const result = await pool.query(
|
||||
'UPDATE weekly_themes SET is_active = false WHERE id = $1 RETURNING *',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
// Check if a game matches the current theme based on title/description keywords
|
||||
async checkThemeMatch(gameTitle, gameDescription, answers) {
|
||||
const currentTheme = await this.getCurrentTheme();
|
||||
if (!currentTheme) return { matches: false };
|
||||
|
||||
// Simple keyword matching - check if theme name words appear in game content
|
||||
const themeWords = currentTheme.name.toLowerCase().split(/\s+/);
|
||||
const gameContent = `${gameTitle} ${gameDescription} ${JSON.stringify(answers || {})}`.toLowerCase();
|
||||
|
||||
const matchCount = themeWords.filter(word =>
|
||||
word.length > 3 && gameContent.includes(word)
|
||||
).length;
|
||||
|
||||
// Match if at least one significant word matches
|
||||
const matches = matchCount > 0;
|
||||
|
||||
return {
|
||||
matches,
|
||||
theme: currentTheme,
|
||||
bonusMultiplier: matches ? parseFloat(currentTheme.bonus_xp_multiplier) : 1
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Themes;
|
||||
@@ -0,0 +1,227 @@
|
||||
const { pool } = require('./db');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
const Users = {
|
||||
async create({ username, email, password, isGuest = false, deviceFingerprint = null }) {
|
||||
const passwordHash = password ? await bcrypt.hash(password, SALT_ROUNDS) : null;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name, is_guest, device_fingerprint)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, username, email, display_name, is_guest, tokens_balance, role, created_at`,
|
||||
[username, email, passwordHash, username, isGuest, deviceFingerprint]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByEmail(email) {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE email = $1 AND is_banned = false',
|
||||
[email]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByUsername(username) {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findById(id) {
|
||||
const result = await pool.query(
|
||||
`SELECT id, username, email, display_name, is_guest, tokens_balance, total_tokens_earned,
|
||||
role, is_teacher, created_at, is_banned, xp_total, creator_level, creator_badge,
|
||||
daily_login_streak, weekly_creation_streak, avatar_data, is_verified,
|
||||
onboarding_complete, onboarding_step, total_api_cost_cents, total_generations
|
||||
FROM users WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async findByFingerprint(fingerprint) {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE device_fingerprint = $1 AND is_guest = true',
|
||||
[fingerprint]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async verifyPassword(user, password) {
|
||||
if (!user.password_hash) return false;
|
||||
return bcrypt.compare(password, user.password_hash);
|
||||
},
|
||||
|
||||
async updatePassword(id, newPassword) {
|
||||
const passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $2 WHERE id = $1',
|
||||
[id, passwordHash]
|
||||
);
|
||||
},
|
||||
|
||||
async updateLastLogin(id) {
|
||||
await pool.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
},
|
||||
|
||||
async updateTokens(id, amount) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET tokens_balance = tokens_balance + $2,
|
||||
total_tokens_earned = CASE WHEN $2 > 0 THEN total_tokens_earned + $2 ELSE total_tokens_earned END
|
||||
WHERE id = $1
|
||||
RETURNING tokens_balance`,
|
||||
[id, amount]
|
||||
);
|
||||
return result.rows[0]?.tokens_balance;
|
||||
},
|
||||
|
||||
async getTokenBalance(id) {
|
||||
const result = await pool.query(
|
||||
'SELECT tokens_balance FROM users WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
return result.rows[0]?.tokens_balance ?? 0;
|
||||
},
|
||||
|
||||
async update(id, { displayName, email }) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET display_name = COALESCE($2, display_name), email = COALESCE($3, email)
|
||||
WHERE id = $1
|
||||
RETURNING id, username, email, display_name, tokens_balance, role`,
|
||||
[id, displayName, email]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async ban(id) {
|
||||
await pool.query('UPDATE users SET is_banned = true WHERE id = $1', [id]);
|
||||
},
|
||||
|
||||
async grantDailyTokens() {
|
||||
// Grant 50 tokens to all users daily, cap at 100
|
||||
await pool.query(
|
||||
`UPDATE users SET tokens_balance = LEAST(tokens_balance + 50, 100)`
|
||||
);
|
||||
},
|
||||
|
||||
async getPublicProfile(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.avatar_data,
|
||||
u.xp_total, u.creator_level, u.creator_badge, u.created_at,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_count,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays,
|
||||
(SELECT COUNT(*) FROM follows WHERE following_id = u.id) as followers_count,
|
||||
(SELECT COUNT(*) FROM follows WHERE follower_id = u.id) as following_count
|
||||
FROM users u
|
||||
WHERE u.id = $1 AND u.is_banned = false`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getPublicProfileByUsername(username) {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.display_name, u.avatar_data,
|
||||
u.xp_total, u.creator_level, u.creator_badge, u.created_at,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_count,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays,
|
||||
(SELECT COUNT(*) FROM follows WHERE following_id = u.id) as followers_count,
|
||||
(SELECT COUNT(*) FROM follows WHERE follower_id = u.id) as following_count
|
||||
FROM users u
|
||||
WHERE u.username = $1 AND u.is_banned = false`,
|
||||
[username]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async updateLoginStreak(userId) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET
|
||||
daily_login_streak = CASE
|
||||
WHEN last_login_date = CURRENT_DATE - INTERVAL '1 day' THEN daily_login_streak + 1
|
||||
WHEN last_login_date = CURRENT_DATE THEN daily_login_streak
|
||||
ELSE 1
|
||||
END,
|
||||
last_login_date = CURRENT_DATE,
|
||||
last_login = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING daily_login_streak`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.daily_login_streak || 1;
|
||||
},
|
||||
|
||||
async updateCreationStreak(userId) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET
|
||||
weekly_creation_streak = CASE
|
||||
WHEN last_creation_date >= CURRENT_DATE - INTERVAL '7 days' THEN weekly_creation_streak + 1
|
||||
ELSE 1
|
||||
END,
|
||||
last_creation_date = CURRENT_DATE
|
||||
WHERE id = $1
|
||||
RETURNING weekly_creation_streak`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0]?.weekly_creation_streak || 1;
|
||||
},
|
||||
|
||||
async updateAvatar(userId, avatarData) {
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET avatar_data = $2 WHERE id = $1 RETURNING avatar_data',
|
||||
[userId, JSON.stringify(avatarData)]
|
||||
);
|
||||
return result.rows[0]?.avatar_data;
|
||||
},
|
||||
|
||||
async getCreatorStats(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = $1 AND status = 'published') as games_published,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = $1) as total_plays,
|
||||
(SELECT COALESCE(SUM(favorite_count), 0) FROM games WHERE creator_id = $1) as total_favorites,
|
||||
(SELECT COALESCE(SUM(tokens_earned), 0) FROM games WHERE creator_id = $1) as tokens_earned,
|
||||
(SELECT COUNT(*) FROM follows WHERE following_id = $1) as followers,
|
||||
(SELECT COALESCE(AVG(CASE WHEN total_ratings > 0 THEN rating_sum::float / total_ratings END), 0)
|
||||
FROM games WHERE creator_id = $1 AND total_ratings > 0) as avg_rating`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async getOnboardingStatus(userId) {
|
||||
const result = await pool.query(
|
||||
`SELECT onboarding_complete, onboarding_step FROM users WHERE id = $1`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async updateOnboardingStep(userId, step) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET onboarding_step = $2 WHERE id = $1
|
||||
RETURNING onboarding_step, onboarding_complete`,
|
||||
[userId, step]
|
||||
);
|
||||
return result.rows[0];
|
||||
},
|
||||
|
||||
async completeOnboarding(userId) {
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET onboarding_complete = true, onboarding_step = 4 WHERE id = $1
|
||||
RETURNING onboarding_step, onboarding_complete`,
|
||||
[userId]
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Users;
|
||||
@@ -0,0 +1,143 @@
|
||||
const { pool } = require('./db');
|
||||
|
||||
// XP Values
|
||||
const XP_VALUES = {
|
||||
GAME_CREATED: 50,
|
||||
GAME_PLAYED_BY_OTHERS: 2,
|
||||
GAME_FAVORITED: 5,
|
||||
GAME_FIVE_STAR: 10,
|
||||
GAME_REMIXED: 25,
|
||||
PLAY_GAME: 5,
|
||||
GET_HIGH_SCORE: 20
|
||||
};
|
||||
|
||||
// Level thresholds
|
||||
const LEVELS = [
|
||||
{ level: 1, xp: 0, badge: 'Newbie', icon: '🌱' },
|
||||
{ level: 2, xp: 100, badge: 'Beginner', icon: '🎮' },
|
||||
{ level: 3, xp: 300, badge: 'Apprentice', icon: '🎯' },
|
||||
{ level: 4, xp: 600, badge: 'Composer', icon: '⭐' },
|
||||
{ level: 5, xp: 1000, badge: 'Pro Composer', icon: '🔥' },
|
||||
{ level: 6, xp: 1500, badge: 'Expert', icon: '💎' },
|
||||
{ level: 7, xp: 2500, badge: 'Maestro', icon: '🏆' },
|
||||
{ level: 8, xp: 4000, badge: 'Master', icon: '👑' },
|
||||
{ level: 9, xp: 6000, badge: 'Legend', icon: '🌟' },
|
||||
{ level: 10, xp: 10000, badge: 'Grandmaster', icon: '🎖️' }
|
||||
];
|
||||
|
||||
const XP = {
|
||||
XP_VALUES,
|
||||
LEVELS,
|
||||
|
||||
getLevelInfo(xp) {
|
||||
let levelInfo = LEVELS[0];
|
||||
for (const level of LEVELS) {
|
||||
if (xp >= level.xp) {
|
||||
levelInfo = level;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return levelInfo;
|
||||
},
|
||||
|
||||
getNextLevelInfo(xp) {
|
||||
for (const level of LEVELS) {
|
||||
if (xp < level.xp) {
|
||||
return level;
|
||||
}
|
||||
}
|
||||
return null; // Max level
|
||||
},
|
||||
|
||||
getProgressToNextLevel(xp) {
|
||||
const currentLevel = this.getLevelInfo(xp);
|
||||
const nextLevel = this.getNextLevelInfo(xp);
|
||||
|
||||
if (!nextLevel) return { progress: 100, xpNeeded: 0 };
|
||||
|
||||
const xpIntoCurrentLevel = xp - currentLevel.xp;
|
||||
const xpForLevel = nextLevel.xp - currentLevel.xp;
|
||||
const progress = Math.floor((xpIntoCurrentLevel / xpForLevel) * 100);
|
||||
|
||||
return {
|
||||
progress,
|
||||
xpNeeded: nextLevel.xp - xp,
|
||||
currentLevelXp: xpIntoCurrentLevel,
|
||||
levelXpRequired: xpForLevel
|
||||
};
|
||||
},
|
||||
|
||||
async award(userId, amount, reason, referenceId = null) {
|
||||
// Check for active weekly theme bonus
|
||||
const themeResult = await pool.query(
|
||||
`SELECT bonus_xp_multiplier FROM weekly_themes
|
||||
WHERE is_active = true AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
let multiplier = 1;
|
||||
if (themeResult.rows.length > 0 && reason === 'game_created') {
|
||||
multiplier = parseFloat(themeResult.rows[0].bonus_xp_multiplier);
|
||||
}
|
||||
|
||||
const finalAmount = Math.floor(amount * multiplier);
|
||||
|
||||
// Use the database function to award XP
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM award_xp($1, $2, $3, $4)',
|
||||
[userId, finalAmount, reason, referenceId]
|
||||
);
|
||||
|
||||
const xpResult = result.rows[0];
|
||||
|
||||
// If leveled up, create notification
|
||||
if (xpResult.leveled_up) {
|
||||
const Notifications = require('./notifications');
|
||||
await Notifications.notifyLevelUp(userId, xpResult.new_level, xpResult.new_badge);
|
||||
}
|
||||
|
||||
return {
|
||||
xpAwarded: finalAmount,
|
||||
newXp: xpResult.new_xp,
|
||||
newLevel: xpResult.new_level,
|
||||
newBadge: xpResult.new_badge,
|
||||
leveledUp: xpResult.leveled_up,
|
||||
multiplier: multiplier > 1 ? multiplier : null
|
||||
};
|
||||
},
|
||||
|
||||
async getTransactions(userId, limit = 50) {
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM xp_transactions
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2`,
|
||||
[userId, limit]
|
||||
);
|
||||
return result.rows;
|
||||
},
|
||||
|
||||
async getUserStats(userId) {
|
||||
const user = await pool.query(
|
||||
'SELECT xp_total, creator_level, creator_badge FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (user.rows.length === 0) return null;
|
||||
|
||||
const { xp_total, creator_level, creator_badge } = user.rows[0];
|
||||
const levelInfo = this.getLevelInfo(xp_total || 0);
|
||||
const progress = this.getProgressToNextLevel(xp_total || 0);
|
||||
|
||||
return {
|
||||
xp: xp_total || 0,
|
||||
level: creator_level || 1,
|
||||
badge: creator_badge || 'Newbie',
|
||||
icon: levelInfo.icon,
|
||||
...progress
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = XP;
|
||||
@@ -0,0 +1,726 @@
|
||||
const express = require('express');
|
||||
const { body, query } = require('express-validator');
|
||||
const Games = require('../models/games');
|
||||
const Users = require('../models/users');
|
||||
const AuditLog = require('../models/auditLog');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper to get client IP address
|
||||
function getClientIp(req) {
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.ip;
|
||||
}
|
||||
|
||||
// Get moderation queue
|
||||
router.get('/moderation-queue',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const games = await Games.getPendingReview();
|
||||
|
||||
res.json({
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
gameCode: g.game_code,
|
||||
creatorUsername: g.creator_username,
|
||||
createdAt: g.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Review game (approve or reject)
|
||||
router.post('/review/:gameId',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([
|
||||
body('action').isIn(['approve', 'reject']),
|
||||
body('reason').optional().trim().isLength({ max: 500 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { action, reason } = req.body;
|
||||
|
||||
const game = await Games.findById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.status !== 'pending_review') {
|
||||
return res.status(400).json({ error: 'Game is not pending review' });
|
||||
}
|
||||
|
||||
if (action === 'approve') {
|
||||
await Games.approve(gameId);
|
||||
|
||||
// Log game approval
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'game_approved',
|
||||
'game',
|
||||
parseInt(gameId),
|
||||
{ gameTitle: game.title, creatorId: game.creator_id },
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({ message: 'Game approved and published' });
|
||||
} else {
|
||||
if (!reason) {
|
||||
return res.status(400).json({ error: 'Rejection reason required' });
|
||||
}
|
||||
await Games.reject(gameId, reason);
|
||||
|
||||
// Log game rejection
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'game_rejected',
|
||||
'game',
|
||||
parseInt(gameId),
|
||||
{ gameTitle: game.title, creatorId: game.creator_id, reason },
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({ message: 'Game rejected', reason });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get game reports
|
||||
router.get('/reports',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT r.*, g.title as game_title, g.creator_id, g.status as game_status,
|
||||
u.username as reporter_username,
|
||||
creator.username as creator_username
|
||||
FROM game_reports r
|
||||
JOIN games g ON r.game_id = g.id
|
||||
JOIN users u ON r.reporter_id = u.id
|
||||
JOIN users creator ON g.creator_id = creator.id
|
||||
WHERE r.status = 'pending'
|
||||
ORDER BY r.created_at DESC`
|
||||
);
|
||||
|
||||
res.json({
|
||||
reports: result.rows.map(r => ({
|
||||
id: r.id,
|
||||
gameId: r.game_id,
|
||||
gameTitle: r.game_title,
|
||||
gameStatus: r.game_status,
|
||||
creatorId: r.creator_id,
|
||||
creatorUsername: r.creator_username,
|
||||
reporterUsername: r.reporter_username,
|
||||
reason: r.reason,
|
||||
description: r.description,
|
||||
createdAt: r.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Resolve a report
|
||||
router.post('/reports/:reportId/resolve',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([
|
||||
body('action').isIn(['dismiss', 'warn', 'unpublish', 'ban']),
|
||||
body('notes').optional().trim().isLength({ max: 500 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
const { action, notes } = req.body;
|
||||
|
||||
// Get the report
|
||||
const reportResult = await pool.query(
|
||||
`SELECT r.*, g.creator_id, g.title as game_title
|
||||
FROM game_reports r
|
||||
JOIN games g ON r.game_id = g.id
|
||||
WHERE r.id = $1`,
|
||||
[reportId]
|
||||
);
|
||||
|
||||
if (reportResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Report not found' });
|
||||
}
|
||||
|
||||
const report = reportResult.rows[0];
|
||||
|
||||
// Mark report as resolved
|
||||
await pool.query(
|
||||
`UPDATE game_reports SET status = 'resolved', reviewed_by = $1, reviewed_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[req.user.id, reportId]
|
||||
);
|
||||
|
||||
// Take action based on decision
|
||||
if (action === 'unpublish') {
|
||||
await pool.query(
|
||||
`UPDATE games SET status = 'rejected', rejection_reason = $1 WHERE id = $2`,
|
||||
[notes || 'Content policy violation', report.game_id]
|
||||
);
|
||||
} else if (action === 'ban') {
|
||||
await Users.ban(report.creator_id);
|
||||
// Also unpublish the game
|
||||
await pool.query(
|
||||
`UPDATE games SET status = 'rejected', rejection_reason = 'Creator banned' WHERE id = $1`,
|
||||
[report.game_id]
|
||||
);
|
||||
}
|
||||
|
||||
// Log report resolution
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'report_resolved',
|
||||
'report',
|
||||
parseInt(reportId),
|
||||
{
|
||||
action,
|
||||
notes,
|
||||
gameId: report.game_id,
|
||||
gameTitle: report.game_title,
|
||||
creatorId: report.creator_id,
|
||||
reason: report.reason
|
||||
},
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: `Report resolved with action: ${action}`,
|
||||
action,
|
||||
reportId: parseInt(reportId)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unpublish a game (without a report)
|
||||
router.post('/unpublish/:gameId',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([
|
||||
body('reason').trim().isLength({ min: 1, max: 500 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { reason } = req.body;
|
||||
|
||||
const game = await Games.findById(gameId);
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE games SET status = 'rejected', rejection_reason = $1 WHERE id = $2`,
|
||||
[reason, gameId]
|
||||
);
|
||||
|
||||
// Log game unpublishing
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'game_unpublished',
|
||||
'game',
|
||||
parseInt(gameId),
|
||||
{ gameTitle: game.title, creatorId: game.creator_id, reason },
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({ message: 'Game unpublished', gameId: parseInt(gameId) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin dashboard stats
|
||||
router.get('/stats',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const [pending, reports, users, games, todayPlays, spending] = await Promise.all([
|
||||
pool.query(`SELECT COUNT(*) FROM games WHERE status = 'pending_review'`),
|
||||
pool.query(`SELECT COUNT(*) FROM game_reports WHERE status = 'pending'`),
|
||||
pool.query(`SELECT COUNT(*) FROM users`),
|
||||
pool.query(`SELECT COUNT(*) FROM games WHERE status = 'published'`),
|
||||
pool.query(`SELECT COUNT(*) FROM plays WHERE started_at >= CURRENT_DATE`),
|
||||
pool.query(`SELECT
|
||||
COALESCE(SUM(total_api_cost_cents), 0) as total_cost,
|
||||
COALESCE(SUM(total_generations), 0) as total_generations,
|
||||
COALESCE(SUM(CASE WHEN created_at >= CURRENT_DATE - INTERVAL '30 days' THEN total_api_cost_cents ELSE 0 END), 0) as cost_30d
|
||||
FROM users`)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
stats: {
|
||||
pendingReview: parseInt(pending.rows[0].count),
|
||||
pendingReports: parseInt(reports.rows[0].count),
|
||||
totalUsers: parseInt(users.rows[0].count),
|
||||
publishedGames: parseInt(games.rows[0].count),
|
||||
todayPlays: parseInt(todayPlays.rows[0].count),
|
||||
totalApiCostCents: parseInt(spending.rows[0].total_cost),
|
||||
totalGenerations: parseInt(spending.rows[0].total_generations),
|
||||
cost30dCents: parseInt(spending.rows[0].cost_30d)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Ban user
|
||||
router.post('/ban-user/:userId',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
const user = await Users.findById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.role === 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot ban admin users' });
|
||||
}
|
||||
|
||||
await Users.ban(userId);
|
||||
|
||||
// Log user ban
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'user_banned',
|
||||
'user',
|
||||
parseInt(userId),
|
||||
{ username: user.username, email: user.email },
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({ message: 'User banned', userId: parseInt(userId) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get audit logs
|
||||
router.get('/audit-log',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([
|
||||
query('limit').optional().isInt({ min: 1, max: 500 }).toInt(),
|
||||
query('adminId').optional().isInt().toInt(),
|
||||
query('targetType').optional().isIn(['game', 'user', 'report']),
|
||||
query('targetId').optional().isInt().toInt()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { limit = 50, adminId, targetType, targetId } = req.query;
|
||||
|
||||
let logs;
|
||||
if (adminId) {
|
||||
logs = await AuditLog.getByAdmin(adminId, limit);
|
||||
} else if (targetType && targetId) {
|
||||
logs = await AuditLog.getByTarget(targetType, targetId);
|
||||
} else {
|
||||
logs = await AuditLog.getRecent(limit);
|
||||
}
|
||||
|
||||
res.json({
|
||||
logs: logs.map(log => ({
|
||||
id: log.id,
|
||||
adminId: log.admin_id,
|
||||
adminUsername: log.admin_username,
|
||||
action: log.action,
|
||||
targetType: log.target_type,
|
||||
targetId: log.target_id,
|
||||
details: log.details,
|
||||
ipAddress: log.ip_address,
|
||||
createdAt: log.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Game diagnostics search
|
||||
router.get('/game-diagnostics',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { search, gameId } = req.query;
|
||||
|
||||
let queryStr;
|
||||
let params = [];
|
||||
|
||||
if (gameId) {
|
||||
// Exact game ID search
|
||||
queryStr = `
|
||||
SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
|
||||
g.generation_error, g.rejection_reason,
|
||||
g.claude_input_tokens, g.claude_output_tokens,
|
||||
g.api_cost_cents, g.total_cost_cents, g.generations_count,
|
||||
g.created_at, g.published_at, g.game_style,
|
||||
u.username as creator_username, u.email as creator_email
|
||||
FROM games g
|
||||
LEFT JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.id = $1
|
||||
`;
|
||||
params = [gameId];
|
||||
} else if (search) {
|
||||
// Search by title or creator username
|
||||
queryStr = `
|
||||
SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
|
||||
g.generation_error, g.rejection_reason,
|
||||
g.claude_input_tokens, g.claude_output_tokens,
|
||||
g.api_cost_cents, g.total_cost_cents, g.generations_count,
|
||||
g.created_at, g.published_at, g.game_style,
|
||||
u.username as creator_username, u.email as creator_email
|
||||
FROM games g
|
||||
LEFT JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.title ILIKE $1 OR u.username ILIKE $1
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
params = [`%${search}%`];
|
||||
} else {
|
||||
// Default: show problematic games
|
||||
queryStr = `
|
||||
SELECT g.id, g.title, g.status, g.game_code, g.creation_prompt,
|
||||
g.generation_error, g.rejection_reason,
|
||||
g.claude_input_tokens, g.claude_output_tokens,
|
||||
g.api_cost_cents, g.total_cost_cents, g.generations_count,
|
||||
g.created_at, g.published_at, g.game_style,
|
||||
u.username as creator_username, u.email as creator_email
|
||||
FROM games g
|
||||
LEFT JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.generation_error IS NOT NULL OR g.status IN ('draft', 'generating')
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
}
|
||||
|
||||
const result = await pool.query(queryStr, params);
|
||||
|
||||
res.json({
|
||||
games: result.rows.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
status: g.status,
|
||||
creatorUsername: g.creator_username,
|
||||
creatorEmail: g.creator_email,
|
||||
error: g.generation_error,
|
||||
rejectionReason: g.rejection_reason,
|
||||
hasCode: !!g.game_code,
|
||||
codeLength: g.game_code ? g.game_code.length : 0,
|
||||
codePreview: g.game_code ? g.game_code.substring(0, 500) : null,
|
||||
prompt: g.creation_prompt ? g.creation_prompt.substring(0, 1000) : null,
|
||||
inputTokens: g.claude_input_tokens || 0,
|
||||
outputTokens: g.claude_output_tokens || 0,
|
||||
apiCostCents: g.api_cost_cents || 0,
|
||||
totalCostCents: g.total_cost_cents || 0,
|
||||
generationsCount: g.generations_count || 0,
|
||||
gameStyle: g.game_style,
|
||||
createdAt: g.created_at,
|
||||
publishedAt: g.published_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Clear Cloudflare cache
|
||||
router.post('/clear-cache',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const zoneId = process.env.CLOUDFLARE_ZONE_ID;
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||
|
||||
if (!zoneId || !apiToken) {
|
||||
return res.status(400).json({
|
||||
error: 'Cloudflare credentials not configured',
|
||||
details: 'Set CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN in environment'
|
||||
});
|
||||
}
|
||||
|
||||
// Purge Cloudflare cache
|
||||
const response = await fetch(
|
||||
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ purge_everything: true })
|
||||
}
|
||||
);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Cloudflare purge failed:', result.errors);
|
||||
return res.status(500).json({
|
||||
error: 'Cloudflare purge failed',
|
||||
details: result.errors
|
||||
});
|
||||
}
|
||||
|
||||
// Log cache clear action
|
||||
await AuditLog.create(
|
||||
req.user.id,
|
||||
'cache_cleared',
|
||||
'system',
|
||||
null,
|
||||
{ type: 'cloudflare', purgedAt: new Date().toISOString() },
|
||||
getClientIp(req)
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Cloudflare cache purged successfully',
|
||||
purgedAt: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Cache clear error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// System health check
|
||||
router.get('/system-health',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const checks = {};
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. Database connectivity and stats
|
||||
try {
|
||||
const dbStats = await pool.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users) as total_users,
|
||||
(SELECT COUNT(*) FROM games WHERE status = 'published') as published_games,
|
||||
(SELECT COUNT(*) FROM games WHERE status = 'pending_review') as pending_review,
|
||||
(SELECT COUNT(*) FROM games WHERE status = 'draft') as draft_games,
|
||||
(SELECT COUNT(*) FROM games WHERE status = 'generating') as generating,
|
||||
(SELECT COUNT(*) FROM games WHERE generation_error IS NOT NULL) as games_with_errors,
|
||||
(SELECT COUNT(*) FROM plays) as total_plays,
|
||||
(SELECT COUNT(*) FROM plays WHERE started_at >= CURRENT_DATE) as today_plays,
|
||||
(SELECT COUNT(*) FROM game_reports WHERE status = 'pending') as pending_reports,
|
||||
(SELECT COUNT(*) FROM bug_reports WHERE status = 'open') as open_bugs,
|
||||
(SELECT COALESCE(SUM(total_api_cost_cents), 0) FROM users) as total_api_cost_cents
|
||||
`);
|
||||
checks.database = {
|
||||
status: 'healthy',
|
||||
stats: dbStats.rows[0]
|
||||
};
|
||||
} catch (dbError) {
|
||||
checks.database = { status: 'error', error: dbError.message };
|
||||
}
|
||||
|
||||
// 2. Memory usage
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
checks.memory = {
|
||||
status: 'healthy',
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024),
|
||||
external: Math.round(memUsage.external / 1024 / 1024)
|
||||
};
|
||||
// Warn if heap usage is over 80%
|
||||
if (memUsage.heapUsed / memUsage.heapTotal > 0.8) {
|
||||
checks.memory.status = 'warning';
|
||||
checks.memory.message = 'High memory usage';
|
||||
}
|
||||
} catch (memError) {
|
||||
checks.memory = { status: 'error', error: memError.message };
|
||||
}
|
||||
|
||||
// 3. Environment checks
|
||||
checks.environment = {
|
||||
status: 'healthy',
|
||||
nodeVersion: process.version,
|
||||
uptime: Math.round(process.uptime()),
|
||||
uptimeFormatted: formatUptime(process.uptime()),
|
||||
claudeApiKey: !!process.env.CLAUDE_API_KEY,
|
||||
cloudflareConfigured: !!(process.env.CLOUDFLARE_ZONE_ID && process.env.CLOUDFLARE_API_TOKEN),
|
||||
vapidConfigured: !!process.env.VAPID_PUBLIC_KEY,
|
||||
smtpConfigured: !!process.env.SMTP_HOST
|
||||
};
|
||||
|
||||
// 4. Disk space (approximate via temp file)
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { execSync } = require('child_process');
|
||||
const dfOutput = execSync('df -h / | tail -1').toString().trim();
|
||||
const parts = dfOutput.split(/\s+/);
|
||||
checks.disk = {
|
||||
status: 'healthy',
|
||||
total: parts[1],
|
||||
used: parts[2],
|
||||
available: parts[3],
|
||||
usagePercent: parseInt(parts[4])
|
||||
};
|
||||
if (checks.disk.usagePercent > 90) {
|
||||
checks.disk.status = 'critical';
|
||||
checks.disk.message = 'Disk space critically low';
|
||||
} else if (checks.disk.usagePercent > 80) {
|
||||
checks.disk.status = 'warning';
|
||||
checks.disk.message = 'Disk space running low';
|
||||
}
|
||||
} catch (diskError) {
|
||||
checks.disk = { status: 'unknown', error: 'Could not check disk space' };
|
||||
}
|
||||
|
||||
// 5. API endpoints health
|
||||
const endpoints = [
|
||||
{ name: 'Arcade Games', path: '/api/arcade/games?limit=1' },
|
||||
{ name: 'Game Styles', path: '/api/creation/styles' },
|
||||
{ name: 'Leaderboard', path: '/api/leaderboard/players?limit=1' },
|
||||
{ name: 'Push VAPID', path: '/api/push/vapid-key' }
|
||||
];
|
||||
|
||||
checks.endpoints = { status: 'healthy', results: [] };
|
||||
for (const ep of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000${ep.path}`);
|
||||
checks.endpoints.results.push({
|
||||
name: ep.name,
|
||||
status: response.ok ? 'ok' : 'error',
|
||||
code: response.status
|
||||
});
|
||||
if (!response.ok) checks.endpoints.status = 'warning';
|
||||
} catch (epError) {
|
||||
checks.endpoints.results.push({
|
||||
name: ep.name,
|
||||
status: 'error',
|
||||
error: epError.message
|
||||
});
|
||||
checks.endpoints.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Recent activity
|
||||
try {
|
||||
const recentActivity = await pool.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE created_at >= NOW() - INTERVAL '24 hours') as new_users_24h,
|
||||
(SELECT COUNT(*) FROM games WHERE created_at >= NOW() - INTERVAL '24 hours') as new_games_24h,
|
||||
(SELECT COUNT(*) FROM plays WHERE started_at >= NOW() - INTERVAL '1 hour') as plays_last_hour
|
||||
`);
|
||||
checks.activity = {
|
||||
status: 'healthy',
|
||||
...recentActivity.rows[0]
|
||||
};
|
||||
} catch (actError) {
|
||||
checks.activity = { status: 'error', error: actError.message };
|
||||
}
|
||||
|
||||
// Calculate overall status
|
||||
const statuses = Object.values(checks).map(c => c.status);
|
||||
let overallStatus = 'healthy';
|
||||
if (statuses.includes('error') || statuses.includes('critical')) {
|
||||
overallStatus = 'critical';
|
||||
} else if (statuses.includes('warning')) {
|
||||
overallStatus = 'warning';
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
res.json({
|
||||
status: overallStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
responseTimeMs: responseTime,
|
||||
checks
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('System health check error:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// Create admin user (one-time setup endpoint)
|
||||
router.post('/setup-admin',
|
||||
validate([
|
||||
body('username').trim().isLength({ min: 3, max: 30 }),
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 8 }),
|
||||
body('setupKey').equals(process.env.JWT_SECRET).withMessage('Invalid setup key')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
// Check if any admin exists
|
||||
const existingAdmin = await pool.query(
|
||||
"SELECT id FROM users WHERE role = 'admin' LIMIT 1"
|
||||
);
|
||||
|
||||
if (existingAdmin.rows.length > 0) {
|
||||
return res.status(400).json({ error: 'Admin already exists' });
|
||||
}
|
||||
|
||||
const user = await Users.create({ username, email, password });
|
||||
|
||||
// Promote to admin
|
||||
await pool.query("UPDATE users SET role = 'admin' WHERE id = $1", [user.id]);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Admin account created',
|
||||
username: user.username
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,174 @@
|
||||
const express = require('express');
|
||||
const { query } = require('express-validator');
|
||||
const Games = require('../models/games');
|
||||
const { HighScores, Ratings } = require('../models/plays');
|
||||
const { optionalAuth } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// List published games
|
||||
router.get('/games',
|
||||
optionalAuth,
|
||||
validate([
|
||||
query('sort').optional().isIn(['trending', 'new', 'top-rated']),
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 }),
|
||||
query('search').optional().trim().isLength({ max: 100 }),
|
||||
query('difficulty').optional().isIn(['easy', 'medium', 'hard', ''])
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
sort = 'trending',
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search = '',
|
||||
difficulty = ''
|
||||
} = req.query;
|
||||
|
||||
const games = await Games.getPublished({
|
||||
sort,
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
search,
|
||||
difficulty
|
||||
});
|
||||
|
||||
// Get user's favorites if logged in
|
||||
let userFavorites = new Set();
|
||||
if (req.user) {
|
||||
const Favorites = require('../models/favorites');
|
||||
const favoriteIds = await Favorites.getUserFavoriteIds(req.user.id);
|
||||
userFavorites = new Set(favoriteIds);
|
||||
}
|
||||
|
||||
res.json({
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
thumbnailData: g.thumbnail_data,
|
||||
playCount: g.play_count,
|
||||
averageRating: parseFloat(g.average_rating) || 0,
|
||||
totalRatings: g.total_ratings,
|
||||
creatorId: g.creator_id,
|
||||
creatorUsername: g.creator_username,
|
||||
creatorDisplayName: g.creator_display_name,
|
||||
publishedAt: g.published_at,
|
||||
isFeatured: g.is_featured,
|
||||
gameCode: g.game_code,
|
||||
favoriteCount: g.favorite_count || 0,
|
||||
isFavorited: userFavorites.has(g.id),
|
||||
difficultyTags: g.difficulty_tags || [],
|
||||
gameStyle: g.game_style
|
||||
})),
|
||||
page: parseInt(page),
|
||||
hasMore: games.length === parseInt(limit)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get public game details
|
||||
router.get('/games/:gameId',
|
||||
optionalAuth,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game || game.status !== 'published') {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
// Get user's rating, play count, and favorite status if logged in
|
||||
let userRating = null;
|
||||
let userPlayCount = 0;
|
||||
let isFavorited = false;
|
||||
if (req.user) {
|
||||
userRating = await Ratings.getUserRating(gameId, req.user.id);
|
||||
const playCountResult = await require('../models/db').pool.query(
|
||||
'SELECT COUNT(*) as count FROM plays WHERE game_id = $1 AND player_id = $2',
|
||||
[gameId, req.user.id]
|
||||
);
|
||||
userPlayCount = parseInt(playCountResult.rows[0].count) || 0;
|
||||
|
||||
// Check if favorited
|
||||
const Favorites = require('../models/favorites');
|
||||
const userFavorites = await Favorites.getUserFavoriteIds(req.user.id);
|
||||
isFavorited = userFavorites.includes(parseInt(gameId));
|
||||
}
|
||||
|
||||
res.json({
|
||||
game: {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
thumbnailUrl: game.thumbnail_url,
|
||||
thumbnailData: game.thumbnail_data,
|
||||
playCount: game.play_count,
|
||||
averageRating: game.total_ratings > 0
|
||||
? game.rating_sum / game.total_ratings
|
||||
: 0,
|
||||
totalRatings: game.total_ratings,
|
||||
creatorId: game.creator_id,
|
||||
creatorUsername: game.creator_username,
|
||||
creatorDisplayName: game.creator_display_name,
|
||||
publishedAt: game.published_at,
|
||||
isFeatured: game.is_featured,
|
||||
prompt: game.prompt,
|
||||
creationPrompt: game.creation_prompt,
|
||||
originalPrompt: game.original_prompt,
|
||||
promptWasEdited: game.prompt_was_edited || false,
|
||||
promptEditCount: game.prompt_edit_count || 0,
|
||||
promptAnswers: game.prompt_answers,
|
||||
gameStyle: game.game_style,
|
||||
difficultyTags: game.difficulty_tags || [],
|
||||
allowRemix: game.allow_remix,
|
||||
remixedFromId: game.remixed_from_id
|
||||
},
|
||||
userRating,
|
||||
userPlayCount,
|
||||
isFavorited
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get high scores for a game
|
||||
router.get('/games/:gameId/high-scores',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game || game.status !== 'published') {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const scores = await HighScores.getTopScores(gameId, 50);
|
||||
|
||||
res.json({
|
||||
gameId: parseInt(gameId),
|
||||
gameTitle: game.title,
|
||||
highScores: scores.map((s, i) => ({
|
||||
rank: i + 1,
|
||||
username: s.username,
|
||||
displayName: s.display_name,
|
||||
score: s.score,
|
||||
achievedAt: s.achieved_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,391 @@
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const Users = require('../models/users');
|
||||
const XP = require('../models/xp');
|
||||
const Achievements = require('../models/achievements');
|
||||
const { GuestSessions } = require('../models/plays');
|
||||
const { generateToken } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const { validateUsername, generateUsernameSuggestions } = require('../utils/usernames');
|
||||
const { filterUsername, logFilterAction } = require('../utils/contentFilter');
|
||||
const { sendWelcomeEmail, sendPasswordResetEmail } = require('../utils/email');
|
||||
const crypto = require('crypto');
|
||||
const { pool } = require('../models/db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get username suggestions
|
||||
router.get('/username-suggestions', (req, res) => {
|
||||
const suggestions = generateUsernameSuggestions(5);
|
||||
res.json({ suggestions });
|
||||
});
|
||||
|
||||
// Validate username
|
||||
router.post('/validate-username',
|
||||
validate([
|
||||
body('username').trim().isLength({ min: 1 })
|
||||
]),
|
||||
async (req, res) => {
|
||||
const { username } = req.body;
|
||||
const validation = validateUsername(username);
|
||||
|
||||
// Also check if username is taken
|
||||
if (validation.valid) {
|
||||
const existing = await Users.findByUsername(username);
|
||||
if (existing) {
|
||||
validation.valid = false;
|
||||
validation.errors.push('This username is already taken');
|
||||
validation.suggestions = generateUsernameSuggestions(5);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(validation);
|
||||
}
|
||||
);
|
||||
|
||||
// Register
|
||||
router.post('/register',
|
||||
validate([
|
||||
body('username')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 30 })
|
||||
.withMessage('Username must be 3-30 characters')
|
||||
.matches(/^[a-zA-Z0-9_]+$/)
|
||||
.withMessage('Username can only contain letters, numbers, and underscores'),
|
||||
body('email')
|
||||
.isEmail()
|
||||
.normalizeEmail()
|
||||
.withMessage('Valid email required'),
|
||||
body('password')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Password must be at least 6 characters'),
|
||||
body('fingerprint')
|
||||
.optional()
|
||||
.isLength({ min: 10, max: 255 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username, email, password, fingerprint } = req.body;
|
||||
|
||||
// Validate username (no real names)
|
||||
const usernameValidation = validateUsername(username);
|
||||
if (!usernameValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: usernameValidation.errors[0],
|
||||
message: 'Real names are not allowed for privacy protection. Please choose a fun gaming name!',
|
||||
suggestions: usernameValidation.suggestions
|
||||
});
|
||||
}
|
||||
|
||||
// Content filter for inappropriate usernames
|
||||
const usernameFilter = filterUsername(username);
|
||||
if (usernameFilter.blocked) {
|
||||
logFilterAction(null, 'username', username, usernameFilter);
|
||||
return res.status(400).json({
|
||||
error: usernameFilter.message,
|
||||
blocked: true,
|
||||
suggestions: generateUsernameSuggestions(5)
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
const existingUsername = await Users.findByUsername(username);
|
||||
if (existingUsername) {
|
||||
return res.status(409).json({
|
||||
error: 'Username already taken',
|
||||
suggestions: generateUsernameSuggestions(5)
|
||||
});
|
||||
}
|
||||
|
||||
const existingEmail = await Users.findByEmail(email);
|
||||
if (existingEmail) {
|
||||
return res.status(409).json({ error: 'Email already registered' });
|
||||
}
|
||||
|
||||
const user = await Users.create({ username, email, password });
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Migrate guest play history if fingerprint provided
|
||||
if (fingerprint) {
|
||||
const guestUser = await Users.findByFingerprint(fingerprint);
|
||||
if (guestUser && guestUser.is_guest) {
|
||||
// Transfer plays from guest to new user
|
||||
await pool.query(
|
||||
'UPDATE plays SET player_id = $1 WHERE player_id = $2',
|
||||
[user.id, guestUser.id]
|
||||
);
|
||||
// Transfer high scores
|
||||
await pool.query(
|
||||
'UPDATE high_scores SET player_id = $1 WHERE player_id = $2',
|
||||
[user.id, guestUser.id]
|
||||
);
|
||||
// Transfer ratings
|
||||
await pool.query(
|
||||
'UPDATE ratings SET user_id = $1 WHERE user_id = $2',
|
||||
[user.id, guestUser.id]
|
||||
);
|
||||
// Add guest's tokens to new user (but cap at reasonable amount)
|
||||
const guestTokens = Math.min(guestUser.tokens_balance || 0, 50);
|
||||
if (guestTokens > 0) {
|
||||
await Users.updateTokens(user.id, guestTokens);
|
||||
}
|
||||
// Delete guest user (cleanup)
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [guestUser.id]);
|
||||
// Clean up guest session
|
||||
await pool.query('DELETE FROM guest_sessions WHERE device_fingerprint = $1', [fingerprint]);
|
||||
console.log(`Migrated guest ${guestUser.id} to new user ${user.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get XP stats (will be defaults for new user)
|
||||
const xpStats = await XP.getUserStats(user.id);
|
||||
|
||||
// Refresh user data after potential token migration
|
||||
const refreshedUser = await Users.findById(user.id);
|
||||
|
||||
// Send welcome email (don't wait, don't fail registration)
|
||||
sendWelcomeEmail({ email, username, displayName: user.display_name }).catch(err => {
|
||||
console.error('Welcome email failed:', err.message);
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Account created successfully',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
tokensBalance: refreshedUser.tokens_balance,
|
||||
role: user.role,
|
||||
isTeacher: user.is_teacher || user.role === 'teacher',
|
||||
xp: xpStats,
|
||||
onboardingComplete: false,
|
||||
onboardingStep: 0
|
||||
},
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Login
|
||||
router.post('/login',
|
||||
validate([
|
||||
body('email').isEmail().normalizeEmail(),
|
||||
body('password').notEmpty()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
const user = await Users.findByEmail(email);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const validPassword = await Users.verifyPassword(user, password);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
// Update login streak and get new streak value
|
||||
const loginStreak = await Users.updateLoginStreak(user.id);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
// Check for streak achievements
|
||||
const streakAchievements = await Achievements.checkAndAward(user.id, 'login_streak', loginStreak);
|
||||
|
||||
// Get XP stats for proper level display
|
||||
const xpStats = await XP.getUserStats(user.id);
|
||||
|
||||
// Get updated user data for streaks
|
||||
const updatedUser = await Users.findById(user.id);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
tokensBalance: user.tokens_balance,
|
||||
role: user.role,
|
||||
isTeacher: user.is_teacher || user.role === 'teacher',
|
||||
xp: xpStats,
|
||||
loginStreak: updatedUser.daily_login_streak,
|
||||
creationStreak: updatedUser.weekly_creation_streak,
|
||||
onboardingComplete: updatedUser.onboarding_complete || false,
|
||||
onboardingStep: updatedUser.onboarding_step || 0
|
||||
},
|
||||
token,
|
||||
newAchievements: streakAchievements.length > 0 ? streakAchievements : undefined
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Guest login
|
||||
router.post('/guest-login',
|
||||
validate([
|
||||
body('fingerprint')
|
||||
.isLength({ min: 10, max: 255 })
|
||||
.withMessage('Valid device fingerprint required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { fingerprint } = req.body;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
|
||||
// Check for existing guest user with this fingerprint
|
||||
let user = await Users.findByFingerprint(fingerprint);
|
||||
|
||||
if (!user) {
|
||||
// Create guest session tracking (pass null for userId since user doesn't exist yet)
|
||||
await GuestSessions.findOrCreate(fingerprint, null, userAgent, ipAddress);
|
||||
|
||||
// Create guest user with fun random name
|
||||
const { generateUsername } = require('../utils/usernames');
|
||||
const guestUsername = generateUsername();
|
||||
user = await Users.create({
|
||||
username: guestUsername,
|
||||
isGuest: true,
|
||||
deviceFingerprint: fingerprint
|
||||
});
|
||||
}
|
||||
|
||||
// Update login streak for guest users too
|
||||
const loginStreak = await Users.updateLoginStreak(user.id);
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name || 'Guest Player',
|
||||
tokensBalance: user.tokens_balance,
|
||||
isGuest: true,
|
||||
role: user.role,
|
||||
isTeacher: user.is_teacher || user.role === 'teacher',
|
||||
loginStreak,
|
||||
onboardingComplete: user.onboarding_complete || false,
|
||||
onboardingStep: user.onboarding_step || 0
|
||||
},
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Request password reset
|
||||
router.post('/forgot-password',
|
||||
validate([
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
// Always return success to prevent email enumeration
|
||||
const successResponse = {
|
||||
message: 'If an account with that email exists, a password reset link has been sent.'
|
||||
};
|
||||
|
||||
const user = await Users.findByEmail(email);
|
||||
if (!user) {
|
||||
return res.json(successResponse);
|
||||
}
|
||||
|
||||
// Can't reset password for guest accounts
|
||||
if (user.is_guest) {
|
||||
return res.json(successResponse);
|
||||
}
|
||||
|
||||
// Generate secure token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
// Store reset token
|
||||
await pool.query(
|
||||
`INSERT INTO password_reset_tokens (user_id, token, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[user.id, token, expiresAt]
|
||||
);
|
||||
|
||||
// Send reset email (don't wait, don't fail)
|
||||
const resetUrl = `${process.env.FRONTEND_URL || 'https://gamercomp.com'}/reset-password?token=${token}`;
|
||||
sendPasswordResetEmail({
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
resetUrl
|
||||
}).catch(err => {
|
||||
console.error('Password reset email failed:', err.message);
|
||||
});
|
||||
|
||||
res.json(successResponse);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Reset password with token
|
||||
router.post('/reset-password',
|
||||
validate([
|
||||
body('token').isLength({ min: 64, max: 64 }).withMessage('Invalid reset token'),
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
|
||||
// Find valid, unused token
|
||||
const result = await pool.query(
|
||||
`SELECT prt.*, u.email
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON u.id = prt.user_id
|
||||
WHERE prt.token = $1
|
||||
AND prt.used = false
|
||||
AND prt.expires_at > NOW()`,
|
||||
[token]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid or expired reset link. Please request a new one.'
|
||||
});
|
||||
}
|
||||
|
||||
const resetToken = result.rows[0];
|
||||
|
||||
// Update password
|
||||
await Users.updatePassword(resetToken.user_id, password);
|
||||
|
||||
// Mark token as used
|
||||
await pool.query(
|
||||
'UPDATE password_reset_tokens SET used = true WHERE id = $1',
|
||||
[resetToken.id]
|
||||
);
|
||||
|
||||
// Invalidate all other reset tokens for this user
|
||||
await pool.query(
|
||||
'UPDATE password_reset_tokens SET used = true WHERE user_id = $1 AND id != $2',
|
||||
[resetToken.user_id, resetToken.id]
|
||||
);
|
||||
|
||||
res.json({ message: 'Password reset successfully. You can now log in.' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Logout (client-side token deletion, but we could add token blacklisting)
|
||||
router.post('/logout', (req, res) => {
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,216 @@
|
||||
const express = require('express');
|
||||
const { body, param, query } = require('express-validator');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const Notifications = require('../models/notifications');
|
||||
const { sendNotificationEmail } = require('../utils/email');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Submit a bug report (authenticated users)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
validate([
|
||||
body('title').trim().isLength({ min: 5, max: 200 }).withMessage('Title must be 5-200 characters'),
|
||||
body('description').trim().isLength({ min: 10, max: 5000 }).withMessage('Description must be 10-5000 characters'),
|
||||
body('pageUrl').optional().trim().isLength({ max: 500 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { title, description, pageUrl } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO bug_reports (user_id, title, description, page_url)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[req.user.id, title, description, pageUrl || null]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Bug report submitted. Thank you for helping improve GamerComp!',
|
||||
report: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get my bug reports (authenticated users)
|
||||
router.get('/my-reports',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, status, priority, created_at, resolved_at
|
||||
FROM bug_reports
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({ reports: result.rows });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Get all bug reports
|
||||
router.get('/admin',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { status, priority } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT br.*,
|
||||
u.username as reporter_username,
|
||||
u.email as reporter_email,
|
||||
resolver.username as resolver_username
|
||||
FROM bug_reports br
|
||||
LEFT JOIN users u ON br.user_id = u.id
|
||||
LEFT JOIN users resolver ON br.resolved_by = resolver.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (status && status !== 'all') {
|
||||
params.push(status);
|
||||
query += ` AND br.status = $${params.length}`;
|
||||
}
|
||||
|
||||
if (priority && priority !== 'all') {
|
||||
params.push(priority);
|
||||
query += ` AND br.priority = $${params.length}`;
|
||||
}
|
||||
|
||||
query += ` ORDER BY
|
||||
CASE br.priority
|
||||
WHEN 'critical' THEN 1
|
||||
WHEN 'high' THEN 2
|
||||
WHEN 'normal' THEN 3
|
||||
WHEN 'low' THEN 4
|
||||
END,
|
||||
br.created_at DESC`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// Get counts by status
|
||||
const countsResult = await pool.query(`
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM bug_reports
|
||||
GROUP BY status
|
||||
`);
|
||||
const counts = {};
|
||||
countsResult.rows.forEach(row => {
|
||||
counts[row.status] = parseInt(row.count);
|
||||
});
|
||||
|
||||
res.json({
|
||||
reports: result.rows,
|
||||
counts
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Update bug report status
|
||||
router.put('/admin/:id',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([
|
||||
param('id').isInt(),
|
||||
body('status').isIn(['open', 'in_progress', 'resolved', 'closed', 'wont_fix']),
|
||||
body('priority').optional().isIn(['low', 'normal', 'high', 'critical']),
|
||||
body('adminNotes').optional().trim().isLength({ max: 2000 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { status, priority, adminNotes } = req.body;
|
||||
|
||||
// Get the current bug report
|
||||
const current = await pool.query(
|
||||
'SELECT * FROM bug_reports WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (current.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Bug report not found' });
|
||||
}
|
||||
|
||||
const bug = current.rows[0];
|
||||
const wasResolved = ['resolved', 'closed'].includes(bug.status);
|
||||
const isNowResolved = ['resolved', 'closed'].includes(status);
|
||||
|
||||
// Update the bug report
|
||||
const isResolvingNow = ['resolved', 'closed'].includes(status);
|
||||
const result = await pool.query(
|
||||
`UPDATE bug_reports
|
||||
SET status = $2,
|
||||
priority = COALESCE($3, priority),
|
||||
admin_notes = COALESCE($4, admin_notes),
|
||||
resolved_at = CASE WHEN $5 AND resolved_at IS NULL THEN NOW() ELSE resolved_at END,
|
||||
resolved_by = CASE WHEN $5 AND resolved_by IS NULL THEN $6 ELSE resolved_by END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[id, status, priority || null, adminNotes || null, isResolvingNow, req.user.id]
|
||||
);
|
||||
|
||||
// If status changed to resolved/closed and user exists, notify them
|
||||
if (!wasResolved && isNowResolved && bug.user_id) {
|
||||
const statusText = status === 'resolved' ? 'has been fixed' : 'has been closed';
|
||||
const notifMessage = `Your bug report "${bug.title}" ${statusText}. Thank you for helping improve GamerComp!`;
|
||||
await Notifications.create({
|
||||
userId: bug.user_id,
|
||||
type: 'bug_resolved',
|
||||
title: 'Bug Report Updated',
|
||||
message: notifMessage,
|
||||
link: null
|
||||
});
|
||||
|
||||
// Send email notification
|
||||
const userResult = await pool.query('SELECT email FROM users WHERE id = $1', [bug.user_id]);
|
||||
if (userResult.rows.length > 0 && userResult.rows[0].email) {
|
||||
sendNotificationEmail(
|
||||
{ email: userResult.rows[0].email },
|
||||
{ title: 'Bug Report Updated', message: notifMessage }
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Bug report updated',
|
||||
report: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Delete bug report
|
||||
router.delete('/admin/:id',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
validate([param('id').isInt()]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.query('DELETE FROM bug_reports WHERE id = $1', [id]);
|
||||
|
||||
res.json({ message: 'Bug report deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,660 @@
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const { pool } = require('../models/db');
|
||||
const Classrooms = require('../models/classrooms');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get my classrooms (as teacher or student)
|
||||
router.get('/my',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const teacherClassrooms = await Classrooms.getByTeacher(req.user.id);
|
||||
const studentClassrooms = await Classrooms.getByStudent(req.user.id);
|
||||
|
||||
res.json({
|
||||
asTeacher: teacherClassrooms.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
joinCode: c.join_code,
|
||||
studentCount: parseInt(c.student_count) || 0,
|
||||
assignmentCount: parseInt(c.assignment_count) || 0,
|
||||
isPublicPublishingAllowed: c.is_public_publishing_allowed,
|
||||
createdAt: c.created_at
|
||||
})),
|
||||
asStudent: studentClassrooms.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
teacherUsername: c.teacher_username,
|
||||
teacherDisplayName: c.teacher_display_name,
|
||||
studentCount: parseInt(c.student_count) || 0,
|
||||
joinedAt: c.joined_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create a new classroom (any authenticated user)
|
||||
router.post('/',
|
||||
authenticate,
|
||||
validate([
|
||||
body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Classroom name required (max 100 chars)'),
|
||||
body('isPublicPublishingAllowed').optional().isBoolean()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name, isPublicPublishingAllowed = false } = req.body;
|
||||
|
||||
const classroom = await Classrooms.create(req.user.id, name, isPublicPublishingAllowed);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Classroom created successfully',
|
||||
classroom: {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
joinCode: classroom.join_code,
|
||||
isPublicPublishingAllowed: classroom.is_public_publishing_allowed,
|
||||
createdAt: classroom.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get classroom details
|
||||
router.get('/:classroomId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const classroom = await Classrooms.findById(classroomId);
|
||||
|
||||
if (!classroom) {
|
||||
return res.status(404).json({ error: 'Classroom not found' });
|
||||
}
|
||||
|
||||
// Check if user has access (teacher or student)
|
||||
const isTeacher = classroom.teacher_id === req.user.id;
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
|
||||
if (!isTeacher && !isStudent) {
|
||||
return res.status(403).json({ error: 'You do not have access to this classroom' });
|
||||
}
|
||||
|
||||
const response = {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
teacherUsername: classroom.teacher_username,
|
||||
teacherDisplayName: classroom.teacher_display_name,
|
||||
studentCount: parseInt(classroom.student_count) || 0,
|
||||
isPublicPublishingAllowed: classroom.is_public_publishing_allowed,
|
||||
createdAt: classroom.created_at,
|
||||
isTeacher,
|
||||
isStudent
|
||||
};
|
||||
|
||||
// Only teachers see the join code
|
||||
if (isTeacher) {
|
||||
response.joinCode = classroom.join_code;
|
||||
}
|
||||
|
||||
res.json({ classroom: response });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update classroom (teacher only)
|
||||
router.put('/:classroomId',
|
||||
authenticate,
|
||||
validate([
|
||||
body('name').optional().trim().isLength({ min: 1, max: 100 }),
|
||||
body('isPublicPublishingAllowed').optional().isBoolean()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const { name, isPublicPublishingAllowed } = req.body;
|
||||
|
||||
const classroom = await Classrooms.update(classroomId, req.user.id, {
|
||||
name,
|
||||
isPublicPublishingAllowed
|
||||
});
|
||||
|
||||
if (!classroom) {
|
||||
return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Classroom updated',
|
||||
classroom: {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
joinCode: classroom.join_code,
|
||||
isPublicPublishingAllowed: classroom.is_public_publishing_allowed
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete classroom (teacher only)
|
||||
router.delete('/:classroomId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const deleted = await Classrooms.delete(classroomId, req.user.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Classroom deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Regenerate join code (teacher only)
|
||||
router.post('/:classroomId/regenerate-code',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const classroom = await Classrooms.regenerateJoinCode(classroomId, req.user.id);
|
||||
|
||||
if (!classroom) {
|
||||
return res.status(404).json({ error: 'Classroom not found or you are not the teacher' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Join code regenerated',
|
||||
joinCode: classroom.join_code
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Join a classroom by code (students)
|
||||
router.post('/join',
|
||||
authenticate,
|
||||
validate([
|
||||
body('joinCode').trim().isLength({ min: 6, max: 10 }).withMessage('Valid join code required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { joinCode } = req.body;
|
||||
|
||||
const classroom = await Classrooms.findByJoinCode(joinCode);
|
||||
if (!classroom) {
|
||||
return res.status(404).json({ error: 'Invalid join code. Please check and try again.' });
|
||||
}
|
||||
|
||||
// Can't join own classroom
|
||||
if (classroom.teacher_id === req.user.id) {
|
||||
return res.status(400).json({ error: 'You cannot join your own classroom as a student' });
|
||||
}
|
||||
|
||||
const enrollment = await Classrooms.addStudent(classroom.id, req.user.id);
|
||||
|
||||
if (!enrollment) {
|
||||
return res.status(400).json({ error: 'You are already enrolled in this classroom' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `Successfully joined ${classroom.name}!`,
|
||||
classroom: {
|
||||
id: classroom.id,
|
||||
name: classroom.name,
|
||||
teacherUsername: classroom.teacher_username,
|
||||
teacherDisplayName: classroom.teacher_display_name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Leave a classroom (students)
|
||||
router.post('/:classroomId/leave',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
'DELETE FROM classroom_students WHERE classroom_id = $1 AND student_id = $2 RETURNING id',
|
||||
[classroomId, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'You are not enrolled in this classroom' });
|
||||
}
|
||||
|
||||
res.json({ message: 'You have left the classroom' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get students in classroom (teacher or student in class)
|
||||
router.get('/:classroomId/students',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
// Check access
|
||||
const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
|
||||
if (!isTeacher && !isStudent) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const students = await Classrooms.getStudents(classroomId);
|
||||
|
||||
res.json({
|
||||
students: students.map(s => ({
|
||||
id: s.id,
|
||||
username: s.username,
|
||||
displayName: s.display_name,
|
||||
xpTotal: s.xp_total,
|
||||
level: s.creator_level,
|
||||
badge: s.creator_badge,
|
||||
gamesCreated: parseInt(s.games_created) || 0,
|
||||
gamesPlayed: parseInt(s.games_played) || 0,
|
||||
joinedAt: s.joined_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove student from classroom (teacher only)
|
||||
router.delete('/:classroomId/students/:studentId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId, studentId } = req.params;
|
||||
|
||||
const removed = await Classrooms.removeStudent(classroomId, parseInt(studentId), req.user.id);
|
||||
|
||||
if (!removed) {
|
||||
return res.status(404).json({ error: 'Student not found or you are not the teacher' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Student removed from classroom' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get classroom leaderboard
|
||||
router.get('/:classroomId/leaderboard',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
// Check access
|
||||
const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
|
||||
if (!isTeacher && !isStudent) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const leaderboard = await Classrooms.getLeaderboard(classroomId);
|
||||
|
||||
res.json({
|
||||
leaderboard: leaderboard.map(s => ({
|
||||
rank: parseInt(s.rank),
|
||||
id: s.id,
|
||||
username: s.username,
|
||||
displayName: s.display_name,
|
||||
xpTotal: s.xp_total,
|
||||
level: s.creator_level,
|
||||
badge: s.creator_badge,
|
||||
gamesCreated: parseInt(s.games_created) || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get detailed student progress (teacher only)
|
||||
router.get('/:classroomId/student-progress',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
// Only teachers can access detailed progress
|
||||
const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
|
||||
if (!isTeacher) {
|
||||
return res.status(403).json({ error: 'Only teachers can access detailed student progress' });
|
||||
}
|
||||
|
||||
const students = await Classrooms.getDetailedStudentProgress(classroomId);
|
||||
|
||||
res.json({
|
||||
students: students.map(s => ({
|
||||
id: s.id,
|
||||
username: s.username,
|
||||
displayName: s.display_name,
|
||||
xpTotal: parseInt(s.xp_total) || 0,
|
||||
level: parseInt(s.level) || 1,
|
||||
badge: s.badge || 'Newbie',
|
||||
joinedAt: s.joined_at,
|
||||
gamesCreated: parseInt(s.games_created) || 0,
|
||||
gamesPlayed: parseInt(s.games_played) || 0,
|
||||
achievementCount: parseInt(s.achievement_count) || 0,
|
||||
xpThisWeek: parseInt(s.xp_this_week) || 0,
|
||||
gamesThisWeek: parseInt(s.games_this_week) || 0,
|
||||
totalPlaysOnGames: parseInt(s.total_plays_on_games) || 0
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// === ASSIGNMENTS ===
|
||||
|
||||
// Create assignment (teacher only)
|
||||
router.post('/:classroomId/assignments',
|
||||
authenticate,
|
||||
validate([
|
||||
body('title').trim().isLength({ min: 1, max: 100 }).withMessage('Assignment title required'),
|
||||
body('description').optional().trim().isLength({ max: 1000 }),
|
||||
body('requiredGameStyle').optional().trim().isLength({ max: 50 }),
|
||||
body('dueDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const { title, description, requiredGameStyle, dueDate } = req.body;
|
||||
|
||||
// Verify teacher
|
||||
const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
|
||||
if (!isTeacher) {
|
||||
return res.status(403).json({ error: 'Only the teacher can create assignments' });
|
||||
}
|
||||
|
||||
const assignment = await Classrooms.createAssignment(classroomId, {
|
||||
title,
|
||||
description,
|
||||
requiredGameStyle,
|
||||
dueDate
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Assignment created',
|
||||
assignment: {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
requiredGameStyle: assignment.required_game_style,
|
||||
dueDate: assignment.due_date,
|
||||
createdAt: assignment.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get assignments for classroom
|
||||
router.get('/:classroomId/assignments',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
// Check access
|
||||
const isTeacher = await Classrooms.isTeacher(classroomId, req.user.id);
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
|
||||
if (!isTeacher && !isStudent) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
|
||||
const assignments = await Classrooms.getAssignments(classroomId);
|
||||
|
||||
res.json({
|
||||
assignments: assignments.map(a => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
requiredGameStyle: a.required_game_style,
|
||||
dueDate: a.due_date,
|
||||
createdAt: a.created_at,
|
||||
isOverdue: a.due_date && new Date(a.due_date) < new Date()
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete assignment (teacher only)
|
||||
router.delete('/:classroomId/assignments/:assignmentId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { assignmentId } = req.params;
|
||||
|
||||
const deleted = await Classrooms.deleteAssignment(assignmentId, req.user.id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Assignment not found or you are not the teacher' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Assignment deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark assignment as completed (student)
|
||||
router.post('/:classroomId/assignments/:assignmentId/complete',
|
||||
authenticate,
|
||||
validate([
|
||||
body('gameId').optional().isInt(),
|
||||
body('notes').optional().trim().isLength({ max: 500 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId, assignmentId } = req.params;
|
||||
const { gameId, notes } = req.body;
|
||||
|
||||
// Check student is enrolled
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
if (!isStudent) {
|
||||
return res.status(403).json({ error: 'You are not enrolled in this classroom' });
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
const result = await pool.query(
|
||||
`INSERT INTO assignment_completions (assignment_id, student_id, game_id, notes)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (assignment_id, student_id)
|
||||
DO UPDATE SET completed_at = NOW(), game_id = COALESCE($3, assignment_completions.game_id), notes = COALESCE($4, assignment_completions.notes)
|
||||
RETURNING *`,
|
||||
[assignmentId, req.user.id, gameId || null, notes || null]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Assignment marked as completed',
|
||||
completion: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get my assignment status (student)
|
||||
router.get('/:classroomId/my-assignments',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
|
||||
// Check student is enrolled
|
||||
const isStudent = await Classrooms.isStudent(classroomId, req.user.id);
|
||||
if (!isStudent) {
|
||||
return res.status(403).json({ error: 'You are not enrolled in this classroom' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT a.*,
|
||||
ac.completed_at, ac.game_id, ac.notes as completion_notes,
|
||||
g.title as submitted_game_title
|
||||
FROM classroom_assignments a
|
||||
LEFT JOIN assignment_completions ac ON a.id = ac.assignment_id AND ac.student_id = $2
|
||||
LEFT JOIN games g ON ac.game_id = g.id
|
||||
WHERE a.classroom_id = $1
|
||||
ORDER BY a.due_date ASC NULLS LAST, a.created_at DESC`,
|
||||
[classroomId, req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
assignments: result.rows.map(a => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
description: a.description,
|
||||
requiredGameStyle: a.required_game_style,
|
||||
dueDate: a.due_date,
|
||||
createdAt: a.created_at,
|
||||
isCompleted: !!a.completed_at,
|
||||
completedAt: a.completed_at,
|
||||
submittedGameId: a.game_id,
|
||||
submittedGameTitle: a.submitted_game_title,
|
||||
completionNotes: a.completion_notes,
|
||||
isOverdue: a.due_date && new Date(a.due_date) < new Date() && !a.completed_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Send message to all students in classroom (teacher only)
|
||||
router.post('/:classroomId/broadcast',
|
||||
authenticate,
|
||||
validate([
|
||||
body('message').trim().isLength({ min: 1, max: 500 }).withMessage('Message required (max 500 chars)'),
|
||||
body('title').optional().trim().isLength({ max: 100 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId } = req.params;
|
||||
const { message, title } = req.body;
|
||||
|
||||
// Check teacher owns classroom
|
||||
const classroom = await Classrooms.findById(classroomId);
|
||||
if (!classroom || classroom.teacher_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You are not the teacher of this classroom' });
|
||||
}
|
||||
|
||||
// Get all students
|
||||
const students = await Classrooms.getStudents(classroomId);
|
||||
|
||||
// Create notification for each student
|
||||
const Notifications = require('../models/notifications');
|
||||
const notificationTitle = title || `Message from ${req.user.displayName || req.user.username}`;
|
||||
|
||||
for (const student of students) {
|
||||
await Notifications.create(student.id, 'classroom_message', notificationTitle, message, `/classrooms`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Broadcast sent successfully',
|
||||
recipientCount: students.length
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get assignment completion stats for teacher
|
||||
router.get('/:classroomId/assignments/:assignmentId/completions',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { classroomId, assignmentId } = req.params;
|
||||
|
||||
// Check teacher owns classroom
|
||||
const classroom = await Classrooms.findById(classroomId);
|
||||
if (!classroom || classroom.teacher_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You are not the teacher of this classroom' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT cs.student_id, u.username, u.display_name,
|
||||
ac.completed_at, ac.game_id, ac.notes,
|
||||
g.title as game_title
|
||||
FROM classroom_students cs
|
||||
JOIN users u ON cs.student_id = u.id
|
||||
LEFT JOIN assignment_completions ac ON ac.assignment_id = $2 AND ac.student_id = cs.student_id
|
||||
LEFT JOIN games g ON ac.game_id = g.id
|
||||
WHERE cs.classroom_id = $1
|
||||
ORDER BY ac.completed_at ASC NULLS LAST, u.username ASC`,
|
||||
[classroomId, assignmentId]
|
||||
);
|
||||
|
||||
const completions = result.rows;
|
||||
const completedCount = completions.filter(c => c.completed_at).length;
|
||||
|
||||
res.json({
|
||||
totalStudents: completions.length,
|
||||
completedCount,
|
||||
completionRate: completions.length > 0 ? Math.round((completedCount / completions.length) * 100) : 0,
|
||||
students: completions.map(c => ({
|
||||
studentId: c.student_id,
|
||||
username: c.username,
|
||||
displayName: c.display_name,
|
||||
isCompleted: !!c.completed_at,
|
||||
completedAt: c.completed_at,
|
||||
submittedGameId: c.game_id,
|
||||
submittedGameTitle: c.game_title,
|
||||
notes: c.notes
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,272 @@
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const Collections = require('../models/collections');
|
||||
const Achievements = require('../models/achievements');
|
||||
const { authenticate, optionalAuth } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const { filterTitle, filterDescription, logFilterAction } = require('../utils/contentFilter');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create a new collection
|
||||
router.post('/',
|
||||
authenticate,
|
||||
validate([
|
||||
body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Name is required (max 100 chars)'),
|
||||
body('description').optional().trim().isLength({ max: 500 }),
|
||||
body('isPublic').optional().isBoolean()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name, description, isPublic } = req.body;
|
||||
|
||||
// Content filter for collection name
|
||||
const nameFilter = filterTitle(name);
|
||||
if (nameFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'collection_name', name, nameFilter);
|
||||
return res.status(400).json({
|
||||
error: nameFilter.message,
|
||||
blocked: true,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
|
||||
// Content filter for description
|
||||
if (description) {
|
||||
const descFilter = filterDescription(description);
|
||||
if (descFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'collection_description', description, descFilter);
|
||||
return res.status(400).json({
|
||||
error: descFilter.message,
|
||||
blocked: true,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const collection = await Collections.create({
|
||||
userId: req.user.id,
|
||||
name,
|
||||
description,
|
||||
isPublic: isPublic !== false
|
||||
});
|
||||
|
||||
// Check for first collection achievement
|
||||
const count = await Collections.getCount(req.user.id);
|
||||
await Achievements.checkAndAward(req.user.id, 'first_collection', count);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Collection created',
|
||||
collection: {
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
isPublic: collection.is_public,
|
||||
createdAt: collection.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get collection by ID
|
||||
router.get('/:id',
|
||||
optionalAuth,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const collection = await Collections.findById(req.params.id);
|
||||
|
||||
if (!collection) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
// Check visibility
|
||||
const isOwner = req.user && req.user.id === collection.user_id;
|
||||
if (!collection.is_public && !isOwner) {
|
||||
return res.status(404).json({ error: 'Collection not found' });
|
||||
}
|
||||
|
||||
let games = await Collections.getGames(req.params.id, isOwner);
|
||||
|
||||
// Filter games by search query if provided
|
||||
const { search } = req.query;
|
||||
if (search && search.trim()) {
|
||||
const searchTerm = search.trim().toLowerCase();
|
||||
games = games.filter(g =>
|
||||
g.title.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
collection: {
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
isPublic: collection.is_public,
|
||||
ownerUsername: collection.owner_username,
|
||||
ownerDisplayName: collection.owner_display_name,
|
||||
gameCount: parseInt(collection.game_count),
|
||||
createdAt: collection.created_at
|
||||
},
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
playCount: g.play_count,
|
||||
creatorUsername: g.creator_username,
|
||||
addedAt: g.added_at
|
||||
})),
|
||||
isOwner
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update collection
|
||||
router.put('/:id',
|
||||
authenticate,
|
||||
validate([
|
||||
body('name').optional().trim().isLength({ min: 1, max: 100 }),
|
||||
body('description').optional().trim().isLength({ max: 500 }),
|
||||
body('isPublic').optional().isBoolean()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name, description, isPublic } = req.body;
|
||||
|
||||
// Content filter for name
|
||||
if (name) {
|
||||
const nameFilter = filterTitle(name);
|
||||
if (nameFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'collection_name', name, nameFilter);
|
||||
return res.status(400).json({
|
||||
error: nameFilter.message,
|
||||
blocked: true,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Content filter for description
|
||||
if (description) {
|
||||
const descFilter = filterDescription(description);
|
||||
if (descFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'collection_description', description, descFilter);
|
||||
return res.status(400).json({
|
||||
error: descFilter.message,
|
||||
blocked: true,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await Collections.update(req.params.id, req.user.id, {
|
||||
name,
|
||||
description,
|
||||
isPublic
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Collection not found or not owned by you' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Collection updated',
|
||||
collection: {
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
description: updated.description,
|
||||
isPublic: updated.is_public
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete collection
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await Collections.delete(req.params.id, req.user.id);
|
||||
res.json({ message: 'Collection deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add game to collection
|
||||
router.post('/:id/games',
|
||||
authenticate,
|
||||
validate([
|
||||
body('gameId').isInt().withMessage('Valid game ID required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.body;
|
||||
|
||||
await Collections.addGame(req.params.id, gameId, req.user.id);
|
||||
|
||||
res.json({ message: 'Game added to collection' });
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove game from collection
|
||||
router.delete('/:id/games/:gameId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await Collections.removeGame(req.params.id, req.params.gameId, req.user.id);
|
||||
res.json({ message: 'Game removed from collection' });
|
||||
} catch (error) {
|
||||
if (error.message.includes('not found')) {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get public collections
|
||||
router.get('/',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const collections = await Collections.getPublicCollections(parseInt(limit), offset);
|
||||
|
||||
res.json({
|
||||
collections: collections.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
ownerUsername: c.owner_username,
|
||||
ownerDisplayName: c.owner_display_name,
|
||||
gameCount: parseInt(c.game_count),
|
||||
createdAt: c.created_at
|
||||
})),
|
||||
page: parseInt(page),
|
||||
hasMore: collections.length === parseInt(limit)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,73 @@
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const validate = require('../middleware/validate');
|
||||
const { forwardToAdmin, sendEmail } = require('../utils/email');
|
||||
const { filterText, logFilterAction } = require('../utils/contentFilter');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Submit contact form
|
||||
router.post('/',
|
||||
validate([
|
||||
body('name').trim().isLength({ min: 1, max: 100 }).withMessage('Name required (max 100 chars)'),
|
||||
body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
|
||||
body('subject').trim().isLength({ min: 1, max: 200 }).withMessage('Subject required (max 200 chars)'),
|
||||
body('message').trim().isLength({ min: 10, max: 5000 }).withMessage('Message must be 10-5000 characters')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name, email, subject, message } = req.body;
|
||||
|
||||
// Content filter
|
||||
const messageFilter = filterText(message, { contentType: 'message' });
|
||||
if (messageFilter.blocked) {
|
||||
logFilterAction(null, 'contact_message', message, messageFilter);
|
||||
return res.status(400).json({
|
||||
error: messageFilter.message,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
|
||||
// Forward to admin
|
||||
const result = await forwardToAdmin(
|
||||
`${name} <${email}>`,
|
||||
subject,
|
||||
message
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Send confirmation to user
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: 'We received your message - GamerComp',
|
||||
text: `Hi ${name},\n\nThanks for reaching out! We received your message and will get back to you soon.\n\nYour message:\n"${message.substring(0, 200)}${message.length > 200 ? '...' : ''}"\n\n- The GamerComp Team`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<h2 style="color: #6366f1;">Thanks for reaching out!</h2>
|
||||
<p>Hi ${name},</p>
|
||||
<p>We received your message and will get back to you soon.</p>
|
||||
<div style="background: #f8fafc; padding: 1rem; border-radius: 0.5rem; margin: 1rem 0;">
|
||||
<strong>Your message:</strong><br>
|
||||
"${message.substring(0, 200)}${message.length > 200 ? '...' : ''}"
|
||||
</div>
|
||||
<p style="color: #64748b;">- The GamerComp Team</p>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Message sent successfully! Check your email for confirmation.'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to send message. Please try again later.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
const express = require('express');
|
||||
const Games = require('../models/games');
|
||||
const { DeveloperEarnings } = require('../models/plays');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get developer dashboard
|
||||
router.get('/dashboard',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const games = await Games.findByCreator(req.user.id);
|
||||
const earnings = await DeveloperEarnings.getByDeveloper(req.user.id);
|
||||
|
||||
const totalPlays = games.reduce((sum, g) => sum + g.play_count, 0);
|
||||
const publishedGames = games.filter(g => g.status === 'published');
|
||||
const pendingGames = games.filter(g => g.status === 'pending_review');
|
||||
const draftGames = games.filter(g => g.status === 'draft');
|
||||
|
||||
res.json({
|
||||
stats: {
|
||||
totalGames: games.length,
|
||||
publishedGames: publishedGames.length,
|
||||
pendingReview: pendingGames.length,
|
||||
drafts: draftGames.length,
|
||||
totalPlays: totalPlays,
|
||||
tokensEarned: parseInt(earnings.total_earned) || 0,
|
||||
currentBalance: req.user.tokens_balance
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get developer's games with detailed stats
|
||||
router.get('/games',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const games = await Games.findByCreator(req.user.id);
|
||||
|
||||
res.json({
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
status: g.status,
|
||||
playCount: g.play_count,
|
||||
tokensEarned: g.tokens_earned,
|
||||
averageRating: g.total_ratings > 0
|
||||
? (g.rating_sum / g.total_ratings).toFixed(1)
|
||||
: null,
|
||||
totalRatings: g.total_ratings,
|
||||
createdAt: g.created_at,
|
||||
publishedAt: g.published_at,
|
||||
rejectionReason: g.rejection_reason
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Request payout (placeholder - would integrate with payment system)
|
||||
router.post('/request-payout',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const minPayout = 100;
|
||||
|
||||
if (req.user.tokens_balance < minPayout) {
|
||||
return res.status(400).json({
|
||||
error: `Minimum payout is ${minPayout} tokens`,
|
||||
currentBalance: req.user.tokens_balance
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Integrate with payment system
|
||||
// For now, just acknowledge the request
|
||||
|
||||
res.json({
|
||||
message: 'Payout request received',
|
||||
amount: req.user.tokens_balance,
|
||||
note: 'Payout processing coming soon!'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get game analytics (creator only)
|
||||
router.get('/games/:gameId/analytics',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
|
||||
// Get the game and verify ownership
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.creator_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'You can only view analytics for your own games' });
|
||||
}
|
||||
|
||||
// Run all analytics queries in parallel for performance
|
||||
const [
|
||||
dailyPlaysResult,
|
||||
ratingDistributionResult,
|
||||
sessionStatsResult,
|
||||
uniquePlayersResult,
|
||||
highScoreDistributionResult,
|
||||
peakHoursResult,
|
||||
highestScoreResult
|
||||
] = await Promise.all([
|
||||
// Daily play counts for last 30 days
|
||||
pool.query(
|
||||
`SELECT DATE(started_at) as date, COUNT(*) as count
|
||||
FROM plays
|
||||
WHERE game_id = $1
|
||||
AND started_at >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY DATE(started_at)
|
||||
ORDER BY date ASC`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// Rating distribution (1-5 stars breakdown)
|
||||
pool.query(
|
||||
`SELECT rating, COUNT(*) as count
|
||||
FROM ratings
|
||||
WHERE game_id = $1
|
||||
GROUP BY rating
|
||||
ORDER BY rating ASC`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// Average session duration
|
||||
pool.query(
|
||||
`SELECT
|
||||
AVG(duration_seconds) as avg_duration,
|
||||
MIN(duration_seconds) as min_duration,
|
||||
MAX(duration_seconds) as max_duration,
|
||||
COUNT(*) as total_sessions
|
||||
FROM plays
|
||||
WHERE game_id = $1 AND duration_seconds IS NOT NULL`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// Total unique players
|
||||
pool.query(
|
||||
`SELECT COUNT(DISTINCT player_id) as unique_players
|
||||
FROM plays
|
||||
WHERE game_id = $1`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// High score distribution (buckets)
|
||||
pool.query(
|
||||
`SELECT
|
||||
CASE
|
||||
WHEN score < 100 THEN '0-99'
|
||||
WHEN score < 500 THEN '100-499'
|
||||
WHEN score < 1000 THEN '500-999'
|
||||
WHEN score < 5000 THEN '1000-4999'
|
||||
WHEN score < 10000 THEN '5000-9999'
|
||||
ELSE '10000+'
|
||||
END as score_range,
|
||||
COUNT(*) as count
|
||||
FROM high_scores
|
||||
WHERE game_id = $1
|
||||
GROUP BY
|
||||
CASE
|
||||
WHEN score < 100 THEN '0-99'
|
||||
WHEN score < 500 THEN '100-499'
|
||||
WHEN score < 1000 THEN '500-999'
|
||||
WHEN score < 5000 THEN '1000-4999'
|
||||
WHEN score < 10000 THEN '5000-9999'
|
||||
ELSE '10000+'
|
||||
END
|
||||
ORDER BY MIN(score) ASC`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// Peak play hours (hour of day distribution)
|
||||
pool.query(
|
||||
`SELECT EXTRACT(HOUR FROM started_at) as hour, COUNT(*) as count
|
||||
FROM plays
|
||||
WHERE game_id = $1
|
||||
GROUP BY EXTRACT(HOUR FROM started_at)
|
||||
ORDER BY hour ASC`,
|
||||
[gameId]
|
||||
),
|
||||
|
||||
// Highest score
|
||||
pool.query(
|
||||
`SELECT MAX(score) as highest_score FROM high_scores WHERE game_id = $1`,
|
||||
[gameId]
|
||||
)
|
||||
]);
|
||||
|
||||
// Build daily plays with date labels (last 30 days filled in)
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const dailyPlaysMap = new Map(
|
||||
dailyPlaysResult.rows.map(row => [
|
||||
row.date.toISOString().split('T')[0],
|
||||
parseInt(row.count)
|
||||
])
|
||||
);
|
||||
|
||||
const dailyPlays = [];
|
||||
for (let d = new Date(thirtyDaysAgo); d <= today; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
const dayNum = d.getDate();
|
||||
dailyPlays.push({
|
||||
date: dateStr,
|
||||
plays: dailyPlaysMap.get(dateStr) || 0,
|
||||
label: dayNum.toString()
|
||||
});
|
||||
}
|
||||
|
||||
// Build rating distribution as object {1: count, 2: count, ...}
|
||||
const ratingDistribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
||||
ratingDistributionResult.rows.forEach(row => {
|
||||
ratingDistribution[row.rating] = parseInt(row.count);
|
||||
});
|
||||
|
||||
// Build peak hours as object {0: count, 1: count, ...23: count}
|
||||
const peakHours = {};
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
peakHours[hour] = 0;
|
||||
}
|
||||
peakHoursResult.rows.forEach(row => {
|
||||
peakHours[parseInt(row.hour)] = parseInt(row.count);
|
||||
});
|
||||
|
||||
// Build score distribution as object with range labels
|
||||
const scoreRanges = ['0-99', '100-499', '500-999', '1000-4999', '5000-9999', '10000+'];
|
||||
const scoreDistribution = {};
|
||||
scoreRanges.forEach(range => { scoreDistribution[range] = 0; });
|
||||
highScoreDistributionResult.rows.forEach(row => {
|
||||
scoreDistribution[row.score_range] = parseInt(row.count);
|
||||
});
|
||||
|
||||
const sessionStats = sessionStatsResult.rows[0];
|
||||
const avgSessionDuration = sessionStats.avg_duration
|
||||
? Math.round(parseFloat(sessionStats.avg_duration))
|
||||
: 0;
|
||||
|
||||
const uniquePlayers = parseInt(uniquePlayersResult.rows[0].unique_players);
|
||||
const highestScore = highestScoreResult.rows[0].highest_score
|
||||
? parseInt(highestScoreResult.rows[0].highest_score)
|
||||
: null;
|
||||
|
||||
const avgRating = game.total_ratings > 0
|
||||
? parseFloat((game.rating_sum / game.total_ratings).toFixed(2))
|
||||
: null;
|
||||
|
||||
res.json({
|
||||
dailyPlays,
|
||||
ratingDistribution,
|
||||
peakHours,
|
||||
scoreDistribution,
|
||||
summary: {
|
||||
totalPlays: game.play_count || 0,
|
||||
uniquePlayers,
|
||||
avgSessionDuration,
|
||||
favoriteCount: game.favorite_count || 0,
|
||||
avgRating,
|
||||
totalRatings: game.total_ratings || 0,
|
||||
highestScore,
|
||||
remixCount: game.remix_count || 0,
|
||||
tokensEarned: game.tokens_earned || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,287 @@
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const Games = require('../models/games');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const { generateGame, regenerateGame } = require('../utils/claude');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create game with AI
|
||||
router.post('/create',
|
||||
authenticate,
|
||||
validate([
|
||||
body('description')
|
||||
.trim()
|
||||
.isLength({ min: 10, max: 1000 })
|
||||
.withMessage('Game description must be 10-1000 characters'),
|
||||
body('title')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { description, title } = req.body;
|
||||
|
||||
// Check if user is a guest
|
||||
if (req.user.is_guest) {
|
||||
return res.status(403).json({
|
||||
error: 'Guests cannot create games. Please create an account first.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate game with Claude
|
||||
const result = await generateGame(description);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Failed to generate game. Please try again.' });
|
||||
}
|
||||
|
||||
// Create game in database
|
||||
const gameTitle = title || `Game by ${req.user.username}`;
|
||||
const game = await Games.create({
|
||||
creatorId: req.user.id,
|
||||
title: gameTitle,
|
||||
description: description,
|
||||
gameCode: result.code,
|
||||
prompt: description
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Game created successfully',
|
||||
game: {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
status: game.status,
|
||||
createdAt: game.created_at
|
||||
},
|
||||
previewCode: result.code,
|
||||
tokensUsed: result.tokensUsed || 0
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get game by ID
|
||||
router.get('/:gameId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
// Only show code if owner or game is published
|
||||
const isOwner = game.creator_id === req.user.id;
|
||||
const isPublished = game.status === 'published';
|
||||
|
||||
if (!isOwner && !isPublished) {
|
||||
return res.status(403).json({ error: 'Game not available' });
|
||||
}
|
||||
|
||||
// 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({
|
||||
game: {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
gameCode: game.game_code,
|
||||
codeVersion: game.code_version || 1,
|
||||
prompt: game.prompt,
|
||||
creationPrompt: game.creation_prompt,
|
||||
originalPrompt: game.original_prompt,
|
||||
promptWasEdited: game.prompt_was_edited || false,
|
||||
promptEditCount: game.prompt_edit_count || 0,
|
||||
promptAnswers: game.prompt_answers,
|
||||
status: game.status,
|
||||
playCount: game.play_count,
|
||||
totalRatings: game.total_ratings,
|
||||
ratingSum: game.rating_sum,
|
||||
creatorId: game.creator_id,
|
||||
creatorUsername: game.creator_username,
|
||||
creatorDisplayName: game.creator_display_name,
|
||||
createdAt: game.created_at,
|
||||
publishedAt: game.published_at,
|
||||
creatorPlayed: game.creator_played,
|
||||
creatorPlayDuration: game.creator_play_duration || 0,
|
||||
isOwner
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update game
|
||||
router.put('/:gameId',
|
||||
authenticate,
|
||||
validate([
|
||||
body('title').optional().trim().isLength({ min: 1, max: 100 }),
|
||||
body('description').optional().trim().isLength({ min: 10, max: 1000 }),
|
||||
body('feedback').optional().trim().isLength({ min: 5, max: 1000 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { title, description, feedback } = req.body;
|
||||
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.creator_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
if (game.status === 'published') {
|
||||
return res.status(400).json({ error: 'Cannot edit published games' });
|
||||
}
|
||||
|
||||
let updatedCode = game.game_code;
|
||||
let tokensUsed = 0;
|
||||
|
||||
// If feedback provided, regenerate with Claude
|
||||
if (feedback) {
|
||||
const result = await regenerateGame(game.game_code, feedback);
|
||||
if (result.success) {
|
||||
updatedCode = result.code;
|
||||
tokensUsed = result.tokensUsed || 0;
|
||||
} else {
|
||||
return res.status(500).json({ error: 'Failed to regenerate game. Please try again.' });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await Games.update(gameId, {
|
||||
title,
|
||||
description,
|
||||
gameCode: feedback ? updatedCode : undefined
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Game updated',
|
||||
game: {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
description: updated.description,
|
||||
status: updated.status,
|
||||
gameCode: updated.game_code
|
||||
},
|
||||
tokensUsed
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Submit for review
|
||||
router.post('/:gameId/submit',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.creator_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
if (game.status !== 'draft') {
|
||||
return res.status(400).json({ error: 'Game has already been submitted' });
|
||||
}
|
||||
|
||||
const updated = await Games.submit(gameId);
|
||||
|
||||
res.json({
|
||||
message: 'Game submitted for review',
|
||||
game: { id: updated.id, status: updated.status }
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unpublish game (creator only)
|
||||
router.post('/:gameId/unpublish',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.creator_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
if (game.status !== 'published') {
|
||||
return res.status(400).json({ error: 'Game is not published' });
|
||||
}
|
||||
|
||||
// Unpublish - set status back to testing so they can re-publish
|
||||
await pool.query(
|
||||
`UPDATE games SET status = 'testing', published_at = NULL WHERE id = $1`,
|
||||
[gameId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Game unpublished. You can edit and re-publish it.',
|
||||
game: { id: parseInt(gameId), status: 'testing' }
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete game
|
||||
router.delete('/:gameId',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
if (game.creator_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Not authorized' });
|
||||
}
|
||||
|
||||
await Games.delete(gameId);
|
||||
|
||||
res.json({ message: 'Game deleted' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,348 @@
|
||||
const express = require('express');
|
||||
const { query } = require('express-validator');
|
||||
const { pool } = require('../models/db');
|
||||
const validate = require('../middleware/validate');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ==========================================
|
||||
// LEADERBOARD ENDPOINTS
|
||||
// Fun leaderboards for our gaming community!
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard/players
|
||||
* Top players by XP - Who's leveling up the fastest?
|
||||
*/
|
||||
router.get('/players',
|
||||
validate([
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 })
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get top players by XP
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_data,
|
||||
u.xp_total,
|
||||
u.creator_level,
|
||||
u.creator_badge,
|
||||
(SELECT COUNT(*) FROM games WHERE creator_id = u.id AND status = 'published') as games_created,
|
||||
(SELECT COALESCE(SUM(play_count), 0) FROM games WHERE creator_id = u.id) as total_plays_received
|
||||
FROM users u
|
||||
WHERE u.is_banned = false AND u.xp_total > 0
|
||||
ORDER BY u.xp_total DESC, u.created_at ASC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count for pagination info
|
||||
const countResult = await pool.query(
|
||||
'SELECT COUNT(*) FROM users WHERE is_banned = false AND xp_total > 0'
|
||||
);
|
||||
const totalCount = parseInt(countResult.rows[0].count);
|
||||
|
||||
res.json({
|
||||
leaderboard: result.rows.map((player, index) => ({
|
||||
rank: offset + index + 1,
|
||||
id: player.id,
|
||||
username: player.username,
|
||||
displayName: player.display_name,
|
||||
avatarData: player.avatar_data,
|
||||
xp: player.xp_total,
|
||||
level: player.creator_level,
|
||||
badge: player.creator_badge,
|
||||
gamesCreated: parseInt(player.games_created) || 0,
|
||||
totalPlaysReceived: parseInt(player.total_plays_received) || 0
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasMore: offset + result.rows.length < totalCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard/creators
|
||||
* Top creators by total plays on their games - Who makes the most popular games?
|
||||
*/
|
||||
router.get('/creators',
|
||||
validate([
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 }),
|
||||
query('period').optional().isIn(['week', 'month', 'all'])
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const period = req.query.period || 'all';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build date filter based on period
|
||||
let dateFilter = '';
|
||||
if (period === 'week') {
|
||||
dateFilter = "AND p.started_at >= NOW() - INTERVAL '7 days'";
|
||||
} else if (period === 'month') {
|
||||
dateFilter = "AND p.started_at >= NOW() - INTERVAL '30 days'";
|
||||
}
|
||||
|
||||
// Get top creators by total plays
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.display_name,
|
||||
u.avatar_data,
|
||||
u.xp_total,
|
||||
u.creator_level,
|
||||
u.creator_badge,
|
||||
COUNT(DISTINCT g.id) as games_count,
|
||||
COUNT(p.id) as total_plays,
|
||||
COALESCE(SUM(g.favorite_count), 0) as total_favorites,
|
||||
COALESCE(
|
||||
AVG(CASE WHEN g.total_ratings > 0 THEN g.rating_sum::float / g.total_ratings END),
|
||||
0
|
||||
) as avg_rating
|
||||
FROM users u
|
||||
JOIN games g ON g.creator_id = u.id AND g.status = 'published'
|
||||
LEFT JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
|
||||
WHERE u.is_banned = false
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(p.id) > 0
|
||||
ORDER BY total_plays DESC, games_count DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(DISTINCT u.id)
|
||||
FROM users u
|
||||
JOIN games g ON g.creator_id = u.id AND g.status = 'published'
|
||||
JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
|
||||
WHERE u.is_banned = false`
|
||||
);
|
||||
const totalCount = parseInt(countResult.rows[0].count);
|
||||
|
||||
res.json({
|
||||
leaderboard: result.rows.map((creator, index) => ({
|
||||
rank: offset + index + 1,
|
||||
id: creator.id,
|
||||
username: creator.username,
|
||||
displayName: creator.display_name,
|
||||
avatarData: creator.avatar_data,
|
||||
xp: creator.xp_total,
|
||||
level: creator.creator_level,
|
||||
badge: creator.creator_badge,
|
||||
gamesCount: parseInt(creator.games_count) || 0,
|
||||
totalPlays: parseInt(creator.total_plays) || 0,
|
||||
totalFavorites: parseInt(creator.total_favorites) || 0,
|
||||
avgRating: parseFloat(creator.avg_rating).toFixed(1) || '0.0'
|
||||
})),
|
||||
period,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasMore: offset + result.rows.length < totalCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/leaderboard/games
|
||||
* Most played games - What's everyone playing?
|
||||
*/
|
||||
router.get('/games',
|
||||
validate([
|
||||
query('page').optional().isInt({ min: 1 }),
|
||||
query('limit').optional().isInt({ min: 1, max: 50 }),
|
||||
query('period').optional().isIn(['week', 'month', 'all'])
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const period = req.query.period || 'all';
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// For week/month, we need to count recent plays
|
||||
// For all-time, we can use the play_count column directly
|
||||
let orderBy = 'g.play_count DESC';
|
||||
let selectPlays = 'g.play_count as period_plays';
|
||||
let dateFilter = '';
|
||||
|
||||
if (period === 'week' || period === 'month') {
|
||||
const interval = period === 'week' ? '7 days' : '30 days';
|
||||
dateFilter = `AND p.started_at >= NOW() - INTERVAL '${interval}'`;
|
||||
|
||||
// Count plays in the time period
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.description,
|
||||
g.thumbnail_url,
|
||||
g.thumbnail_data,
|
||||
g.play_count as all_time_plays,
|
||||
g.favorite_count,
|
||||
g.total_ratings,
|
||||
g.rating_sum,
|
||||
g.published_at,
|
||||
g.game_style,
|
||||
u.id as creator_id,
|
||||
u.username as creator_username,
|
||||
u.display_name as creator_display_name,
|
||||
u.avatar_data as creator_avatar_data,
|
||||
COUNT(p.id) as period_plays
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
LEFT JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
|
||||
WHERE g.status = 'published' AND u.is_banned = false
|
||||
GROUP BY g.id, u.id
|
||||
HAVING COUNT(p.id) > 0
|
||||
ORDER BY period_plays DESC, g.play_count DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(DISTINCT g.id)
|
||||
FROM games g
|
||||
JOIN plays p ON p.game_id = g.id AND p.is_creator_play = false ${dateFilter}
|
||||
WHERE g.status = 'published'`
|
||||
);
|
||||
const totalCount = parseInt(countResult.rows[0].count);
|
||||
|
||||
return res.json({
|
||||
leaderboard: result.rows.map((game, index) => ({
|
||||
rank: offset + index + 1,
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
thumbnailUrl: game.thumbnail_url,
|
||||
thumbnailData: game.thumbnail_data,
|
||||
gameStyle: game.game_style,
|
||||
periodPlays: parseInt(game.period_plays) || 0,
|
||||
allTimePlays: parseInt(game.all_time_plays) || 0,
|
||||
favoriteCount: parseInt(game.favorite_count) || 0,
|
||||
avgRating: game.total_ratings > 0
|
||||
? (game.rating_sum / game.total_ratings).toFixed(1)
|
||||
: '0.0',
|
||||
totalRatings: game.total_ratings,
|
||||
publishedAt: game.published_at,
|
||||
creator: {
|
||||
id: game.creator_id,
|
||||
username: game.creator_username,
|
||||
displayName: game.creator_display_name,
|
||||
avatarData: game.creator_avatar_data
|
||||
}
|
||||
})),
|
||||
period,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasMore: offset + result.rows.length < totalCount
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// All-time leaderboard using play_count column
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
g.id,
|
||||
g.title,
|
||||
g.description,
|
||||
g.thumbnail_url,
|
||||
g.thumbnail_data,
|
||||
g.play_count,
|
||||
g.favorite_count,
|
||||
g.total_ratings,
|
||||
g.rating_sum,
|
||||
g.published_at,
|
||||
g.game_style,
|
||||
u.id as creator_id,
|
||||
u.username as creator_username,
|
||||
u.display_name as creator_display_name,
|
||||
u.avatar_data as creator_avatar_data
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.status = 'published' AND u.is_banned = false AND g.play_count > 0
|
||||
ORDER BY g.play_count DESC, g.published_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*)
|
||||
FROM games g
|
||||
JOIN users u ON g.creator_id = u.id
|
||||
WHERE g.status = 'published' AND u.is_banned = false AND g.play_count > 0`
|
||||
);
|
||||
const totalCount = parseInt(countResult.rows[0].count);
|
||||
|
||||
res.json({
|
||||
leaderboard: result.rows.map((game, index) => ({
|
||||
rank: offset + index + 1,
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
thumbnailUrl: game.thumbnail_url,
|
||||
thumbnailData: game.thumbnail_data,
|
||||
gameStyle: game.game_style,
|
||||
periodPlays: parseInt(game.play_count) || 0,
|
||||
allTimePlays: parseInt(game.play_count) || 0,
|
||||
favoriteCount: parseInt(game.favorite_count) || 0,
|
||||
avgRating: game.total_ratings > 0
|
||||
? (game.rating_sum / game.total_ratings).toFixed(1)
|
||||
: '0.0',
|
||||
totalRatings: game.total_ratings,
|
||||
publishedAt: game.published_at,
|
||||
creator: {
|
||||
id: game.creator_id,
|
||||
username: game.creator_username,
|
||||
displayName: game.creator_display_name,
|
||||
avatarData: game.creator_avatar_data
|
||||
}
|
||||
})),
|
||||
period,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
hasMore: offset + result.rows.length < totalCount
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,409 @@
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const { filterDescription, logFilterAction } = require('../utils/contentFilter');
|
||||
const GameVersions = require('../models/gameVersions');
|
||||
const { refinePrompt, classifyTweak, applyTweak, isAvailable } = require('../utils/localAI');
|
||||
const { preprocessTweak, explainError, summarizeCodeChanges } = require('../utils/haiku');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ==========================================
|
||||
// HEALTH CHECK (no auth)
|
||||
// ==========================================
|
||||
|
||||
router.get('/health', async (req, res) => {
|
||||
const health = await isAvailable();
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// STAGE 1: PROMPT REFINEMENT CHAT
|
||||
// ==========================================
|
||||
|
||||
router.post('/refine-prompt',
|
||||
authenticate,
|
||||
validate([
|
||||
body('gameIdea').trim().isLength({ min: 3, max: 1000 }).withMessage('Game idea required (3-1000 chars)'),
|
||||
body('conversationHistory').isArray().withMessage('Conversation history must be an array')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameIdea, conversationHistory } = req.body;
|
||||
|
||||
// Content filter the game idea
|
||||
const ideaFilter = filterDescription(gameIdea);
|
||||
if (ideaFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'prompt_refine', gameIdea, ideaFilter);
|
||||
return res.status(400).json({
|
||||
error: ideaFilter.message,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
|
||||
// Filter latest user message in conversation
|
||||
if (conversationHistory.length > 0) {
|
||||
const lastUserMsg = [...conversationHistory].reverse().find(m => m.role === 'user');
|
||||
if (lastUserMsg) {
|
||||
const msgFilter = filterDescription(lastUserMsg.text);
|
||||
if (msgFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'prompt_refine_msg', lastUserMsg.text, msgFilter);
|
||||
return res.status(400).json({
|
||||
error: msgFilter.message,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await refinePrompt(gameIdea, conversationHistory);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(503).json({
|
||||
error: 'AI refinement is temporarily unavailable. Please use the guided questions instead.',
|
||||
unavailable: true
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
question: result.question,
|
||||
isComplete: result.isComplete,
|
||||
refinedSpec: result.refinedSpec,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// STAGE 3: CLASSIFY FEEDBACK
|
||||
// ==========================================
|
||||
|
||||
router.post('/classify',
|
||||
authenticate,
|
||||
validate([
|
||||
body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)'),
|
||||
body('gameId').isInt().withMessage('Valid game ID required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { feedback } = req.body;
|
||||
|
||||
const result = await classifyTweak(feedback);
|
||||
|
||||
// Estimate cost for Claude if major
|
||||
const estimatedCostCents = result.classification === 'major_change' ? 5 : 0;
|
||||
|
||||
res.json({
|
||||
classification: result.classification,
|
||||
confidence: result.confidence,
|
||||
reason: result.reason,
|
||||
estimatedCostCents
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// STAGE 3: PREVIEW TWEAK (before applying)
|
||||
// ==========================================
|
||||
|
||||
router.post('/tweak/:gameId/preview',
|
||||
authenticate,
|
||||
validate([
|
||||
body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { feedback } = req.body;
|
||||
|
||||
// Get the game and verify ownership
|
||||
const gameResult = await pool.query(
|
||||
'SELECT id, title, status, creator_id FROM games WHERE id = $1 AND creator_id = $2',
|
||||
[gameId, req.user.id]
|
||||
);
|
||||
|
||||
if (gameResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const game = gameResult.rows[0];
|
||||
|
||||
// Content filter
|
||||
const feedbackFilter = filterDescription(feedback);
|
||||
if (feedbackFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'local_tweak_preview', feedback, feedbackFilter);
|
||||
return res.status(400).json({
|
||||
error: feedbackFilter.message,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
|
||||
// Haiku preprocessing - check feasibility and optimize
|
||||
const preprocess = await preprocessTweak(feedback, game.title);
|
||||
|
||||
if (preprocess.needsClarification) {
|
||||
return res.json({
|
||||
canProceed: false,
|
||||
needsClarification: true,
|
||||
question: preprocess.question
|
||||
});
|
||||
}
|
||||
|
||||
if (preprocess.canTweak === false) {
|
||||
return res.json({
|
||||
canProceed: false,
|
||||
tooComplex: true,
|
||||
reason: preprocess.reason
|
||||
});
|
||||
}
|
||||
|
||||
// Return the optimized request for confirmation
|
||||
res.json({
|
||||
canProceed: true,
|
||||
originalRequest: feedback,
|
||||
optimizedRequest: preprocess.cleanedRequest || feedback,
|
||||
wasOptimized: (preprocess.cleanedRequest && preprocess.cleanedRequest !== feedback)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// STAGE 3: REVIEW MANUAL CODE CHANGES
|
||||
// ==========================================
|
||||
|
||||
router.post('/review-code/:gameId',
|
||||
authenticate,
|
||||
validate([
|
||||
body('newCode').isLength({ min: 100 }).withMessage('Code is too short')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { newCode } = req.body;
|
||||
|
||||
// Get the game and verify ownership
|
||||
const gameResult = await pool.query(
|
||||
'SELECT id, title, game_code, status, creator_id FROM games WHERE id = $1 AND creator_id = $2',
|
||||
[gameId, req.user.id]
|
||||
);
|
||||
|
||||
if (gameResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const game = gameResult.rows[0];
|
||||
|
||||
if (!['testing', 'draft'].includes(game.status)) {
|
||||
return res.status(400).json({ error: 'Cannot edit code for this game in its current state.' });
|
||||
}
|
||||
|
||||
// Use Haiku to summarize what changed
|
||||
const summary = await summarizeCodeChanges(game.game_code, newCode, 'manual code edits');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
summary,
|
||||
oldCodeLength: (game.game_code || '').length,
|
||||
newCodeLength: newCode.length
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// STAGE 3: SAVE MANUAL CODE CHANGES
|
||||
// ==========================================
|
||||
|
||||
router.post('/save-code/:gameId',
|
||||
authenticate,
|
||||
validate([
|
||||
body('newCode').isLength({ min: 100 }).withMessage('Code is too short')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { newCode } = req.body;
|
||||
|
||||
// Get the game and verify ownership
|
||||
const gameResult = await pool.query(
|
||||
'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
|
||||
[gameId, req.user.id]
|
||||
);
|
||||
|
||||
if (gameResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const game = gameResult.rows[0];
|
||||
|
||||
if (!['testing', 'draft'].includes(game.status)) {
|
||||
return res.status(400).json({ error: 'Cannot edit code for this game in its current state.' });
|
||||
}
|
||||
|
||||
// Save pre-edit version
|
||||
await GameVersions.saveVersion(game.id, game.game_code, 'pre_manual_edit', 'Manual code edit').catch(() => {});
|
||||
|
||||
// Update game code
|
||||
await pool.query(
|
||||
`UPDATE games SET
|
||||
game_code = $1,
|
||||
code_version = COALESCE(code_version, 0) + 1,
|
||||
code_updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[newCode, gameId]
|
||||
);
|
||||
|
||||
// Save post-edit version
|
||||
await GameVersions.saveVersion(game.id, newCode, 'manual_edit', 'Manual code edit').catch(() => {});
|
||||
|
||||
// Get summary for display
|
||||
const summary = await summarizeCodeChanges(game.game_code, newCode, 'manual code edits');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
summary,
|
||||
codeLength: newCode.length
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// STAGE 3: APPLY FREE TWEAK
|
||||
// ==========================================
|
||||
|
||||
router.post('/tweak/:gameId',
|
||||
authenticate,
|
||||
validate([
|
||||
body('feedback').trim().isLength({ min: 5, max: 500 }).withMessage('Feedback required (5-500 chars)')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const { feedback } = req.body;
|
||||
|
||||
// Get the game and verify ownership
|
||||
const gameResult = await pool.query(
|
||||
'SELECT * FROM games WHERE id = $1 AND creator_id = $2',
|
||||
[gameId, req.user.id]
|
||||
);
|
||||
|
||||
if (gameResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const game = gameResult.rows[0];
|
||||
|
||||
if (!['testing', 'draft'].includes(game.status)) {
|
||||
return res.status(400).json({ error: 'Cannot tweak this game in its current state.' });
|
||||
}
|
||||
|
||||
if (!game.game_code) {
|
||||
return res.status(400).json({ error: 'No game code to tweak. Generate the game first.' });
|
||||
}
|
||||
|
||||
// Content filter
|
||||
const feedbackFilter = filterDescription(feedback);
|
||||
if (feedbackFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'local_tweak', feedback, feedbackFilter);
|
||||
return res.status(400).json({
|
||||
error: feedbackFilter.message,
|
||||
blocked: true
|
||||
});
|
||||
}
|
||||
|
||||
// Haiku preprocessing - check feasibility and clean up
|
||||
const preprocess = await preprocessTweak(feedback, game.title);
|
||||
if (preprocess.needsClarification) {
|
||||
return res.json({
|
||||
success: false,
|
||||
needsClarification: true,
|
||||
question: preprocess.question
|
||||
});
|
||||
}
|
||||
if (preprocess.canTweak === false) {
|
||||
return res.json({
|
||||
success: false,
|
||||
cannotTweak: true,
|
||||
reason: preprocess.reason,
|
||||
suggestOverhaul: true
|
||||
});
|
||||
}
|
||||
const cleanedFeedback = preprocess.cleanedRequest || feedback;
|
||||
|
||||
// Save pre-tweak version
|
||||
await GameVersions.saveVersion(game.id, game.game_code, 'pre_tweak', cleanedFeedback).catch(() => {});
|
||||
|
||||
// Apply tweak via Haiku
|
||||
const result = await applyTweak(game.game_code, cleanedFeedback);
|
||||
|
||||
if (!result.success) {
|
||||
const { friendlyError } = await explainError(result.error || 'Tweak failed', 'tweak');
|
||||
return res.json({
|
||||
success: false,
|
||||
error: friendlyError,
|
||||
rawError: result.error,
|
||||
estimatedCostCents: 5
|
||||
});
|
||||
}
|
||||
|
||||
// Get Haiku summary of changes
|
||||
const changesDescription = await summarizeCodeChanges(game.game_code, result.code, cleanedFeedback);
|
||||
|
||||
// Update game code
|
||||
const tweakCount = (game.local_ai_tweaks_count || 0) + 1;
|
||||
const savedCents = (game.local_ai_savings_cents || 0) + 5; // Each tweak saves ~$0.05
|
||||
|
||||
await pool.query(
|
||||
`UPDATE games SET
|
||||
game_code = $1,
|
||||
code_version = COALESCE(code_version, 0) + 1,
|
||||
code_updated_at = NOW(),
|
||||
local_ai_tweaks_count = $2,
|
||||
local_ai_savings_cents = $3
|
||||
WHERE id = $4`,
|
||||
[result.code, tweakCount, savedCents, gameId]
|
||||
);
|
||||
|
||||
// Save post-tweak version
|
||||
await GameVersions.saveVersion(game.id, result.code, 'local_tweak', feedback).catch(() => {});
|
||||
|
||||
// Update user stats
|
||||
await pool.query(
|
||||
`UPDATE users SET
|
||||
total_local_ai_tweaks = COALESCE(total_local_ai_tweaks, 0) + 1,
|
||||
total_local_ai_savings_cents = COALESCE(total_local_ai_savings_cents, 0) + 5
|
||||
WHERE id = $1`,
|
||||
[req.user.id]
|
||||
).catch(() => {});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
gameCode: result.code,
|
||||
changesDescription,
|
||||
tweakCount,
|
||||
savedCents,
|
||||
durationMs: result.durationMs
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,53 @@
|
||||
const express = require('express');
|
||||
const { body } = require('express-validator');
|
||||
const { authenticate } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const pushNotify = require('../utils/pushNotify');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get VAPID public key (no auth needed)
|
||||
router.get('/vapid-key', (req, res) => {
|
||||
const key = process.env.VAPID_PUBLIC_KEY;
|
||||
if (!key) {
|
||||
return res.status(503).json({ error: 'Push notifications not configured' });
|
||||
}
|
||||
res.json({ publicKey: key });
|
||||
});
|
||||
|
||||
// Subscribe to push notifications
|
||||
router.post('/subscribe',
|
||||
authenticate,
|
||||
validate([
|
||||
body('subscription').isObject().withMessage('Subscription object required'),
|
||||
body('subscription.endpoint').isURL().withMessage('Valid endpoint required'),
|
||||
body('subscription.keys.auth').isString().withMessage('Auth key required'),
|
||||
body('subscription.keys.p256dh').isString().withMessage('P256dh key required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await pushNotify.subscribe(req.user.id, req.body.subscription);
|
||||
res.json({ success: true, message: 'Push subscription saved' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
router.post('/unsubscribe',
|
||||
authenticate,
|
||||
validate([
|
||||
body('endpoint').isURL().withMessage('Endpoint required')
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await pushNotify.unsubscribe(req.user.id, req.body.endpoint);
|
||||
res.json({ success: true, message: 'Push subscription removed' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,211 @@
|
||||
const express = require('express');
|
||||
const Games = require('../models/games');
|
||||
const { pool } = require('../models/db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Generate share page with Open Graph meta tags
|
||||
router.get('/game/:gameId', async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game || game.status !== 'published') {
|
||||
return res.status(404).send('Game not found');
|
||||
}
|
||||
|
||||
const gameUrl = `https://gamercomp.com/play/${gameId}`;
|
||||
const shareImage = `https://gamercomp.com/api/share/game/${gameId}/image`;
|
||||
const description = game.description || `Play ${game.title} on GamerComp - created by ${game.creator_username}`;
|
||||
|
||||
// Serve HTML with meta tags for social media crawlers
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(game.title)} - GamerComp</title>
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="${gameUrl}">
|
||||
<meta property="og:title" content="${escapeHtml(game.title)} - GamerComp">
|
||||
<meta property="og:description" content="${escapeHtml(description)}">
|
||||
<meta property="og:image" content="${shareImage}">
|
||||
<meta property="og:site_name" content="GamerComp">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="${gameUrl}">
|
||||
<meta name="twitter:title" content="${escapeHtml(game.title)} - GamerComp">
|
||||
<meta name="twitter:description" content="${escapeHtml(description)}">
|
||||
<meta name="twitter:image" content="${shareImage}">
|
||||
|
||||
<!-- Redirect to actual game page -->
|
||||
<meta http-equiv="refresh" content="0; url=${gameUrl}">
|
||||
<link rel="canonical" href="${gameUrl}">
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="${gameUrl}">${escapeHtml(game.title)}</a>...</p>
|
||||
<script>window.location.href = "${gameUrl}";</script>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate a simple share image (placeholder - returns a styled SVG)
|
||||
router.get('/game/:gameId/image', async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game) {
|
||||
return res.status(404).send('Game not found');
|
||||
}
|
||||
|
||||
// Generate a simple SVG image for sharing
|
||||
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#6366f1"/>
|
||||
<stop offset="100%" style="stop-color:#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<text x="600" y="250" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="white" text-anchor="middle">${escapeHtml(game.title)}</text>
|
||||
<text x="600" y="340" font-family="Arial, sans-serif" font-size="36" fill="rgba(255,255,255,0.8)" text-anchor="middle">by ${escapeHtml(game.creator_username)}</text>
|
||||
<text x="600" y="450" font-family="Arial, sans-serif" font-size="28" fill="rgba(255,255,255,0.7)" text-anchor="middle">${game.play_count} plays</text>
|
||||
<text x="600" y="550" font-family="Arial, sans-serif" font-size="48" fill="white" text-anchor="middle">🎮 GamerComp.com</text>
|
||||
</svg>`;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.send(svg);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Embed page - serves game in a clean, embeddable format
|
||||
router.get('/embed/:gameId', async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game || game.status !== 'published') {
|
||||
return res.status(404).send(`<!DOCTYPE html>
|
||||
<html><head><title>Game Not Found</title></head>
|
||||
<body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;background:#1e1b4b;color:white;font-family:sans-serif;">
|
||||
<p>Game not found or not published.</p>
|
||||
</body></html>`);
|
||||
}
|
||||
|
||||
// Increment play count for embeds
|
||||
await Games.incrementPlayCount(gameId);
|
||||
|
||||
// Serve the game code directly in an embeddable page
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>${escapeHtml(game.title)} - GamerComp</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; overflow: hidden; }
|
||||
body { background: #1e1b4b; }
|
||||
.embed-container { width: 100%; height: 100%; position: relative; }
|
||||
.embed-frame { width: 100%; height: calc(100% - 40px); border: none; }
|
||||
.embed-footer {
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.embed-title {
|
||||
color: white;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.embed-title:hover { text-decoration: underline; }
|
||||
.embed-logo {
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-family: sans-serif;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
.embed-logo:hover { color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="embed-container">
|
||||
<iframe class="embed-frame" srcdoc="${escapeHtml(game.game_code).replace(/"/g, '"')}" sandbox="allow-scripts allow-pointer-lock allow-same-origin"></iframe>
|
||||
<div class="embed-footer">
|
||||
<a href="https://gamercomp.com/play/${gameId}" target="_blank" class="embed-title">
|
||||
🎮 ${escapeHtml(game.title)}
|
||||
</a>
|
||||
<a href="https://gamercomp.com" target="_blank" class="embed-logo">
|
||||
Play more at GamerComp.com
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get share data for a game (for frontend use)
|
||||
router.get('/data/:gameId', async (req, res, next) => {
|
||||
try {
|
||||
const { gameId } = req.params;
|
||||
const game = await Games.findById(gameId);
|
||||
|
||||
if (!game || game.status !== 'published') {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const baseUrl = 'https://gamercomp.com';
|
||||
|
||||
res.json({
|
||||
title: game.title,
|
||||
description: game.description || `Play ${game.title} on GamerComp`,
|
||||
creator: game.creator_username,
|
||||
playCount: game.play_count,
|
||||
urls: {
|
||||
play: `${baseUrl}/play/${gameId}`,
|
||||
share: `${baseUrl}/api/share/game/${gameId}`,
|
||||
embed: `${baseUrl}/api/share/embed/${gameId}`,
|
||||
image: `${baseUrl}/api/share/game/${gameId}/image`
|
||||
},
|
||||
embedCode: `<iframe src="${baseUrl}/api/share/embed/${gameId}" width="800" height="600" frameborder="0" allowfullscreen></iframe>`,
|
||||
social: {
|
||||
twitter: `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out "${game.title}" on GamerComp! 🎮`)}&url=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}`,
|
||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}`,
|
||||
reddit: `https://reddit.com/submit?url=${encodeURIComponent(`${baseUrl}/api/share/game/${gameId}`)}&title=${encodeURIComponent(game.title + ' - GamerComp')}`,
|
||||
whatsapp: `https://wa.me/?text=${encodeURIComponent(`Check out "${game.title}" on GamerComp! 🎮 ${baseUrl}/api/share/game/${gameId}`)}`
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,128 @@
|
||||
const express = require('express');
|
||||
const Themes = require('../models/themes');
|
||||
const { authenticate, requireRole } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get current active theme (public)
|
||||
router.get('/current', async (req, res, next) => {
|
||||
try {
|
||||
const theme = await Themes.getCurrentTheme();
|
||||
|
||||
if (!theme) {
|
||||
return res.json({ theme: null });
|
||||
}
|
||||
|
||||
res.json({
|
||||
theme: {
|
||||
id: theme.id,
|
||||
name: theme.name,
|
||||
description: theme.description,
|
||||
startDate: theme.start_date,
|
||||
endDate: theme.end_date,
|
||||
bonusXpMultiplier: parseFloat(theme.bonus_xp_multiplier),
|
||||
daysRemaining: Math.ceil((new Date(theme.end_date) - new Date()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get upcoming themes (public)
|
||||
router.get('/upcoming', async (req, res, next) => {
|
||||
try {
|
||||
const themes = await Themes.getUpcomingThemes();
|
||||
|
||||
res.json({
|
||||
themes: themes.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
startDate: t.start_date,
|
||||
endDate: t.end_date,
|
||||
bonusXpMultiplier: parseFloat(t.bonus_xp_multiplier)
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get past themes (public)
|
||||
router.get('/past', async (req, res, next) => {
|
||||
try {
|
||||
const themes = await Themes.getPastThemes();
|
||||
|
||||
res.json({
|
||||
themes: themes.map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
startDate: t.start_date,
|
||||
endDate: t.end_date
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Admin: Create new theme
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { name, description, startDate, endDate, bonusXpMultiplier } = req.body;
|
||||
|
||||
const theme = await Themes.create({
|
||||
name,
|
||||
description,
|
||||
startDate,
|
||||
endDate,
|
||||
bonusXpMultiplier
|
||||
});
|
||||
|
||||
res.status(201).json({ theme });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Activate theme
|
||||
router.post('/:themeId/activate',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const theme = await Themes.activate(req.params.themeId);
|
||||
if (!theme) {
|
||||
return res.status(404).json({ error: 'Theme not found' });
|
||||
}
|
||||
res.json({ theme, message: 'Theme activated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Deactivate theme
|
||||
router.post('/:themeId/deactivate',
|
||||
authenticate,
|
||||
requireRole('admin'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const theme = await Themes.deactivate(req.params.themeId);
|
||||
if (!theme) {
|
||||
return res.status(404).json({ error: 'Theme not found' });
|
||||
}
|
||||
res.json({ theme, message: 'Theme deactivated' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,794 @@
|
||||
const express = require('express');
|
||||
const { body, param } = require('express-validator');
|
||||
const Users = require('../models/users');
|
||||
const Games = require('../models/games');
|
||||
const Favorites = require('../models/favorites');
|
||||
const Follows = require('../models/follows');
|
||||
const Collections = require('../models/collections');
|
||||
const Notifications = require('../models/notifications');
|
||||
const Achievements = require('../models/achievements');
|
||||
const XP = require('../models/xp');
|
||||
const { pool } = require('../models/db');
|
||||
const { authenticate, optionalAuth } = require('../middleware/auth');
|
||||
const validate = require('../middleware/validate');
|
||||
const { filterUsername, logFilterAction } = require('../utils/contentFilter');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ==========================================
|
||||
// CURRENT USER ENDPOINTS
|
||||
// ==========================================
|
||||
|
||||
// Get current user profile (includes email - private)
|
||||
router.get('/me',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const xpStats = await XP.getUserStats(req.user.id);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
email: req.user.email,
|
||||
displayName: req.user.display_name,
|
||||
isGuest: req.user.is_guest,
|
||||
tokensBalance: req.user.tokens_balance,
|
||||
totalTokensEarned: req.user.total_tokens_earned,
|
||||
role: req.user.role,
|
||||
isTeacher: req.user.is_teacher || req.user.role === 'teacher',
|
||||
createdAt: req.user.created_at,
|
||||
avatarData: req.user.avatar_data,
|
||||
xp: xpStats,
|
||||
onboardingComplete: req.user.onboarding_complete || false,
|
||||
onboardingStep: req.user.onboarding_step || 0,
|
||||
loginStreak: req.user.daily_login_streak || 0,
|
||||
creationStreak: req.user.weekly_creation_streak || 0,
|
||||
totalApiCostCents: req.user.total_api_cost_cents || 0,
|
||||
totalGenerations: req.user.total_generations || 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update current user profile
|
||||
router.put('/me',
|
||||
authenticate,
|
||||
validate([
|
||||
body('displayName').optional().trim().isLength({ min: 1, max: 50 }),
|
||||
body('email').optional().isEmail().normalizeEmail()
|
||||
]),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { displayName, email } = req.body;
|
||||
|
||||
if (email && email !== req.user.email) {
|
||||
const existing = await Users.findByEmail(email);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email already in use' });
|
||||
}
|
||||
}
|
||||
|
||||
// Content filter for display name
|
||||
if (displayName) {
|
||||
const displayNameFilter = filterUsername(displayName);
|
||||
if (displayNameFilter.blocked) {
|
||||
logFilterAction(req.user.id, 'display_name', displayName, displayNameFilter);
|
||||
return res.status(400).json({
|
||||
error: displayNameFilter.message,
|
||||
blocked: true,
|
||||
canRetry: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await Users.update(req.user.id, { displayName, email });
|
||||
|
||||
// Award profile customization achievement if display name changed
|
||||
if (displayName && displayName !== req.user.display_name) {
|
||||
const awarded = await Achievements.award(req.user.id, 'profile_customized');
|
||||
if (awarded) {
|
||||
await Notifications.notifyAchievement(req.user.id, 'Personal Touch', '✨');
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Profile updated',
|
||||
user: {
|
||||
id: updated.id,
|
||||
username: updated.username,
|
||||
email: updated.email,
|
||||
displayName: updated.display_name,
|
||||
tokensBalance: updated.tokens_balance,
|
||||
role: updated.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update current user's avatar
|
||||
router.put('/me/avatar',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { avatar } = req.body;
|
||||
|
||||
if (!avatar || typeof avatar !== 'object') {
|
||||
return res.status(400).json({ error: 'Avatar data is required' });
|
||||
}
|
||||
|
||||
// Validate avatar has expected properties
|
||||
const validKeys = ['skinTone', 'hairStyle', 'hairColor', 'eyes', 'eyeColor', 'mouth', 'accessory', 'background'];
|
||||
const hasValidKeys = validKeys.some(key => key in avatar);
|
||||
if (!hasValidKeys) {
|
||||
return res.status(400).json({ error: 'Invalid avatar data' });
|
||||
}
|
||||
|
||||
await Users.updateAvatar(req.user.id, avatar);
|
||||
|
||||
// Award profile customization achievement
|
||||
const awarded = await Achievements.award(req.user.id, 'profile_customized');
|
||||
if (awarded) {
|
||||
await Notifications.notifyAchievement(req.user.id, 'Personal Touch', '✨');
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Avatar updated',
|
||||
avatar
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's games
|
||||
router.get('/me/games',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, title, description, thumbnail_url, status, play_count,
|
||||
favorite_count, total_ratings, rating_sum, created_at, published_at
|
||||
FROM games WHERE creator_id = $1 ORDER BY created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
games: result.rows.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
status: g.status,
|
||||
playCount: g.play_count,
|
||||
favoriteCount: g.favorite_count,
|
||||
averageRating: g.total_ratings > 0 ? (g.rating_sum / g.total_ratings).toFixed(1) : null,
|
||||
totalRatings: g.total_ratings,
|
||||
createdAt: g.created_at,
|
||||
publishedAt: g.published_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's favorites
|
||||
router.get('/me/favorites',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const favorites = await Favorites.getUserFavorites(req.user.id);
|
||||
|
||||
res.json({
|
||||
favorites: favorites.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
playCount: g.play_count,
|
||||
creatorUsername: g.creator_username,
|
||||
creatorDisplayName: g.creator_display_name,
|
||||
favoritedAt: g.favorited_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's collections
|
||||
router.get('/me/collections',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const collections = await Collections.getUserCollections(req.user.id);
|
||||
|
||||
res.json({
|
||||
collections: collections.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
isPublic: c.is_public,
|
||||
gameCount: parseInt(c.game_count),
|
||||
createdAt: c.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's achievements
|
||||
router.get('/me/achievements',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const showAll = req.query.all === 'true';
|
||||
let achievements;
|
||||
|
||||
if (showAll) {
|
||||
// Get all achievements with user's earned status
|
||||
achievements = await Achievements.getAllWithUserStatus(req.user.id);
|
||||
} else {
|
||||
// Get only earned achievements
|
||||
achievements = await Achievements.getUserAchievements(req.user.id);
|
||||
}
|
||||
|
||||
res.json({
|
||||
achievements: achievements.map(a => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
xpReward: a.xp_reward,
|
||||
category: a.category,
|
||||
threshold: a.threshold || null,
|
||||
earnedAt: a.earned_at || null
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's notifications
|
||||
router.get('/me/notifications',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const unreadOnly = req.query.unread === 'true';
|
||||
const notifications = await Notifications.getForUser(req.user.id, 50, unreadOnly);
|
||||
const unreadCount = await Notifications.getUnreadCount(req.user.id);
|
||||
|
||||
res.json({
|
||||
unreadCount,
|
||||
notifications: notifications.map(n => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
body: n.message,
|
||||
link: n.link,
|
||||
read_at: n.is_read ? n.created_at : null,
|
||||
created_at: n.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark notification as read
|
||||
router.post('/me/notifications/:id/read',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await Notifications.markAsRead(req.params.id, req.user.id);
|
||||
res.json({ message: 'Notification marked as read' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark all notifications as read
|
||||
router.post('/me/notifications/read-all',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await Notifications.markAllAsRead(req.user.id);
|
||||
res.json({ message: 'All notifications marked as read' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's play history
|
||||
router.get('/me/play-history',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
g.id as game_id,
|
||||
g.title,
|
||||
g.thumbnail_url,
|
||||
COUNT(p.id) as play_count,
|
||||
MAX(p.score) as best_score,
|
||||
MAX(p.started_at) as last_played,
|
||||
hs.score as high_score,
|
||||
hs.achieved_at as high_score_date
|
||||
FROM plays p
|
||||
JOIN games g ON p.game_id = g.id
|
||||
LEFT JOIN high_scores hs ON hs.game_id = g.id AND hs.player_id = p.player_id
|
||||
WHERE p.player_id = $1 AND g.status = 'published'
|
||||
GROUP BY g.id, g.title, g.thumbnail_url, hs.score, hs.achieved_at
|
||||
ORDER BY last_played DESC
|
||||
LIMIT 50`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const statsResult = await pool.query(
|
||||
`SELECT
|
||||
COUNT(DISTINCT game_id) as games_played,
|
||||
COUNT(*) as total_plays,
|
||||
SUM(duration_seconds) as total_time
|
||||
FROM plays
|
||||
WHERE player_id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
stats: {
|
||||
gamesPlayed: parseInt(statsResult.rows[0]?.games_played) || 0,
|
||||
totalPlays: parseInt(statsResult.rows[0]?.total_plays) || 0,
|
||||
totalTime: parseInt(statsResult.rows[0]?.total_time) || 0
|
||||
},
|
||||
history: result.rows.map(row => ({
|
||||
gameId: row.game_id,
|
||||
title: row.title,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
playCount: parseInt(row.play_count),
|
||||
bestScore: parseInt(row.best_score) || 0,
|
||||
highScore: parseInt(row.high_score) || 0,
|
||||
lastPlayed: row.last_played,
|
||||
highScoreDate: row.high_score_date
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's XP transaction history
|
||||
router.get('/me/xp-history',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
|
||||
const transactions = await XP.getTransactions(req.user.id, limit);
|
||||
|
||||
// Format reason labels
|
||||
const reasonLabels = {
|
||||
game_created: 'Published a game',
|
||||
game_played: 'Game was played',
|
||||
game_favorited: 'Game was favorited',
|
||||
game_rated_5: 'Game got 5-star rating',
|
||||
game_remixed: 'Game was remixed',
|
||||
play_game: 'Played a game',
|
||||
played_game: 'Played a game',
|
||||
high_score: 'Got high score',
|
||||
achievement: 'Achievement earned'
|
||||
};
|
||||
|
||||
// Helper to format reason into human-readable label
|
||||
function formatReasonLabel(reason) {
|
||||
// Check if it's a simple reason
|
||||
if (reasonLabels[reason]) {
|
||||
return reasonLabels[reason];
|
||||
}
|
||||
|
||||
// Handle achievement:code format (e.g., "achievement:first_arcade_visit")
|
||||
if (reason.startsWith('achievement:')) {
|
||||
const code = reason.substring('achievement:'.length);
|
||||
// Convert snake_case to Title Case
|
||||
return code
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Fallback: convert snake_case/kebab-case to Title Case
|
||||
return reason
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
res.json({
|
||||
transactions: transactions.map(t => ({
|
||||
id: t.id,
|
||||
amount: t.amount,
|
||||
reason: t.reason,
|
||||
reasonLabel: formatReasonLabel(t.reason),
|
||||
referenceId: t.reference_id,
|
||||
createdAt: t.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's onboarding status
|
||||
router.get('/me/onboarding',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const status = await Users.getOnboardingStatus(req.user.id);
|
||||
res.json({
|
||||
step: status.onboarding_step || 0,
|
||||
complete: status.onboarding_complete || false
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update current user's onboarding progress
|
||||
router.post('/me/onboarding',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { step, complete } = req.body;
|
||||
|
||||
if (complete) {
|
||||
// Mark onboarding as complete and award achievements
|
||||
await Users.completeOnboarding(req.user.id);
|
||||
|
||||
// Award welcome tour achievement
|
||||
await Achievements.award(req.user.id, 'welcome_tour');
|
||||
await Notifications.notifyAchievement(req.user.id, 'Welcome Tour', '🎉');
|
||||
|
||||
// Award first arcade visit achievement (they'll land on arcade after tutorial)
|
||||
await Achievements.award(req.user.id, 'first_arcade_visit');
|
||||
await Notifications.notifyAchievement(req.user.id, 'Explorer', '🗺️');
|
||||
|
||||
res.json({
|
||||
message: 'Onboarding completed',
|
||||
step: 4,
|
||||
complete: true
|
||||
});
|
||||
} else if (typeof step === 'number' && step >= 0 && step <= 4) {
|
||||
const result = await Users.updateOnboardingStep(req.user.id, step);
|
||||
res.json({
|
||||
message: 'Onboarding step updated',
|
||||
step: result.onboarding_step,
|
||||
complete: result.onboarding_complete
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid step value' });
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get current user's creator stats (dashboard)
|
||||
router.get('/me/stats',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const stats = await Users.getCreatorStats(req.user.id);
|
||||
const xpStats = await XP.getUserStats(req.user.id);
|
||||
|
||||
// Get recent plays on user's games
|
||||
const recentPlaysResult = await pool.query(
|
||||
`SELECT g.title, p.started_at, p.score
|
||||
FROM plays p
|
||||
JOIN games g ON p.game_id = g.id
|
||||
WHERE g.creator_id = $1 AND p.is_creator_play = false
|
||||
ORDER BY p.started_at DESC
|
||||
LIMIT 10`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
stats: {
|
||||
gamesPublished: parseInt(stats.games_published) || 0,
|
||||
totalPlays: parseInt(stats.total_plays) || 0,
|
||||
totalFavorites: parseInt(stats.total_favorites) || 0,
|
||||
tokensEarned: parseInt(stats.tokens_earned) || 0,
|
||||
followers: parseInt(stats.followers) || 0,
|
||||
avgRating: parseFloat(stats.avg_rating) || 0
|
||||
},
|
||||
xp: xpStats,
|
||||
recentPlays: recentPlaysResult.rows.map(r => ({
|
||||
gameTitle: r.title,
|
||||
playedAt: r.started_at,
|
||||
score: r.score
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================
|
||||
// PUBLIC USER ENDPOINTS
|
||||
// ==========================================
|
||||
|
||||
// Get public user profile by username (for /creator/:username)
|
||||
router.get('/by-username/:username',
|
||||
optionalAuth,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Find user by username
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE LOWER(username) = LOWER($1) AND is_banned = false',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
const profile = await Users.getPublicProfile(userId);
|
||||
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
let isFollowing = false;
|
||||
if (req.user) {
|
||||
isFollowing = await Follows.isFollowing(req.user.id, userId);
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
displayName: profile.display_name,
|
||||
avatarData: profile.avatar_data,
|
||||
bio: profile.bio,
|
||||
level: profile.creator_level,
|
||||
badge: profile.creator_badge,
|
||||
xp: profile.xp_total,
|
||||
gamesCount: parseInt(profile.games_count),
|
||||
totalPlays: parseInt(profile.total_plays),
|
||||
followersCount: parseInt(profile.followers_count),
|
||||
followingCount: parseInt(profile.following_count),
|
||||
createdAt: profile.created_at
|
||||
},
|
||||
isFollowing
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's published games by username
|
||||
router.get('/by-username/:username/games',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { username } = req.params;
|
||||
|
||||
// Find user by username
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE LOWER(username) = LOWER($1) AND is_banned = false',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
const games = await Games.getByUser(userId);
|
||||
|
||||
res.json({
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
thumbnailData: g.thumbnail_data,
|
||||
playCount: g.play_count,
|
||||
favoriteCount: g.favorite_count,
|
||||
averageRating: g.total_ratings > 0
|
||||
? (g.rating_sum / g.total_ratings).toFixed(1)
|
||||
: 0,
|
||||
totalRatings: g.total_ratings,
|
||||
gameStyle: g.game_style,
|
||||
publishedAt: g.published_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get public user profile by ID
|
||||
router.get('/:userId',
|
||||
optionalAuth,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const profile = await Users.getPublicProfile(userId);
|
||||
|
||||
if (!profile) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
let isFollowing = false;
|
||||
if (req.user) {
|
||||
isFollowing = await Follows.isFollowing(req.user.id, userId);
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: profile.id,
|
||||
username: profile.username,
|
||||
displayName: profile.display_name,
|
||||
avatarData: profile.avatar_data,
|
||||
level: profile.creator_level,
|
||||
badge: profile.creator_badge,
|
||||
xp: profile.xp_total,
|
||||
gamesCount: parseInt(profile.games_count),
|
||||
totalPlays: parseInt(profile.total_plays),
|
||||
followersCount: parseInt(profile.followers_count),
|
||||
followingCount: parseInt(profile.following_count),
|
||||
createdAt: profile.created_at
|
||||
},
|
||||
isFollowing
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's published games
|
||||
router.get('/:userId/games',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const games = await Games.getByUser(userId);
|
||||
|
||||
res.json({
|
||||
games: games.map(g => ({
|
||||
id: g.id,
|
||||
title: g.title,
|
||||
description: g.description,
|
||||
thumbnailUrl: g.thumbnail_url,
|
||||
playCount: g.play_count,
|
||||
averageRating: g.total_ratings > 0
|
||||
? (g.rating_sum / g.total_ratings).toFixed(1)
|
||||
: null,
|
||||
totalRatings: g.total_ratings,
|
||||
publishedAt: g.published_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Follow a user
|
||||
router.post('/:userId/follow',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const followingId = parseInt(req.params.userId);
|
||||
|
||||
if (followingId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot follow yourself' });
|
||||
}
|
||||
|
||||
const targetUser = await Users.findById(followingId);
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
await Follows.follow(req.user.id, followingId);
|
||||
|
||||
// Check for first follow achievement
|
||||
const followingCount = await Follows.getFollowingCount(req.user.id);
|
||||
await Achievements.checkAndAward(req.user.id, 'first_follow', followingCount);
|
||||
|
||||
// Notify the followed user
|
||||
await Notifications.notifyNewFollower(followingId, req.user.username);
|
||||
|
||||
res.json({ message: 'Now following', following: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unfollow a user
|
||||
router.delete('/:userId/follow',
|
||||
authenticate,
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const followingId = parseInt(req.params.userId);
|
||||
await Follows.unfollow(req.user.id, followingId);
|
||||
|
||||
res.json({ message: 'Unfollowed', following: false });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get user's followers
|
||||
router.get('/:userId/followers',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const followers = await Follows.getFollowers(parseInt(req.params.userId));
|
||||
|
||||
res.json({
|
||||
followers: followers.map(f => ({
|
||||
id: f.id,
|
||||
username: f.username,
|
||||
displayName: f.display_name,
|
||||
avatarData: f.avatar_data,
|
||||
level: f.creator_level,
|
||||
badge: f.creator_badge,
|
||||
followedAt: f.followed_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get users this user is following
|
||||
router.get('/:userId/following',
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const following = await Follows.getFollowing(parseInt(req.params.userId));
|
||||
|
||||
res.json({
|
||||
following: following.map(f => ({
|
||||
id: f.id,
|
||||
username: f.username,
|
||||
displayName: f.display_name,
|
||||
avatarData: f.avatar_data,
|
||||
level: f.creator_level,
|
||||
badge: f.creator_badge,
|
||||
followedAt: f.followed_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,451 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
|
||||
const client = new Anthropic({
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
timeout: 10 * 60 * 1000 // 10 minutes
|
||||
});
|
||||
|
||||
// Model configuration - easy to switch for self-hosted
|
||||
const MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-4-20250514';
|
||||
const MAX_OUTPUT_TOKENS = parseInt(process.env.MAX_OUTPUT_TOKENS) || 16000;
|
||||
|
||||
// Ultra-compact system prompt - every word counts
|
||||
const SYSTEM_PROMPT = `Game dev for kids 8-16. Return ONLY working HTML5 canvas code.
|
||||
|
||||
RULES:
|
||||
- Canvas 100vw×100vh, safe zones 10% top/bottom
|
||||
- Start EASY (8yr old playable 30s), gradual difficulty
|
||||
- Controls: touch+keyboard+mouse (ALL THREE required)
|
||||
- Bright colors, large text, kid-friendly only
|
||||
- Auto-start, game over shows score+"Play Again"
|
||||
|
||||
GAME STATE (critical):
|
||||
- Track gameState: 'playing', 'gameOver' (start playing immediately)
|
||||
- Canvas tap/click to restart ONLY works when gameState === 'gameOver'
|
||||
- During gameplay (gameState === 'playing'), taps must NOT restart the game
|
||||
- Use on-screen buttons for in-game controls, NOT canvas taps
|
||||
|
||||
CONTROLS (mandatory):
|
||||
- Keyboard: arrow keys + space/enter
|
||||
- Touch: on-screen buttons (60px min, bottom corners) for movement/actions
|
||||
- Canvas tap ONLY for "Play Again" after game over
|
||||
- Mouse: click/drag support where applicable
|
||||
- Touch buttons: touchstart/touchend with e.preventDefault()
|
||||
|
||||
PHYSICS (for action/racing/physics/sports games):
|
||||
- Use requestAnimationFrame for smooth 60fps
|
||||
- Gravity: velY += 0.5 to 1.0 per frame for falling objects
|
||||
- Velocity: track velX/velY, apply friction (multiply by 0.95-0.98)
|
||||
- Collisions: AABB box collision with generous hitboxes
|
||||
- Bounce: on collision, reverse velocity * 0.7 for elasticity
|
||||
- Ground check: player.y + height >= groundY
|
||||
|
||||
AUDIO (Web Audio API):
|
||||
- Background music: 16+ note melody loop, not just 2-3 repeating tones
|
||||
- Use pentatonic scale for pleasant tunes: C4,D4,E4,G4,A4,C5
|
||||
- SFX: short sounds for actions (jump, shoot, collect, hit)
|
||||
- Exports: window.setMusicEnabled(bool), window.setSfxEnabled(bool)
|
||||
- Exports: window.pauseMusic(), window.resumeMusic()
|
||||
- Music should pause when game paused, resume when unpaused
|
||||
|
||||
REQUIRED EXPORTS:
|
||||
- window.gameScore (number)
|
||||
- window.pauseGame(), window.resumeGame()`;
|
||||
|
||||
// Token budgets - guides allocation, never causes failures
|
||||
const TOKEN_BUDGETS = {
|
||||
action: 10000,
|
||||
puzzle: 12000,
|
||||
story: 16000,
|
||||
racing: 10000,
|
||||
pet: 12000,
|
||||
trivia: 14000,
|
||||
music: 10000,
|
||||
creative: 10000,
|
||||
rpg: 16000,
|
||||
sports: 10000,
|
||||
physics: 10000,
|
||||
freeform: 14000
|
||||
};
|
||||
|
||||
// Complexity multipliers
|
||||
const COMPLEXITY_FACTORS = {
|
||||
'5+': 1.4, '3': 1.2, '1': 1.0, // endings
|
||||
'20': 1.3, '10': 1.1, '5': 1.0, 'endless': 1.05, // levels
|
||||
'all': 1.1, 'easy-medium': 1.05, 'easy': 1.0, // difficulty
|
||||
'local2p': 1.2 // features
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate complexity and recommend token budget
|
||||
*/
|
||||
function estimateComplexity(styleId, answers = {}) {
|
||||
const baseBudget = TOKEN_BUDGETS[styleId] || TOKEN_BUDGETS.freeform;
|
||||
let multiplier = 1.0;
|
||||
const warnings = [];
|
||||
|
||||
for (const [key, value] of Object.entries(answers)) {
|
||||
if (typeof value === 'string' && COMPLEXITY_FACTORS[value]) {
|
||||
multiplier *= COMPLEXITY_FACTORS[value];
|
||||
if (value === '5+') warnings.push('5+ endings is very complex - consider starting with 3');
|
||||
if (value === '20') warnings.push('20 levels is a lot - consider 10 to start');
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
if (COMPLEXITY_FACTORS[v]) multiplier *= COMPLEXITY_FACTORS[v];
|
||||
if (v === 'local2p') warnings.push('2-player mode adds significant complexity');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (answers.fullDescription?.length > 1000) {
|
||||
multiplier *= 1.15;
|
||||
warnings.push('Very detailed description - AI may not include everything');
|
||||
}
|
||||
|
||||
const recommendedBudget = Math.round(baseBudget * multiplier);
|
||||
const complexity = Math.min(5, Math.round(multiplier * 2.5));
|
||||
|
||||
return {
|
||||
tokenBudget: recommendedBudget,
|
||||
complexity,
|
||||
warnings,
|
||||
isHighComplexity: complexity >= 4
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip unnecessary whitespace and comments from code to reduce tokens
|
||||
*/
|
||||
function compressCode(code) {
|
||||
if (!code) return code;
|
||||
return code
|
||||
// Remove single-line comments (but keep URLs)
|
||||
.replace(/(?<!:)\/\/(?!.*https?:).*$/gm, '')
|
||||
// Remove multi-line comments
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
// Collapse multiple newlines
|
||||
.replace(/\n\s*\n\s*\n/g, '\n\n')
|
||||
// Trim lines
|
||||
.split('\n').map(l => l.trimEnd()).join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rough token estimate (~4 chars per token)
|
||||
*/
|
||||
function estimateTokens(text) {
|
||||
return Math.ceil((text || '').length / 4);
|
||||
}
|
||||
|
||||
async function generateGame(description, styleId = null, answers = {}) {
|
||||
try {
|
||||
const { tokenBudget, complexity, warnings, isHighComplexity } =
|
||||
estimateComplexity(styleId, answers);
|
||||
|
||||
console.log(`Generate: style=${styleId}, complexity=${complexity}, budget=${tokenBudget}`);
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Create game: ${description}\n\nReturn ONLY complete HTML.`
|
||||
}]
|
||||
});
|
||||
|
||||
let gameCode = message.content[0].text;
|
||||
|
||||
// Clean markdown wrappers
|
||||
gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
|
||||
|
||||
// Ensure valid HTML structure
|
||||
if (!gameCode.includes('<html') && !gameCode.includes('<!DOCTYPE')) {
|
||||
if (gameCode.includes('<canvas') || gameCode.includes('<script')) {
|
||||
gameCode = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Game</title></head><body>${gameCode}</body></html>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for truncation - warn but don't fail
|
||||
const wasTruncated = message.stop_reason === 'max_tokens';
|
||||
if (wasTruncated) {
|
||||
console.warn(`Generation truncated at ${message.usage.output_tokens} tokens`);
|
||||
warnings.push('Game may be incomplete - try simplifying if issues occur');
|
||||
}
|
||||
|
||||
// Cost calculation (Claude Sonnet: $3/M input, $15/M output)
|
||||
const inputCostCents = Math.ceil((message.usage.input_tokens / 1000000) * 300);
|
||||
const outputCostCents = Math.ceil((message.usage.output_tokens / 1000000) * 1500);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: gameCode,
|
||||
tokensUsed: message.usage.input_tokens + message.usage.output_tokens,
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
apiCostCents: inputCostCents + outputCostCents,
|
||||
complexity,
|
||||
warnings,
|
||||
wasTruncated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Claude API error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Compact system prompt just for refinements
|
||||
const REFINE_PROMPT = `Modify HTML5 canvas game. Keep ALL existing code working. ONLY change what's requested. Return complete HTML.`;
|
||||
|
||||
async function regenerateGame(existingCode, feedback) {
|
||||
try {
|
||||
// Compress existing code to reduce input tokens
|
||||
const compressedCode = compressCode(existingCode);
|
||||
const inputTokens = estimateTokens(compressedCode);
|
||||
|
||||
console.log(`Regenerate: ~${inputTokens} input tokens (compressed from ~${estimateTokens(existingCode)})`);
|
||||
|
||||
const message = await client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
system: REFINE_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `GAME:\n${compressedCode}\n\nCHANGES: ${feedback}\n\nOutput complete updated HTML only, no explanation.`
|
||||
}]
|
||||
});
|
||||
|
||||
let gameCode = message.content[0].text;
|
||||
gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
|
||||
|
||||
const wasTruncated = message.stop_reason === 'max_tokens';
|
||||
if (wasTruncated) {
|
||||
console.warn(`Regeneration truncated at ${message.usage.output_tokens} tokens`);
|
||||
}
|
||||
|
||||
const inputCostCents = Math.ceil((message.usage.input_tokens / 1000000) * 300);
|
||||
const outputCostCents = Math.ceil((message.usage.output_tokens / 1000000) * 1500);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: gameCode,
|
||||
tokensUsed: message.usage.input_tokens + message.usage.output_tokens,
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
apiCostCents: inputCostCents + outputCostCents,
|
||||
wasTruncated,
|
||||
warnings: wasTruncated ? ['Game may be incomplete - try smaller changes'] : []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Claude API error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect code structure phase from streaming chunks for real-time progress
|
||||
*/
|
||||
function detectPhase(accumulated) {
|
||||
// Check from most specific to least specific
|
||||
if (accumulated.includes('pauseGame') || accumulated.includes('resumeGame'))
|
||||
return { phase: 'pause', message: 'Adding pause functionality...' };
|
||||
if (accumulated.includes('gameOver') || accumulated.includes('Game Over'))
|
||||
return { phase: 'endgame', message: 'Adding win/lose conditions...' };
|
||||
if (accumulated.includes('AudioContext') || accumulated.includes('oscillator'))
|
||||
return { phase: 'audio', message: 'Composing music and sounds...' };
|
||||
if (accumulated.includes('requestAnimationFrame') || accumulated.includes('setInterval'))
|
||||
return { phase: 'gameloop', message: 'Building the game loop...' };
|
||||
if (accumulated.includes('addEventListener') || accumulated.includes('onkeydown'))
|
||||
return { phase: 'controls', message: 'Setting up controls...' };
|
||||
if (/function\s+\w/.test(accumulated) || /class\s+\w/.test(accumulated))
|
||||
return { phase: 'logic', message: 'Writing game logic...' };
|
||||
if (accumulated.includes('<canvas'))
|
||||
return { phase: 'canvas', message: 'Creating the game canvas...' };
|
||||
if (accumulated.includes('<style'))
|
||||
return { phase: 'styling', message: 'Designing the visual style...' };
|
||||
if (accumulated.includes('<!DOCTYPE') || accumulated.includes('<html'))
|
||||
return { phase: 'structure', message: 'Setting up the game page...' };
|
||||
return { phase: 'starting', message: 'AI is thinking...' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming game generation - calls onProgress as tokens arrive
|
||||
*/
|
||||
async function generateGameStream(description, styleId = null, answers = {}, onProgress) {
|
||||
try {
|
||||
const { tokenBudget, complexity, warnings, isHighComplexity } =
|
||||
estimateComplexity(styleId, answers);
|
||||
|
||||
console.log(`StreamGenerate: style=${styleId}, complexity=${complexity}, budget=${tokenBudget}`);
|
||||
|
||||
let accumulated = '';
|
||||
let lastPhase = '';
|
||||
let tokensReceived = 0;
|
||||
|
||||
const stream = await client.messages.stream({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Create game: ${description}\n\nReturn ONLY complete HTML.`
|
||||
}]
|
||||
});
|
||||
|
||||
stream.on('text', (text) => {
|
||||
accumulated += text;
|
||||
tokensReceived += Math.ceil(text.length / 4); // rough estimate
|
||||
|
||||
const phaseInfo = detectPhase(accumulated);
|
||||
if (phaseInfo.phase !== lastPhase) {
|
||||
lastPhase = phaseInfo.phase;
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
type: 'phase',
|
||||
phase: phaseInfo.phase,
|
||||
message: phaseInfo.message,
|
||||
progress: Math.min(95, Math.round((tokensReceived / tokenBudget) * 100))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Send progress every ~500 tokens
|
||||
if (tokensReceived % 500 < 4 && onProgress) {
|
||||
onProgress({
|
||||
type: 'progress',
|
||||
progress: Math.min(95, Math.round((tokensReceived / tokenBudget) * 100)),
|
||||
tokensReceived
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const finalMessage = await stream.finalMessage();
|
||||
|
||||
let gameCode = finalMessage.content[0].text;
|
||||
gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
|
||||
|
||||
if (!gameCode.includes('<html') && !gameCode.includes('<!DOCTYPE')) {
|
||||
if (gameCode.includes('<canvas') || gameCode.includes('<script')) {
|
||||
gameCode = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Game</title></head><body>${gameCode}</body></html>`;
|
||||
}
|
||||
}
|
||||
|
||||
const wasTruncated = finalMessage.stop_reason === 'max_tokens';
|
||||
if (wasTruncated) {
|
||||
console.warn(`Stream generation truncated at ${finalMessage.usage.output_tokens} tokens`);
|
||||
warnings.push('Game may be incomplete - try simplifying if issues occur');
|
||||
}
|
||||
|
||||
const inputCostCents = Math.ceil((finalMessage.usage.input_tokens / 1000000) * 300);
|
||||
const outputCostCents = Math.ceil((finalMessage.usage.output_tokens / 1000000) * 1500);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: gameCode,
|
||||
tokensUsed: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
|
||||
inputTokens: finalMessage.usage.input_tokens,
|
||||
outputTokens: finalMessage.usage.output_tokens,
|
||||
apiCostCents: inputCostCents + outputCostCents,
|
||||
complexity,
|
||||
warnings,
|
||||
wasTruncated
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Claude streaming API error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming game refinement
|
||||
*/
|
||||
async function regenerateGameStream(existingCode, feedback, onProgress) {
|
||||
try {
|
||||
const compressedCode = compressCode(existingCode);
|
||||
const inputTokens = estimateTokens(compressedCode);
|
||||
const expectedBudget = Math.max(inputTokens, 8000);
|
||||
|
||||
console.log(`StreamRegenerate: ~${inputTokens} input tokens`);
|
||||
|
||||
let accumulated = '';
|
||||
let lastPhase = '';
|
||||
let tokensReceived = 0;
|
||||
|
||||
const stream = await client.messages.stream({
|
||||
model: MODEL,
|
||||
max_tokens: MAX_OUTPUT_TOKENS,
|
||||
system: REFINE_PROMPT,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `GAME:\n${compressedCode}\n\nCHANGES: ${feedback}\n\nOutput complete updated HTML only, no explanation.`
|
||||
}]
|
||||
});
|
||||
|
||||
stream.on('text', (text) => {
|
||||
accumulated += text;
|
||||
tokensReceived += Math.ceil(text.length / 4);
|
||||
|
||||
const phaseInfo = detectPhase(accumulated);
|
||||
if (phaseInfo.phase !== lastPhase) {
|
||||
lastPhase = phaseInfo.phase;
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
type: 'phase',
|
||||
phase: phaseInfo.phase,
|
||||
message: phaseInfo.message,
|
||||
progress: Math.min(95, Math.round((tokensReceived / expectedBudget) * 100))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (tokensReceived % 500 < 4 && onProgress) {
|
||||
onProgress({
|
||||
type: 'progress',
|
||||
progress: Math.min(95, Math.round((tokensReceived / expectedBudget) * 100)),
|
||||
tokensReceived
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const finalMessage = await stream.finalMessage();
|
||||
|
||||
let gameCode = finalMessage.content[0].text;
|
||||
gameCode = gameCode.replace(/^```html?\n?/i, '').replace(/\n?```$/i, '');
|
||||
|
||||
const wasTruncated = finalMessage.stop_reason === 'max_tokens';
|
||||
if (wasTruncated) {
|
||||
console.warn(`Stream regeneration truncated at ${finalMessage.usage.output_tokens} tokens`);
|
||||
}
|
||||
|
||||
const inputCostCents = Math.ceil((finalMessage.usage.input_tokens / 1000000) * 300);
|
||||
const outputCostCents = Math.ceil((finalMessage.usage.output_tokens / 1000000) * 1500);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: gameCode,
|
||||
tokensUsed: finalMessage.usage.input_tokens + finalMessage.usage.output_tokens,
|
||||
inputTokens: finalMessage.usage.input_tokens,
|
||||
outputTokens: finalMessage.usage.output_tokens,
|
||||
apiCostCents: inputCostCents + outputCostCents,
|
||||
wasTruncated,
|
||||
warnings: wasTruncated ? ['Game may be incomplete - try smaller changes'] : []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Claude streaming API error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateGame,
|
||||
regenerateGame,
|
||||
generateGameStream,
|
||||
regenerateGameStream,
|
||||
estimateComplexity,
|
||||
estimateTokens,
|
||||
compressCode,
|
||||
TOKEN_BUDGETS,
|
||||
MODEL,
|
||||
MAX_OUTPUT_TOKENS
|
||||
};
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Content Filter Module for GamerComp
|
||||
* Filters inappropriate content for kids' platform (ages 8-16)
|
||||
*/
|
||||
|
||||
// Core profanity list (common English profanity)
|
||||
const PROFANITY_LIST = [
|
||||
// Strong profanity
|
||||
'fuck', 'fucking', 'fucked', 'fucker', 'fucks',
|
||||
'shit', 'shits', 'shitting', 'shitty',
|
||||
'bitch', 'bitches', 'bitching',
|
||||
'ass', 'asses', 'asshole', 'assholes',
|
||||
'damn', 'damned', 'dammit',
|
||||
'crap', 'crappy',
|
||||
'hell',
|
||||
'bastard', 'bastards',
|
||||
'piss', 'pissed', 'pissing',
|
||||
'cunt', 'cunts',
|
||||
'dick', 'dicks', 'dickhead',
|
||||
'cock', 'cocks',
|
||||
'whore', 'whores',
|
||||
'slut', 'sluts',
|
||||
// Racial slurs (abbreviated for safety)
|
||||
'nigger', 'nigga', 'niggas',
|
||||
'faggot', 'fag', 'fags',
|
||||
'retard', 'retarded', 'retards',
|
||||
// Additional
|
||||
'wtf', 'stfu', 'lmfao',
|
||||
'bullshit', 'horseshit',
|
||||
'jackass', 'dumbass', 'badass',
|
||||
'motherfucker', 'motherfucking',
|
||||
// Drug references
|
||||
'cocaine', 'heroin', 'meth', 'methamphetamine',
|
||||
'weed', 'marijuana', 'stoned', 'pothead',
|
||||
'druggie', 'crackhead',
|
||||
// Alcohol for minors
|
||||
'drunk', 'wasted', 'hammered', 'plastered',
|
||||
'beer', 'vodka', 'whiskey', 'tequila',
|
||||
// Violence escalation
|
||||
'murder', 'murderer', 'murdering',
|
||||
'torture', 'torturing', 'tortured',
|
||||
'mutilate', 'mutilation',
|
||||
'rape', 'raped', 'raping', 'rapist',
|
||||
// Hate terms
|
||||
'nazi', 'hitler', 'kkk',
|
||||
// Grooming/predatory
|
||||
'sexy', 'hottie', 'babe'
|
||||
];
|
||||
|
||||
// Gaming context allowlist - words that are OK in game creation
|
||||
const GAMING_ALLOWLIST = [
|
||||
'shoot', 'shooter', 'shooting',
|
||||
'kill', 'killed', 'killing', 'killer',
|
||||
'die', 'died', 'dying', 'death', 'dead',
|
||||
'weapon', 'weapons',
|
||||
'sword', 'swords',
|
||||
'gun', 'guns',
|
||||
'knife', 'knives',
|
||||
'bomb', 'bombs', 'explosion',
|
||||
'monster', 'monsters',
|
||||
'zombie', 'zombies',
|
||||
'ghost', 'ghosts',
|
||||
'skeleton', 'skeletons',
|
||||
'dragon', 'dragons',
|
||||
'demon', 'demons',
|
||||
'devil',
|
||||
'battle', 'battles', 'battling',
|
||||
'fight', 'fights', 'fighting', 'fighter',
|
||||
'combat',
|
||||
'war', 'wars',
|
||||
'attack', 'attacks', 'attacking',
|
||||
'destroy', 'destroys', 'destruction',
|
||||
'enemy', 'enemies',
|
||||
'boss', 'bosses',
|
||||
'defeat', 'defeated',
|
||||
'hit', 'hits',
|
||||
'punch', 'punches',
|
||||
'kick', 'kicks',
|
||||
'slash', 'slashes',
|
||||
'blood moon', 'bloodmoon'
|
||||
];
|
||||
|
||||
// PII detection patterns
|
||||
const PII_PATTERNS = {
|
||||
phone: /(\+?1?[-.\s]?)?\(?[2-9]\d{2}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g,
|
||||
email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
|
||||
address: /\d{1,5}\s+[A-Za-z\s]+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Court|Ct|Way|Place|Pl)\.?/gi,
|
||||
ssn: /\d{3}[-\s]?\d{2}[-\s]?\d{4}/g
|
||||
};
|
||||
|
||||
// Graphic violence keywords
|
||||
const GRAPHIC_VIOLENCE = [
|
||||
'gore', 'gory', 'bloody', 'bloodbath', 'massacre',
|
||||
'torture', 'torturing', 'mutilate', 'mutilation',
|
||||
'decapitate', 'decapitation', 'dismember',
|
||||
'disembowel', 'eviscerate', 'strangle', 'strangling',
|
||||
'suffocate'
|
||||
];
|
||||
|
||||
// Sexual content keywords
|
||||
const SEXUAL_CONTENT = [
|
||||
'sex', 'sexual', 'porn', 'pornography', 'nude', 'naked',
|
||||
'strip', 'stripper', 'erotic', 'orgasm', 'masturbate',
|
||||
'penis', 'vagina', 'breasts', 'boobs',
|
||||
'horny', 'aroused', 'seduce', 'seductive'
|
||||
];
|
||||
|
||||
// Kid-friendly error messages
|
||||
const ERROR_MESSAGES = {
|
||||
profanity: "Oops! Let's keep our words friendly. Try a different way to say that!",
|
||||
pii: "For your safety, please don't share personal info like phone numbers or addresses.",
|
||||
violence: "Let's keep things fun! Try describing your game without scary details.",
|
||||
sexual: "That content isn't appropriate for our platform. Please try something else.",
|
||||
drugs: "Let's keep our games drug-free! Try a different idea.",
|
||||
hate: "We want everyone to feel welcome here. Please use kind words.",
|
||||
general: "Some of that content isn't allowed. Please try again with different words."
|
||||
};
|
||||
|
||||
// Leet-speak character mappings
|
||||
const LEET_MAP = {
|
||||
'0': 'o',
|
||||
'1': 'i',
|
||||
'3': 'e',
|
||||
'4': 'a',
|
||||
'5': 's',
|
||||
'7': 't',
|
||||
'8': 'b',
|
||||
'@': 'a',
|
||||
'$': 's',
|
||||
'!': 'i',
|
||||
'+': 't'
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize text by converting leet-speak to regular letters
|
||||
*/
|
||||
function normalizeLeetSpeak(text) {
|
||||
let normalized = text.toLowerCase();
|
||||
for (const [leet, letter] of Object.entries(LEET_MAP)) {
|
||||
normalized = normalized.replace(new RegExp('\\' + leet, 'g'), letter);
|
||||
}
|
||||
// Remove repeated characters (e.g., "fuuuuck" -> "fuck")
|
||||
normalized = normalized.replace(/(.)\1{2,}/g, '$1$1');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains profanity
|
||||
*/
|
||||
function containsProfanity(text, allowGamingContext = false) {
|
||||
const normalized = normalizeLeetSpeak(text);
|
||||
const words = normalized.split(/[\s.,!?;:'"()\[\]{}]+/);
|
||||
|
||||
for (const word of words) {
|
||||
const cleanWord = word.replace(/[^a-z]/g, '');
|
||||
if (!cleanWord) continue;
|
||||
|
||||
// Skip if it's in the gaming allowlist and gaming context is allowed
|
||||
if (allowGamingContext && GAMING_ALLOWLIST.includes(cleanWord)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check against profanity list
|
||||
if (PROFANITY_LIST.includes(cleanWord)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for embedded profanity (e.g., "motherf*cker")
|
||||
for (const badWord of PROFANITY_LIST) {
|
||||
if (normalized.includes(badWord)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains any words from a list
|
||||
*/
|
||||
function containsWord(text, wordList) {
|
||||
const normalized = normalizeLeetSpeak(text);
|
||||
|
||||
for (const badWord of wordList) {
|
||||
if (normalized.includes(badWord)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for PII in text
|
||||
*/
|
||||
function detectPII(text) {
|
||||
const found = [];
|
||||
|
||||
for (const [type, pattern] of Object.entries(PII_PATTERNS)) {
|
||||
const regex = new RegExp(pattern.source, pattern.flags);
|
||||
if (regex.test(text)) {
|
||||
found.push(type);
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean text by replacing bad words with asterisks
|
||||
*/
|
||||
function cleanText(text) {
|
||||
let cleaned = text;
|
||||
const normalized = normalizeLeetSpeak(text);
|
||||
|
||||
for (const badWord of PROFANITY_LIST) {
|
||||
const regex = new RegExp(badWord, 'gi');
|
||||
if (regex.test(normalized)) {
|
||||
// Replace with asterisks of same length
|
||||
cleaned = cleaned.replace(regex, '*'.repeat(badWord.length));
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main content filter function
|
||||
*/
|
||||
function filterText(text, options = {}) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return {
|
||||
clean: true,
|
||||
filtered: text || '',
|
||||
blocked: false,
|
||||
severity: 'safe',
|
||||
categories: [],
|
||||
message: null,
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
allowGamingContext = false,
|
||||
contentType = 'general',
|
||||
strictMode = false
|
||||
} = options;
|
||||
|
||||
const categories = [];
|
||||
let severity = 'safe';
|
||||
let message = null;
|
||||
let blocked = false;
|
||||
|
||||
// Check for PII (always block)
|
||||
const piiTypes = detectPII(text);
|
||||
if (piiTypes.length > 0) {
|
||||
categories.push('pii');
|
||||
severity = 'critical';
|
||||
message = ERROR_MESSAGES.pii;
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
// Check for sexual content (always block)
|
||||
if (containsWord(text, SEXUAL_CONTENT)) {
|
||||
categories.push('sexual');
|
||||
severity = 'critical';
|
||||
message = ERROR_MESSAGES.sexual;
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
// Check for graphic violence
|
||||
if (containsWord(text, GRAPHIC_VIOLENCE)) {
|
||||
categories.push('violence');
|
||||
if (severity !== 'critical') severity = 'high';
|
||||
message = message || ERROR_MESSAGES.violence;
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
// Check for profanity
|
||||
if (containsProfanity(text, allowGamingContext)) {
|
||||
categories.push('profanity');
|
||||
if (severity === 'safe') severity = strictMode ? 'high' : 'medium';
|
||||
message = message || ERROR_MESSAGES.profanity;
|
||||
blocked = strictMode || severity === 'high' || severity === 'critical';
|
||||
}
|
||||
|
||||
// If no issues found
|
||||
if (categories.length === 0) {
|
||||
return {
|
||||
clean: true,
|
||||
filtered: text,
|
||||
blocked: false,
|
||||
severity: 'safe',
|
||||
categories: [],
|
||||
message: null,
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
// Generate filtered version
|
||||
const filtered = cleanText(text);
|
||||
|
||||
return {
|
||||
clean: false,
|
||||
filtered,
|
||||
blocked,
|
||||
severity,
|
||||
categories,
|
||||
message: message || ERROR_MESSAGES.general,
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter game creation answers object
|
||||
*/
|
||||
function filterGamePrompt(answers) {
|
||||
if (!answers || typeof answers !== 'object') {
|
||||
return { clean: true, blocked: false, issues: [] };
|
||||
}
|
||||
|
||||
const issues = [];
|
||||
|
||||
for (const [key, value] of Object.entries(answers)) {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const result = filterText(value, {
|
||||
allowGamingContext: true,
|
||||
contentType: 'game_prompt'
|
||||
});
|
||||
|
||||
if (!result.clean) {
|
||||
issues.push({
|
||||
field: key,
|
||||
value: value,
|
||||
...result
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = issues.some(i => i.blocked);
|
||||
const worstSeverity = issues.reduce((worst, issue) => {
|
||||
const severityOrder = ['safe', 'low', 'medium', 'high', 'critical'];
|
||||
return severityOrder.indexOf(issue.severity) > severityOrder.indexOf(worst)
|
||||
? issue.severity
|
||||
: worst;
|
||||
}, 'safe');
|
||||
|
||||
return {
|
||||
clean: issues.length === 0,
|
||||
blocked,
|
||||
severity: worstSeverity,
|
||||
issues,
|
||||
message: issues[0]?.message || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter username (strict mode)
|
||||
*/
|
||||
function filterUsername(username) {
|
||||
return filterText(username, {
|
||||
allowGamingContext: false,
|
||||
contentType: 'username',
|
||||
strictMode: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter game/collection title
|
||||
*/
|
||||
function filterTitle(title) {
|
||||
return filterText(title, {
|
||||
allowGamingContext: true,
|
||||
contentType: 'title',
|
||||
strictMode: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter description text
|
||||
*/
|
||||
function filterDescription(description) {
|
||||
return filterText(description, {
|
||||
allowGamingContext: true,
|
||||
contentType: 'description',
|
||||
strictMode: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick check if text is clean
|
||||
*/
|
||||
function isClean(text, options = {}) {
|
||||
const result = filterText(text, options);
|
||||
return result.clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity level of text
|
||||
*/
|
||||
function getSeverity(text, options = {}) {
|
||||
const result = filterText(text, options);
|
||||
return result.severity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log content filter action for moderation review
|
||||
*/
|
||||
function logFilterAction(userId, contentType, content, result) {
|
||||
if (!result.clean) {
|
||||
console.log('[CONTENT_FILTER]', JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
userId,
|
||||
contentType,
|
||||
blocked: result.blocked,
|
||||
severity: result.severity,
|
||||
categories: result.categories,
|
||||
contentPreview: content.substring(0, 50) + (content.length > 50 ? '...' : '')
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
filterText,
|
||||
filterGamePrompt,
|
||||
filterUsername,
|
||||
filterTitle,
|
||||
filterDescription,
|
||||
isClean,
|
||||
getSeverity,
|
||||
logFilterAction,
|
||||
// Export for testing
|
||||
normalizeLeetSpeak,
|
||||
detectPII,
|
||||
containsProfanity,
|
||||
GAMING_ALLOWLIST,
|
||||
ERROR_MESSAGES
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
// Mail API configuration
|
||||
const MAIL_API_URL = process.env.MAIL_API_URL || 'https://keylinkit.net/api/mail';
|
||||
const MAIL_API_KEY = process.env.MAIL_API_KEY;
|
||||
const FROM_EMAIL = process.env.SMTP_USER || 'info@gamercomp.com';
|
||||
const FROM_NAME = process.env.SMTP_FROM_NAME || 'GamerComp';
|
||||
|
||||
// SMTP fallback configuration
|
||||
let smtpTransporter = null;
|
||||
let smtpEnabled = false;
|
||||
|
||||
// Initialize SMTP transporter as fallback
|
||||
function initSmtpTransporter() {
|
||||
if (!process.env.SMTP_PASS) {
|
||||
console.log('Email: SMTP fallback not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
smtpTransporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST || 'mail.keylinkit.net',
|
||||
port: parseInt(process.env.SMTP_PORT) || 2525,
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || 'info@gamercomp.com',
|
||||
pass: process.env.SMTP_PASS
|
||||
},
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 5000
|
||||
});
|
||||
|
||||
smtpTransporter.verify((error) => {
|
||||
if (error) {
|
||||
console.log('Email: SMTP fallback not available -', error.message);
|
||||
smtpEnabled = false;
|
||||
} else {
|
||||
console.log('Email: SMTP fallback ready');
|
||||
smtpEnabled = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
initSmtpTransporter();
|
||||
|
||||
// Send via Mail API
|
||||
async function sendViaApi({ to, subject, text, html, replyTo }) {
|
||||
if (!MAIL_API_KEY) {
|
||||
throw new Error('Mail API key not configured');
|
||||
}
|
||||
|
||||
const response = await fetch(`${MAIL_API_URL}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MAIL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from: FROM_EMAIL,
|
||||
fromName: FROM_NAME,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Mail API error');
|
||||
}
|
||||
|
||||
return { success: true, messageId: data.messageId, via: 'api' };
|
||||
}
|
||||
|
||||
// Send via SMTP (fallback)
|
||||
async function sendViaSmtp({ to, subject, text, html }) {
|
||||
if (!smtpEnabled || !smtpTransporter) {
|
||||
throw new Error('SMTP not available');
|
||||
}
|
||||
|
||||
const info = await smtpTransporter.sendMail({
|
||||
from: `"${FROM_NAME}" <${FROM_EMAIL}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html
|
||||
});
|
||||
|
||||
return { success: true, messageId: info.messageId, via: 'smtp' };
|
||||
}
|
||||
|
||||
// Main send function - tries API first, falls back to SMTP
|
||||
async function sendEmail({ to, subject, text, html, replyTo }) {
|
||||
// Try Mail API first
|
||||
if (MAIL_API_KEY) {
|
||||
try {
|
||||
const result = await sendViaApi({ to, subject, text, html, replyTo });
|
||||
console.log(`Email sent via API: ${result.messageId}`);
|
||||
return result;
|
||||
} catch (apiError) {
|
||||
console.log('Email: API failed, trying SMTP fallback -', apiError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to SMTP
|
||||
if (smtpEnabled) {
|
||||
try {
|
||||
const result = await sendViaSmtp({ to, subject, text, html });
|
||||
console.log(`Email sent via SMTP: ${result.messageId}`);
|
||||
return result;
|
||||
} catch (smtpError) {
|
||||
console.error('Email: SMTP fallback failed -', smtpError.message);
|
||||
return { success: false, error: smtpError.message };
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Email: No delivery method available, skipping -', subject);
|
||||
return { success: false, error: 'No email delivery method configured' };
|
||||
}
|
||||
|
||||
// Check if email is enabled
|
||||
function isEmailEnabled() {
|
||||
return !!(MAIL_API_KEY || smtpEnabled);
|
||||
}
|
||||
|
||||
// HTML escape utility to prevent XSS in emails
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Safety footer for all emails
|
||||
const SAFETY_FOOTER_HTML = `
|
||||
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid #e2e8f0; text-align: center;">
|
||||
<p style="margin: 0 0 0.5rem; font-size: 0.85rem; color: #64748b;">
|
||||
<span style="display: inline-block; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-weight: 600; margin-right: 0.5rem;">E</span>
|
||||
<strong>Rated E for Everyone</strong>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 0.8rem; color: #94a3b8;">
|
||||
No Chat • No Strangers • All Games Moderated
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const SAFETY_FOOTER_TEXT = '\n\n---\nRated E for Everyone • No Chat • No Strangers • All Games Moderated';
|
||||
|
||||
// Forward user contact emails to admin
|
||||
async function forwardToAdmin(originalFrom, subject, body) {
|
||||
const adminEmail = process.env.ADMIN_FORWARD_EMAIL || 'allen@keylinkit.com';
|
||||
|
||||
return sendEmail({
|
||||
to: adminEmail,
|
||||
subject: `[GamerComp Contact] ${subject}`,
|
||||
text: `Forwarded message from: ${originalFrom}\n\n---\n\n${body}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<p><strong>Forwarded message from:</strong> ${escapeHtml(originalFrom)}</p>
|
||||
<hr style="border: 1px solid #e2e8f0;">
|
||||
<div style="padding: 1rem 0;">
|
||||
${escapeHtml(body).replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
// Welcome email for new users
|
||||
async function sendWelcomeEmail(user) {
|
||||
// Try template endpoint first if API is available
|
||||
if (MAIL_API_KEY) {
|
||||
try {
|
||||
const response = await fetch(`${MAIL_API_URL}/send-template`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MAIL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: user.email,
|
||||
from: FROM_EMAIL,
|
||||
fromName: FROM_NAME,
|
||||
template: 'welcome',
|
||||
variables: {
|
||||
username: user.displayName || user.username,
|
||||
platformName: 'GamerComp',
|
||||
loginUrl: 'https://gamercomp.com/arcade',
|
||||
brandColor: '#6366f1'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log(`Welcome email sent via template: ${data.messageId}`);
|
||||
return { success: true, messageId: data.messageId };
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Email: Template failed, using inline HTML -', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to inline HTML
|
||||
return sendEmail({
|
||||
to: user.email,
|
||||
subject: 'Welcome to GamerComp!',
|
||||
text: `Hi ${user.displayName || user.username}!
|
||||
|
||||
Welcome to GamerComp - the AI-powered game arcade!
|
||||
|
||||
You can now:
|
||||
- Play games created by the community
|
||||
- Create your own games using our guided wizard
|
||||
- Earn XP and achievements
|
||||
- Join leaderboards and compete
|
||||
|
||||
Start playing at: https://gamercomp.com/arcade
|
||||
|
||||
Have fun!
|
||||
- The GamerComp Team${SAFETY_FOOTER_TEXT}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 2rem; text-align: center; border-radius: 1rem 1rem 0 0;">
|
||||
<h1 style="color: white; margin: 0;">Welcome to GamerComp!</h1>
|
||||
</div>
|
||||
<div style="padding: 2rem; background: #f8fafc; border-radius: 0 0 1rem 1rem;">
|
||||
<p>Hi <strong>${user.displayName || user.username}</strong>!</p>
|
||||
<p>Welcome to GamerComp - the AI-powered game arcade!</p>
|
||||
<p>You can now:</p>
|
||||
<ul>
|
||||
<li>Play games created by the community</li>
|
||||
<li>Create your own games using our guided wizard</li>
|
||||
<li>Earn XP and achievements</li>
|
||||
<li>Join leaderboards and compete</li>
|
||||
</ul>
|
||||
<p style="text-align: center; margin-top: 2rem;">
|
||||
<a href="https://gamercomp.com/arcade" style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; padding: 0.75rem 2rem; border-radius: 0.5rem; text-decoration: none; font-weight: bold;">Start Playing</a>
|
||||
</p>
|
||||
<p style="margin-top: 2rem; color: #64748b; font-size: 0.9rem;">Have fun!<br>- The GamerComp Team</p>
|
||||
${SAFETY_FOOTER_HTML}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
// Notification email
|
||||
async function sendNotificationEmail(user, notification) {
|
||||
// Try template endpoint first
|
||||
if (MAIL_API_KEY) {
|
||||
try {
|
||||
const response = await fetch(`${MAIL_API_URL}/send-template`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MAIL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: user.email,
|
||||
from: FROM_EMAIL,
|
||||
fromName: FROM_NAME,
|
||||
template: 'notification',
|
||||
variables: {
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
actionUrl: notification.link || 'https://gamercomp.com',
|
||||
actionText: 'View on GamerComp',
|
||||
brandColor: '#6366f1'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log(`Notification email sent via template: ${data.messageId}`);
|
||||
return { success: true, messageId: data.messageId };
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Email: Template failed, using inline -', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return sendEmail({
|
||||
to: user.email,
|
||||
subject: `GamerComp: ${notification.title}`,
|
||||
text: notification.message + SAFETY_FOOTER_TEXT,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<h2 style="color: #6366f1;">${notification.title}</h2>
|
||||
<p>${notification.message}</p>
|
||||
${notification.link ? `<p><a href="${notification.link}" style="color: #6366f1;">View on GamerComp</a></p>` : ''}
|
||||
${SAFETY_FOOTER_HTML}
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
// Password reset email
|
||||
async function sendPasswordResetEmail({ email, username, resetUrl }) {
|
||||
// Try template endpoint first
|
||||
if (MAIL_API_KEY) {
|
||||
try {
|
||||
const response = await fetch(`${MAIL_API_URL}/send-template`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${MAIL_API_KEY}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to: email,
|
||||
from: FROM_EMAIL,
|
||||
fromName: FROM_NAME,
|
||||
template: 'password-reset',
|
||||
variables: {
|
||||
username,
|
||||
resetUrl,
|
||||
expiresIn: '1 hour',
|
||||
brandColor: '#6366f1',
|
||||
platformName: 'GamerComp'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
console.log(`Password reset email sent via template: ${data.messageId}`);
|
||||
return { success: true, messageId: data.messageId };
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Email: Template failed, using inline -', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return sendEmail({
|
||||
to: email,
|
||||
subject: 'Reset your GamerComp password',
|
||||
text: `Hi ${username},
|
||||
|
||||
Someone requested a password reset for your GamerComp account.
|
||||
|
||||
Click here to reset your password:
|
||||
${resetUrl}
|
||||
|
||||
This link expires in 1 hour.
|
||||
|
||||
If you didn't request this, you can safely ignore this email - your password will remain unchanged.
|
||||
|
||||
- The GamerComp Team${SAFETY_FOOTER_TEXT}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<div style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); padding: 2rem; text-align: center; border-radius: 1rem 1rem 0 0;">
|
||||
<h1 style="color: white; margin: 0;">Password Reset</h1>
|
||||
</div>
|
||||
<div style="padding: 2rem; background: #f8fafc; border-radius: 0 0 1rem 1rem;">
|
||||
<p>Hi <strong>${username}</strong>,</p>
|
||||
<p>Someone requested a password reset for your GamerComp account.</p>
|
||||
<p style="text-align: center; margin: 2rem 0;">
|
||||
<a href="${resetUrl}" style="background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; padding: 0.875rem 2rem; border-radius: 0.5rem; text-decoration: none; font-weight: bold; display: inline-block;">Reset Password</a>
|
||||
</p>
|
||||
<p style="color: #64748b; font-size: 0.9rem;">This link expires in 1 hour.</p>
|
||||
<hr style="border: 1px solid #e2e8f0; margin: 1.5rem 0;">
|
||||
<p style="color: #94a3b8; font-size: 0.85rem;">If you didn't request this, you can safely ignore this email - your password will remain unchanged.</p>
|
||||
<p style="color: #64748b; font-size: 0.9rem;">- The GamerComp Team</p>
|
||||
${SAFETY_FOOTER_HTML}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
// Game ready email (sent when background generation completes)
|
||||
async function sendGameReadyEmail(user, gameTitle, gameId, failed = false) {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'https://gamercomp.com';
|
||||
|
||||
if (failed) {
|
||||
return sendNotificationEmail(user, {
|
||||
title: 'Game Generation Failed',
|
||||
message: `Your game "${gameTitle}" couldn't be generated. Don't worry - your prompt is saved! Head back to try again.`,
|
||||
link: `${frontendUrl}/create`
|
||||
});
|
||||
}
|
||||
|
||||
return sendNotificationEmail(user, {
|
||||
title: 'Your Game is Ready!',
|
||||
message: `Great news! Your game "${gameTitle}" has been generated and is ready to play! Head over to test it and get it published.`,
|
||||
link: `${frontendUrl}/play/${gameId}?newGame=true`
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sendEmail,
|
||||
forwardToAdmin,
|
||||
sendWelcomeEmail,
|
||||
sendNotificationEmail,
|
||||
sendGameReadyEmail,
|
||||
sendPasswordResetEmail,
|
||||
isEmailEnabled
|
||||
};
|
||||
@@ -0,0 +1,427 @@
|
||||
// Game Styles Configuration
|
||||
// Defines the guided questions for each game style
|
||||
|
||||
const GAME_STYLES = {
|
||||
action: {
|
||||
id: 'action',
|
||||
name: 'Action/Arcade',
|
||||
icon: '🎮',
|
||||
description: 'Fast-paced games with quick reflexes',
|
||||
examples: ['Space shooters', 'Platformers', 'Endless runners'],
|
||||
questions: [
|
||||
{
|
||||
id: 'movement',
|
||||
question: 'How does the player move or act?',
|
||||
placeholder: 'e.g., jump and dodge, shoot lasers, fly through obstacles',
|
||||
suggestions: ['jump and run', 'shoot and dodge', 'fly and collect', 'swing and grab']
|
||||
},
|
||||
{
|
||||
id: 'damage',
|
||||
question: 'What happens when you get hit?',
|
||||
placeholder: 'e.g., lose a life, shrink smaller, slow down temporarily',
|
||||
suggestions: ['lose a life', 'shrink smaller', 'get knocked back', 'lose points']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
puzzle: {
|
||||
id: 'puzzle',
|
||||
name: 'Puzzle',
|
||||
icon: '🧩',
|
||||
description: 'Brain teasers and logic games',
|
||||
examples: ['Match-3', 'Sliding puzzles', 'Word games'],
|
||||
questions: [
|
||||
{
|
||||
id: 'mechanic',
|
||||
question: "What's the core puzzle mechanic?",
|
||||
placeholder: 'e.g., match colors, slide tiles, connect dots',
|
||||
suggestions: ['match 3 or more', 'slide tiles', 'rotate pieces', 'connect paths', 'find differences']
|
||||
},
|
||||
{
|
||||
id: 'levels',
|
||||
question: 'How many levels should this have?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '5', label: '5 levels (quick game)' },
|
||||
{ value: '10', label: '10 levels (standard)' },
|
||||
{ value: '20', label: '20 levels (full game)' },
|
||||
{ value: 'endless', label: 'Endless (procedural)' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
story: {
|
||||
id: 'story',
|
||||
name: 'Story Adventure',
|
||||
icon: '📖',
|
||||
description: 'Narrative-driven experiences',
|
||||
examples: ['Choose your adventure', 'Visual novels', 'Quest games'],
|
||||
questions: [
|
||||
{
|
||||
id: 'characters',
|
||||
question: 'Who does the player meet along the way?',
|
||||
placeholder: 'e.g., a wise wizard, a friendly dragon, a sneaky thief',
|
||||
suggestions: ['helpful guide', 'mysterious stranger', 'friendly animal companion', 'rival adventurer']
|
||||
},
|
||||
{
|
||||
id: 'items',
|
||||
question: 'Are there items to collect or use?',
|
||||
placeholder: 'e.g., magic potions, keys, treasure maps',
|
||||
suggestions: ['magic items', 'keys and locks', 'collectible coins', 'power-up gems']
|
||||
},
|
||||
{
|
||||
id: 'endings',
|
||||
question: 'How many different endings?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '1', label: '1 ending (linear story)' },
|
||||
{ value: '3', label: '3 endings (good/neutral/bad)' },
|
||||
{ value: '5+', label: '5+ endings (branching paths)' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
racing: {
|
||||
id: 'racing',
|
||||
name: 'Racing',
|
||||
icon: '🏎️',
|
||||
description: 'Speed and competition',
|
||||
examples: ['Car racing', 'Running games', 'Obstacle courses'],
|
||||
questions: [
|
||||
{
|
||||
id: 'vehicle',
|
||||
question: 'What does the player race with?',
|
||||
placeholder: 'e.g., a cool car, a speedy bike, a rocket ship',
|
||||
suggestions: ['sports car', 'motorcycle', 'spaceship', 'running character', 'animal']
|
||||
},
|
||||
{
|
||||
id: 'obstacles',
|
||||
question: 'What obstacles or hazards are on the track?',
|
||||
placeholder: 'e.g., oil slicks, ramps, other racers',
|
||||
suggestions: ['barriers', 'jumps and ramps', 'moving obstacles', 'speed boosts', 'shortcuts']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
pet: {
|
||||
id: 'pet',
|
||||
name: 'Pet/Simulator',
|
||||
icon: '🐾',
|
||||
description: 'Care for virtual creatures or manage systems',
|
||||
examples: ['Virtual pets', 'Farm games', 'Tycoon games'],
|
||||
questions: [
|
||||
{
|
||||
id: 'creature',
|
||||
question: 'What creature or thing does the player care for?',
|
||||
placeholder: 'e.g., a cute puppy, a magical dragon egg, a garden',
|
||||
suggestions: ['cute pet', 'magical creature', 'garden/farm', 'restaurant', 'space station']
|
||||
},
|
||||
{
|
||||
id: 'needs',
|
||||
question: 'What needs does it have?',
|
||||
placeholder: 'e.g., feeding, playing, cleaning, sleeping',
|
||||
suggestions: ['food and water', 'play and exercise', 'cleaning', 'sleep', 'attention/love']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
trivia: {
|
||||
id: 'trivia',
|
||||
name: 'Trivia/Quiz',
|
||||
icon: '❓',
|
||||
description: 'Test knowledge and answer questions',
|
||||
examples: ['Quiz shows', 'Fact games', 'Educational games'],
|
||||
questions: [
|
||||
{
|
||||
id: 'topic',
|
||||
question: 'What topic should the questions be about?',
|
||||
placeholder: 'e.g., animals, space, movies, general knowledge',
|
||||
suggestions: ['animals', 'science', 'geography', 'movies', 'sports', 'history', 'general knowledge']
|
||||
},
|
||||
{
|
||||
id: 'format',
|
||||
question: 'How should questions be presented?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'multiple', label: 'Multiple choice (4 options)' },
|
||||
{ value: 'truefalse', label: 'True or False' },
|
||||
{ value: 'mixed', label: 'Mix of both' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
music: {
|
||||
id: 'music',
|
||||
name: 'Music/Rhythm',
|
||||
icon: '🎵',
|
||||
description: 'Games synced to beats and music',
|
||||
examples: ['Rhythm games', 'Dance games', 'Music puzzles'],
|
||||
questions: [
|
||||
{
|
||||
id: 'musicStyle',
|
||||
question: 'What style of music?',
|
||||
placeholder: 'e.g., upbeat pop, electronic dance, relaxing jazz',
|
||||
suggestions: ['upbeat pop', 'electronic', 'rock', 'classical', 'chiptune/8-bit']
|
||||
},
|
||||
{
|
||||
id: 'interaction',
|
||||
question: 'How does the player interact with the music?',
|
||||
placeholder: 'e.g., tap to the beat, catch falling notes, dance moves',
|
||||
suggestions: ['tap notes on beat', 'catch falling objects', 'follow patterns', 'build melodies']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
creative: {
|
||||
id: 'creative',
|
||||
name: 'Creative/Drawing',
|
||||
icon: '🎨',
|
||||
description: 'Express creativity through art',
|
||||
examples: ['Drawing games', 'Design tools', 'Coloring'],
|
||||
questions: [
|
||||
{
|
||||
id: 'medium',
|
||||
question: 'What does the player create with?',
|
||||
placeholder: 'e.g., paint and brushes, building blocks, musical notes',
|
||||
suggestions: ['paint brushes', 'shapes and colors', 'stickers', 'building blocks', 'patterns']
|
||||
},
|
||||
{
|
||||
id: 'goal',
|
||||
question: 'Is there a goal or is it free-form?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'free', label: 'Free creative mode (no goals)' },
|
||||
{ value: 'challenges', label: 'Creative challenges to complete' },
|
||||
{ value: 'both', label: 'Both modes available' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
rpg: {
|
||||
id: 'rpg',
|
||||
name: 'RPG/Battle',
|
||||
icon: '🏰',
|
||||
description: 'Heroes, quests, and combat',
|
||||
examples: ['Turn-based battles', 'Dungeon crawlers', 'Hero adventures'],
|
||||
questions: [
|
||||
{
|
||||
id: 'hero',
|
||||
question: 'Who is the hero?',
|
||||
placeholder: 'e.g., a brave knight, a young wizard, a space explorer',
|
||||
suggestions: ['brave knight', 'young wizard', 'ninja warrior', 'space explorer', 'animal hero']
|
||||
},
|
||||
{
|
||||
id: 'combat',
|
||||
question: 'How does combat work?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'turnbased', label: 'Turn-based (take turns attacking)' },
|
||||
{ value: 'realtime', label: 'Real-time (action combat)' },
|
||||
{ value: 'auto', label: 'Auto-battle (strategic choices)' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'progression',
|
||||
question: 'How does the hero get stronger?',
|
||||
placeholder: 'e.g., level up, find better weapons, learn new spells',
|
||||
suggestions: ['level up stats', 'find equipment', 'learn abilities', 'upgrade skills']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
sports: {
|
||||
id: 'sports',
|
||||
name: 'Sports',
|
||||
icon: '🎯',
|
||||
description: 'Athletic competitions and games',
|
||||
examples: ['Ball games', 'Olympic events', 'Arcade sports'],
|
||||
questions: [
|
||||
{
|
||||
id: 'sport',
|
||||
question: 'What sport or activity?',
|
||||
placeholder: 'e.g., basketball, soccer, bowling, mini-golf',
|
||||
suggestions: ['basketball', 'soccer', 'bowling', 'mini-golf', 'archery', 'tennis']
|
||||
},
|
||||
{
|
||||
id: 'mode',
|
||||
question: 'Single player or vs computer?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'single', label: 'Single player (beat your score)' },
|
||||
{ value: 'vsai', label: 'Vs Computer opponent' },
|
||||
{ value: 'local', label: 'Local 2-player (same device)' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
physics: {
|
||||
id: 'physics',
|
||||
name: 'Physics/Pinball',
|
||||
icon: '⚙️',
|
||||
description: 'Games using realistic physics',
|
||||
examples: ['Pinball', 'Angry Birds style', 'Marble games'],
|
||||
questions: [
|
||||
{
|
||||
id: 'object',
|
||||
question: 'What object does the player control or launch?',
|
||||
placeholder: 'e.g., a bouncy ball, a slingshot, pinball flippers',
|
||||
suggestions: ['bouncy ball', 'projectile launcher', 'pinball', 'rolling marble', 'swinging object']
|
||||
},
|
||||
{
|
||||
id: 'environment',
|
||||
question: 'What does it interact with?',
|
||||
placeholder: 'e.g., bumpers and ramps, destructible blocks, water and platforms',
|
||||
suggestions: ['bumpers and ramps', 'destructible targets', 'moving platforms', 'water physics', 'gravity zones']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
freeform: {
|
||||
id: 'freeform',
|
||||
name: 'Freeform (Advanced)',
|
||||
icon: '✨',
|
||||
description: 'Write your own complete game description',
|
||||
examples: ['Any game idea', 'Complex mechanics', 'Unique combinations'],
|
||||
skipCommonQuestions: true,
|
||||
questions: [
|
||||
{
|
||||
id: 'fullDescription',
|
||||
question: 'Describe your game in detail',
|
||||
type: 'textarea',
|
||||
placeholder: `Describe exactly what game you want. Be as detailed as possible!
|
||||
|
||||
Example:
|
||||
A space shooter where you control a cat astronaut. Move with arrow keys, shoot lasers with spacebar. Enemies are alien mice that fly in wave patterns. Collect cheese powerups for rapid fire. The background scrolls through colorful nebulas. Start with 3 lives, game over when all lives lost.
|
||||
|
||||
Include: theme, controls, enemies/obstacles, powerups, scoring, and any unique mechanics.`,
|
||||
maxLength: 2000
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Common questions asked for ALL game styles
|
||||
const COMMON_QUESTIONS = [
|
||||
{
|
||||
id: 'theme',
|
||||
question: "What's the theme or setting?",
|
||||
placeholder: 'e.g., outer space, underwater kingdom, magical forest',
|
||||
suggestions: ['outer space', 'underwater', 'magical forest', 'haunted mansion', 'candy land', 'jungle', 'city', 'arctic']
|
||||
},
|
||||
{
|
||||
id: 'player',
|
||||
question: 'Who or what does the player control?',
|
||||
placeholder: 'e.g., a brave astronaut, a friendly robot, a bouncing ball',
|
||||
suggestions: ['brave hero', 'cute animal', 'robot', 'spaceship', 'ball/shape', 'wizard']
|
||||
},
|
||||
{
|
||||
id: 'goal',
|
||||
question: "What's the goal? How do you win?",
|
||||
placeholder: 'e.g., reach the finish line, collect all the stars, defeat the boss',
|
||||
suggestions: ['reach the end', 'collect items', 'survive waves', 'beat high score', 'solve the puzzle', 'defeat enemies']
|
||||
},
|
||||
{
|
||||
id: 'challenge',
|
||||
question: 'What makes it challenging?',
|
||||
placeholder: 'e.g., time limit, tricky enemies, faster speeds over time',
|
||||
suggestions: ['time pressure', 'enemies/obstacles', 'increasing speed', 'limited lives', 'complex puzzles']
|
||||
}
|
||||
];
|
||||
|
||||
// Final configuration questions
|
||||
const FINAL_QUESTIONS = [
|
||||
{
|
||||
id: 'avatar',
|
||||
question: 'Should the game support custom player avatars?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes - Let players customize their look' },
|
||||
{ value: 'no', label: 'No - Use a fixed character design' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'difficulty',
|
||||
question: 'What difficulty levels should this have?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'easy', label: 'Easy only (great for young kids)' },
|
||||
{ value: 'easy-medium', label: 'Easy + Medium' },
|
||||
{ value: 'all', label: 'Easy, Medium, and Hard' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'musicStyle',
|
||||
question: 'What style of music should your game have?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'chiptune', label: 'Chiptune/8-bit (classic arcade)', icon: '🎵' },
|
||||
{ value: 'rock', label: 'Rock/energetic', icon: '🎸' },
|
||||
{ value: 'calm', label: 'Calm/peaceful', icon: '🎹' },
|
||||
{ value: 'epic', label: 'Epic/adventure', icon: '🌟' },
|
||||
{ value: 'spooky', label: 'Spooky/mysterious', icon: '👻' },
|
||||
{ value: 'electronic', label: 'Electronic/synth', icon: '🤖' },
|
||||
{ value: 'silly', label: 'Silly/fun', icon: '🎪' },
|
||||
{ value: 'none', label: 'No music', icon: '🔇' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'features',
|
||||
question: 'Any special features?',
|
||||
type: 'multiselect',
|
||||
options: [
|
||||
{ value: 'timer', label: 'Time limit' },
|
||||
{ value: 'notimer', label: 'No time pressure (zen mode)' },
|
||||
{ value: 'local2p', label: 'Local 2-player mode' },
|
||||
{ value: 'endless', label: 'Endless/survival mode' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'accessibility',
|
||||
question: 'Add accessibility features?',
|
||||
type: 'multiselect',
|
||||
options: [
|
||||
{ value: 'onehand', label: 'One-hand playable' },
|
||||
{ value: 'colorblind', label: 'Colorblind friendly (shapes + colors)' },
|
||||
{ value: 'audio', label: 'Audio descriptions' },
|
||||
{ value: 'largetargets', label: 'Large touch targets' },
|
||||
{ value: 'adjustspeed', label: 'Adjustable game speed' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
GAME_STYLES,
|
||||
COMMON_QUESTIONS,
|
||||
FINAL_QUESTIONS,
|
||||
|
||||
getStyleById(styleId) {
|
||||
return GAME_STYLES[styleId] || null;
|
||||
},
|
||||
|
||||
getAllStyles() {
|
||||
return Object.values(GAME_STYLES).map(style => ({
|
||||
id: style.id,
|
||||
name: style.name,
|
||||
icon: style.icon,
|
||||
description: style.description,
|
||||
examples: style.examples
|
||||
}));
|
||||
},
|
||||
|
||||
getQuestionsForStyle(styleId) {
|
||||
const style = GAME_STYLES[styleId];
|
||||
if (!style) return null;
|
||||
|
||||
return {
|
||||
common: style.skipCommonQuestions ? [] : COMMON_QUESTIONS,
|
||||
styleSpecific: style.questions,
|
||||
final: FINAL_QUESTIONS,
|
||||
skipCommonQuestions: style.skipCommonQuestions || false
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
|
||||
const client = new Anthropic({
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
timeout: 15 * 1000 // 15 seconds max for Haiku calls
|
||||
});
|
||||
|
||||
const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
|
||||
|
||||
/**
|
||||
* Preprocess a tweak request - check feasibility, clean up, ask for clarification
|
||||
* Returns: { canTweak, needsClarification, cleanedRequest, question, reason }
|
||||
*/
|
||||
async function preprocessTweak(feedback, gameTitle) {
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 300,
|
||||
system: `You analyze game tweak requests for a kids' HTML5 game arcade. Respond with JSON only.
|
||||
|
||||
A "tweak" is a small code change: colors, sizes, speeds, text, simple visual adjustments.
|
||||
NOT a tweak: adding new game mechanics, rewriting game logic, adding multiplayer, new levels, complete redesigns.
|
||||
|
||||
Respond with exactly one JSON object:
|
||||
- If the request is vague/ambiguous: {"needsClarification":true,"question":"<kid-friendly question to clarify>"}
|
||||
- If too complex for a tweak: {"canTweak":false,"reason":"<kid-friendly 1-sentence explanation suggesting they use AI Overhaul instead>"}
|
||||
- If it's a valid tweak: {"canTweak":true,"cleanedRequest":"<cleaned up version with fixed typos and clear instructions>"}`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Game: "${gameTitle}"\nTweak request: "${feedback}"`
|
||||
}]
|
||||
});
|
||||
|
||||
const text = response.content[0].text.trim();
|
||||
// Extract JSON from response (handle markdown code blocks)
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
return { canTweak: true, cleanedRequest: feedback };
|
||||
}
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
|
||||
if (result.needsClarification) {
|
||||
return { needsClarification: true, question: result.question };
|
||||
}
|
||||
if (result.canTweak === false) {
|
||||
return { canTweak: false, reason: result.reason, suggestOverhaul: true };
|
||||
}
|
||||
return { canTweak: true, cleanedRequest: result.cleanedRequest || feedback };
|
||||
} catch (error) {
|
||||
console.warn('Haiku preprocessTweak failed, falling through:', error.message);
|
||||
return { canTweak: true, cleanedRequest: feedback };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess a game creation or refine prompt - clean up grammar, make vague ideas specific
|
||||
* Context: "creation" or "refine"
|
||||
* Returns: { success, cleanedText }
|
||||
*/
|
||||
async function preprocessPrompt(promptText, context) {
|
||||
try {
|
||||
const contextInstructions = context === 'creation'
|
||||
? 'This is a game creation prompt describing what game to build.'
|
||||
: 'This is feedback for refining an existing game.';
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 1000,
|
||||
system: `You clean up game prompts for a kids' HTML5 game arcade. ${contextInstructions}
|
||||
|
||||
Your job:
|
||||
- Fix typos and grammar
|
||||
- Make vague descriptions slightly more specific (but don't add ideas the user didn't mention)
|
||||
- Keep the same meaning and intent
|
||||
- Keep it concise
|
||||
|
||||
Return ONLY the cleaned-up text, nothing else. No quotes, no explanation.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: promptText
|
||||
}]
|
||||
});
|
||||
|
||||
const cleanedText = response.content[0].text.trim();
|
||||
if (!cleanedText || cleanedText.length < 3) {
|
||||
return { success: true, cleanedText: promptText };
|
||||
}
|
||||
return { success: true, cleanedText };
|
||||
} catch (error) {
|
||||
console.warn('Haiku preprocessPrompt failed, falling through:', error.message);
|
||||
return { success: true, cleanedText: promptText };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a technical error into a kid-friendly explanation
|
||||
* Context: "tweak", "generation", or "refine"
|
||||
* Returns: { success, friendlyError }
|
||||
*/
|
||||
async function explainError(rawError, context) {
|
||||
try {
|
||||
const contextLabel = {
|
||||
tweak: 'applying a free tweak to their game',
|
||||
generation: 'generating a new game',
|
||||
refine: 'refining their game with AI'
|
||||
}[context] || 'working on their game';
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 100,
|
||||
system: `You write kid-friendly (ages 8-16) error messages for a game arcade website.
|
||||
The user was ${contextLabel} and got an error.
|
||||
Write a 1-2 sentence friendly explanation. Be encouraging. Don't mention technical details.
|
||||
Return ONLY the error message text.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Technical error: ${rawError}`
|
||||
}]
|
||||
});
|
||||
|
||||
const friendlyError = response.content[0].text.trim();
|
||||
if (!friendlyError) {
|
||||
return { success: false, friendlyError: 'Something went wrong, try again!' };
|
||||
}
|
||||
return { success: true, friendlyError };
|
||||
} catch (error) {
|
||||
console.warn('Haiku explainError failed, using generic message:', error.message);
|
||||
return { success: false, friendlyError: 'Something went wrong, try again!' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize code changes after a tweak or refinement
|
||||
* Returns kid-friendly 2-3 sentence description of what changed
|
||||
*/
|
||||
async function summarizeCodeChanges(oldCode, newCode, userRequest) {
|
||||
try {
|
||||
// Compute a simple diff summary by comparing lengths and key sections
|
||||
const oldLen = (oldCode || '').length;
|
||||
const newLen = (newCode || '').length;
|
||||
const sizeDiff = newLen - oldLen;
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 200,
|
||||
system: `You summarize game code changes for kids aged 8-16. Write 2-3 short, fun sentences about what changed. Use simple language. Don't mention code or technical details - describe the visible result.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `The user asked: "${userRequest}"\nCode changed by ${sizeDiff > 0 ? '+' : ''}${sizeDiff} characters.\nSummarize what likely changed in the game.`
|
||||
}]
|
||||
});
|
||||
|
||||
return response.content[0].text.trim();
|
||||
} catch (error) {
|
||||
console.warn('Haiku summarizeCodeChanges failed:', error.message);
|
||||
return userRequest; // Fall back to the original request as description
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize a newly generated game for the user
|
||||
* Returns 2-3 sentence description
|
||||
*/
|
||||
async function summarizeNewGame(gameCode, originalPrompt) {
|
||||
try {
|
||||
// Extract key indicators from the code
|
||||
const hasCanvas = gameCode.includes('<canvas');
|
||||
const hasAudio = gameCode.includes('AudioContext') || gameCode.includes('oscillator');
|
||||
const hasTouch = gameCode.includes('touchstart') || gameCode.includes('ontouchstart');
|
||||
const codeSize = Math.round(gameCode.length / 1024);
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 200,
|
||||
system: `You write exciting, kid-friendly (ages 8-16) game summaries. Write 2-3 short sentences. Be enthusiastic but honest. Don't describe things that aren't in the prompt.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `A game was just generated! Prompt: "${originalPrompt.substring(0, 500)}"\nGame features: ${codeSize}KB, ${hasCanvas ? 'canvas graphics' : 'HTML'}, ${hasAudio ? 'music & sounds' : 'no audio'}, ${hasTouch ? 'touch controls' : 'keyboard/mouse'}.\nWrite a fun summary of this game.`
|
||||
}]
|
||||
});
|
||||
|
||||
return response.content[0].text.trim();
|
||||
} catch (error) {
|
||||
console.warn('Haiku summarizeNewGame failed:', error.message);
|
||||
return 'Your game is ready to play!';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
preprocessTweak,
|
||||
preprocessPrompt,
|
||||
explainError,
|
||||
summarizeCodeChanges,
|
||||
summarizeNewGame
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Local AI Client - Haiku-powered lightweight AI operations
|
||||
* Used for: prompt refinement chat, tweak classification, minor code tweaks
|
||||
* (Replaced Ollama with Claude Haiku for reliability)
|
||||
*/
|
||||
|
||||
const Anthropic = require('@anthropic-ai/sdk');
|
||||
const { compressCode } = require('./claude');
|
||||
|
||||
const client = new Anthropic({
|
||||
apiKey: process.env.CLAUDE_API_KEY,
|
||||
timeout: 60 * 1000 // 60 seconds for tweak/refine operations
|
||||
});
|
||||
|
||||
const HAIKU_MODEL = 'claude-haiku-4-5-20251001';
|
||||
|
||||
/**
|
||||
* Stage 1: Prompt refinement chat
|
||||
*/
|
||||
async function refinePrompt(gameIdea, conversationHistory = []) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const systemPrompt = `You are a game design assistant helping kids aged 8-16 create HTML5 canvas games.
|
||||
Your job is to ask ONE clarifying question at a time to help flesh out their game idea.
|
||||
|
||||
Ask about these topics (one at a time):
|
||||
- Theme/setting (space, underwater, forest, etc.)
|
||||
- Player character (what does the player control?)
|
||||
- Goal (what's the objective?)
|
||||
- Controls (keyboard, mouse, touch?)
|
||||
- Enemies/obstacles (what makes it challenging?)
|
||||
- Visual style (pixel art, cartoon, neon, etc.)
|
||||
- Difficulty (easy start? how does it get harder?)
|
||||
|
||||
After 4-6 questions (when you have enough detail), output a complete game specification.
|
||||
When the specification is ready, prefix it with SPEC_COMPLETE: on its own line, then the full spec.
|
||||
|
||||
Keep your questions short, fun, and encouraging. Use simple language a kid would understand.
|
||||
Never ask more than ONE question per response.`;
|
||||
|
||||
let userContent = `Game idea: "${gameIdea}"\n\n`;
|
||||
|
||||
if (conversationHistory.length > 0) {
|
||||
userContent += 'Conversation so far:\n';
|
||||
for (const msg of conversationHistory) {
|
||||
userContent += `${msg.role === 'ai' ? 'You' : 'User'}: ${msg.text}\n`;
|
||||
}
|
||||
userContent += '\nAsk your next question, or if you have enough detail, output the complete specification prefixed with SPEC_COMPLETE:';
|
||||
} else {
|
||||
userContent += 'This is the start of the conversation. Ask your first clarifying question.';
|
||||
}
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 2000,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userContent }]
|
||||
});
|
||||
|
||||
const text = response.content[0].text.trim();
|
||||
const isComplete = text.includes('SPEC_COMPLETE:');
|
||||
|
||||
if (isComplete) {
|
||||
const specStart = text.indexOf('SPEC_COMPLETE:') + 'SPEC_COMPLETE:'.length;
|
||||
const refinedSpec = text.substring(specStart).trim();
|
||||
return {
|
||||
success: true,
|
||||
question: null,
|
||||
isComplete: true,
|
||||
refinedSpec,
|
||||
durationMs: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
question: text,
|
||||
isComplete: false,
|
||||
refinedSpec: null,
|
||||
durationMs: Date.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Haiku refinePrompt failed:', error.message);
|
||||
return { success: false, error: error.message, durationMs: Date.now() - startTime };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: Classify feedback as minor tweak or major change
|
||||
*/
|
||||
async function classifyTweak(feedback) {
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 300,
|
||||
system: `You classify game modification requests as either "minor_tweak" or "major_change".
|
||||
|
||||
MINOR_TWEAK examples (simple value/style changes):
|
||||
- Change colors (background, player, enemies)
|
||||
- Adjust speed values (faster, slower)
|
||||
- Change sizes (bigger player, smaller enemies)
|
||||
- Change text/labels (title, score label, game over text)
|
||||
- Numeric tweaks (more lives, higher score, different timer)
|
||||
- Simple CSS changes (font size, borders, spacing)
|
||||
- Sound volume adjustments
|
||||
- Change starting values (initial speed, health, ammo)
|
||||
|
||||
MAJOR_CHANGE examples (structural/logic changes):
|
||||
- Add new game mechanics (power-ups, shields, combos)
|
||||
- Add enemies or new enemy types
|
||||
- Add levels or a level system
|
||||
- Add multiplayer support
|
||||
- Change game genre or core gameplay
|
||||
- Restructure game loop or scoring system
|
||||
- Add new UI elements (menus, HUD, minimap)
|
||||
- Add animations or particle effects
|
||||
- Add save/load functionality
|
||||
|
||||
Be CONSERVATIVE: if uncertain, classify as "major_change".
|
||||
|
||||
Respond with ONLY a JSON object: {"classification": "minor_tweak" or "major_change", "confidence": 0.0-1.0, "reason": "brief explanation"}`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Classify this game modification request:\n"${feedback}"`
|
||||
}]
|
||||
});
|
||||
|
||||
const text = response.content[0].text.trim();
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) {
|
||||
return { classification: 'major_change', confidence: 0.5, reason: 'Could not parse classification' };
|
||||
}
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
return {
|
||||
classification: parsed.classification === 'minor_tweak' ? 'minor_tweak' : 'major_change',
|
||||
confidence: Math.min(1, Math.max(0, parsed.confidence || 0.5)),
|
||||
reason: parsed.reason || 'No reason provided'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Haiku classifyTweak failed:', error.message);
|
||||
return { classification: 'major_change', confidence: 0, reason: 'Classification unavailable', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 3: Apply a minor tweak via diff/patch approach
|
||||
*/
|
||||
async function applyTweak(gameCode, tweakDescription) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: HAIKU_MODEL,
|
||||
max_tokens: 4096,
|
||||
system: `You output ONLY valid JSON arrays of find/replace pairs. No explanation, no markdown code blocks, no commentary. Just the raw JSON array.`,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: `Here is an HTML5 game:\n\n${gameCode}\n\nThe user wants: "${tweakDescription}"\n\nFind ALL places in the code that need to change. Return a JSON array of find/replace pairs:\n[{"find": "exact original text", "replace": "new text"}]\n\nRules:\n- Each "find" must be an EXACT copy-paste substring from the original code\n- Find ALL instances that need changing - check CSS, JS variables, canvas drawing code, etc.\n- Only include strings that actually need to change\n- Keep "find" strings short and precise - just the value/line that changes, not huge blocks\n- Return ONLY the JSON array`
|
||||
}]
|
||||
});
|
||||
|
||||
const text = response.content[0].text.trim();
|
||||
|
||||
// Parse JSON patches
|
||||
let patches;
|
||||
let cleanText = text;
|
||||
if (cleanText.startsWith('```')) cleanText = cleanText.split('\n').slice(1).join('\n');
|
||||
if (cleanText.endsWith('```')) cleanText = cleanText.slice(0, cleanText.lastIndexOf('```'));
|
||||
cleanText = cleanText.trim();
|
||||
|
||||
const arrStart = cleanText.indexOf('[');
|
||||
const arrEnd = cleanText.lastIndexOf(']');
|
||||
if (arrStart === -1 || arrEnd === -1) {
|
||||
return { success: false, error: 'AI did not return valid patches', durationMs: Date.now() - startTime };
|
||||
}
|
||||
|
||||
patches = JSON.parse(cleanText.substring(arrStart, arrEnd + 1));
|
||||
|
||||
if (!Array.isArray(patches) || patches.length === 0) {
|
||||
return { success: false, error: 'AI returned no patches', durationMs: Date.now() - startTime };
|
||||
}
|
||||
|
||||
// Apply patches to original code
|
||||
let modifiedCode = gameCode;
|
||||
let applied = 0;
|
||||
let missed = 0;
|
||||
const appliedChanges = [];
|
||||
|
||||
for (const patch of patches) {
|
||||
if (!patch.find || !patch.replace || patch.find === patch.replace) continue;
|
||||
|
||||
if (modifiedCode.includes(patch.find)) {
|
||||
modifiedCode = modifiedCode.split(patch.find).join(patch.replace);
|
||||
applied++;
|
||||
appliedChanges.push(`"${patch.find.substring(0, 40)}" -> "${patch.replace.substring(0, 40)}"`);
|
||||
} else {
|
||||
missed++;
|
||||
console.log(`Haiku patch miss: could not find "${patch.find.substring(0, 60)}" in game code`);
|
||||
}
|
||||
}
|
||||
|
||||
if (applied === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `None of the ${patches.length} patches matched the game code`,
|
||||
durationMs: Date.now() - startTime
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`Haiku tweak: ${applied} patches applied, ${missed} missed, ${patches.length} total`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
code: modifiedCode,
|
||||
changesDescription: tweakDescription,
|
||||
patchesApplied: applied,
|
||||
patchesMissed: missed,
|
||||
patchDetails: appliedChanges,
|
||||
durationMs: Date.now() - startTime
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Haiku applyTweak failed:', error.message);
|
||||
return { success: false, error: error.message, durationMs: Date.now() - startTime };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check - Haiku is always available
|
||||
*/
|
||||
async function isAvailable() {
|
||||
return { available: true };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
refinePrompt,
|
||||
classifyTweak,
|
||||
applyTweak,
|
||||
isAvailable
|
||||
};
|
||||
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Prompt Builder
|
||||
* Constructs Claude prompts from guided question answers
|
||||
*/
|
||||
|
||||
const { GAME_STYLES } = require('./gameStyles');
|
||||
|
||||
/**
|
||||
* Build a detailed game prompt from user answers
|
||||
*/
|
||||
function buildPrompt(styleId, answers) {
|
||||
const style = GAME_STYLES[styleId];
|
||||
if (!style) {
|
||||
throw new Error(`Unknown game style: ${styleId}`);
|
||||
}
|
||||
|
||||
// Handle freeform style differently - use the full description directly
|
||||
if (styleId === 'freeform') {
|
||||
let prompt = `Create a game based on this description:\n\n${answers.fullDescription || ''}\n\n`;
|
||||
|
||||
// Add avatar customization
|
||||
if (answers.avatar === 'yes') {
|
||||
prompt += 'Avatar Customization: At the start of the game (or on the title screen), let the player pick their character appearance - offer at least 3-4 color/style options. Remember the choice throughout gameplay.\n\n';
|
||||
}
|
||||
|
||||
// Still add difficulty, features, accessibility, and requirements
|
||||
prompt += buildDifficultyPrompt(answers);
|
||||
prompt += buildFeaturesPrompt(answers);
|
||||
prompt += buildAccessibilityPrompt(answers);
|
||||
prompt += buildRequirementsReminder(styleId, answers);
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// Start with the game type and theme
|
||||
let prompt = `Create a ${style.name.toLowerCase()} game`;
|
||||
|
||||
// Add theme if provided
|
||||
if (answers.theme) {
|
||||
prompt += ` set in ${answers.theme}`;
|
||||
}
|
||||
prompt += '.\n\n';
|
||||
|
||||
// Add player character
|
||||
if (answers.player) {
|
||||
prompt += `The player controls ${answers.player}. `;
|
||||
}
|
||||
|
||||
// Add goal
|
||||
if (answers.goal) {
|
||||
prompt += `The goal is to ${answers.goal}. `;
|
||||
}
|
||||
|
||||
// Add challenge
|
||||
if (answers.challenge) {
|
||||
prompt += `The challenge comes from ${answers.challenge}. `;
|
||||
}
|
||||
|
||||
prompt += '\n\n';
|
||||
|
||||
// Add style-specific details
|
||||
prompt += buildStyleSpecificPrompt(styleId, answers);
|
||||
|
||||
// Add avatar customization
|
||||
if (answers.avatar === 'yes') {
|
||||
prompt += 'Avatar Customization: At the start of the game (or on the title screen), let the player pick their character appearance - offer at least 3-4 color/style options. Remember the choice throughout gameplay.\n\n';
|
||||
}
|
||||
|
||||
// Add difficulty configuration
|
||||
prompt += buildDifficultyPrompt(answers);
|
||||
|
||||
// Add special features
|
||||
prompt += buildFeaturesPrompt(answers);
|
||||
|
||||
// Add accessibility requirements
|
||||
prompt += buildAccessibilityPrompt(answers);
|
||||
|
||||
// Add reinforcement of core requirements
|
||||
prompt += buildRequirementsReminder(styleId, answers);
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildStyleSpecificPrompt(styleId, answers) {
|
||||
let prompt = '';
|
||||
|
||||
switch (styleId) {
|
||||
case 'action':
|
||||
if (answers.movement) {
|
||||
prompt += `Player actions: ${answers.movement}. `;
|
||||
}
|
||||
if (answers.damage) {
|
||||
prompt += `When hit: ${answers.damage}. `;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'puzzle':
|
||||
if (answers.mechanic) {
|
||||
prompt += `Core mechanic: ${answers.mechanic}. `;
|
||||
}
|
||||
if (answers.levels) {
|
||||
if (answers.levels === 'endless') {
|
||||
prompt += 'Include endless procedurally generated levels. ';
|
||||
} else {
|
||||
prompt += `Include ${answers.levels} levels with increasing difficulty. `;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'story':
|
||||
if (answers.characters) {
|
||||
prompt += `NPCs to meet: ${answers.characters}. `;
|
||||
}
|
||||
if (answers.items) {
|
||||
prompt += `Collectible items: ${answers.items}. `;
|
||||
}
|
||||
if (answers.endings) {
|
||||
if (answers.endings === '1') {
|
||||
prompt += 'Linear story with one ending. ';
|
||||
} else if (answers.endings === '3') {
|
||||
prompt += 'Branching story with 3 endings (good, neutral, bad). ';
|
||||
} else {
|
||||
prompt += 'Branching story with 5+ unique endings based on player choices. ';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'racing':
|
||||
if (answers.vehicle) {
|
||||
prompt += `Player races with: ${answers.vehicle}. `;
|
||||
}
|
||||
if (answers.obstacles) {
|
||||
prompt += `Track features: ${answers.obstacles}. `;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pet':
|
||||
if (answers.creature) {
|
||||
prompt += `The player cares for: ${answers.creature}. `;
|
||||
}
|
||||
if (answers.needs) {
|
||||
prompt += `Needs to manage: ${answers.needs}. `;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'trivia':
|
||||
if (answers.topic) {
|
||||
prompt += `Questions about: ${answers.topic}. `;
|
||||
}
|
||||
if (answers.format) {
|
||||
const formatMap = {
|
||||
'multiple': 'Use multiple choice questions with 4 options. ',
|
||||
'truefalse': 'Use true/false questions. ',
|
||||
'mixed': 'Mix multiple choice and true/false questions. '
|
||||
};
|
||||
prompt += formatMap[answers.format] || '';
|
||||
}
|
||||
prompt += 'Include at least 20 unique questions. ';
|
||||
break;
|
||||
|
||||
case 'music':
|
||||
if (answers.musicStyle) {
|
||||
prompt += `Music style: ${answers.musicStyle}. `;
|
||||
}
|
||||
if (answers.interaction) {
|
||||
prompt += `Player interaction: ${answers.interaction}. `;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'creative':
|
||||
if (answers.medium) {
|
||||
prompt += `Creation tools: ${answers.medium}. `;
|
||||
}
|
||||
if (answers.goal === 'free') {
|
||||
prompt += 'Pure sandbox mode with no goals, just creative freedom. ';
|
||||
} else if (answers.goal === 'challenges') {
|
||||
prompt += 'Include creative challenges and prompts for the player to complete. ';
|
||||
} else {
|
||||
prompt += 'Include both free mode and challenge mode. ';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rpg':
|
||||
if (answers.hero) {
|
||||
prompt += `The hero is: ${answers.hero}. `;
|
||||
}
|
||||
if (answers.combat) {
|
||||
const combatMap = {
|
||||
'turnbased': 'Turn-based combat where player and enemies take turns. ',
|
||||
'realtime': 'Real-time action combat with dodging and attacking. ',
|
||||
'auto': 'Auto-battle with strategic ability choices. '
|
||||
};
|
||||
prompt += combatMap[answers.combat] || '';
|
||||
}
|
||||
if (answers.progression) {
|
||||
prompt += `Progression system: ${answers.progression}. `;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sports':
|
||||
if (answers.sport) {
|
||||
prompt += `Sport: ${answers.sport}. `;
|
||||
}
|
||||
if (answers.mode) {
|
||||
const modeMap = {
|
||||
'single': 'Single player mode to beat your high score. ',
|
||||
'vsai': 'Vs computer opponent with adjustable AI difficulty. ',
|
||||
'local': 'Local 2-player mode on the same device. '
|
||||
};
|
||||
prompt += modeMap[answers.mode] || '';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'physics':
|
||||
if (answers.object) {
|
||||
prompt += `Player controls: ${answers.object}. `;
|
||||
}
|
||||
if (answers.environment) {
|
||||
prompt += `Environment features: ${answers.environment}. `;
|
||||
}
|
||||
prompt += 'Use realistic physics simulation. ';
|
||||
break;
|
||||
}
|
||||
|
||||
return prompt + '\n\n';
|
||||
}
|
||||
|
||||
function buildDifficultyPrompt(answers) {
|
||||
let prompt = 'DIFFICULTY: ';
|
||||
|
||||
switch (answers.difficulty) {
|
||||
case 'easy':
|
||||
prompt += 'Easy mode only - very forgiving, suitable for young kids (ages 6-8). ';
|
||||
prompt += 'Very slow speeds, large targets, generous timers if any. ';
|
||||
break;
|
||||
case 'easy-medium':
|
||||
prompt += 'Include Easy and Medium difficulties. ';
|
||||
prompt += 'Easy should be suitable for ages 6-8, Medium for ages 9-12. ';
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
prompt += 'Include Easy, Medium, and Hard difficulties. ';
|
||||
prompt += 'Easy for ages 6-8, Medium for ages 9-12, Hard for teens who want a challenge. ';
|
||||
break;
|
||||
}
|
||||
|
||||
prompt += 'Start on Easy by default. Add difficulty selector on game over screen. ';
|
||||
return prompt + '\n\n';
|
||||
}
|
||||
|
||||
function buildFeaturesPrompt(answers) {
|
||||
if (!answers.features || answers.features.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let prompt = 'SPECIAL FEATURES:\n';
|
||||
|
||||
if (answers.features.includes('timer')) {
|
||||
prompt += '- Include a time limit that adds pressure\n';
|
||||
}
|
||||
if (answers.features.includes('notimer')) {
|
||||
prompt += '- Zen mode with no time pressure\n';
|
||||
}
|
||||
if (answers.features.includes('local2p')) {
|
||||
prompt += '- Local 2-player mode: Player 1 uses WASD, Player 2 uses Arrow keys\n';
|
||||
}
|
||||
if (answers.features.includes('endless')) {
|
||||
prompt += '- Endless/survival mode that continues until game over\n';
|
||||
}
|
||||
|
||||
return prompt + '\n';
|
||||
}
|
||||
|
||||
function buildAccessibilityPrompt(answers) {
|
||||
if (!answers.accessibility || answers.accessibility.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let prompt = 'ACCESSIBILITY FEATURES (REQUIRED):\n';
|
||||
|
||||
if (answers.accessibility.includes('onehand')) {
|
||||
prompt += '- Must be fully playable with one hand (all controls on one side)\n';
|
||||
}
|
||||
if (answers.accessibility.includes('colorblind')) {
|
||||
prompt += '- Use shapes AND colors (never color alone) for all game elements\n';
|
||||
prompt += '- Add patterns or symbols to distinguish items\n';
|
||||
}
|
||||
if (answers.accessibility.includes('audio')) {
|
||||
prompt += '- Add audio cues and spoken descriptions for key events\n';
|
||||
}
|
||||
if (answers.accessibility.includes('largetargets')) {
|
||||
prompt += '- All touch targets must be extra large (60px minimum)\n';
|
||||
}
|
||||
if (answers.accessibility.includes('adjustspeed')) {
|
||||
prompt += '- Add game speed slider (0.5x to 2x) in pause menu\n';
|
||||
}
|
||||
|
||||
return prompt + '\n';
|
||||
}
|
||||
|
||||
function buildRequirementsReminder(styleId, answers) {
|
||||
let prompt = '\nIMPORTANT REQUIREMENTS:\n';
|
||||
prompt += '- Game MUST start very easy - playable by an 8-year-old for at least 30 seconds\n';
|
||||
|
||||
// Handle music style
|
||||
const musicStyles = {
|
||||
chiptune: 'Include catchy 8-bit chiptune background music using Web Audio API - classic arcade style with square waves and simple melodies',
|
||||
rock: 'Include energetic rock-style background music using Web Audio API - driving rhythms, power chord sounds, upbeat tempo',
|
||||
calm: 'Include calm, peaceful background music using Web Audio API - gentle melodies, soft tones, relaxing atmosphere',
|
||||
epic: 'Include epic adventure-style background music using Web Audio API - sweeping melodies, building intensity, heroic feel',
|
||||
spooky: 'Include spooky, mysterious background music using Web Audio API - minor keys, eerie sounds, suspenseful atmosphere',
|
||||
electronic: 'Include electronic/synth background music using Web Audio API - pulsing beats, synthesizer sounds, modern feel',
|
||||
silly: 'Include silly, fun background music using Web Audio API - playful melodies, bouncy rhythms, whimsical sounds',
|
||||
none: 'No background music - only sound effects for game actions'
|
||||
};
|
||||
|
||||
const selectedMusic = answers.musicStyle || 'chiptune';
|
||||
if (selectedMusic !== 'none') {
|
||||
prompt += `- ${musicStyles[selectedMusic] || musicStyles.chiptune}\n`;
|
||||
} else {
|
||||
prompt += `- ${musicStyles.none}\n`;
|
||||
}
|
||||
|
||||
prompt += '- Support all three controls: touch (on-screen buttons), keyboard (arrows + space), mouse\n';
|
||||
prompt += '- Use bright, kid-friendly colors and graphics\n';
|
||||
prompt += '- Add visual feedback when scoring (flash, particles)\n';
|
||||
prompt += '- Large, forgiving hitboxes for collision detection\n';
|
||||
prompt += '- 10% safe zones at top and bottom of screen\n';
|
||||
prompt += '- Game over screen shows final score with large "Play Again" button\n';
|
||||
|
||||
// Add style-specific reminders
|
||||
if (['action', 'racing', 'rpg'].includes(styleId)) {
|
||||
prompt += '- Start with 5 lives, obstacles move slowly at first\n';
|
||||
}
|
||||
if (['puzzle', 'trivia'].includes(styleId)) {
|
||||
prompt += '- First few levels/questions should be very easy\n';
|
||||
}
|
||||
if (styleId === 'story') {
|
||||
prompt += '- Text should be large and readable, with a "continue" button\n';
|
||||
}
|
||||
|
||||
// Count details for achievement tracking
|
||||
const detailCount = Object.values(answers).filter(v => v && v.length > 0).length;
|
||||
if (detailCount >= 5) {
|
||||
prompt += `\n(User provided ${detailCount} specific details - create a detailed, polished game!)\n`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a summary of the game from answers (for user review)
|
||||
*/
|
||||
function generateSummary(styleId, answers) {
|
||||
const style = GAME_STYLES[styleId];
|
||||
if (!style) return null;
|
||||
|
||||
// For freeform, use a truncated version of the description
|
||||
if (styleId === 'freeform' && answers.fullDescription) {
|
||||
const desc = answers.fullDescription;
|
||||
const truncated = desc.length > 150 ? desc.substring(0, 147) + '...' : desc;
|
||||
return `Custom game: ${truncated}`;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
|
||||
parts.push(`A ${style.name.toLowerCase()} game`);
|
||||
|
||||
if (answers.theme) {
|
||||
parts.push(`set in ${answers.theme}`);
|
||||
}
|
||||
|
||||
if (answers.player) {
|
||||
parts.push(`where you control ${answers.player}`);
|
||||
}
|
||||
|
||||
if (answers.goal) {
|
||||
parts.push(`and try to ${answers.goal}`);
|
||||
}
|
||||
|
||||
let summary = parts.join(' ') + '.';
|
||||
|
||||
// Add key features
|
||||
const features = [];
|
||||
|
||||
if (answers.difficulty === 'all') {
|
||||
features.push('3 difficulty levels');
|
||||
}
|
||||
if (answers.features?.includes('local2p')) {
|
||||
features.push('2-player mode');
|
||||
}
|
||||
if (answers.features?.includes('endless')) {
|
||||
features.push('endless mode');
|
||||
}
|
||||
if (answers.accessibility?.length > 0) {
|
||||
features.push('accessibility options');
|
||||
}
|
||||
|
||||
if (features.length > 0) {
|
||||
summary += ` Features: ${features.join(', ')}.`;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count details provided (for achievements)
|
||||
*/
|
||||
function countDetails(answers) {
|
||||
return Object.values(answers).filter(v => {
|
||||
if (Array.isArray(v)) return v.length > 0;
|
||||
if (typeof v === 'string') return v.trim().length > 0;
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPrompt,
|
||||
generateSummary,
|
||||
countDetails
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
const { pool } = require('../models/db');
|
||||
|
||||
let webpush;
|
||||
let initialized = false;
|
||||
|
||||
function init() {
|
||||
if (initialized) return;
|
||||
try {
|
||||
webpush = require('web-push');
|
||||
const vapidPublic = process.env.VAPID_PUBLIC_KEY;
|
||||
const vapidPrivate = process.env.VAPID_PRIVATE_KEY;
|
||||
const vapidEmail = process.env.VAPID_EMAIL || 'info@gamercomp.com';
|
||||
|
||||
if (vapidPublic && vapidPrivate) {
|
||||
webpush.setVapidDetails(`mailto:${vapidEmail}`, vapidPublic, vapidPrivate);
|
||||
initialized = true;
|
||||
console.log('Push notifications: VAPID configured');
|
||||
} else {
|
||||
console.log('Push notifications: VAPID keys not configured, push disabled');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Push notifications: web-push not installed, push disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on first require
|
||||
init();
|
||||
|
||||
/**
|
||||
* Send push notification to all subscriptions for a user
|
||||
*/
|
||||
async function send(userId, { title, body, url }) {
|
||||
if (!initialized || !webpush) return;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, endpoint, auth_key, p256dh_key FROM push_subscriptions WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return;
|
||||
|
||||
const payload = JSON.stringify({ title, body, url });
|
||||
const failedIds = [];
|
||||
|
||||
for (const sub of result.rows) {
|
||||
try {
|
||||
await webpush.sendNotification({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
auth: sub.auth_key,
|
||||
p256dh: sub.p256dh_key
|
||||
}
|
||||
}, payload);
|
||||
} catch (err) {
|
||||
// 410 Gone or 404 means subscription expired
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
failedIds.push(sub.id);
|
||||
}
|
||||
console.warn(`Push failed for subscription ${sub.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune expired subscriptions
|
||||
if (failedIds.length > 0) {
|
||||
await pool.query(
|
||||
'DELETE FROM push_subscriptions WHERE id = ANY($1)',
|
||||
[failedIds]
|
||||
);
|
||||
console.log(`Pruned ${failedIds.length} expired push subscriptions for user ${userId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Push send error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a push subscription for a user
|
||||
*/
|
||||
async function subscribe(userId, subscription) {
|
||||
const { endpoint, keys } = subscription;
|
||||
await pool.query(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, auth_key, p256dh_key)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
user_id = $1, auth_key = $3, p256dh_key = $4`,
|
||||
[userId, endpoint, keys.auth, keys.p256dh]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a push subscription
|
||||
*/
|
||||
async function unsubscribe(userId, endpoint) {
|
||||
await pool.query(
|
||||
'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
|
||||
[userId, endpoint]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { send, subscribe, unsubscribe, initialized: () => initialized };
|
||||
@@ -0,0 +1,118 @@
|
||||
// Lists of words for generating fun usernames
|
||||
const adjectives = [
|
||||
'cosmic', 'turbo', 'mega', 'super', 'hyper', 'ultra', 'epic', 'blazing',
|
||||
'swift', 'clever', 'mighty', 'brave', 'fierce', 'sneaky', 'zippy', 'jolly',
|
||||
'sparkly', 'neon', 'cyber', 'pixel', 'retro', 'funky', 'groovy', 'radical',
|
||||
'stellar', 'cosmic', 'galactic', 'thunder', 'lightning', 'shadow', 'golden',
|
||||
'crystal', 'frozen', 'flaming', 'electric', 'magnetic', 'sonic', 'atomic',
|
||||
'ninja', 'wizard', 'dragon', 'phoenix', 'lucky', 'happy', 'crazy', 'wild'
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'gamer', 'player', 'coder', 'hacker', 'ninja', 'wizard', 'pirate', 'knight',
|
||||
'dragon', 'phoenix', 'falcon', 'tiger', 'wolf', 'fox', 'panda', 'koala',
|
||||
'rocket', 'comet', 'star', 'nova', 'nebula', 'galaxy', 'pixel', 'byte',
|
||||
'quest', 'legend', 'hero', 'champion', 'master', 'ace', 'pro', 'boss',
|
||||
'storm', 'blaze', 'spark', 'flash', 'dash', 'bolt', 'wave', 'vortex',
|
||||
'arcade', 'joystick', 'controller', 'console', 'avatar', 'sprite', 'level'
|
||||
];
|
||||
|
||||
// Common first names to block (partial list - expand as needed)
|
||||
const commonFirstNames = new Set([
|
||||
'james', 'john', 'robert', 'michael', 'william', 'david', 'richard', 'joseph',
|
||||
'thomas', 'charles', 'christopher', 'daniel', 'matthew', 'anthony', 'mark',
|
||||
'donald', 'steven', 'paul', 'andrew', 'joshua', 'kenneth', 'kevin', 'brian',
|
||||
'mary', 'patricia', 'jennifer', 'linda', 'elizabeth', 'barbara', 'susan',
|
||||
'jessica', 'sarah', 'karen', 'nancy', 'lisa', 'betty', 'margaret', 'sandra',
|
||||
'ashley', 'dorothy', 'kimberly', 'emily', 'donna', 'michelle', 'carol',
|
||||
'emma', 'olivia', 'ava', 'isabella', 'sophia', 'mia', 'charlotte', 'amelia',
|
||||
'harper', 'evelyn', 'abigail', 'ella', 'scarlett', 'grace', 'chloe', 'camila',
|
||||
'liam', 'noah', 'oliver', 'elijah', 'lucas', 'mason', 'logan', 'alexander',
|
||||
'ethan', 'jacob', 'aiden', 'jackson', 'sebastian', 'jack', 'owen', 'henry',
|
||||
'samuel', 'ryan', 'nathan', 'adam', 'tyler', 'dylan', 'zachary', 'aaron',
|
||||
'allen', 'alex', 'jake', 'mike', 'tom', 'bob', 'joe', 'tim', 'sam', 'ben',
|
||||
'max', 'charlie', 'luke', 'leo', 'theo', 'finn', 'oscar', 'archie', 'alfie'
|
||||
]);
|
||||
|
||||
// Patterns that look like real names
|
||||
const realNamePatterns = [
|
||||
/^[a-z]+\s+[a-z]+$/i, // "John Smith"
|
||||
/^[a-z]+_[a-z]+$/i, // "john_smith"
|
||||
/^[a-z]+\.[a-z]+$/i, // "john.smith"
|
||||
/^[a-z]{2,15}[0-9]{0,4}$/i, // Common pattern like "john123"
|
||||
];
|
||||
|
||||
function generateUsername() {
|
||||
const adj = adjectives[Math.floor(Math.random() * adjectives.length)];
|
||||
const noun = nouns[Math.floor(Math.random() * nouns.length)];
|
||||
const num = Math.floor(Math.random() * 999) + 1;
|
||||
return `${adj}_${noun}${num}`;
|
||||
}
|
||||
|
||||
function generateUsernameSuggestions(count = 5) {
|
||||
const suggestions = new Set();
|
||||
while (suggestions.size < count) {
|
||||
suggestions.add(generateUsername());
|
||||
}
|
||||
return Array.from(suggestions);
|
||||
}
|
||||
|
||||
function looksLikeRealName(username) {
|
||||
const lower = username.toLowerCase().replace(/[0-9_]/g, '');
|
||||
|
||||
// Check if it contains a common first name
|
||||
if (commonFirstNames.has(lower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for name patterns
|
||||
for (const name of commonFirstNames) {
|
||||
if (lower.startsWith(name) && lower.length <= name.length + 5) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if username is just letters (potential name)
|
||||
if (/^[a-z]{4,15}$/i.test(username)) {
|
||||
// Could be a name, flag for review but allow with underscore/numbers
|
||||
const lower = username.toLowerCase();
|
||||
if (commonFirstNames.has(lower)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateUsername(username) {
|
||||
const errors = [];
|
||||
|
||||
// Basic validation
|
||||
if (username.length < 3) {
|
||||
errors.push('Username must be at least 3 characters');
|
||||
}
|
||||
if (username.length > 30) {
|
||||
errors.push('Username must be 30 characters or less');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
errors.push('Username can only contain letters, numbers, and underscores');
|
||||
}
|
||||
|
||||
// Real name check
|
||||
if (looksLikeRealName(username)) {
|
||||
errors.push('Please use a fun gaming name instead of a real name. Your privacy matters!');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
suggestions: errors.length > 0 ? generateUsernameSuggestions(5) : []
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateUsername,
|
||||
generateUsernameSuggestions,
|
||||
looksLikeRealName,
|
||||
validateUsername
|
||||
};
|
||||
Reference in New Issue
Block a user