From cacf29271b8e68a71a0cf103c4136b333761010e Mon Sep 17 00:00:00 2001 From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:25:27 +0000 Subject: [PATCH] feat: expose read-only outfit textures for plushies --- backend/src/api/characters_routes.py | 16 ++++++++ .../src/api/test_plugin_worn_skin_route.py | 41 +++++++++++++++++++ backend/src/characters/wardrobe.py | 19 +++++++++ 3 files changed, 76 insertions(+) diff --git a/backend/src/api/characters_routes.py b/backend/src/api/characters_routes.py index 01f77893..37fb9499 100644 --- a/backend/src/api/characters_routes.py +++ b/backend/src/api/characters_routes.py @@ -72,6 +72,7 @@ store_masked_template, upload_pending_create_wardrobe, upload_slot, + wardrobe_slot_texture, worn_skin_texture, ) from src.skins.auth import ( @@ -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, diff --git a/backend/src/api/test_plugin_worn_skin_route.py b/backend/src/api/test_plugin_worn_skin_route.py index 4ce45ede..0e67b488 100644 --- a/backend/src/api/test_plugin_worn_skin_route.py +++ b/backend/src/api/test_plugin_worn_skin_route.py @@ -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() diff --git a/backend/src/characters/wardrobe.py b/backend/src/characters/wardrobe.py index e1d9b118..29aa2aeb 100644 --- a/backend/src/characters/wardrobe.py +++ b/backend/src/characters/wardrobe.py @@ -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)