import json
from pathlib import Path
from attr import attrib, dataclass
from gd.logging import get_logger
from gd.typing import (
Any,
Client,
Comment,
Dict,
Level,
LevelRecord,
List,
Optional,
Tuple,
Type,
Union,
)
from gd.abstractentity import AbstractEntity
from gd.abstractuser import AbstractUser
from gd.song import Song
from gd.errors import MissingAccess, NothingFound
from gd.api.editor import Editor
from gd.utils.converter import Converter
from gd.utils.enums import (
DemonDifficulty,
LevelDifficulty,
CommentStrategy,
LevelLength,
TimelyType,
LevelLeaderboardStrategy,
)
from gd.utils.indexer import Index
from gd.utils.parser import ExtDict
from gd.utils.search_utils import get
from gd.utils.text_tools import make_repr, object_split
from gd.utils.crypto.coders import Coder
official_levels_path = Path(__file__).parent / "official_levels.json"
if official_levels_path.exists():
official_levels_data = json.loads(official_levels_path.read_text())
else:
official_levels_data = {}
log = get_logger(__name__)
def excluding(*args: Tuple[Type[BaseException]]) -> Tuple[Type[BaseException]]:
return args
DEFAULT_EXCLUDE: Tuple[Type[BaseException]] = excluding(NothingFound)
ZERO_STR = "0"
[docs]class Level(AbstractEntity):
"""Class that represents a Geometry Dash Level.
This class is derived from :class:`.AbstractEntity`.
"""
def __repr__(self) -> str:
info = {
"id": self.id,
"name": repr(self.name),
"creator": self.creator,
"version": self.version,
"difficulty": self.difficulty,
}
return make_repr(self, info)
def __str__(self) -> str:
return str(self.name)
def __json__(self) -> Dict[str, Any]:
return dict(
super().__json__(),
featured=self.is_featured(),
was_unfeatured=self.was_unfeatured(),
objects=self.objects,
)
[docs] @classmethod
def official(
cls,
id: Optional[int] = None,
name: Optional[str] = None,
index: Optional[int] = None,
client: Optional[Client] = None,
get_data: bool = True,
server_style: bool = False,
) -> Level:
"""Get official level to work with.
Lookup is done in the following form: ``id -> name -> index``.
Parameters
----------
id: Optional[:class:`int`]
ID of the official level.
name: Optional[:class:`str`]
Name of the official level.
index: Optional[:class:`int`]
Index (position) of the official level.
client: Optional[:class:`.Client`]
Client to attach to the level.
get_data: :class:`bool`
Whether to attach data to the level. Default is ``True``.
server_style: :class:`bool`
Indicates if server-style of official song ID should be used.
Set this to ``True`` in case of uploading level to the server. Defaults to ``False``.
Raises
------
:exc:`ValueError`
No queries were given.
:exc:`LookupError`
Level was not found.
Returns
-------
:class:`.Level`
Official level that was found.
"""
if id is not None:
official_level = get(official_levels, level_id=id)
elif name is not None:
official_level = get(official_levels, name=name)
elif index is not None:
try:
official_level = official_levels[index]
except (IndexError, TypeError):
official_level = None
else:
raise ValueError("Expected either of queries: level_id, name or index.")
if official_level is None:
raise LookupError("Could not find official level by given query.")
return official_level.into_level(client, get_data=get_data, server_style=server_style)
@classmethod
def from_data(
cls,
data: ExtDict,
creator: Union[ExtDict, AbstractUser],
song: Union[ExtDict, Song],
client: Client,
) -> Level:
if isinstance(creator, ExtDict):
creator = AbstractUser(**creator, client=client)
if isinstance(song, ExtDict):
if any(key.isdigit() for key in song.keys()):
song = Song.from_data(song, client=client)
else:
song = Song(**song, client=client)
string = data.get(Index.LEVEL_PASS)
if string is None:
copyable, password = False, None
else:
if string == ZERO_STR:
copyable, password = False, None
else:
try:
# decode password
password = Coder.decode(type="levelpass", string=string)
except Exception:
# failed to get password
copyable, password = False, None
else:
copyable = True
if not password:
password = None
else:
# password is in format 1XXXXXX
password = password[1:]
password = int(password) if password.isdigit() else None
desc = Coder.do_base64(
data.get(Index.LEVEL_DESCRIPTION, ""), encode=False, errors="replace"
)
level_data = data.get(Index.LEVEL_DATA, "")
try:
level_data = Coder.unzip(level_data)
except Exception: # conversion failed
pass
diff = data.getcast(Index.LEVEL_DIFFICULTY, 0, int)
demon_diff = data.getcast(Index.LEVEL_DEMON_DIFFICULTY, 0, int)
is_demon = bool(data.getcast(Index.LEVEL_IS_DEMON, 0, int))
is_auto = bool(data.getcast(Index.LEVEL_IS_AUTO, 0, int))
difficulty = Converter.convert_level_difficulty(
diff=diff, demon_diff=demon_diff, is_demon=is_demon, is_auto=is_auto
)
return cls(
id=data.getcast(Index.LEVEL_ID, 0, int),
name=data.get(Index.LEVEL_NAME, "unknown"),
description=desc,
version=data.getcast(Index.LEVEL_VERSION, 0, int),
creator=creator,
song=song,
data=level_data,
password=password,
copyable=copyable,
is_demon=is_demon,
is_auto=is_auto,
low_detail_mode=bool(data.get(Index.LEVEL_HAS_LDM)),
difficulty=difficulty,
stars=data.getcast(Index.LEVEL_STARS, 0, int),
coins=data.getcast(Index.LEVEL_COIN_COUNT, 0, int),
verified_coins=bool(data.getcast(Index.LEVEL_COIN_VERIFIED, 0, int)),
is_epic=bool(data.getcast(Index.LEVEL_IS_EPIC, 0, int)),
original=data.getcast(Index.LEVEL_ORIGINAL, 0, int),
downloads=data.getcast(Index.LEVEL_DOWNLOADS, 0, int),
rating=data.getcast(Index.LEVEL_LIKES, 0, int),
score=data.getcast(Index.LEVEL_FEATURED_SCORE, 0, int),
uploaded_timestamp=data.get(Index.LEVEL_UPLOADED_TIMESTAMP, "unknown"),
last_updated_timestamp=data.get(Index.LEVEL_LAST_UPDATED_TIMESTAMP, "unknown"),
length=LevelLength.from_value(data.getcast(Index.LEVEL_LENGTH, 0, int)),
game_version=data.getcast(Index.LEVEL_GAME_VERSION, 0, int),
stars_requested=data.getcast(Index.LEVEL_REQUESTED_STARS, 0, int),
object_count=data.getcast(Index.LEVEL_OBJECT_COUNT, 0, int),
type=TimelyType.from_value(data.getcast(Index.LEVEL_TIMELY_TYPE, 0, int), 0),
time_n=data.getcast(Index.LEVEL_TIMELY_INDEX, -1, int),
cooldown=data.getcast(Index.LEVEL_TIMELY_COOLDOWN, -1, int),
client=client,
)
@property
def name(self) -> str:
""":class:`str`: The name of the level."""
return self.options.get("name", "Unnamed")
@property
def description(self) -> str:
""":class:`str`: Description of the level."""
return self.options.get("description", "")
@property
def version(self) -> int:
""":class:`int`: Version of the level."""
return self.options.get("version", 0)
@property
def downloads(self) -> int:
""":class:`int`: Amount of the level's downloads."""
return self.options.get("downloads", 0)
@property
def rating(self) -> int:
""":class:`int`: Amount of the level's likes or dislikes."""
return self.options.get("rating", 0)
@property
def score(self) -> int:
""":class:`int`: Level's featured score."""
return self.options.get("score", 0)
@property
def creator(self) -> AbstractUser:
""":class:`.AbstractUser`: Creator of the level."""
return self.options.get("creator", AbstractUser(client=self.options.get("client")))
@property
def song(self) -> Song:
""":class:`.Song`: Song used in the level."""
return self.options.get("song", Song(client=self.options.get("client")))
@property
def difficulty(self) -> Union[DemonDifficulty, LevelDifficulty]:
"""Union[:class:`.LevelDifficulty`, :class:`.DemonDifficulty`]: Difficulty of the level."""
difficulty = self.options.get("difficulty", -1)
if self.is_demon():
return DemonDifficulty.from_value(difficulty)
else:
return LevelDifficulty.from_value(difficulty)
@property
def password(self) -> Optional[int]:
"""Optional[:class:`int`]: The password to copy the level.
See :meth:`.Level.is_copyable`.
"""
return self.options.get("password")
[docs] def is_copyable(self) -> bool:
""":class:`bool`: Indicates whether a level is copyable."""
return bool(self.options.get("copyable"))
@property
def stars(self) -> int:
""":class:`int`: Amount of stars the level has."""
return self.options.get("stars", 0)
@property
def coins(self) -> int:
""":class:`int`: Amount of coins in the level."""
return self.options.get("coins", 0)
@property
def original_id(self) -> int:
""":class:`int`: ID of the original level. (``0`` if is not a copy)"""
return self.options.get("original", 0)
@property
def uploaded_timestamp(self) -> str:
""":class:`str`: A human-readable string representing how much time ago level was uploaded."""
return self.options.get("uploaded_timestamp", "unknown")
@property
def last_updated_timestamp(self) -> str:
""":class:`str`: A human-readable string showing how much time ago the last update was."""
return self.options.get("last_updated_timestamp", "unknown")
@property
def length(self) -> LevelLength:
""":class:`.LevelLength`: A type that represents length of the level."""
return LevelLength.from_value(self.options.get("length", -1))
@property
def game_version(self) -> int:
""":class:`int`: A version of the game required to play the level."""
return self.options.get("game_version", 0)
@property
def requested_stars(self) -> int:
""":class:`int`: Amount of stars creator of the level has requested."""
return self.options.get("stars_requested", 0)
@property
def objects(self) -> int:
""":class:`int`: Amount of objects the level has in data."""
return len(object_split(self.data))
@property
def object_count(self) -> int:
""":class:`int`: Amount of objects the level according to the servers."""
return self.options.get("object_count", 0)
@property
def type(self) -> TimelyType:
""":class:`.TimelyType`: A type that shows whether a level is Daily/Weekly."""
return TimelyType.from_value(self.options.get("type", 0))
@property
def timely_index(self) -> int:
""":class:`int`: A number that represents current index of the timely.
Increments on new dailies/weeklies. If not timely, equals ``-1``.
"""
return self.options.get("time_n", -1)
@property
def cooldown(self) -> int:
""":class:`int`: Represents a cooldown until next timely. If not timely, equals ``-1``."""
return self.options.get("cooldown", -1)
@property
def data(self) -> Union[bytes, str]:
"""Union[:class:`str`, :class:`bytes`]: Level data, represented as a stream."""
return self.options.get("data", "")
@data.setter
def data(self, value: Union[bytes, str]) -> None:
"""Set ``self.data`` to ``value``."""
self.options.update(data=value)
[docs] def is_timely(self, daily_or_weekly: Optional[str] = None) -> bool:
""":class:`bool`: Indicates whether a level is timely/daily/weekly.
For instance, let's suppose a *level* is daily. Then, the behavior of this method is:
``level.is_timely() -> True`` and ``level.is_timely('daily') -> True`` but
``level.is_timely('weekly') -> False``."""
if self.type is None: # pragma: no cover
return False
if daily_or_weekly is None:
return self.type.value > 0
assert daily_or_weekly in ("daily", "weekly")
return self.type.name.lower() == daily_or_weekly
[docs] def is_rated(self) -> bool:
""":class:`bool`: Indicates if a level is rated (has stars)."""
return self.stars > 0
[docs] def was_unfeatured(self) -> bool:
""":class:`bool`: Indicates if a level was featured, but got unfeatured."""
return self.score < 0
[docs] def is_featured(self) -> bool:
""":class:`bool`: Indicates whether a level is featured."""
return self.score > 0 # not sure if this is the right way though
[docs] def is_epic(self) -> bool:
""":class:`bool`: Indicates whether a level is epic."""
return bool(self.options.get("is_epic"))
[docs] def is_demon(self) -> bool:
""":class:`bool`: Indicates whether a level is demon."""
return bool(self.options.get("is_demon"))
[docs] def is_auto(self) -> bool:
""":class:`bool`: Indicates whether a level is auto."""
return bool(self.options.get("is_auto"))
[docs] def is_original(self) -> bool:
""":class:`bool`: Indicates whether a level is original."""
return not self.original_id
[docs] def has_coins_verified(self) -> bool:
""":class:`bool`: Indicates whether level's coins are verified."""
return bool(self.options.get("verified_coins"))
[docs] def download(self) -> Union[bytes, str]:
"""Union[:class:`str`, :class:`bytes`]: Returns level data, represented as string."""
return self.data
def has_ldm(self) -> bool:
return bool(self.options.get("low_detail_mode"))
def open_editor(self) -> Editor:
return Editor.launch(self, "data")
[docs] async def report(self) -> None:
"""|coro|
Reports a level.
Raises
------
:exc:`.MissingAccess`
Failed to report a level.
"""
await self.client.report_level(self)
[docs] async def upload(self, **kwargs) -> None:
r"""|coro|
Upload ``self``.
Parameters
----------
\*\*kwargs
Arguments that :meth:`.Client.upload_level` accepts.
Defaults are properties of the level.
"""
track, song_id = (self.song.id, 0)
if self.song.is_custom():
track, song_id = song_id, track
try:
client = self.client
except Exception:
client = kwargs.pop("from_client", None)
if client is None:
raise MissingAccess(
"Could not find the client to upload level from. "
"Either attach a client to this level or provide <from_client> parameter."
) from None
password = kwargs.pop("password", self.password)
args = dict(
name=self.name,
id=self.id,
version=self.version,
length=abs(self.length.value),
track=track,
song_id=song_id,
two_player=False,
is_auto=self.is_auto(),
original=self.original_id,
objects=self.objects,
coins=self.coins,
star_amount=self.stars,
unlisted=False,
friends_only=False,
ldm=False,
password=password,
copyable=self.is_copyable(),
description=self.description,
data=self.data,
)
args.update(kwargs)
uploaded = await client.upload_level(**args)
self.options = uploaded.options
[docs] async def delete(self) -> None:
"""|coro|
Deletes a level.
Raises
------
:exc:`.MissingAccess`
Failed to delete a level.
"""
await self.client.delete_level(self)
[docs] async def update_description(self, content: Optional[str] = None) -> None:
"""|coro|
Updates level description.
Parameters
----------
content: :class:`str`
Content of the new description. If ``None`` or omitted, nothing is run.
Raises
------
:exc:`.MissingAccess`
Failed to update level's description.
"""
if content is None:
return
await self.client.update_level_description(self, content)
[docs] async def rate(self, stars: int = 1) -> None:
"""|coro|
Sends level rating.
Parameters
----------
stars: :class:`int`
Amount of stars to rate with.
Raises
------
:exc:`.MissingAccess`
Failed to rate a level.
"""
await self.client.rate_level(self, stars)
[docs] async def rate_demon(
self, demon_difficulty: Union[int, str, DemonDifficulty] = 1, as_mod: bool = False
) -> None:
"""|coro|
Sends level demon rating.
Parameters
----------
demon_difficulty: Union[:class:`int`, :class:`str`, :class:`.DemonDifficulty`]
Demon difficulty to rate a level with.
as_mod: :class:`bool`
Whether to send a demon rating as moderator.
Raises
------
:exc:`.MissingAccess`
If attempted to rate a level as moderator without required permissions.
"""
await self.client.rate_demon(self, demon_difficulty=demon_difficulty, as_mod=as_mod)
[docs] async def send(self, stars: int = 1, featured: bool = True) -> None:
"""|coro|
Sends a level to Geometry Dash Developer and Administrator, *RobTop*.
Parameters
----------
stars: :class:`int`
Amount of stars to send with.
featured: :class:`bool`
Whether to send to feature, or to simply rate.
Raises
------
:exc:`.MissingAccess`
Missing required moderator permissions.
"""
await self.client.send_level(self, stars=stars, featured=featured)
[docs] async def is_alive(self) -> bool:
"""|coro|
Checks if a level is still on Geometry Dash servers.
Returns
-------
:class:`bool`
``True`` if a level is still *alive*, and ``False`` otherwise.
Also ``False`` if a client is not attached to the level.s
"""
try:
await self.client.search_levels_on_page(query=str(self.id))
except MissingAccess:
return False
return True
[docs] async def refresh(self) -> Optional[Level]:
"""|coro|
Refreshes a level. Returns ``None`` on fail.
.. note::
This function actually refreshes a level and its stats.
No need to do funky stuff with its return.
Returns
-------
:class:`.Level`
A newly fetched version. ``None`` if failed to fetch.
"""
try:
if self.is_timely():
async_func = getattr(self.client, "get_" + self.type.name.lower())
new_ver = await async_func()
if new_ver.id != self.id:
log.warning(
f"There is a new {self.type.title} Level: {new_ver!r}. Updating to it..."
)
else:
new_ver = await self.client.get_level(self.id)
except MissingAccess:
return log.warning("Failed to refresh level: %r. Most likely it was deleted.", self)
self.options = new_ver.options
return self
[docs] async def like(self) -> None:
"""|coro|
Likes a level.
Raises
------
:exc:`.MissingAccess`
Failed to like a level.
"""
await self.client.like(self)
[docs] async def dislike(self) -> None:
"""|coro|
Dislikes a level.
Raises
------
:exc:`.MissingAccess`
Failed to dislike a level.
"""
await self.client.dislike(self)
[docs] async def get_leaderboard(
self, strategy: Union[int, str, LevelLeaderboardStrategy] = 0
) -> List[LevelRecord]:
"""|coro|
Retrieves the leaderboard of a level.
Parameters
----------
strategy: Union[:class:`int`, :class:`str`, :class:`.LevelLeaderboardStrategy`]
A strategy to apply.
Returns
-------
List[:class:`.LevelRecord`]
A list of user-like objects.
"""
return await self.client.get_level_leaderboard(self, strategy=strategy)
@dataclass
class OfficialLevel:
level_id: int = attrib(kw_only=True)
song_id: int = attrib(kw_only=True)
name: str = attrib(kw_only=True)
stars: int = attrib(kw_only=True)
difficulty: str = attrib(kw_only=True)
coins: int = attrib(kw_only=True)
length: str = attrib(kw_only=True)
def is_auto(self) -> bool:
return "auto" in self.difficulty
def is_demon(self) -> bool:
return "demon" in self.difficulty
def get_song_id(self, server_style: bool = False) -> int:
return self.song_id - 1 if server_style else self.song_id # assume non-server by default
def into_level(
self, client: Optional[Client] = None, get_data: bool = True, server_style: bool = False,
) -> Level:
if self.is_demon():
difficulty = DemonDifficulty.from_name(self.difficulty)
else:
difficulty = LevelDifficulty.from_name(self.difficulty)
if get_data:
data = official_levels_data.get(self.name, "")
if data:
data = Coder.unzip(data)
else:
data = ""
return Level(
id=self.level_id,
name=self.name,
description=f"Official Level: {self.name}",
version=1,
creator=AbstractUser(name="RobTop", id=16, account_id=71, client=client),
song=Song.official(
self.get_song_id(server_style), client=client, server_style=server_style
),
data=data,
password=None,
copyable=False,
is_demon=self.is_demon(),
is_auto=self.is_auto(),
difficulty=difficulty,
stars=self.stars,
coins=self.coins,
verified_coins=True,
is_epic=False,
original=True,
low_detail_mode=False,
downloads=0,
rating=0,
score=1,
uploaded_timestamp="unknown",
last_updated_timestamp="unknown",
length=LevelLength.from_name(self.length),
stars_requested=self.stars,
object_count=0,
type=TimelyType.from_value(0),
time_n=-1,
cooldown=-1,
client=client,
)
official_levels = [
OfficialLevel(
level_id=1,
song_id=1,
name="Stereo Madness",
stars=1,
difficulty="easy",
coins=3,
length="long",
),
OfficialLevel(
level_id=2,
song_id=2,
name="Back On Track",
stars=2,
difficulty="easy",
coins=3,
length="long",
),
OfficialLevel(
level_id=3,
song_id=3,
name="Polargeist",
stars=3,
difficulty="normal",
coins=3,
length="long",
),
OfficialLevel(
level_id=4, song_id=4, name="Dry Out", stars=4, difficulty="normal", coins=3, length="long",
),
OfficialLevel(
level_id=5,
song_id=5,
name="Base After Base",
stars=5,
difficulty="hard",
coins=3,
length="long",
),
OfficialLevel(
level_id=6,
song_id=6,
name="Cant Let Go",
stars=6,
difficulty="hard",
coins=3,
length="long",
),
OfficialLevel(
level_id=7, song_id=7, name="Jumper", stars=7, difficulty="harder", coins=3, length="long",
),
OfficialLevel(
level_id=8,
song_id=8,
name="Time Machine",
stars=8,
difficulty="harder",
coins=3,
length="long",
),
OfficialLevel(
level_id=9, song_id=9, name="Cycles", stars=9, difficulty="harder", coins=3, length="long",
),
OfficialLevel(
level_id=10,
song_id=10,
name="xStep",
stars=10,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=11,
song_id=11,
name="Clutterfunk",
stars=11,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=12,
song_id=12,
name="Theory of Everything",
stars=12,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=13,
song_id=13,
name="Electroman Adventures",
stars=10,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=14,
song_id=14,
name="Clubstep",
stars=14,
difficulty="easy_demon",
coins=3,
length="long",
),
OfficialLevel(
level_id=15,
song_id=15,
name="Electrodynamix",
stars=12,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=16,
song_id=16,
name="Hexagon Force",
stars=12,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=17,
song_id=17,
name="Blast Processing",
stars=10,
difficulty="harder",
coins=3,
length="long",
),
OfficialLevel(
level_id=18,
song_id=18,
name="Theory of Everything 2",
stars=14,
difficulty="easy_demon",
coins=3,
length="long",
),
OfficialLevel(
level_id=19,
song_id=19,
name="Geometrical Dominator",
stars=10,
difficulty="harder",
coins=3,
length="long",
),
OfficialLevel(
level_id=20,
song_id=20,
name="Deadlocked",
stars=15,
difficulty="medium_demon",
coins=3,
length="long",
),
OfficialLevel(
level_id=21,
song_id=21,
name="Fingerdash",
stars=12,
difficulty="insane",
coins=3,
length="long",
),
OfficialLevel(
level_id=1001,
song_id=22,
name="The Seven Seas",
stars=1,
difficulty="easy",
coins=3,
length="long",
),
OfficialLevel(
level_id=1002,
song_id=23,
name="Viking Arena",
stars=2,
difficulty="normal",
coins=3,
length="long",
),
OfficialLevel(
level_id=1003,
song_id=24,
name="Airborne Robots",
stars=3,
difficulty="hard",
coins=3,
length="long",
),
OfficialLevel(
level_id=2001,
song_id=26,
name="Payload",
stars=2,
difficulty="easy",
coins=0,
length="short",
),
OfficialLevel(
level_id=2002,
song_id=27,
name="Beast Mode",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2003,
song_id=28,
name="Machina",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2004,
song_id=29,
name="Years",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2005,
song_id=30,
name="Frontlines",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2006,
song_id=31,
name="Space Pirates",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2007,
song_id=32,
name="Striker",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2008,
song_id=33,
name="Embers",
stars=3,
difficulty="normal",
coins=0,
length="short",
),
OfficialLevel(
level_id=2009,
song_id=34,
name="Round 1",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=2010,
song_id=35,
name="Monster Dance Off",
stars=3,
difficulty="normal",
coins=0,
length="medium",
),
OfficialLevel(
level_id=3001,
song_id=25,
name="The Challenge",
stars=3,
difficulty="hard",
coins=0,
length="short",
),
OfficialLevel(
level_id=4001,
song_id=36,
name="Press Start",
stars=4,
difficulty="normal",
coins=3,
length="long",
),
OfficialLevel(
level_id=4002,
song_id=37,
name="Nock Em",
stars=6,
difficulty="hard",
coins=3,
length="long",
),
OfficialLevel(
level_id=4003,
song_id=38,
name="Power Trip",
stars=8,
difficulty="harder",
coins=3,
length="long",
),
]