diff --git a/client/src/services/socket.service.ts b/client/src/services/socket.service.ts index 967232e..978d90e 100644 --- a/client/src/services/socket.service.ts +++ b/client/src/services/socket.service.ts @@ -17,12 +17,12 @@ class SocketService { this.socket?.on('PIXEL_UPDATE', cb); } - emitDraw(payload: { lobbyId: string; x: number; y: number; color: number }) { + emitDraw(payload: { lobbyName: string; x: number; y: number; color: number }) { this.socket?.emit('DRAW', payload); } - emitJoinLobby(lobbyId: string) { - this.socket?.emit('JOIN_LOBBY', lobbyId); + emitJoinLobby(lobbyName: string) { + this.socket?.emit('JOIN_LOBBY', lobbyName); } } diff --git a/client/src/stores/editor.store.ts b/client/src/stores/editor.store.ts index 84e1d44..ce20fb5 100644 --- a/client/src/stores/editor.store.ts +++ b/client/src/stores/editor.store.ts @@ -68,7 +68,7 @@ export const useEditorStore = defineStore('editor', () => { // Notify Server socketService.emitDraw({ - lobbyId: 'default', + lobbyName: 'Default Lobby', x, y, color: selectedColorIndex.value @@ -85,7 +85,7 @@ export const useEditorStore = defineStore('editor', () => { function init() { socketService.connect(); - socketService.emitJoinLobby('default'); + socketService.emitJoinLobby('Default Lobby'); isConnected.value = true; // Logic to bind Model events to ViewModel state diff --git a/server/src/config.ts b/server/src/config.ts index 7c85851..540254c 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,6 +1,12 @@ export const CONFIG = { // Server settings PORT: process.env.PORT || 3000, + MONGO_URI: process.env.MONGO_URI || "mongodb://localhost:27017/pixie", + CLIENT_ORIGIN: process.env.CLIENT_ORIGIN || "http://localhost:5173", + JWT: { + SECRET: process.env.JWT_SECRET || "dev-key", + EXPIRES_IN: "7d", + }, // Game Logic settings CANVAS: { diff --git a/server/src/controllers/lobby.controller.ts b/server/src/controllers/lobby.controller.ts new file mode 100644 index 0000000..843fbc9 --- /dev/null +++ b/server/src/controllers/lobby.controller.ts @@ -0,0 +1,40 @@ +import { Request, Response } from 'express'; +import { LobbyService } from '../services/lobby.service.js'; + +export class LobbyController { + + // POST /api/lobbies + static async create(req: Request, res: Response) { + try { + const { name, ownerId } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Lobby name is required' }); + } + + const lobby = await LobbyService.create(name, ownerId); + + return res.status(201).json(lobby); + + } catch (error: any) { + // Duplicate name + if (error.code === 11000) { + return res.status(409).json({ error: 'Lobby name already taken' }); + } + + console.error('[LobbyController] Create Error:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } + } + + // GET /api/lobbies + static async getAll(req: Request, res: Response) { + try { + const lobbies = await LobbyService.getAll(); + return res.json(lobbies); + } catch (error) { + console.error('[LobbyController] GetAll Error:', error); + return res.status(500).json({ error: 'Internal Server Error' }); + } + } +} \ No newline at end of file diff --git a/server/src/db/connect.ts b/server/src/db/connect.ts index f447615..fbd0339 100644 --- a/server/src/db/connect.ts +++ b/server/src/db/connect.ts @@ -1,7 +1,8 @@ import mongoose from "mongoose" -import { seedUsers } from "./seed.js" +import { seedUsers, seedLobbies } from "./seed.js" -const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017/pixie" +import { CONFIG } from "../config.js" +const MONGO_URI = CONFIG.MONGO_URI export const connectDB = async (): Promise => { try { @@ -16,6 +17,7 @@ export const connectDB = async (): Promise => { console.log("[INFO] MongoDB is ready") await seedUsers() + await seedLobbies() } catch (err) { const error = err as Error console.error("[ERROR] MongoDB Connection Error:", error.message) diff --git a/server/src/db/seed.ts b/server/src/db/seed.ts index 1c44110..899e301 100644 --- a/server/src/db/seed.ts +++ b/server/src/db/seed.ts @@ -1,4 +1,7 @@ import { User } from "../models/User.js" +import { Lobby, ILobby } from "../models/Lobby.js" +import { Canvas } from "../models/Canvas.js" +import { CONFIG } from "../config.js" export const seedUsers = async (): Promise => { const users = [ @@ -20,3 +23,45 @@ export const seedUsers = async (): Promise => { console.error("Seed failed:", error) } } + +export const seedLobbies = async (): Promise => { + try { + console.log("Checking lobby seeds...") + const lobbyName = "Default Lobby" + let lobby: ILobby | null = await Lobby.findOne({ name: lobbyName }) + + if (!lobby) { + console.log(`Creating '${lobbyName}'...`) + lobby = await Lobby.createWithCanvas(lobbyName) + } else { + console.log(`'${lobbyName}' already exists`) + } + + if (lobby) { + // Get the canvas to check/draw pixels + const canvas = await Canvas.findById(lobby.canvas) + if (canvas) { + const hasContent = canvas.data.some((pixel) => pixel !== 0) + + if (!hasContent) { + console.log(`Canvas for '${lobbyName}' is empty. Seeding pattern...`) + // Draw a red diagonal line + const width = CONFIG.CANVAS.WIDTH + const height = CONFIG.CANVAS.HEIGHT + const color = 4 // Red + + for (let i = 0; i < Math.min(width, height); i++) { + const index = i * width + i + canvas.data[index] = color + } + + canvas.markModified("data") + await canvas.save() + console.log(`Seeded '${lobbyName}' with a diagonal pattern`) + } + } + } + } catch (error) { + console.error("Lobby seed failed:", error) + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 33ec6f2..0419af8 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,7 +7,7 @@ import router from "./routes/index.js"; import { setupSocket } from "./sockets/index.js"; // Import the Socket Manager import { CONFIG } from "./config.js"; -const PORT = process.env.PORT || 3000; +const PORT = CONFIG.PORT; // Configuration const app = express(); @@ -18,7 +18,7 @@ const httpServer = createServer(app); // Middleware app.use( cors({ - origin: "*", + origin: CONFIG.CLIENT_ORIGIN, methods: ["GET", "POST", "PUT", "DELETE"], allowedHeaders: ["Content-Type", "Authorization"], }) @@ -45,7 +45,7 @@ app.use(errorHandler); // --- SOCKET.IO SETUP (Real-time) --- const io = new Server(httpServer, { cors: { - origin: "*", // Allow all origins for development + origin: CONFIG.CLIENT_ORIGIN, // Allow all origins for development methods: ["GET", "POST"] } }); diff --git a/server/src/models/Canvas.ts b/server/src/models/Canvas.ts new file mode 100644 index 0000000..81ced49 --- /dev/null +++ b/server/src/models/Canvas.ts @@ -0,0 +1,20 @@ +import { Schema, model, Document, Types } from 'mongoose'; +import { CONFIG } from '../config.js'; + +export interface ICanvas extends Document { + lobby: Types.ObjectId; // Back-link to Lobby (useful for maintenance) + width: number; + height: number; + data: Buffer; + lastModified: Date; +} + +const canvasSchema = new Schema({ + lobby: { type: Schema.Types.ObjectId, ref: 'Lobby', required: true }, + width: { type: Number, default: CONFIG.CANVAS.WIDTH }, + height: { type: Number, default: CONFIG.CANVAS.HEIGHT }, + data: { type: Buffer, required: true }, + lastModified: { type: Date, default: Date.now } +}); + +export const Canvas = model('Canvas', canvasSchema); \ No newline at end of file diff --git a/server/src/models/Lobby.ts b/server/src/models/Lobby.ts new file mode 100644 index 0000000..cd10990 --- /dev/null +++ b/server/src/models/Lobby.ts @@ -0,0 +1,61 @@ +import { Schema, model, Document, Types, Model } from 'mongoose'; +import { Canvas } from './Canvas.js'; +import { CONFIG } from '../config.js'; + +export interface ILobby extends Document { + name: string; + owner?: Types.ObjectId; // Links to your User schema + canvas: Types.ObjectId; // Links to Canvas schema + allowedUsers: Types.ObjectId[]; + bannedUsers: Types.ObjectId[]; + createdAt: Date; + updatedAt: Date; +} + +// Static method interface +interface LobbyModel extends Model { + createWithCanvas(name: string, ownerId?: string): Promise; +} + +const lobbySchema = new Schema({ + name: { + type: String, + required: true, + unique: true, + trim: true + }, + owner: { type: Schema.Types.ObjectId, ref: 'User' }, + canvas: { type: Schema.Types.ObjectId, ref: 'Canvas', required: true }, + allowedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }], + bannedUsers: [{ type: Schema.Types.ObjectId, ref: 'User' }] +}, { timestamps: true }); + +// --- FACTORY METHOD: Creates Lobby + Empty Canvas atomically --- +lobbySchema.statics.createWithCanvas = async function (name: string, ownerId?: string) { + const lobbyId = new Types.ObjectId(); + const canvasId = new Types.ObjectId(); + + // Initialized to 0 (Transparent/White depending on palette) + const emptyBuffer = Buffer.alloc(CONFIG.CANVAS.WIDTH * CONFIG.CANVAS.HEIGHT, 0); + + const canvas = new Canvas({ + _id: canvasId, + lobby: lobbyId, + width: CONFIG.CANVAS.WIDTH, + height: CONFIG.CANVAS.HEIGHT, + data: emptyBuffer + }); + + const lobby = new this({ + _id: lobbyId, + name, + owner: ownerId ? new Types.ObjectId(ownerId) : undefined, + canvas: canvasId + }); + + // Save both + await Promise.all([canvas.save(), lobby.save()]); + return lobby; +}; + +export const Lobby = model('Lobby', lobbySchema); \ No newline at end of file diff --git a/server/src/routes/api.ts b/server/src/routes/api.ts index 3120b1d..1c0b589 100644 --- a/server/src/routes/api.ts +++ b/server/src/routes/api.ts @@ -2,6 +2,7 @@ import express, { Router } from "express" import { LoginController } from "../controllers/LoginController.js" import { UserController } from "../controllers/UserController.js" import { authenticateToken } from "../middlewares/authMiddleware.js" +import { LobbyController } from "../controllers/lobby.controller.js" const router: Router = express.Router() @@ -13,4 +14,8 @@ router.get("/users", authenticateToken, UserController.getAll) // Test Error +// Lobbies +router.post("/lobbies", LobbyController.create) +router.get("/lobbies", LobbyController.getAll) + export default router diff --git a/server/src/services/UserService.ts b/server/src/services/UserService.ts index 2fcc21a..1faf0aa 100644 --- a/server/src/services/UserService.ts +++ b/server/src/services/UserService.ts @@ -1,5 +1,6 @@ import jwt from "jsonwebtoken" import { User } from "../models/User.js" +import { CONFIG } from "../config.js" interface LoginResponse { username: string @@ -19,7 +20,7 @@ export class UserService { * Handle user login/registration and token generation */ static async login(username: string): Promise { - const JWT_SECRET = process.env.JWT_SECRET || "dev-key" + const JWT_SECRET = CONFIG.JWT.SECRET let user = await User.findOne({ username }) let isNewUser = false @@ -29,7 +30,7 @@ export class UserService { isNewUser = true } - const token = jwt.sign({ id: user._id, username: user.username }, JWT_SECRET, { expiresIn: "7d" }) + const token = jwt.sign({ id: user._id, username: user.username }, JWT_SECRET, { expiresIn: CONFIG.JWT.EXPIRES_IN as any }) return { username: user.username, diff --git a/server/src/services/canvas.service.ts b/server/src/services/canvas.service.ts index 1c906ae..4ceb756 100644 --- a/server/src/services/canvas.service.ts +++ b/server/src/services/canvas.service.ts @@ -1,31 +1,68 @@ -import { lobbyStore } from '../store/lobby.store.js'; +import { canvasStore } from '../store/canvas.store.js'; +import { Lobby } from '../models/Lobby.js'; +import { Canvas } from '../models/Canvas.js'; import { CONFIG } from '../config.js'; export class CanvasService { - static getLobbyState(lobbyId: string): Uint8Array { - return lobbyStore.getLobbyBuffer(lobbyId); - } + // Request Coalescing: Track pending loads to prevent duplicate DB fetches + private static pendingLoads: Map> = new Map(); - static drawPixel(lobbyId: string, x: number, y: number, color: number) { - if (x < 0 || x >= CONFIG.CANVAS.WIDTH || y < 0 || y >= CONFIG.CANVAS.HEIGHT) { - console.warn(`[CanvasService] Out of bounds draw attempt: ${x}, ${y}`); - return null; + // Retrieves pixel state from memory, loading from DB if necessary + static async getState(lobbyName: string): Promise { + // 1. Fast Path: Already in memory + if (canvasStore.isLobbyInMemory(lobbyName)) { + return canvasStore.getLobbyPixelData(lobbyName)!; } - if (color < 0 || color > CONFIG.MAX_COLOR_ID) { - console.warn(`[CanvasService] Invalid color index: ${color}`); - return null; + // 2. Coalescing Path: Already loading, wait for existing promise + if (this.pendingLoads.has(lobbyName)) { + return this.pendingLoads.get(lobbyName)!; } - const index = y * CONFIG.CANVAS.WIDTH + x; + // 3. Slow Path: Fetch from DB + const loadPromise = (async () => { + try { + const lobby = await Lobby.findOne({ name: lobbyName }); + if (!lobby) throw new Error(`Lobby '${lobbyName}' not found`); + + const canvas = await Canvas.findById(lobby.canvas); + if (!canvas) throw new Error(`Canvas data missing for lobby '${lobbyName}'`); - const hasChanged = lobbyStore.setPixel(lobbyId, index, color); + return canvasStore.loadLobbyToMemory(lobbyName, canvas.data); + } finally { + // Cleanup promise when done (success or failure) + this.pendingLoads.delete(lobbyName); + } + })(); - if (hasChanged) { - return { x, y, color }; + this.pendingLoads.set(lobbyName, loadPromise); + return loadPromise; + } + + // Updates a pixel in memory if coordinates are valid + static draw(lobbyName: string, x: number, y: number, color: number) { + if (x < 0 || x >= CONFIG.CANVAS.WIDTH || y < 0 || y >= CONFIG.CANVAS.HEIGHT) { + return false; } - return null; + const index = y * CONFIG.CANVAS.WIDTH + x; + return canvasStore.modifyPixelColor(lobbyName, index, color); + } + + // Persists the current in-memory state to the database + static async saveToDB(lobbyName: string) { + const memoryBuffer = canvasStore.getLobbyPixelData(lobbyName); + if (!memoryBuffer) return; + + const lobby = await Lobby.findOne({ name: lobbyName }); + if (!lobby) return; + + await Canvas.findByIdAndUpdate(lobby.canvas, { + data: Buffer.from(memoryBuffer), + lastModified: new Date() + }); + + console.log(`[CanvasService] Saved lobby '${lobbyName}' to DB`); } } \ No newline at end of file diff --git a/server/src/services/lobby.service.ts b/server/src/services/lobby.service.ts new file mode 100644 index 0000000..d14e2e9 --- /dev/null +++ b/server/src/services/lobby.service.ts @@ -0,0 +1,21 @@ +import { Lobby } from '../models/Lobby.js'; + +export class LobbyService { + + static async create(name: string, ownerId?: string) { + // Uses the Factory Method defined in the Model + return await Lobby.createWithCanvas(name, ownerId); + } + + static async getAll() { + // Returns list with owner info, sorted by newest + return await Lobby.find() + .select('name owner createdAt allowedUsers') + .populate('owner', 'username') + .sort({ createdAt: -1 }); + } + + static async getByName(name: string) { + return await Lobby.findOne({ name }).populate('owner', 'username'); + } +} \ No newline at end of file diff --git a/server/src/sockets/index.ts b/server/src/sockets/index.ts index 9f8afa8..52744fb 100644 --- a/server/src/sockets/index.ts +++ b/server/src/sockets/index.ts @@ -8,34 +8,39 @@ export const setupSocket = (io: Server) => { // --- EVENT: JOIN_LOBBY --- // User requests to enter a specific room - socket.on(CONFIG.EVENTS.CLIENT.JOIN_LOBBY, (lobbyId: string) => { - console.log(`[Socket] ${socket.id} joining lobby: ${lobbyId}`); + socket.on(CONFIG.EVENTS.CLIENT.JOIN_LOBBY, async (lobbyName: string) => { + console.log(`[Socket] ${socket.id} joining lobby: ${lobbyName}`); // 1. Join the Socket.io room channel - socket.join(lobbyId); + socket.join(lobbyName); - // 2. Get current state from Service - const state = CanvasService.getLobbyState(lobbyId); + try { + // 2. Get current state from Service + const state = await CanvasService.getState(lobbyName); - // 3. Send state back to the user - socket.emit(CONFIG.EVENTS.SERVER.INIT_STATE, state); + // 3. Send state back to the user + socket.emit(CONFIG.EVENTS.SERVER.INIT_STATE, state); + } catch (error) { + console.error(`[Socket] Error joining lobby ${lobbyName}:`, error); + socket.emit(CONFIG.EVENTS.SERVER.ERROR, { message: "Failed to join lobby" }); + } }); // --- EVENT: DRAW --- // User wants to color a pixel socket.on(CONFIG.EVENTS.CLIENT.DRAW, (payload: any) => { // Payload validation could happen here or in a DTO - const { lobbyId, x, y, color } = payload; + const { lobbyName, x, y, color } = payload; - if (!lobbyId) return; + if (!lobbyName) return; // 1. Process Logic via Service - const result = CanvasService.drawPixel(lobbyId, x, y, color); + const success = CanvasService.draw(lobbyName, x, y, color); // 2. Broadcast if successful - if (result) { + if (success) { // Send to everyone in the room EXCEPT the sender - socket.to(lobbyId).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE, result); + socket.to(lobbyName).emit(CONFIG.EVENTS.SERVER.PIXEL_UPDATE, { x, y, color }); } }); diff --git a/server/src/store/canvas.store.ts b/server/src/store/canvas.store.ts new file mode 100644 index 0000000..b0ebf3f --- /dev/null +++ b/server/src/store/canvas.store.ts @@ -0,0 +1,42 @@ +import { CONFIG } from '../config.js'; + +export class CanvasStore { + private buffers: Map = new Map(); + + public isLobbyInMemory(lobbyName: string): boolean { + return this.buffers.has(lobbyName); + } + + public getLobbyPixelData(lobbyName: string): Uint8Array | undefined { + return this.buffers.get(lobbyName); + } + + // Load data from DB buffer to RAM Uint8Array + public loadLobbyToMemory(lobbyName: string, data: Buffer | Uint8Array): Uint8Array { + console.log(`[CanvasStore] Loading lobby: ${lobbyName}`); + const memoryBuffer = new Uint8Array(data); + this.buffers.set(lobbyName, memoryBuffer); + return memoryBuffer; + } + + public modifyPixelColor(lobbyName: string, index: number, color: number): boolean { + const buffer = this.buffers.get(lobbyName); + if (!buffer) return false; + + // Boundary check + if (index < 0 || index >= buffer.length) return false; + + // Optimization: only update if value changed + if (buffer[index] !== color) { + buffer[index] = color; + return true; + } + return false; + } + + public getInMemoryLobbyIds(): string[] { + return Array.from(this.buffers.keys()); + } +} + +export const canvasStore = new CanvasStore(); \ No newline at end of file