From 758c3e064acefda7ed96ee44dc9566aed165edd5 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 11:32:07 +0200 Subject: [PATCH 1/7] feat: broadcast equipment changes made over REST --- src/api/v0/cosmetics/put_player.rs | 11 +++++++++-- src/api/v0/websocket/endpoint.rs | 23 +++++++++++++++++++++++ src/api/v0/websocket/mod.rs | 4 +++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/api/v0/cosmetics/put_player.rs b/src/api/v0/cosmetics/put_player.rs index f76f6ca..e11dfc0 100644 --- a/src/api/v0/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -17,7 +17,10 @@ use serde::Deserialize; use crate::api::{ ApiState, - v0::{account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics}, + v0::{ + account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics, + websocket::publish_equipment_change, + }, }; #[derive(thiserror::Error, Debug, OperationIo)] @@ -90,7 +93,7 @@ async fn endpoint( let txn = state.database.begin().await?; - for (slot, value) in body.equipment.equipped { + for (slot, value) in body.equipment.equipped.clone() { if let Some(id) = value { let owned = PlayerOwnedCosmetic::find() .filter(player_owned_cosmetic::Column::PlayerId.eq(player.id)) @@ -147,5 +150,9 @@ async fn endpoint( txn.commit().await?; } + for (slot, cosmetic_id) in body.equipment.equipped { + publish_equipment_change(&state, player.minecraft_uuid, slot, cosmetic_id).await; + } + Ok(NoContent) } diff --git a/src/api/v0/websocket/endpoint.rs b/src/api/v0/websocket/endpoint.rs index f67e6f8..bbcab03 100644 --- a/src/api/v0/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -768,6 +768,29 @@ pub(crate) async fn unequip_revoked(state: &ApiState, revoked: &HashMap, +) { + if let Some(player_state) = state.realtime.player_runtime.write().await.get_mut(&player) { + if let Some(cosmetic_id) = cosmetic_id { + player_state.equipped.insert(slot.clone(), cosmetic_id); + } else { + player_state.equipped.remove(&slot); + } + } + + let packet = || ClientBoundPacket::PlayerCosmeticEquipped { + player, + slot: slot.clone(), + cosmetic_id, + }; + broadcast_to_watchers(state, player, packet).await; + send_to_owner(state, player, packet).await; +} + pub(crate) async fn broadcast_all( state: &ApiState, mut make_packet: impl FnMut() -> ClientBoundPacket, diff --git a/src/api/v0/websocket/mod.rs b/src/api/v0/websocket/mod.rs index 97e1f2d..032a6b8 100644 --- a/src/api/v0/websocket/mod.rs +++ b/src/api/v0/websocket/mod.rs @@ -5,7 +5,9 @@ use crate::api::ApiState; mod endpoint; pub mod structs; -pub(crate) use endpoint::{broadcast_all, send_to_owner, unequip_revoked}; +pub(crate) use endpoint::{ + broadcast_all, publish_equipment_change, send_to_owner, unequip_revoked, +}; pub(super) async fn setup_router() -> ApiRouter { ApiRouter::new().merge(endpoint::router()) From 496360f62e97959a7b83ec266dd8d7614da26629 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:41 +0200 Subject: [PATCH 2/7] feat: add outfit and skin tables --- database/entities/src/entities/cosmetic.rs | 17 ++ database/entities/src/entities/mod.rs | 3 + .../entities/src/entities/player_outfit.rs | 69 +++++ .../src/entities/player_outfit_cosmetic.rs | 46 +++ database/entities/src/entities/player_skin.rs | 46 +++ database/entities/src/entities/prelude.rs | 3 + database/entities/src/entities/user.rs | 17 ++ database/migrations/src/lib.rs | 2 + ...0261015_000000_create_outfits_and_skins.rs | 269 ++++++++++++++++++ 9 files changed, 472 insertions(+) create mode 100644 database/entities/src/entities/player_outfit.rs create mode 100644 database/entities/src/entities/player_outfit_cosmetic.rs create mode 100644 database/entities/src/entities/player_skin.rs create mode 100644 database/migrations/src/m20261015_000000_create_outfits_and_skins.rs diff --git a/database/entities/src/entities/cosmetic.rs b/database/entities/src/entities/cosmetic.rs index 6827965..548c0ec 100644 --- a/database/entities/src/entities/cosmetic.rs +++ b/database/entities/src/entities/cosmetic.rs @@ -66,6 +66,8 @@ pub enum Relation { CosmeticPackage, #[sea_orm(has_many = "super::player_equipped_cosmetic::Entity")] PlayerEquippedCosmetic, + #[sea_orm(has_many = "super::player_outfit_cosmetic::Entity")] + PlayerOutfitCosmetic, #[sea_orm(has_many = "super::player_owned_cosmetic::Entity")] PlayerOwnedCosmetic, #[sea_orm(has_many = "super::tags_cosmetic::Entity")] @@ -116,6 +118,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerOutfitCosmetic.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::PlayerOwnedCosmetic.def() @@ -143,6 +151,15 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + super::player_outfit_cosmetic::Relation::PlayerOutfit.def() + } + fn via() -> Option { + Some(super::player_outfit_cosmetic::Relation::Cosmetic.def().rev()) + } +} + impl Related for Entity { fn to() -> RelationDef { super::tags_cosmetic::Relation::Tags.def() diff --git a/database/entities/src/entities/mod.rs b/database/entities/src/entities/mod.rs index 90f2638..3369805 100644 --- a/database/entities/src/entities/mod.rs +++ b/database/entities/src/entities/mod.rs @@ -43,7 +43,10 @@ pub mod player_client_info; pub mod player_equipped_cosmetic; pub mod player_geo; pub mod player_mod; +pub mod player_outfit; +pub mod player_outfit_cosmetic; pub mod player_owned_cosmetic; +pub mod player_skin; pub mod product_requirement; pub mod product_settings; pub mod relationship_requests; diff --git a/database/entities/src/entities/player_outfit.rs b/database/entities/src/entities/player_outfit.rs new file mode 100644 index 0000000..3e186ac --- /dev/null +++ b/database/entities/src/entities/player_outfit.rs @@ -0,0 +1,69 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "player_outfit")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_id: i32, + #[sea_orm(column_type = "Text")] + pub name: String, + pub favorite: bool, + pub skin_id: Option, + #[sea_orm(column_type = "Text", nullable)] + pub mojang_cape_id: Option, + pub particle_color: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::player_outfit_cosmetic::Entity")] + PlayerOutfitCosmetic, + #[sea_orm( + belongs_to = "super::player_skin::Entity", + from = "Column::SkinId", + to = "super::player_skin::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + PlayerSkin, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::PlayerId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerOutfitCosmetic.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerSkin.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::player_outfit_cosmetic::Relation::Cosmetic.def() + } + fn via() -> Option { + Some(super::player_outfit_cosmetic::Relation::PlayerOutfit.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/player_outfit_cosmetic.rs b/database/entities/src/entities/player_outfit_cosmetic.rs new file mode 100644 index 0000000..4e2c0b1 --- /dev/null +++ b/database/entities/src/entities/player_outfit_cosmetic.rs @@ -0,0 +1,46 @@ +use super::sea_orm_active_enums::BodySlot; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "player_outfit_cosmetic")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub outfit_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub slot: BodySlot, + pub cosmetic_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::cosmetic::Entity", + from = "Column::CosmeticId", + to = "super::cosmetic::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Cosmetic, + #[sea_orm( + belongs_to = "super::player_outfit::Entity", + from = "Column::OutfitId", + to = "super::player_outfit::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + PlayerOutfit, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Cosmetic.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerOutfit.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/player_skin.rs b/database/entities/src/entities/player_skin.rs new file mode 100644 index 0000000..636ef12 --- /dev/null +++ b/database/entities/src/entities/player_skin.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "player_skin")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub player_id: i32, + #[sea_orm(column_type = "Text")] + pub name: String, + #[sea_orm(column_type = "Text")] + pub model: String, + #[sea_orm(column_type = "Text")] + pub hash: String, + pub favorite: bool, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::player_outfit::Entity")] + PlayerOutfit, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::PlayerId", + to = "super::user::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerOutfit.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/database/entities/src/entities/prelude.rs b/database/entities/src/entities/prelude.rs index 985a765..9866af2 100644 --- a/database/entities/src/entities/prelude.rs +++ b/database/entities/src/entities/prelude.rs @@ -41,7 +41,10 @@ pub use super::player_client_info::Entity as PlayerClientInfo; pub use super::player_equipped_cosmetic::Entity as PlayerEquippedCosmetic; pub use super::player_geo::Entity as PlayerGeo; pub use super::player_mod::Entity as PlayerMod; +pub use super::player_outfit::Entity as PlayerOutfit; +pub use super::player_outfit_cosmetic::Entity as PlayerOutfitCosmetic; pub use super::player_owned_cosmetic::Entity as PlayerOwnedCosmetic; +pub use super::player_skin::Entity as PlayerSkin; pub use super::product_requirement::Entity as ProductRequirement; pub use super::product_settings::Entity as ProductSettings; pub use super::relationship_requests::Entity as RelationshipRequests; diff --git a/database/entities/src/entities/user.rs b/database/entities/src/entities/user.rs index 0ccaa97..bead078 100644 --- a/database/entities/src/entities/user.rs +++ b/database/entities/src/entities/user.rs @@ -20,6 +20,7 @@ pub struct Model { pub chargeback_count: i32, #[sea_orm(column_type = "Text", nullable, unique)] pub paynow_customer_id: Option, + pub active_outfit_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -30,8 +31,18 @@ pub enum Relation { MonthlyActiveLogin, #[sea_orm(has_many = "super::player_equipped_cosmetic::Entity")] PlayerEquippedCosmetic, + #[sea_orm( + belongs_to = "super::player_outfit::Entity", + from = "Column::ActiveOutfitId", + to = "super::player_outfit::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + PlayerOutfit, #[sea_orm(has_many = "super::player_owned_cosmetic::Entity")] PlayerOwnedCosmetic, + #[sea_orm(has_many = "super::player_skin::Entity")] + PlayerSkin, #[sea_orm(has_many = "super::transaction_line::Entity")] TransactionLine, } @@ -60,6 +71,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::PlayerSkin.def() + } +} + impl Related for Entity { fn to() -> RelationDef { super::player_equipped_cosmetic::Relation::Cosmetic.def() diff --git a/database/migrations/src/lib.rs b/database/migrations/src/lib.rs index 3b77f08..479c924 100644 --- a/database/migrations/src/lib.rs +++ b/database/migrations/src/lib.rs @@ -71,6 +71,7 @@ mod m20260924_000001_ownership_expiry; mod m20260928_000000_create_player_mods; mod m20260929_000000_product_for_sale; mod m20261014_000000_create_dm_delegates; +mod m20261015_000000_create_outfits_and_skins; pub struct Migrator; @@ -149,6 +150,7 @@ impl MigratorTrait for Migrator { Box::new(m20260928_000000_create_player_mods::Migration), Box::new(m20260929_000000_product_for_sale::Migration), Box::new(m20261014_000000_create_dm_delegates::Migration), + Box::new(m20261015_000000_create_outfits_and_skins::Migration), ] } } diff --git a/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs new file mode 100644 index 0000000..59238cc --- /dev/null +++ b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs @@ -0,0 +1,269 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum User { + Table, + Id, + ActiveOutfitId, +} + +#[derive(DeriveIden)] +enum Cosmetic { + Table, + Id, +} + +#[derive(DeriveIden)] +enum BodySlot { + #[sea_orm(iden = "body_slot")] + Enum, +} + +#[derive(DeriveIden)] +enum PlayerSkin { + Table, + Id, + PlayerId, + Name, + Model, + Hash, + Favorite, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum PlayerOutfit { + Table, + Id, + PlayerId, + Name, + Favorite, + SkinId, + MojangCapeId, + ParticleColor, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum PlayerOutfitCosmetic { + Table, + OutfitId, + Slot, + CosmeticId, +} + +const SKIN_PLAYER_HASH_IDX: &str = "player_skin_player_id_hash_key"; +const OUTFIT_PLAYER_IDX: &str = "player_outfit_player_id_idx"; +const USER_ACTIVE_OUTFIT_FK: &str = "user_active_outfit_id_fkey"; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(PlayerSkin::Table) + .if_not_exists() + .col( + ColumnDef::new(PlayerSkin::Id) + .integer() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(PlayerSkin::PlayerId).integer().not_null()) + .col(ColumnDef::new(PlayerSkin::Name).text().not_null()) + .col(ColumnDef::new(PlayerSkin::Model).text().not_null()) + .col(ColumnDef::new(PlayerSkin::Hash).text().not_null()) + .col( + ColumnDef::new(PlayerSkin::Favorite) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(PlayerSkin::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PlayerSkin::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .from(PlayerSkin::Table, PlayerSkin::PlayerId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name(SKIN_PLAYER_HASH_IDX) + .table(PlayerSkin::Table) + .col(PlayerSkin::PlayerId) + .col(PlayerSkin::Hash) + .unique() + .if_not_exists() + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(PlayerOutfit::Table) + .if_not_exists() + .col( + ColumnDef::new(PlayerOutfit::Id) + .integer() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(PlayerOutfit::PlayerId).integer().not_null()) + .col(ColumnDef::new(PlayerOutfit::Name).text().not_null()) + .col( + ColumnDef::new(PlayerOutfit::Favorite) + .boolean() + .not_null() + .default(false), + ) + .col(ColumnDef::new(PlayerOutfit::SkinId).integer().null()) + .col(ColumnDef::new(PlayerOutfit::MojangCapeId).text().null()) + .col(ColumnDef::new(PlayerOutfit::ParticleColor).integer().null()) + .col( + ColumnDef::new(PlayerOutfit::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(PlayerOutfit::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .from(PlayerOutfit::Table, PlayerOutfit::PlayerId) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(PlayerOutfit::Table, PlayerOutfit::SkinId) + .to(PlayerSkin::Table, PlayerSkin::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name(OUTFIT_PLAYER_IDX) + .table(PlayerOutfit::Table) + .col(PlayerOutfit::PlayerId) + .if_not_exists() + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(PlayerOutfitCosmetic::Table) + .if_not_exists() + .col( + ColumnDef::new(PlayerOutfitCosmetic::OutfitId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(PlayerOutfitCosmetic::Slot) + .custom(BodySlot::Enum) + .not_null(), + ) + .col( + ColumnDef::new(PlayerOutfitCosmetic::CosmeticId) + .integer() + .not_null(), + ) + .primary_key( + Index::create() + .col(PlayerOutfitCosmetic::OutfitId) + .col(PlayerOutfitCosmetic::Slot), + ) + .foreign_key( + ForeignKey::create() + .from( + PlayerOutfitCosmetic::Table, + PlayerOutfitCosmetic::OutfitId, + ) + .to(PlayerOutfit::Table, PlayerOutfit::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from( + PlayerOutfitCosmetic::Table, + PlayerOutfitCosmetic::CosmeticId, + ) + .to(Cosmetic::Table, Cosmetic::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(User::Table) + .add_column(ColumnDef::new(User::ActiveOutfitId).integer().null()) + .add_foreign_key( + TableForeignKey::new() + .name(USER_ACTIVE_OUTFIT_FK) + .from_tbl(User::Table) + .from_col(User::ActiveOutfitId) + .to_tbl(PlayerOutfit::Table) + .to_col(PlayerOutfit::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(User::Table) + .drop_column(User::ActiveOutfitId) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(PlayerOutfitCosmetic::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(PlayerOutfit::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(PlayerSkin::Table).to_owned()) + .await + } +} From 6d454b961d77368d8a18627c9dd923b68614224a Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:45 +0200 Subject: [PATCH 3/7] feat: add outfit and skin endpoints Skins are stored as references to Mojang textures; applying an outfit swaps the equipped cosmetics and broadcasts the change. --- src/api/v0/mod.rs | 2 + src/api/v0/outfits/mod.rs | 248 ++++++++++++++++ src/api/v0/outfits/outfit.rs | 482 +++++++++++++++++++++++++++++++ src/api/v0/outfits/skin.rs | 275 ++++++++++++++++++ src/api/v0/websocket/endpoint.rs | 16 + src/api/v0/websocket/mod.rs | 3 +- 6 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 src/api/v0/outfits/mod.rs create mode 100644 src/api/v0/outfits/outfit.rs create mode 100644 src/api/v0/outfits/skin.rs diff --git a/src/api/v0/mod.rs b/src/api/v0/mod.rs index d4b255a..6b19b7a 100644 --- a/src/api/v0/mod.rs +++ b/src/api/v0/mod.rs @@ -8,6 +8,7 @@ pub(super) mod cosmetics; mod groups; mod links; pub(super) mod oidc; +pub(super) mod outfits; pub(super) mod players; pub(super) mod sessions; mod social; @@ -29,6 +30,7 @@ pub(super) async fn setup_router() -> ApiRouter { .merge(collections::setup_router().await) .merge(links::setup_router().await) .merge(oidc::setup_router().await) + .merge(outfits::setup_router().await) .merge(players::setup_router().await) .merge(cosmetics::setup_router().await) // .merge(global_chat::setup_router().await) diff --git a/src/api/v0/outfits/mod.rs b/src/api/v0/outfits/mod.rs new file mode 100644 index 0000000..8be57e8 --- /dev/null +++ b/src/api/v0/outfits/mod.rs @@ -0,0 +1,248 @@ +mod outfit; +mod skin; + +use std::collections::HashMap; + +use aide::{OperationIo, axum::ApiRouter}; +use axum::{http::StatusCode, response::IntoResponse}; +use chrono::{DateTime, FixedOffset}; +use entities::{ + player_outfit, player_outfit_cosmetic, player_skin, sea_orm_active_enums::BodySlot, + user, +}; +use schemars::JsonSchema; +use sea_orm::{ConnectionTrait, DbErr}; +use serde::{Deserialize, Serialize}; + +use crate::api::ApiState; + +pub(super) const OUTFIT_LIMIT: u64 = 128; +pub(super) const SKIN_LIMIT: u64 = 128; +const MAX_NAME_LENGTH: usize = 64; +const MAX_CAPE_ID_LENGTH: usize = 64; +const MIN_TEXTURE_HASH_LENGTH: usize = 32; +const MAX_TEXTURE_HASH_LENGTH: usize = 64; +const MOJANG_TEXTURE_URL: &str = "https://textures.minecraft.net/texture/"; + +pub(super) async fn setup_router() -> ApiRouter { + ApiRouter::new() + .merge(outfit::router()) + .merge(skin::router()) +} + +#[derive(thiserror::Error, Debug, OperationIo)] +pub enum OutfitError { + #[error("No outfit with that id exists")] + OutfitMissing, + #[error("No skin with that id exists")] + SkinMissing, + #[error("The given skin is not in your skin library")] + InvalidSkin, + #[error("You can have at most {} outfits", OUTFIT_LIMIT)] + OutfitLimit, + #[error("You can have at most {} skins", SKIN_LIMIT)] + SkinLimit, + #[error("Names must be between 1 and {} characters long", MAX_NAME_LENGTH)] + InvalidName, + #[error("Mojang cape ids must be at most {} characters long", MAX_CAPE_ID_LENGTH)] + InvalidCapeId, + #[error( + "Texture hashes must be {}-{} hexadecimal characters", + MIN_TEXTURE_HASH_LENGTH, + MAX_TEXTURE_HASH_LENGTH + )] + InvalidTextureHash, + #[error("Unable to query database: {0}")] + Database(#[from] DbErr), +} + +impl IntoResponse for OutfitError { + fn into_response(self) -> axum::response::Response { + crate::api::error_response( + match &self { + Self::OutfitMissing | Self::SkinMissing => StatusCode::NOT_FOUND, + Self::InvalidSkin + | Self::InvalidName + | Self::InvalidCapeId + | Self::InvalidTextureHash => StatusCode::BAD_REQUEST, + Self::OutfitLimit | Self::SkinLimit => StatusCode::CONFLICT, + Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, + }, + self, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SkinModel { + Classic, + Slim, +} + +impl SkinModel { + fn as_str(self) -> &'static str { + match self { + Self::Classic => "classic", + Self::Slim => "slim", + } + } + + fn parse(value: &str) -> Option { + match value { + "classic" => Some(Self::Classic), + "slim" => Some(Self::Slim), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SkinInfo { + id: i32, + name: String, + model: SkinModel, + url: String, + hash: String, + favorite: bool, + created_at: DateTime, +} + +impl SkinInfo { + fn from_model(skin: player_skin::Model) -> Self { + Self { + id: skin.id, + name: skin.name, + model: SkinModel::parse(&skin.model).unwrap_or(SkinModel::Classic), + url: texture_url(&skin.hash), + hash: skin.hash, + favorite: skin.favorite, + created_at: skin.created_at, + } + } +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct OutfitInfo { + id: i32, + name: String, + favorite: bool, + skin_id: Option, + mojang_cape_id: Option, + particle_color: Option, + equipped: HashMap, + created_at: DateTime, + updated_at: DateTime, +} + +impl OutfitInfo { + fn from_model( + outfit: player_outfit::Model, + equipped: HashMap, + ) -> Self { + Self { + id: outfit.id, + name: outfit.name, + favorite: outfit.favorite, + skin_id: outfit.skin_id, + mojang_cape_id: outfit.mojang_cape_id, + particle_color: outfit.particle_color, + equipped, + created_at: outfit.created_at, + updated_at: outfit.updated_at, + } + } +} + +fn validate_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed.chars().count() > MAX_NAME_LENGTH { + return Err(OutfitError::InvalidName); + } + + Ok(trimmed.to_string()) +} + +fn texture_url(hash: &str) -> String { + format!("{MOJANG_TEXTURE_URL}{hash}") +} + +fn normalize_texture_hash(hash: &str) -> Result { + let hash = hash.trim().to_ascii_lowercase(); + if !(MIN_TEXTURE_HASH_LENGTH..=MAX_TEXTURE_HASH_LENGTH).contains(&hash.len()) + || !hash.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(OutfitError::InvalidTextureHash); + } + + Ok(hash) +} + +fn normalize_cape_id(cape_id: Option) -> Result, OutfitError> { + let Some(cape_id) = cape_id else { + return Ok(None); + }; + + let trimmed = cape_id.trim(); + if trimmed.is_empty() { + return Ok(None); + } + if trimmed.chars().count() > MAX_CAPE_ID_LENGTH { + return Err(OutfitError::InvalidCapeId); + } + + Ok(Some(trimmed.to_string())) +} + +pub(crate) async fn mirror_active_outfit_slot( + db: &C, + player: &user::Model, + slot: BodySlot, + cosmetic_id: Option, +) -> Result<(), DbErr> { + use entities::prelude::*; + use sea_orm::{ + ColumnTrait, EntityTrait, QueryFilter, Set, + sea_query::{Expr, OnConflict}, + }; + + let Some(outfit_id) = player.active_outfit_id else { + return Ok(()); + }; + + match cosmetic_id { + Some(cosmetic_id) => { + PlayerOutfitCosmetic::insert(player_outfit_cosmetic::ActiveModel { + outfit_id: Set(outfit_id), + slot: Set(slot), + cosmetic_id: Set(cosmetic_id), + }) + .on_conflict( + OnConflict::columns([ + player_outfit_cosmetic::Column::OutfitId, + player_outfit_cosmetic::Column::Slot, + ]) + .update_column(player_outfit_cosmetic::Column::CosmeticId) + .to_owned(), + ) + .exec(db) + .await?; + } + None => { + PlayerOutfitCosmetic::delete_many() + .filter(player_outfit_cosmetic::Column::OutfitId.eq(outfit_id)) + .filter(player_outfit_cosmetic::Column::Slot.eq(slot)) + .exec(db) + .await?; + } + } + + PlayerOutfit::update_many() + .col_expr(player_outfit::Column::UpdatedAt, Expr::current_timestamp().into()) + .filter(player_outfit::Column::Id.eq(outfit_id)) + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .exec(db) + .await?; + + Ok(()) +} diff --git a/src/api/v0/outfits/outfit.rs b/src/api/v0/outfits/outfit.rs new file mode 100644 index 0000000..80f3420 --- /dev/null +++ b/src/api/v0/outfits/outfit.rs @@ -0,0 +1,482 @@ +use std::collections::{HashMap, HashSet}; + +use aide::{ + axum::{ + ApiRouter, + routing::{get_with, patch_with, post_with}, + }, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use entities::{ + cosmetic_allowed_slot, player_equipped_cosmetic, player_outfit, + player_outfit_cosmetic, player_owned_cosmetic, player_skin, prelude::*, + sea_orm_active_enums::BodySlot, user, +}; +use schemars::JsonSchema; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, EntityTrait, + PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, sea_query::Expr, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + OUTFIT_LIMIT, OutfitError, OutfitInfo, SkinInfo, normalize_cape_id, validate_name, +}; +use crate::api::{ + ApiState, + v0::{ + account::AuthenticatedPlayer, + websocket::{publish_equipment_change, publish_particle_color_change}, + }, +}; + +#[derive(Debug, Serialize, JsonSchema)] +struct OutfitListResponse { + outfits: Vec, + active_outfit_id: Option, + limit: u64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct CreateOutfitRequest { + name: Option, + skin_id: Option, + mojang_cape_id: Option, + particle_color: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct UpdateOutfitRequest { + name: Option, + favorite: Option, + #[serde(default, deserialize_with = "serde_with::rust::double_option::deserialize")] + skin_id: Option>, + #[serde(default, deserialize_with = "serde_with::rust::double_option::deserialize")] + mojang_cape_id: Option>, + #[serde(default, deserialize_with = "serde_with::rust::double_option::deserialize")] + particle_color: Option>, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ApplyOutfitResponse { + outfit: OutfitInfo, + skin: Option, +} + +fn list_doc(op: TransformOperation) -> TransformOperation { + op.id("listOutfits") + .summary("List the player's outfits") + .description( + "Lists the authenticated player's outfits, newest first, along with the \ + id of the active outfit and the library limit.", + ) + .tag("outfits") +} + +fn create_doc(op: TransformOperation) -> TransformOperation { + op.id("createOutfit") + .summary("Create an outfit from the current look") + .description( + "Creates an outfit holding the player's currently equipped cosmetics plus \ + the given skin, Mojang cape and particle color, and makes it the active \ + outfit. Names default to `Outfit #N`.", + ) + .tag("outfits") + .response_with::<{ StatusCode::CREATED.as_u16() }, Json, _>(|res| { + res.description("The created outfit") + }) + .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { + res.description( + "The skin is not in the player's library, or a field is invalid", + ) + }) + .response_with::<{ StatusCode::CONFLICT.as_u16() }, String, _>(|res| { + res.description("The player already has the maximum number of outfits") + }) +} + +fn update_doc(op: TransformOperation) -> TransformOperation { + op.id("updateOutfit") + .summary("Update an outfit") + .description( + "Updates an outfit's fields. Absent fields are left unchanged; an explicit \ + `null` clears `skin_id`, `mojang_cape_id` or `particle_color`.", + ) + .tag("outfits") + .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { + res.description( + "The skin is not in the player's library, or a field is invalid", + ) + }) + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("The outfit does not exist or is not the player's") + }) +} + +fn delete_doc(op: TransformOperation) -> TransformOperation { + op.id("deleteOutfit") + .summary("Delete an outfit") + .description( + "Deletes an outfit. Deleting the active outfit leaves no outfit active.", + ) + .tag("outfits") + .response_with::<{ StatusCode::NO_CONTENT.as_u16() }, (), _>(|res| { + res.description("The outfit was deleted") + }) + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("The outfit does not exist or is not the player's") + }) +} + +fn apply_doc(op: TransformOperation) -> TransformOperation { + op.id("applyOutfit") + .summary("Switch to an outfit") + .description( + "Replaces the player's equipped cosmetics with the outfit's, skipping any \ + that are no longer owned, sets the particle color and makes the outfit \ + active. Changes are broadcast to websocket watchers and the player's own \ + connections. The client applies the outfit's skin and Mojang cape itself.", + ) + .tag("outfits") + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("The outfit does not exist or is not the player's") + }) +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new() + .api_route( + "/outfits", + get_with(self::list, self::list_doc) + .post_with(self::create, self::create_doc), + ) + .api_route( + "/outfits/{id}", + patch_with(self::update, self::update_doc) + .delete_with(self::delete, self::delete_doc), + ) + .api_route("/outfits/{id}/apply", post_with(self::apply, self::apply_doc)) +} + +async fn find_outfit( + db: &C, + player_id: i32, + id: i32, +) -> Result { + PlayerOutfit::find_by_id(id) + .filter(player_outfit::Column::PlayerId.eq(player_id)) + .one(db) + .await? + .ok_or(OutfitError::OutfitMissing) +} + +async fn require_skin( + db: &C, + player_id: i32, + skin_id: i32, +) -> Result { + PlayerSkin::find_by_id(skin_id) + .filter(player_skin::Column::PlayerId.eq(player_id)) + .one(db) + .await? + .ok_or(OutfitError::InvalidSkin) +} + +async fn load_outfit_cosmetics( + db: &C, + outfit_ids: impl IntoIterator, +) -> Result>, sea_orm::DbErr> { + let mut by_outfit: HashMap> = HashMap::new(); + for row in PlayerOutfitCosmetic::find() + .filter(player_outfit_cosmetic::Column::OutfitId.is_in(outfit_ids)) + .all(db) + .await? + { + by_outfit + .entry(row.outfit_id) + .or_default() + .insert(row.slot, row.cosmetic_id); + } + + Ok(by_outfit) +} + +async fn outfit_info( + db: &C, + outfit: player_outfit::Model, +) -> Result { + let equipped = load_outfit_cosmetics(db, [outfit.id]) + .await? + .remove(&outfit.id) + .unwrap_or_default(); + + Ok(OutfitInfo::from_model(outfit, equipped)) +} + +async fn current_equipment( + state: &ApiState, + player: &user::Model, +) -> Result, OutfitError> { + if let Some(runtime) = state + .realtime + .player_runtime + .read() + .await + .get(&player.minecraft_uuid) + { + return Ok(runtime.equipped.clone()); + } + + Ok(PlayerEquippedCosmetic::find() + .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) + .all(&state.database) + .await? + .into_iter() + .map(|row| (row.slot, row.cosmetic_id)) + .collect()) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn list( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, +) -> Result, OutfitError> { + let outfits = PlayerOutfit::find() + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .order_by_desc(player_outfit::Column::CreatedAt) + .order_by_desc(player_outfit::Column::Id) + .all(&state.database) + .await?; + let mut cosmetics = load_outfit_cosmetics( + &state.database, + outfits.iter().map(|outfit| outfit.id), + ) + .await?; + + Ok(Json(OutfitListResponse { + outfits: outfits + .into_iter() + .map(|outfit| { + let equipped = cosmetics.remove(&outfit.id).unwrap_or_default(); + OutfitInfo::from_model(outfit, equipped) + }) + .collect(), + active_outfit_id: player.active_outfit_id, + limit: OUTFIT_LIMIT, + })) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn create( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Json(body): Json, +) -> Result<(StatusCode, Json), OutfitError> { + let name = body.name.as_deref().map(validate_name).transpose()?; + let mojang_cape_id = normalize_cape_id(body.mojang_cape_id)?; + let equipped = current_equipment(&state, &player).await?; + + let txn = state.database.begin().await?; + + let count = PlayerOutfit::find() + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .count(&txn) + .await?; + if count >= OUTFIT_LIMIT { + return Err(OutfitError::OutfitLimit); + } + + if let Some(skin_id) = body.skin_id { + require_skin(&txn, player.id, skin_id).await?; + } + + let outfit = player_outfit::ActiveModel { + player_id: Set(player.id), + name: Set(name.unwrap_or_else(|| format!("Outfit #{}", count + 1))), + favorite: Set(false), + skin_id: Set(body.skin_id), + mojang_cape_id: Set(mojang_cape_id), + particle_color: Set(body.particle_color), + ..Default::default() + } + .insert(&txn) + .await?; + + if !equipped.is_empty() { + PlayerOutfitCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { + player_outfit_cosmetic::ActiveModel { + outfit_id: Set(outfit.id), + slot: Set(slot.clone()), + cosmetic_id: Set(*cosmetic_id), + } + })) + .exec(&txn) + .await?; + } + + User::update_many() + .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(outfit.id))) + .filter(user::Column::Id.eq(player.id)) + .exec(&txn) + .await?; + + txn.commit().await?; + + Ok((StatusCode::CREATED, Json(OutfitInfo::from_model(outfit, equipped)))) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn update( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(id): Path, + Json(body): Json, +) -> Result, OutfitError> { + let name = body.name.as_deref().map(validate_name).transpose()?; + let mojang_cape_id = body.mojang_cape_id.map(normalize_cape_id).transpose()?; + + let outfit = find_outfit(&state.database, player.id, id).await?; + if let Some(Some(skin_id)) = body.skin_id { + require_skin(&state.database, player.id, skin_id).await?; + } + + let mut active: player_outfit::ActiveModel = outfit.into(); + if let Some(name) = name { + active.name = Set(name); + } + if let Some(favorite) = body.favorite { + active.favorite = Set(favorite); + } + if let Some(skin_id) = body.skin_id { + active.skin_id = Set(skin_id); + } + if let Some(mojang_cape_id) = mojang_cape_id { + active.mojang_cape_id = Set(mojang_cape_id); + } + if let Some(particle_color) = body.particle_color { + active.particle_color = Set(particle_color); + } + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + let outfit = active.update(&state.database).await?; + + Ok(Json(outfit_info(&state.database, outfit).await?)) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn delete( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(id): Path, +) -> Result { + let result = PlayerOutfit::delete_many() + .filter(player_outfit::Column::Id.eq(id)) + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .exec(&state.database) + .await?; + if result.rows_affected == 0 { + return Err(OutfitError::OutfitMissing); + } + + Ok(StatusCode::NO_CONTENT) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn apply( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(id): Path, +) -> Result, OutfitError> { + let previous = current_equipment(&state, &player).await?; + + let txn = state.database.begin().await?; + + let outfit = find_outfit(&txn, player.id, id).await?; + let outfit_cosmetics = load_outfit_cosmetics(&txn, [outfit.id]) + .await? + .remove(&outfit.id) + .unwrap_or_default(); + + let cosmetic_ids: Vec = outfit_cosmetics.values().copied().collect(); + let owned: HashSet = PlayerOwnedCosmetic::find() + .filter(player_owned_cosmetic::Column::PlayerId.eq(player.id)) + .filter(player_owned_cosmetic::Column::CosmeticId.is_in(cosmetic_ids.clone())) + .all(&txn) + .await? + .into_iter() + .map(|row| row.cosmetic_id) + .collect(); + let allowed: HashSet<(i32, BodySlot)> = CosmeticAllowedSlot::find() + .filter(cosmetic_allowed_slot::Column::CosmeticId.is_in(cosmetic_ids)) + .all(&txn) + .await? + .into_iter() + .map(|row| (row.cosmetic_id, row.slot)) + .collect(); + + let equipped: HashMap = outfit_cosmetics + .iter() + .filter(|(slot, cosmetic_id)| { + owned.contains(cosmetic_id) + && allowed.contains(&(**cosmetic_id, (*slot).clone())) + }) + .map(|(slot, cosmetic_id)| (slot.clone(), *cosmetic_id)) + .collect(); + + PlayerEquippedCosmetic::delete_many() + .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) + .exec(&txn) + .await?; + if !equipped.is_empty() { + PlayerEquippedCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { + player_equipped_cosmetic::ActiveModel { + player_id: Set(player.id), + slot: Set(slot.clone()), + cosmetic_id: Set(*cosmetic_id), + updated_at: ActiveValue::NotSet, + } + })) + .exec(&txn) + .await?; + } + + User::update_many() + .col_expr(user::Column::ParticleColor, Expr::value(outfit.particle_color)) + .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(outfit.id))) + .filter(user::Column::Id.eq(player.id)) + .exec(&txn) + .await?; + + let skin = match outfit.skin_id { + Some(skin_id) => { + PlayerSkin::find_by_id(skin_id) + .filter(player_skin::Column::PlayerId.eq(player.id)) + .one(&txn) + .await? + } + None => None, + }; + + txn.commit().await?; + + let slots: HashSet = + previous.keys().chain(equipped.keys()).cloned().collect(); + for slot in slots { + let cosmetic_id = equipped.get(&slot).copied(); + if previous.get(&slot).copied() != cosmetic_id { + publish_equipment_change(&state, player.minecraft_uuid, slot, cosmetic_id) + .await; + } + } + publish_particle_color_change(&state, player.minecraft_uuid, outfit.particle_color) + .await; + + Ok(Json(ApplyOutfitResponse { + outfit: OutfitInfo::from_model(outfit, outfit_cosmetics), + skin: skin.map(SkinInfo::from_model), + })) +} diff --git a/src/api/v0/outfits/skin.rs b/src/api/v0/outfits/skin.rs new file mode 100644 index 0000000..80b429e --- /dev/null +++ b/src/api/v0/outfits/skin.rs @@ -0,0 +1,275 @@ +use aide::{ + axum::{ + ApiRouter, + routing::{get_with, patch_with}, + }, + transform::TransformOperation, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use entities::{player_skin, prelude::*}; +use schemars::JsonSchema; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, + Set, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + OutfitError, SKIN_LIMIT, SkinInfo, SkinModel, normalize_texture_hash, validate_name, +}; +use crate::api::{ApiState, v0::account::AuthenticatedPlayer}; + +#[derive(Debug, Serialize, JsonSchema)] +struct SkinListResponse { + skins: Vec, + limit: u64, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct AddSkinRequest { + texture_hash: String, + model: Option, + name: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct UpdateSkinRequest { + name: Option, + favorite: Option, + model: Option, +} + +fn list_doc(op: TransformOperation) -> TransformOperation { + op.id("listSkins") + .summary("List the player's skins") + .description("Lists the authenticated player's skin library, newest first.") + .tag("outfits") +} + +fn add_doc(op: TransformOperation) -> TransformOperation { + op.id("addSkin") + .summary("Add a skin to the library") + .description( + "Records a Mojang skin texture in the library by its `texture_hash` (32 to \ + 64 hexadecimal characters, the last path segment of its \ + `textures.minecraft.net` URL), with an optional `model` (`classic` or \ + `slim`, default `classic`) and `name`. Names default to `Skin #N`. \ + Adding a texture already in the library returns the existing entry \ + unchanged with `200`.", + ) + .tag("outfits") + .response_with::<{ StatusCode::CREATED.as_u16() }, Json, _>(|res| { + res.description("The skin was added to the library") + }) + .response_with::<{ StatusCode::OK.as_u16() }, Json, _>(|res| { + res.description("The skin was already in the library") + }) + .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { + res.description("The texture hash or name is invalid") + }) + .response_with::<{ StatusCode::CONFLICT.as_u16() }, String, _>(|res| { + res.description("The player already has the maximum number of skins") + }) +} + +fn update_doc(op: TransformOperation) -> TransformOperation { + op.id("updateSkin") + .summary("Update a skin") + .description("Renames a skin, toggles its favorite flag or changes its model.") + .tag("outfits") + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("The skin does not exist or is not the player's") + }) +} + +fn delete_doc(op: TransformOperation) -> TransformOperation { + op.id("deleteSkin") + .summary("Delete a skin") + .description( + "Removes a skin from the library. Outfits using it are left without a skin.", + ) + .tag("outfits") + .response_with::<{ StatusCode::NO_CONTENT.as_u16() }, (), _>(|res| { + res.description("The skin was deleted") + }) + .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { + res.description("The skin does not exist or is not the player's") + }) +} + +pub(super) fn router() -> ApiRouter { + ApiRouter::new() + .api_route( + "/skins", + get_with(self::list, self::list_doc).post_with(self::add, self::add_doc), + ) + .api_route( + "/skins/{id}", + patch_with(self::update, self::update_doc) + .delete_with(self::delete, self::delete_doc), + ) +} + +async fn find_skin( + state: &ApiState, + player_id: i32, + id: i32, +) -> Result { + PlayerSkin::find_by_id(id) + .filter(player_skin::Column::PlayerId.eq(player_id)) + .one(&state.database) + .await? + .ok_or(OutfitError::SkinMissing) +} + +async fn find_skin_by_hash( + state: &ApiState, + player_id: i32, + hash: &str, +) -> Result, OutfitError> { + Ok(PlayerSkin::find() + .filter(player_skin::Column::PlayerId.eq(player_id)) + .filter(player_skin::Column::Hash.eq(hash)) + .one(&state.database) + .await?) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn list( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, +) -> Result, OutfitError> { + let skins = PlayerSkin::find() + .filter(player_skin::Column::PlayerId.eq(player.id)) + .order_by_desc(player_skin::Column::CreatedAt) + .order_by_desc(player_skin::Column::Id) + .all(&state.database) + .await?; + + Ok(Json(SkinListResponse { + skins: skins.into_iter().map(SkinInfo::from_model).collect(), + limit: SKIN_LIMIT, + })) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn add( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Json(body): Json, +) -> Result<(StatusCode, Json), OutfitError> { + let hash = normalize_texture_hash(&body.texture_hash)?; + let name = body + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .map(validate_name) + .transpose()?; + let model = body.model.unwrap_or(SkinModel::Classic); + + if let Some(skin) = find_skin_by_hash(&state, player.id, &hash).await? { + return Ok((StatusCode::OK, Json(SkinInfo::from_model(skin)))); + } + + let count = PlayerSkin::find() + .filter(player_skin::Column::PlayerId.eq(player.id)) + .count(&state.database) + .await?; + if count >= SKIN_LIMIT { + return Err(OutfitError::SkinLimit); + } + + let inserted = player_skin::ActiveModel { + player_id: Set(player.id), + name: Set(name.unwrap_or_else(|| format!("Skin #{}", count + 1))), + model: Set(model.as_str().to_string()), + hash: Set(hash.clone()), + favorite: Set(false), + ..Default::default() + } + .insert(&state.database) + .await; + + match inserted { + Ok(skin) => Ok((StatusCode::CREATED, Json(SkinInfo::from_model(skin)))), + Err(error) => match find_skin_by_hash(&state, player.id, &hash).await? { + Some(skin) => Ok((StatusCode::OK, Json(SkinInfo::from_model(skin)))), + None => Err(error.into()), + }, + } +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn update( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(id): Path, + Json(body): Json, +) -> Result, OutfitError> { + let name = body.name.as_deref().map(validate_name).transpose()?; + let skin = find_skin(&state, player.id, id).await?; + + let mut active: player_skin::ActiveModel = skin.into(); + if let Some(name) = name { + active.name = Set(name); + } + if let Some(favorite) = body.favorite { + active.favorite = Set(favorite); + } + if let Some(model) = body.model { + active.model = Set(model.as_str().to_string()); + } + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + let skin = active.update(&state.database).await?; + + Ok(Json(SkinInfo::from_model(skin))) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn delete( + State(state): State, + AuthenticatedPlayer(player): AuthenticatedPlayer, + Path(id): Path, +) -> Result { + let skin = find_skin(&state, player.id, id).await?; + PlayerSkin::delete_by_id(skin.id).exec(&state.database).await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::super::{normalize_texture_hash, texture_url}; + + #[test] + fn accepts_mojang_texture_hashes() { + let hash = "1a4af718455d4aab528e7a61f86fa25e6a369d1768dcb13f7df319a713eb810b"; + assert_eq!(normalize_texture_hash(hash).unwrap(), hash); + assert_eq!( + normalize_texture_hash(&hash.to_ascii_uppercase()).unwrap(), + hash + ); + assert!(normalize_texture_hash(&"a".repeat(32)).is_ok()); + } + + #[test] + fn rejects_invalid_texture_hashes() { + assert!(normalize_texture_hash("").is_err()); + assert!(normalize_texture_hash(&"a".repeat(31)).is_err()); + assert!(normalize_texture_hash(&"a".repeat(65)).is_err()); + assert!(normalize_texture_hash(&"g".repeat(32)).is_err()); + assert!(normalize_texture_hash(&format!("{}/x", "a".repeat(32))).is_err()); + } + + #[test] + fn builds_texture_urls() { + assert_eq!( + texture_url("abc"), + "https://textures.minecraft.net/texture/abc" + ); + } +} diff --git a/src/api/v0/websocket/endpoint.rs b/src/api/v0/websocket/endpoint.rs index bbcab03..df1bee4 100644 --- a/src/api/v0/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -791,6 +791,22 @@ pub(crate) async fn publish_equipment_change( send_to_owner(state, player, packet).await; } +pub(crate) async fn publish_particle_color_change( + state: &ApiState, + player: Uuid, + color: Option, +) { + if let Some(player_state) = + state.realtime.player_runtime.write().await.get_mut(&player) + { + player_state.particle_color = color; + } + + let packet = || ClientBoundPacket::PlayerParticleColorChanged { player, color }; + broadcast_to_watchers(state, player, packet).await; + send_to_owner(state, player, packet).await; +} + pub(crate) async fn broadcast_all( state: &ApiState, mut make_packet: impl FnMut() -> ClientBoundPacket, diff --git a/src/api/v0/websocket/mod.rs b/src/api/v0/websocket/mod.rs index 032a6b8..509b999 100644 --- a/src/api/v0/websocket/mod.rs +++ b/src/api/v0/websocket/mod.rs @@ -6,7 +6,8 @@ mod endpoint; pub mod structs; pub(crate) use endpoint::{ - broadcast_all, publish_equipment_change, send_to_owner, unequip_revoked, + broadcast_all, publish_equipment_change, publish_particle_color_change, send_to_owner, + unequip_revoked, }; pub(super) async fn setup_router() -> ApiRouter { From 1265705d73f5a6620f16171be9713386c9596649 Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 13:58:45 +0200 Subject: [PATCH 4/7] feat: keep the active outfit in sync with equipment changes --- src/api/state/persistence.rs | 34 ++++++++++++++++++++++++------ src/api/v0/cosmetics/put_player.rs | 6 +++++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/api/state/persistence.rs b/src/api/state/persistence.rs index 1a24b55..8af7a0f 100644 --- a/src/api/state/persistence.rs +++ b/src/api/state/persistence.rs @@ -7,7 +7,9 @@ use tokio::sync::mpsc; use tracing::warn; use uuid::Uuid; -use crate::api::state::realtime::PlaytimeSessions; +use crate::api::{ + state::realtime::PlaytimeSessions, v0::outfits::mirror_active_outfit_slot, +}; /// Capacity of the persistence channels before senders start to wait. const PERSIST_QUEUE_SIZE: usize = 256; @@ -72,13 +74,13 @@ async fn persist_equipment_queue( .one(&database) .await? else { - return Ok::<(), sea_orm::DbErr>(()); + return Ok::<_, sea_orm::DbErr>(None); }; if let Some(cosmetic_id) = update.cosmetic_id { PlayerEquippedCosmetic::insert(player_equipped_cosmetic::ActiveModel { player_id: Set(player.id), - slot: Set(update.slot), + slot: Set(update.slot.clone()), cosmetic_id: Set(cosmetic_id), updated_at: ActiveValue::NotSet, }) @@ -99,17 +101,35 @@ async fn persist_equipment_queue( } else { PlayerEquippedCosmetic::delete_many() .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) - .filter(player_equipped_cosmetic::Column::Slot.eq(update.slot)) + .filter( + player_equipped_cosmetic::Column::Slot.eq(update.slot.clone()), + ) .exec(&database) .await?; } - Ok(()) + Ok(Some(player)) } .await; - if let Err(error) = result { - warn!("Unable to persist websocket equipment update: {error}"); + match result { + Ok(Some(player)) => { + if let Err(error) = mirror_active_outfit_slot( + &database, + &player, + update.slot, + update.cosmetic_id, + ) + .await + { + warn!( + "Unable to mirror websocket equipment update into active \ + outfit: {error}" + ); + } + } + Ok(None) => {} + Err(error) => warn!("Unable to persist websocket equipment update: {error}"), } } } diff --git a/src/api/v0/cosmetics/put_player.rs b/src/api/v0/cosmetics/put_player.rs index e11dfc0..a9c80dc 100644 --- a/src/api/v0/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -19,7 +19,7 @@ use crate::api::{ ApiState, v0::{ account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics, - websocket::publish_equipment_change, + outfits::mirror_active_outfit_slot, websocket::publish_equipment_change, }, }; @@ -147,6 +147,10 @@ async fn endpoint( } } + for (slot, value) in body.equipment.equipped.clone() { + mirror_active_outfit_slot(&txn, &player, slot, value).await?; + } + txn.commit().await?; } From 6125ceadbdd8da2e74f09af5622813a684b3e20f Mon Sep 17 00:00:00 2001 From: Deftu Date: Wed, 14 Oct 2026 15:25:54 +0200 Subject: [PATCH 5/7] feat: give players eight starter outfits --- .../entities/src/entities/player_outfit.rs | 1 + database/entities/src/entities/user.rs | 1 + ...0261015_000000_create_outfits_and_skins.rs | 15 ++ src/api/v0/outfits/mod.rs | 2 + src/api/v0/outfits/outfit.rs | 145 ++++++++++++++++-- 5 files changed, 153 insertions(+), 11 deletions(-) diff --git a/database/entities/src/entities/player_outfit.rs b/database/entities/src/entities/player_outfit.rs index 3e186ac..cc7265b 100644 --- a/database/entities/src/entities/player_outfit.rs +++ b/database/entities/src/entities/player_outfit.rs @@ -13,6 +13,7 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable)] pub mojang_cape_id: Option, pub particle_color: Option, + pub blank: bool, pub created_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone, } diff --git a/database/entities/src/entities/user.rs b/database/entities/src/entities/user.rs index bead078..6ffba4e 100644 --- a/database/entities/src/entities/user.rs +++ b/database/entities/src/entities/user.rs @@ -21,6 +21,7 @@ pub struct Model { #[sea_orm(column_type = "Text", nullable, unique)] pub paynow_customer_id: Option, pub active_outfit_id: Option, + pub outfits_seeded: bool, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs index 59238cc..a1c31ec 100644 --- a/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs +++ b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs @@ -8,6 +8,7 @@ enum User { Table, Id, ActiveOutfitId, + OutfitsSeeded, } #[derive(DeriveIden)] @@ -45,6 +46,7 @@ enum PlayerOutfit { SkinId, MojangCapeId, ParticleColor, + Blank, CreatedAt, UpdatedAt, } @@ -142,6 +144,12 @@ impl MigrationTrait for Migration { .col(ColumnDef::new(PlayerOutfit::SkinId).integer().null()) .col(ColumnDef::new(PlayerOutfit::MojangCapeId).text().null()) .col(ColumnDef::new(PlayerOutfit::ParticleColor).integer().null()) + .col( + ColumnDef::new(PlayerOutfit::Blank) + .boolean() + .not_null() + .default(false), + ) .col( ColumnDef::new(PlayerOutfit::CreatedAt) .timestamp_with_time_zone() @@ -242,6 +250,12 @@ impl MigrationTrait for Migration { .to_col(PlayerOutfit::Id) .on_delete(ForeignKeyAction::SetNull), ) + .add_column( + ColumnDef::new(User::OutfitsSeeded) + .boolean() + .not_null() + .default(false), + ) .to_owned(), ) .await @@ -253,6 +267,7 @@ impl MigrationTrait for Migration { Table::alter() .table(User::Table) .drop_column(User::ActiveOutfitId) + .drop_column(User::OutfitsSeeded) .to_owned(), ) .await?; diff --git a/src/api/v0/outfits/mod.rs b/src/api/v0/outfits/mod.rs index 8be57e8..9c3d713 100644 --- a/src/api/v0/outfits/mod.rs +++ b/src/api/v0/outfits/mod.rs @@ -130,6 +130,7 @@ pub struct OutfitInfo { skin_id: Option, mojang_cape_id: Option, particle_color: Option, + blank: bool, equipped: HashMap, created_at: DateTime, updated_at: DateTime, @@ -147,6 +148,7 @@ impl OutfitInfo { skin_id: outfit.skin_id, mojang_cape_id: outfit.mojang_cape_id, particle_color: outfit.particle_color, + blank: outfit.blank, equipped, created_at: outfit.created_at, updated_at: outfit.updated_at, diff --git a/src/api/v0/outfits/outfit.rs b/src/api/v0/outfits/outfit.rs index 80f3420..583d531 100644 --- a/src/api/v0/outfits/outfit.rs +++ b/src/api/v0/outfits/outfit.rs @@ -35,6 +35,8 @@ use crate::api::{ }, }; +const STARTER_OUTFITS: u32 = 8; + #[derive(Debug, Serialize, JsonSchema)] struct OutfitListResponse { outfits: Vec, @@ -72,8 +74,10 @@ fn list_doc(op: TransformOperation) -> TransformOperation { op.id("listOutfits") .summary("List the player's outfits") .description( - "Lists the authenticated player's outfits, newest first, along with the \ - id of the active outfit and the library limit.", + "Lists the authenticated player's outfits, oldest first, along with the \ + id of the active outfit and the library limit. The first list creates \ + eight blank outfits, `Outfit #1` to `Outfit #8`; `Outfit #1` holds the \ + player's current cosmetics and becomes the active outfit.", ) .tag("outfits") } @@ -84,7 +88,7 @@ fn create_doc(op: TransformOperation) -> TransformOperation { .description( "Creates an outfit holding the player's currently equipped cosmetics plus \ the given skin, Mojang cape and particle color, and makes it the active \ - outfit. Names default to `Outfit #N`.", + outfit. Names default to the lowest unused `Outfit #N`.", ) .tag("outfits") .response_with::<{ StatusCode::CREATED.as_u16() }, Json, _>(|res| { @@ -105,7 +109,8 @@ fn update_doc(op: TransformOperation) -> TransformOperation { .summary("Update an outfit") .description( "Updates an outfit's fields. Absent fields are left unchanged; an explicit \ - `null` clears `skin_id`, `mojang_cape_id` or `particle_color`.", + `null` clears `skin_id`, `mojang_cape_id` or `particle_color`. Setting \ + `skin_id` or `mojang_cape_id` marks the outfit as no longer blank.", ) .tag("outfits") .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { @@ -163,6 +168,86 @@ pub(super) fn router() -> ApiRouter { .api_route("/outfits/{id}/apply", post_with(self::apply, self::apply_doc)) } +fn next_outfit_name<'a>(names: impl IntoIterator) -> String { + let taken: HashSet = names + .into_iter() + .filter_map(|name| name.strip_prefix("Outfit #")?.parse().ok()) + .collect(); + let number = (1..).find(|number| !taken.contains(number)).unwrap_or(1); + format!("Outfit #{number}") +} + +async fn seed_starter_outfits( + state: &ApiState, + player: &user::Model, +) -> Result, OutfitError> { + let equipped = current_equipment(state, player).await?; + + let txn = state.database.begin().await?; + + let claimed = User::update_many() + .col_expr(user::Column::OutfitsSeeded, Expr::value(true)) + .filter(user::Column::Id.eq(player.id)) + .filter(user::Column::OutfitsSeeded.eq(false)) + .exec(&txn) + .await? + .rows_affected + == 1; + if !claimed { + return Ok(None); + } + + let existing = PlayerOutfit::find() + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .count(&txn) + .await?; + if existing > 0 { + txn.commit().await?; + return Ok(None); + } + + let mut first = None; + for number in 1..=STARTER_OUTFITS { + let outfit = player_outfit::ActiveModel { + player_id: Set(player.id), + name: Set(format!("Outfit #{number}")), + favorite: Set(false), + particle_color: Set(if number == 1 { player.particle_color } else { None }), + blank: Set(true), + ..Default::default() + } + .insert(&txn) + .await?; + first.get_or_insert(outfit.id); + } + let Some(first) = first else { + txn.commit().await?; + return Ok(None); + }; + + if !equipped.is_empty() { + PlayerOutfitCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { + player_outfit_cosmetic::ActiveModel { + outfit_id: Set(first), + slot: Set(slot.clone()), + cosmetic_id: Set(*cosmetic_id), + } + })) + .exec(&txn) + .await?; + } + + User::update_many() + .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(first))) + .filter(user::Column::Id.eq(player.id)) + .exec(&txn) + .await?; + + txn.commit().await?; + + Ok(Some(first)) +} + async fn find_outfit( db: &C, player_id: i32, @@ -246,10 +331,17 @@ async fn list( State(state): State, AuthenticatedPlayer(player): AuthenticatedPlayer, ) -> Result, OutfitError> { + let mut active_outfit_id = player.active_outfit_id; + if !player.outfits_seeded + && let Some(seeded) = seed_starter_outfits(&state, &player).await? + { + active_outfit_id = Some(seeded); + } + let outfits = PlayerOutfit::find() .filter(player_outfit::Column::PlayerId.eq(player.id)) - .order_by_desc(player_outfit::Column::CreatedAt) - .order_by_desc(player_outfit::Column::Id) + .order_by_asc(player_outfit::Column::CreatedAt) + .order_by_asc(player_outfit::Column::Id) .all(&state.database) .await?; let mut cosmetics = load_outfit_cosmetics( @@ -266,7 +358,7 @@ async fn list( OutfitInfo::from_model(outfit, equipped) }) .collect(), - active_outfit_id: player.active_outfit_id, + active_outfit_id, limit: OUTFIT_LIMIT, })) } @@ -283,11 +375,11 @@ async fn create( let txn = state.database.begin().await?; - let count = PlayerOutfit::find() + let existing = PlayerOutfit::find() .filter(player_outfit::Column::PlayerId.eq(player.id)) - .count(&txn) + .all(&txn) .await?; - if count >= OUTFIT_LIMIT { + if existing.len() as u64 >= OUTFIT_LIMIT { return Err(OutfitError::OutfitLimit); } @@ -297,7 +389,9 @@ async fn create( let outfit = player_outfit::ActiveModel { player_id: Set(player.id), - name: Set(name.unwrap_or_else(|| format!("Outfit #{}", count + 1))), + name: Set(name.unwrap_or_else(|| { + next_outfit_name(existing.iter().map(|outfit| outfit.name.as_str())) + })), favorite: Set(false), skin_id: Set(body.skin_id), mojang_cape_id: Set(mojang_cape_id), @@ -352,6 +446,9 @@ async fn update( if let Some(favorite) = body.favorite { active.favorite = Set(favorite); } + if body.skin_id.is_some() || mojang_cape_id.is_some() { + active.blank = Set(false); + } if let Some(skin_id) = body.skin_id { active.skin_id = Set(skin_id); } @@ -480,3 +577,29 @@ async fn apply( skin: skin.map(SkinInfo::from_model), })) } + +#[cfg(test)] +mod tests { + use super::next_outfit_name; + + #[test] + fn names_the_first_outfit() { + assert_eq!(next_outfit_name([]), "Outfit #1"); + } + + #[test] + fn fills_the_lowest_gap() { + assert_eq!( + next_outfit_name(["Outfit #1", "Outfit #2", "Outfit #4"]), + "Outfit #3" + ); + } + + #[test] + fn ignores_custom_names() { + assert_eq!( + next_outfit_name(["Summer", "Outfit #1", "Outfit #x", "Outfit #2"]), + "Outfit #3" + ); + } +} From 07c93f7857bc472bdf476650dbef1ea7cbee8c19 Mon Sep 17 00:00:00 2001 From: Deftu Date: Thu, 15 Oct 2026 14:24:31 +0200 Subject: [PATCH 6/7] fix: address outfit review feedback --- src/api/state/mod.rs | 11 +- src/api/state/persistence.rs | 232 ++++++++++++++++---- src/api/v0/cosmetics/put_player.rs | 33 ++- src/api/v0/outfits/mod.rs | 96 ++++++-- src/api/v0/outfits/outfit.rs | 337 +++++++++++++++++++++-------- src/api/v0/outfits/skin.rs | 73 ++++--- src/api/v0/websocket/endpoint.rs | 137 +++++++----- src/api/v0/websocket/mod.rs | 4 +- 8 files changed, 664 insertions(+), 259 deletions(-) diff --git a/src/api/state/mod.rs b/src/api/state/mod.rs index b4655f3..07c3b8c 100644 --- a/src/api/state/mod.rs +++ b/src/api/state/mod.rs @@ -17,13 +17,12 @@ use pasetors::{ use reqwest::{Client, ClientBuilder}; use s3::{Bucket, creds::Credentials}; use sea_orm::{ConnectOptions, Database, DatabaseConnection, EntityTrait}; -use tokio::sync::mpsc; use tracing::{info, warn}; pub(in crate::api) use self::{ analytics_rollup::{ANALYTICS_DAILY_JOB, SESSION_LENGTH_BUCKETS}, instrumentation::Instrumentation, - persistence::{EquipmentPersistence, ParticleColorPersistence}, + persistence::{EquipmentPersistence, ParticleColorPersistence, PersistQueue}, realtime::{ ConnectionCounts, ConnectionId, PlayerRuntimeState, PlaytimeSession, RealtimeConnection, RealtimeState, @@ -74,8 +73,8 @@ pub(super) struct ApiState { pub(super) s3_public_url: Arc, pub(super) asset_cache: Cache, pub(super) realtime: RealtimeState, - pub(super) equipment_persist_tx: mpsc::Sender, - pub(super) particle_color_persist_tx: mpsc::Sender, + pub(super) equipment_persist: PersistQueue, + pub(super) particle_color_persist: PersistQueue, pub(super) admin_password: String, pub(super) render_service_url: String, pub(super) global_chat_cooldown: Cache, @@ -135,8 +134,8 @@ impl ApiState { .expect("Unable to generate paseto signing key"), asset_cache, realtime, - equipment_persist_tx: persistence::spawn_equipment_queue(database.clone()), - particle_color_persist_tx: persistence::spawn_particle_color_queue( + equipment_persist: persistence::spawn_equipment_queue(database.clone()), + particle_color_persist: persistence::spawn_particle_color_queue( database.clone(), ), admin_password: args.admin_password.clone(), diff --git a/src/api/state/persistence.rs b/src/api/state/persistence.rs index 8af7a0f..4985693 100644 --- a/src/api/state/persistence.rs +++ b/src/api/state/persistence.rs @@ -1,4 +1,8 @@ -use std::time::Duration; +use std::{ + collections::{BTreeSet, HashMap}, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; use chrono::{DateTime, Utc}; use entities::sea_orm_active_enums::BodySlot; @@ -8,7 +12,8 @@ use tracing::warn; use uuid::Uuid; use crate::api::{ - state::realtime::PlaytimeSessions, v0::outfits::mirror_active_outfit_slot, + state::realtime::PlaytimeSessions, + v0::outfits::{mirror_active_outfit_particle_color, mirror_active_outfit_slots}, }; /// Capacity of the persistence channels before senders start to wait. @@ -31,22 +36,98 @@ pub struct ParticleColorPersistence { pub color: Option, } +#[derive(Debug, Clone)] +pub struct PersistQueue { + tx: mpsc::Sender<(u64, T)>, + order: Arc, +} + +impl PersistQueue { + fn spawn(worker: impl FnOnce(mpsc::Receiver<(u64, T)>, Arc) -> F) -> Self + where + F: Future + Send + 'static, + { + let (tx, rx) = mpsc::channel(PERSIST_QUEUE_SIZE); + let order = Arc::new(WriteOrder::default()); + tokio::spawn(worker(rx, order.clone())); + Self { tx, order } + } + + pub fn enqueue(&self, update: T) { + let seq = self.order.begin(); + if self.tx.try_send((seq, update)).is_err() { + self.order.finish(seq); + } + } + + pub fn supersede(&self, player: Uuid) { + self.order.supersede(player); + } +} + +#[derive(Debug, Default)] +struct WriteOrder(Mutex); + +#[derive(Debug, Default)] +struct WriteOrderState { + next: u64, + pending: BTreeSet, + superseded: HashMap, +} + +impl WriteOrder { + fn state(&self) -> MutexGuard<'_, WriteOrderState> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn begin(&self) -> u64 { + let mut state = self.state(); + let seq = state.next; + state.next += 1; + state.pending.insert(seq); + seq + } + + fn supersede(&self, player: Uuid) { + let mut state = self.state(); + if state.pending.is_empty() { + return; + } + + let seq = state.next; + state.next += 1; + state.superseded.insert(player, seq); + } + + fn is_superseded(&self, player: Uuid, seq: u64) -> bool { + self.state() + .superseded + .get(&player) + .is_some_and(|&superseded| superseded > seq) + } + + fn finish(&self, seq: u64) { + let mut state = self.state(); + state.pending.remove(&seq); + match state.pending.first().copied() { + Some(oldest) => state.superseded.retain(|_, superseded| *superseded > oldest), + None => state.superseded.clear(), + } + } +} + /// Spawns the equipment writer and returns the channel feeding it. pub(super) fn spawn_equipment_queue( database: DatabaseConnection, -) -> mpsc::Sender { - let (tx, rx) = mpsc::channel(PERSIST_QUEUE_SIZE); - tokio::spawn(persist_equipment_queue(database, rx)); - tx +) -> PersistQueue { + PersistQueue::spawn(|rx, order| persist_equipment_queue(database, rx, order)) } /// Spawns the particle color writer and returns the channel feeding it. pub(super) fn spawn_particle_color_queue( database: DatabaseConnection, -) -> mpsc::Sender { - let (tx, rx) = mpsc::channel(PERSIST_QUEUE_SIZE); - tokio::spawn(persist_particle_color_queue(database, rx)); - tx +) -> PersistQueue { + PersistQueue::spawn(|rx, order| persist_particle_color_queue(database, rx, order)) } /// Spawns the periodic playtime flusher for the given session map. @@ -59,23 +140,30 @@ pub(super) fn spawn_playtime_flush( async fn persist_equipment_queue( database: DatabaseConnection, - mut rx: mpsc::Receiver, + mut rx: mpsc::Receiver<(u64, EquipmentPersistence)>, + order: Arc, ) { use entities::{player_equipped_cosmetic, prelude::*, user}; use sea_orm::{ - ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, + ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, + TransactionTrait, sea_query::{Expr, OnConflict}, }; - while let Some(update) = rx.recv().await { + while let Some((seq, update)) = rx.recv().await { let result = async { + let txn = database.begin().await?; let Some(player) = User::find() .filter(user::Column::MinecraftUuid.eq(update.player)) - .one(&database) + .lock_exclusive() + .one(&txn) .await? else { - return Ok::<_, sea_orm::DbErr>(None); + return Ok::<_, sea_orm::DbErr>(()); }; + if order.is_superseded(update.player, seq) { + return Ok(()); + } if let Some(cosmetic_id) = update.cosmetic_id { PlayerEquippedCosmetic::insert(player_equipped_cosmetic::ActiveModel { @@ -96,7 +184,7 @@ async fn persist_equipment_queue( ) .to_owned(), ) - .exec(&database) + .exec(&txn) .await?; } else { PlayerEquippedCosmetic::delete_many() @@ -104,60 +192,72 @@ async fn persist_equipment_queue( .filter( player_equipped_cosmetic::Column::Slot.eq(update.slot.clone()), ) - .exec(&database) + .exec(&txn) .await?; } - Ok(Some(player)) + mirror_active_outfit_slots( + &txn, + player.id, + player.active_outfit_id, + [(update.slot, update.cosmetic_id)], + ) + .await?; + + txn.commit().await } .await; + order.finish(seq); - match result { - Ok(Some(player)) => { - if let Err(error) = mirror_active_outfit_slot( - &database, - &player, - update.slot, - update.cosmetic_id, - ) - .await - { - warn!( - "Unable to mirror websocket equipment update into active \ - outfit: {error}" - ); - } - } - Ok(None) => {} - Err(error) => warn!("Unable to persist websocket equipment update: {error}"), + if let Err(error) = result { + warn!("Unable to persist websocket equipment update: {error}"); } } } async fn persist_particle_color_queue( database: DatabaseConnection, - mut rx: mpsc::Receiver, + mut rx: mpsc::Receiver<(u64, ParticleColorPersistence)>, + order: Arc, ) { use entities::{prelude::*, user}; - use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + use sea_orm::{ + ColumnTrait, EntityTrait, QueryFilter, QuerySelect, TransactionTrait, + sea_query::Expr, + }; - while let Some(update) = rx.recv().await { + while let Some((seq, update)) = rx.recv().await { let result = async { + let txn = database.begin().await?; let Some(player) = User::find() .filter(user::Column::MinecraftUuid.eq(update.player)) - .one(&database) + .lock_exclusive() + .one(&txn) .await? else { return Ok::<(), sea_orm::DbErr>(()); }; + if order.is_superseded(update.player, seq) { + return Ok(()); + } - let mut player: user::ActiveModel = player.into(); - player.particle_color = Set(update.color); - player.update(&database).await?; + User::update_many() + .col_expr(user::Column::ParticleColor, Expr::value(update.color)) + .filter(user::Column::Id.eq(player.id)) + .exec(&txn) + .await?; + mirror_active_outfit_particle_color( + &txn, + player.id, + player.active_outfit_id, + update.color, + ) + .await?; - Ok(()) + txn.commit().await } .await; + order.finish(seq); if let Err(error) = result { warn!("Unable to persist websocket particle color update: {error}"); @@ -228,3 +328,47 @@ async fn heartbeat_sessions( Ok(()) } + +#[cfg(test)] +mod tests { + use uuid::Uuid; + + use super::WriteOrder; + + #[test] + fn supersedes_only_earlier_writes() { + let order = WriteOrder::default(); + let player = Uuid::new_v4(); + let earlier = order.begin(); + order.supersede(player); + let later = order.begin(); + + assert!(order.is_superseded(player, earlier)); + assert!(!order.is_superseded(player, later)); + assert!(!order.is_superseded(Uuid::new_v4(), earlier)); + } + + #[test] + fn ignores_overwrites_with_nothing_pending() { + let order = WriteOrder::default(); + let player = Uuid::new_v4(); + order.supersede(player); + + assert!(order.state().superseded.is_empty()); + assert!(!order.is_superseded(player, order.begin())); + } + + #[test] + fn forgets_overwrites_once_earlier_writes_finish() { + let order = WriteOrder::default(); + let player = Uuid::new_v4(); + let earlier = order.begin(); + order.supersede(player); + let later = order.begin(); + + order.finish(earlier); + assert!(order.state().superseded.is_empty()); + order.finish(later); + assert!(order.state().pending.is_empty()); + } +} diff --git a/src/api/v0/cosmetics/put_player.rs b/src/api/v0/cosmetics/put_player.rs index a9c80dc..f61da91 100644 --- a/src/api/v0/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -11,15 +11,18 @@ use axum::{ }; use schemars::JsonSchema; use sea_orm::{ - ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait, + ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, + TransactionTrait, }; use serde::Deserialize; use crate::api::{ ApiState, v0::{ - account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics, - outfits::mirror_active_outfit_slot, websocket::publish_equipment_change, + account::AuthenticatedPlayer, + cosmetics::PartialEquippedCosmetics, + outfits::mirror_active_outfit_slots, + websocket::{ChangeSource, publish_equipment_change}, }, }; @@ -92,6 +95,11 @@ async fn endpoint( use sea_orm::sea_query::{Expr, OnConflict}; let txn = state.database.begin().await?; + let active_outfit_id = User::find_by_id(player.id) + .lock_exclusive() + .one(&txn) + .await? + .and_then(|player| player.active_outfit_id); for (slot, value) in body.equipment.equipped.clone() { if let Some(id) = value { @@ -147,15 +155,26 @@ async fn endpoint( } } - for (slot, value) in body.equipment.equipped.clone() { - mirror_active_outfit_slot(&txn, &player, slot, value).await?; - } + mirror_active_outfit_slots( + &txn, + player.id, + active_outfit_id, + body.equipment.equipped.clone(), + ) + .await?; txn.commit().await?; } for (slot, cosmetic_id) in body.equipment.equipped { - publish_equipment_change(&state, player.minecraft_uuid, slot, cosmetic_id).await; + publish_equipment_change( + &state, + player.minecraft_uuid, + slot, + cosmetic_id, + ChangeSource::Server, + ) + .await; } Ok(NoContent) diff --git a/src/api/v0/outfits/mod.rs b/src/api/v0/outfits/mod.rs index 9c3d713..672475c 100644 --- a/src/api/v0/outfits/mod.rs +++ b/src/api/v0/outfits/mod.rs @@ -1,7 +1,7 @@ mod outfit; mod skin; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use aide::{OperationIo, axum::ApiRouter}; use axum::{http::StatusCode, response::IntoResponse}; @@ -196,11 +196,37 @@ fn normalize_cape_id(cape_id: Option) -> Result, OutfitEr Ok(Some(trimmed.to_string())) } -pub(crate) async fn mirror_active_outfit_slot( +fn next_numbered_name<'a>( + prefix: &str, + names: impl IntoIterator, +) -> String { + let taken: HashSet = names + .into_iter() + .filter_map(|name| name.strip_prefix(prefix)?.parse().ok()) + .collect(); + let number = (1..).find(|number| !taken.contains(number)).unwrap_or(1); + format!("{prefix}{number}") +} + +async fn lock_player( db: &C, player: &user::Model, - slot: BodySlot, - cosmetic_id: Option, +) -> Result { + use entities::prelude::*; + use sea_orm::{EntityTrait, QuerySelect}; + + Ok(User::find_by_id(player.id) + .lock_exclusive() + .one(db) + .await? + .unwrap_or_else(|| player.clone())) +} + +pub(crate) async fn mirror_active_outfit_slots( + db: &C, + player_id: i32, + active_outfit_id: Option, + changes: impl IntoIterator)>, ) -> Result<(), DbErr> { use entities::prelude::*; use sea_orm::{ @@ -208,17 +234,28 @@ pub(crate) async fn mirror_active_outfit_slot( sea_query::{Expr, OnConflict}, }; - let Some(outfit_id) = player.active_outfit_id else { + let Some(outfit_id) = active_outfit_id else { return Ok(()); }; - match cosmetic_id { - Some(cosmetic_id) => { - PlayerOutfitCosmetic::insert(player_outfit_cosmetic::ActiveModel { + let mut equipped = Vec::new(); + let mut cleared = Vec::new(); + for (slot, cosmetic_id) in changes { + match cosmetic_id { + Some(cosmetic_id) => equipped.push(player_outfit_cosmetic::ActiveModel { outfit_id: Set(outfit_id), slot: Set(slot), cosmetic_id: Set(cosmetic_id), - }) + }), + None => cleared.push(slot), + } + } + if equipped.is_empty() && cleared.is_empty() { + return Ok(()); + } + + if !equipped.is_empty() { + PlayerOutfitCosmetic::insert_many(equipped) .on_conflict( OnConflict::columns([ player_outfit_cosmetic::Column::OutfitId, @@ -229,20 +266,43 @@ pub(crate) async fn mirror_active_outfit_slot( ) .exec(db) .await?; - } - None => { - PlayerOutfitCosmetic::delete_many() - .filter(player_outfit_cosmetic::Column::OutfitId.eq(outfit_id)) - .filter(player_outfit_cosmetic::Column::Slot.eq(slot)) - .exec(db) - .await?; - } } + if !cleared.is_empty() { + PlayerOutfitCosmetic::delete_many() + .filter(player_outfit_cosmetic::Column::OutfitId.eq(outfit_id)) + .filter(player_outfit_cosmetic::Column::Slot.is_in(cleared)) + .exec(db) + .await?; + } + + PlayerOutfit::update_many() + .col_expr(player_outfit::Column::UpdatedAt, Expr::current_timestamp().into()) + .filter(player_outfit::Column::Id.eq(outfit_id)) + .filter(player_outfit::Column::PlayerId.eq(player_id)) + .exec(db) + .await?; + + Ok(()) +} + +pub(crate) async fn mirror_active_outfit_particle_color( + db: &C, + player_id: i32, + active_outfit_id: Option, + color: Option, +) -> Result<(), DbErr> { + use entities::prelude::*; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, sea_query::Expr}; + + let Some(outfit_id) = active_outfit_id else { + return Ok(()); + }; PlayerOutfit::update_many() + .col_expr(player_outfit::Column::ParticleColor, Expr::value(color)) .col_expr(player_outfit::Column::UpdatedAt, Expr::current_timestamp().into()) .filter(player_outfit::Column::Id.eq(outfit_id)) - .filter(player_outfit::Column::PlayerId.eq(player.id)) + .filter(player_outfit::Column::PlayerId.eq(player_id)) .exec(db) .await?; diff --git a/src/api/v0/outfits/outfit.rs b/src/api/v0/outfits/outfit.rs index 583d531..38f9946 100644 --- a/src/api/v0/outfits/outfit.rs +++ b/src/api/v0/outfits/outfit.rs @@ -19,23 +19,27 @@ use entities::{ }; use schemars::JsonSchema; use sea_orm::{ - ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, EntityTrait, - PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, sea_query::Expr, + ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseTransaction, + EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, + sea_query::Expr, }; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use super::{ - OUTFIT_LIMIT, OutfitError, OutfitInfo, SkinInfo, normalize_cape_id, validate_name, + OUTFIT_LIMIT, OutfitError, OutfitInfo, SkinInfo, lock_player, next_numbered_name, + normalize_cape_id, validate_name, }; use crate::api::{ ApiState, v0::{ account::AuthenticatedPlayer, - websocket::{publish_equipment_change, publish_particle_color_change}, + websocket::{announce_equipment_change, announce_particle_color_change}, }, }; const STARTER_OUTFITS: u32 = 8; +const OUTFIT_NAME_PREFIX: &str = "Outfit #"; #[derive(Debug, Serialize, JsonSchema)] struct OutfitListResponse { @@ -76,8 +80,9 @@ fn list_doc(op: TransformOperation) -> TransformOperation { .description( "Lists the authenticated player's outfits, oldest first, along with the \ id of the active outfit and the library limit. The first list creates \ - eight blank outfits, `Outfit #1` to `Outfit #8`; `Outfit #1` holds the \ - player's current cosmetics and becomes the active outfit.", + eight outfits, `Outfit #1` to `Outfit #8`; `Outfit #1` holds the \ + player's current look and becomes the active outfit, the rest are \ + blank.", ) .tag("outfits") } @@ -88,7 +93,9 @@ fn create_doc(op: TransformOperation) -> TransformOperation { .description( "Creates an outfit holding the player's currently equipped cosmetics plus \ the given skin, Mojang cape and particle color, and makes it the active \ - outfit. Names default to the lowest unused `Outfit #N`.", + outfit. The particle color defaults to the player's current one; a \ + different color is applied to the player and broadcast. Names default \ + to the lowest unused `Outfit #N`.", ) .tag("outfits") .response_with::<{ StatusCode::CREATED.as_u16() }, Json, _>(|res| { @@ -110,7 +117,9 @@ fn update_doc(op: TransformOperation) -> TransformOperation { .description( "Updates an outfit's fields. Absent fields are left unchanged; an explicit \ `null` clears `skin_id`, `mojang_cape_id` or `particle_color`. Setting \ - `skin_id` or `mojang_cape_id` marks the outfit as no longer blank.", + `skin_id` or `mojang_cape_id` to a value marks the outfit as no longer \ + blank. Changing the active outfit's `particle_color` also applies it to \ + the player and broadcasts it.", ) .tag("outfits") .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { @@ -145,7 +154,10 @@ fn apply_doc(op: TransformOperation) -> TransformOperation { "Replaces the player's equipped cosmetics with the outfit's, skipping any \ that are no longer owned, sets the particle color and makes the outfit \ active. Changes are broadcast to websocket watchers and the player's own \ - connections. The client applies the outfit's skin and Mojang cape itself.", + connections. The client applies the outfit's skin and Mojang cape itself. \ + A blank outfit instead takes on the player's current cosmetics, and its \ + own particle color or else the current one, stops being blank and \ + becomes active without changing what is equipped.", ) .tag("outfits") .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { @@ -168,21 +180,10 @@ pub(super) fn router() -> ApiRouter { .api_route("/outfits/{id}/apply", post_with(self::apply, self::apply_doc)) } -fn next_outfit_name<'a>(names: impl IntoIterator) -> String { - let taken: HashSet = names - .into_iter() - .filter_map(|name| name.strip_prefix("Outfit #")?.parse().ok()) - .collect(); - let number = (1..).find(|number| !taken.contains(number)).unwrap_or(1); - format!("Outfit #{number}") -} - async fn seed_starter_outfits( state: &ApiState, player: &user::Model, ) -> Result, OutfitError> { - let equipped = current_equipment(state, player).await?; - let txn = state.database.begin().await?; let claimed = User::update_many() @@ -206,14 +207,17 @@ async fn seed_starter_outfits( return Ok(None); } + let player = lock_player(&txn, player).await?; + let (equipped, particle_color) = current_look(state, &txn, &player).await?; + let mut first = None; for number in 1..=STARTER_OUTFITS { let outfit = player_outfit::ActiveModel { player_id: Set(player.id), - name: Set(format!("Outfit #{number}")), + name: Set(format!("{OUTFIT_NAME_PREFIX}{number}")), favorite: Set(false), - particle_color: Set(if number == 1 { player.particle_color } else { None }), - blank: Set(true), + particle_color: Set(if number == 1 { particle_color } else { None }), + blank: Set(number != 1), ..Default::default() } .insert(&txn) @@ -225,23 +229,8 @@ async fn seed_starter_outfits( return Ok(None); }; - if !equipped.is_empty() { - PlayerOutfitCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { - player_outfit_cosmetic::ActiveModel { - outfit_id: Set(first), - slot: Set(slot.clone()), - cosmetic_id: Set(*cosmetic_id), - } - })) - .exec(&txn) - .await?; - } - - User::update_many() - .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(first))) - .filter(user::Column::Id.eq(player.id)) - .exec(&txn) - .await?; + insert_outfit_cosmetics(&txn, first, &equipped).await?; + activate_outfit(&txn, &player, first, None).await?; txn.commit().await?; @@ -303,10 +292,24 @@ async fn outfit_info( Ok(OutfitInfo::from_model(outfit, equipped)) } -async fn current_equipment( +async fn stored_equipment( + db: &C, + player_id: i32, +) -> Result, sea_orm::DbErr> { + Ok(PlayerEquippedCosmetic::find() + .filter(player_equipped_cosmetic::Column::PlayerId.eq(player_id)) + .all(db) + .await? + .into_iter() + .map(|row| (row.slot, row.cosmetic_id)) + .collect()) +} + +async fn current_look( state: &ApiState, + db: &C, player: &user::Model, -) -> Result, OutfitError> { +) -> Result<(HashMap, Option), OutfitError> { if let Some(runtime) = state .realtime .player_runtime @@ -314,16 +317,71 @@ async fn current_equipment( .await .get(&player.minecraft_uuid) { - return Ok(runtime.equipped.clone()); + return Ok((runtime.equipped.clone(), runtime.particle_color)); } - Ok(PlayerEquippedCosmetic::find() - .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) - .all(&state.database) - .await? - .into_iter() - .map(|row| (row.slot, row.cosmetic_id)) - .collect()) + Ok((stored_equipment(db, player.id).await?, player.particle_color)) +} + +async fn insert_outfit_cosmetics( + db: &C, + outfit_id: i32, + equipped: &HashMap, +) -> Result<(), sea_orm::DbErr> { + if equipped.is_empty() { + return Ok(()); + } + + PlayerOutfitCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { + player_outfit_cosmetic::ActiveModel { + outfit_id: Set(outfit_id), + slot: Set(slot.clone()), + cosmetic_id: Set(*cosmetic_id), + } + })) + .exec(db) + .await?; + + Ok(()) +} + +async fn find_outfit_skin( + db: &C, + player_id: i32, + outfit: &player_outfit::Model, +) -> Result, sea_orm::DbErr> { + let Some(skin_id) = outfit.skin_id else { + return Ok(None); + }; + + PlayerSkin::find_by_id(skin_id) + .filter(player_skin::Column::PlayerId.eq(player_id)) + .one(db) + .await +} + +async fn override_particle_color(state: &ApiState, player: Uuid, color: Option) { + let mut player_runtime = state.realtime.player_runtime.write().await; + state.particle_color_persist.supersede(player); + if let Some(runtime) = player_runtime.get_mut(&player) { + runtime.particle_color = color; + } +} + +async fn activate_outfit( + db: &C, + player: &user::Model, + outfit_id: i32, + particle_color: Option>, +) -> Result<(), sea_orm::DbErr> { + let mut update = User::update_many() + .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(outfit_id))); + if let Some(particle_color) = particle_color { + update = update.col_expr(user::Column::ParticleColor, Expr::value(particle_color)); + } + update.filter(user::Column::Id.eq(player.id)).exec(db).await?; + + Ok(()) } #[tracing::instrument(level = "debug", skip(state))] @@ -371,9 +429,9 @@ async fn create( ) -> Result<(StatusCode, Json), OutfitError> { let name = body.name.as_deref().map(validate_name).transpose()?; let mojang_cape_id = normalize_cape_id(body.mojang_cape_id)?; - let equipped = current_equipment(&state, &player).await?; let txn = state.database.begin().await?; + let player = lock_player(&txn, &player).await?; let existing = PlayerOutfit::find() .filter(player_outfit::Column::PlayerId.eq(player.id)) @@ -387,40 +445,42 @@ async fn create( require_skin(&txn, player.id, skin_id).await?; } + let (equipped, current_color) = current_look(&state, &txn, &player).await?; + let particle_color = body.particle_color.or(current_color); + let outfit = player_outfit::ActiveModel { player_id: Set(player.id), name: Set(name.unwrap_or_else(|| { - next_outfit_name(existing.iter().map(|outfit| outfit.name.as_str())) + next_numbered_name( + OUTFIT_NAME_PREFIX, + existing.iter().map(|outfit| outfit.name.as_str()), + ) })), favorite: Set(false), skin_id: Set(body.skin_id), mojang_cape_id: Set(mojang_cape_id), - particle_color: Set(body.particle_color), + particle_color: Set(particle_color), ..Default::default() } .insert(&txn) .await?; - if !equipped.is_empty() { - PlayerOutfitCosmetic::insert_many(equipped.iter().map(|(slot, cosmetic_id)| { - player_outfit_cosmetic::ActiveModel { - outfit_id: Set(outfit.id), - slot: Set(slot.clone()), - cosmetic_id: Set(*cosmetic_id), - } - })) - .exec(&txn) - .await?; - } + insert_outfit_cosmetics(&txn, outfit.id, &equipped).await?; - User::update_many() - .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(outfit.id))) - .filter(user::Column::Id.eq(player.id)) - .exec(&txn) + let color_changed = particle_color != current_color; + activate_outfit(&txn, &player, outfit.id, color_changed.then_some(particle_color)) .await?; + if color_changed { + override_particle_color(&state, player.minecraft_uuid, particle_color).await; + } txn.commit().await?; + if color_changed { + announce_particle_color_change(&state, player.minecraft_uuid, particle_color, true) + .await; + } + Ok((StatusCode::CREATED, Json(OutfitInfo::from_model(outfit, equipped)))) } @@ -434,10 +494,14 @@ async fn update( let name = body.name.as_deref().map(validate_name).transpose()?; let mojang_cape_id = body.mojang_cape_id.map(normalize_cape_id).transpose()?; - let outfit = find_outfit(&state.database, player.id, id).await?; + let txn = state.database.begin().await?; + let player = lock_player(&txn, &player).await?; + + let outfit = find_outfit(&txn, player.id, id).await?; if let Some(Some(skin_id)) = body.skin_id { - require_skin(&state.database, player.id, skin_id).await?; + require_skin(&txn, player.id, skin_id).await?; } + let is_active = player.active_outfit_id == Some(outfit.id); let mut active: player_outfit::ActiveModel = outfit.into(); if let Some(name) = name { @@ -446,7 +510,7 @@ async fn update( if let Some(favorite) = body.favorite { active.favorite = Set(favorite); } - if body.skin_id.is_some() || mojang_cape_id.is_some() { + if matches!(body.skin_id, Some(Some(_))) || matches!(mojang_cape_id, Some(Some(_))) { active.blank = Set(false); } if let Some(skin_id) = body.skin_id { @@ -459,9 +523,27 @@ async fn update( active.particle_color = Set(particle_color); } active.updated_at = Set(chrono::Utc::now().fixed_offset()); - let outfit = active.update(&state.database).await?; + let outfit = active.update(&txn).await?; + + let applied_color = body.particle_color.filter(|_| is_active); + if let Some(particle_color) = applied_color { + User::update_many() + .col_expr(user::Column::ParticleColor, Expr::value(particle_color)) + .filter(user::Column::Id.eq(player.id)) + .exec(&txn) + .await?; + override_particle_color(&state, player.minecraft_uuid, particle_color).await; + } + + let info = outfit_info(&txn, outfit).await?; + txn.commit().await?; + + if let Some(particle_color) = applied_color { + announce_particle_color_change(&state, player.minecraft_uuid, particle_color, true) + .await; + } - Ok(Json(outfit_info(&state.database, outfit).await?)) + Ok(Json(info)) } #[tracing::instrument(level = "debug", skip(state))] @@ -470,15 +552,20 @@ async fn delete( AuthenticatedPlayer(player): AuthenticatedPlayer, Path(id): Path, ) -> Result { + let txn = state.database.begin().await?; + lock_player(&txn, &player).await?; + let result = PlayerOutfit::delete_many() .filter(player_outfit::Column::Id.eq(id)) .filter(player_outfit::Column::PlayerId.eq(player.id)) - .exec(&state.database) + .exec(&txn) .await?; if result.rows_affected == 0 { return Err(OutfitError::OutfitMissing); } + txn.commit().await?; + Ok(StatusCode::NO_CONTENT) } @@ -488,11 +575,14 @@ async fn apply( AuthenticatedPlayer(player): AuthenticatedPlayer, Path(id): Path, ) -> Result, OutfitError> { - let previous = current_equipment(&state, &player).await?; - let txn = state.database.begin().await?; + let player = lock_player(&txn, &player).await?; let outfit = find_outfit(&txn, player.id, id).await?; + if outfit.blank { + return adopt_current_look(&state, txn, &player, outfit).await.map(Json); + } + let outfit_cosmetics = load_outfit_cosmetics(&txn, [outfit.id]) .await? .remove(&outfit.id) @@ -524,6 +614,7 @@ async fn apply( .map(|(slot, cosmetic_id)| (slot.clone(), *cosmetic_id)) .collect(); + let stored = stored_equipment(&txn, player.id).await?; PlayerEquippedCosmetic::delete_many() .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) .exec(&txn) @@ -541,22 +632,19 @@ async fn apply( .await?; } - User::update_many() - .col_expr(user::Column::ParticleColor, Expr::value(outfit.particle_color)) - .col_expr(user::Column::ActiveOutfitId, Expr::value(Some(outfit.id))) - .filter(user::Column::Id.eq(player.id)) - .exec(&txn) - .await?; + activate_outfit(&txn, &player, outfit.id, Some(outfit.particle_color)).await?; + let skin = find_outfit_skin(&txn, player.id, &outfit).await?; - let skin = match outfit.skin_id { - Some(skin_id) => { - PlayerSkin::find_by_id(skin_id) - .filter(player_skin::Column::PlayerId.eq(player.id)) - .one(&txn) - .await? - } - None => None, - }; + let previous = { + let mut player_runtime = state.realtime.player_runtime.write().await; + state.equipment_persist.supersede(player.minecraft_uuid); + state.particle_color_persist.supersede(player.minecraft_uuid); + player_runtime.get_mut(&player.minecraft_uuid).map(|runtime| { + runtime.particle_color = outfit.particle_color; + std::mem::replace(&mut runtime.equipped, equipped.clone()) + }) + } + .unwrap_or(stored); txn.commit().await?; @@ -565,12 +653,23 @@ async fn apply( for slot in slots { let cosmetic_id = equipped.get(&slot).copied(); if previous.get(&slot).copied() != cosmetic_id { - publish_equipment_change(&state, player.minecraft_uuid, slot, cosmetic_id) - .await; + announce_equipment_change( + &state, + player.minecraft_uuid, + slot, + cosmetic_id, + true, + ) + .await; } } - publish_particle_color_change(&state, player.minecraft_uuid, outfit.particle_color) - .await; + announce_particle_color_change( + &state, + player.minecraft_uuid, + outfit.particle_color, + true, + ) + .await; Ok(Json(ApplyOutfitResponse { outfit: OutfitInfo::from_model(outfit, outfit_cosmetics), @@ -578,9 +677,55 @@ async fn apply( })) } +async fn adopt_current_look( + state: &ApiState, + txn: DatabaseTransaction, + player: &user::Model, + outfit: player_outfit::Model, +) -> Result { + let (equipped, current_color) = current_look(state, &txn, player).await?; + let particle_color = outfit.particle_color.or(current_color); + + PlayerOutfitCosmetic::delete_many() + .filter(player_outfit_cosmetic::Column::OutfitId.eq(outfit.id)) + .exec(&txn) + .await?; + insert_outfit_cosmetics(&txn, outfit.id, &equipped).await?; + + let mut active: player_outfit::ActiveModel = outfit.into(); + active.blank = Set(false); + active.particle_color = Set(particle_color); + active.updated_at = Set(chrono::Utc::now().fixed_offset()); + let outfit = active.update(&txn).await?; + + let color_changed = particle_color != current_color; + activate_outfit(&txn, player, outfit.id, color_changed.then_some(particle_color)) + .await?; + let skin = find_outfit_skin(&txn, player.id, &outfit).await?; + if color_changed { + override_particle_color(state, player.minecraft_uuid, particle_color).await; + } + + txn.commit().await?; + + if color_changed { + announce_particle_color_change(state, player.minecraft_uuid, particle_color, true) + .await; + } + + Ok(ApplyOutfitResponse { + outfit: OutfitInfo::from_model(outfit, equipped), + skin: skin.map(SkinInfo::from_model), + }) +} + #[cfg(test)] mod tests { - use super::next_outfit_name; + use super::super::next_numbered_name; + + fn next_outfit_name<'a>(names: impl IntoIterator) -> String { + next_numbered_name(super::OUTFIT_NAME_PREFIX, names) + } #[test] fn names_the_first_outfit() { diff --git a/src/api/v0/outfits/skin.rs b/src/api/v0/outfits/skin.rs index 80b429e..c93990a 100644 --- a/src/api/v0/outfits/skin.rs +++ b/src/api/v0/outfits/skin.rs @@ -13,16 +13,19 @@ use axum::{ use entities::{player_skin, prelude::*}; use schemars::JsonSchema; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, - Set, + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, + TransactionTrait, }; use serde::{Deserialize, Serialize}; use super::{ - OutfitError, SKIN_LIMIT, SkinInfo, SkinModel, normalize_texture_hash, validate_name, + OutfitError, SKIN_LIMIT, SkinInfo, SkinModel, lock_player, next_numbered_name, + normalize_texture_hash, validate_name, }; use crate::api::{ApiState, v0::account::AuthenticatedPlayer}; +const SKIN_NAME_PREFIX: &str = "Skin #"; + #[derive(Debug, Serialize, JsonSchema)] struct SkinListResponse { skins: Vec, @@ -126,18 +129,6 @@ async fn find_skin( .ok_or(OutfitError::SkinMissing) } -async fn find_skin_by_hash( - state: &ApiState, - player_id: i32, - hash: &str, -) -> Result, OutfitError> { - Ok(PlayerSkin::find() - .filter(player_skin::Column::PlayerId.eq(player_id)) - .filter(player_skin::Column::Hash.eq(hash)) - .one(&state.database) - .await?) -} - #[tracing::instrument(level = "debug", skip(state))] async fn list( State(state): State, @@ -171,36 +162,39 @@ async fn add( .transpose()?; let model = body.model.unwrap_or(SkinModel::Classic); - if let Some(skin) = find_skin_by_hash(&state, player.id, &hash).await? { - return Ok((StatusCode::OK, Json(SkinInfo::from_model(skin)))); - } + let txn = state.database.begin().await?; + lock_player(&txn, &player).await?; - let count = PlayerSkin::find() + let existing = PlayerSkin::find() .filter(player_skin::Column::PlayerId.eq(player.id)) - .count(&state.database) + .all(&txn) .await?; - if count >= SKIN_LIMIT { + if let Some(skin) = existing.iter().find(|skin| skin.hash == hash) { + return Ok((StatusCode::OK, Json(SkinInfo::from_model(skin.clone())))); + } + if existing.len() as u64 >= SKIN_LIMIT { return Err(OutfitError::SkinLimit); } - let inserted = player_skin::ActiveModel { + let skin = player_skin::ActiveModel { player_id: Set(player.id), - name: Set(name.unwrap_or_else(|| format!("Skin #{}", count + 1))), + name: Set(name.unwrap_or_else(|| { + next_numbered_name( + SKIN_NAME_PREFIX, + existing.iter().map(|skin| skin.name.as_str()), + ) + })), model: Set(model.as_str().to_string()), - hash: Set(hash.clone()), + hash: Set(hash), favorite: Set(false), ..Default::default() } - .insert(&state.database) - .await; + .insert(&txn) + .await?; - match inserted { - Ok(skin) => Ok((StatusCode::CREATED, Json(SkinInfo::from_model(skin)))), - Err(error) => match find_skin_by_hash(&state, player.id, &hash).await? { - Some(skin) => Ok((StatusCode::OK, Json(SkinInfo::from_model(skin)))), - None => Err(error.into()), - }, - } + txn.commit().await?; + + Ok((StatusCode::CREATED, Json(SkinInfo::from_model(skin)))) } #[tracing::instrument(level = "debug", skip(state))] @@ -243,7 +237,18 @@ async fn delete( #[cfg(test)] mod tests { - use super::super::{normalize_texture_hash, texture_url}; + use super::{ + SKIN_NAME_PREFIX, + super::{next_numbered_name, normalize_texture_hash, texture_url}, + }; + + #[test] + fn names_skins_after_the_lowest_gap() { + assert_eq!( + next_numbered_name(SKIN_NAME_PREFIX, ["Skin #1", "Skin #3", "Outfit #2"]), + "Skin #2" + ); + } #[test] fn accepts_mojang_texture_hashes() { diff --git a/src/api/v0/websocket/endpoint.rs b/src/api/v0/websocket/endpoint.rs index df1bee4..8d9c708 100644 --- a/src/api/v0/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -768,43 +768,106 @@ pub(crate) async fn unequip_revoked(state: &ApiState, revoked: &HashMap, + source: ChangeSource, ) { - if let Some(player_state) = state.realtime.player_runtime.write().await.get_mut(&player) { - if let Some(cosmetic_id) = cosmetic_id { - player_state.equipped.insert(slot.clone(), cosmetic_id); - } else { - player_state.equipped.remove(&slot); + { + let mut player_runtime = state.realtime.player_runtime.write().await; + let player_state = match source { + ChangeSource::Game => Some(player_runtime.entry(player).or_default()), + ChangeSource::Server => player_runtime.get_mut(&player), + }; + if let Some(player_state) = player_state { + if let Some(cosmetic_id) = cosmetic_id { + player_state.equipped.insert(slot.clone(), cosmetic_id); + } else { + player_state.equipped.remove(&slot); + } + } + if source == ChangeSource::Game { + state.equipment_persist.enqueue(EquipmentPersistence { + player, + slot: slot.clone(), + cosmetic_id, + }); } } + announce_equipment_change( + state, + player, + slot, + cosmetic_id, + source == ChangeSource::Server, + ) + .await; +} + +pub(crate) async fn announce_equipment_change( + state: &ApiState, + player: Uuid, + slot: BodySlot, + cosmetic_id: Option, + notify_owner: bool, +) { let packet = || ClientBoundPacket::PlayerCosmeticEquipped { player, slot: slot.clone(), cosmetic_id, }; broadcast_to_watchers(state, player, packet).await; - send_to_owner(state, player, packet).await; + if notify_owner { + send_to_owner(state, player, packet).await; + } } pub(crate) async fn publish_particle_color_change( state: &ApiState, player: Uuid, color: Option, + source: ChangeSource, ) { - if let Some(player_state) = - state.realtime.player_runtime.write().await.get_mut(&player) { - player_state.particle_color = color; + let mut player_runtime = state.realtime.player_runtime.write().await; + let player_state = match source { + ChangeSource::Game => Some(player_runtime.entry(player).or_default()), + ChangeSource::Server => player_runtime.get_mut(&player), + }; + if let Some(player_state) = player_state { + player_state.particle_color = color; + } + if source == ChangeSource::Game { + state + .particle_color_persist + .enqueue(ParticleColorPersistence { player, color }); + } } + announce_particle_color_change(state, player, color, source == ChangeSource::Server) + .await; +} + +pub(crate) async fn announce_particle_color_change( + state: &ApiState, + player: Uuid, + color: Option, + notify_owner: bool, +) { let packet = || ClientBoundPacket::PlayerParticleColorChanged { player, color }; broadcast_to_watchers(state, player, packet).await; - send_to_owner(state, player, packet).await; + if notify_owner { + send_to_owner(state, player, packet).await; + } } pub(crate) async fn broadcast_all( @@ -920,52 +983,22 @@ async fn handle_packet( validate_cosmetic(state, player.id, &slot, cosmetic_id).await?; } - { - let mut player_runtime = state.realtime.player_runtime.write().await; - let equipment = &mut player_runtime - .entry(player.minecraft_uuid) - .or_default() - .equipped; - if let Some(cosmetic_id) = cosmetic_id { - equipment.insert(slot.clone(), cosmetic_id); - } else { - equipment.remove(&slot); - } - } - let _ = state.equipment_persist_tx.try_send(EquipmentPersistence { - player: player.minecraft_uuid, - slot: slot.clone(), + publish_equipment_change( + state, + player.minecraft_uuid, + slot, cosmetic_id, - }); - broadcast_to_watchers(state, player.minecraft_uuid, || { - ClientBoundPacket::PlayerCosmeticEquipped { - player: player.minecraft_uuid, - slot: slot.clone(), - cosmetic_id, - } - }) + ChangeSource::Game, + ) .await; } ServerBoundPacket::SetParticleColor { color } => { - { - let mut player_runtime = state.realtime.player_runtime.write().await; - player_runtime - .entry(player.minecraft_uuid) - .or_default() - .particle_color = color; - } - let _ = state - .particle_color_persist_tx - .try_send(ParticleColorPersistence { - player: player.minecraft_uuid, - color, - }); - broadcast_to_watchers(state, player.minecraft_uuid, || { - ClientBoundPacket::PlayerParticleColorChanged { - player: player.minecraft_uuid, - color, - } - }) + publish_particle_color_change( + state, + player.minecraft_uuid, + color, + ChangeSource::Game, + ) .await; } ServerBoundPacket::PlayEmote { emote_id } => { diff --git a/src/api/v0/websocket/mod.rs b/src/api/v0/websocket/mod.rs index 509b999..dfc657f 100644 --- a/src/api/v0/websocket/mod.rs +++ b/src/api/v0/websocket/mod.rs @@ -6,8 +6,8 @@ mod endpoint; pub mod structs; pub(crate) use endpoint::{ - broadcast_all, publish_equipment_change, publish_particle_color_change, send_to_owner, - unequip_revoked, + ChangeSource, announce_equipment_change, announce_particle_color_change, + broadcast_all, publish_equipment_change, send_to_owner, unequip_revoked, }; pub(super) async fn setup_router() -> ApiRouter { From 5d4e761a29d4e6f8fbda3a1f41ad24b46048c62b Mon Sep 17 00:00:00 2001 From: Deftu Date: Thu, 15 Oct 2026 18:12:15 +0200 Subject: [PATCH 7/7] fix: keep adopted outfits blank and tighten outfit locking --- ...0261015_000000_create_outfits_and_skins.rs | 36 +++++++ src/api/state/mod.rs | 3 +- src/api/state/persistence.rs | 55 +++++++---- src/api/v0/cosmetics/put_player.rs | 65 ++++++++++-- src/api/v0/outfits/mod.rs | 24 ++++- src/api/v0/outfits/outfit.rs | 98 +++++++++++++++---- src/api/v0/outfits/skin.rs | 28 ++++-- src/api/v0/websocket/endpoint.rs | 85 ++++------------ src/api/v0/websocket/mod.rs | 4 +- 9 files changed, 264 insertions(+), 134 deletions(-) diff --git a/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs index a1c31ec..ee20743 100644 --- a/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs +++ b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs @@ -61,7 +61,10 @@ enum PlayerOutfitCosmetic { const SKIN_PLAYER_HASH_IDX: &str = "player_skin_player_id_hash_key"; const OUTFIT_PLAYER_IDX: &str = "player_outfit_player_id_idx"; +const OUTFIT_SKIN_IDX: &str = "player_outfit_skin_id_idx"; +const OUTFIT_COSMETIC_COSMETIC_IDX: &str = "player_outfit_cosmetic_cosmetic_id_idx"; const USER_ACTIVE_OUTFIT_FK: &str = "user_active_outfit_id_fkey"; +const USER_ACTIVE_OUTFIT_IDX: &str = "user_active_outfit_id_idx"; #[async_trait::async_trait] impl MigrationTrait for Migration { @@ -189,6 +192,17 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_index( + Index::create() + .name(OUTFIT_SKIN_IDX) + .table(PlayerOutfit::Table) + .col(PlayerOutfit::SkinId) + .if_not_exists() + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -236,6 +250,17 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_index( + Index::create() + .name(OUTFIT_COSMETIC_COSMETIC_IDX) + .table(PlayerOutfitCosmetic::Table) + .col(PlayerOutfitCosmetic::CosmeticId) + .if_not_exists() + .to_owned(), + ) + .await?; + manager .alter_table( Table::alter() @@ -258,6 +283,17 @@ impl MigrationTrait for Migration { ) .to_owned(), ) + .await?; + + manager + .create_index( + Index::create() + .name(USER_ACTIVE_OUTFIT_IDX) + .table(User::Table) + .col(User::ActiveOutfitId) + .if_not_exists() + .to_owned(), + ) .await } diff --git a/src/api/state/mod.rs b/src/api/state/mod.rs index 07c3b8c..3f43cf3 100644 --- a/src/api/state/mod.rs +++ b/src/api/state/mod.rs @@ -73,7 +73,8 @@ pub(super) struct ApiState { pub(super) s3_public_url: Arc, pub(super) asset_cache: Cache, pub(super) realtime: RealtimeState, - pub(super) equipment_persist: PersistQueue, + pub(super) equipment_persist: + PersistQueue, pub(super) particle_color_persist: PersistQueue, pub(super) admin_password: String, pub(super) render_service_url: String, diff --git a/src/api/state/persistence.rs b/src/api/state/persistence.rs index 4985693..5117201 100644 --- a/src/api/state/persistence.rs +++ b/src/api/state/persistence.rs @@ -1,5 +1,6 @@ use std::{ collections::{BTreeSet, HashMap}, + hash::Hash, sync::{Arc, Mutex, MutexGuard, PoisonError}, time::Duration, }; @@ -37,13 +38,15 @@ pub struct ParticleColorPersistence { } #[derive(Debug, Clone)] -pub struct PersistQueue { +pub struct PersistQueue { tx: mpsc::Sender<(u64, T)>, - order: Arc, + order: Arc>, } -impl PersistQueue { - fn spawn(worker: impl FnOnce(mpsc::Receiver<(u64, T)>, Arc) -> F) -> Self +impl PersistQueue { + fn spawn( + worker: impl FnOnce(mpsc::Receiver<(u64, T)>, Arc>) -> F, + ) -> Self where F: Future + Send + 'static, { @@ -60,23 +63,33 @@ impl PersistQueue { } } - pub fn supersede(&self, player: Uuid) { - self.order.supersede(player); + pub fn supersede(&self, key: K) { + self.order.supersede(key); } } -#[derive(Debug, Default)] -struct WriteOrder(Mutex); +#[derive(Debug)] +struct WriteOrder(Mutex>); -#[derive(Debug, Default)] -struct WriteOrderState { +impl Default for WriteOrder { + fn default() -> Self { + Self(Mutex::new(WriteOrderState { + next: 0, + pending: BTreeSet::new(), + superseded: HashMap::new(), + })) + } +} + +#[derive(Debug)] +struct WriteOrderState { next: u64, pending: BTreeSet, - superseded: HashMap, + superseded: HashMap, } -impl WriteOrder { - fn state(&self) -> MutexGuard<'_, WriteOrderState> { +impl WriteOrder { + fn state(&self) -> MutexGuard<'_, WriteOrderState> { self.0.lock().unwrap_or_else(PoisonError::into_inner) } @@ -88,7 +101,7 @@ impl WriteOrder { seq } - fn supersede(&self, player: Uuid) { + fn supersede(&self, key: K) { let mut state = self.state(); if state.pending.is_empty() { return; @@ -96,13 +109,13 @@ impl WriteOrder { let seq = state.next; state.next += 1; - state.superseded.insert(player, seq); + state.superseded.insert(key, seq); } - fn is_superseded(&self, player: Uuid, seq: u64) -> bool { + fn is_superseded(&self, key: K, seq: u64) -> bool { self.state() .superseded - .get(&player) + .get(&key) .is_some_and(|&superseded| superseded > seq) } @@ -119,7 +132,7 @@ impl WriteOrder { /// Spawns the equipment writer and returns the channel feeding it. pub(super) fn spawn_equipment_queue( database: DatabaseConnection, -) -> PersistQueue { +) -> PersistQueue { PersistQueue::spawn(|rx, order| persist_equipment_queue(database, rx, order)) } @@ -141,7 +154,7 @@ pub(super) fn spawn_playtime_flush( async fn persist_equipment_queue( database: DatabaseConnection, mut rx: mpsc::Receiver<(u64, EquipmentPersistence)>, - order: Arc, + order: Arc>, ) { use entities::{player_equipped_cosmetic, prelude::*, user}; use sea_orm::{ @@ -161,7 +174,7 @@ async fn persist_equipment_queue( else { return Ok::<_, sea_orm::DbErr>(()); }; - if order.is_superseded(update.player, seq) { + if order.is_superseded((update.player, update.slot.clone()), seq) { return Ok(()); } @@ -218,7 +231,7 @@ async fn persist_equipment_queue( async fn persist_particle_color_queue( database: DatabaseConnection, mut rx: mpsc::Receiver<(u64, ParticleColorPersistence)>, - order: Arc, + order: Arc>, ) { use entities::{prelude::*, user}; use sea_orm::{ diff --git a/src/api/v0/cosmetics/put_player.rs b/src/api/v0/cosmetics/put_player.rs index f61da91..af346df 100644 --- a/src/api/v0/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -9,20 +9,23 @@ use axum::{ http::StatusCode, response::{IntoResponse, NoContent}, }; +use entities::sea_orm_active_enums::BodySlot; use schemars::JsonSchema; use sea_orm::{ ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, }; use serde::Deserialize; +use uuid::Uuid; use crate::api::{ ApiState, + state::EquipmentPersistence, v0::{ account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics, outfits::mirror_active_outfit_slots, - websocket::{ChangeSource, publish_equipment_change}, + websocket::announce_equipment_change, }, }; @@ -163,19 +166,61 @@ async fn endpoint( ) .await?; - txn.commit().await?; + let applied = { + let mut player_runtime = state.realtime.player_runtime.write().await; + let mut runtime = player_runtime.get_mut(&player.minecraft_uuid); + let mut applied = Vec::new(); + for (slot, cosmetic_id) in &body.equipment.equipped { + state + .equipment_persist + .supersede((player.minecraft_uuid, slot.clone())); + if let Some(runtime) = runtime.as_deref_mut() { + let previous = match cosmetic_id { + Some(cosmetic_id) => { + runtime.equipped.insert(slot.clone(), *cosmetic_id) + } + None => runtime.equipped.remove(slot), + }; + applied.push((slot.clone(), previous, *cosmetic_id)); + } + } + applied + }; + + if let Err(error) = txn.commit().await { + restore_runtime(&state, player.minecraft_uuid, applied).await; + return Err(error.into()); + } } for (slot, cosmetic_id) in body.equipment.equipped { - publish_equipment_change( - &state, - player.minecraft_uuid, - slot, - cosmetic_id, - ChangeSource::Server, - ) - .await; + announce_equipment_change(&state, player.minecraft_uuid, slot, cosmetic_id, true) + .await; } Ok(NoContent) } + +async fn restore_runtime( + state: &ApiState, + player: Uuid, + applied: Vec<(BodySlot, Option, Option)>, +) { + let mut player_runtime = state.realtime.player_runtime.write().await; + let Some(runtime) = player_runtime.get_mut(&player) else { + return; + }; + for (slot, previous, cosmetic_id) in applied { + if runtime.equipped.get(&slot).copied() == cosmetic_id { + match previous { + Some(previous) => runtime.equipped.insert(slot.clone(), previous), + None => runtime.equipped.remove(&slot), + }; + } + state.equipment_persist.enqueue(EquipmentPersistence { + player, + cosmetic_id: runtime.equipped.get(&slot).copied(), + slot, + }); + } +} diff --git a/src/api/v0/outfits/mod.rs b/src/api/v0/outfits/mod.rs index 672475c..e6533f5 100644 --- a/src/api/v0/outfits/mod.rs +++ b/src/api/v0/outfits/mod.rs @@ -42,9 +42,15 @@ pub enum OutfitError { OutfitLimit, #[error("You can have at most {} skins", SKIN_LIMIT)] SkinLimit, - #[error("Names must be between 1 and {} characters long", MAX_NAME_LENGTH)] + #[error( + "Names must be between 1 and {} characters long, without control characters", + MAX_NAME_LENGTH + )] InvalidName, - #[error("Mojang cape ids must be at most {} characters long", MAX_CAPE_ID_LENGTH)] + #[error( + "Mojang cape ids must be at most {} characters long, without control characters", + MAX_CAPE_ID_LENGTH + )] InvalidCapeId, #[error( "Texture hashes must be {}-{} hexadecimal characters", @@ -52,6 +58,8 @@ pub enum OutfitError { MAX_TEXTURE_HASH_LENGTH )] InvalidTextureHash, + #[error("Outfits can only be marked as not blank")] + InvalidBlank, #[error("Unable to query database: {0}")] Database(#[from] DbErr), } @@ -64,7 +72,8 @@ impl IntoResponse for OutfitError { Self::InvalidSkin | Self::InvalidName | Self::InvalidCapeId - | Self::InvalidTextureHash => StatusCode::BAD_REQUEST, + | Self::InvalidTextureHash + | Self::InvalidBlank => StatusCode::BAD_REQUEST, Self::OutfitLimit | Self::SkinLimit => StatusCode::CONFLICT, Self::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, }, @@ -158,7 +167,10 @@ impl OutfitInfo { fn validate_name(name: &str) -> Result { let trimmed = name.trim(); - if trimmed.is_empty() || trimmed.chars().count() > MAX_NAME_LENGTH { + if trimmed.is_empty() + || trimmed.chars().count() > MAX_NAME_LENGTH + || trimmed.chars().any(char::is_control) + { return Err(OutfitError::InvalidName); } @@ -189,7 +201,9 @@ fn normalize_cape_id(cape_id: Option) -> Result, OutfitEr if trimmed.is_empty() { return Ok(None); } - if trimmed.chars().count() > MAX_CAPE_ID_LENGTH { + if trimmed.chars().count() > MAX_CAPE_ID_LENGTH + || trimmed.chars().any(char::is_control) + { return Err(OutfitError::InvalidCapeId); } diff --git a/src/api/v0/outfits/outfit.rs b/src/api/v0/outfits/outfit.rs index 38f9946..3a84e09 100644 --- a/src/api/v0/outfits/outfit.rs +++ b/src/api/v0/outfits/outfit.rs @@ -20,8 +20,8 @@ use entities::{ use schemars::JsonSchema; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, DatabaseTransaction, - EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, - sea_query::Expr, + EntityTrait, Iterable, PaginatorTrait, QueryFilter, QueryOrder, Set, + TransactionTrait, sea_query::Expr, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -66,12 +66,14 @@ struct UpdateOutfitRequest { mojang_cape_id: Option>, #[serde(default, deserialize_with = "serde_with::rust::double_option::deserialize")] particle_color: Option>, + blank: Option, } #[derive(Debug, Serialize, JsonSchema)] struct ApplyOutfitResponse { outfit: OutfitInfo, skin: Option, + adopted: bool, } fn list_doc(op: TransformOperation) -> TransformOperation { @@ -80,9 +82,10 @@ fn list_doc(op: TransformOperation) -> TransformOperation { .description( "Lists the authenticated player's outfits, oldest first, along with the \ id of the active outfit and the library limit. The first list creates \ - eight outfits, `Outfit #1` to `Outfit #8`; `Outfit #1` holds the \ - player's current look and becomes the active outfit, the rest are \ - blank.", + eight blank outfits, `Outfit #1` to `Outfit #8`; `Outfit #1` holds the \ + player's current cosmetics and particle color and becomes the active \ + outfit, so the client can save the current skin and Mojang cape into \ + it.", ) .tag("outfits") } @@ -116,15 +119,19 @@ fn update_doc(op: TransformOperation) -> TransformOperation { .summary("Update an outfit") .description( "Updates an outfit's fields. Absent fields are left unchanged; an explicit \ - `null` clears `skin_id`, `mojang_cape_id` or `particle_color`. Setting \ - `skin_id` or `mojang_cape_id` to a value marks the outfit as no longer \ - blank. Changing the active outfit's `particle_color` also applies it to \ - the player and broadcasts it.", + `null` clears `skin_id`, `mojang_cape_id` or `particle_color`. `blank: \ + false` marks the outfit as no longer blank, even when `skin_id` and \ + `mojang_cape_id` are `null`; clients send it with the `skin_id` and \ + `mojang_cape_id` they save into an adopted outfit. `blank: true` is \ + rejected. Without `blank`, setting `skin_id` or `mojang_cape_id` to a \ + value marks the outfit as no longer blank. Changing the active outfit's \ + `particle_color` also applies it to the player and broadcasts it.", ) .tag("outfits") .response_with::<{ StatusCode::BAD_REQUEST.as_u16() }, String, _>(|res| { res.description( - "The skin is not in the player's library, or a field is invalid", + "The skin is not in the player's library, `blank` is `true`, or a \ + field is invalid", ) }) .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { @@ -156,8 +163,12 @@ fn apply_doc(op: TransformOperation) -> TransformOperation { active. Changes are broadcast to websocket watchers and the player's own \ connections. The client applies the outfit's skin and Mojang cape itself. \ A blank outfit instead takes on the player's current cosmetics, and its \ - own particle color or else the current one, stops being blank and \ - becomes active without changing what is equipped.", + own particle color or else the current one, and becomes active without \ + changing what is equipped. It stays blank: the response has `adopted` \ + set to `true`, the outfit's `blank` still `true` and its `skin_id` and \ + `mojang_cape_id` unset. The client then saves the player's current skin \ + and Mojang cape into the outfit with an update that also sends `blank: \ + false`, instead of changing them.", ) .tag("outfits") .response_with::<{ StatusCode::NOT_FOUND.as_u16() }, String, _>(|res| { @@ -217,7 +228,7 @@ async fn seed_starter_outfits( name: Set(format!("{OUTFIT_NAME_PREFIX}{number}")), favorite: Set(false), particle_color: Set(if number == 1 { particle_color } else { None }), - blank: Set(number != 1), + blank: Set(true), ..Default::default() } .insert(&txn) @@ -384,16 +395,34 @@ async fn activate_outfit( Ok(()) } +fn clears_blank( + blank: Option, + skin_id: Option>, + mojang_cape_id: Option<&Option>, +) -> Result { + match blank { + Some(true) => Err(OutfitError::InvalidBlank), + Some(false) => Ok(true), + None => Ok( + matches!(skin_id, Some(Some(_))) || matches!(mojang_cape_id, Some(Some(_))) + ), + } +} + #[tracing::instrument(level = "debug", skip(state))] async fn list( State(state): State, AuthenticatedPlayer(player): AuthenticatedPlayer, ) -> Result, OutfitError> { let mut active_outfit_id = player.active_outfit_id; - if !player.outfits_seeded - && let Some(seeded) = seed_starter_outfits(&state, &player).await? - { - active_outfit_id = Some(seeded); + if !player.outfits_seeded { + active_outfit_id = match seed_starter_outfits(&state, &player).await? { + Some(seeded) => Some(seeded), + None => User::find_by_id(player.id) + .one(&state.database) + .await? + .and_then(|player| player.active_outfit_id), + }; } let outfits = PlayerOutfit::find() @@ -493,6 +522,7 @@ async fn update( ) -> Result, OutfitError> { let name = body.name.as_deref().map(validate_name).transpose()?; let mojang_cape_id = body.mojang_cape_id.map(normalize_cape_id).transpose()?; + let clears_blank = clears_blank(body.blank, body.skin_id, mojang_cape_id.as_ref())?; let txn = state.database.begin().await?; let player = lock_player(&txn, &player).await?; @@ -510,7 +540,7 @@ async fn update( if let Some(favorite) = body.favorite { active.favorite = Set(favorite); } - if matches!(body.skin_id, Some(Some(_))) || matches!(mojang_cape_id, Some(Some(_))) { + if clears_blank { active.blank = Set(false); } if let Some(skin_id) = body.skin_id { @@ -524,6 +554,7 @@ async fn update( } active.updated_at = Set(chrono::Utc::now().fixed_offset()); let outfit = active.update(&txn).await?; + let info = outfit_info(&txn, outfit).await?; let applied_color = body.particle_color.filter(|_| is_active); if let Some(particle_color) = applied_color { @@ -535,7 +566,6 @@ async fn update( override_particle_color(&state, player.minecraft_uuid, particle_color).await; } - let info = outfit_info(&txn, outfit).await?; txn.commit().await?; if let Some(particle_color) = applied_color { @@ -637,7 +667,9 @@ async fn apply( let previous = { let mut player_runtime = state.realtime.player_runtime.write().await; - state.equipment_persist.supersede(player.minecraft_uuid); + for slot in BodySlot::iter() { + state.equipment_persist.supersede((player.minecraft_uuid, slot)); + } state.particle_color_persist.supersede(player.minecraft_uuid); player_runtime.get_mut(&player.minecraft_uuid).map(|runtime| { runtime.particle_color = outfit.particle_color; @@ -674,6 +706,7 @@ async fn apply( Ok(Json(ApplyOutfitResponse { outfit: OutfitInfo::from_model(outfit, outfit_cosmetics), skin: skin.map(SkinInfo::from_model), + adopted: false, })) } @@ -693,7 +726,6 @@ async fn adopt_current_look( insert_outfit_cosmetics(&txn, outfit.id, &equipped).await?; let mut active: player_outfit::ActiveModel = outfit.into(); - active.blank = Set(false); active.particle_color = Set(particle_color); active.updated_at = Set(chrono::Utc::now().fixed_offset()); let outfit = active.update(&txn).await?; @@ -716,6 +748,7 @@ async fn adopt_current_look( Ok(ApplyOutfitResponse { outfit: OutfitInfo::from_model(outfit, equipped), skin: skin.map(SkinInfo::from_model), + adopted: true, }) } @@ -747,4 +780,27 @@ mod tests { "Outfit #3" ); } + + #[test] + fn explicit_blank_false_clears_blank_without_a_look() { + assert!(super::clears_blank(Some(false), Some(None), Some(&None)).unwrap()); + assert!(super::clears_blank(Some(false), None, None).unwrap()); + } + + #[test] + fn rejects_marking_an_outfit_blank() { + assert!(matches!( + super::clears_blank(Some(true), Some(Some(1)), None), + Err(super::OutfitError::InvalidBlank) + )); + } + + #[test] + fn absent_blank_clears_only_for_a_skin_or_cape() { + let cape = Some("cape".to_string()); + assert!(super::clears_blank(None, Some(Some(1)), None).unwrap()); + assert!(super::clears_blank(None, None, Some(&cape)).unwrap()); + assert!(!super::clears_blank(None, Some(None), Some(&None)).unwrap()); + assert!(!super::clears_blank(None, None, None).unwrap()); + } } diff --git a/src/api/v0/outfits/skin.rs b/src/api/v0/outfits/skin.rs index c93990a..7ed02a2 100644 --- a/src/api/v0/outfits/skin.rs +++ b/src/api/v0/outfits/skin.rs @@ -13,8 +13,8 @@ use axum::{ use entities::{player_skin, prelude::*}; use schemars::JsonSchema; use sea_orm::{ - ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, - TransactionTrait, + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, + QueryOrder, Set, TransactionTrait, }; use serde::{Deserialize, Serialize}; @@ -62,7 +62,8 @@ fn add_doc(op: TransformOperation) -> TransformOperation { `textures.minecraft.net` URL), with an optional `model` (`classic` or \ `slim`, default `classic`) and `name`. Names default to `Skin #N`. \ Adding a texture already in the library returns the existing entry \ - unchanged with `200`.", + unchanged with `200`, even when a different `model` is given; use \ + `PATCH /skins/{id}` to change its model.", ) .tag("outfits") .response_with::<{ StatusCode::CREATED.as_u16() }, Json, _>(|res| { @@ -117,14 +118,14 @@ pub(super) fn router() -> ApiRouter { ) } -async fn find_skin( - state: &ApiState, +async fn find_skin( + db: &C, player_id: i32, id: i32, ) -> Result { PlayerSkin::find_by_id(id) .filter(player_skin::Column::PlayerId.eq(player_id)) - .one(&state.database) + .one(db) .await? .ok_or(OutfitError::SkinMissing) } @@ -205,7 +206,10 @@ async fn update( Json(body): Json, ) -> Result, OutfitError> { let name = body.name.as_deref().map(validate_name).transpose()?; - let skin = find_skin(&state, player.id, id).await?; + + let txn = state.database.begin().await?; + lock_player(&txn, &player).await?; + let skin = find_skin(&txn, player.id, id).await?; let mut active: player_skin::ActiveModel = skin.into(); if let Some(name) = name { @@ -218,7 +222,8 @@ async fn update( active.model = Set(model.as_str().to_string()); } active.updated_at = Set(chrono::Utc::now().fixed_offset()); - let skin = active.update(&state.database).await?; + let skin = active.update(&txn).await?; + txn.commit().await?; Ok(Json(SkinInfo::from_model(skin))) } @@ -229,8 +234,11 @@ async fn delete( AuthenticatedPlayer(player): AuthenticatedPlayer, Path(id): Path, ) -> Result { - let skin = find_skin(&state, player.id, id).await?; - PlayerSkin::delete_by_id(skin.id).exec(&state.database).await?; + let txn = state.database.begin().await?; + lock_player(&txn, &player).await?; + let skin = find_skin(&txn, player.id, id).await?; + PlayerSkin::delete_by_id(skin.id).exec(&txn).await?; + txn.commit().await?; Ok(StatusCode::NO_CONTENT) } diff --git a/src/api/v0/websocket/endpoint.rs b/src/api/v0/websocket/endpoint.rs index 8d9c708..2f88311 100644 --- a/src/api/v0/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -768,49 +768,28 @@ pub(crate) async fn unequip_revoked(state: &ApiState, revoked: &HashMap, - source: ChangeSource, ) { { let mut player_runtime = state.realtime.player_runtime.write().await; - let player_state = match source { - ChangeSource::Game => Some(player_runtime.entry(player).or_default()), - ChangeSource::Server => player_runtime.get_mut(&player), - }; - if let Some(player_state) = player_state { - if let Some(cosmetic_id) = cosmetic_id { - player_state.equipped.insert(slot.clone(), cosmetic_id); - } else { - player_state.equipped.remove(&slot); - } - } - if source == ChangeSource::Game { - state.equipment_persist.enqueue(EquipmentPersistence { - player, - slot: slot.clone(), - cosmetic_id, - }); + let equipped = &mut player_runtime.entry(player).or_default().equipped; + if let Some(cosmetic_id) = cosmetic_id { + equipped.insert(slot.clone(), cosmetic_id); + } else { + equipped.remove(&slot); } + state.equipment_persist.enqueue(EquipmentPersistence { + player, + slot: slot.clone(), + cosmetic_id, + }); } - announce_equipment_change( - state, - player, - slot, - cosmetic_id, - source == ChangeSource::Server, - ) - .await; + announce_equipment_change(state, player, slot, cosmetic_id, false).await; } pub(crate) async fn announce_equipment_change( @@ -831,30 +810,20 @@ pub(crate) async fn announce_equipment_change( } } -pub(crate) async fn publish_particle_color_change( +async fn publish_particle_color_change( state: &ApiState, player: Uuid, color: Option, - source: ChangeSource, ) { { let mut player_runtime = state.realtime.player_runtime.write().await; - let player_state = match source { - ChangeSource::Game => Some(player_runtime.entry(player).or_default()), - ChangeSource::Server => player_runtime.get_mut(&player), - }; - if let Some(player_state) = player_state { - player_state.particle_color = color; - } - if source == ChangeSource::Game { - state - .particle_color_persist - .enqueue(ParticleColorPersistence { player, color }); - } + player_runtime.entry(player).or_default().particle_color = color; + state + .particle_color_persist + .enqueue(ParticleColorPersistence { player, color }); } - announce_particle_color_change(state, player, color, source == ChangeSource::Server) - .await; + announce_particle_color_change(state, player, color, false).await; } pub(crate) async fn announce_particle_color_change( @@ -983,23 +952,11 @@ async fn handle_packet( validate_cosmetic(state, player.id, &slot, cosmetic_id).await?; } - publish_equipment_change( - state, - player.minecraft_uuid, - slot, - cosmetic_id, - ChangeSource::Game, - ) - .await; + publish_equipment_change(state, player.minecraft_uuid, slot, cosmetic_id) + .await; } ServerBoundPacket::SetParticleColor { color } => { - publish_particle_color_change( - state, - player.minecraft_uuid, - color, - ChangeSource::Game, - ) - .await; + publish_particle_color_change(state, player.minecraft_uuid, color).await; } ServerBoundPacket::PlayEmote { emote_id } => { validate_emote(state, player.id, emote_id).await?; diff --git a/src/api/v0/websocket/mod.rs b/src/api/v0/websocket/mod.rs index dfc657f..4f9687f 100644 --- a/src/api/v0/websocket/mod.rs +++ b/src/api/v0/websocket/mod.rs @@ -6,8 +6,8 @@ mod endpoint; pub mod structs; pub(crate) use endpoint::{ - ChangeSource, announce_equipment_change, announce_particle_color_change, - broadcast_all, publish_equipment_change, send_to_owner, unequip_revoked, + announce_equipment_change, announce_particle_color_change, broadcast_all, + send_to_owner, unequip_revoked, }; pub(super) async fn setup_router() -> ApiRouter {