fixes lol
All checks were successful
Build and Push / build-all (push) Successful in 9m17s

This commit is contained in:
doomtube 2026-01-11 14:54:44 -05:00
parent 33c20bf59d
commit a92bfc1d22
14 changed files with 2629 additions and 59 deletions

View file

@ -0,0 +1,779 @@
#include "PyramidController.h"
#include "../services/DatabaseService.h"
#include "../common/HttpHelpers.h"
#include "../common/AuthHelpers.h"
#include <regex>
#include <mutex>
#include <chrono>
using namespace drogon::orm;
// Static members for WebSocket controller
std::unordered_map<WebSocketConnectionPtr, PyramidWebSocketController::ConnectionInfo> 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<void(const HttpResponsePtr &)> &&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<int>();
pixel["x"] = row["x"].as<int>();
pixel["y"] = row["y"].as<int>();
pixel["color"] = row["color"].as<std::string>();
resp["pixels"].append(pixel);
}
resp["totalPixels"] = static_cast<Json::UInt>(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<void(const HttpResponsePtr &)> &&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<int>(faceId)
>> [callback, faceId](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["faceId"] = static_cast<int>(faceId);
resp["pixels"] = Json::Value(Json::arrayValue);
for (const auto& row : r) {
Json::Value pixel;
pixel["x"] = row["x"].as<int>();
pixel["y"] = row["y"].as<int>();
pixel["color"] = row["color"].as<std::string>();
resp["pixels"].append(pixel);
}
resp["totalPixels"] = static_cast<Json::UInt>(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<void(const HttpResponsePtr &)> &&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<std::string>();
color["name"] = row["name"].isNull() ? "" : row["name"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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>();
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<void(const HttpResponsePtr &)> &&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<int>(faceId), static_cast<int>(x), static_cast<int>(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<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
>> [callback, faceId, x, y](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["faceId"] = static_cast<int>(faceId);
resp["x"] = static_cast<int>(x);
resp["y"] = static_cast<int>(y);
if (r.empty()) {
resp["isEmpty"] = true;
} else {
const auto& row = r[0];
resp["isEmpty"] = false;
resp["color"] = row["color"].as<std::string>();
resp["placedAt"] = row["placed_at"].as<std::string>();
resp["user"]["id"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
resp["user"]["username"] = row["username"].as<std::string>();
resp["user"]["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
resp["user"]["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
}
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<void(const HttpResponsePtr &)> &&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<int>();
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<void(const HttpResponsePtr &)> &&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<int64_t>();
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<std::string>();
int64_t prevUserId = r[1]["placed_by"].as<int64_t>();
*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<void(const HttpResponsePtr &)> &&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<int>(faceId), static_cast<int>(x), static_cast<int>(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<int>(faceId) << static_cast<int>(x) << static_cast<int>(y)
>> [callback, faceId, x, y](const Result& r) {
Json::Value resp;
resp["success"] = true;
resp["faceId"] = static_cast<int>(faceId);
resp["x"] = static_cast<int>(x);
resp["y"] = static_cast<int>(y);
resp["history"] = Json::Value(Json::arrayValue);
for (const auto& row : r) {
Json::Value entry;
entry["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
entry["color"] = row["color"].as<std::string>();
entry["placedAt"] = row["placed_at"].as<std::string>();
entry["rolledBack"] = row["rolled_back"].as<bool>();
entry["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
entry["username"] = row["username"].as<std::string>();
entry["userColor"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
if (row["rolled_back"].as<bool>()) {
entry["rolledBackAt"] = row["rolled_back_at"].as<std::string>();
entry["rolledBackBy"] = row["rolled_back_by_username"].isNull() ? "" : row["rolled_back_by_username"].as<std::string>();
}
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<int>();
}
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<std::mutex> 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<int>();
*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<Json::Int64>(userId);
msg["username"] = username;
Json::StreamWriterBuilder builder;
std::string msgStr = Json::writeString(builder, msg);
std::lock_guard<std::mutex> 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<std::mutex> lock(connectionsMutex_);
for (const auto& [conn, info] : connections_) {
conn->send(msgStr);
}
}

View file

@ -0,0 +1,106 @@
#pragma once
#include <drogon/HttpController.h>
#include <drogon/WebSocketController.h>
#include "../services/AuthService.h"
using namespace drogon;
class PyramidController : public HttpController<PyramidController> {
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<void(const HttpResponsePtr &)> &&callback);
void getFaceState(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &faceId);
void getColors(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Pixel operation methods
void placePixel(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getPixelInfo(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback,
const std::string &faceId,
const std::string &x,
const std::string &y);
// User limits methods
void getLimits(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
// Moderation methods
void rollbackPixel(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&callback);
void getPixelHistory(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<PyramidWebSocketController> {
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<WebSocketConnectionPtr, ConnectionInfo> connections_;
static std::mutex connectionsMutex_;
void handleAuth(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg);
void handlePlacePixel(const WebSocketConnectionPtr &wsConnPtr, const Json::Value &msg);
};

View file

@ -1291,3 +1291,110 @@ BEGIN
ALTER TABLE realms ADD COLUMN live_started_at TIMESTAMP WITH TIME ZONE;
END IF;
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;

View file

@ -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"
}

View file

@ -0,0 +1,89 @@
<script>
import { pyramidStore, colorPalette, selectedColor } from '$lib/stores/pyramid';
export let selected = '#E50000';
function selectColor(color) {
selected = color;
pyramidStore.setSelectedColor(color);
}
</script>
<div class="color-palette">
<h3>Colors</h3>
<div class="colors-grid">
{#each $colorPalette as colorItem}
<button
class="color-swatch"
class:selected={selected === colorItem.color}
style="background-color: {colorItem.color}"
title={colorItem.name}
on:click={() => selectColor(colorItem.color)}
/>
{/each}
</div>
<div class="selected-color">
<div class="preview" style="background-color: {selected}" />
<span>{selected}</span>
</div>
</div>
<style>
.color-palette {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #fff;
}
.colors-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
margin-bottom: 1rem;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s, border-color 0.1s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: #fff;
transform: scale(1.1);
}
.selected-color {
display: flex;
align-items: center;
gap: 8px;
padding-top: 0.5rem;
border-top: 1px solid #333;
}
.preview {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #444;
}
.selected-color span {
font-family: monospace;
color: #888;
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,236 @@
<script>
import { pyramidStore, FACE_NAMES } from '$lib/stores/pyramid';
import { onDestroy } from 'svelte';
let selectedPixel = null;
let pixelInfo = null;
let loading = false;
let error = null;
const unsubscribe = pyramidStore.subscribe(state => {
if (state.selectedPixel !== selectedPixel) {
selectedPixel = state.selectedPixel;
if (selectedPixel) {
loadPixelInfo(selectedPixel);
} else {
pixelInfo = null;
}
}
});
onDestroy(unsubscribe);
async function loadPixelInfo(pixel) {
if (!pixel) return;
loading = true;
error = null;
try {
const res = await fetch(
`/api/pyramid/pixel/${pixel.faceId}/${pixel.x}/${pixel.y}`,
{ credentials: 'include' }
);
if (!res.ok) throw new Error('Failed to load pixel info');
const data = await res.json();
pixelInfo = data;
} catch (e) {
error = e.message;
pixelInfo = null;
} finally {
loading = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleString();
}
function clearSelection() {
pyramidStore.setSelectedPixel(null);
}
</script>
<div class="pixel-info">
<h3>Pixel Info</h3>
{#if !selectedPixel}
<p class="hint">Click a pixel to see who placed it</p>
{:else if loading}
<p class="loading">Loading...</p>
{:else if error}
<p class="error">{error}</p>
{:else if pixelInfo}
<div class="info-content">
<div class="position">
<strong>{FACE_NAMES[pixelInfo.faceId]}</strong>
<span>({pixelInfo.x}, {pixelInfo.y})</span>
</div>
{#if pixelInfo.isEmpty}
<p class="empty">No pixel placed here</p>
{:else}
<div class="color-display">
<div class="color-preview" style="background-color: {pixelInfo.color}" />
<span class="color-code">{pixelInfo.color}</span>
</div>
<div class="user-info">
{#if pixelInfo.user?.avatarUrl}
<img src={pixelInfo.user.avatarUrl} alt="" class="avatar" />
{:else}
<div class="avatar placeholder" />
{/if}
<div class="user-details">
<a
href="/profile/{pixelInfo.user?.username}"
class="username"
style="color: {pixelInfo.user?.userColor || '#888'}"
>
{pixelInfo.user?.username || 'Unknown'}
</a>
<span class="time">{formatDate(pixelInfo.placedAt)}</span>
</div>
</div>
{/if}
<button class="close-btn" on:click={clearSelection}>Clear</button>
</div>
{/if}
</div>
<style>
.pixel-info {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
h3 {
margin: 0 0 0.75rem 0;
font-size: 1rem;
color: #fff;
}
.hint {
color: #666;
font-size: 0.9rem;
margin: 0;
}
.loading {
color: #888;
font-size: 0.9rem;
margin: 0;
}
.error {
color: #e50000;
font-size: 0.9rem;
margin: 0;
}
.empty {
color: #666;
font-style: italic;
margin: 0.5rem 0;
}
.info-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.position {
display: flex;
align-items: center;
gap: 8px;
}
.position strong {
color: #fff;
}
.position span {
color: #888;
font-family: monospace;
}
.color-display {
display: flex;
align-items: center;
gap: 8px;
}
.color-preview {
width: 24px;
height: 24px;
border-radius: 4px;
border: 1px solid #444;
}
.color-code {
font-family: monospace;
color: #888;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
padding-top: 0.5rem;
border-top: 1px solid #333;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.avatar.placeholder {
background: #333;
}
.user-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.username {
text-decoration: none;
font-weight: 500;
}
.username:hover {
text-decoration: underline;
}
.time {
color: #666;
font-size: 0.8rem;
}
.close-btn {
margin-top: 0.5rem;
padding: 6px 12px;
background: #333;
border: none;
border-radius: 4px;
color: #888;
cursor: pointer;
font-size: 0.85rem;
}
.close-btn:hover {
background: #444;
color: #fff;
}
</style>

View file

@ -0,0 +1,384 @@
<script>
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { pyramidStore, FACE_SIZE, FACE_NAMES } from '$lib/stores/pyramid';
export let selectedColor = '#E50000';
const dispatch = createEventDispatcher();
let container;
let scene, camera, renderer, controls;
let pyramidGroup;
let faceMeshes = [];
let faceTextures = [];
let raycaster, mouse;
let animationId;
// Track if we're hovering over a face
let hoveredFace = null;
let hoveredPixel = null;
// Unsubscribe function for store
let unsubscribePyramid;
onMount(() => {
initScene();
createPyramid();
setupControls();
setupRaycasting();
animate();
// Subscribe to store updates for real-time pixel changes
unsubscribePyramid = pyramidStore.subscribe(state => {
if (!state.loading) {
updateAllTextures(state.faces);
}
});
// Handle window resize
window.addEventListener('resize', onWindowResize);
});
onDestroy(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (unsubscribePyramid) {
unsubscribePyramid();
}
window.removeEventListener('resize', onWindowResize);
// Cleanup Three.js resources
if (renderer) {
renderer.dispose();
}
faceMeshes.forEach(mesh => {
mesh.geometry.dispose();
mesh.material.dispose();
});
faceTextures.forEach(texture => {
texture.dispose();
});
});
function initScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
const aspect = container.clientWidth / container.clientHeight;
camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
camera.position.set(250, 200, 250);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight2.position.set(-1, 0.5, -1);
scene.add(directionalLight2);
// Grid helper for reference
const gridHelper = new THREE.GridHelper(400, 20, 0x222222, 0x111111);
gridHelper.position.y = -1;
scene.add(gridHelper);
}
function createDataTexture(faceData) {
const size = FACE_SIZE;
const data = new Uint8Array(size * size * 4);
// Fill with default color (dark gray)
for (let i = 0; i < size * size; i++) {
data[i * 4] = 34; // R
data[i * 4 + 1] = 34; // G
data[i * 4 + 2] = 34; // B
data[i * 4 + 3] = 255; // A
}
// Apply pixel data from the face map
if (faceData) {
faceData.forEach((color, key) => {
const [x, y] = key.split(',').map(Number);
const idx = (y * size + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
data[idx] = rgb.r;
data[idx + 1] = rgb.g;
data[idx + 2] = rgb.b;
}
});
}
const texture = new THREE.DataTexture(data, size, size, THREE.RGBAFormat);
texture.needsUpdate = true;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
return texture;
}
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function createPyramid() {
pyramidGroup = new THREE.Group();
// Create base (face 0) - square at y=0
const baseGeometry = new THREE.PlaneGeometry(FACE_SIZE, FACE_SIZE);
const baseTexture = createDataTexture(null);
faceTextures[0] = baseTexture;
const baseMaterial = new THREE.MeshStandardMaterial({
map: baseTexture,
side: THREE.DoubleSide
});
const baseMesh = new THREE.Mesh(baseGeometry, baseMaterial);
baseMesh.rotation.x = -Math.PI / 2;
baseMesh.position.y = 0;
baseMesh.userData = { faceId: 0 };
pyramidGroup.add(baseMesh);
faceMeshes[0] = baseMesh;
// Create four triangular faces (faces 1-4)
const halfBase = FACE_SIZE / 2;
const pyramidHeight = FACE_SIZE * 0.8;
const apex = new THREE.Vector3(0, pyramidHeight, 0);
const baseCorners = [
new THREE.Vector3(-halfBase, 0, -halfBase), // NW
new THREE.Vector3(halfBase, 0, -halfBase), // NE
new THREE.Vector3(halfBase, 0, halfBase), // SE
new THREE.Vector3(-halfBase, 0, halfBase) // SW
];
// Face indices: 1=North, 2=East, 3=South, 4=West
const faceIndices = [
[0, 1], // North: NW-NE
[1, 2], // East: NE-SE
[2, 3], // South: SE-SW
[3, 0] // West: SW-NW
];
for (let i = 0; i < 4; i++) {
const [idx1, idx2] = faceIndices[i];
const v1 = baseCorners[idx1];
const v2 = baseCorners[idx2];
// Create triangle geometry
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
apex.x, apex.y, apex.z,
v1.x, v1.y, v1.z,
v2.x, v2.y, v2.z
]);
// UV mapping for triangular face
const uvs = new Float32Array([
0.5, 0, // apex at top center
0, 1, // left corner at bottom left
1, 1 // right corner at bottom right
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
geometry.computeVertexNormals();
const texture = createDataTexture(null);
faceTextures[i + 1] = texture;
const material = new THREE.MeshStandardMaterial({
map: texture,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.userData = { faceId: i + 1 };
pyramidGroup.add(mesh);
faceMeshes[i + 1] = mesh;
}
scene.add(pyramidGroup);
}
function setupControls() {
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.screenSpacePanning = false;
controls.minDistance = 100;
controls.maxDistance = 500;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
controls.target.set(0, 50, 0);
controls.update();
}
function setupRaycasting() {
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
container.addEventListener('mousemove', onMouseMove);
container.addEventListener('click', onClick);
}
function onMouseMove(event) {
const rect = container.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(faceMeshes);
if (intersects.length > 0) {
const intersect = intersects[0];
const faceId = intersect.object.userData.faceId;
const uv = intersect.uv;
if (uv) {
const x = Math.floor(uv.x * FACE_SIZE);
const y = Math.floor((1 - uv.y) * FACE_SIZE);
hoveredFace = faceId;
hoveredPixel = { faceId, x, y };
dispatch('hover', { faceId, x, y });
pyramidStore.setHoveredPixel({ faceId, x, y });
}
} else {
hoveredFace = null;
hoveredPixel = null;
pyramidStore.setHoveredPixel(null);
}
}
function onClick(event) {
if (!hoveredPixel) return;
const { faceId, x, y } = hoveredPixel;
// Dispatch place event for parent to handle
dispatch('place', { faceId, x, y, color: selectedColor });
// Also set as selected for info panel
pyramidStore.setSelectedPixel({ faceId, x, y });
}
function updateAllTextures(faces) {
for (let i = 0; i < 5; i++) {
updateFaceTexture(i, faces[i]);
}
}
function updateFaceTexture(faceId, faceData) {
if (!faceTextures[faceId]) return;
const texture = faceTextures[faceId];
const size = FACE_SIZE;
// Reset to default color
for (let i = 0; i < size * size; i++) {
texture.image.data[i * 4] = 34;
texture.image.data[i * 4 + 1] = 34;
texture.image.data[i * 4 + 2] = 34;
}
// Apply pixel data
if (faceData) {
faceData.forEach((color, key) => {
const [x, y] = key.split(',').map(Number);
const idx = (y * size + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
texture.image.data[idx] = rgb.r;
texture.image.data[idx + 1] = rgb.g;
texture.image.data[idx + 2] = rgb.b;
}
});
}
texture.needsUpdate = true;
}
// Public method to update a single pixel (for real-time updates)
export function updatePixel(faceId, x, y, color) {
if (!faceTextures[faceId]) return;
const texture = faceTextures[faceId];
const idx = (y * FACE_SIZE + x) * 4;
const rgb = hexToRgb(color);
if (rgb) {
texture.image.data[idx] = rgb.r;
texture.image.data[idx + 1] = rgb.g;
texture.image.data[idx + 2] = rgb.b;
texture.needsUpdate = true;
}
}
function onWindowResize() {
if (!container || !camera || !renderer) return;
camera.aspect = container.clientWidth / container.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(container.clientWidth, container.clientHeight);
}
function animate() {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
</script>
<div class="pyramid-canvas" bind:this={container}>
{#if hoveredPixel}
<div class="hover-info">
{FACE_NAMES[hoveredPixel.faceId]} ({hoveredPixel.x}, {hoveredPixel.y})
</div>
{/if}
</div>
<style>
.pyramid-canvas {
width: 100%;
height: 100%;
min-height: 400px;
position: relative;
background: #0a0a0a;
border-radius: 8px;
overflow: hidden;
}
.pyramid-canvas :global(canvas) {
cursor: crosshair;
}
.hover-info {
position: absolute;
bottom: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
}
</style>

View file

@ -0,0 +1,118 @@
<script>
import { pyramidStore, remainingPixels, isConnected, DAILY_LIMIT } from '$lib/stores/pyramid';
import { onDestroy } from 'svelte';
let remaining = DAILY_LIMIT;
let connected = false;
const unsubscribeRemaining = remainingPixels.subscribe(val => {
remaining = val;
});
const unsubscribeConnected = isConnected.subscribe(val => {
connected = val;
});
onDestroy(() => {
unsubscribeRemaining();
unsubscribeConnected();
});
$: percentage = (remaining / DAILY_LIMIT) * 100;
$: barColor = remaining > 200 ? '#02BE01' : remaining > 50 ? '#E5D900' : '#E50000';
</script>
<div class="user-stats">
<div class="stat-header">
<h3>Your Pixels</h3>
<span class="status" class:connected>
{connected ? 'Live' : 'Offline'}
</span>
</div>
<div class="remaining">
<span class="count">{remaining}</span>
<span class="label">/ {DAILY_LIMIT} remaining today</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
style="width: {percentage}%; background-color: {barColor}"
/>
</div>
<p class="note">
Resets at midnight UTC
</p>
</div>
<style>
.user-stats {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
h3 {
margin: 0;
font-size: 1rem;
color: #fff;
}
.status {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 10px;
background: #333;
color: #666;
}
.status.connected {
background: rgba(2, 190, 1, 0.2);
color: #02BE01;
}
.remaining {
display: flex;
align-items: baseline;
gap: 4px;
margin-bottom: 0.5rem;
}
.count {
font-size: 1.5rem;
font-weight: bold;
color: #fff;
}
.label {
color: #888;
font-size: 0.9rem;
}
.progress-bar {
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
transition: width 0.3s ease, background-color 0.3s ease;
}
.note {
margin: 0.5rem 0 0 0;
color: #666;
font-size: 0.8rem;
}
</style>

View file

@ -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,26 +179,30 @@
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;
// 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;
@ -167,6 +217,7 @@
}
}
}
}
function startShatter() {
phase = 'shattering';
@ -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
}
}

View file

@ -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();

View file

@ -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 });

View file

@ -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);

View file

@ -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 @@
</svg>
Games
</a>
<a href="/pyramid" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 22h20L12 2z"/>
<path d="M12 2v20M2 22h20"/>
</svg>
World
</a>
<a href="/stats" class="dropdown-item">
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10M12 20V4M6 20v-6"/>

View file

@ -0,0 +1,267 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth';
import { pyramidStore, isLoading, pyramidError, selectedColor } from '$lib/stores/pyramid';
import { pyramidWebSocket } from '$lib/pyramid/pyramidWebSocket';
import PyramidCanvas from '$lib/components/pyramid/PyramidCanvas.svelte';
import ColorPalette from '$lib/components/pyramid/ColorPalette.svelte';
import PixelInfo from '$lib/components/pyramid/PixelInfo.svelte';
import UserStats from '$lib/components/pyramid/UserStats.svelte';
let pyramidCanvas;
let currentColor = '#E50000';
let authLoaded = false;
// Subscribe to auth store
const unsubscribeAuth = auth.subscribe(value => {
if (!value.loading) {
authLoaded = true;
if (!value.user) {
goto('/login');
}
}
});
// Subscribe to selected color
const unsubscribeColor = selectedColor.subscribe(val => {
currentColor = val;
});
onMount(async () => {
// Wait for auth to load
if (!authLoaded) {
const checkAuth = setInterval(() => {
if (!$auth.loading) {
clearInterval(checkAuth);
if (!$auth.user) {
goto('/login');
} else {
initPyramid();
}
}
}, 100);
} else if ($auth.user) {
initPyramid();
}
});
async function initPyramid() {
// Load initial state
await pyramidStore.loadState();
// Connect WebSocket with auth token
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
if (data.token) {
await pyramidWebSocket.connect(data.token);
}
} else {
// Try connecting without token (will be guest)
await pyramidWebSocket.connect();
}
} catch (e) {
console.error('Failed to get auth token:', e);
await pyramidWebSocket.connect();
}
}
onDestroy(() => {
unsubscribeAuth();
unsubscribeColor();
pyramidWebSocket.disconnect();
pyramidStore.reset();
});
async function handlePlacePixel(event) {
const { faceId, x, y, color } = event.detail;
// Optimistic update
pyramidStore.updatePixel(faceId, x, y, color);
pyramidStore.decrementRemaining();
// Send via WebSocket
const success = pyramidWebSocket.placePixel(faceId, x, y, color);
if (!success) {
// WebSocket not connected, fall back to REST API
try {
const res = await fetch('/api/pyramid/pixel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ faceId, x, y, color })
});
if (!res.ok) {
const data = await res.json();
pyramidStore.setError(data.error || 'Failed to place pixel');
// Reload state to revert optimistic update
pyramidStore.loadState();
} else {
const data = await res.json();
if (data.remainingToday !== undefined) {
pyramidStore.setRemaining(data.remainingToday);
}
}
} catch (e) {
pyramidStore.setError('Network error');
pyramidStore.loadState();
}
}
}
</script>
<svelte:head>
<title>Pyramid World - Realms</title>
</svelte:head>
<div class="pyramid-page">
<header class="pyramid-header">
<h1>Pyramid World</h1>
<p class="subtitle">Collaborative pixel art on a 3D pyramid</p>
</header>
{#if $isLoading}
<div class="loading-container">
<div class="loading">Loading pyramid...</div>
</div>
{:else if $pyramidError}
<div class="error-container">
<div class="error">{$pyramidError}</div>
<button on:click={() => pyramidStore.loadState()}>Retry</button>
</div>
{:else}
<div class="pyramid-layout">
<div class="canvas-container">
<PyramidCanvas
bind:this={pyramidCanvas}
selectedColor={currentColor}
on:place={handlePlacePixel}
/>
</div>
<aside class="sidebar">
<UserStats />
<ColorPalette bind:selected={currentColor} />
<PixelInfo />
</aside>
</div>
<div class="controls-hint">
<p>Drag to rotate | Scroll to zoom | Click to place pixel</p>
</div>
{/if}
</div>
<style>
.pyramid-page {
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem;
}
.pyramid-header {
text-align: center;
margin-bottom: 1.5rem;
}
.pyramid-header h1 {
font-size: 2rem;
margin: 0 0 0.5rem 0;
background: linear-gradient(135deg, #561D5E, #8b3a92);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: #888;
margin: 0;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 1rem;
}
.loading {
color: #888;
font-size: 1.2rem;
}
.error {
color: #E50000;
font-size: 1.1rem;
}
.error-container button {
padding: 8px 16px;
background: #561D5E;
border: none;
border-radius: 4px;
color: #fff;
cursor: pointer;
}
.error-container button:hover {
background: #6d2473;
}
.pyramid-layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.5rem;
}
.canvas-container {
background: #111;
border-radius: 12px;
overflow: hidden;
min-height: 500px;
aspect-ratio: 16 / 10;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
}
.controls-hint {
text-align: center;
margin-top: 1rem;
}
.controls-hint p {
color: #666;
font-size: 0.9rem;
margin: 0;
}
@media (max-width: 900px) {
.pyramid-layout {
grid-template-columns: 1fr;
}
.sidebar {
flex-direction: row;
flex-wrap: wrap;
}
.sidebar > :global(*) {
flex: 1;
min-width: 200px;
}
}
</style>