Skip to content

feat: add outfits, a skin library and REST equip broadcasts - #18

Merged
LynithDev merged 7 commits into
Polyfrost:mainfrom
Deftu:feat/wardrobe
Oct 18, 2026
Merged

LynithDev merged 7 commits into
Polyfrost:mainfrom
Deftu:feat/wardrobe

Conversation

@Deftu

@Deftu Deftu commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Adds saved outfits and a skin library for the wardrobe in PolyPlus and OneClient, and broadcasts equipment changes made over REST so a running game picks them up.

Outfits

  • GET and POST /outfits, PATCH and DELETE /outfits/{id}, and POST /outfits/{id}/apply
  • An outfit holds the equipped cosmetics, the particle colour, a Mojang cape and a saved skin. Applying one swaps the equipped cosmetics and broadcasts the change.
  • The active outfit follows equipment changes made anywhere else
  • New players get eight starter outfits, which pick up their current look the first time they're worn
  • Up to 128 outfits per player

Skins

  • GET and POST /skins, PATCH and DELETE /skins/{id}
  • A skin is a reference to a Mojang texture hash, not an upload. Adding a hash that's already saved returns the existing entry.
  • Up to 128 skins per player

Equipment broadcast

PUT /cosmetics/player now sends PlayerCosmeticEquipped over the websocket, the same as an equip made in game. Without it, changes made in OneClient don't reach a game that's already running.

Database

One migration (m20261015_000000_create_outfits_and_skins) adds the player_skin, player_outfit and player_outfit_cosmetic tables and an active outfit column on user.

The clients are in Polyfrost/PolyPlus#205 and Polyfrost/OneLauncher#1003.

@Wyvest Wyvest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the outfits/skins/broadcast changes. Main issue: apply ignores blank, so wearing a starter outfit wipes the player's look. Particle color also isn't kept in sync with the active outfit in a few places. Details inline.

}

#[tracing::instrument(level = "debug", skip(state))]
async fn apply(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apply never looks at outfit.blank. Starter outfits #2–#8 are seeded with blank = true, no cosmetics and particle_color = None, so applying one deletes every player_equipped_cosmetic row, clears user.particle_color, and broadcasts an unequip for every slot. The PR description says starter outfits should pick up the current look the first time they're worn.

Suggest: when outfit.blank, copy the current equipment and particle color into the outfit, set blank = false, make it active, and leave equipment untouched instead of swapping.

Comment thread src/api/v0/outfits/mod.rs Outdated
Ok(Some(trimmed.to_string()))
}

pub(crate) async fn mirror_active_outfit_slot<C: ConnectionTrait>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only slot changes get mirrored into the active outfit. In-game SetParticleColor goes through persist_particle_color_queue, which updates user.particle_color but not the active outfit's particle_color. So: active outfit A is red, player switches to blue in game, applies B, re-applies A, and the color goes back to red. Equivalent mirroring in the particle color queue would fix this.

Comment thread src/api/state/persistence.rs Outdated
warn!("Unable to persist websocket equipment update: {error}");
match result {
Ok(Some(player)) => {
if let Err(error) = mirror_active_outfit_slot(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ordering race: this re-reads the user and mirrors into whatever outfit is active when the queued update is processed. If a player equips in game (runtime updated, update queued) and then immediately applies outfit B over REST, the queued update can land after apply commits. It then upserts the old slot into player_equipped_cosmetic and mirrors it into outfit B. DB and runtime disagree, and B is silently modified. The window is small, but it's the exact flow this PR enables (OneClient + running game).

Comment thread src/api/v0/outfits/outfit.rs Outdated
.await?;
}

User::update_many()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create makes the new outfit active but doesn't apply its particle_color. user.particle_color and runtime keep the old value, and nothing is broadcast. Also, when the body omits particle_color it's stored as NULL rather than the player's current color, so an outfit "created from the current look" clears particles the next time it's applied. Consider defaulting to player.particle_color (or the runtime value).

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PATCHing particle_color on the active outfit only changes the outfit row. user.particle_color and watchers keep the old color until the outfit is re-applied, so active outfit and live state diverge. If the outfit is active, write user.particle_color and call publish_particle_color_change.

Comment thread src/api/v0/cosmetics/put_player.rs Outdated
}

for (slot, value) in body.equipment.equipped.clone() {
mirror_active_outfit_slot(&txn, &player, slot, value).await?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mirroring now runs inside the equip transaction with ?, using the player row loaded at auth time. If the active outfit is deleted concurrently, the insert into player_outfit_cosmetic violates the FK and the whole equip rolls back with a 500. The websocket path only warns on mirror failure. Either re-read active_outfit_id inside the txn or treat mirror failure as non-fatal. (Minor: this also issues one updated_at UPDATE per slot. Could fold into the first loop and bump once.)

.filter(player_outfit::Column::PlayerId.eq(player.id))
.all(&txn)
.await?;
if existing.len() as u64 >= OUTFIT_LIMIT {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Count-then-insert under READ COMMITTED with no lock: two concurrent POST /outfits at 127 both pass and you end up at 129. Same pattern in skin add. Low impact, but SELECT ... FOR UPDATE on the user row (or a lock on user at the start of the txn) would make the limit real.

Comment thread src/api/v0/outfits/skin.rs Outdated

let inserted = player_skin::ActiveModel {
player_id: Set(player.id),
name: Set(name.unwrap_or_else(|| format!("Skin #{}", count + 1))),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skin #{count + 1} repeats names after deletions (have #1 and #2, delete #1, add, get a second "Skin #2"). next_outfit_name already does lowest-gap numbering; generalizing it over the prefix would cover both.

Comment thread src/api/v0/outfits/outfit.rs Outdated
if let Some(favorite) = body.favorite {
active.favorite = Set(favorite);
}
if body.skin_id.is_some() || mojang_cape_id.is_some() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

body.skin_id.is_some() is also true for an explicit null (Some(None)), same for mojang_cape_id (and normalize_cape_id maps "" to None). Clearing a field on a blank outfit marks it non-blank even though nothing was set, contrary to the doc ("Setting skin_id or mojang_cape_id marks..."). Use matches!(body.skin_id, Some(Some(_))) etc.

Comment thread src/api/v0/websocket/endpoint.rs Outdated
}
}

pub(crate) async fn publish_equipment_change(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the runtime update + broadcast already inline in the SetEquippedCosmetic handler. Having the handler call this helper (with the owner echo made optional) keeps the two paths from drifting.

@LynithDev
LynithDev merged commit e49d8c3 into Polyfrost:main Oct 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants