diff --git a/backend/src/controllers/PyramidController.cpp b/backend/src/controllers/PyramidController.cpp new file mode 100644 index 0000000..2976afc --- /dev/null +++ b/backend/src/controllers/PyramidController.cpp @@ -0,0 +1,779 @@ +#include "PyramidController.h" +#include "../services/DatabaseService.h" +#include "../common/HttpHelpers.h" +#include "../common/AuthHelpers.h" +#include +#include +#include + +using namespace drogon::orm; + +// Static members for WebSocket controller +std::unordered_map PyramidWebSocketController::connections_; +std::mutex PyramidWebSocketController::connectionsMutex_; + +// ==================== Validation Helpers ==================== + +bool PyramidController::isValidPixelPosition(int faceId, int x, int y) { + // Face ID must be 0-4 + if (faceId < 0 || faceId > 4) return false; + + // X and Y must be within face bounds + if (x < 0 || x >= FACE_SIZE || y < 0 || y >= FACE_SIZE) return false; + + // For triangular faces (1-4), validate within triangle bounds + if (faceId > 0) { + // Triangle with base at bottom (y=199), apex at top (y=0) + // At y=0, only x=99,100 are valid (center) + // At y=199, x=0-199 are valid (full width) + int halfWidth = (y + 1) / 2; + int center = FACE_SIZE / 2; + int minX = center - halfWidth; + int maxX = center + halfWidth; + if (x < minX || x > maxX) return false; + } + + return true; +} + +bool PyramidController::isValidColor(const std::string &color) { + // Must be a valid hex color like #RRGGBB + if (color.length() != 7 || color[0] != '#') return false; + + for (int i = 1; i < 7; i++) { + char c = std::toupper(color[i]); + if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F'))) { + return false; + } + } + return true; +} + +// ==================== Canvas State Methods ==================== + +void PyramidController::getState(const HttpRequestPtr &req, + std::function &&callback) { + try { + auto dbClient = app().getDbClient(); + + // Get all pixels grouped by face + *dbClient << "SELECT face_id, x, y, color FROM pyramid_pixels ORDER BY face_id, y, x" + >> [callback](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["pixels"] = Json::Value(Json::arrayValue); + + for (const auto& row : r) { + Json::Value pixel; + pixel["faceId"] = row["face_id"].as(); + pixel["x"] = row["x"].as(); + pixel["y"] = row["y"].as(); + pixel["color"] = row["color"].as(); + resp["pixels"].append(pixel); + } + + resp["totalPixels"] = static_cast(r.size()); + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get pyramid state"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getState: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +void PyramidController::getFaceState(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceIdStr) { + try { + PARSE_ID(faceId, faceIdStr, callback); + + if (faceId < 0 || faceId > 4) { + callback(jsonError("Invalid face ID (must be 0-4)")); + return; + } + + auto dbClient = app().getDbClient(); + *dbClient << "SELECT x, y, color FROM pyramid_pixels WHERE face_id = $1 ORDER BY y, x" + << static_cast(faceId) + >> [callback, faceId](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["faceId"] = static_cast(faceId); + resp["pixels"] = Json::Value(Json::arrayValue); + + for (const auto& row : r) { + Json::Value pixel; + pixel["x"] = row["x"].as(); + pixel["y"] = row["y"].as(); + pixel["color"] = row["color"].as(); + resp["pixels"].append(pixel); + } + + resp["totalPixels"] = static_cast(r.size()); + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get face state"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getFaceState: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +void PyramidController::getColors(const HttpRequestPtr &req, + std::function &&callback) { + try { + auto dbClient = app().getDbClient(); + *dbClient << "SELECT color, name FROM pyramid_colors WHERE is_active = true ORDER BY sort_order" + >> [callback](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["colors"] = Json::Value(Json::arrayValue); + + for (const auto& row : r) { + Json::Value color; + color["color"] = row["color"].as(); + color["name"] = row["name"].isNull() ? "" : row["name"].as(); + resp["colors"].append(color); + } + + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get colors"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getColors: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +// ==================== Pixel Operation Methods ==================== + +void PyramidController::placePixel(const HttpRequestPtr &req, + std::function &&callback) { + try { + UserInfo user = getUserFromRequest(req); + CHECK_AUTH(user, callback); + + auto json = req->getJsonObject(); + if (!json) { + callback(jsonError("Invalid JSON body")); + return; + } + + // Validate required fields + if (!json->isMember("faceId") || !json->isMember("x") || + !json->isMember("y") || !json->isMember("color")) { + callback(jsonError("Missing required fields: faceId, x, y, color")); + return; + } + + int faceId = (*json)["faceId"].asInt(); + int x = (*json)["x"].asInt(); + int y = (*json)["y"].asInt(); + std::string color = (*json)["color"].asString(); + + // Validate pixel position + if (!isValidPixelPosition(faceId, x, y)) { + callback(jsonError("Invalid pixel position")); + return; + } + + // Validate color format + if (!isValidColor(color)) { + callback(jsonError("Invalid color format (use #RRGGBB)")); + return; + } + + // Uppercase the color for consistency + std::transform(color.begin(), color.end(), color.begin(), ::toupper); + + auto dbClient = app().getDbClient(); + int64_t userId = user.id; + std::string username = user.username; + + // Check daily limit using upsert pattern + *dbClient << R"( + INSERT INTO pyramid_daily_limits (user_id, date, pixels_placed) + VALUES ($1, CURRENT_DATE, 1) + ON CONFLICT (user_id, date) + DO UPDATE SET pixels_placed = pyramid_daily_limits.pixels_placed + 1 + WHERE pyramid_daily_limits.pixels_placed < $2 + RETURNING pixels_placed + )" + << userId << DAILY_PIXEL_LIMIT + >> [callback, dbClient, userId, username, faceId, x, y, color](const Result& r) { + if (r.empty()) { + // Daily limit reached + callback(jsonError("Daily pixel limit reached (1000/day)")); + return; + } + + int pixelsPlaced = r[0]["pixels_placed"].as(); + int remaining = PyramidController::DAILY_PIXEL_LIMIT - pixelsPlaced; + + // Insert/update the pixel + *dbClient << R"( + INSERT INTO pyramid_pixels (face_id, x, y, color, placed_by, placed_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + ON CONFLICT (face_id, x, y) + DO UPDATE SET color = $4, placed_by = $5, placed_at = CURRENT_TIMESTAMP + )" + << faceId << x << y << color << userId + >> [callback, dbClient, userId, username, faceId, x, y, color, remaining](const Result&) { + // Record in history + *dbClient << R"( + INSERT INTO pyramid_pixel_history (face_id, x, y, color, placed_by, placed_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + )" + << faceId << x << y << color << userId + >> [callback, userId, username, faceId, x, y, color, remaining](const Result&) { + // Broadcast to WebSocket clients + PyramidWebSocketController::broadcastPixelUpdate( + faceId, x, y, color, userId, username); + + Json::Value resp; + resp["success"] = true; + resp["remainingToday"] = remaining; + resp["pixel"]["faceId"] = faceId; + resp["pixel"]["x"] = x; + resp["pixel"]["y"] = y; + resp["pixel"]["color"] = color; + resp["pixel"]["placedBy"] = username; + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "record pixel history"); + } + >> DB_ERROR(callback, "place pixel"); + } + >> DB_ERROR(callback, "check daily limit"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in placePixel: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +void PyramidController::getPixelInfo(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceIdStr, + const std::string &xStr, + const std::string &yStr) { + try { + PARSE_ID(faceId, faceIdStr, callback); + PARSE_ID(x, xStr, callback); + PARSE_ID(y, yStr, callback); + + if (!isValidPixelPosition(static_cast(faceId), static_cast(x), static_cast(y))) { + callback(jsonError("Invalid pixel position")); + return; + } + + auto dbClient = app().getDbClient(); + *dbClient << R"( + SELECT p.color, p.placed_at, u.id as user_id, u.username, u.user_color, u.avatar_url + FROM pyramid_pixels p + JOIN users u ON p.placed_by = u.id + WHERE p.face_id = $1 AND p.x = $2 AND p.y = $3 + )" + << static_cast(faceId) << static_cast(x) << static_cast(y) + >> [callback, faceId, x, y](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["faceId"] = static_cast(faceId); + resp["x"] = static_cast(x); + resp["y"] = static_cast(y); + + if (r.empty()) { + resp["isEmpty"] = true; + } else { + const auto& row = r[0]; + resp["isEmpty"] = false; + resp["color"] = row["color"].as(); + resp["placedAt"] = row["placed_at"].as(); + resp["user"]["id"] = static_cast(row["user_id"].as()); + resp["user"]["username"] = row["username"].as(); + resp["user"]["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as(); + resp["user"]["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + } + + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get pixel info"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getPixelInfo: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +// ==================== User Limits Methods ==================== + +void PyramidController::getLimits(const HttpRequestPtr &req, + std::function &&callback) { + try { + UserInfo user = getUserFromRequest(req); + CHECK_AUTH(user, callback); + + auto dbClient = app().getDbClient(); + *dbClient << "SELECT pixels_placed FROM pyramid_daily_limits WHERE user_id = $1 AND date = CURRENT_DATE" + << user.id + >> [callback](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["dailyLimit"] = PyramidController::DAILY_PIXEL_LIMIT; + + if (r.empty()) { + resp["pixelsPlacedToday"] = 0; + resp["remainingToday"] = PyramidController::DAILY_PIXEL_LIMIT; + } else { + int placed = r[0]["pixels_placed"].as(); + resp["pixelsPlacedToday"] = placed; + resp["remainingToday"] = PyramidController::DAILY_PIXEL_LIMIT - placed; + } + + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get limits"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getLimits: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +// ==================== Moderation Methods ==================== + +void PyramidController::rollbackPixel(const HttpRequestPtr &req, + std::function &&callback) { + try { + UserInfo user = getUserFromRequest(req); + CHECK_AUTH(user, callback); + + // Only admins and moderators can rollback + if (!user.isAdmin && !user.isModerator) { + callback(jsonError("Moderator access required", k403Forbidden)); + return; + } + + auto json = req->getJsonObject(); + if (!json) { + callback(jsonError("Invalid JSON body")); + return; + } + + if (!json->isMember("faceId") || !json->isMember("x") || !json->isMember("y")) { + callback(jsonError("Missing required fields: faceId, x, y")); + return; + } + + int faceId = (*json)["faceId"].asInt(); + int x = (*json)["x"].asInt(); + int y = (*json)["y"].asInt(); + + if (!isValidPixelPosition(faceId, x, y)) { + callback(jsonError("Invalid pixel position")); + return; + } + + auto dbClient = app().getDbClient(); + int64_t moderatorId = user.id; + std::string moderatorName = user.username; + + // Find the previous state (second most recent non-rolled-back entry) + *dbClient << R"( + SELECT id, color, placed_by FROM pyramid_pixel_history + WHERE face_id = $1 AND x = $2 AND y = $3 AND rolled_back = false + ORDER BY placed_at DESC + LIMIT 2 + )" + << faceId << x << y + >> [callback, dbClient, moderatorId, moderatorName, faceId, x, y](const Result& r) { + if (r.size() < 1) { + callback(jsonError("No pixel found at this position")); + return; + } + + int64_t currentHistoryId = r[0]["id"].as(); + + if (r.size() < 2) { + // No previous state, delete the pixel entirely + *dbClient << "DELETE FROM pyramid_pixels WHERE face_id = $1 AND x = $2 AND y = $3" + << faceId << x << y + >> [callback, dbClient, currentHistoryId, moderatorId, moderatorName, faceId, x, y](const Result&) { + // Mark as rolled back + *dbClient << R"( + UPDATE pyramid_pixel_history + SET rolled_back = true, rolled_back_by = $1, rolled_back_at = CURRENT_TIMESTAMP + WHERE id = $2 + )" + << moderatorId << currentHistoryId + >> [callback, moderatorName, faceId, x, y](const Result&) { + // Broadcast rollback (empty means deleted) + PyramidWebSocketController::broadcastRollback( + faceId, x, y, "", moderatorName); + + Json::Value resp; + resp["success"] = true; + resp["message"] = "Pixel deleted (no previous state)"; + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "mark rolled back"); + } + >> DB_ERROR(callback, "delete pixel"); + } else { + // Restore previous state + std::string prevColor = r[1]["color"].as(); + int64_t prevUserId = r[1]["placed_by"].as(); + + *dbClient << R"( + UPDATE pyramid_pixels + SET color = $1, placed_by = $2, placed_at = CURRENT_TIMESTAMP + WHERE face_id = $3 AND x = $4 AND y = $5 + )" + << prevColor << prevUserId << faceId << x << y + >> [callback, dbClient, currentHistoryId, moderatorId, moderatorName, faceId, x, y, prevColor](const Result&) { + // Mark current as rolled back + *dbClient << R"( + UPDATE pyramid_pixel_history + SET rolled_back = true, rolled_back_by = $1, rolled_back_at = CURRENT_TIMESTAMP + WHERE id = $2 + )" + << moderatorId << currentHistoryId + >> [callback, moderatorName, faceId, x, y, prevColor](const Result&) { + // Broadcast rollback + PyramidWebSocketController::broadcastRollback( + faceId, x, y, prevColor, moderatorName); + + Json::Value resp; + resp["success"] = true; + resp["message"] = "Pixel rolled back to previous state"; + resp["newColor"] = prevColor; + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "mark rolled back"); + } + >> DB_ERROR(callback, "restore pixel"); + } + } + >> DB_ERROR(callback, "find pixel history"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in rollbackPixel: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +void PyramidController::getPixelHistory(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceIdStr, + const std::string &xStr, + const std::string &yStr) { + try { + UserInfo user = getUserFromRequest(req); + CHECK_AUTH(user, callback); + + // Only admins and moderators can view full history + if (!user.isAdmin && !user.isModerator) { + callback(jsonError("Moderator access required", k403Forbidden)); + return; + } + + PARSE_ID(faceId, faceIdStr, callback); + PARSE_ID(x, xStr, callback); + PARSE_ID(y, yStr, callback); + + if (!isValidPixelPosition(static_cast(faceId), static_cast(x), static_cast(y))) { + callback(jsonError("Invalid pixel position")); + return; + } + + auto dbClient = app().getDbClient(); + *dbClient << R"( + SELECT h.id, h.color, h.placed_at, h.rolled_back, h.rolled_back_at, + u.id as user_id, u.username, u.user_color, + rb.username as rolled_back_by_username + FROM pyramid_pixel_history h + JOIN users u ON h.placed_by = u.id + LEFT JOIN users rb ON h.rolled_back_by = rb.id + WHERE h.face_id = $1 AND h.x = $2 AND h.y = $3 + ORDER BY h.placed_at DESC + LIMIT 50 + )" + << static_cast(faceId) << static_cast(x) << static_cast(y) + >> [callback, faceId, x, y](const Result& r) { + Json::Value resp; + resp["success"] = true; + resp["faceId"] = static_cast(faceId); + resp["x"] = static_cast(x); + resp["y"] = static_cast(y); + resp["history"] = Json::Value(Json::arrayValue); + + for (const auto& row : r) { + Json::Value entry; + entry["id"] = static_cast(row["id"].as()); + entry["color"] = row["color"].as(); + entry["placedAt"] = row["placed_at"].as(); + entry["rolledBack"] = row["rolled_back"].as(); + entry["userId"] = static_cast(row["user_id"].as()); + entry["username"] = row["username"].as(); + entry["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as(); + + if (row["rolled_back"].as()) { + entry["rolledBackAt"] = row["rolled_back_at"].as(); + entry["rolledBackBy"] = row["rolled_back_by_username"].isNull() ? "" : row["rolled_back_by_username"].as(); + } + + resp["history"].append(entry); + } + + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get pixel history"); + } catch (const std::exception& e) { + LOG_ERROR << "Exception in getPixelHistory: " << e.what(); + callback(jsonError("Internal server error")); + } +} + +// ==================== WebSocket Controller ==================== + +void PyramidWebSocketController::handleNewConnection(const HttpRequestPtr &req, + const WebSocketConnectionPtr &wsConnPtr) { + std::lock_guard lock(connectionsMutex_); + connections_[wsConnPtr] = ConnectionInfo{}; + + // Send welcome message + Json::Value welcome; + welcome["type"] = "welcome"; + welcome["message"] = "Connected to Pyramid World"; + + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, welcome)); + + LOG_INFO << "Pyramid WebSocket connection established"; +} + +void PyramidWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr &wsConnPtr) { + std::lock_guard lock(connectionsMutex_); + connections_.erase(wsConnPtr); + LOG_INFO << "Pyramid WebSocket connection closed"; +} + +void PyramidWebSocketController::handleNewMessage(const WebSocketConnectionPtr &wsConnPtr, + std::string &&message, + const WebSocketMessageType &type) { + if (type != WebSocketMessageType::Text) { + return; + } + + try { + Json::Value msg; + Json::CharReaderBuilder builder; + std::istringstream stream(message); + std::string errors; + + if (!Json::parseFromStream(builder, stream, &msg, &errors)) { + LOG_ERROR << "Failed to parse WebSocket message: " << errors; + return; + } + + std::string msgType = msg["type"].asString(); + + if (msgType == "auth") { + handleAuth(wsConnPtr, msg); + } else if (msgType == "place_pixel") { + handlePlacePixel(wsConnPtr, msg); + } + } catch (const std::exception& e) { + LOG_ERROR << "Error handling WebSocket message: " << e.what(); + } +} + +void PyramidWebSocketController::handleAuth(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg) { + std::string token = msg["token"].asString(); + + if (token.empty()) { + Json::Value error; + error["type"] = "error"; + error["message"] = "No token provided"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + return; + } + + UserInfo user; + if (!AuthService::getInstance().validateToken(token, user) || user.id == 0) { + Json::Value error; + error["type"] = "error"; + error["message"] = "Invalid token"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + return; + } + + { + std::lock_guard lock(connectionsMutex_); + auto it = connections_.find(wsConnPtr); + if (it != connections_.end()) { + it->second.userId = user.id; + it->second.username = user.username; + it->second.isAuthenticated = true; + it->second.isModerator = user.isModerator; + it->second.isAdmin = user.isAdmin; + } + } + + // Get remaining pixels for today + auto dbClient = drogon::app().getDbClient(); + *dbClient << "SELECT pixels_placed FROM pyramid_daily_limits WHERE user_id = $1 AND date = CURRENT_DATE" + << user.id + >> [wsConnPtr](const Result& r) { + int remaining = 1000; + if (!r.empty()) { + remaining = 1000 - r[0]["pixels_placed"].as(); + } + + Json::Value authSuccess; + authSuccess["type"] = "auth_success"; + authSuccess["remainingPixels"] = remaining; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, authSuccess)); + } + >> [](const DrogonDbException& e) { + LOG_ERROR << "Failed to get daily limit: " << e.base().what(); + }; +} + +void PyramidWebSocketController::handlePlacePixel(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg) { + ConnectionInfo connInfo; + { + std::lock_guard lock(connectionsMutex_); + auto it = connections_.find(wsConnPtr); + if (it == connections_.end() || !it->second.isAuthenticated) { + Json::Value error; + error["type"] = "error"; + error["message"] = "Not authenticated"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + return; + } + connInfo = it->second; + } + + int faceId = msg["faceId"].asInt(); + int x = msg["x"].asInt(); + int y = msg["y"].asInt(); + std::string color = msg["color"].asString(); + + // Validate and uppercase color + std::transform(color.begin(), color.end(), color.begin(), ::toupper); + + // Use the REST endpoint logic for actual placement + // This is a simplified version - in production, consider calling the same code + auto dbClient = drogon::app().getDbClient(); + + *dbClient << R"( + INSERT INTO pyramid_daily_limits (user_id, date, pixels_placed) + VALUES ($1, CURRENT_DATE, 1) + ON CONFLICT (user_id, date) + DO UPDATE SET pixels_placed = pyramid_daily_limits.pixels_placed + 1 + WHERE pyramid_daily_limits.pixels_placed < 1000 + RETURNING pixels_placed + )" + << connInfo.userId << 1000 + >> [wsConnPtr, dbClient, connInfo, faceId, x, y, color](const Result& r) { + if (r.empty()) { + Json::Value error; + error["type"] = "error"; + error["message"] = "Daily limit reached"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + return; + } + + int remaining = 1000 - r[0]["pixels_placed"].as(); + + *dbClient << R"( + INSERT INTO pyramid_pixels (face_id, x, y, color, placed_by, placed_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + ON CONFLICT (face_id, x, y) + DO UPDATE SET color = $4, placed_by = $5, placed_at = CURRENT_TIMESTAMP + )" + << faceId << x << y << color << connInfo.userId + >> [wsConnPtr, dbClient, connInfo, faceId, x, y, color, remaining](const Result&) { + *dbClient << R"( + INSERT INTO pyramid_pixel_history (face_id, x, y, color, placed_by, placed_at) + VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) + )" + << faceId << x << y << color << connInfo.userId + >> [wsConnPtr, connInfo, faceId, x, y, color, remaining](const Result&) { + // Broadcast to all clients + broadcastPixelUpdate(faceId, x, y, color, connInfo.userId, connInfo.username); + + // Send confirmation to the placing client + Json::Value confirm; + confirm["type"] = "pixel_placed"; + confirm["remainingPixels"] = remaining; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, confirm)); + } + >> [](const DrogonDbException& e) { + LOG_ERROR << "Failed to record history: " << e.base().what(); + }; + } + >> [wsConnPtr](const DrogonDbException& e) { + LOG_ERROR << "Failed to place pixel: " << e.base().what(); + Json::Value error; + error["type"] = "error"; + error["message"] = "Failed to place pixel"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + }; + } + >> [wsConnPtr](const DrogonDbException& e) { + LOG_ERROR << "Failed to check limit: " << e.base().what(); + Json::Value error; + error["type"] = "error"; + error["message"] = "Database error"; + Json::StreamWriterBuilder builder; + wsConnPtr->send(Json::writeString(builder, error)); + }; +} + +void PyramidWebSocketController::broadcastPixelUpdate(int faceId, int x, int y, + const std::string &color, + int64_t userId, + const std::string &username) { + Json::Value msg; + msg["type"] = "pixel_update"; + msg["faceId"] = faceId; + msg["x"] = x; + msg["y"] = y; + msg["color"] = color; + msg["userId"] = static_cast(userId); + msg["username"] = username; + + Json::StreamWriterBuilder builder; + std::string msgStr = Json::writeString(builder, msg); + + std::lock_guard lock(connectionsMutex_); + for (const auto& [conn, info] : connections_) { + conn->send(msgStr); + } +} + +void PyramidWebSocketController::broadcastRollback(int faceId, int x, int y, + const std::string &newColor, + const std::string &moderator) { + Json::Value msg; + msg["type"] = "rollback"; + msg["faceId"] = faceId; + msg["x"] = x; + msg["y"] = y; + msg["newColor"] = newColor; + msg["moderator"] = moderator; + + Json::StreamWriterBuilder builder; + std::string msgStr = Json::writeString(builder, msg); + + std::lock_guard lock(connectionsMutex_); + for (const auto& [conn, info] : connections_) { + conn->send(msgStr); + } +} diff --git a/backend/src/controllers/PyramidController.h b/backend/src/controllers/PyramidController.h new file mode 100644 index 0000000..93d22a3 --- /dev/null +++ b/backend/src/controllers/PyramidController.h @@ -0,0 +1,106 @@ +#pragma once +#include +#include +#include "../services/AuthService.h" + +using namespace drogon; + +class PyramidController : public HttpController { +public: + METHOD_LIST_BEGIN + // Canvas state + ADD_METHOD_TO(PyramidController::getState, "/api/pyramid/state", Get); + ADD_METHOD_TO(PyramidController::getFaceState, "/api/pyramid/face/{1}", Get); + ADD_METHOD_TO(PyramidController::getColors, "/api/pyramid/colors", Get); + + // Pixel operations + ADD_METHOD_TO(PyramidController::placePixel, "/api/pyramid/pixel", Post); + ADD_METHOD_TO(PyramidController::getPixelInfo, "/api/pyramid/pixel/{1}/{2}/{3}", Get); + + // User limits + ADD_METHOD_TO(PyramidController::getLimits, "/api/pyramid/limits", Get); + + // Moderation (admin/moderator only) + ADD_METHOD_TO(PyramidController::rollbackPixel, "/api/pyramid/rollback", Post); + ADD_METHOD_TO(PyramidController::getPixelHistory, "/api/pyramid/history/{1}/{2}/{3}", Get); + METHOD_LIST_END + + // Canvas state methods + void getState(const HttpRequestPtr &req, + std::function &&callback); + void getFaceState(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceId); + void getColors(const HttpRequestPtr &req, + std::function &&callback); + + // Pixel operation methods + void placePixel(const HttpRequestPtr &req, + std::function &&callback); + void getPixelInfo(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceId, + const std::string &x, + const std::string &y); + + // User limits methods + void getLimits(const HttpRequestPtr &req, + std::function &&callback); + + // Moderation methods + void rollbackPixel(const HttpRequestPtr &req, + std::function &&callback); + void getPixelHistory(const HttpRequestPtr &req, + std::function &&callback, + const std::string &faceId, + const std::string &x, + const std::string &y); + +private: + static const int DAILY_PIXEL_LIMIT = 1000; + static const int FACE_SIZE = 200; + + bool isValidPixelPosition(int faceId, int x, int y); + bool isValidColor(const std::string &color); +}; + +// WebSocket controller for real-time pixel updates +class PyramidWebSocketController : public WebSocketController { +public: + void handleNewMessage(const WebSocketConnectionPtr &wsConnPtr, + std::string &&message, + const WebSocketMessageType &type) override; + void handleNewConnection(const HttpRequestPtr &req, + const WebSocketConnectionPtr &wsConnPtr) override; + void handleConnectionClosed(const WebSocketConnectionPtr &wsConnPtr) override; + + // Static method to broadcast pixel updates to all connected clients + static void broadcastPixelUpdate(int faceId, int x, int y, + const std::string &color, + int64_t userId, + const std::string &username); + + // Static method to broadcast rollback to all connected clients + static void broadcastRollback(int faceId, int x, int y, + const std::string &newColor, + const std::string &moderator); + + WS_PATH_LIST_BEGIN + WS_PATH_ADD("/ws/pyramid"); + WS_PATH_LIST_END + +private: + struct ConnectionInfo { + int64_t userId = 0; + std::string username; + bool isAuthenticated = false; + bool isModerator = false; + bool isAdmin = false; + }; + + static std::unordered_map connections_; + static std::mutex connectionsMutex_; + + void handleAuth(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg); + void handlePlacePixel(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg); +}; diff --git a/database/init.sql b/database/init.sql index e28937c..1d1206c 100644 --- a/database/init.sql +++ b/database/init.sql @@ -1290,4 +1290,111 @@ BEGIN ) THEN ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE; END IF; -END $$; \ No newline at end of file +END $$; + +-- ============================================ +-- PYRAMID PIXEL WORLD (Collaborative Canvas) +-- ============================================ + +-- Pyramid world configuration (singleton) +CREATE TABLE IF NOT EXISTS pyramid_world ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + name VARCHAR(100) DEFAULT 'The Pyramid', + is_active BOOLEAN DEFAULT true, + total_pixels_placed BIGINT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default pyramid world row +INSERT INTO pyramid_world (id, name, is_active) +VALUES (1, 'The Pyramid', true) +ON CONFLICT (id) DO NOTHING; + +-- Trigger for pyramid_world updated_at +DROP TRIGGER IF EXISTS update_pyramid_world_updated_at ON pyramid_world; +CREATE TRIGGER update_pyramid_world_updated_at BEFORE UPDATE ON pyramid_world + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Current pixel state (latest placement wins) +-- Face IDs: 0 = Base (square 200x200), 1-4 = North/East/South/West triangular faces +CREATE TABLE IF NOT EXISTS pyramid_pixels ( + id BIGSERIAL PRIMARY KEY, + face_id SMALLINT NOT NULL CHECK (face_id >= 0 AND face_id <= 4), + x SMALLINT NOT NULL CHECK (x >= 0 AND x < 200), + y SMALLINT NOT NULL CHECK (y >= 0 AND y < 200), + color VARCHAR(7) NOT NULL, -- Hex color #RRGGBB + placed_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + placed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE(face_id, x, y) +); + +-- Create indexes for pyramid_pixels +CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_face ON pyramid_pixels(face_id); +CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_coords ON pyramid_pixels(face_id, x, y); +CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_user ON pyramid_pixels(placed_by); +CREATE INDEX IF NOT EXISTS idx_pyramid_pixels_placed_at ON pyramid_pixels(placed_at DESC); + +-- Pixel history (append-only for rollback and lookup) +CREATE TABLE IF NOT EXISTS pyramid_pixel_history ( + id BIGSERIAL PRIMARY KEY, + face_id SMALLINT NOT NULL CHECK (face_id >= 0 AND face_id <= 4), + x SMALLINT NOT NULL CHECK (x >= 0 AND x < 200), + y SMALLINT NOT NULL CHECK (y >= 0 AND y < 200), + color VARCHAR(7) NOT NULL, + placed_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + placed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + rolled_back BOOLEAN DEFAULT false, + rolled_back_by BIGINT REFERENCES users(id), + rolled_back_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for pyramid_pixel_history +CREATE INDEX IF NOT EXISTS idx_pyramid_history_coords ON pyramid_pixel_history(face_id, x, y); +CREATE INDEX IF NOT EXISTS idx_pyramid_history_user ON pyramid_pixel_history(placed_by); +CREATE INDEX IF NOT EXISTS idx_pyramid_history_placed_at ON pyramid_pixel_history(placed_at DESC); +CREATE INDEX IF NOT EXISTS idx_pyramid_history_active ON pyramid_pixel_history(rolled_back) WHERE rolled_back = false; + +-- Daily pixel limits per user (resets at midnight UTC) +CREATE TABLE IF NOT EXISTS pyramid_daily_limits ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date DATE NOT NULL DEFAULT CURRENT_DATE, + pixels_placed INTEGER DEFAULT 0, + UNIQUE(user_id, date) +); + +-- Create index for pyramid_daily_limits +CREATE INDEX IF NOT EXISTS idx_pyramid_daily_user_date ON pyramid_daily_limits(user_id, date); + +-- 16-color palette (r/place style) +CREATE TABLE IF NOT EXISTS pyramid_colors ( + id SERIAL PRIMARY KEY, + color VARCHAR(7) NOT NULL UNIQUE, + name VARCHAR(50), + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true +); + +-- Insert default 16-color palette +INSERT INTO pyramid_colors (color, name, sort_order) VALUES + ('#FFFFFF', 'White', 1), + ('#E4E4E4', 'Light Gray', 2), + ('#888888', 'Gray', 3), + ('#222222', 'Black', 4), + ('#FFA7D1', 'Pink', 5), + ('#E50000', 'Red', 6), + ('#E59500', 'Orange', 7), + ('#A06A42', 'Brown', 8), + ('#E5D900', 'Yellow', 9), + ('#94E044', 'Lime', 10), + ('#02BE01', 'Green', 11), + ('#00D3DD', 'Cyan', 12), + ('#0083C7', 'Blue', 13), + ('#0000EA', 'Dark Blue', 14), + ('#CF6EE4', 'Magenta', 15), + ('#820080', 'Purple', 16) +ON CONFLICT (color) DO NOTHING; + +-- Create index for pyramid_colors +CREATE INDEX IF NOT EXISTS idx_pyramid_colors_sort ON pyramid_colors(sort_order) WHERE is_active = true; \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 85bd0a4..6650a19 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,12 +25,14 @@ "@fingerprintjs/fingerprintjs": "^4.5.1", "chess.js": "^1.0.0-beta.8", "@types/dompurify": "^3.0.5", + "@types/three": "^0.160.0", "dompurify": "^3.3.0", "foliate-js": "^1.0.1", "hls.js": "^1.6.7", "marked": "^17.0.1", "openpgp": "^6.0.0-alpha.0", - "ovenplayer": "^0.10.43" + "ovenplayer": "^0.10.43", + "three": "^0.160.0" }, "type": "module" } diff --git a/frontend/src/lib/components/pyramid/ColorPalette.svelte b/frontend/src/lib/components/pyramid/ColorPalette.svelte new file mode 100644 index 0000000..a058442 --- /dev/null +++ b/frontend/src/lib/components/pyramid/ColorPalette.svelte @@ -0,0 +1,89 @@ + + +
+

