Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/src/api/characters_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
store_masked_template,
upload_pending_create_wardrobe,
upload_slot,
wardrobe_slot_texture,
worn_skin_texture,
)
from src.skins.auth import (
Expand Down Expand Up @@ -1157,6 +1158,21 @@ def plugin_get_worn_skin(
raise _wardrobe_http(e) from e


@characters_router.get("/plugin/wardrobe/{player_uuid}/{character_id}/slots/{slot}")
def plugin_get_wardrobe_slot_texture(
player_uuid: str,
character_id: str,
slot: str,
x_plugin_key: str | None = Header(default=None, alias=HEADER_PLUGIN_KEY),
):
"""Read one outfit for a fixed player head; no wardrobe state is changed."""
_require_plugin(x_plugin_key)
try:
return wardrobe_slot_texture(player_uuid, character_id, slot)
except WardrobeError as e:
raise _wardrobe_http(e) from e


@characters_router.post("/plugin/wardrobe/{player_uuid}/{character_id}/ack")
def plugin_ack_wardrobe(
player_uuid: str,
Expand Down
41 changes: 41 additions & 0 deletions backend/src/api/test_plugin_worn_skin_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,47 @@ def _active(self) -> str | None:
"SELECT wardrobe_active_slot FROM character_roster WHERE character_id = ?", (CHAR_ID,)
).fetchone()[0]

def slot_url(self, slot: str) -> str:
return URL.removesuffix("worn") + "slots/" + slot

def test_selected_outfit_is_read_without_equipping_or_acknowledging(self) -> None:
self.put_slot("base", "v-base", "s-base")
self.put_slot("extra_1", "v-extra", "s-extra")
self.put_slot("masked", "v-masked", "s-masked")
before = self.state()
with mock.patch.object(self.wardrobe, "swappable_slot_count", return_value=3):
res = self.get(self.slot_url("extra_1"))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json(), {
"character_id": CHAR_ID, "slot": "extra_1", "value": "v-extra", "signature": "s-extra",
})
self.assertEqual(self.state(), before)

def test_locked_or_masked_slots_are_refused_without_changing_data(self) -> None:
self.put_slot("extra_1", "v-extra", "s-extra")
before = self.state()
self.assertEqual(self.get(self.slot_url("extra_1")).status_code, 403)
for slot in ("masked", "unknown"):
self.assertEqual(self.get(self.slot_url(slot)).status_code, 400)
self.assertEqual(self.state(), before)

def test_missing_and_unsigned_outfits_do_not_borrow_another_skin(self) -> None:
self.put_slot("base", "v-base", "s-base")
self.put_slot("extra_1", "v-extra", None)
with mock.patch.object(self.wardrobe, "swappable_slot_count", return_value=3):
for slot in ("extra_1", "extra_2"):
res = self.get(self.slot_url(slot))
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json(), {
"character_id": CHAR_ID, "slot": slot, "value": None, "signature": None,
})

def test_selected_slot_requires_plugin_auth_and_matching_owner(self) -> None:
self.assertEqual(self.get(self.slot_url("base"), key=None).status_code, 401)
self.assertEqual(self.get(self.slot_url("base"), key="wrong").status_code, 401)
self.assertEqual(self.get(self.slot_url("base").replace(PLAYER, "other")).status_code, 403)
self.assertEqual(self.get(self.slot_url("base").replace(CHAR_ID, "unknown")).status_code, 403)


if __name__ == "__main__":
unittest.main()
19 changes: 19 additions & 0 deletions backend/src/characters/wardrobe.py
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,25 @@ def worn_skin_texture(player_uuid: str, character_id: str) -> dict[str, Any]:
return {"character_id": cid, "value": value, "signature": signature}


def wardrobe_slot_texture(player_uuid: str, character_id: str, slot: str) -> dict[str, Any]:
"""Read one unlocked outfit for a head without reconciling, equipping or acknowledging it."""
slot_key = _normalize_slot(slot)
if slot_key not in SWAPPABLE_SLOTS:
raise WardrobeError("Expected a wardrobe outfit", status_code=400)
roster = _require_owned_character(player_uuid, character_id)
uuid = str(roster["player_uuid"])
cid = str(roster["character_id"])
swappable = swappable_slot_count(uuid, realm_id=roster.get("realm_id"))
if not _slot_unlocked(slot_key, swappable):
raise WardrobeError("Wardrobe outfit is locked", status_code=403)
row = _load_slots(uuid, cid).get(slot_key) or {}
value = row.get("texture_value") or None
signature = row.get("texture_signature") or None
if not (row.get("png_relpath") and value and signature):
value = signature = None
return {"character_id": cid, "slot": slot_key, "value": value, "signature": signature}


def active_face_png(player_uuid: str, character_id: str, *, realm_id: str | None = None) -> bytes | None:
"""The face of the skin a roster character wears."""
worn = _active_slot(player_uuid, character_id, realm_id=realm_id)
Expand Down
Loading