import json
from pathlib import Path
from attr import attrib, dataclass
from iters import iter
from gd.abstract_entity import AbstractEntity
from gd.api.editor import Editor
from gd.async_iters import awaitable_iterator
from gd.converters import GameVersion
from gd.crypto import unzip_level_str, zip_level_str
from gd.datetime import datetime, timedelta
from gd.decorators import cache_by
from gd.enums import (
CommentStrategy,
DemonDifficulty,
LevelDifficulty,
LevelLeaderboardStrategy,
LevelLength,
TimelyType,
)
from gd.errors import MissingAccess
from gd.logging import get_logger
from gd.model import LevelModel # type: ignore
from gd.song import Song
from gd.text_utils import is_level_probably_decoded, make_repr, object_count
from gd.typing import TYPE_CHECKING, Any, AsyncIterator, Dict, Iterable, Optional, Type, Union, cast
from gd.user import User
if TYPE_CHECKING:
from gd.client import Client # noqa
from gd.comment import Comment # noqa
__all__ = ("Level", "official_levels", "official_levels_data")
CONCURRENT = True
COMMENT_PAGE_SIZE = 20
ZERO_PAGE = (0,)
official_levels_path = Path(__file__).parent / "official_levels.json"
official_levels_data: Dict[str, str] = {}
log = get_logger(__name__)
[docs]class Level(AbstractEntity):
"""Class that represents a Geometry Dash Level.
This class is derived from :class:`~gd.AbstractEntity`.
.. container:: operations
.. describe:: x == y
Check if two objects are equal. Compared by hash and type.
.. describe:: x != y
Check if two objects are not equal.
.. describe:: str(x)
Return name of the level.
.. describe:: repr(x)
Return representation of the level, useful for debugging.
.. describe:: hash(x)
Returns ``hash(self.hash_str)``.
"""
def __init__(self, *, client: Optional["Client"] = None, **options) -> None:
options.setdefault("unprocessed_data", zip_level_str(options.get("data", "")))
super().__init__(client=client, **options)
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)
[docs] def to_dict(self) -> Dict[str, Any]:
result = super().to_dict()
result.update(
editor_time=self.editor_time,
copies_time=self.copies_time,
featured=self.is_featured(),
was_unfeatured=self.was_unfeatured(),
)
return result
[docs] @classmethod
def from_model( # type: ignore
cls,
model: LevelModel,
*,
client: Optional["Client"] = None,
creator: Optional[User] = None,
song: Optional[Song] = None,
type: Optional[TimelyType] = None,
timely_id: int = 0,
cooldown: int = -1,
) -> "Level":
return cls(
id=model.id,
name=model.name,
description=model.description,
unprocessed_data=model.unprocessed_data,
version=model.version,
creator=(creator if creator else User()).attach_client(client),
song=(song if song else Song()).attach_client(client),
downloads=model.downloads,
game_version=model.game_version,
rating=model.rating,
length=model.length,
demon=model.demon,
stars=model.stars,
score=model.score,
auto=model.auto,
password=model.password,
copyable=model.copyable,
difficulty=model.difficulty,
uploaded_at=model.uploaded_at,
updated_at=model.updated_at,
original_id=model.original_id,
two_player=model.two_player,
extra_string=model.extra_string,
coins=model.coins,
verified_coins=model.verified_coins,
requested_stars=model.requested_stars,
low_detail_mode=model.low_detail_mode,
epic=model.epic,
object_count=model.object_count,
editor_seconds=model.editor_seconds,
copies_seconds=model.copies_seconds,
timely_id=(timely_id if timely_id > 0 else model.timely_id),
type=(model.type if type is None else type),
cooldown=cooldown,
client=client,
)
[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:`~gd.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:`~gd.Level`
Official level that was found.
"""
if id is not None:
official_level = iter(official_levels).get(level_id=id)
elif name is not None:
official_level = iter(official_levels).get(name=name)
elif index is not None:
try:
official_level = official_levels[index]
except (IndexError, ValueError, 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 cast(OfficialLevel, official_level).into_level(
client, get_data=get_data, server_style=server_style
)
@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) -> User:
""":class:`~gd.User`: Creator of the level."""
return self.options.get("creator", User(client=self.client_unchecked))
@property
def song(self) -> Song:
""":class:`~gd.Song`: Song used in the level."""
return self.options.get("song", Song(client=self.client_unchecked))
@property
def difficulty(self) -> Union[DemonDifficulty, LevelDifficulty]:
"""Union[:class:`~gd.LevelDifficulty`, :class:`~gd.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:`~gd.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_id", 0)
@property
def uploaded_at(self) -> Optional[datetime]:
"""Optional[:class:`~datetime.datetime`]:
Timestamp representing when the level was uploaded.
"""
return self.options.get("uploaded_at")
@property
def updated_at(self) -> Optional[datetime]:
"""Optional[:class:`~datetime.datetime`]:
Timestamp representing when the level was last updated.
"""
return self.options.get("updated_at")
last_updated_at = updated_at
@property
def length(self) -> LevelLength:
""":class:`~gd.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("requested_stars", 0)
@property
def objects(self) -> int:
""":class:`int`: Amount of objects the level has in data."""
return object_count(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:`~gd.TimelyType`: A type that shows whether a level is Daily/Weekly."""
return TimelyType.from_value(self.options.get("type", 0))
@property
def timely_id(self) -> int:
""":class:`int`: A number that represents current ID of the timely.
Increments on new dailies/weeklies. If not timely, equals ``-1``.
"""
return self.options.get("timely_id", -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 editor_seconds(self) -> int:
return self.options.get("editor_seconds", 0)
@property
def copies_seconds(self) -> int:
return self.options.get("copies_seconds", 0)
@property
def editor_time(self) -> timedelta:
return timedelta(seconds=self.editor_seconds)
@property
def copies_time(self) -> timedelta:
return timedelta(seconds=self.copies_seconds)
def get_unprocessed_data(self) -> str:
return self.options.get("unprocessed_data", "")
def set_unprocessed_data(self, unprocessed_data: str) -> None:
self.options.update(unprocessed_data=unprocessed_data)
unprocessed_data = property(get_unprocessed_data, set_unprocessed_data)
@cache_by("unprocessed_data")
def get_data(self) -> str:
unprocessed_data = self.unprocessed_data
if is_level_probably_decoded(unprocessed_data):
return unprocessed_data
else:
return unzip_level_str(unprocessed_data)
def set_data(self, data: str) -> None:
if is_level_probably_decoded(data):
self.unprocessed_data = zip_level_str(data)
else:
self.unprocessed_data = data
data = property(get_data, set_data)
[docs] def is_timely(self, timely_type: Optional[Union[int, str, TimelyType]] = None) -> bool:
""":class:`bool`: Indicates whether a level is timely/daily/weekly."""
if timely_type is None:
return self.type is not TimelyType.NOT_TIMELY
return self.type is TimelyType.from_value(timely_type)
[docs] def is_current_timely(self, timely_type: Optional[Union[int, str, TimelyType]] = None) -> bool:
""":class:`bool`: Indicates whether a level is current timely/daily/weekly level."""
return self.is_timely(timely_type) and self.cooldown > 0
[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
[docs] def is_epic(self) -> bool:
""":class:`bool`: Indicates whether a level is epic."""
return bool(self.options.get("epic"))
[docs] def is_demon(self) -> bool:
""":class:`bool`: Indicates whether a level is demon."""
return bool(self.options.get("demon"))
[docs] def is_auto(self) -> bool:
""":class:`bool`: Indicates whether a level is auto."""
return bool(self.options.get("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"))
def has_low_detail_mode(self) -> bool:
return bool(self.options.get("low_detail_mode"))
def has_two_player(self) -> bool:
return bool(self.options.get("two_player"))
def open_editor(self) -> Editor:
return Editor.load_from(self, "data")
[docs] async def report(self) -> None:
"""Reports a level.
Raises
------
:exc:`~gd.MissingAccess`
Failed to report a level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.report_level(self)
[docs] async def upload(self, **kwargs) -> None:
r"""Upload ``self``.
Parameters
----------
\*\*kwargs
Arguments that :meth:`~gd.Client.upload_level` accepts.
Defaults are properties of the level.
Raises
------
:exc:`~gd.MissingAccess`
Failed to upload the level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
track_id, song_id = (0, self.song.id) if self.song.is_custom() else (self.song.id, 0)
client = self.client_unchecked
if client is None:
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."
)
args = dict(
name=self.name,
id=self.id,
version=self.version,
length=abs(self.length.value),
track_id=track_id,
song_id=song_id,
two_player=self.has_two_player(),
is_auto=self.is_auto(),
original=self.original_id,
objects=self.objects,
coins=self.coins,
stars=self.stars,
unlisted=False,
friends_only=False,
low_detail_mode=self.has_low_detail_mode(),
password=self.password,
copyable=self.is_copyable(),
description=self.description,
editor_seconds=self.editor_seconds,
copies_seconds=self.copies_seconds,
data=self.data,
)
args.update(kwargs)
uploaded = await client.upload_level(**args)
self.options.update(uploaded.options)
[docs] async def delete(self) -> None:
"""Deletes a level.
Raises
------
:exc:`~gd.MissingAccess`
Failed to delete a level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.delete_level(self)
[docs] async def update_description(self, content: str) -> None:
"""Updates level description.
Parameters
----------
content: :class:`str`
Content of the new description. If ``None`` or omitted, nothing is run.
Raises
------
:exc:`~gd.MissingAccess`
Failed to update level's description.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.update_level_description(self, content)
[docs] async def rate(self, stars: int) -> None:
"""Sends level rating.
Parameters
----------
stars: :class:`int`
Amount of stars to rate with.
Raises
------
:exc:`~gd.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], as_mod: bool = False
) -> None:
"""Sends level demon rating.
Parameters
----------
demon_difficulty: Union[:class:`int`, :class:`str`, :class:`~gd.DemonDifficulty`]
Demon difficulty to rate a level with.
as_mod: :class:`bool`
Whether to send a demon rating as moderator.
Raises
------
:exc:`~gd.MissingAccess`
If attempted to rate a level as moderator without required permissions.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.rate_demon(self, demon_difficulty=demon_difficulty, as_mod=as_mod)
[docs] async def send(self, stars: int, featured: bool) -> None:
"""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 for a feature, or for a rate.
Raises
------
:exc:`~gd.MissingAccess`
Missing required moderator permissions.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.send_level(self, stars=stars, featured=featured)
[docs] async def is_alive(self) -> bool:
"""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.
"""
try:
await self.client.search_levels_on_page(query=self.id)
except MissingAccess:
return False
return True
[docs] async def update(self, *, get_data: bool = True) -> Optional["Level"]:
"""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:`~gd.Level`
A newly fetched version. ``None`` if failed to fetch.
"""
try:
if self.type is TimelyType.DAILY:
new = await self.client.get_daily()
elif self.type is TimelyType.WEEKLY:
new = await self.client.get_weekly()
else:
new = await self.client.get_level(self.id, get_data=get_data)
if new.id != self.id:
log.warning(
f"Level has changed: {self.name} ({self.id}) -> "
f"{new.name} ({new.id}). Updating to it..."
)
except MissingAccess:
log.warning("Failed to update the level: %r. Most likely it was deleted.", self)
return None
self.options.update(new.options)
return self
refresh = update
[docs] async def like(self) -> None:
"""Likes a level.
Raises
------
:exc:`~gd.MissingAccess`
Failed to like a level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.like(self)
[docs] async def dislike(self) -> None:
"""Dislikes a level.
Raises
------
:exc:`~gd.MissingAccess`
Failed to dislike a level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
"""
await self.client.dislike(self)
[docs] @awaitable_iterator
def get_leaderboard(
self, strategy: Union[int, str, LevelLeaderboardStrategy] = LevelLeaderboardStrategy.ALL,
) -> AsyncIterator[User]:
"""Retrieves the leaderboard of a level.
Parameters
----------
strategy: Union[:class:`int`, :class:`str`, :class:`~gd.LevelLeaderboardStrategy`]
Strategy to apply when fetching.
Raises
------
:exc:`~gd.MissingAccess`
Failed to get leaderboard of the level.
:exc:`~gd.HTTPStatusError`
Server returned error status code.
:exc:`~gd.HTTPError`
Failed to process the request.
Returns
-------
AsyncIterator[:class:`~gd.User`]
A list of users.
"""
return self.client.get_level_leaderboard(self, strategy=strategy)
@dataclass
class OfficialLevel:
"""Simple dataclass to hold information about official levels.
Use :meth:`~gd.OfficialLevel.into_level` for converting to :class:`~gd.Level`.
"""
level_id: int = attrib()
song_id: int = attrib()
name: str = attrib()
stars: int = attrib()
difficulty: str = attrib()
coins: int = attrib()
length: str = attrib()
game_version: GameVersion = attrib()
def is_auto(self) -> bool:
"""Check whether the official level is auto."""
return "auto" in self.difficulty
def is_demon(self) -> bool:
"""Check whether the official level is demon."""
return "demon" in self.difficulty
def get_song_id(self, server_style: bool = False) -> int:
"""Get correct song ID from ``self.song_id``.
``server_style`` indicates whether the song ID should match one on the server.
"""
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,
cls: Type[Level] = Level,
) -> Level:
"""Convert :class:`~gd.OfficialLevel` dataclass into :class:`~gd.Level`.
Parameters
----------
client: Optional[:class:`~gd.Client`]
Client to attach. ``None`` by default.
get_data: :class:`bool`
Whether to decode and attach level data. ``True`` by default.
server_style: :class:`bool`
Whether song ID should match one on the server.
Returns
-------
:class:`~gd.Level`
Level created from the dataclass.
"""
global official_levels_data
if self.is_demon():
difficulty = DemonDifficulty.from_name(self.difficulty)
else:
difficulty = LevelDifficulty.from_name(self.difficulty)
if get_data:
if not official_levels_data and official_levels_path.exists():
official_levels_data = json.loads(official_levels_path.read_text())
unprocessed_data = official_levels_data.get(self.name, "")
else:
unprocessed_data = ""
return cls(
id=self.level_id,
name=self.name,
description=f"Official Level: {self.name}",
unprocessed_data=unprocessed_data,
version=1,
creator=User(name="RobTop", id=16, account_id=71, client=client),
song=Song.official(
self.get_song_id(server_style), client=client, server_style=server_style
),
downloads=0,
game_version=self.game_version,
rating=0,
length=LevelLength.from_name(self.length),
demon=self.is_demon(),
stars=self.stars,
score=1,
auto=self.is_auto(),
password=None,
copyable=False,
difficulty=difficulty,
uploaded_at=None,
updated_at=None,
original_id=0,
two_player=False,
extra_string="",
coins=self.coins,
verified_coins=True,
requested_stars=self.stars,
low_detail_mode=False,
epic=False,
object_count=0,
editor_seconds=0,
copies_seconds=0,
timely_id=-1,
type=TimelyType.NOT_TIMELY,
cooldown=-1,
client=client,
)
official_levels = (
OfficialLevel(
level_id=1,
song_id=1,
name="Stereo Madness",
stars=1,
difficulty="easy",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=2,
song_id=2,
name="Back On Track",
stars=2,
difficulty="easy",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=3,
song_id=3,
name="Polargeist",
stars=3,
difficulty="normal",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=4,
song_id=4,
name="Dry Out",
stars=4,
difficulty="normal",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=5,
song_id=5,
name="Base After Base",
stars=5,
difficulty="hard",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=6,
song_id=6,
name="Cant Let Go",
stars=6,
difficulty="hard",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=7,
song_id=7,
name="Jumper",
stars=7,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(1, 0),
),
OfficialLevel(
level_id=8,
song_id=8,
name="Time Machine",
stars=8,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(1, 1),
),
OfficialLevel(
level_id=9,
song_id=9,
name="Cycles",
stars=9,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(1, 2),
),
OfficialLevel(
level_id=10,
song_id=10,
name="xStep",
stars=10,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 3),
),
OfficialLevel(
level_id=11,
song_id=11,
name="Clutterfunk",
stars=11,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 4),
),
OfficialLevel(
level_id=12,
song_id=12,
name="Theory of Everything",
stars=12,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 5),
),
OfficialLevel(
level_id=13,
song_id=13,
name="Electroman Adventures",
stars=10,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 6),
),
OfficialLevel(
level_id=14,
song_id=14,
name="Clubstep",
stars=14,
difficulty="easy_demon",
coins=3,
length="long",
game_version=GameVersion(1, 6),
),
OfficialLevel(
level_id=15,
song_id=15,
name="Electrodynamix",
stars=12,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 7),
),
OfficialLevel(
level_id=16,
song_id=16,
name="Hexagon Force",
stars=12,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(1, 8),
),
OfficialLevel(
level_id=17,
song_id=17,
name="Blast Processing",
stars=10,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(1, 9),
),
OfficialLevel(
level_id=18,
song_id=18,
name="Theory of Everything 2",
stars=14,
difficulty="easy_demon",
coins=3,
length="long",
game_version=GameVersion(1, 9),
),
OfficialLevel(
level_id=19,
song_id=19,
name="Geometrical Dominator",
stars=10,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(2, 0),
),
OfficialLevel(
level_id=20,
song_id=20,
name="Deadlocked",
stars=15,
difficulty="medium_demon",
coins=3,
length="long",
game_version=GameVersion(2, 0),
),
OfficialLevel(
level_id=21,
song_id=21,
name="Fingerdash",
stars=12,
difficulty="insane",
coins=3,
length="long",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=1001,
song_id=22,
name="The Seven Seas",
stars=1,
difficulty="easy",
coins=3,
length="long",
game_version=GameVersion(2, 0),
),
OfficialLevel(
level_id=1002,
song_id=23,
name="Viking Arena",
stars=2,
difficulty="normal",
coins=3,
length="long",
game_version=GameVersion(2, 0),
),
OfficialLevel(
level_id=1003,
song_id=24,
name="Airborne Robots",
stars=3,
difficulty="hard",
coins=3,
length="long",
game_version=GameVersion(2, 0),
),
OfficialLevel(
level_id=2001,
song_id=26,
name="Payload",
stars=2,
difficulty="easy",
coins=0,
length="short",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2002,
song_id=27,
name="Beast Mode",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2003,
song_id=28,
name="Machina",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2004,
song_id=29,
name="Years",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2005,
song_id=30,
name="Frontlines",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2006,
song_id=31,
name="Space Pirates",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2007,
song_id=32,
name="Striker",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2008,
song_id=33,
name="Embers",
stars=3,
difficulty="normal",
coins=0,
length="short",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2009,
song_id=34,
name="Round 1",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=2010,
song_id=35,
name="Monster Dance Off",
stars=3,
difficulty="normal",
coins=0,
length="medium",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=3001,
song_id=25,
name="The Challenge",
stars=3,
difficulty="hard",
coins=0,
length="short",
game_version=GameVersion(2, 1),
),
OfficialLevel(
level_id=4001,
song_id=36,
name="Press Start",
stars=4,
difficulty="normal",
coins=3,
length="long",
game_version=GameVersion(2, 2),
),
OfficialLevel(
level_id=4002,
song_id=37,
name="Nock Em",
stars=6,
difficulty="hard",
coins=3,
length="long",
game_version=GameVersion(2, 2),
),
OfficialLevel(
level_id=4003,
song_id=38,
name="Power Trip",
stars=8,
difficulty="harder",
coins=3,
length="long",
game_version=GameVersion(2, 2),
),
)