Colors

+
+ {#each $colorPalette as colorItem} +
+
+
+ {selected} +
+
+ + diff --git a/frontend/src/lib/components/pyramid/PixelInfo.svelte b/frontend/src/lib/components/pyramid/PixelInfo.svelte new file mode 100644 index 0000000..c72f952 --- /dev/null +++ b/frontend/src/lib/components/pyramid/PixelInfo.svelte @@ -0,0 +1,236 @@ + + +
+

Pixel Info

+ + {#if !selectedPixel} +

Click a pixel to see who placed it

+ {:else if loading} +

Loading...

+ {:else if error} +

{error}

+ {:else if pixelInfo} +
+
+ {FACE_NAMES[pixelInfo.faceId]} + ({pixelInfo.x}, {pixelInfo.y}) +
+ + {#if pixelInfo.isEmpty} +

No pixel placed here

+ {:else} +
+
+ {pixelInfo.color} +
+ + + {/if} +
+ + diff --git a/frontend/src/lib/components/pyramid/PyramidCanvas.svelte b/frontend/src/lib/components/pyramid/PyramidCanvas.svelte new file mode 100644 index 0000000..7955f26 --- /dev/null +++ b/frontend/src/lib/components/pyramid/PyramidCanvas.svelte @@ -0,0 +1,384 @@ + + +
+ {#if hoveredPixel} +
+ {FACE_NAMES[hoveredPixel.faceId]} ({hoveredPixel.x}, {hoveredPixel.y}) +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/pyramid/UserStats.svelte b/frontend/src/lib/components/pyramid/UserStats.svelte new file mode 100644 index 0000000..b90d1f8 --- /dev/null +++ b/frontend/src/lib/components/pyramid/UserStats.svelte @@ -0,0 +1,118 @@ + + +
+
+

Your Pixels

+ + {connected ? 'Live' : 'Offline'} + +
+ +
+ {remaining} + / {DAILY_LIMIT} remaining today +
+ +
+
+
+ +

+ Resets at midnight UTC +

+
+ + diff --git a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte index 983a748..9b84cbb 100644 --- a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte +++ b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte @@ -7,12 +7,17 @@ let animationId; let width, height; let hue = 0; - let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving' + let phase = 'growing'; // 'growing' | 'shattering' let phaseStartTime = 0; let branches = []; // Active growing branch tips let crystalPoints = []; // All drawn points for shatter effect let shatterParticles = []; + // Collision detection grid + let occupiedGrid = []; + const GRID_CELL_SIZE = 4; + let gridWidth, gridHeight; + const CONFIG = { seedCount: 5, // Number of initial seed points branchSpeed: 2, // Pixels per frame @@ -20,9 +25,8 @@ branchChance: 0.03, // Chance to spawn new branch per frame turnAngle: 0.3, // Max random turn per frame (radians) hueShiftSpeed: 0.2, // Color cycling speed - maxPoints: 15000, // Max crystal points before shatter + maxPoints: 15000, // Max crystal points (kept for reference) shatterDuration: 2500, // Milliseconds for shatter effect - dissolveDuration: 2000, // Milliseconds for dissolve effect lineWidth: 2 }; @@ -33,6 +37,38 @@ canvas.width = width; canvas.height = height; ctx = canvas.getContext('2d'); + initGrid(); + } + + function initGrid() { + gridWidth = Math.ceil(width / GRID_CELL_SIZE); + gridHeight = Math.ceil(height / GRID_CELL_SIZE); + occupiedGrid = Array(gridHeight).fill(null).map(() => Array(gridWidth).fill(false)); + } + + function isOccupied(x, y) { + const gx = Math.floor(x / GRID_CELL_SIZE); + const gy = Math.floor(y / GRID_CELL_SIZE); + if (gx < 0 || gx >= gridWidth || gy < 0 || gy >= gridHeight) return true; + return occupiedGrid[gy][gx]; + } + + function markOccupied(x, y) { + const gx = Math.floor(x / GRID_CELL_SIZE); + const gy = Math.floor(y / GRID_CELL_SIZE); + if (gx >= 0 && gx < gridWidth && gy >= 0 && gy < gridHeight) { + occupiedGrid[gy][gx] = true; + } + } + + function getCoverage() { + let filled = 0; + for (let row of occupiedGrid) { + for (let cell of row) { + if (cell) filled++; + } + } + return filled / (gridWidth * gridHeight); } function initCrystal() { @@ -42,6 +78,9 @@ phase = 'growing'; phaseStartTime = performance.now(); + // Reset collision grid + initGrid(); + // Clear canvas if (ctx) { ctx.fillStyle = 'black'; @@ -74,8 +113,6 @@ updateGrowing(); } else if (phase === 'shattering') { updateShattering(); - } else if (phase === 'dissolving') { - updateDissolving(); } } @@ -100,6 +137,15 @@ continue; } + // Check collision - kill branch if cell is already occupied + if (isOccupied(newX, newY)) { + branches.splice(i, 1); + continue; + } + + // Mark cell as occupied + markOccupied(newX, newY); + // Store point for shatter effect crystalPoints.push({ x: newX, @@ -133,37 +179,42 @@ generation: branch.generation + 1 }); } - - // Chance to die (increases with age and generation) - const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002; - if (Math.random() < deathChance) { - branches.splice(i, 1); - } } // Add new branches branches.push(...newBranches); - // Check if we should shatter (too many points or no more branches) - if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) { + // Check if we should shatter (coverage threshold reached or no more branches) + const coverage = getCoverage(); + if (coverage > 0.75 || (branches.length === 0 && crystalPoints.length > 100)) { startShatter(); } - // Spawn new seeds occasionally if branches are dying off - if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) { - const x = Math.random() * width; - const y = Math.random() * height; - const branchCount = 2 + Math.floor(Math.random() * 3); - for (let j = 0; j < branchCount; j++) { - const angle = Math.random() * Math.PI * 2; - branches.push({ - x, - y, - angle, - hue: Math.random() * 360, - age: 0, - generation: 0 - }); + // Spawn new seeds if branches are dying off and we haven't filled the screen + if (branches.length < 10 && coverage < 0.7) { + // Find an unoccupied spot for the new seed + let attempts = 0; + let x, y; + do { + x = Math.random() * width; + y = Math.random() * height; + attempts++; + } while (isOccupied(x, y) && attempts < 50); + + // Only spawn if we found an unoccupied spot + if (!isOccupied(x, y)) { + const branchCount = 2 + Math.floor(Math.random() * 3); + for (let j = 0; j < branchCount; j++) { + const angle = Math.random() * Math.PI * 2; + branches.push({ + x, + y, + angle, + hue: Math.random() * 360, + age: 0, + generation: 0 + }); + } } } } @@ -214,32 +265,7 @@ } if (elapsed >= CONFIG.shatterDuration) { - phase = 'dissolving'; - phaseStartTime = performance.now(); - } - } - - function updateDissolving() { - const elapsed = performance.now() - phaseStartTime; - - ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; - ctx.fillRect(0, 0, width, height); - - for (const p of shatterParticles) { - p.x += p.vx * 0.5; - p.y += p.vy * 0.5; - p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration)); - - if (p.alpha > 0.01) { - ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`; - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2); - ctx.fill(); - } - } - - if (elapsed >= CONFIG.dissolveDuration) { - initCrystal(); // Regrow + initCrystal(); // Go directly to regrow } } diff --git a/frontend/src/lib/pyramid/pyramidWebSocket.js b/frontend/src/lib/pyramid/pyramidWebSocket.js new file mode 100644 index 0000000..5003e22 --- /dev/null +++ b/frontend/src/lib/pyramid/pyramidWebSocket.js @@ -0,0 +1,236 @@ +import { pyramidStore } from '$lib/stores/pyramid'; + +class PyramidWebSocket { + constructor() { + this.ws = null; + this.token = null; + this.reconnectAttempts = 0; + this.maxReconnectAttempts = 5; + this.reconnectDelay = 1000; + this.maxReconnectDelay = 30000; + this.reconnectResetTimer = null; + } + + async connect(token = null) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + this.token = token; + + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.host}/ws/pyramid`; + + console.log('[PyramidWebSocket] Connecting to:', wsUrl); + pyramidStore.setConnected(false); + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('[PyramidWebSocket] Connected'); + pyramidStore.setConnected(true); + this.reconnectAttempts = 0; + + // Send auth token if available + if (this.token) { + this.ws.send(JSON.stringify({ type: 'auth', token: this.token })); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.handleMessage(data); + } catch (error) { + console.error('[PyramidWebSocket] Failed to parse message:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('[PyramidWebSocket] Error:', error); + }; + + this.ws.onclose = () => { + console.log('[PyramidWebSocket] Disconnected'); + pyramidStore.setConnected(false); + this.attemptReconnect(); + }; + } catch (error) { + console.error('[PyramidWebSocket] Failed to create WebSocket:', error); + pyramidStore.setConnected(false); + } + } + + handleMessage(data) { + switch (data.type) { + case 'welcome': + console.log('[PyramidWebSocket] Welcome:', data.message); + break; + + case 'auth_success': + console.log('[PyramidWebSocket] Authenticated'); + if (data.remainingPixels !== undefined) { + pyramidStore.setRemaining(data.remainingPixels); + } + break; + + case 'pixel_update': + // Another user placed a pixel + pyramidStore.updatePixel( + data.faceId, + data.x, + data.y, + data.color + ); + break; + + case 'pixel_placed': + // Confirmation of our own pixel placement + if (data.remainingPixels !== undefined) { + pyramidStore.setRemaining(data.remainingPixels); + } + break; + + case 'rollback': + // Moderator rolled back a pixel + pyramidStore.updatePixel( + data.faceId, + data.x, + data.y, + data.newColor || null + ); + console.log(`[PyramidWebSocket] Pixel rolled back by ${data.moderator}`); + break; + + case 'error': + console.error('[PyramidWebSocket] Server error:', data.message); + pyramidStore.setError(data.message); + break; + + default: + console.log('[PyramidWebSocket] Unknown message type:', data.type); + } + } + + placePixel(faceId, x, y, color) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.error('[PyramidWebSocket] Not connected'); + return false; + } + + this.ws.send(JSON.stringify({ + type: 'place_pixel', + faceId, + x, + y, + color + })); + + return true; + } + + async attemptReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[PyramidWebSocket] Max reconnect attempts reached'); + + // Try again after 60 seconds + if (this.reconnectResetTimer) { + clearTimeout(this.reconnectResetTimer); + } + this.reconnectResetTimer = setTimeout(() => { + console.log('[PyramidWebSocket] Resetting reconnect attempts'); + this.reconnectAttempts = 0; + this.attemptReconnect(); + }, 60000); + return; + } + + this.reconnectAttempts++; + const delay = Math.min( + this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), + this.maxReconnectDelay + ); + + console.log(`[PyramidWebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + + setTimeout(async () => { + // Try to refresh token before reconnecting + if (this.token) { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + this.token = data.token; + } + } + } catch (e) { + console.warn('[PyramidWebSocket] Token refresh failed:', e); + } + } + this.connect(this.token); + }, delay); + } + + async manualReconnect(token = null) { + console.log('[PyramidWebSocket] Manual reconnect'); + + if (this.reconnectResetTimer) { + clearTimeout(this.reconnectResetTimer); + this.reconnectResetTimer = null; + } + + this.reconnectAttempts = 0; + + // Get fresh token if not provided + let freshToken = token; + if (!freshToken) { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + if (data.token) { + freshToken = data.token; + } + } + } catch (e) { + console.warn('[PyramidWebSocket] Could not refresh token:', e); + } + } + + this.token = freshToken; + + // Close existing connection + if (this.ws) { + this.ws.onclose = null; + this.ws.close(); + this.ws = null; + } + + this.connect(this.token); + } + + disconnect() { + if (this.reconnectResetTimer) { + clearTimeout(this.reconnectResetTimer); + this.reconnectResetTimer = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.token = null; + this.reconnectAttempts = 0; + pyramidStore.setConnected(false); + } +} + +// Singleton instance +export const pyramidWebSocket = new PyramidWebSocket(); diff --git a/frontend/src/lib/stores/auth.js b/frontend/src/lib/stores/auth.js index d72a612..4e95a3c 100644 --- a/frontend/src/lib/stores/auth.js +++ b/frontend/src/lib/stores/auth.js @@ -11,6 +11,9 @@ let refreshInterval = null; let isRefreshing = false; let refreshPromise = null; let consecutiveFailures = 0; +let lastVisibilityRefresh = 0; +const VISIBILITY_REFRESH_COOLDOWN_MS = 60 * 1000; // 1 minute cooldown +let visibilityHandlerSetup = false; // Silent token refresh function with retry logic // Returns: { success: boolean, isAuthError: boolean } @@ -90,6 +93,9 @@ export async function refreshAccessToken() { function startTokenRefresh() { if (!browser) return; + // Setup visibility handler to refresh when tab becomes visible + setupVisibilityHandler(); + // Clear any existing interval if (refreshInterval) { clearInterval(refreshInterval); @@ -122,6 +128,24 @@ function stopTokenRefresh() { } } +// Setup visibility change handler to refresh token when tab becomes visible +function setupVisibilityHandler() { + if (!browser || visibilityHandlerSetup) return; + visibilityHandlerSetup = true; + + document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible') { + const now = Date.now(); + // Only refresh if enough time has passed since last refresh + if (now - lastVisibilityRefresh > VISIBILITY_REFRESH_COOLDOWN_MS) { + lastVisibilityRefresh = now; + console.log('Tab became visible, refreshing token...'); + await refreshAccessToken(); + } + } + }); +} + function createAuthStore() { const { subscribe, set, update } = writable({ user: null, @@ -136,11 +160,24 @@ function createAuthStore() { // Use cookie-based auth - no localStorage tokens try { - const response = await fetch('/api/user/me', { + let response = await fetch('/api/user/me', { credentials: 'include', // Send cookies cache: 'no-store' // Always fetch fresh data }); + // If access token expired, try to refresh before giving up + if (response.status === 401) { + console.log('Access token expired, attempting refresh...'); + const refreshed = await refreshAccessToken(); + if (refreshed) { + // Retry with new token + response = await fetch('/api/user/me', { + credentials: 'include', + cache: 'no-store' + }); + } + } + if (response.ok) { const data = await response.json(); set({ user: data.user, loading: false }); diff --git a/frontend/src/lib/stores/pyramid.js b/frontend/src/lib/stores/pyramid.js new file mode 100644 index 0000000..6f27e3d --- /dev/null +++ b/frontend/src/lib/stores/pyramid.js @@ -0,0 +1,174 @@ +import { writable, derived } from 'svelte/store'; + +// Face configuration +export const FACE_SIZE = 200; +export const FACE_NAMES = ['Base', 'North', 'East', 'South', 'West']; +export const DAILY_LIMIT = 1000; + +function createPyramidStore() { + const { subscribe, set, update } = writable({ + // Canvas state - 5 faces, each is a Map of "x,y" -> color + faces: [new Map(), new Map(), new Map(), new Map(), new Map()], + // Loading states + loading: true, + error: null, + // User limits + remainingPixels: DAILY_LIMIT, + pixelsPlacedToday: 0, + // Selection state + selectedColor: '#E50000', + hoveredPixel: null, + selectedPixel: null, + // Color palette + colors: [], + // WebSocket connection status + connected: false + }); + + return { + subscribe, + + // Load initial canvas state from API + async loadState() { + update(state => ({ ...state, loading: true, error: null })); + + try { + const [stateRes, colorsRes, limitsRes] = await Promise.all([ + fetch('/api/pyramid/state', { credentials: 'include' }), + fetch('/api/pyramid/colors', { credentials: 'include' }), + fetch('/api/pyramid/limits', { credentials: 'include' }) + ]); + + if (!stateRes.ok) throw new Error('Failed to load canvas state'); + if (!colorsRes.ok) throw new Error('Failed to load colors'); + + const stateData = await stateRes.json(); + const colorsData = await colorsRes.json(); + const limitsData = limitsRes.ok ? await limitsRes.json() : null; + + // Build face maps from pixel data + const faces = [new Map(), new Map(), new Map(), new Map(), new Map()]; + for (const pixel of stateData.pixels || []) { + const key = `${pixel.x},${pixel.y}`; + faces[pixel.faceId].set(key, pixel.color); + } + + update(state => ({ + ...state, + faces, + colors: colorsData.colors || [], + remainingPixels: limitsData?.remainingToday ?? DAILY_LIMIT, + pixelsPlacedToday: limitsData?.pixelsPlacedToday ?? 0, + loading: false, + selectedColor: colorsData.colors?.[5]?.color || '#E50000' + })); + } catch (error) { + console.error('Failed to load pyramid state:', error); + update(state => ({ + ...state, + loading: false, + error: error.message + })); + } + }, + + // Update a single pixel (from WebSocket or local placement) + updatePixel(faceId, x, y, color) { + update(state => { + const newFaces = [...state.faces]; + const key = `${x},${y}`; + + if (color) { + newFaces[faceId] = new Map(newFaces[faceId]); + newFaces[faceId].set(key, color); + } else { + // Empty color means delete + newFaces[faceId] = new Map(newFaces[faceId]); + newFaces[faceId].delete(key); + } + + return { ...state, faces: newFaces }; + }); + }, + + // Set selected color + setSelectedColor(color) { + update(state => ({ ...state, selectedColor: color })); + }, + + // Set hovered pixel + setHoveredPixel(pixel) { + update(state => ({ ...state, hoveredPixel: pixel })); + }, + + // Set selected pixel (for info panel) + setSelectedPixel(pixel) { + update(state => ({ ...state, selectedPixel: pixel })); + }, + + // Decrement remaining pixels after successful placement + decrementRemaining() { + update(state => ({ + ...state, + remainingPixels: Math.max(0, state.remainingPixels - 1), + pixelsPlacedToday: state.pixelsPlacedToday + 1 + })); + }, + + // Update remaining from server + setRemaining(remaining) { + update(state => ({ + ...state, + remainingPixels: remaining, + pixelsPlacedToday: DAILY_LIMIT - remaining + })); + }, + + // Set connection status + setConnected(connected) { + update(state => ({ ...state, connected })); + }, + + // Set error + setError(error) { + update(state => ({ ...state, error })); + }, + + // Get pixel color from face + getPixelColor(faceId, x, y) { + let color = null; + const unsubscribe = subscribe(state => { + const key = `${x},${y}`; + color = state.faces[faceId]?.get(key) || null; + }); + unsubscribe(); + return color; + }, + + // Reset store + reset() { + set({ + faces: [new Map(), new Map(), new Map(), new Map(), new Map()], + loading: true, + error: null, + remainingPixels: DAILY_LIMIT, + pixelsPlacedToday: 0, + selectedColor: '#E50000', + hoveredPixel: null, + selectedPixel: null, + colors: [], + connected: false + }); + } + }; +} + +export const pyramidStore = createPyramidStore(); + +// Derived stores for convenience +export const isLoading = derived(pyramidStore, $store => $store.loading); +export const pyramidError = derived(pyramidStore, $store => $store.error); +export const remainingPixels = derived(pyramidStore, $store => $store.remainingPixels); +export const selectedColor = derived(pyramidStore, $store => $store.selectedColor); +export const colorPalette = derived(pyramidStore, $store => $store.colors); +export const isConnected = derived(pyramidStore, $store => $store.connected); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 57be7ae..91d1b7c 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -50,7 +50,8 @@ if (state.user) { screensaver.init({ screensaverEnabled: state.user.screensaverEnabled, - screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes + screensaverTimeoutMinutes: state.user.screensaverTimeoutMinutes, + screensaverType: state.user.screensaverType }); } }); @@ -58,7 +59,8 @@ if ($auth.user) { screensaver.init({ screensaverEnabled: $auth.user.screensaverEnabled, - screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes + screensaverTimeoutMinutes: $auth.user.screensaverTimeoutMinutes, + screensaverType: $auth.user.screensaverType }); } }); @@ -562,6 +564,13 @@ Games + + + + + + World + diff --git a/frontend/src/routes/pyramid/+page.svelte b/frontend/src/routes/pyramid/+page.svelte new file mode 100644 index 0000000..219dd37 --- /dev/null +++ b/frontend/src/routes/pyramid/+page.svelte @@ -0,0 +1,267 @@ + + + + Pyramid World - Realms + + +
+
+

Pyramid World

+

Collaborative pixel art on a 3D pyramid

+
+ + {#if $isLoading} +
+
Loading pyramid...
+
+ {:else if $pyramidError} +
+
{$pyramidError}
+ +
+ {:else} +
+
+ +
+ + +
+ +
+

Drag to rotate | Scroll to zoom | Click to place pixel

+
+ {/if} +
+ +