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..cc7265b --- /dev/null +++ b/database/entities/src/entities/player_outfit.rs @@ -0,0 +1,70 @@ +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 blank: 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_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..6ffba4e 100644 --- a/database/entities/src/entities/user.rs +++ b/database/entities/src/entities/user.rs @@ -20,6 +20,8 @@ pub struct Model { pub chargeback_count: i32, #[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)] @@ -30,8 +32,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 +72,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..ee20743 --- /dev/null +++ b/database/migrations/src/m20261015_000000_create_outfits_and_skins.rs @@ -0,0 +1,320 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(DeriveIden)] +enum User { + Table, + Id, + ActiveOutfitId, + OutfitsSeeded, +} + +#[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, + Blank, + 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 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 { + 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::Blank) + .boolean() + .not_null() + .default(false), + ) + .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_index( + Index::create() + .name(OUTFIT_SKIN_IDX) + .table(PlayerOutfit::Table) + .col(PlayerOutfit::SkinId) + .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 + .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() + .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), + ) + .add_column( + ColumnDef::new(User::OutfitsSeeded) + .boolean() + .not_null() + .default(false), + ) + .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 + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(User::Table) + .drop_column(User::ActiveOutfitId) + .drop_column(User::OutfitsSeeded) + .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 + } +} diff --git a/src/api/state/mod.rs b/src/api/state/mod.rs index b4655f3..3f43cf3 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,9 @@ 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 +135,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 1a24b55..5117201 100644 --- a/src/api/state/persistence.rs +++ b/src/api/state/persistence.rs @@ -1,4 +1,9 @@ -use std::time::Duration; +use std::{ + collections::{BTreeSet, HashMap}, + hash::Hash, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + time::Duration, +}; use chrono::{DateTime, Utc}; use entities::sea_orm_active_enums::BodySlot; @@ -7,7 +12,10 @@ 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_particle_color, mirror_active_outfit_slots}, +}; /// Capacity of the persistence channels before senders start to wait. const PERSIST_QUEUE_SIZE: usize = 256; @@ -29,22 +37,110 @@ 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, key: K) { + self.order.supersede(key); + } +} + +#[derive(Debug)] +struct WriteOrder(Mutex>); + +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, +} + +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, key: K) { + let mut state = self.state(); + if state.pending.is_empty() { + return; + } + + let seq = state.next; + state.next += 1; + state.superseded.insert(key, seq); + } + + fn is_superseded(&self, key: K, seq: u64) -> bool { + self.state() + .superseded + .get(&key) + .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. @@ -57,28 +153,35 @@ 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>(()); + return Ok::<_, sea_orm::DbErr>(()); }; + if order.is_superseded((update.player, update.slot.clone()), seq) { + return Ok(()); + } 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, }) @@ -94,19 +197,30 @@ async fn persist_equipment_queue( ) .to_owned(), ) - .exec(&database) + .exec(&txn) .await?; } else { PlayerEquippedCosmetic::delete_many() .filter(player_equipped_cosmetic::Column::PlayerId.eq(player.id)) - .filter(player_equipped_cosmetic::Column::Slot.eq(update.slot)) - .exec(&database) + .filter( + player_equipped_cosmetic::Column::Slot.eq(update.slot.clone()), + ) + .exec(&txn) .await?; } - Ok(()) + mirror_active_outfit_slots( + &txn, + player.id, + player.active_outfit_id, + [(update.slot, update.cosmetic_id)], + ) + .await?; + + txn.commit().await } .await; + order.finish(seq); if let Err(error) = result { warn!("Unable to persist websocket equipment update: {error}"); @@ -116,28 +230,47 @@ async fn persist_equipment_queue( 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}"); @@ -208,3 +341,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 f76f6ca..af346df 100644 --- a/src/api/v0/cosmetics/put_player.rs +++ b/src/api/v0/cosmetics/put_player.rs @@ -9,15 +9,24 @@ use axum::{ http::StatusCode, response::{IntoResponse, NoContent}, }; +use entities::sea_orm_active_enums::BodySlot; use schemars::JsonSchema; use sea_orm::{ - ActiveValue, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait, + ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, + TransactionTrait, }; use serde::Deserialize; +use uuid::Uuid; use crate::api::{ ApiState, - v0::{account::AuthenticatedPlayer, cosmetics::PartialEquippedCosmetics}, + state::EquipmentPersistence, + v0::{ + account::AuthenticatedPlayer, + cosmetics::PartialEquippedCosmetics, + outfits::mirror_active_outfit_slots, + websocket::announce_equipment_change, + }, }; #[derive(thiserror::Error, Debug, OperationIo)] @@ -89,8 +98,13 @@ 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 { + 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)) @@ -144,8 +158,69 @@ async fn endpoint( } } - txn.commit().await?; + mirror_active_outfit_slots( + &txn, + player.id, + active_outfit_id, + body.equipment.equipped.clone(), + ) + .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 { + 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/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..e6533f5 --- /dev/null +++ b/src/api/v0/outfits/mod.rs @@ -0,0 +1,324 @@ +mod outfit; +mod skin; + +use std::collections::{HashMap, HashSet}; + +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, without control characters", + MAX_NAME_LENGTH + )] + InvalidName, + #[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", + MIN_TEXTURE_HASH_LENGTH, + MAX_TEXTURE_HASH_LENGTH + )] + InvalidTextureHash, + #[error("Outfits can only be marked as not blank")] + InvalidBlank, + #[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 + | Self::InvalidBlank => 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, + blank: bool, + 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, + blank: outfit.blank, + 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 + || trimmed.chars().any(char::is_control) + { + 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 + || trimmed.chars().any(char::is_control) + { + return Err(OutfitError::InvalidCapeId); + } + + Ok(Some(trimmed.to_string())) +} + +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, +) -> 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::{ + ColumnTrait, EntityTrait, QueryFilter, Set, + sea_query::{Expr, OnConflict}, + }; + + let Some(outfit_id) = active_outfit_id else { + return Ok(()); + }; + + 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, + player_outfit_cosmetic::Column::Slot, + ]) + .update_column(player_outfit_cosmetic::Column::CosmeticId) + .to_owned(), + ) + .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)) + .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..3a84e09 --- /dev/null +++ b/src/api/v0/outfits/outfit.rs @@ -0,0 +1,806 @@ +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, DatabaseTransaction, + EntityTrait, Iterable, PaginatorTrait, QueryFilter, QueryOrder, Set, + TransactionTrait, sea_query::Expr, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ + OUTFIT_LIMIT, OutfitError, OutfitInfo, SkinInfo, lock_player, next_numbered_name, + normalize_cape_id, validate_name, +}; +use crate::api::{ + ApiState, + v0::{ + account::AuthenticatedPlayer, + 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 { + 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>, + blank: Option, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ApplyOutfitResponse { + outfit: OutfitInfo, + skin: Option, + adopted: bool, +} + +fn list_doc(op: TransformOperation) -> TransformOperation { + op.id("listOutfits") + .summary("List the player's outfits") + .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 particle color and becomes the active \ + outfit, so the client can save the current skin and Mojang cape into \ + it.", + ) + .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. 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| { + 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`. `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, `blank` is `true`, 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. \ + A blank outfit instead takes on the player's current cosmetics, and its \ + 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| { + 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 seed_starter_outfits( + state: &ApiState, + player: &user::Model, +) -> Result, OutfitError> { + 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 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_NAME_PREFIX}{number}")), + favorite: Set(false), + particle_color: Set(if number == 1 { 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); + }; + + insert_outfit_cosmetics(&txn, first, &equipped).await?; + activate_outfit(&txn, &player, first, None).await?; + + txn.commit().await?; + + Ok(Some(first)) +} + +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 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<(HashMap, Option), OutfitError> { + if let Some(runtime) = state + .realtime + .player_runtime + .read() + .await + .get(&player.minecraft_uuid) + { + return Ok((runtime.equipped.clone(), runtime.particle_color)); + } + + 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(()) +} + +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 { + 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() + .filter(player_outfit::Column::PlayerId.eq(player.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( + &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, + 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 txn = state.database.begin().await?; + let player = lock_player(&txn, &player).await?; + + let existing = PlayerOutfit::find() + .filter(player_outfit::Column::PlayerId.eq(player.id)) + .all(&txn) + .await?; + if existing.len() as u64 >= OUTFIT_LIMIT { + return Err(OutfitError::OutfitLimit); + } + + if let Some(skin_id) = body.skin_id { + 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_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(particle_color), + ..Default::default() + } + .insert(&txn) + .await?; + + insert_outfit_cosmetics(&txn, outfit.id, &equipped).await?; + + 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)))) +} + +#[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 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?; + + let outfit = find_outfit(&txn, player.id, id).await?; + if let Some(Some(skin_id)) = body.skin_id { + 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 { + active.name = Set(name); + } + if let Some(favorite) = body.favorite { + active.favorite = Set(favorite); + } + if clears_blank { + active.blank = Set(false); + } + 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(&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 { + 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; + } + + txn.commit().await?; + + if let Some(particle_color) = applied_color { + announce_particle_color_change(&state, player.minecraft_uuid, particle_color, true) + .await; + } + + Ok(Json(info)) +} + +#[tracing::instrument(level = "debug", skip(state))] +async fn delete( + State(state): State, + 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(&txn) + .await?; + if result.rows_affected == 0 { + return Err(OutfitError::OutfitMissing); + } + + txn.commit().await?; + + 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 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) + .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(); + + let stored = stored_equipment(&txn, player.id).await?; + 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?; + } + + activate_outfit(&txn, &player, outfit.id, Some(outfit.particle_color)).await?; + let skin = find_outfit_skin(&txn, player.id, &outfit).await?; + + let previous = { + let mut player_runtime = state.realtime.player_runtime.write().await; + 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; + std::mem::replace(&mut runtime.equipped, equipped.clone()) + }) + } + .unwrap_or(stored); + + 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 { + announce_equipment_change( + &state, + player.minecraft_uuid, + slot, + cosmetic_id, + true, + ) + .await; + } + } + announce_particle_color_change( + &state, + player.minecraft_uuid, + outfit.particle_color, + true, + ) + .await; + + Ok(Json(ApplyOutfitResponse { + outfit: OutfitInfo::from_model(outfit, outfit_cosmetics), + skin: skin.map(SkinInfo::from_model), + adopted: false, + })) +} + +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.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), + adopted: true, + }) +} + +#[cfg(test)] +mod tests { + 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() { + 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" + ); + } + + #[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 new file mode 100644 index 0000000..7ed02a2 --- /dev/null +++ b/src/api/v0/outfits/skin.rs @@ -0,0 +1,288 @@ +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, ConnectionTrait, EntityTrait, QueryFilter, + QueryOrder, Set, TransactionTrait, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + 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, + 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`, 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| { + 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( + db: &C, + player_id: i32, + id: i32, +) -> Result { + PlayerSkin::find_by_id(id) + .filter(player_skin::Column::PlayerId.eq(player_id)) + .one(db) + .await? + .ok_or(OutfitError::SkinMissing) +} + +#[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); + + let txn = state.database.begin().await?; + lock_player(&txn, &player).await?; + + let existing = PlayerSkin::find() + .filter(player_skin::Column::PlayerId.eq(player.id)) + .all(&txn) + .await?; + 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 skin = player_skin::ActiveModel { + player_id: Set(player.id), + 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), + favorite: Set(false), + ..Default::default() + } + .insert(&txn) + .await?; + + txn.commit().await?; + + Ok((StatusCode::CREATED, Json(SkinInfo::from_model(skin)))) +} + +#[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 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 { + 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(&txn).await?; + txn.commit().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 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) +} + +#[cfg(test)] +mod tests { + 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() { + 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 f67e6f8..2f88311 100644 --- a/src/api/v0/websocket/endpoint.rs +++ b/src/api/v0/websocket/endpoint.rs @@ -768,6 +768,77 @@ pub(crate) async fn unequip_revoked(state: &ApiState, revoked: &HashMap, +) { + { + let mut player_runtime = state.realtime.player_runtime.write().await; + 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, false).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; + if notify_owner { + send_to_owner(state, player, packet).await; + } +} + +async fn publish_particle_color_change( + state: &ApiState, + player: Uuid, + color: Option, +) { + { + let mut player_runtime = state.realtime.player_runtime.write().await; + player_runtime.entry(player).or_default().particle_color = color; + state + .particle_color_persist + .enqueue(ParticleColorPersistence { player, color }); + } + + announce_particle_color_change(state, player, color, false).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; + if notify_owner { + send_to_owner(state, player, packet).await; + } +} + pub(crate) async fn broadcast_all( state: &ApiState, mut make_packet: impl FnMut() -> ClientBoundPacket, @@ -881,53 +952,11 @@ 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(), - cosmetic_id, - }); - broadcast_to_watchers(state, player.minecraft_uuid, || { - ClientBoundPacket::PlayerCosmeticEquipped { - player: player.minecraft_uuid, - slot: slot.clone(), - cosmetic_id, - } - }) - .await; + publish_equipment_change(state, player.minecraft_uuid, slot, cosmetic_id) + .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, - } - }) - .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 97e1f2d..4f9687f 100644 --- a/src/api/v0/websocket/mod.rs +++ b/src/api/v0/websocket/mod.rs @@ -5,7 +5,10 @@ use crate::api::ApiState; mod endpoint; pub mod structs; -pub(crate) use endpoint::{broadcast_all, send_to_owner, unequip_revoked}; +pub(crate) use endpoint::{ + announce_equipment_change, announce_particle_color_change, broadcast_all, + send_to_owner, unequip_revoked, +}; pub(super) async fn setup_router() -> ApiRouter { ApiRouter::new().merge(endpoint::router())