diff --git a/adapters/src/incoming/http_axum/auth/backend.rs b/adapters/src/incoming/http_axum/auth/backend.rs index 1fd83d0..fb8e0ca 100644 --- a/adapters/src/incoming/http_axum/auth/backend.rs +++ b/adapters/src/incoming/http_axum/auth/backend.rs @@ -15,6 +15,8 @@ pub struct User { pub email: String, pub username: String, pub email_verified_at: Option, + pub available_charges: i32, + pub charges_updated_at: time::OffsetDateTime, pub roles: Vec, } @@ -25,6 +27,8 @@ impl From for User { email: user_public.email, username: user_public.username, email_verified_at: user_public.email_verified_at, + available_charges: user_public.available_charges, + charges_updated_at: user_public.charges_updated_at, roles: user_public.roles, } } @@ -37,8 +41,8 @@ impl From for UserPublic { email: user.email, username: user.username, email_verified_at: user.email_verified_at, - available_charges: 0, - charges_updated_at: time::OffsetDateTime::now_utc(), + available_charges: user.available_charges, + charges_updated_at: user.charges_updated_at, roles: user.roles, } } diff --git a/adapters/src/incoming/http_axum/docs.rs b/adapters/src/incoming/http_axum/docs.rs index 539bcd4..cdb130c 100644 --- a/adapters/src/incoming/http_axum/docs.rs +++ b/adapters/src/incoming/http_axum/docs.rs @@ -23,7 +23,8 @@ use dto::responses::{ BanResponse, PaintOkEnvelope, PaintPixelResponse, PixelHistoryEntry, PixelInfoResponse, TileImageResponse, UserResponse, }; -use handlers::palette::{PaletteEntry, PaletteResponse, SpecialColorEntry}; +use handlers::canvas::CanvasConfigResponse; +use handlers::worlds::{CreateWorldRequest, PaletteEntry, WorldResponse}; use utoipa::OpenApi; #[derive(OpenApi)] @@ -32,7 +33,11 @@ use utoipa::OpenApi; handlers::tiles::serve_tile, handlers::tiles::serve_tile_head, handlers::tiles::paint_pixels_batch, - handlers::palette::get_palette, + handlers::canvas::get_canvas_config, + handlers::worlds::list_worlds, + handlers::worlds::get_world_by_id, + handlers::worlds::get_world_by_name, + handlers::worlds::create_world, handlers::pixel_info::get_pixel_info, handlers::health::health_check, handlers::auth::register_handler, @@ -58,9 +63,10 @@ use utoipa::OpenApi; ApiResponseUser, PaintOkEnvelope, PaintPixelResponse, + CanvasConfigResponse, + WorldResponse, + CreateWorldRequest, PaletteEntry, - PaletteResponse, - SpecialColorEntry, RegisterRequest, LoginRequest, UpdateUsernameRequest, @@ -93,7 +99,8 @@ use utoipa::OpenApi; tags( (name = "tiles", description = "Tile management operations - serve WebP tile images with caching and rate limiting"), (name = "painting", description = "Pixel painting operations - place pixels on tiles with rate limiting and backoff guidance"), - (name = "palette", description = "Color palette management - retrieve available colors for pixel painting"), + (name = "canvas", description = "Canvas configuration - retrieve default world ID, and other global settings in the future"), + (name = "worlds", description = "World management - list, retrieve, and create worlds with palette configuration, tile size, and pixel size"), (name = "pixel", description = "Pixel information operations - retrieve metadata about individual pixels"), (name = "auth", description = "Authentication and user management - register, login, logout, and user profile operations"), (name = "admin", description = "Admin operations - role management and user administration (requires admin privileges)"), diff --git a/adapters/src/incoming/http_axum/dto/requests.rs b/adapters/src/incoming/http_axum/dto/requests.rs index 80aaac8..7851bc2 100644 --- a/adapters/src/incoming/http_axum/dto/requests.rs +++ b/adapters/src/incoming/http_axum/dto/requests.rs @@ -30,7 +30,7 @@ pub struct PaintRequest { #[cfg_attr(feature = "docs", schema(example = 256, minimum = 0))] pub py: usize, #[cfg_attr(feature = "docs", schema(example = 2))] - pub color_id: u8, + pub color_id: i16, } #[cfg_attr(feature = "docs", derive(ToSchema))] @@ -49,7 +49,7 @@ pub struct BatchPixelPaint { #[cfg_attr(feature = "docs", schema(example = 256, minimum = 0))] pub py: usize, #[cfg_attr(feature = "docs", schema(example = 2))] - pub color_id: u8, + pub color_id: i16, } impl BatchPixelPaint { diff --git a/adapters/src/incoming/http_axum/dto/responses.rs b/adapters/src/incoming/http_axum/dto/responses.rs index 36e19f3..2cae062 100644 --- a/adapters/src/incoming/http_axum/dto/responses.rs +++ b/adapters/src/incoming/http_axum/dto/responses.rs @@ -248,7 +248,7 @@ pub struct PixelHistoryEntry { #[cfg_attr(feature = "docs", schema(example = 64))] pub py: usize, #[cfg_attr(feature = "docs", schema(example = 5))] - pub color_id: u8, + pub color_id: i16, #[cfg_attr(feature = "docs", schema(example = "2023-01-01T12:00:00Z"))] pub timestamp: String, } @@ -273,7 +273,7 @@ pub struct PixelInfoResponse { #[cfg_attr(feature = "docs", schema(example = "johndoe"))] pub username: String, #[cfg_attr(feature = "docs", schema(example = 5))] - pub color_id: u8, + pub color_id: i16, #[cfg_attr(feature = "docs", schema(example = "2023-01-01T12:00:00Z"))] pub timestamp: String, } diff --git a/adapters/src/incoming/http_axum/error_mapper.rs b/adapters/src/incoming/http_axum/error_mapper.rs index 9078588..cb5ad48 100644 --- a/adapters/src/incoming/http_axum/error_mapper.rs +++ b/adapters/src/incoming/http_axum/error_mapper.rs @@ -81,6 +81,8 @@ impl IntoResponse for HttpError { AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string()), + AppError::NotFound { message } => (StatusCode::NOT_FOUND, message.clone()), + AppError::EmailNotVerified => ( StatusCode::FORBIDDEN, "Email verification is required".to_string(), diff --git a/adapters/src/incoming/http_axum/handlers/auth_user_response.rs b/adapters/src/incoming/http_axum/handlers/auth_user_response.rs index 0e51d21..2e5c034 100644 --- a/adapters/src/incoming/http_axum/handlers/auth_user_response.rs +++ b/adapters/src/incoming/http_axum/handlers/auth_user_response.rs @@ -1,7 +1,6 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; -use domain::auth::UserPublic; -use domain::credits::{CreditBalance, CreditConfig}; +use domain::{auth::UserPublic, credits::CreditConfig}; use fedi_wplace_application::error::AppError; use crate::{incoming::http_axum::dto::responses::UserResponse, shared::app_state::AppState}; @@ -11,16 +10,15 @@ pub async fn build_user_response( state: &AppState, now: OffsetDateTime, ) -> Result { + let balance = state.credit_store.get_user_credits(&user_public.id).await?; + let credit_config = CreditConfig::new( state.config.credits.max_charges, state.config.credits.charge_cooldown_seconds, ); - let credit_balance = CreditBalance::new( - user_public.available_charges, - user_public.charges_updated_at, - ); - let current_charges = credit_balance.calculate_current_balance(now, &credit_config); - let seconds_until_next_charge = credit_balance.seconds_until_next_charge(now, &credit_config); + + let current_charges = balance.calculate_current_balance(now, &credit_config); + let seconds_until_next_charge = balance.seconds_until_next_charge(now, &credit_config); let roles = user_public .roles diff --git a/adapters/src/incoming/http_axum/handlers/canvas.rs b/adapters/src/incoming/http_axum/handlers/canvas.rs new file mode 100644 index 0000000..c14b125 --- /dev/null +++ b/adapters/src/incoming/http_axum/handlers/canvas.rs @@ -0,0 +1,78 @@ +use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[cfg(feature = "docs")] +use utoipa::ToSchema; + +use crate::incoming::http_axum::dto::responses::ApiResponse; +#[cfg(feature = "docs")] +use crate::incoming::http_axum::dto::responses::ApiResponseValue; +use crate::shared::app_state::AppState; + +#[cfg_attr(feature = "docs", derive(ToSchema))] +#[cfg_attr( + feature = "docs", + schema( + description = "Canvas configuration with default world ID", + example = json!({ + "default_world_id": "550e8400-e29b-41d4-a716-446655440000" + }) + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CanvasConfigResponse { + pub default_world_id: Uuid, +} + +#[cfg_attr( + feature = "docs", + utoipa::path( + get, + path = "/canvas/config", + responses( + (status = 200, description = "Canvas configuration retrieved successfully", + body = ApiResponseValue, + example = json!({ + "ok": true, + "data": { + "default_world_id": "550e8400-e29b-41d4-a716-446655440000" + } + }) + ), + (status = 500, description = "Internal server error", body = ApiResponseValue) + ), + tag = "canvas" + ) +)] +pub async fn get_canvas_config( + State(state): State, +) -> Json> { + let canvas_config = match state.canvas_config_service.get_canvas_config().await { + Ok(config) => config, + Err(e) => { + return Json(ApiResponse:: { + ok: false, + error: Some(format!("Failed to retrieve canvas config: {e}")), + data: None, + }); + } + }; + + let response = CanvasConfigResponse { + default_world_id: *canvas_config.default_world_id.as_uuid(), + }; + + let response_data = match serde_json::to_value(response) { + Ok(data) => Some(data), + Err(_) => { + return Json(ApiResponse:: { + ok: false, + error: Some("Failed to serialize canvas config data".to_string()), + data: None, + }); + } + }; + + Json(ApiResponse::success_with_data(response_data)) +} diff --git a/adapters/src/incoming/http_axum/handlers/health.rs b/adapters/src/incoming/http_axum/handlers/health.rs index 37690e5..07aa1a1 100644 --- a/adapters/src/incoming/http_axum/handlers/health.rs +++ b/adapters/src/incoming/http_axum/handlers/health.rs @@ -4,7 +4,10 @@ use axum::{Json, extract::State}; use crate::incoming::http_axum::dto::responses::ApiResponseValue; use crate::incoming::http_axum::{dto::responses::ApiResponse, error_mapper::HttpError}; use crate::shared::app_state::AppState; -use fedi_wplace_application::ports::incoming::tiles::MetricsQueryUseCase; +use fedi_wplace_application::{ + error::AppError, + ports::incoming::tiles::MetricsQueryUseCase, +}; #[cfg_attr(feature = "docs", utoipa::path( get, @@ -36,8 +39,20 @@ use fedi_wplace_application::ports::incoming::tiles::MetricsQueryUseCase; pub async fn health_check( State(state): State, ) -> Result>, HttpError> { + let default_world = state + .world_service + .get_default_world() + .await + .map_err(HttpError)? + .ok_or_else(|| HttpError(AppError::NotFound { + message: "Default world not found".to_string(), + }))?; + let metrics_uc: &dyn MetricsQueryUseCase = &*state.metrics_query_service; - let metrics = metrics_uc.get_metrics().await.map_err(HttpError)?; + let metrics = metrics_uc + .get_metrics(&default_world.id) + .await + .map_err(HttpError)?; Ok(Json(ApiResponse::success_with_data(Some( serde_json::json!({ diff --git a/adapters/src/incoming/http_axum/handlers/mod.rs b/adapters/src/incoming/http_axum/handlers/mod.rs index 2f89b85..9911370 100644 --- a/adapters/src/incoming/http_axum/handlers/mod.rs +++ b/adapters/src/incoming/http_axum/handlers/mod.rs @@ -1,10 +1,10 @@ pub(crate) mod auth_user_response; -pub(crate) mod palette; -// keep public for OpenAPI docs pub mod admin; pub mod auth; pub mod ban; +pub mod canvas; pub mod health; pub mod pixel_info; pub mod tiles; +pub mod worlds; diff --git a/adapters/src/incoming/http_axum/handlers/palette.rs b/adapters/src/incoming/http_axum/handlers/palette.rs deleted file mode 100644 index 026ad34..0000000 --- a/adapters/src/incoming/http_axum/handlers/palette.rs +++ /dev/null @@ -1,152 +0,0 @@ -use axum::{Json, extract::State}; -use serde::{Deserialize, Serialize}; -#[cfg(feature = "docs")] -use utoipa::ToSchema; - -use crate::incoming::http_axum::dto::responses::ApiResponse; -#[cfg(feature = "docs")] -use crate::incoming::http_axum::dto::responses::ApiResponseValue; -use crate::shared::app_state::AppState; -use domain::color::RgbColor; - -#[cfg_attr(feature = "docs", derive(ToSchema))] -#[cfg_attr(feature = "docs", schema( - description = "Color palette entry with ID and RGB values", - example = json!({ - "id": 1, - "color": { - "r": 255, - "g": 255, - "b": 255 - } - }) -))] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaletteEntry { - pub id: u8, - pub color: RgbColor, -} - -#[cfg_attr(feature = "docs", derive(ToSchema))] -#[cfg_attr(feature = "docs", schema( - description = "Special color entry with ID and purpose description", - example = json!({ - "id": 0, - "purpose": "transparency" - }) -))] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SpecialColorEntry { - pub id: u8, - pub purpose: String, -} - -#[cfg_attr(feature = "docs", derive(ToSchema))] -#[cfg_attr(feature = "docs", schema( - description = "Complete color palette with regular paintable colors and special colors", - example = json!({ - "regular_colors": [ - { - "id": 1, - "color": { - "r": 255, - "g": 255, - "b": 255 - } - } - ], - "special_colors": [ - { - "id": 0, - "purpose": "transparency" - } - ] - }) -))] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PaletteResponse { - pub regular_colors: Vec, - pub special_colors: Vec, -} - -#[cfg_attr(feature = "docs", utoipa::path( - get, - path = "/palette", - responses( - (status = 200, description = "Color palette retrieved successfully", - body = ApiResponseValue, - example = json!({ - "ok": true, - "data": { - "regular_colors": [ - { - "id": 1, - "color": { - "r": 255, - "g": 255, - "b": 255 - } - }, - { - "id": 2, - "color": { - "r": 0, - "g": 0, - "b": 0 - } - } - ], - "special_colors": [ - { - "id": 0, - "purpose": "transparency" - } - ] - } - }) - ), - (status = 500, description = "Internal server error", body = ApiResponseValue) - ), - tag = "palette" -))] - -pub async fn get_palette(State(state): State) -> Json> { - let palette_config = &state.config.color_palette; - - let regular_colors: Vec = palette_config - .colors - .iter() - .enumerate() - .map(|(index, color)| PaletteEntry { - id: u8::try_from(index).unwrap_or(u8::MAX), - color: *color, - }) - .collect(); - - let special_colors: Vec = palette_config - .get_special_colors_with_ids() - .into_iter() - .map(|(id, purpose)| SpecialColorEntry { - id, - purpose: purpose.clone(), - }) - .collect(); - - let palette_response = PaletteResponse { - regular_colors, - special_colors, - }; - - let response_data = match serde_json::to_value(palette_response) { - Ok(data) => Some(data), - Err(_) => { - return Json(ApiResponse:: { - ok: false, - error: Some("Failed to serialize palette data".to_string()), - data: None, - }); - } - }; - - Json(ApiResponse::success_with_data(response_data)) -} diff --git a/adapters/src/incoming/http_axum/handlers/pixel_info.rs b/adapters/src/incoming/http_axum/handlers/pixel_info.rs index 6adc193..f354cd6 100644 --- a/adapters/src/incoming/http_axum/handlers/pixel_info.rs +++ b/adapters/src/incoming/http_axum/handlers/pixel_info.rs @@ -2,12 +2,13 @@ use axum::{ Json, extract::{Path, State}, }; +use uuid::Uuid; use fedi_wplace_application::{error::AppError, ports::incoming::tiles::PixelInfoQueryUseCase}; use crate::incoming::http_axum::{dto::responses::PixelInfoResponse, error_mapper::HttpError}; use crate::shared::app_state::AppState; -use domain::coords::GlobalCoord; +use domain::{coords::GlobalCoord, world::WorldId}; #[cfg(feature = "docs")] use crate::incoming::http_axum::dto::common_responses::{ @@ -16,7 +17,7 @@ use crate::incoming::http_axum::dto::common_responses::{ #[cfg_attr(feature = "docs", utoipa::path( get, - path = "/pixel/{x}/{y}", + path = "/worlds/{world_id}/pixels/{x}/{y}", responses( (status = 200, body = Option, description = "Pixel information found", example = json!({"user_id": "12345", "username": "alice", "color_id": 15, "timestamp": "2025-09-10T12:34:56.789Z"})), (status = 400, response = BadRequestResponse), @@ -29,15 +30,16 @@ use crate::incoming::http_axum::dto::common_responses::{ operation_id = "get_pixel_info" ))] pub async fn get_pixel_info( - Path((x, y)): Path<(i32, i32)>, + Path((world_id, x, y)): Path<(Uuid, i32, i32)>, State(state): State, ) -> Result>, HttpError> { let coord = GlobalCoord::new(x, y); coord.validate().map_err(|e| HttpError(AppError::from(e)))?; + let world_id = WorldId::from_uuid(world_id); let pixel_info_uc: &dyn PixelInfoQueryUseCase = &*state.pixel_info_query_service; let app_pixel_info = pixel_info_uc - .get_pixel_info(coord) + .get_pixel_info(&world_id, coord) .await .map_err(HttpError)?; diff --git a/adapters/src/incoming/http_axum/handlers/tiles.rs b/adapters/src/incoming/http_axum/handlers/tiles.rs index 4e95661..b79e5f7 100644 --- a/adapters/src/incoming/http_axum/handlers/tiles.rs +++ b/adapters/src/incoming/http_axum/handlers/tiles.rs @@ -1,12 +1,13 @@ use axum::{ Json, - extract::State, + extract::{Path, State}, http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use axum_login::AuthSession; use axum_valid::Valid; -use domain::auth::UserId; +use domain::{auth::UserId, world::WorldId}; +use uuid::Uuid; use fedi_wplace_application::error::AppError; @@ -14,7 +15,7 @@ use crate::incoming::http_axum::{ auth::backend::AuthBackend, core::{ etag, - extractors::{IfNoneMatchHeader, TilePath, extract_tile_coord}, + extractors::{IfNoneMatchHeader, extract_tile_coord}, }, dto::{ requests::BatchPaintPixelsRequest, @@ -42,7 +43,12 @@ fn is_not_modified(if_none_match: &IfNoneMatchHeader, etag: &str) -> bool { #[cfg_attr(feature = "docs", utoipa::path( get, - path = "/tiles/{x}/{y}", + path = "/worlds/{world_id}/tiles/{x}/{y}", + params( + ("world_id" = Uuid, Path, description = "World ID"), + ("x" = i32, Path, description = "Tile X coordinate"), + ("y" = i32, Path, description = "Tile Y coordinate") + ), responses( (status = 200, response = TileImageResponse), (status = 304, response = NotModifiedResponse), @@ -58,16 +64,17 @@ fn is_not_modified(if_none_match: &IfNoneMatchHeader, etag: &str) -> bool { operation_id = "get_tile" ))] pub async fn serve_tile( - tile_path: TilePath, + Path((world_id, x, y)): Path<(Uuid, i32, i32)>, _headers: HeaderMap, if_none_match: IfNoneMatchHeader, State(state): State, ) -> Result { - let coord = extract_tile_coord(tile_path)?; + let coord = extract_tile_coord(Path((x, y)))?; + let world_id = WorldId::from_uuid(world_id); let tile_query_uc: &dyn TilesQueryUseCase = &*state.tiles_query_service; let tile_data = tile_query_uc - .get_tile_webp(coord) + .get_tile_webp(&world_id, coord) .await .map_err(HttpError)?; @@ -89,7 +96,12 @@ pub async fn serve_tile( #[cfg_attr(feature = "docs", utoipa::path( head, - path = "/tiles/{x}/{y}", + path = "/worlds/{world_id}/tiles/{x}/{y}", + params( + ("world_id" = Uuid, Path, description = "World ID"), + ("x" = i32, Path, description = "Tile X coordinate"), + ("y" = i32, Path, description = "Tile Y coordinate") + ), responses( (status = 200, description = "Tile headers - HEAD", headers( ("ETag" = String), @@ -111,16 +123,17 @@ pub async fn serve_tile( operation_id = "get_tile_head" ))] pub async fn serve_tile_head( - tile_path: TilePath, + Path((world_id, x, y)): Path<(Uuid, i32, i32)>, _headers: HeaderMap, if_none_match: IfNoneMatchHeader, State(state): State, ) -> Result { - let coord = extract_tile_coord(tile_path)?; + let coord = extract_tile_coord(Path((x, y)))?; + let world_id = WorldId::from_uuid(world_id); let tile_query_uc: &dyn TilesQueryUseCase = &*state.tiles_query_service; let tile_version = tile_query_uc - .get_tile_version(coord) + .get_tile_version(&world_id, coord) .await .map_err(HttpError)?; @@ -139,7 +152,12 @@ pub async fn serve_tile_head( #[cfg_attr(feature = "docs", utoipa::path( post, - path = "/tiles/{x}/{y}/pixels", + path = "/worlds/{world_id}/tiles/{x}/{y}/pixels", + params( + ("world_id" = Uuid, Path, description = "World ID"), + ("x" = i32, Path, description = "Tile X coordinate"), + ("y" = i32, Path, description = "Tile Y coordinate") + ), request_body = BatchPaintPixelsRequest, responses( (status = 200, body = PaintPixelResponse), @@ -157,7 +175,7 @@ pub async fn serve_tile_head( operation_id = "paint_pixels_batch" ))] pub async fn paint_pixels_batch( - tile_path: TilePath, + Path((world_id, x, y)): Path<(Uuid, i32, i32)>, State(state): State, auth_session: AuthSession, Valid(Json(paint_req)): Valid>, @@ -166,7 +184,8 @@ pub async fn paint_pixels_batch( return Err(HttpError(AppError::Unauthorized)); }; - let tile_coord = extract_tile_coord(tile_path)?; + let tile_coord = extract_tile_coord(Path((x, y)))?; + let world_id = WorldId::from_uuid(world_id); let pixels: Vec<_> = paint_req .pixels @@ -176,7 +195,7 @@ pub async fn paint_pixels_batch( let paint_uc: &dyn PaintPixelsUseCase = &*state.paint_pixels_service; let painting_result = paint_uc - .paint_pixels_batch(UserId::from_uuid(user.id), tile_coord, &pixels) + .paint_pixels_batch(&world_id, UserId::from_uuid(user.id), tile_coord, &pixels) .await .map_err(HttpError)?; diff --git a/adapters/src/incoming/http_axum/handlers/worlds.rs b/adapters/src/incoming/http_axum/handlers/worlds.rs new file mode 100644 index 0000000..18a51b6 --- /dev/null +++ b/adapters/src/incoming/http_axum/handlers/worlds.rs @@ -0,0 +1,210 @@ +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use axum_login::AuthSession; +use uuid::Uuid; + +#[cfg(feature = "docs")] +use utoipa::ToSchema; + +use domain::world::{World, WorldId}; +use fedi_wplace_application::{error::AppError, world::service::WorldService}; + +use crate::{ + incoming::http_axum::{auth::backend::AuthBackend, error_mapper::HttpError}, + shared::app_state::AppState, +}; + +#[derive(serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "docs", derive(utoipa::ToSchema))] +pub struct PaletteEntry { + pub id: i16, + pub hex_color: String, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "docs", derive(utoipa::ToSchema))] +pub struct WorldResponse { + pub id: Uuid, + pub name: String, + pub created_at: String, + pub updated_at: String, + pub tile_size: usize, + pub pixel_size: usize, + pub colors: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[cfg_attr(feature = "docs", derive(utoipa::ToSchema))] +pub struct CreateWorldRequest { + pub name: String, +} + +async fn build_world_response(world: World, state: &AppState) -> Result { + let palette_colors = state.world_service.get_palette_colors(&world.id).await?; + + let colors: Vec = palette_colors + .into_iter() + .map(|palette_color| PaletteEntry { + id: palette_color.palette_index, + hex_color: palette_color.hex_color.0, + }) + .collect(); + + Ok(WorldResponse { + id: *world.id.as_uuid(), + name: world.name, + created_at: world.created_at.to_string(), + updated_at: world.updated_at.to_string(), + tile_size: state.config.tiles.tile_size, + pixel_size: state.config.tiles.pixel_size, + colors, + }) +} + +#[cfg_attr( + feature = "docs", + utoipa::path( + get, + path = "/worlds", + responses( + (status = 200, description = "List of all worlds with their configuration", body = Vec), + (status = 500, description = "Internal server error") + ), + tag = "worlds" + ) +)] +pub async fn list_worlds( + State(state): State, +) -> Result>, HttpError> { + let world_service: &WorldService = &state.world_service; + let worlds = world_service.list_worlds().await.map_err(HttpError)?; + + let mut response = Vec::new(); + for world in worlds { + let world_response = build_world_response(world, &state) + .await + .map_err(HttpError)?; + response.push(world_response); + } + + Ok(Json(response)) +} + +#[cfg_attr( + feature = "docs", + utoipa::path( + get, + path = "/worlds/{world_id}", + params( + ("world_id" = Uuid, Path, description = "World UUID") + ), + responses( + (status = 200, description = "World configuration retrieved successfully", body = WorldResponse), + (status = 404, description = "World not found"), + (status = 500, description = "Internal server error") + ), + tag = "worlds" + ) +)] +pub async fn get_world_by_id( + Path(world_id): Path, + State(state): State, +) -> Result, HttpError> { + let world_service: &WorldService = &state.world_service; + let world_id = WorldId::from_uuid(world_id); + + let world = world_service + .get_world_by_id(&world_id) + .await + .map_err(HttpError)? + .ok_or(HttpError(AppError::NotFound { + message: "World not found".to_string(), + }))?; + + let response = build_world_response(world, &state) + .await + .map_err(HttpError)?; + + Ok(Json(response)) +} + +#[cfg_attr( + feature = "docs", + utoipa::path( + get, + path = "/worlds/by-name/{name}", + params( + ("name" = String, Path, description = "World name") + ), + responses( + (status = 200, description = "World configuration retrieved successfully", body = WorldResponse), + (status = 404, description = "World not found"), + (status = 500, description = "Internal server error") + ), + tag = "worlds" + ) +)] +pub async fn get_world_by_name( + Path(name): Path, + State(state): State, +) -> Result, HttpError> { + let world_service: &WorldService = &state.world_service; + + let world = world_service + .get_world_by_name(&name) + .await + .map_err(HttpError)? + .ok_or(HttpError(AppError::NotFound { + message: "World not found".to_string(), + }))?; + + let response = build_world_response(world, &state) + .await + .map_err(HttpError)?; + + Ok(Json(response)) +} + +#[cfg_attr( + feature = "docs", + utoipa::path( + post, + path = "/worlds", + request_body = CreateWorldRequest, + responses( + (status = 201, description = "World created successfully", body = WorldResponse), + (status = 401, description = "Not authenticated"), + (status = 403, description = "Not authorized (admin only)"), + (status = 500, description = "Internal server error") + ), + tag = "worlds" + ) +)] +pub async fn create_world( + State(state): State, + auth_session: AuthSession, + Json(req): Json, +) -> Result<(StatusCode, Json), HttpError> { + let Some(user) = auth_session.user else { + return Err(HttpError(AppError::Unauthorized)); + }; + + if !user.is_admin() { + return Err(HttpError(AppError::Forbidden)); + } + + let world_service: &WorldService = &state.world_service; + let world = world_service + .create_world(req.name) + .await + .map_err(HttpError)?; + + let response = build_world_response(world, &state) + .await + .map_err(HttpError)?; + + Ok((StatusCode::CREATED, Json(response))) +} diff --git a/adapters/src/incoming/http_axum/routes.rs b/adapters/src/incoming/http_axum/routes.rs index c2466ef..6515799 100644 --- a/adapters/src/incoming/http_axum/routes.rs +++ b/adapters/src/incoming/http_axum/routes.rs @@ -25,10 +25,11 @@ use crate::{ update_username_handler, verify_email_handler, }, ban::{ban_user, get_user_ban_status, list_active_bans, unban_user}, + canvas::get_canvas_config, health::health_check, - palette::get_palette, pixel_info::get_pixel_info, tiles::{paint_pixels_batch, serve_tile, serve_tile_head}, + worlds::{create_world, get_world_by_id, get_world_by_name, list_worlds}, }, middleware::{ admin_auth::require_admin_role, @@ -59,18 +60,20 @@ pub async fn build_application_router( let (auth_routes, auth_layer) = build_auth_routes(state, user_store, password_hasher, ban_store).await?; let tile_routes = build_tile_routes_with_auth(state, auth_layer.clone()); - let admin_routes = build_admin_routes_with_auth(auth_layer); + let admin_routes = build_admin_routes_with_auth(auth_layer.clone()); + + let world_routes = build_world_routes_with_auth(auth_layer); Ok(core_routes .merge(tile_routes) .merge(auth_routes) - .merge(admin_routes)) + .merge(admin_routes) + .merge(world_routes)) } fn build_core_routes() -> Router { let router = Router::new() - .route("/palette", get(get_palette)) - .route("/pixel/{x}/{y}", get(get_pixel_info)) + .route("/canvas/config", get(get_canvas_config)) .route("/live", get(websocket_handler)); #[cfg(feature = "docs")] @@ -88,8 +91,17 @@ fn build_tile_routes_with_auth( state: &AppState, auth_layer: AuthManagerLayer>, ) -> Router { - let tile_routes = Router::new().route("/tiles/{x}/{y}", get(serve_tile).head(serve_tile_head)); - let paint_routes = Router::new().route("/tiles/{x}/{y}/pixels", post(paint_pixels_batch)); + let tile_routes = Router::new() + .route( + "/worlds/{world_id}/tiles/{x}/{y}", + get(serve_tile).head(serve_tile_head), + ) + .route("/worlds/{world_id}/pixels/{x}/{y}", get(get_pixel_info)); + + let paint_routes = Router::new().route( + "/worlds/{world_id}/tiles/{x}/{y}/pixels", + post(paint_pixels_batch), + ); let tile_routes_final = if state.config.rate_limit.enabled { let tile_limiter = create_tile_rate_limiter(&state.config.rate_limit); @@ -127,6 +139,21 @@ fn build_admin_routes_with_auth( .with_auth(auth_layer) } +fn build_world_routes_with_auth( + auth_layer: AuthManagerLayer>, +) -> Router { + let public_routes = Router::new() + .route("/worlds", get(list_worlds)) + .route("/worlds/{world_id}", get(get_world_by_id)) + .route("/worlds/by-name/{name}", get(get_world_by_name)); + + let authenticated_routes = Router::new() + .route("/worlds", post(create_world)) + .with_auth(auth_layer); + + public_routes.merge(authenticated_routes) +} + async fn build_auth_routes( state: &AppState, user_store: DynUserStorePort, diff --git a/adapters/src/incoming/ws_axum/connection.rs b/adapters/src/incoming/ws_axum/connection.rs index b84d9d5..0f4723e 100644 --- a/adapters/src/incoming/ws_axum/connection.rs +++ b/adapters/src/incoming/ws_axum/connection.rs @@ -16,6 +16,7 @@ use crate::incoming::ws_axum::WsAdapterPolicy; use crate::incoming::ws_axum::protocol::{ClientMessage, RejectedTile, WSMessage}; use crate::shared::app_state::AppState; use domain::coords::TileCoord; +use domain::world::WorldId; use fedi_wplace_application::{ contracts::subscriptions::SubscriptionResult, error::AppError, ports::incoming::tiles::TilesQueryUseCase, @@ -48,16 +49,18 @@ pub struct Connection { subscriptions: SubscriptionManager, client_ip: IpAddr, heartbeat_interval: Option, + world_id: WorldId, } impl Connection { - pub fn new(socket: WebSocket, client_ip: IpAddr) -> (Self, SplitStream) { + pub fn new(socket: WebSocket, client_ip: IpAddr, world_id: WorldId) -> (Self, SplitStream) { let (sender, receiver) = socket.split(); let connection = Self { socket_sender: sender, subscriptions: SubscriptionManager::new(), client_ip, heartbeat_interval: None, + world_id, }; (connection, receiver) } @@ -185,7 +188,7 @@ impl Connection { match app_state .subscription_service - .subscribe(self.client_ip, &requested_tile_coordinates) + .subscribe(self.client_ip, &self.world_id, &requested_tile_coordinates) .await { Ok(subscription_result) => { @@ -272,7 +275,10 @@ impl Connection { ) -> ConnectionResult<()> { let tile_query_uc: &dyn TilesQueryUseCase = &*app_state.tiles_query_service; for tile_coordinate in newly_subscribed_tiles { - match tile_query_uc.get_tile_version(*tile_coordinate).await { + match tile_query_uc + .get_tile_version(&self.world_id, *tile_coordinate) + .await + { Ok(current_version) => { let version_message = WSMessage::tile_version(*tile_coordinate, current_version); @@ -301,7 +307,7 @@ impl Connection { ) -> ConnectionResult<()> { match state .subscription_service - .unsubscribe(self.client_ip, &tiles) + .unsubscribe(self.client_ip, &self.world_id, &tiles) .await { Ok(_redis_removed_tiles) => { @@ -351,7 +357,7 @@ impl Connection { match state .subscription_service - .refresh_subscriptions(self.client_ip, &tiles) + .refresh_subscriptions(self.client_ip, &self.world_id, &tiles) .await { Ok(()) => { @@ -385,7 +391,7 @@ impl Connection { if let Err(e) = state .subscription_service - .unsubscribe(self.client_ip, &subscribed_tiles) + .unsubscribe(self.client_ip, &self.world_id, &subscribed_tiles) .await { warn!( diff --git a/adapters/src/incoming/ws_axum/endpoint.rs b/adapters/src/incoming/ws_axum/endpoint.rs index ead795e..289c8d6 100644 --- a/adapters/src/incoming/ws_axum/endpoint.rs +++ b/adapters/src/incoming/ws_axum/endpoint.rs @@ -105,11 +105,28 @@ pub async fn websocket_handler( .into_response(); } + let default_world = match state.world_service.get_default_world().await { + Ok(Some(world)) => world, + Ok(None) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Default world not found", + ) + .into_response(); + } + Err(e) => { + tracing::error!("Failed to fetch default world: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") + .into_response(); + } + }; + let default_world_id = default_world.id; + ws.on_upgrade(move |socket| { let handler = if state.config.websocket.connection_buffer_size > 0 { - ConnectionHandler::new_with_buffering(socket, &state, client_ip) + ConnectionHandler::new_with_buffering(socket, &state, client_ip, default_world_id) } else { - ConnectionHandler::new(socket, &state, client_ip) + ConnectionHandler::new(socket, &state, client_ip, default_world_id) }; handler.run(state) }) diff --git a/adapters/src/incoming/ws_axum/handler.rs b/adapters/src/incoming/ws_axum/handler.rs index 53dc37e..f320d40 100644 --- a/adapters/src/incoming/ws_axum/handler.rs +++ b/adapters/src/incoming/ws_axum/handler.rs @@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn}; use crate::incoming::ws_axum::protocol::WSMessage; use crate::shared::app_state::AppState; -use domain::{events::TileVersionEvent, tile::TileVersion}; +use domain::{events::TileVersionEvent, tile::TileVersion, world::WorldId}; use super::{buffer::BufferedMessageHandler, connection::Connection}; @@ -44,8 +44,8 @@ pub struct ConnectionHandler { } impl ConnectionHandler { - pub fn new(socket: WebSocket, state: &AppState, client_ip: IpAddr) -> Self { - let (connection, message_receiver) = Connection::new(socket, client_ip); + pub fn new(socket: WebSocket, state: &AppState, client_ip: IpAddr, world_id: WorldId) -> Self { + let (connection, message_receiver) = Connection::new(socket, client_ip, world_id); let broadcast_receiver = state.ws_broadcast.subscribe(); Self { @@ -57,8 +57,8 @@ impl ConnectionHandler { } } - pub fn new_with_buffering(socket: WebSocket, state: &AppState, client_ip: IpAddr) -> Self { - let (connection, message_receiver) = Connection::new(socket, client_ip); + pub fn new_with_buffering(socket: WebSocket, state: &AppState, client_ip: IpAddr, world_id: WorldId) -> Self { + let (connection, message_receiver) = Connection::new(socket, client_ip, world_id); let broadcast_receiver = state.ws_broadcast.subscribe(); let (outgoing_sender, outgoing_receiver) = mpsc::unbounded_channel(); diff --git a/adapters/src/outgoing/postgres_sqlx/mod.rs b/adapters/src/outgoing/postgres_sqlx/mod.rs index 189a147..e7605ce 100644 --- a/adapters/src/outgoing/postgres_sqlx/mod.rs +++ b/adapters/src/outgoing/postgres_sqlx/mod.rs @@ -4,3 +4,4 @@ pub mod ban_store_postgres; pub mod credit_store_postgres; pub mod pixel_history_store_postgres; pub mod user_store_postgres; +pub mod world_store_postgres; diff --git a/adapters/src/outgoing/postgres_sqlx/pixel_history_store_postgres.rs b/adapters/src/outgoing/postgres_sqlx/pixel_history_store_postgres.rs index 81e0ea8..73fe11b 100644 --- a/adapters/src/outgoing/postgres_sqlx/pixel_history_store_postgres.rs +++ b/adapters/src/outgoing/postgres_sqlx/pixel_history_store_postgres.rs @@ -1,8 +1,10 @@ use domain::{ action::PaintAction, coords::{GlobalCoord, TileCoord}, + world::WorldId, }; use sqlx::{PgPool, types::time::OffsetDateTime}; +use std::collections::HashMap; use tracing::instrument; use uuid::Uuid; @@ -32,22 +34,55 @@ impl PostgresPixelHistoryStoreAdapter { #[async_trait::async_trait] impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { #[instrument(skip(self, actions))] - async fn record_paint_actions(&self, actions: &[PaintAction]) -> AppResult<()> { + async fn record_paint_actions( + &self, + world_id: &WorldId, + actions: &[PaintAction], + ) -> AppResult<()> { if actions.is_empty() { return Ok(()); } + let world_uuid = world_id.as_uuid(); + + let palette_map = self.executor.execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT palette_index, id FROM palette_colors + WHERE world_id = $1 + "#, + world_uuid + ) + .fetch_all(&self.pool) + }, + "Failed to fetch palette color mappings", + ) + .await? + .into_iter() + .map(|row| (row.palette_index, row.id)) + .collect::>(); + + let mut world_ids: Vec = Vec::with_capacity(actions.len()); let mut user_ids: Vec = Vec::with_capacity(actions.len()); let mut global_xs: Vec = Vec::with_capacity(actions.len()); let mut global_ys: Vec = Vec::with_capacity(actions.len()); - let mut color_ids: Vec = Vec::with_capacity(actions.len()); + let mut color_ids: Vec> = Vec::with_capacity(actions.len()); let mut timestamps: Vec = Vec::with_capacity(actions.len()); for action in actions { - user_ids.push(action.user_id.0); + world_ids.push(*world_uuid); + user_ids.push(*action.user_id.as_uuid()); global_xs.push(action.global_coord.x); global_ys.push(action.global_coord.y); - color_ids.push(i16::from(action.color_id.0)); + + let color_uuid = if action.color_id.is_transparent() { + None + } else { + palette_map.get(&action.color_id.id()).copied() + }; + + color_ids.push(color_uuid); timestamps.push(action.timestamp); } @@ -55,18 +90,19 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { || { sqlx::query!( r#" - INSERT INTO pixel_history (user_id, global_x, global_y, color_id, created_at) - SELECT * FROM UNNEST($1::UUID[], $2::INTEGER[], $3::INTEGER[], $4::SMALLINT[], $5::TIMESTAMPTZ[]) - ON CONFLICT (global_x, global_y) + INSERT INTO pixel_history (world_id, user_id, global_x, global_y, color_id, created_at) + SELECT * FROM UNNEST($1::UUID[], $2::UUID[], $3::INTEGER[], $4::INTEGER[], $5::UUID[], $6::TIMESTAMPTZ[]) + ON CONFLICT (world_id, global_x, global_y) DO UPDATE SET user_id = EXCLUDED.user_id, color_id = EXCLUDED.color_id, created_at = EXCLUDED.created_at "#, + &world_ids[..], &user_ids[..], &global_xs[..], &global_ys[..], - &color_ids[..], + &color_ids as &[Option], ×tamps[..] ) .execute(&self.pool) @@ -80,7 +116,12 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { } #[instrument(skip(self))] - async fn get_history_for_tile(&self, coord: TileCoord) -> AppResult> { + async fn get_history_for_tile( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult> { + let world_uuid = world_id.as_uuid(); let tile_size = self.tile_size as i32; let min_x = coord.x * tile_size; let max_x = min_x + tile_size - 1; @@ -98,14 +139,17 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { u.username, ph.global_x, ph.global_y, - ph.color_id, + COALESCE(pc.palette_index, -1) as "palette_index!", ph.created_at FROM pixel_history ph JOIN users u ON ph.user_id = u.id - WHERE ph.global_x >= $1 AND ph.global_x <= $2 - AND ph.global_y >= $3 AND ph.global_y <= $4 + LEFT JOIN palette_colors pc ON ph.color_id = pc.id + WHERE ph.world_id = $1 + AND ph.global_x >= $2 AND ph.global_x <= $3 + AND ph.global_y >= $4 AND ph.global_y <= $5 ORDER BY ph.created_at DESC "#, + world_uuid, min_x, max_x, min_y, @@ -131,7 +175,7 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { username: row.username, pixel_x, pixel_y, - color_id: row.color_id as u8, + color_id: row.palette_index as i16, timestamp: row.created_at, } }) @@ -147,7 +191,12 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { } #[instrument(skip(self))] - async fn get_current_tile_state(&self, coord: TileCoord) -> AppResult> { + async fn get_current_tile_state( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult> { + let world_uuid = world_id.as_uuid(); let tile_size = self.tile_size as i32; let min_x = coord.x * tile_size; let max_x = min_x + tile_size - 1; @@ -161,13 +210,16 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { sqlx::query!( r#" SELECT - global_x, - global_y, - color_id - FROM pixel_history - WHERE global_x >= $1 AND global_x <= $2 - AND global_y >= $3 AND global_y <= $4 + ph.global_x, + ph.global_y, + COALESCE(pc.palette_index, -1) as "palette_index!" + FROM pixel_history ph + LEFT JOIN palette_colors pc ON ph.color_id = pc.id + WHERE ph.world_id = $1 + AND ph.global_x >= $2 AND ph.global_x <= $3 + AND ph.global_y >= $4 AND ph.global_y <= $5 "#, + world_uuid, min_x, max_x, min_y, @@ -182,12 +234,12 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { ) .await?; - let current_state: Vec<(usize, usize, u8)> = pixels + let current_state: Vec<(usize, usize, i16)> = pixels .into_iter() .map(|row| { let pixel_x = (row.global_x - min_x) as usize; let pixel_y = (row.global_y - min_y) as usize; - (pixel_x, pixel_y, row.color_id as u8) + (pixel_x, pixel_y, row.palette_index as i16) }) .collect(); @@ -201,7 +253,12 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { } #[instrument(skip(self))] - async fn get_distinct_tile_count(&self, tile_size: usize) -> AppResult { + async fn get_distinct_tile_count( + &self, + world_id: &WorldId, + tile_size: usize, + ) -> AppResult { + let world_uuid = world_id.as_uuid(); let tile_size = tile_size as i32; let result = self @@ -212,8 +269,10 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { r#" SELECT COUNT(DISTINCT (global_x / $1, global_y / $1)) as count FROM pixel_history + WHERE world_id = $2 "#, - tile_size + tile_size, + world_uuid ) .fetch_one(&self.pool) }, @@ -227,7 +286,12 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { } #[instrument(skip(self))] - async fn get_pixel_info(&self, coord: GlobalCoord) -> AppResult> { + async fn get_pixel_info( + &self, + world_id: &WorldId, + coord: GlobalCoord, + ) -> AppResult> { + let world_uuid = world_id.as_uuid(); let pixel_info = self .executor .execute_with_timeout( @@ -237,12 +301,14 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { SELECT ph.user_id, u.username, - ph.color_id, + COALESCE(pc.palette_index, -1) as "palette_index!", ph.created_at FROM pixel_history ph JOIN users u ON ph.user_id = u.id - WHERE ph.global_x = $1 AND ph.global_y = $2 + LEFT JOIN palette_colors pc ON ph.color_id = pc.id + WHERE ph.world_id = $1 AND ph.global_x = $2 AND ph.global_y = $3 "#, + world_uuid, coord.x, coord.y ) @@ -258,7 +324,7 @@ impl PixelHistoryStorePort for PostgresPixelHistoryStoreAdapter { let result = pixel_info.map(|row| PixelInfo { user_id: row.user_id, username: row.username, - color_id: row.color_id as u8, + color_id: row.palette_index as i16, timestamp: row.created_at, }); diff --git a/adapters/src/outgoing/postgres_sqlx/user_store_postgres.rs b/adapters/src/outgoing/postgres_sqlx/user_store_postgres.rs index 1840a47..b8bf770 100644 --- a/adapters/src/outgoing/postgres_sqlx/user_store_postgres.rs +++ b/adapters/src/outgoing/postgres_sqlx/user_store_postgres.rs @@ -164,22 +164,23 @@ impl UserStorePort for PostgresUserStoreAdapter { #[instrument(skip(self))] async fn find_user_by_username(&self, username: &str) -> AppResult> { - let row = self.executor.execute_with_timeout( - || { - sqlx::query!( - r#" + let row = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" SELECT id, email, username, email_verified_at, available_charges, charges_updated_at FROM users WHERE username = $1 "#, - username - ) - .fetch_optional(&self.pool) - }, - &format!("Failed to find user by username {}", username), - - ) - .await?; + username + ) + .fetch_optional(&self.pool) + }, + &format!("Failed to find user by username {}", username), + ) + .await?; if let Some(record) = row { debug!("Found user by username {} with id {}", username, record.id); @@ -202,22 +203,23 @@ impl UserStorePort for PostgresUserStoreAdapter { #[instrument(skip(self))] async fn find_user_by_id(&self, id: Uuid) -> AppResult> { - let row = self.executor.execute_with_timeout( - || { - sqlx::query!( - r#" + let row = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" SELECT id, email, username, email_verified_at, available_charges, charges_updated_at FROM users WHERE id = $1 "#, - id - ) - .fetch_optional(&self.pool) - }, - &format!("Failed to find user by id {}", id), - - ) - .await?; + id + ) + .fetch_optional(&self.pool) + }, + &format!("Failed to find user by id {}", id), + ) + .await?; if let Some(record) = row { debug!("Found user by id {}", id); @@ -246,27 +248,28 @@ impl UserStorePort for PostgresUserStoreAdapter { email: Option<&str>, username: Option<&str>, ) -> AppResult { - let existing_identity = self.executor.execute_with_timeout( - || { - sqlx::query!( - r#" + let existing_identity = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" SELECT ui.user_id, u.email, u.username, u.email_verified_at, u.available_charges, u.charges_updated_at FROM user_identities ui JOIN users u ON ui.user_id = u.id WHERE ui.provider = $1 AND ui.provider_user_id = $2 "#, - provider, - provider_user_id - ) - .fetch_optional(&self.pool) - }, - &format!( - "Failed to find identity for provider {} user {}", - provider, provider_user_id - ), - - ) - .await?; + provider, + provider_user_id + ) + .fetch_optional(&self.pool) + }, + &format!( + "Failed to find identity for provider {} user {}", + provider, provider_user_id + ), + ) + .await?; if let Some(identity_record) = existing_identity { debug!( diff --git a/adapters/src/outgoing/postgres_sqlx/world_store_postgres.rs b/adapters/src/outgoing/postgres_sqlx/world_store_postgres.rs new file mode 100644 index 0000000..02847bd --- /dev/null +++ b/adapters/src/outgoing/postgres_sqlx/world_store_postgres.rs @@ -0,0 +1,205 @@ +use super::utils::PostgresExecutor; +use domain::{ + color::HexColor, + world::{PaletteColor, World, WorldId}, +}; +use fedi_wplace_application::{error::AppResult, ports::outgoing::world_store::WorldStorePort}; +use sqlx::PgPool; +use tracing::instrument; + +pub struct PostgresWorldStoreAdapter { + pool: PgPool, + executor: PostgresExecutor, +} + +impl PostgresWorldStoreAdapter { + pub fn new(pool: PgPool, query_timeout_secs: u64) -> Self { + Self { + pool, + executor: PostgresExecutor::new(query_timeout_secs), + } + } +} + +#[async_trait::async_trait] +impl WorldStorePort for PostgresWorldStoreAdapter { + #[instrument(skip(self))] + async fn get_world_by_id(&self, world_id: &WorldId) -> AppResult> { + let world_uuid = world_id.as_uuid(); + + let row = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT id, name, is_default, created_at, updated_at + FROM worlds + WHERE id = $1 + "#, + world_uuid + ) + .fetch_optional(&self.pool) + }, + &format!("Failed to get world by id {}", world_uuid), + ) + .await?; + + Ok(row.map(|r| World { + id: WorldId::from_uuid(r.id), + name: r.name, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + #[instrument(skip(self))] + async fn get_world_by_name(&self, name: &str) -> AppResult> { + let row = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT id, name, is_default, created_at, updated_at + FROM worlds + WHERE name = $1 + "#, + name + ) + .fetch_optional(&self.pool) + }, + &format!("Failed to get world by name {}", name), + ) + .await?; + + Ok(row.map(|r| World { + id: WorldId::from_uuid(r.id), + name: r.name, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + #[instrument(skip(self))] + async fn get_default_world(&self) -> AppResult> { + let row = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT id, name, is_default, created_at, updated_at + FROM worlds + WHERE is_default = TRUE + "# + ) + .fetch_optional(&self.pool) + }, + "Failed to get default world", + ) + .await?; + + Ok(row.map(|r| World { + id: WorldId::from_uuid(r.id), + name: r.name, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + #[instrument(skip(self))] + async fn list_worlds(&self) -> AppResult> { + let rows = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT id, name, is_default, created_at, updated_at + FROM worlds + ORDER BY created_at DESC + "# + ) + .fetch_all(&self.pool) + }, + "Failed to list worlds", + ) + .await?; + + Ok(rows + .into_iter() + .map(|r| World { + id: WorldId::from_uuid(r.id), + name: r.name, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect()) + } + + #[instrument(skip(self, world))] + async fn create_world(&self, world: &World) -> AppResult<()> { + let world_id = world.id.as_uuid(); + + self.executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + INSERT INTO worlds (id, name, is_default, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5) + "#, + world_id, + world.name, + world.is_default, + world.created_at, + world.updated_at + ) + .execute(&self.pool) + }, + &format!("Failed to create world {}", world.name), + ) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_palette_colors(&self, world_id: &WorldId) -> AppResult> { + let world_uuid = world_id.as_uuid(); + + let rows = self + .executor + .execute_with_timeout( + || { + sqlx::query!( + r#" + SELECT id, world_id, palette_index, hex_color + FROM palette_colors + WHERE world_id = $1 + ORDER BY palette_index + "#, + world_uuid + ) + .fetch_all(&self.pool) + }, + &format!("Failed to get palette colors for world {}", world_uuid), + ) + .await?; + + Ok(rows + .into_iter() + .map(|r| PaletteColor { + id: r.id, + world_id: WorldId::from_uuid(r.world_id), + palette_index: r.palette_index, + hex_color: HexColor::new(r.hex_color), + }) + .collect()) + } +} diff --git a/adapters/src/outgoing/redis_deadpool/keys.rs b/adapters/src/outgoing/redis_deadpool/keys.rs index dfb8c01..f783ccf 100644 --- a/adapters/src/outgoing/redis_deadpool/keys.rs +++ b/adapters/src/outgoing/redis_deadpool/keys.rs @@ -1,36 +1,61 @@ +use uuid::Uuid; + #[derive(Clone)] pub struct RedisKeyBuilder { - namespace: String, + tile_namespace: String, + subscription_namespace: String, } impl RedisKeyBuilder { pub fn new(environment: &str) -> Self { + let root_namespace = "fediplace"; Self { - namespace: format!("fediplace:{}:tile:v2", environment), + tile_namespace: format!("{}:{}:tile:v3", root_namespace, environment), + subscription_namespace: format!("{}:{}:sub:v3", root_namespace, environment), } } - pub fn current_key(&self, x: i32, y: i32) -> String { - format!("{}:{}:{}:current", self.namespace, x, y) + pub fn current_key(&self, world_id: &Uuid, x: i32, y: i32) -> String { + format!("{}:{}:{}:{}:current", self.tile_namespace, world_id, x, y) } - pub fn webp_key(&self, x: i32, y: i32, version: u64) -> String { - format!("{}:{}:{}:webp:v{}", self.namespace, x, y, version) + pub fn webp_key(&self, world_id: &Uuid, x: i32, y: i32, version: u64) -> String { + format!( + "{}:{}:{}:{}:webp:v{}", + self.tile_namespace, world_id, x, y, version + ) } - pub fn rgba_key(&self, x: i32, y: i32, version: u64) -> String { - format!("{}:{}:{}:rgba:v{}", self.namespace, x, y, version) + pub fn rgba_key(&self, world_id: &Uuid, x: i32, y: i32, version: u64) -> String { + format!( + "{}:{}:{}:{}:rgba:v{}", + self.tile_namespace, world_id, x, y, version + ) } - pub fn palette_key(&self, x: i32, y: i32, version: u64) -> String { - format!("{}:{}:{}:palette:v{}", self.namespace, x, y, version) + pub fn palette_key(&self, world_id: &Uuid, x: i32, y: i32, version: u64) -> String { + format!( + "{}:{}:{}:{}:palette:v{}", + self.tile_namespace, world_id, x, y, version + ) } - pub fn missing_sentinel_key(&self, x: i32, y: i32) -> String { - format!("{}:{}:{}:exists:false", self.namespace, x, y) + pub fn missing_sentinel_key(&self, world_id: &Uuid, x: i32, y: i32) -> String { + format!( + "{}:{}:{}:{}:exists:false", + self.tile_namespace, world_id, x, y + ) } pub fn namespace_prefix(&self) -> String { - format!("{}:*", self.namespace) + format!("{}:*", self.tile_namespace) + } + + pub fn world_namespace_prefix(&self, world_id: &Uuid) -> String { + format!("{}:{}:*", self.tile_namespace, world_id) + } + + pub fn subscription_key(&self, world_id: &Uuid, ip_key: &str) -> String { + format!("{}:{}:{}", self.subscription_namespace, world_id, ip_key) } } diff --git a/adapters/src/outgoing/redis_deadpool/subscription_redis.rs b/adapters/src/outgoing/redis_deadpool/subscription_redis.rs index 3eb5e35..b669fe1 100644 --- a/adapters/src/outgoing/redis_deadpool/subscription_redis.rs +++ b/adapters/src/outgoing/redis_deadpool/subscription_redis.rs @@ -9,8 +9,8 @@ use deadpool_redis::{ use tokio::time::timeout; use tracing::debug; -use crate::shared::net::ip_key; -use domain::coords::TileCoord; +use crate::{outgoing::redis_deadpool::keys::RedisKeyBuilder, shared::net::ip_key}; +use domain::{coords::TileCoord, world::WorldId}; use fedi_wplace_application::{ contracts::subscriptions::{SubscriptionRejection, SubscriptionResult}, error::{AppError, AppResult}, @@ -87,15 +87,26 @@ pub struct RedisSubscriptionConfig { pub struct RedisSubscriptionAdapter { redis_pool: RedisPool, policy: SubscriptionPolicyConfig, + key_builder: RedisKeyBuilder, } impl RedisSubscriptionAdapter { - pub fn new(redis_pool: RedisPool, max_tiles_per_ip: usize, subscription_ttl_ms: u64) -> Self { + pub fn new( + redis_pool: RedisPool, + max_tiles_per_ip: usize, + subscription_ttl_ms: u64, + environment: &str, + ) -> Self { let policy = SubscriptionPolicyConfig { max_tiles_per_ip, ttl_ms: subscription_ttl_ms, }; - Self { redis_pool, policy } + let key_builder = RedisKeyBuilder::new(environment); + Self { + redis_pool, + policy, + key_builder, + } } fn subscription_policy(&self) -> &SubscriptionPolicyConfig { @@ -309,9 +320,17 @@ async fn refresh_subscription_expiration_times( #[async_trait::async_trait] impl SubscriptionPort for RedisSubscriptionAdapter { - async fn subscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult { + async fn subscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult { let policy = self.subscription_policy(); - let ip_key = ip_key(ip); + let ip_key_raw = ip_key(ip); + let ip_key = self + .key_builder + .subscription_key(world_id.as_uuid(), &ip_key_raw); let redis_connection_timeout = Duration::from_millis(500); let mut redis_conn = timeout(redis_connection_timeout, self.redis_pool.get()) @@ -372,8 +391,16 @@ impl SubscriptionPort for RedisSubscriptionAdapter { Ok(SubscriptionResult { accepted, rejected }) } - async fn unsubscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult> { - let ip_key = ip_key(ip); + async fn unsubscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult> { + let ip_key_raw = ip_key(ip); + let ip_key = self + .key_builder + .subscription_key(world_id.as_uuid(), &ip_key_raw); let redis_connection_timeout = Duration::from_millis(500); let mut redis_conn = timeout(redis_connection_timeout, self.redis_pool.get()) @@ -408,13 +435,21 @@ impl SubscriptionPort for RedisSubscriptionAdapter { Ok(unsubscribed) } - async fn refresh_subscriptions(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult<()> { + async fn refresh_subscriptions( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult<()> { if tiles.is_empty() { return Ok(()); } let policy = self.subscription_policy(); - let ip_key = ip_key(ip); + let ip_key_raw = ip_key(ip); + let ip_key = self + .key_builder + .subscription_key(world_id.as_uuid(), &ip_key_raw); let redis_connection_timeout = Duration::from_millis(500); let mut redis_conn = timeout(redis_connection_timeout, self.redis_pool.get()) diff --git a/adapters/src/outgoing/redis_deadpool/tile_cache_redis.rs b/adapters/src/outgoing/redis_deadpool/tile_cache_redis.rs index 921eb17..16f0f42 100644 --- a/adapters/src/outgoing/redis_deadpool/tile_cache_redis.rs +++ b/adapters/src/outgoing/redis_deadpool/tile_cache_redis.rs @@ -6,7 +6,7 @@ use std::time::Duration; use tokio::time::timeout; use tracing::{debug, warn}; -use domain::coords::TileCoord; +use domain::{coords::TileCoord, world::WorldId}; use fedi_wplace_application::{ error::{AppError, AppResult}, ports::outgoing::tile_cache::TileCachePort, @@ -78,27 +78,49 @@ impl RedisTileCacheAdapter { #[async_trait::async_trait] impl TileCachePort for RedisTileCacheAdapter { - async fn get_version(&self, coord: TileCoord) -> AppResult> { + async fn get_version(&self, world_id: &WorldId, coord: TileCoord) -> AppResult> { let mut conn = self.get_redis_connection().await?; - let current_key = self.redis_keys.current_key(coord.x, coord.y); + let current_key = self + .redis_keys + .current_key(world_id.as_uuid(), coord.x, coord.y); match conn.get::<_, u64>(¤t_key).await { Ok(version) => { - debug!("Found version {} in Redis for tile {}", version, coord); + debug!( + "Found version {} in Redis for tile {} in world {:?}", + version, coord, world_id + ); Ok(Some(version)) } Err(_) => Ok(None), } } - async fn get_palette(&self, coord: TileCoord, version: u64) -> AppResult>> { + async fn get_palette( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) -> AppResult>> { let mut conn = self.get_redis_connection().await?; - let palette_key = self.redis_keys.palette_key(coord.x, coord.y, version); + let palette_key = + self.redis_keys + .palette_key(world_id.as_uuid(), coord.x, coord.y, version); match conn.get::<_, Vec>(&palette_key).await { Ok(palette_bytes) if !palette_bytes.is_empty() => { debug!("Palette cache hit for tile {} v{}", coord, version); - Ok(Some(palette_bytes)) + let palette_i16 = palette_bytes + .chunks_exact(2) + .filter_map(|chunk| { + if let [a, b] = chunk { + Some(i16::from_le_bytes([*a, *b])) + } else { + None + } + }) + .collect(); + Ok(Some(palette_i16)) } _ => { debug!("Palette cache miss for tile {} v{}", coord, version); @@ -107,12 +129,22 @@ impl TileCachePort for RedisTileCacheAdapter { } } - async fn store_palette(&self, coord: TileCoord, version: u64, data: &[u8]) -> AppResult<()> { + async fn store_palette( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[i16], + ) -> AppResult<()> { let mut conn = self.get_redis_connection().await?; - let palette_key = self.redis_keys.palette_key(coord.x, coord.y, version); + let palette_key = + self.redis_keys + .palette_key(world_id.as_uuid(), coord.x, coord.y, version); + + let bytes: Vec = data.iter().flat_map(|&val| val.to_le_bytes()).collect(); let _: () = conn - .set_ex(&palette_key, data, self.ttls.rgba) + .set_ex(&palette_key, bytes, self.ttls.rgba) .await .map_err(|e| AppError::CacheError { message: format!("Failed to store palette data for tile {}: {}", coord, e), @@ -122,9 +154,16 @@ impl TileCachePort for RedisTileCacheAdapter { Ok(()) } - async fn get_webp(&self, coord: TileCoord, version: u64) -> AppResult>> { + async fn get_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) -> AppResult>> { let mut conn = self.get_redis_connection().await?; - let webp_key = self.redis_keys.webp_key(coord.x, coord.y, version); + let webp_key = self + .redis_keys + .webp_key(world_id.as_uuid(), coord.x, coord.y, version); match conn.get::<_, Vec>(&webp_key).await { Ok(webp_bytes) if !webp_bytes.is_empty() => { @@ -138,7 +177,13 @@ impl TileCachePort for RedisTileCacheAdapter { } } - async fn store_webp(&self, coord: TileCoord, version: u64, data: &[u8]) -> AppResult<()> { + async fn store_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[u8], + ) -> AppResult<()> { if data.is_empty() { warn!( "Refusing to cache empty WebP data for tile {} v{}", @@ -148,7 +193,9 @@ impl TileCachePort for RedisTileCacheAdapter { } let mut conn = self.get_redis_connection().await?; - let webp_key = self.redis_keys.webp_key(coord.x, coord.y, version); + let webp_key = self + .redis_keys + .webp_key(world_id.as_uuid(), coord.x, coord.y, version); let _: () = conn .set_ex(&webp_key, data, self.ttls.webp) @@ -166,9 +213,11 @@ impl TileCachePort for RedisTileCacheAdapter { Ok(()) } - async fn has_missing_sentinel(&self, coord: TileCoord) -> AppResult { + async fn has_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult { let mut conn = self.get_redis_connection().await?; - let missing_sentinel_key = self.redis_keys.missing_sentinel_key(coord.x, coord.y); + let missing_sentinel_key = + self.redis_keys + .missing_sentinel_key(world_id.as_uuid(), coord.x, coord.y); match conn.get::<_, bool>(&missing_sentinel_key).await { Ok(exists) => Ok(exists), @@ -176,9 +225,11 @@ impl TileCachePort for RedisTileCacheAdapter { } } - async fn set_missing_sentinel(&self, coord: TileCoord) -> AppResult<()> { + async fn set_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult<()> { let mut conn = self.get_redis_connection().await?; - let missing_sentinel_key = self.redis_keys.missing_sentinel_key(coord.x, coord.y); + let missing_sentinel_key = + self.redis_keys + .missing_sentinel_key(world_id.as_uuid(), coord.x, coord.y); let _: () = conn .set_ex(&missing_sentinel_key, true, self.ttls.missing) @@ -194,9 +245,11 @@ impl TileCachePort for RedisTileCacheAdapter { Ok(()) } - async fn clear_missing_sentinel(&self, coord: TileCoord) -> AppResult<()> { + async fn clear_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult<()> { let mut conn = self.get_redis_connection().await?; - let missing_sentinel_key = self.redis_keys.missing_sentinel_key(coord.x, coord.y); + let missing_sentinel_key = + self.redis_keys + .missing_sentinel_key(world_id.as_uuid(), coord.x, coord.y); let _: () = conn.del(&missing_sentinel_key).await.map_err(|e| { warn!("Failed to clear missing sentinel for tile {}: {}", coord, e); @@ -209,9 +262,16 @@ impl TileCachePort for RedisTileCacheAdapter { Ok(()) } - async fn update_version_optimistically(&self, coord: TileCoord, version: u64) { + async fn update_version_optimistically( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) { if let Ok(mut conn) = self.get_redis_connection().await { - let current_key = self.redis_keys.current_key(coord.x, coord.y); + let current_key = self + .redis_keys + .current_key(world_id.as_uuid(), coord.x, coord.y); let _: () = conn .set_ex(¤t_key, version, self.ttls.current) .await @@ -219,11 +279,20 @@ impl TileCachePort for RedisTileCacheAdapter { } } - async fn store_palette_optimistically(&self, coord: TileCoord, version: u64, data: &[u8]) { + async fn store_palette_optimistically( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[i16], + ) { if let Ok(mut conn) = self.get_redis_connection().await { - let palette_key = self.redis_keys.palette_key(coord.x, coord.y, version); + let palette_key = + self.redis_keys + .palette_key(world_id.as_uuid(), coord.x, coord.y, version); + let bytes: Vec = data.iter().flat_map(|&val| val.to_le_bytes()).collect(); if let Err(e) = conn - .set_ex::<_, _, ()>(&palette_key, data, self.ttls.rgba) + .set_ex::<_, _, ()>(&palette_key, bytes, self.ttls.rgba) .await { warn!( @@ -234,11 +303,11 @@ impl TileCachePort for RedisTileCacheAdapter { } } - async fn clear_cache(&self) -> AppResult<()> { + async fn clear_cache(&self, world_id: &WorldId) -> AppResult<()> { use tracing::info; let mut conn = self.get_redis_connection().await?; - let namespace_prefix = self.redis_keys.namespace_prefix(); + let namespace_prefix = self.redis_keys.world_namespace_prefix(world_id.as_uuid()); let mut cursor: u64 = 0; let mut redis_key_count = 0; diff --git a/adapters/src/shared/app_state.rs b/adapters/src/shared/app_state.rs index bac3c9b..fcff39c 100644 --- a/adapters/src/shared/app_state.rs +++ b/adapters/src/shared/app_state.rs @@ -10,15 +10,22 @@ use crate::incoming::http_axum::middleware::rate_limit::RateLimiter; use crate::incoming::ws_axum::WsAdapterPolicy; use domain::events::TileVersionEvent; -use fedi_wplace_application::ports::incoming::{ - admin::AdminUseCase, - auth::AuthUseCase, - ban::BanUseCase, - subscriptions::SubscriptionUseCase, - tiles::{ - MetricsQueryUseCase, PaintPixelsUseCase, PixelHistoryQueryUseCase, PixelInfoQueryUseCase, - TilesQueryUseCase, +use fedi_wplace_application::{ + canvas::service::CanvasConfigService, + ports::{ + incoming::{ + admin::AdminUseCase, + auth::AuthUseCase, + ban::BanUseCase, + subscriptions::SubscriptionUseCase, + tiles::{ + MetricsQueryUseCase, PaintPixelsUseCase, PixelHistoryQueryUseCase, + PixelInfoQueryUseCase, TilesQueryUseCase, + }, + }, + outgoing::credit_store::DynCreditStorePort, }, + world::service::WorldService, }; #[derive(Clone)] @@ -34,6 +41,9 @@ pub struct AppState { pub auth_use_case: Arc, pub admin_use_case: Arc, pub ban_use_case: Arc, + pub world_service: Arc, + pub canvas_config_service: Arc, + pub credit_store: DynCreditStorePort, pub ws_broadcast: broadcast::Sender, pub websocket_rate_limiter: Option>, pub active_websocket_connections: Arc, @@ -53,6 +63,9 @@ impl AppState { auth_use_case: Arc, admin_use_case: Arc, ban_use_case: Arc, + world_service: Arc, + canvas_config_service: Arc, + credit_store: DynCreditStorePort, ws_broadcast: broadcast::Sender, websocket_rate_limiter: Option>, active_websocket_connections: Arc, @@ -69,6 +82,9 @@ impl AppState { auth_use_case, admin_use_case, ban_use_case, + world_service, + canvas_config_service, + credit_store, ws_broadcast, websocket_rate_limiter, active_websocket_connections, diff --git a/application/src/canvas/mod.rs b/application/src/canvas/mod.rs new file mode 100644 index 0000000..1f278a4 --- /dev/null +++ b/application/src/canvas/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/application/src/canvas/service.rs b/application/src/canvas/service.rs new file mode 100644 index 0000000..a53cb9c --- /dev/null +++ b/application/src/canvas/service.rs @@ -0,0 +1,39 @@ +use crate::error::{AppError, AppResult}; +use crate::ports::outgoing::world_store::DynWorldStorePort; +use domain::canvas_config::CanvasConfig; +use domain::color::RgbColor; + +pub struct CanvasConfigService { + world_store: DynWorldStorePort, +} + +impl CanvasConfigService { + pub fn new(world_store: DynWorldStorePort) -> Self { + Self { world_store } + } + + pub async fn get_canvas_config(&self) -> AppResult { + let default_world = + self.world_store + .get_default_world() + .await? + .ok_or_else(|| AppError::NotFound { + message: "No default world found".to_string(), + })?; + + let palette_colors = self + .world_store + .get_palette_colors(&default_world.id) + .await?; + + let mut palette = Vec::new(); + + for palette_color in palette_colors { + if let Some(rgba_u32) = palette_color.hex_color.to_rgba_u32() { + palette.push(RgbColor::from_rgba_u32(rgba_u32)); + } + } + + Ok(CanvasConfig::new(default_world.id, palette)) + } +} diff --git a/application/src/config.rs b/application/src/config.rs index a88e78f..3d88e9e 100644 --- a/application/src/config.rs +++ b/application/src/config.rs @@ -1,4 +1,3 @@ -use crate::infrastructure_config::ColorPaletteConfig; use domain::color::RgbColor; use std::sync::Arc; @@ -7,6 +6,4 @@ pub struct TileSettings { pub tile_size: usize, pub pixel_size: usize, pub palette: Arc<[RgbColor]>, - pub transparency_color_id: u8, - pub color_palette_config: Arc, } diff --git a/application/src/error.rs b/application/src/error.rs index ab46555..14d31f2 100644 --- a/application/src/error.rs +++ b/application/src/error.rs @@ -62,6 +62,9 @@ pub enum AppError { #[error("Forbidden")] Forbidden, + #[error("Not found: {message}")] + NotFound { message: String }, + #[error("Email verification is required")] EmailNotVerified, diff --git a/application/src/infrastructure_config.rs b/application/src/infrastructure_config.rs index f2e138c..633744f 100644 --- a/application/src/infrastructure_config.rs +++ b/application/src/infrastructure_config.rs @@ -1,9 +1,7 @@ use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; -use std::collections::HashSet; use crate::error::{AppError, AppResult}; -use domain::color::RgbColor; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -17,7 +15,6 @@ pub struct Config { pub credits: CreditConfig, pub logging: LoggingConfig, pub environment: EnvironmentConfig, - pub color_palette: ColorPaletteConfig, pub auth: AuthConfig, } @@ -220,119 +217,6 @@ pub enum LogFormat { Pretty, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ColorPaletteConfig { - pub colors: Vec, - #[serde(default)] - pub special_colors: Vec, -} - -impl ColorPaletteConfig { - #[must_use] - pub fn get_color(&self, color_id: u8) -> Option<&RgbColor> { - self.colors.get(color_id as usize) - } - - pub fn get_color_or_special(&self, color_id: u8) -> Result, String> { - let regular_color_count = u8::try_from(self.colors.len()) - .map_err(|_| "Too many colors in palette".to_string())?; - - if color_id < regular_color_count { - Ok(self.colors.get(color_id as usize).copied()) - } else { - let special_index = (color_id - regular_color_count) as usize; - if let Some(purpose) = self.special_colors.get(special_index) { - match purpose.as_str() { - "transparency" => Ok(None), - _ => Err(format!( - "Special color '{purpose}' (ID {color_id}) cannot be used for painting" - )), - } - } else { - Err(format!("Invalid color ID: {color_id}")) - } - } - } - - #[must_use] - pub fn find_color_id(&self, color: &RgbColor) -> Option { - self.colors - .iter() - .position(|c| c == color) - .and_then(|index| u8::try_from(index).ok()) - } - - #[must_use] - pub fn get_special_colors_with_ids(&self) -> Vec<(u8, &String)> { - #[allow(clippy::cast_possible_truncation)] - let regular_color_count = self.colors.len() as u8; - self.special_colors - .iter() - .enumerate() - .map(|(index, purpose)| { - #[allow(clippy::cast_possible_truncation)] - let id = regular_color_count + index as u8; - (id, purpose) - }) - .collect() - } - - #[must_use] - pub fn get_special_color_ids(&self) -> HashSet { - let regular_color_count = u8::try_from(self.colors.len()).unwrap_or(u8::MAX); - (0..self.special_colors.len()) - .map(|index| regular_color_count.saturating_add(u8::try_from(index).unwrap_or(u8::MAX))) - .collect() - } - - #[must_use] - pub fn get_transparency_color_id(&self) -> Option { - let regular_color_count = u8::try_from(self.colors.len()).unwrap_or(u8::MAX); - self.special_colors - .iter() - .position(|purpose| purpose == "transparency") - .map(|index| regular_color_count.saturating_add(u8::try_from(index).unwrap_or(u8::MAX))) - } - - pub fn validate(&self) -> AppResult<()> { - if self.colors.is_empty() { - return Err(AppError::ConfigError { - message: "Color palette cannot be empty".to_string(), - }); - } - - if self.colors.len() > 256 { - return Err(AppError::ConfigError { - message: "Color palette cannot have more than 256 colors".to_string(), - }); - } - - let regular_color_count = self.colors.len(); - let total_colors = regular_color_count + self.special_colors.len(); - - if total_colors > 256 { - return Err(AppError::ConfigError { - message: format!( - "Total colors ({} regular + {} special) exceeds maximum of 256", - regular_color_count, - self.special_colors.len() - ), - }); - } - - let mut seen_purposes = HashSet::new(); - for purpose in &self.special_colors { - if !seen_purposes.insert(purpose) { - return Err(AppError::ConfigError { - message: format!("Duplicate special color purpose: '{purpose}'"), - }); - } - } - - Ok(()) - } -} - impl Default for EmailConfig { fn default() -> Self { Self { @@ -384,32 +268,6 @@ impl Default for AuthConfig { } } -impl Default for ColorPaletteConfig { - fn default() -> Self { - Self { - colors: vec![ - RgbColor::new(255, 255, 255), - RgbColor::new(0, 0, 0), - RgbColor::new(255, 0, 0), - RgbColor::new(0, 255, 0), - RgbColor::new(0, 0, 255), - RgbColor::new(255, 255, 0), - RgbColor::new(255, 0, 255), - RgbColor::new(0, 255, 255), - RgbColor::new(128, 128, 128), - RgbColor::new(255, 165, 0), - RgbColor::new(128, 0, 128), - RgbColor::new(0, 128, 0), - RgbColor::new(0, 0, 128), - RgbColor::new(128, 0, 0), - RgbColor::new(255, 192, 203), - RgbColor::new(165, 42, 42), - ], - special_colors: vec!["transparency".to_string()], - } - } -} - impl Default for Config { fn default() -> Self { Self { @@ -473,7 +331,6 @@ impl Default for Config { environment: EnvironmentConfig { env: "development".to_string(), }, - color_palette: ColorPaletteConfig::default(), auth: AuthConfig::default(), } } @@ -595,8 +452,6 @@ impl Config { } } - self.color_palette.validate()?; - if self.credits.max_charges <= 0 { return Err(AppError::ConfigError { message: "max_charges must be greater than 0".to_string(), diff --git a/application/src/lib.rs b/application/src/lib.rs index 07bdf9b..9715741 100644 --- a/application/src/lib.rs +++ b/application/src/lib.rs @@ -10,6 +10,7 @@ compile_error!("application must not depend on adapters/framework crates"); pub mod admin; pub mod auth; pub mod ban; +pub mod canvas; pub mod config; pub mod contracts; pub mod error; @@ -17,3 +18,4 @@ pub mod infrastructure_config; pub mod ports; pub mod subscriptions; pub mod tiles; +pub mod world; diff --git a/application/src/ports/incoming/subscriptions.rs b/application/src/ports/incoming/subscriptions.rs index d87c4c3..b1abf0f 100644 --- a/application/src/ports/incoming/subscriptions.rs +++ b/application/src/ports/incoming/subscriptions.rs @@ -1,13 +1,28 @@ use std::net::IpAddr; use crate::{contracts::subscriptions::SubscriptionResult, error::AppResult}; -use domain::coords::TileCoord; +use domain::{coords::TileCoord, world::WorldId}; #[async_trait::async_trait] pub trait SubscriptionUseCase: Send + Sync { - async fn subscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult; + async fn subscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult; - async fn unsubscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult>; + async fn unsubscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult>; - async fn refresh_subscriptions(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult<()>; + async fn refresh_subscriptions( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult<()>; } diff --git a/application/src/ports/incoming/tiles.rs b/application/src/ports/incoming/tiles.rs index 83e4963..4b7d3da 100644 --- a/application/src/ports/incoming/tiles.rs +++ b/application/src/ports/incoming/tiles.rs @@ -8,19 +8,29 @@ use domain::{ color::ColorId, coords::{GlobalCoord, PixelCoord, TileCoord}, tile::TileVersion, + world::WorldId, }; #[async_trait::async_trait] pub trait TilesQueryUseCase: Send + Sync { - async fn get_tile_webp(&self, coord: TileCoord) -> AppResult; + async fn get_tile_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult; - async fn get_tile_version(&self, coord: TileCoord) -> AppResult; + async fn get_tile_version( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult; } #[async_trait::async_trait] pub trait PaintPixelsUseCase: Send + Sync { async fn paint_pixels_batch( &self, + world_id: &WorldId, user_id: UserId, tile: TileCoord, pixels: &[(PixelCoord, ColorId)], @@ -29,15 +39,23 @@ pub trait PaintPixelsUseCase: Send + Sync { #[async_trait::async_trait] pub trait MetricsQueryUseCase: Send + Sync { - async fn get_metrics(&self) -> AppResult; + async fn get_metrics(&self, world_id: &WorldId) -> AppResult; } #[async_trait::async_trait] pub trait PixelHistoryQueryUseCase: Send + Sync { - async fn get_history_for_tile(&self, coord: TileCoord) -> AppResult>; + async fn get_history_for_tile( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult>; } #[async_trait::async_trait] pub trait PixelInfoQueryUseCase: Send + Sync { - async fn get_pixel_info(&self, coord: GlobalCoord) -> AppResult>; + async fn get_pixel_info( + &self, + world_id: &WorldId, + coord: GlobalCoord, + ) -> AppResult>; } diff --git a/application/src/ports/outgoing/blocking_task.rs b/application/src/ports/outgoing/blocking_task.rs deleted file mode 100644 index 8e3b82a..0000000 --- a/application/src/ports/outgoing/blocking_task.rs +++ /dev/null @@ -1,17 +0,0 @@ -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -#[derive(Debug)] -pub struct BlockingTaskError { - pub message: String, -} - -pub trait WebPEncodingPort: Send + Sync { - fn encode_webp_lossless( - &self, - rgba_pixels: Vec, - ) -> Pin, BlockingTaskError>> + Send + 'static>>; -} - -pub type DynWebPEncodingPort = Arc; diff --git a/application/src/ports/outgoing/mod.rs b/application/src/ports/outgoing/mod.rs index 002ce8b..3e7597c 100644 --- a/application/src/ports/outgoing/mod.rs +++ b/application/src/ports/outgoing/mod.rs @@ -1,10 +1,8 @@ pub mod ban_store; -pub mod blocking_task; pub mod credit_store; pub mod email_sender; pub mod events; pub mod image_codec; -pub mod palette_compression; pub mod password_hasher; pub mod pixel_history_store; pub mod subscription_port; @@ -12,3 +10,4 @@ pub mod task_spawn; pub mod tile_cache; pub mod timeout; pub mod user_store; +pub mod world_store; diff --git a/application/src/ports/outgoing/palette_compression.rs b/application/src/ports/outgoing/palette_compression.rs deleted file mode 100644 index bfc4455..0000000 --- a/application/src/ports/outgoing/palette_compression.rs +++ /dev/null @@ -1,9 +0,0 @@ -use crate::error::AppResult; -use std::sync::Arc; - -pub trait PaletteCompressionPort: Send + Sync { - fn compress(&self, palette_data: &[u8]) -> AppResult>; - fn decompress(&self, compressed_data: &[u8]) -> AppResult>; -} - -pub type DynPaletteCompressionPort = Arc; diff --git a/application/src/ports/outgoing/pixel_history_store.rs b/application/src/ports/outgoing/pixel_history_store.rs index 9152db5..35716be 100644 --- a/application/src/ports/outgoing/pixel_history_store.rs +++ b/application/src/ports/outgoing/pixel_history_store.rs @@ -2,6 +2,7 @@ use crate::error::AppResult; use domain::{ action::PaintAction, coords::{GlobalCoord, TileCoord}, + world::WorldId, }; use std::sync::Arc; use uuid::Uuid; @@ -12,7 +13,7 @@ pub struct PixelHistoryEntry { pub username: String, pub pixel_x: usize, pub pixel_y: usize, - pub color_id: u8, + pub color_id: i16, pub timestamp: time::OffsetDateTime, } @@ -20,17 +21,34 @@ pub struct PixelHistoryEntry { pub struct PixelInfo { pub user_id: Uuid, pub username: String, - pub color_id: u8, + pub color_id: i16, pub timestamp: time::OffsetDateTime, } #[async_trait::async_trait] pub trait PixelHistoryStorePort: Send + Sync { - async fn record_paint_actions(&self, actions: &[PaintAction]) -> AppResult<()>; - async fn get_history_for_tile(&self, coord: TileCoord) -> AppResult>; - async fn get_current_tile_state(&self, coord: TileCoord) -> AppResult>; - async fn get_distinct_tile_count(&self, tile_size: usize) -> AppResult; - async fn get_pixel_info(&self, coord: GlobalCoord) -> AppResult>; + async fn record_paint_actions( + &self, + world_id: &WorldId, + actions: &[PaintAction], + ) -> AppResult<()>; + async fn get_history_for_tile( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult>; + async fn get_current_tile_state( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult>; + async fn get_distinct_tile_count(&self, world_id: &WorldId, tile_size: usize) + -> AppResult; + async fn get_pixel_info( + &self, + world_id: &WorldId, + coord: GlobalCoord, + ) -> AppResult>; } pub type DynPixelHistoryStorePort = Arc; diff --git a/application/src/ports/outgoing/subscription_port.rs b/application/src/ports/outgoing/subscription_port.rs index ae2b70c..dd17bfe 100644 --- a/application/src/ports/outgoing/subscription_port.rs +++ b/application/src/ports/outgoing/subscription_port.rs @@ -1,11 +1,25 @@ -use std::net::IpAddr; - use crate::{contracts::subscriptions::SubscriptionResult, error::AppResult}; -use domain::coords::TileCoord; +use domain::{coords::TileCoord, world::WorldId}; +use std::net::IpAddr; #[async_trait::async_trait] pub trait SubscriptionPort: Send + Sync { - async fn subscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult; - async fn unsubscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult>; - async fn refresh_subscriptions(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult<()>; + async fn subscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult; + async fn unsubscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult>; + async fn refresh_subscriptions( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult<()>; } diff --git a/application/src/ports/outgoing/tile_cache.rs b/application/src/ports/outgoing/tile_cache.rs index be529d5..8a71e59 100644 --- a/application/src/ports/outgoing/tile_cache.rs +++ b/application/src/ports/outgoing/tile_cache.rs @@ -1,25 +1,58 @@ use crate::error::AppResult; -use domain::coords::TileCoord; +use domain::{coords::TileCoord, world::WorldId}; use std::sync::Arc; #[async_trait::async_trait] pub trait TileCachePort: Send + Sync { - async fn get_version(&self, coord: TileCoord) -> AppResult>; + async fn get_version(&self, world_id: &WorldId, coord: TileCoord) -> AppResult>; - async fn get_palette(&self, coord: TileCoord, version: u64) -> AppResult>>; - async fn store_palette(&self, coord: TileCoord, version: u64, data: &[u8]) -> AppResult<()>; + async fn get_palette( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) -> AppResult>>; + async fn store_palette( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[i16], + ) -> AppResult<()>; - async fn get_webp(&self, coord: TileCoord, version: u64) -> AppResult>>; - async fn store_webp(&self, coord: TileCoord, version: u64, data: &[u8]) -> AppResult<()>; + async fn get_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) -> AppResult>>; + async fn store_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[u8], + ) -> AppResult<()>; - async fn has_missing_sentinel(&self, coord: TileCoord) -> AppResult; - async fn set_missing_sentinel(&self, coord: TileCoord) -> AppResult<()>; - async fn clear_missing_sentinel(&self, coord: TileCoord) -> AppResult<()>; + async fn has_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult; + async fn set_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult<()>; + async fn clear_missing_sentinel(&self, world_id: &WorldId, coord: TileCoord) -> AppResult<()>; - async fn update_version_optimistically(&self, coord: TileCoord, version: u64); - async fn store_palette_optimistically(&self, coord: TileCoord, version: u64, data: &[u8]); + async fn update_version_optimistically( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ); + async fn store_palette_optimistically( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + data: &[i16], + ); - async fn clear_cache(&self) -> AppResult<()>; + async fn clear_cache(&self, world_id: &WorldId) -> AppResult<()>; } pub type DynTileCachePort = Arc; diff --git a/application/src/ports/outgoing/world_store.rs b/application/src/ports/outgoing/world_store.rs new file mode 100644 index 0000000..e035e8f --- /dev/null +++ b/application/src/ports/outgoing/world_store.rs @@ -0,0 +1,15 @@ +use crate::error::AppResult; +use domain::world::{PaletteColor, World, WorldId}; +use std::sync::Arc; + +#[async_trait::async_trait] +pub trait WorldStorePort: Send + Sync { + async fn get_world_by_id(&self, world_id: &WorldId) -> AppResult>; + async fn get_world_by_name(&self, name: &str) -> AppResult>; + async fn get_default_world(&self) -> AppResult>; + async fn list_worlds(&self) -> AppResult>; + async fn create_world(&self, world: &World) -> AppResult<()>; + async fn get_palette_colors(&self, world_id: &WorldId) -> AppResult>; +} + +pub type DynWorldStorePort = Arc; diff --git a/application/src/subscriptions/service.rs b/application/src/subscriptions/service.rs index 1a66110..86ec58d 100644 --- a/application/src/subscriptions/service.rs +++ b/application/src/subscriptions/service.rs @@ -7,7 +7,7 @@ use crate::{ incoming::subscriptions::SubscriptionUseCase, outgoing::subscription_port::SubscriptionPort, }, }; -use domain::coords::TileCoord; +use domain::{coords::TileCoord, world::WorldId}; pub struct SubscriptionService { subscription_port: Arc, @@ -21,35 +21,65 @@ impl SubscriptionService { pub async fn subscribe( &self, ip: IpAddr, + world_id: &WorldId, tiles: &[TileCoord], ) -> AppResult { - self.subscription_port.subscribe(ip, tiles).await + self.subscription_port.subscribe(ip, world_id, tiles).await } - pub async fn unsubscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult> { - self.subscription_port.unsubscribe(ip, tiles).await + pub async fn unsubscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult> { + self.subscription_port + .unsubscribe(ip, world_id, tiles) + .await } - pub async fn refresh_subscriptions(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult<()> { + pub async fn refresh_subscriptions( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult<()> { self.subscription_port - .refresh_subscriptions(ip, tiles) + .refresh_subscriptions(ip, world_id, tiles) .await } } #[async_trait::async_trait] impl SubscriptionUseCase for SubscriptionService { - async fn subscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult { - self.subscription_port.subscribe(ip, tiles).await + async fn subscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult { + self.subscription_port.subscribe(ip, world_id, tiles).await } - async fn unsubscribe(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult> { - self.subscription_port.unsubscribe(ip, tiles).await + async fn unsubscribe( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult> { + self.subscription_port + .unsubscribe(ip, world_id, tiles) + .await } - async fn refresh_subscriptions(&self, ip: IpAddr, tiles: &[TileCoord]) -> AppResult<()> { + async fn refresh_subscriptions( + &self, + ip: IpAddr, + world_id: &WorldId, + tiles: &[TileCoord], + ) -> AppResult<()> { self.subscription_port - .refresh_subscriptions(ip, tiles) + .refresh_subscriptions(ip, world_id, tiles) .await } } diff --git a/application/src/tiles/gateway.rs b/application/src/tiles/gateway.rs index 57e86ed..6a8051d 100644 --- a/application/src/tiles/gateway.rs +++ b/application/src/tiles/gateway.rs @@ -1,9 +1,3 @@ -use std::{ - sync::{Arc, atomic::Ordering}, - time::Duration, -}; -use tracing::debug; - use crate::{ config::TileSettings, error::{AppError, AppResult}, @@ -15,7 +9,13 @@ use crate::{ use domain::{ coords::TileCoord, tile::{PaletteBufferPool, Tile, TileVersion}, + world::WorldId, }; +use std::{ + sync::{Arc, atomic::Ordering}, + time::Duration, +}; +use tracing::debug; use super::util::{PaletteColorLookup, palette_to_rgba_pixels, populate_tile_from_rgba}; @@ -77,9 +77,9 @@ impl TileGateway { } } - pub async fn get_distinct_tile_count(&self) -> AppResult { + pub async fn get_distinct_tile_count(&self, world_id: &WorldId) -> AppResult { self.pixel_history_store - .get_distinct_tile_count(self.config.tile_size) + .get_distinct_tile_count(world_id, self.config.tile_size) .await } @@ -87,10 +87,16 @@ impl TileGateway { &self.palette_buffer_pool } - pub async fn get_tile_webp(&self, coord: TileCoord) -> AppResult { - debug!("Getting WebP for tile {}", coord); + pub async fn get_tile_webp( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult { + debug!("Getting WebP for tile {} in world {:?}", coord, world_id); - let version_lookup = self.find_authoritative_tile_version(coord).await?; + let version_lookup = self + .find_authoritative_tile_version(world_id, coord) + .await?; debug!( "Found authoritative version {} for tile {} from {:?}", @@ -100,38 +106,49 @@ impl TileGateway { let etag = Some(format!("\"{}\"", version_lookup.version)); if let Some(cached_webp) = self - .try_get_cached_webp(coord, version_lookup.version, etag.clone()) + .try_get_cached_webp(world_id, coord, version_lookup.version, etag.clone()) .await? { return Ok(cached_webp); } - self.generate_and_cache_webp(coord, version_lookup.version, etag) + self.generate_and_cache_webp(world_id, coord, version_lookup.version, etag) .await } - pub async fn get_tile_rgba(&self, coord: TileCoord) -> AppResult { - let authoritative_version_lookup = self.find_authoritative_tile_version(coord).await?; + pub async fn get_tile_rgba( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult { + let authoritative_version_lookup = self + .find_authoritative_tile_version(world_id, coord) + .await?; - self.load_rgba_pixels_from_cache_or_database(coord, authoritative_version_lookup.version) - .await + self.load_rgba_pixels_from_cache_or_database( + world_id, + coord, + authoritative_version_lookup.version, + ) + .await } - pub async fn load_tile_for_painting(&self, tile_coord: TileCoord) -> AppResult> { - let authoritative_version_lookup = self.find_authoritative_tile_version(tile_coord).await?; + pub async fn load_tile_for_painting( + &self, + world_id: &WorldId, + tile_coord: TileCoord, + ) -> AppResult> { + let authoritative_version_lookup = self + .find_authoritative_tile_version(world_id, tile_coord) + .await?; if let Some(palette_bytes) = self .cache_port - .get_palette(tile_coord, authoritative_version_lookup.version) + .get_palette(world_id, tile_coord, authoritative_version_lookup.version) .await? { let rgba_pixels = palette_to_rgba_pixels(&palette_bytes, &self.config.palette); - let transparency_id = self.config.transparency_color_id; - let tile = Arc::new(Tile::new( - tile_coord, - self.config.tile_size, - transparency_id, - )); + let tile = Arc::new(Tile::new(tile_coord, self.config.tile_size)); populate_tile_from_rgba(&tile, &rgba_pixels, &self.palette_color_lookup)?; tile.mark_clean(authoritative_version_lookup.version); return Ok(tile); @@ -139,15 +156,10 @@ impl TileGateway { let pixel_state = self .pixel_history_store - .get_current_tile_state(tile_coord) + .get_current_tile_state(world_id, tile_coord) .await?; let tile = if pixel_state.is_empty() { - let transparency_id = self.config.transparency_color_id; - Arc::new(Tile::new( - tile_coord, - self.config.tile_size, - transparency_id, - )) + Arc::new(Tile::new(tile_coord, self.config.tile_size)) } else { self.reconstruct_tile_from_pixel_history(tile_coord, &pixel_state) }; @@ -158,6 +170,7 @@ impl TileGateway { pub async fn store_palette_in_cache( &self, + world_id: &WorldId, tile_coord: TileCoord, version: u64, tile_arc: &Arc, @@ -165,7 +178,7 @@ impl TileGateway { let (_, palette_data) = tile_arc.snapshot_palette(&self.palette_buffer_pool); self.cache_port - .store_palette(tile_coord, version, &palette_data) + .store_palette(world_id, tile_coord, version, &palette_data) .await?; self.palette_buffer_pool.release_buffer(palette_data); @@ -174,24 +187,26 @@ impl TileGateway { pub async fn update_cache_optimistically( &self, + world_id: &WorldId, tile_coord: TileCoord, version: u64, _tile_arc: &Arc, ) -> AppResult<()> { self.cache_port - .update_version_optimistically(tile_coord, version) + .update_version_optimistically(world_id, tile_coord, version) .await; Ok(()) } async fn load_rgba_pixels_from_cache_or_database( &self, + world_id: &WorldId, coord: TileCoord, authoritative_version: u64, ) -> AppResult { if let Some(palette_bytes) = self .cache_port - .get_palette(coord, authoritative_version) + .get_palette(world_id, coord, authoritative_version) .await? { let rgba_pixels = palette_to_rgba_pixels(&palette_bytes, &self.config.palette); @@ -201,21 +216,22 @@ impl TileGateway { }); } - self.load_rgba_pixels_from_database_and_populate_caches(coord) + self.load_rgba_pixels_from_database_and_populate_caches(world_id, coord) .await } async fn load_rgba_pixels_from_database_and_populate_caches( &self, + world_id: &WorldId, coord: TileCoord, ) -> AppResult { let pixel_state = self .pixel_history_store - .get_current_tile_state(coord) + .get_current_tile_state(world_id, coord) .await?; if pixel_state.is_empty() { - return self.handle_complete_cache_miss(coord).await; + return self.handle_complete_cache_miss(world_id, coord).await; } let tile = self.reconstruct_tile_from_pixel_history(coord, &pixel_state); @@ -225,10 +241,10 @@ impl TileGateway { let version = pixel_state.len() as u64; self.cache_port - .store_palette_optimistically(coord, version, &palette_data) + .store_palette_optimistically(world_id, coord, version, &palette_data) .await; self.cache_port - .update_version_optimistically(coord, version) + .update_version_optimistically(world_id, coord, version) .await; self.palette_buffer_pool.release_buffer(palette_data); @@ -249,10 +265,9 @@ impl TileGateway { fn reconstruct_tile_from_pixel_history( &self, coord: TileCoord, - pixel_state: &[(usize, usize, u8)], + pixel_state: &[(usize, usize, i16)], ) -> Arc { - let transparency_id = self.config.transparency_color_id; - let tile = Arc::new(Tile::new(coord, self.config.tile_size, transparency_id)); + let tile = Arc::new(Tile::new(coord, self.config.tile_size)); for &(x, y, color_id) in pixel_state { if x < self.config.tile_size && y < self.config.tile_size { @@ -268,9 +283,14 @@ impl TileGateway { async fn handle_complete_cache_miss( &self, + world_id: &WorldId, coord: TileCoord, ) -> AppResult { - if self.cache_port.has_missing_sentinel(coord).await? { + if self + .cache_port + .has_missing_sentinel(world_id, coord) + .await? + { debug!( "Found missing sentinel for tile {}, returning empty buffer", coord @@ -280,14 +300,16 @@ impl TileGateway { debug!("Complete cache miss for tile {}, setting sentinel", coord); - self.cache_port.set_missing_sentinel(coord).await.ok(); + self.cache_port + .set_missing_sentinel(world_id, coord) + .await + .ok(); Ok(self.create_empty_tile_data(coord)) } fn create_empty_tile_data(&self, coord: TileCoord) -> CacheHierarchyResult { debug!("Creating empty tile for {}", coord); - let transparency_id = self.config.transparency_color_id; - let new_tile = Arc::new(Tile::new(coord, self.config.tile_size, transparency_id)); + let new_tile = Arc::new(Tile::new(coord, self.config.tile_size)); CacheHierarchyResult { rgba_pixels: { @@ -309,23 +331,34 @@ impl TileGateway { }) } - pub async fn get_tile_version(&self, coord: TileCoord) -> AppResult { - let authoritative_version_lookup = self.find_authoritative_tile_version(coord).await?; + pub async fn get_tile_version( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult { + let authoritative_version_lookup = self + .find_authoritative_tile_version(world_id, coord) + .await?; Ok(TileVersion::from_u64(authoritative_version_lookup.version)) } async fn find_authoritative_tile_version( &self, + world_id: &WorldId, coord: TileCoord, ) -> AppResult { - if let Some(version) = self.cache_port.get_version(coord).await? { + if let Some(version) = self.cache_port.get_version(world_id, coord).await? { return Ok(VersionLookupResult { version, source: VersionSource::Redis, }); } - if self.cache_port.has_missing_sentinel(coord).await? { + if self + .cache_port + .has_missing_sentinel(world_id, coord) + .await? + { debug!("Found missing sentinel for tile {}", coord); return Ok(VersionLookupResult { version: 0, @@ -335,7 +368,7 @@ impl TileGateway { let pixel_state = self .pixel_history_store - .get_current_tile_state(coord) + .get_current_tile_state(world_id, coord) .await?; if pixel_state.is_empty() { @@ -359,11 +392,12 @@ impl TileGateway { async fn try_get_cached_webp( &self, + world_id: &WorldId, coord: TileCoord, version: u64, etag: Option, ) -> AppResult> { - if let Some(webp_data) = self.cache_port.get_webp(coord, version).await? { + if let Some(webp_data) = self.cache_port.get_webp(world_id, coord, version).await? { debug!( "WebP cache hit for tile {} v{} ({} bytes)", coord, @@ -381,6 +415,7 @@ impl TileGateway { async fn generate_and_cache_webp( &self, + world_id: &WorldId, coord: TileCoord, version: u64, etag: Option, @@ -388,14 +423,14 @@ impl TileGateway { debug!("Generating WebP for tile {} v{}", coord, version); let rgba_hierarchy_result = self - .load_rgba_pixels_from_cache_or_database(coord, version) + .load_rgba_pixels_from_cache_or_database(world_id, coord, version) .await?; let webp_data = self .encode_webp_from_rgba(rgba_hierarchy_result.rgba_pixels) .await?; self.cache_port - .store_webp(coord, version, &webp_data) + .store_webp(world_id, coord, version, &webp_data) .await?; debug!( @@ -413,19 +448,31 @@ impl TileGateway { pub async fn get_palette_from_cache( &self, + world_id: &WorldId, coord: TileCoord, version: u64, - ) -> AppResult>> { - self.cache_port.get_palette(coord, version).await + ) -> AppResult>> { + self.cache_port.get_palette(world_id, coord, version).await } - pub async fn clear_missing_sentinel(&self, coord: TileCoord) -> AppResult<()> { - self.cache_port.clear_missing_sentinel(coord).await + pub async fn clear_missing_sentinel( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult<()> { + self.cache_port + .clear_missing_sentinel(world_id, coord) + .await } - pub async fn update_version_optimistically(&self, coord: TileCoord, version: u64) { + pub async fn update_version_optimistically( + &self, + world_id: &WorldId, + coord: TileCoord, + version: u64, + ) { self.cache_port - .update_version_optimistically(coord, version) + .update_version_optimistically(world_id, coord, version) .await; } } diff --git a/application/src/tiles/service.rs b/application/src/tiles/service.rs index c8744d8..d1aa011 100644 --- a/application/src/tiles/service.rs +++ b/application/src/tiles/service.rs @@ -1,19 +1,11 @@ -use std::sync::Arc; -use tracing::{debug, instrument}; - -use domain::{ - action::PaintAction, - auth::UserId, - color::ColorId, - coords::{GlobalCoord, PixelCoord, TileCoord}, - credits::CreditConfig, - events::TileVersionEvent, - tile::{PaletteBufferPool, Tile, TileVersion}, +use super::{ + commands::{PaintingResult, execute_batch_pixel_painting}, + gateway::TileGateway, + util::validate_color_id, }; - use crate::{ config::TileSettings, - error::AppResult, + error::{AppError, AppResult}, ports::{ incoming::tiles::{ MetricsQueryUseCase, PaintPixelsUseCase, PixelHistoryQueryUseCase, @@ -30,12 +22,18 @@ use crate::{ }, }, }; - -use super::{ - commands::{PaintingResult, execute_batch_pixel_painting}, - gateway::TileGateway, - util::validate_color_id, +use domain::{ + action::PaintAction, + auth::UserId, + color::ColorId, + coords::{GlobalCoord, PixelCoord, TileCoord}, + credits::CreditBalance, + events::TileVersionEvent, + tile::{PaletteBufferPool, Tile, TileVersion}, + world::WorldId, }; +use std::sync::Arc; +use tracing::{debug, instrument}; pub type PaletteColorLookup = super::util::PaletteColorLookup; @@ -49,7 +47,6 @@ pub struct TileServiceDeps { pub task_spawn_port: DynTaskSpawnPort, pub pixel_history_store: DynPixelHistoryStorePort, pub credit_store: DynCreditStorePort, - pub credit_config: CreditConfig, } pub struct TileService { @@ -58,7 +55,6 @@ pub struct TileService { events_port: DynEventsPort, pixel_history_store: DynPixelHistoryStorePort, credit_store: DynCreditStorePort, - credit_config: CreditConfig, } impl TileService { @@ -78,7 +74,6 @@ impl TileService { events_port: deps.events_port, pixel_history_store: deps.pixel_history_store, credit_store: deps.credit_store, - credit_config: deps.credit_config, }); Ok(service) @@ -97,42 +92,52 @@ impl TileService { #[instrument(skip(self))] pub async fn get_tile_webp( &self, + world_id: &WorldId, coord: TileCoord, ) -> AppResult { coord.validate_bounds()?; - self.repository.get_tile_webp(coord).await + self.repository.get_tile_webp(world_id, coord).await } - async fn load_tile_for_painting(&self, tile_coord: TileCoord) -> AppResult> { + async fn load_tile_for_painting( + &self, + world_id: &WorldId, + tile_coord: TileCoord, + ) -> AppResult> { tile_coord.validate_bounds()?; - self.repository.load_tile_for_painting(tile_coord).await + self.repository + .load_tile_for_painting(world_id, tile_coord) + .await } async fn store_palette_in_redis( &self, + world_id: &WorldId, tile_coord: TileCoord, version: u64, tile_arc: &Arc, ) -> AppResult<()> { self.repository - .store_palette_in_cache(tile_coord, version, tile_arc) + .store_palette_in_cache(world_id, tile_coord, version, tile_arc) .await } async fn update_cache_optimistically( &self, + world_id: &WorldId, tile_coord: TileCoord, version: u64, tile_arc: &Arc, ) -> AppResult<()> { self.repository - .update_cache_optimistically(tile_coord, version, tile_arc) + .update_cache_optimistically(world_id, tile_coord, version, tile_arc) .await } #[instrument(skip(self, pixels))] pub async fn paint_pixels_batch( &self, + world_id: &WorldId, user_id: UserId, tile_coord: TileCoord, pixels: &[(PixelCoord, ColorId)], @@ -141,18 +146,40 @@ impl TileService { for (pixel_coord, color_id) in pixels { pixel_coord.validate_bounds(self.config.tile_size)?; - validate_color_id(color_id.id(), &self.config.color_palette_config)?; + validate_color_id(color_id.id(), &self.config.palette)?; } #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] let pixel_count = pixels.len() as i32; + + let balance = self.credit_store.get_user_credits(&user_id).await?; + + if balance.available_charges < pixel_count { + return Err(AppError::InsufficientCredits { + message: format!( + "Required {} charges, but only {} available", + pixel_count, balance.available_charges + ), + }); + } + + let new_balance = CreditBalance { + available_charges: balance.available_charges - pixel_count, + charges_updated_at: time::OffsetDateTime::now_utc(), + }; + self.credit_store - .spend_user_credits(&user_id, pixel_count, &self.credit_config) + .update_user_credits(&user_id, &new_balance) .await?; - debug!("Painting {} pixels on tile {}", pixels.len(), tile_coord); + debug!( + "Painting {} pixels on tile {} in world {:?}", + pixels.len(), + tile_coord, + world_id + ); - let tile_arc = self.load_tile_for_painting(tile_coord).await?; + let tile_arc = self.load_tile_for_painting(world_id, tile_coord).await?; let painting_result = execute_batch_pixel_painting(tile_coord, pixels, &tile_arc, &self.config)?; @@ -161,16 +188,22 @@ impl TileService { tile_coord, painting_result.new_version ); - self.store_palette_in_redis(tile_coord, painting_result.new_version, &tile_arc) + self.store_palette_in_redis(world_id, tile_coord, painting_result.new_version, &tile_arc) .await?; - self.update_cache_optimistically(tile_coord, painting_result.new_version, &tile_arc) - .await?; + self.update_cache_optimistically( + world_id, + tile_coord, + painting_result.new_version, + &tile_arc, + ) + .await?; let paint_actions: Vec = pixels .iter() .map(|(pixel_coord, color_id)| { PaintAction::from_tile_and_pixel( + world_id.clone(), user_id.clone(), tile_coord, *pixel_coord, @@ -182,11 +215,12 @@ impl TileService { .collect(); self.pixel_history_store - .record_paint_actions(&paint_actions) + .record_paint_actions(world_id, &paint_actions) .await?; self.events_port .broadcast_tile_version(TileVersionEvent { + world_id: world_id.clone(), coord: tile_coord, version: painting_result.new_version, }) @@ -201,12 +235,16 @@ impl TileService { } #[instrument(skip(self))] - pub async fn get_tile_version(&self, coord: TileCoord) -> AppResult { - self.repository.get_tile_version(coord).await + pub async fn get_tile_version( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult { + self.repository.get_tile_version(world_id, coord).await } - pub async fn get_metrics(&self) -> AppResult { - let tile_count = self.repository.get_distinct_tile_count().await?; + pub async fn get_metrics(&self, world_id: &WorldId) -> AppResult { + let tile_count = self.repository.get_distinct_tile_count(world_id).await?; Ok(serde_json::json!({ "total_tiles": tile_count, @@ -219,13 +257,18 @@ impl TileService { impl TilesQueryUseCase for TileService { async fn get_tile_webp( &self, + world_id: &WorldId, coord: TileCoord, ) -> AppResult { - self.get_tile_webp(coord).await + self.get_tile_webp(world_id, coord).await } - async fn get_tile_version(&self, coord: TileCoord) -> AppResult { - self.get_tile_version(coord).await + async fn get_tile_version( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult { + self.get_tile_version(world_id, coord).await } } @@ -233,33 +276,47 @@ impl TilesQueryUseCase for TileService { impl PaintPixelsUseCase for TileService { async fn paint_pixels_batch( &self, + world_id: &WorldId, user_id: UserId, tile: TileCoord, pixels: &[(PixelCoord, ColorId)], ) -> AppResult { - self.paint_pixels_batch(user_id, tile, pixels).await + self.paint_pixels_batch(world_id, user_id, tile, pixels) + .await } } #[async_trait::async_trait] impl MetricsQueryUseCase for TileService { - async fn get_metrics(&self) -> AppResult { - self.get_metrics().await + async fn get_metrics(&self, world_id: &WorldId) -> AppResult { + self.get_metrics(world_id).await } } #[async_trait::async_trait] impl PixelHistoryQueryUseCase for TileService { - async fn get_history_for_tile(&self, coord: TileCoord) -> AppResult> { + async fn get_history_for_tile( + &self, + world_id: &WorldId, + coord: TileCoord, + ) -> AppResult> { coord.validate_bounds()?; - self.pixel_history_store.get_history_for_tile(coord).await + self.pixel_history_store + .get_history_for_tile(world_id, coord) + .await } } #[async_trait::async_trait] impl PixelInfoQueryUseCase for TileService { - async fn get_pixel_info(&self, coord: GlobalCoord) -> AppResult> { + async fn get_pixel_info( + &self, + world_id: &WorldId, + coord: GlobalCoord, + ) -> AppResult> { coord.validate()?; - self.pixel_history_store.get_pixel_info(coord).await + self.pixel_history_store + .get_pixel_info(world_id, coord) + .await } } diff --git a/application/src/tiles/util.rs b/application/src/tiles/util.rs index 50db4d6..c665a05 100644 --- a/application/src/tiles/util.rs +++ b/application/src/tiles/util.rs @@ -1,12 +1,14 @@ use std::{collections::HashMap, sync::atomic::Ordering}; use crate::error::{AppError, AppResult}; -use crate::infrastructure_config::ColorPaletteConfig; +use domain::color::ColorId; use domain::{ color::{Color, RgbColor}, tile::Tile, }; +const TRANSPARENT_COLOR: u32 = 0; + #[derive(Debug, Clone)] pub struct PaletteColorLookup { color_to_id: HashMap, @@ -34,13 +36,17 @@ impl PaletteColorLookup { } } -pub fn palette_to_rgba_pixels(palette_bytes: &[u8], color_palette: &[RgbColor]) -> Vec { - let mut rgba_pixels = Vec::with_capacity(palette_bytes.len()); - for &palette_id in palette_bytes { - if let Some(color) = color_palette.get(palette_id as usize) { +pub fn palette_to_rgba_pixels(palette_ids: &[i16], color_palette: &[RgbColor]) -> Vec { + let mut rgba_pixels = Vec::with_capacity(palette_ids.len()); + for &palette_id in palette_ids { + let color_id = ColorId::new(palette_id); + + if color_id.is_transparent() { + rgba_pixels.push(TRANSPARENT_COLOR); + } else if let Some(color) = color_palette.get(color_id.id() as usize) { rgba_pixels.push(color.to_rgba_u32()); } else { - rgba_pixels.push(Color::transparent_rgba_u32()); + rgba_pixels.push(TRANSPARENT_COLOR); } } rgba_pixels @@ -63,89 +69,50 @@ pub fn populate_tile_from_rgba( } for (pixel, &rgba_value) in tile.pixels.iter().zip(rgba_pixel_data.iter()) { - if rgba_value == Color::transparent_rgba_u32() { - pixel.store(tile.transparency_color_id(), Ordering::Relaxed); + let palette_id = if rgba_value == TRANSPARENT_COLOR { + ColorId::TRANSPARENT } else { let color = Color::from_rgba_u32(rgba_value); - let palette_id = palette_lookup.find_color_id(color).ok_or_else(|| { + i16::from(palette_lookup.find_color_id(color).ok_or_else(|| { AppError::InvalidColorFormat { message: format!( "Color not found in palette: ({}, {}, {})", color.r, color.g, color.b ), } - })?; - pixel.store(palette_id, Ordering::Relaxed); - } + })?) + }; + pixel.store(palette_id, Ordering::Relaxed); } Ok(()) } -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ColorRules { - max_regular_id: u8, - transparency_id: Option, - total_valid_ids: u8, -} - -#[allow(dead_code)] -impl ColorRules { - pub fn new(palette_cfg: &ColorPaletteConfig) -> Self { - #[allow(clippy::cast_possible_truncation)] - let regular_color_count = palette_cfg.colors.len() as u8; - let max_regular_id = regular_color_count.saturating_sub(1); - let transparency_id = palette_cfg.get_transparency_color_id(); - - #[allow(clippy::cast_possible_truncation)] - let total_valid_ids = regular_color_count + palette_cfg.special_colors.len() as u8; - - Self { - max_regular_id, - transparency_id, - total_valid_ids, - } - } +pub fn validate_color_id(id: i16, palette: &[RgbColor]) -> AppResult<()> { + use domain::color::ColorId; - pub fn is_valid_for_painting(&self, color_id: u8) -> bool { - color_id < self.total_valid_ids - && (color_id <= self.max_regular_id || Some(color_id) == self.transparency_id) + if id == ColorId::TRANSPARENT { + return Ok(()); } - pub fn is_in_bounds(&self, color_id: u8) -> bool { - color_id < self.total_valid_ids + if id < 0 { + return Err(AppError::InvalidColorFormat { + message: format!("Color ID {} is invalid (must be -1 or 0-255)", id), + }); } -} -pub fn validate_color_id(id: u8, palette_cfg: &ColorPaletteConfig) -> AppResult<()> { - #[allow(clippy::cast_possible_truncation)] - let regular_color_count = palette_cfg.colors.len() as u8; - #[allow(clippy::cast_possible_truncation)] - let total_colors = regular_color_count + palette_cfg.special_colors.len() as u8; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let color_count = palette.len() as i16; - if id >= total_colors { + if id >= color_count { return Err(AppError::InvalidColorFormat { message: format!( - "Color ID {} is out of bounds (valid range: 0-{})", + "Color ID {} is out of bounds (valid range: -1 (transparent) or 0-{})", id, - total_colors.saturating_sub(1) + color_count.saturating_sub(1) ), }); } - if id >= regular_color_count { - let special_index = (id - regular_color_count) as usize; - if let Some(purpose) = palette_cfg.special_colors.get(special_index) { - if purpose != "transparency" { - return Err(AppError::InvalidColorFormat { - message: format!( - "Special color '{purpose}' (ID {id}) cannot be used for painting" - ), - }); - } - } - } - Ok(()) } diff --git a/application/src/world/mod.rs b/application/src/world/mod.rs new file mode 100644 index 0000000..1f278a4 --- /dev/null +++ b/application/src/world/mod.rs @@ -0,0 +1 @@ +pub mod service; diff --git a/application/src/world/service.rs b/application/src/world/service.rs new file mode 100644 index 0000000..eff8312 --- /dev/null +++ b/application/src/world/service.rs @@ -0,0 +1,38 @@ +use crate::{error::AppResult, ports::outgoing::world_store::DynWorldStorePort}; +use domain::world::{PaletteColor, World, WorldId}; + +pub struct WorldService { + world_store: DynWorldStorePort, +} + +impl WorldService { + pub fn new(world_store: DynWorldStorePort) -> Self { + Self { world_store } + } + + pub async fn get_world_by_id(&self, world_id: &WorldId) -> AppResult> { + self.world_store.get_world_by_id(world_id).await + } + + pub async fn get_world_by_name(&self, name: &str) -> AppResult> { + self.world_store.get_world_by_name(name).await + } + + pub async fn get_default_world(&self) -> AppResult> { + self.world_store.get_default_world().await + } + + pub async fn list_worlds(&self) -> AppResult> { + self.world_store.list_worlds().await + } + + pub async fn create_world(&self, name: String) -> AppResult { + let world = World::new(name); + self.world_store.create_world(&world).await?; + Ok(world) + } + + pub async fn get_palette_colors(&self, world_id: &WorldId) -> AppResult> { + self.world_store.get_palette_colors(world_id).await + } +} diff --git a/config.toml.example b/config.toml.example index 24612f2..6582ce9 100644 --- a/config.toml.example +++ b/config.toml.example @@ -61,171 +61,6 @@ level = "debug" format = "pretty" # Options: "pretty" or "json" include_location = false -[color_palette] -colors = [ - [ - 109, - 0, - 26, - ], - [ - 190, - 0, - 57, - ], - [ - 255, - 69, - 0, - ], - [ - 255, - 168, - 0, - ], - [ - 255, - 214, - 53, - ], - [ - 255, - 248, - 184, - ], - [ - 0, - 163, - 104, - ], - [ - 0, - 204, - 120, - ], - [ - 126, - 237, - 86, - ], - [ - 0, - 117, - 111, - ], - [ - 0, - 158, - 170, - ], - [ - 0, - 204, - 192, - ], - [ - 36, - 80, - 164, - ], - [ - 54, - 144, - 234, - ], - [ - 81, - 233, - 244, - ], - [ - 73, - 58, - 193, - ], - [ - 106, - 92, - 255, - ], - [ - 148, - 179, - 255, - ], - [ - 129, - 30, - 159, - ], - [ - 180, - 74, - 192, - ], - [ - 228, - 171, - 255, - ], - [ - 222, - 16, - 127, - ], - [ - 255, - 56, - 129, - ], - [ - 255, - 153, - 170, - ], - [ - 109, - 72, - 47, - ], - [ - 156, - 105, - 38, - ], - [ - 255, - 180, - 112, - ], - [ - 0, - 0, - 0, - ], - [ - 81, - 82, - 82, - ], - [ - 137, - 141, - 144, - ], - [ - 212, - 215, - 217, - ], - [ - 255, - 255, - 255, - ], -] - -special_colors = ["transparency"] [auth] cookie_name = "sid" diff --git a/domain/src/action.rs b/domain/src/action.rs index f266252..d72ba0c 100644 --- a/domain/src/action.rs +++ b/domain/src/action.rs @@ -1,10 +1,12 @@ use crate::auth::UserId; use crate::color::ColorId; use crate::coords::{GlobalCoord, PixelCoord, TileCoord}; +use crate::world::WorldId; use time::OffsetDateTime; #[derive(Debug, Clone, PartialEq, Eq)] pub struct PaintAction { + pub world_id: WorldId, pub user_id: UserId, pub global_coord: GlobalCoord, pub color_id: ColorId, @@ -24,6 +26,7 @@ impl PaintAction { #[must_use] pub fn from_tile_and_pixel( + world_id: WorldId, user_id: UserId, tile_coord: TileCoord, pixel_coord: PixelCoord, @@ -32,6 +35,7 @@ impl PaintAction { tile_size: usize, ) -> Self { Self { + world_id, user_id, global_coord: GlobalCoord::from_tile_and_pixel(tile_coord, pixel_coord, tile_size), color_id, diff --git a/domain/src/canvas_config.rs b/domain/src/canvas_config.rs new file mode 100644 index 0000000..0cd4b45 --- /dev/null +++ b/domain/src/canvas_config.rs @@ -0,0 +1,17 @@ +use crate::color::RgbColor; +use crate::world::WorldId; + +#[derive(Debug, Clone)] +pub struct CanvasConfig { + pub default_world_id: WorldId, + pub palette: Vec, +} + +impl CanvasConfig { + pub fn new(default_world_id: WorldId, palette: Vec) -> Self { + Self { + default_world_id, + palette, + } + } +} diff --git a/domain/src/color.rs b/domain/src/color.rs index 51ec3b8..7c5f27c 100644 --- a/domain/src/color.rs +++ b/domain/src/color.rs @@ -22,11 +22,6 @@ impl RgbColor { (255u32 << 24) | (u32::from(self.b) << 16) | (u32::from(self.g) << 8) | u32::from(self.r) } - #[must_use] - pub fn transparent_rgba_u32() -> u32 { - 0x0000_0000 - } - #[must_use] pub fn from_rgba_u32(rgba: u32) -> Self { Self { @@ -35,15 +30,42 @@ impl RgbColor { b: u8::try_from((rgba >> 16) & 0xFF).unwrap_or(0), } } +} + +#[cfg_attr(feature = "docs", derive(ToSchema))] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct HexColor(pub String); + +impl HexColor { + #[must_use] + pub fn new(hex: String) -> Self { + Self(hex) + } #[must_use] - pub fn fully_transparent() -> Self { - Self { r: 0, g: 0, b: 0 } + pub fn to_rgba_u32(&self) -> Option { + if !self.0.starts_with('#') || self.0.len() != 9 { + return None; + } + + let hex_digits = &self.0[1..]; + u32::from_str_radix(hex_digits, 16).ok().map(|rgba| { + let r = (rgba >> 24) & 0xFF; + let g = (rgba >> 16) & 0xFF; + let b = (rgba >> 8) & 0xFF; + let a = rgba & 0xFF; + (a << 24) | (b << 16) | (g << 8) | r + }) } #[must_use] - pub fn transparent() -> Self { - Self::fully_transparent() + pub fn from_rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Self(format!("#{:02X}{:02X}{:02X}{:02X}", r, g, b, a)) + } + + #[must_use] + pub fn is_transparent(&self) -> bool { + self.0.ends_with("00") } } @@ -51,23 +73,35 @@ impl RgbColor { #[cfg_attr( feature = "docs", schema( - description = "Color palette ID (0-255) that maps to a predefined RGBA color", + description = "Color palette index (-1 for transparent/None, 0-255 for palette colors)", example = 0 ) )] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ColorId(pub u8); +pub struct ColorId(pub i16); impl ColorId { + pub const TRANSPARENT: i16 = -1; + #[must_use] - pub fn new(id: u8) -> Self { + pub fn transparent() -> Self { + Self(Self::TRANSPARENT) + } + + #[must_use] + pub fn new(id: i16) -> Self { Self(id) } #[must_use] - pub fn id(&self) -> u8 { + pub fn id(&self) -> i16 { self.0 } + + #[must_use] + pub fn is_transparent(&self) -> bool { + self.0 == Self::TRANSPARENT + } } impl fmt::Display for ColorId { @@ -76,13 +110,13 @@ impl fmt::Display for ColorId { } } -impl From for ColorId { - fn from(id: u8) -> Self { +impl From for ColorId { + fn from(id: i16) -> Self { Self(id) } } -impl From for u8 { +impl From for i16 { fn from(color_id: ColorId) -> Self { color_id.0 } diff --git a/domain/src/events.rs b/domain/src/events.rs index 58f0fdd..0ebc0e9 100644 --- a/domain/src/events.rs +++ b/domain/src/events.rs @@ -1,7 +1,8 @@ -use crate::coords::TileCoord; +use crate::{coords::TileCoord, world::WorldId}; #[derive(Clone, Debug)] pub struct TileVersionEvent { + pub world_id: WorldId, pub coord: TileCoord, pub version: u64, } diff --git a/domain/src/lib.rs b/domain/src/lib.rs index 6b0a4ed..f9f1d23 100644 --- a/domain/src/lib.rs +++ b/domain/src/lib.rs @@ -1,9 +1,11 @@ pub mod action; pub mod auth; pub mod ban; +pub mod canvas_config; pub mod color; pub mod coords; pub mod credits; pub mod error; pub mod events; pub mod tile; +pub mod world; diff --git a/domain/src/tile.rs b/domain/src/tile.rs index 690ddac..13677e4 100644 --- a/domain/src/tile.rs +++ b/domain/src/tile.rs @@ -2,7 +2,7 @@ use crossbeam_queue::ArrayQueue; use serde::{Deserialize, Serialize}; use std::fmt; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI16, AtomicU64, Ordering}; #[cfg(feature = "docs")] use utoipa::ToSchema; @@ -11,7 +11,7 @@ use crate::coords::{PixelCoord, TileCoord}; use crate::error::{DomainError, DomainResult}; pub struct PaletteBufferPool { - buffers: Arc>>, + buffers: Arc>>, buffer_size_pixels: usize, } @@ -26,37 +26,36 @@ impl PaletteBufferPool { } #[must_use] - pub fn acquire_buffer(&self) -> Vec { + pub fn acquire_buffer(&self) -> Vec { if let Some(mut buffer) = self.buffers.pop() { buffer.clear(); - buffer.resize(self.buffer_size_pixels, 0); + buffer.resize(self.buffer_size_pixels, ColorId::TRANSPARENT); return buffer; } - vec![0; self.buffer_size_pixels] + vec![ColorId::TRANSPARENT; self.buffer_size_pixels] } - pub fn release_buffer(&self, buffer: Vec) { + pub fn release_buffer(&self, buffer: Vec) { self.buffers.push(buffer).ok(); } } pub struct Tile { - pub pixels: Box<[AtomicU8]>, + pub pixels: Box<[AtomicI16]>, pub dirty: AtomicBool, pub version: AtomicU64, pub dirty_since: AtomicU64, pub coord: TileCoord, pub tile_size: usize, - transparency_color_id: u8, } impl Tile { #[must_use] - pub fn new(coord: TileCoord, tile_size: usize, transparency_color_id: u8) -> Self { + pub fn new(coord: TileCoord, tile_size: usize) -> Self { let total_pixels = tile_size * tile_size; let mut pixels = Vec::with_capacity(total_pixels); for _ in 0..total_pixels { - pixels.push(AtomicU8::new(transparency_color_id)); + pixels.push(AtomicI16::new(ColorId::TRANSPARENT)); } Self { @@ -66,14 +65,9 @@ impl Tile { dirty_since: AtomicU64::new(u64::MAX), coord, tile_size, - transparency_color_id, } } - pub fn transparency_color_id(&self) -> u8 { - self.transparency_color_id - } - fn total_pixels(&self) -> usize { self.tile_size * self.tile_size } @@ -150,7 +144,7 @@ impl Tile { } } - pub fn snapshot_palette(&self, palette_pool: &PaletteBufferPool) -> (u64, Vec) { + pub fn snapshot_palette(&self, palette_pool: &PaletteBufferPool) -> (u64, Vec) { loop { let version_before = self.version.load(Ordering::Acquire); @@ -174,7 +168,7 @@ impl Tile { } } - pub fn populate_from_palette(&self, palette_data: &[u8]) -> DomainResult<()> { + pub fn populate_from_palette(&self, palette_data: &[i16]) -> DomainResult<()> { let expected_pixels = self.total_pixels(); if palette_data.len() != expected_pixels { return Err(DomainError::CodecError(format!( diff --git a/domain/src/world.rs b/domain/src/world.rs new file mode 100644 index 0000000..2fad298 --- /dev/null +++ b/domain/src/world.rs @@ -0,0 +1,70 @@ +use crate::color::{ColorId, HexColor}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorldId(pub Uuid); + +impl WorldId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + pub fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + pub fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl Default for WorldId { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone)] +pub struct World { + pub id: WorldId, + pub name: String, + pub is_default: bool, + pub created_at: time::OffsetDateTime, + pub updated_at: time::OffsetDateTime, +} + +impl World { + pub fn new(name: String) -> Self { + let now = time::OffsetDateTime::now_utc(); + Self { + id: WorldId::new(), + name, + is_default: false, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone)] +pub struct PaletteColor { + pub id: Uuid, + pub world_id: WorldId, + pub palette_index: i16, + pub hex_color: HexColor, +} + +impl PaletteColor { + pub fn new(id: Uuid, world_id: WorldId, palette_index: i16, hex_color: HexColor) -> Self { + Self { + id, + world_id, + palette_index, + hex_color, + } + } + + pub fn color_id(&self) -> ColorId { + ColorId::new(self.palette_index) + } +} diff --git a/migrations/20251001195219_add_worlds_system.down.sql b/migrations/20251001195219_add_worlds_system.down.sql new file mode 100644 index 0000000..d14230f --- /dev/null +++ b/migrations/20251001195219_add_worlds_system.down.sql @@ -0,0 +1,13 @@ +DROP INDEX IF EXISTS idx_pixel_history_world_user; +DROP INDEX IF EXISTS idx_pixel_history_world_coords; +DROP INDEX IF EXISTS idx_pixel_history_world_created_at; + +ALTER TABLE pixel_history DROP CONSTRAINT pixel_history_pkey; + +ALTER TABLE pixel_history DROP COLUMN world_id; + +ALTER TABLE pixel_history ADD PRIMARY KEY (global_x, global_y); + +DROP INDEX IF EXISTS idx_worlds_unique_default; + +DROP TABLE worlds; diff --git a/migrations/20251001195219_add_worlds_system.up.sql b/migrations/20251001195219_add_worlds_system.up.sql new file mode 100644 index 0000000..a92b92a --- /dev/null +++ b/migrations/20251001195219_add_worlds_system.up.sql @@ -0,0 +1,29 @@ +CREATE INDEX IF NOT EXISTS idx_users_charges_updated_at ON users(charges_updated_at); + +CREATE TABLE worlds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT UNIQUE NOT NULL, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT worlds_name_check CHECK (length(name) > 0 AND length(name) <= 100) +); + +CREATE INDEX idx_worlds_name ON worlds(name); + +CREATE UNIQUE INDEX idx_worlds_unique_default ON worlds(is_default) WHERE is_default = TRUE; + +INSERT INTO worlds (name, is_default) VALUES ('default', TRUE); + +ALTER TABLE pixel_history DROP CONSTRAINT pixel_history_pkey; + +ALTER TABLE pixel_history ADD COLUMN world_id UUID NOT NULL; + +ALTER TABLE pixel_history ADD CONSTRAINT pixel_history_world_id_fkey + FOREIGN KEY (world_id) REFERENCES worlds(id) ON DELETE CASCADE; + +ALTER TABLE pixel_history ADD PRIMARY KEY (world_id, global_x, global_y); + +CREATE INDEX idx_pixel_history_world_user ON pixel_history(world_id, user_id); +CREATE INDEX idx_pixel_history_world_coords ON pixel_history(world_id, global_x, global_y); +CREATE INDEX idx_pixel_history_world_created_at ON pixel_history(world_id, created_at DESC); diff --git a/migrations/20251015214930_add_world_palettes.down.sql b/migrations/20251015214930_add_world_palettes.down.sql new file mode 100644 index 0000000..4a1dbf4 --- /dev/null +++ b/migrations/20251015214930_add_world_palettes.down.sql @@ -0,0 +1,27 @@ +ALTER TABLE pixel_history ADD COLUMN color_id_old SMALLINT; + +UPDATE pixel_history ph +SET color_id_old = ( + SELECT pc.palette_index + FROM palette_colors pc + WHERE pc.id = ph.color_id +); + +UPDATE pixel_history +SET color_id_old = -1 +WHERE color_id IS NULL; + +DROP INDEX IF EXISTS idx_pixel_history_color_id; + +ALTER TABLE pixel_history DROP CONSTRAINT IF EXISTS pixel_history_color_id_fkey; + +ALTER TABLE pixel_history DROP COLUMN IF EXISTS color_id; + +ALTER TABLE pixel_history RENAME COLUMN color_id_old TO color_id; + +ALTER TABLE pixel_history ALTER COLUMN color_id SET NOT NULL; + +DROP INDEX IF EXISTS idx_palette_colors_world_index; +DROP INDEX IF EXISTS idx_palette_colors_world_id; + +DROP TABLE IF EXISTS palette_colors; diff --git a/migrations/20251015214930_add_world_palettes.up.sql b/migrations/20251015214930_add_world_palettes.up.sql new file mode 100644 index 0000000..0744889 --- /dev/null +++ b/migrations/20251015214930_add_world_palettes.up.sql @@ -0,0 +1,76 @@ +CREATE TABLE palette_colors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + world_id UUID NOT NULL, + palette_index SMALLINT NOT NULL, + hex_color TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT palette_colors_world_id_fkey FOREIGN KEY (world_id) REFERENCES worlds(id) ON DELETE CASCADE, + CONSTRAINT palette_colors_unique_world_index UNIQUE (world_id, palette_index), + CONSTRAINT palette_colors_palette_index_range CHECK (palette_index >= 0 AND palette_index <= 255), + CONSTRAINT palette_colors_hex_format CHECK (hex_color ~ '^#[0-9A-Fa-f]{8}$') +); + +CREATE INDEX idx_palette_colors_world_id ON palette_colors(world_id); +CREATE INDEX idx_palette_colors_world_index ON palette_colors(world_id, palette_index); + +INSERT INTO palette_colors (world_id, palette_index, hex_color) +SELECT + w.id as world_id, + palette_index::smallint, + hex_color +FROM worlds w +CROSS JOIN (VALUES + (0, '#6D001AFF'), + (1, '#BE0039FF'), + (2, '#FF4500FF'), + (3, '#FFA800FF'), + (4, '#FFD635FF'), + (5, '#FFF8B8FF'), + (6, '#00A368FF'), + (7, '#00CC78FF'), + (8, '#7EED56FF'), + (9, '#00756FFF'), + (10, '#009EAAFF'), + (11, '#00CCC0FF'), + (12, '#2450A4FF'), + (13, '#3690EAFF'), + (14, '#51E9F4FF'), + (15, '#493AC1FF'), + (16, '#6A5CFFFF'), + (17, '#94B3FFFF'), + (18, '#811E9FFF'), + (19, '#B44AC0FF'), + (20, '#E4ABFFFF'), + (21, '#DE107FFF'), + (22, '#FF3881FF'), + (23, '#FF99AAFF'), + (24, '#6D482FFF'), + (25, '#9C6926FF'), + (26, '#FFB470FF'), + (27, '#000000FF'), + (28, '#515252FF'), + (29, '#898D90FF'), + (30, '#D4D7D9FF'), + (31, '#FFFFFFFF') +) AS colors(palette_index, hex_color) +WHERE w.is_default = TRUE; + +ALTER TABLE pixel_history RENAME COLUMN color_id TO color_id_old; + +ALTER TABLE pixel_history ADD COLUMN color_id UUID; + +UPDATE pixel_history ph +SET color_id = ( + SELECT pc.id + FROM palette_colors pc + WHERE pc.world_id = ph.world_id + AND pc.palette_index = ph.color_id_old +) +WHERE ph.color_id_old >= 0; + +ALTER TABLE pixel_history ADD CONSTRAINT pixel_history_color_id_fkey + FOREIGN KEY (color_id) REFERENCES palette_colors(id) ON DELETE SET NULL; + +CREATE INDEX idx_pixel_history_color_id ON pixel_history(color_id); + +ALTER TABLE pixel_history DROP COLUMN color_id_old; diff --git a/server/src/bootstrap/state.rs b/server/src/bootstrap/state.rs index 0f26692..3b19414 100644 --- a/server/src/bootstrap/state.rs +++ b/server/src/bootstrap/state.rs @@ -3,8 +3,7 @@ use sqlx::{PgPool, postgres::PgPoolOptions}; use std::sync::{Arc, atomic::AtomicUsize}; use tokio::sync::broadcast; -use domain::credits::CreditConfig; -use domain::{events::TileVersionEvent, tile::PaletteBufferPool}; +use domain::{color::RgbColor, events::TileVersionEvent, tile::PaletteBufferPool}; use fedi_wplace_adapters::shared::app_state::AppState as AdaptersAppState; use fedi_wplace_adapters::{ incoming::{ @@ -24,6 +23,7 @@ use fedi_wplace_adapters::{ credit_store_postgres::PostgresCreditStoreAdapter, pixel_history_store_postgres::PostgresPixelHistoryStoreAdapter, user_store_postgres::PostgresUserStoreAdapter, + world_store_postgres::PostgresWorldStoreAdapter, }, redis_deadpool::{ subscription_redis::RedisSubscriptionAdapter, tile_cache_redis::RedisTileCacheAdapter, @@ -41,12 +41,13 @@ use fedi_wplace_application::ports::outgoing::{ ban_store::BanStorePort, credit_store::CreditStorePort, email_sender::EmailSenderPort, events::EventsPort, image_codec::ImageCodecPort, password_hasher::PasswordHasherPort, pixel_history_store::PixelHistoryStorePort, subscription_port::SubscriptionPort, - tile_cache::TileCachePort, user_store::UserStorePort, + tile_cache::TileCachePort, user_store::UserStorePort, world_store::WorldStorePort, }; use fedi_wplace_application::{ admin::service::AdminService, auth::service::AuthService, ban::service::BanService, + canvas::service::CanvasConfigService, config::TileSettings, ports::incoming::{ admin::AdminUseCase, auth::AuthUseCase, ban::BanUseCase, subscriptions::SubscriptionUseCase, @@ -54,6 +55,7 @@ use fedi_wplace_application::{ subscriptions::service::SubscriptionService, tiles::service::PaletteColorLookup, tiles::service::{TileService, TileServiceDeps}, + world::service::WorldService, }; #[derive(Clone)] @@ -66,6 +68,8 @@ pub struct AppState { pub auth_service: Arc, pub admin_service: Arc, pub ban_service: Arc, + pub world_service: Arc, + pub canvas_config_service: Arc, pub ws_broadcast: broadcast::Sender, pub websocket_rate_limiter: Option>, pub active_websocket_connections: Arc, @@ -75,12 +79,17 @@ impl AppState { pub async fn new(config: Config) -> Result { let config = Arc::new(config); - let (palette_color_lookup, palette_buffer_pool) = Self::create_palette_components(&config); let (db_pool, redis_pool) = Self::create_database_connections(&config).await?; + + let default_palette = Self::load_default_palette(&config, &db_pool).await?; + let (palette_color_lookup, palette_buffer_pool) = + Self::create_palette_components(&config, &default_palette); + let (ws_broadcast, _) = broadcast::channel(config.websocket.broadcast_buffer_size); let tile_service = Self::create_tile_service( &config, + &default_palette, &palette_color_lookup, &palette_buffer_pool, &db_pool, @@ -92,6 +101,8 @@ impl AppState { let auth_service = Self::create_auth_service(&config, &db_pool)?; let admin_service = Self::create_admin_service(&config, &db_pool); let ban_service = Self::create_ban_service(&config, &db_pool); + let world_service = Self::create_world_service(&config, &db_pool); + let canvas_config_service = Self::create_canvas_config_service(&config, &db_pool); let websocket_rate_limiter = if config.rate_limit.enabled { Some(create_websocket_rate_limiter(&config.rate_limit)) @@ -108,18 +119,50 @@ impl AppState { auth_service, admin_service, ban_service, + world_service, + canvas_config_service, ws_broadcast, websocket_rate_limiter, active_websocket_connections: Arc::new(AtomicUsize::new(0)), }) } + async fn load_default_palette( + config: &Config, + db_pool: &PgPool, + ) -> Result, AppError> { + let world_store_port: Arc = Arc::new(PostgresWorldStoreAdapter::new( + db_pool.clone(), + config.db.query_timeout_secs, + )); + + let default_world = + world_store_port + .get_default_world() + .await? + .ok_or_else(|| AppError::ConfigError { + message: "No default world found in database".to_string(), + })?; + + let palette_colors = world_store_port + .get_palette_colors(&default_world.id) + .await?; + + let mut palette_vec = Vec::new(); + for palette_color in palette_colors { + if let Some(rgba_u32) = palette_color.hex_color.to_rgba_u32() { + palette_vec.push(RgbColor::from_rgba_u32(rgba_u32)); + } + } + + Ok(palette_vec) + } + fn create_palette_components( config: &Config, + palette: &[RgbColor], ) -> (Arc, Arc) { - let palette_color_lookup = Arc::new(PaletteColorLookup::from_color_palette( - &config.color_palette.colors, - )); + let palette_color_lookup = Arc::new(PaletteColorLookup::from_color_palette(palette)); let palette_buffer_pool = Arc::new(PaletteBufferPool::new( config.tiles.tile_size, config.tiles.buffer_pool_max_size, @@ -148,6 +191,7 @@ impl AppState { fn create_tile_service( config: &Config, + palette: &[RgbColor], palette_color_lookup: &Arc, palette_buffer_pool: &Arc, db_pool: &PgPool, @@ -172,10 +216,9 @@ impl AppState { config.tiles.tile_size, config.db.query_timeout_secs, )); - let credit_store: Arc = Arc::new(PostgresCreditStoreAdapter::new( - db_pool.clone(), - config.db.query_timeout_secs, - )); + let credit_store_port: Arc = Arc::new( + PostgresCreditStoreAdapter::new(db_pool.clone(), config.db.query_timeout_secs), + ); let codec_port: Arc = Arc::new(ImageWebpAdapter::new(webp_config)); let events_port: Arc = Arc::new(TokioBroadcastEventsAdapter::new(ws_broadcast.clone())); @@ -183,12 +226,7 @@ impl AppState { let tile_settings = Arc::new(TileSettings { tile_size: config.tiles.tile_size, pixel_size: config.tiles.pixel_size, - palette: config.color_palette.colors.clone().into(), - transparency_color_id: config - .color_palette - .get_transparency_color_id() - .unwrap_or(255), - color_palette_config: Arc::new(config.color_palette.clone()), + palette: palette.to_vec().into(), }); let tile_service = TileService::new( @@ -202,11 +240,7 @@ impl AppState { events_port, task_spawn_port: Arc::new(TokioTaskSpawnAdapter::new()), pixel_history_store, - credit_store, - credit_config: CreditConfig::new( - config.credits.max_charges, - config.credits.charge_cooldown_seconds, - ), + credit_store: credit_store_port, }, )?; @@ -221,6 +255,7 @@ impl AppState { redis_pool.clone(), config.ws_policy.max_tiles_per_ip, config.ws_policy.subscription_ttl_secs * 1000, + &config.environment.env, )); Arc::new(SubscriptionService::new(subscription_port)) } @@ -285,6 +320,22 @@ impl AppState { Arc::new(BanService::new(ban_store_port, user_store_port)) } + fn create_world_service(config: &Config, db_pool: &PgPool) -> Arc { + let world_store_port: Arc = Arc::new(PostgresWorldStoreAdapter::new( + db_pool.clone(), + config.db.query_timeout_secs, + )); + Arc::new(WorldService::new(world_store_port)) + } + + fn create_canvas_config_service(config: &Config, db_pool: &PgPool) -> Arc { + let world_store_port: Arc = Arc::new(PostgresWorldStoreAdapter::new( + db_pool.clone(), + config.db.query_timeout_secs, + )); + Arc::new(CanvasConfigService::new(world_store_port)) + } + pub fn db_pool(&self) -> &PgPool { &self.db_pool } @@ -319,6 +370,11 @@ impl AppState { self.db_pool.clone(), self.config.db.query_timeout_secs, )); + let credit_store_port: Arc = + Arc::new(PostgresCreditStoreAdapter::new( + self.db_pool.clone(), + self.config.db.query_timeout_secs, + )); let admin_service = Arc::new(AdminService::new(Arc::clone(&user_store_port))); let adapters_state = AdaptersAppState::new( @@ -333,6 +389,9 @@ impl AppState { self.auth_service, admin_service, self.ban_service, + self.world_service, + self.canvas_config_service, + credit_store_port, self.ws_broadcast, self.websocket_rate_limiter, self.active_websocket_connections,