From 4256245d640818fface30ed2dc5f741756712dd0 Mon Sep 17 00:00:00 2001 From: Steve C Date: Thu, 1 Aug 2024 01:24:28 -0400 Subject: [PATCH] add support for alt styles --- dti/client.py | 4 ++ dti/constants.py | 12 ++-- dti/http.py | 2 + dti/models.py | 155 ++++++++++++++++++++++++++++++++++++++---- dti/state.py | 43 +++++++++++- dti/types/__init__.py | 21 +++++- 6 files changed, 214 insertions(+), 23 deletions(-) diff --git a/dti/client.py b/dti/client.py index 619bfae..f6ad791 100644 --- a/dti/client.py +++ b/dti/client.py @@ -222,6 +222,7 @@ async def fetch_neopet( *, species: int | str | Species, color: int | str | Color, + alt_style_id: int | None = None, item_names: list[str] | None = None, item_ids: list[int] | None = None, size: LayerImageSize | None = None, @@ -238,6 +239,8 @@ async def fetch_neopet( The name, or ID, or Species object of the desired Species. Case-insensitive. color: Union[:class:`int`, :class:`str`, :class:`Color`] The name, or ID, or Color object of the desired Color. Case-insensitive. + alt_style_id: Optional[:class:`int`] + The ID of the alternative/nostalgic style you'd like to use. If one is not supplied, it defaults to `None`. item_names: Optional[List[:class:`str`]] A list of item names to search for + add to the items of the Neopet. item_ids: Optional[List[:class:`int`]] @@ -287,6 +290,7 @@ async def fetch_neopet( return await Neopet._fetch_assets_for( # type: ignore species=species, color=color, + alt_style_id=alt_style_id, item_names=item_names, item_ids=item_ids, size=size, diff --git a/dti/constants.py b/dti/constants.py index 46523ec..bba59d1 100644 --- a/dti/constants.py +++ b/dti/constants.py @@ -105,11 +105,11 @@ SEARCH_TO_FIT = ( """ -query($query: String!, $fitsPet: FitsPetSearchFilter!, $speciesId: ID!, $colorId: ID!, $itemKind: ItemKindSearchFilter, $offset: Int, $limit: Int, $size: LayerImageSize!) { +query($query: String!, $fitsPet: FitsPetSearchFilter!, $speciesId: ID!, $colorId: ID!, $altStyleId: ID, $itemKind: ItemKindSearchFilter, $offset: Int, $limit: Int, $size: LayerImageSize!) { itemSearch(query: $query, fitsPet: $fitsPet, itemKind: $itemKind, offset: $offset, limit: $limit) { items { ...ItemProperties - appearanceOn(speciesId: $speciesId, colorId: $colorId) { + appearanceOn(speciesId: $speciesId, colorId: $colorId, altStyleId: $altStyleId) { ...ItemAppearanceForOutfitPreview } } @@ -144,13 +144,13 @@ # grab pet appearances GRAB_PET_APPEARANCE_WITH_ITEMS_BY_IDS = ( """ -query ($allItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!, $size: LayerImageSize!, $pose: Pose!) { +query ($allItemIds: [ID!]!, $speciesId: ID!, $colorId: ID!, $altStyleId: ID, $size: LayerImageSize!, $pose: Pose!) { petAppearance(speciesId: $speciesId, colorId: $colorId, pose: $pose) { ...PetAppearanceForOutfitPreview } items(ids: $allItemIds) { ...ItemProperties - appearanceOn(speciesId: $speciesId, colorId: $colorId) { + appearanceOn(speciesId: $speciesId, colorId: $colorId, altStyleId: $altStyleId) { ...ItemAppearanceForOutfitPreview } } @@ -162,13 +162,13 @@ GRAB_PET_APPEARANCE_WITH_ITEMS_BY_NAMES = ( """ -query ($names: [String!]!, $speciesId: ID!, $colorId: ID!, $size: LayerImageSize!, $pose: Pose!) { +query ($names: [String!]!, $speciesId: ID!, $colorId: ID!, $altStyleId: ID, $size: LayerImageSize!, $pose: Pose!) { petAppearance(speciesId: $speciesId, colorId: $colorId, pose: $pose) { ...PetAppearanceForOutfitPreview } items: itemsByName(names: $names) { ...ItemProperties - appearanceOn(speciesId: $speciesId, colorId: $colorId) { + appearanceOn(speciesId: $speciesId, colorId: $colorId, altStyleId: $altStyleId) { ...ItemAppearanceForOutfitPreview } } diff --git a/dti/http.py b/dti/http.py index 29f1555..121f931 100644 --- a/dti/http.py +++ b/dti/http.py @@ -223,6 +223,7 @@ async def fetch_assets_for( *, species: Species, color: Color, + alt_style_id: int | None = None, pose: PetPose, item_ids: Sequence[ID] | None = None, item_names: Sequence[str] | None = None, @@ -235,6 +236,7 @@ async def fetch_assets_for( "colorId": color.id, "size": str(size), "pose": str(pose), + "altStyleId": alt_style_id, } if item_names: diff --git a/dti/models.py b/dti/models.py index ab96989..bed4dbf 100644 --- a/dti/models.py +++ b/dti/models.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, overload from urllib.parse import urlencode + from . import utils from .enums import ( AppearanceLayerKnownGlitch, @@ -28,8 +29,6 @@ import os from collections.abc import Sequence - from dti.types import FetchAssetsPayload, FetchedNeopetPayload - from .state import BitField, State from .types import ( ID, @@ -41,6 +40,10 @@ PetAppearancePayload, SpeciesPayload, ZonePayload, + FetchAssetsPayload, + FetchedNeopetPayload, + BodyPayload, + AltStylePayload, ) __all__: tuple[str, ...] = ( @@ -426,6 +429,7 @@ class PetAppearance(Object): "restricted_zones", "size", "species", + "alt_style_id", ) def __init__( @@ -434,21 +438,35 @@ def __init__( state: State, size: LayerImageSize, data: PetAppearancePayload, + alt_style_id: int | None = None, ) -> None: self._state = state self.id: int = int(data["id"]) self.body_id: int = int(data["bodyId"]) self.is_glitched: bool = data["isGlitched"] self.size: LayerImageSize = size + self.alt_style_id: int | None = alt_style_id # create new, somewhat temporary colors from this data since we don't have async access self.color: Color = Color(data=data["color"], state=state) self.species: Species = Species(data=data["species"], state=state) self.pose: PetPose = PetPose(data["pose"]) - self.layers: list[AppearanceLayer] = [ - AppearanceLayer(parent=self, data=layer) for layer in data["layers"] - ] + + alt_style = None + if self.alt_style_id is not None: + alt_style = state.get_alt_style_by_id(self.alt_style_id) + + if alt_style is None: + self.layers: list[AppearanceLayer] = [ + AppearanceLayer(parent=self, data=layer) for layer in data["layers"] + ] + else: + self.layers: list[AppearanceLayer] = [ + AppearanceLayer(parent=self, data=layer) + for layer in alt_style.layers_list + ] + self.restricted_zones: list[Zone] = [ Zone(restricted) for restricted in data["restrictedZones"] ] @@ -461,7 +479,12 @@ def has_glitches(self) -> bool: @property def url(self) -> str: """:class:`str`: The URL of this pet appearance as an editable outfit.""" - return f"https://impress.openneo.net/outfits/new?species={self.species.id}&color={self.color.id}&pose={self.pose.name}&state={self.id}" + url = f"https://impress.openneo.net/outfits/new?species={self.species.id}&color={self.color.id}&pose={self.pose.name}&state={self.id}" + + if self.alt_style_id: + url += f"&style={self.alt_style_id}" + + return url def __repr__(self) -> str: attrs: list[tuple[str, Any]] = [ @@ -851,6 +874,8 @@ class Neopet: A list of the items that will be applied to the pet. Can be empty. name: Optional[:class:`str`] The name of the Neopet, if one is supplied. + alt_style_id: Optional[:class:`int`] + The ID of the alternative style of the pet, if one is supplied. """ @@ -864,6 +889,7 @@ class Neopet: "pose", "size", "species", + "alt_style_id", ) def __init__( @@ -877,6 +903,7 @@ def __init__( items: Sequence[Item] | None = None, size: LayerImageSize | None = None, name: str | None = None, + alt_style_id: int | None = None, state: State, ) -> None: self._state: State = state @@ -888,6 +915,7 @@ def __init__( self.size: LayerImageSize = size or LayerImageSize.SIZE_600 self.pose: PetPose = pose self._valid_poses: BitField = valid_poses + self.alt_style_id: int | None = alt_style_id @classmethod async def _fetch_assets_for( @@ -895,6 +923,7 @@ async def _fetch_assets_for( *, species: Species, color: Color, + alt_style_id: int | None = None, pose: PetPose, item_ids: Sequence[ID] | None = None, item_names: Sequence[str] | None = None, @@ -911,9 +940,19 @@ async def _fetch_assets_for( size = size or LayerImageSize.SIZE_600 + if alt_style_id is None: + alt_style = state.get_alt_style_by_species_color_pose( + species_id=species.id, + color_id=color.id, + pose=pose, + ) + if alt_style: + alt_style_id = alt_style.id + data: FetchAssetsPayload = await state.http.fetch_assets_for( species=species, color=color, + alt_style_id=alt_style_id, pose=pose, item_ids=item_ids, item_names=item_names, @@ -924,7 +963,12 @@ async def _fetch_assets_for( Item(data=item, state=state) for item in data["items"] if item is not None ] - appearance = PetAppearance(data=data["petAppearance"], size=size, state=state) + appearance = PetAppearance( + data=data["petAppearance"], + size=size, + state=state, + alt_style_id=alt_style_id, + ) bit: BitField = await state._get_bit(species_id=species.id, color_id=color.id) # type: ignore @@ -937,6 +981,7 @@ async def _fetch_assets_for( appearance=appearance, name=name, size=size, + alt_style_id=alt_style_id, state=state, ) @@ -948,8 +993,7 @@ async def _from_appearance( /, *, item: Item | None = None, - ) -> Neopet: - ... + ) -> Neopet: ... @overload @classmethod @@ -959,8 +1003,7 @@ async def _from_appearance( /, *, items: Sequence[Item] | None = None, - ) -> Neopet: - ... + ) -> Neopet: ... @classmethod async def _from_appearance( @@ -1062,7 +1105,9 @@ def closet_url(self) -> str: objects, closet = _render_items(self.items) params["objects[]"] = [item.id for item in objects] params["closet[]"] = [item.id for item in closet] - return f"https://impress.openneo.net/outfits/new?{urlencode(params, doseq=True)}" + return ( + f"https://impress.openneo.net/outfits/new?{urlencode(params, doseq=True)}" + ) @property def worn_items(self) -> list[Item]: @@ -1157,7 +1202,12 @@ async def render( pose=pose, size=self.size, ) - pet_appearance = PetAppearance(data=data, size=self.size, state=self._state) + pet_appearance = PetAppearance( + data=data, + size=self.size, + state=self._state, + alt_style_id=self.alt_style_id, + ) await pet_appearance.render(fp, items=self.items, seek_begin=seek_begin) @@ -1321,7 +1371,7 @@ def legacy_url(self) -> str: @property def url(self) -> str: """:class:`str`: Returns the outfit URL for the ID provided. - + Since the 2020 site is soon to be deprecated, this will redirect to the legacy URL. """ return self.legacy_url @@ -1437,7 +1487,82 @@ def __repr__(self) -> str: return f"" -# utility functions below +class AltStyle(Object): + """Represents an Alternative Style for a Neopet. + + + Attributes + ---------- + id: :class:`int` + The alternative style's ID. + species_id: :class:`int` + The alternative style's species ID. + body_id: :class:`int` + The alternative style's body ID. + color_id: :class:`int` + The alternative style's color ID. + series_name: :class:`str` + The alternative style's series name. `Nostalgic` for example. + thumbnail_url: :class:`str` + The alternative style's thumbnail URL. + adjective_name: :class:`str` + The alternative style's adjective name. `Nostalgic Robot` for example. + swf_assets: List[:class:`dict`] + The alternative style's SWF assets. + """ + + __slots__ = ( + "id", + "body_id", + "species_id", + "color_id", + "series_name", + "thumbnail_url", + "adjective_name", + "swf_assets", + ) + + def __init__(self, data: AltStylePayload): + self.id = data["id"] + self.species_id = data["species_id"] + self.body_id = data["body_id"] + self.color_id = data["color_id"] + self.series_name = data["series_name"] + self.thumbnail_url = data["thumbnail_url"] + self.adjective_name = data["adjective_name"] + self.swf_assets = data["swf_assets"] + + @property + def layers_list(self) -> list[AppearanceLayerPayload]: + layers = [] + for asset in self.swf_assets: + layers.append( + { + "id": self.id, + "zone": asset["zone"], + "remoteId": asset["id"], + "bodyId": asset["body_id"], + "imageUrlV2": asset["urls"]["png"], # type: ignore + "knownGlitches": asset["known_glitches"], + } + ) + return layers + + def __repr__(self) -> str: + attrs: list[tuple[str, Any]] = [ + ("id", self.id), + ("species_id", self.species_id), + ("body_id", self.body_id), + ("color_id", self.color_id), + ("series_name", self.series_name), + ("adjective_name", self.adjective_name), + ("thumbnail_url", self.thumbnail_url), + ] + joined = " ".join(starmap("{}={!r}".format, attrs)) + return f"" + + +# MARK: Utility Functions def _render_items(items: Sequence[Item]) -> tuple[list[Item], list[Item]]: diff --git a/dti/state.py b/dti/state.py index d8cad8d..eeb72fb 100644 --- a/dti/state.py +++ b/dti/state.py @@ -2,9 +2,11 @@ import asyncio import contextlib +import json import time from typing import TYPE_CHECKING, TypeVar + from .constants import ALL_SPECIES_AND_COLORS from .errors import InvalidPairBytes from .http import HTTPClient @@ -13,7 +15,7 @@ if TYPE_CHECKING: from .enums import PetPose - from .models import Color, Species + from .models import Color, Species, AltStyle T = TypeVar("T", Color, Species) else: @@ -169,6 +171,7 @@ class State: "_species", "_update_lock", "_valid_pairs", + "_alt_styles", "http", ) @@ -181,6 +184,7 @@ def __init__( # alternatively you can list them out by doing self._colors.values() self._colors: dict[str | int, Color] = _NameDict() self._species: dict[str | int, Species] = _NameDict() + self._alt_styles: dict[int, AltStyle] = {} self._cached: bool = False self._last_update: float = 0.0 @@ -222,6 +226,40 @@ async def _fetch_species_and_color(self) -> None: }, ) + async def _fetch_alt_styles(self) -> None: + data = await self.http._fetch_binary_data( + "https://impress.openneo.net/alt-styles.json" + ) + from .models import AltStyle + + json_data = json.loads(data) + + self._alt_styles = {alt["id"]: AltStyle(data=alt) for alt in json_data} + + def get_alt_style_by_id(self, alt_style_id: int) -> AltStyle | None: + return self._alt_styles.get(alt_style_id) + + def get_alt_style_by_species_color_pose( + self, + species_id: int, + color_id: int, + pose: PetPose, + ) -> AltStyle | None: + from .enums import PetPose + + mapping = { + # this is a mapping of the series name to the pose + # for example, unconverted seems to be strictly "Nostalgic" + # who knows what more series there will be! + PetPose.UNCONVERTED: "Nostalgic", + } + + for alt in self._alt_styles.values(): + if alt.species_id == species_id and alt.color_id == color_id: + if mapping.get(pose) == alt.series_name: + return alt + return None + @property def is_cached(self) -> bool: return self._cached @@ -278,6 +316,9 @@ async def _update(self: S, force: bool = False) -> S: self._species.clear() await self._fetch_species_and_color() + self._alt_styles.clear() + await self._fetch_alt_styles() + self._cached = True self._last_update = time.monotonic() return self diff --git a/dti/types/__init__.py b/dti/types/__init__.py index 2a75d73..f9ae7dc 100644 --- a/dti/types/__init__.py +++ b/dti/types/__init__.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal, TypedDict, TypeVar +from typing import Any, Literal, TypedDict, TypeVar PetPoseType = Literal[ "HAPPY_MASC", @@ -116,4 +116,23 @@ class FetchAllAppearancesPayload(TypedDict): color: ColorAppliedToAllCompatibleSpeciesPayload +class SWFAsset(TypedDict): + body_id: int + id: int + urls: list[dict[str, Any]] + zone: ZonePayload + known_glitches: list[Any] + + +class AltStylePayload(TypedDict): + id: int + species_id: int + color_id: int + body_id: int + series_name: str + thumbnail_url: str + adjective_name: str + swf_assets: list[SWFAsset] + + ID = TypeVar("ID", bound=str | int)