Initial commit

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