Initial commit of BEMANI Utilities to GitHub.

This commit is contained in:
Jennifer Taylor
2019-12-08 21:43:49 +00:00
commit 74c0407173
490 changed files with 131920 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
from bemani.api.objects.base import BaseObject
from bemani.api.objects.catalog import CatalogObject
from bemani.api.objects.records import RecordsObject
from bemani.api.objects.profile import ProfileObject
from bemani.api.objects.statistics import StatisticsObject

View File

@@ -0,0 +1,23 @@
from typing import List, Any, Dict
from bemani.api.exceptions import APIException
from bemani.data import Data
class BaseObject:
"""
A base class which represents a fetchable API object. Every fetchable object
will subclass from this and implement one or more version fetches. These
are dynamically looked up by the version number provided by the client, so
objects can control which versions they reply to by subclassing or ignoring
various fetch versions.
"""
def __init__(self, data: Data, game: str, version: int, omnimix: bool) -> None:
self.data = data
self.game = game
self.version = version
self.omnimix = omnimix
def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> Any:
raise APIException('Object fetch not supported for this version!')

View File

@@ -0,0 +1,185 @@
from typing import Any, Dict, List
from bemani.api.exceptions import APIException
from bemani.api.objects.base import BaseObject
from bemani.common import GameConstants, APIConstants, DBConstants, VersionConstants
from bemani.data import Song
class CatalogObject(BaseObject):
def __format_ddr_song(self, song: Song) -> Dict[str, Any]:
groove = song.data.get_dict('groove')
return {
'editid': str(song.data.get_int('edit_id')),
'difficulty': song.data.get_int('difficulty'),
'bpm_min': song.data.get_int('bpm_min'),
'bpm_max': song.data.get_int('bpm_max'),
'category': str(song.data.get_int('category')),
'groove': {
'air': groove.get_int('air'),
'chaos': groove.get_int('chaos'),
'freeze': groove.get_int('freeze'),
'stream': groove.get_int('stream'),
'voltage': groove.get_int('voltage'),
},
}
def __format_iidx_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'bpm_min': song.data.get_int('bpm_min'),
'bpm_max': song.data.get_int('bpm_max'),
'notecount': song.data.get_int('notecount'),
'category': str(int(song.id / 1000)),
}
def __format_jubeat_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'bpm_min': song.data.get_int('bpm_min'),
'bpm_max': song.data.get_int('bpm_max'),
}
def __format_museca_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'bpm_min': song.data.get_int('bpm_min'),
'bpm_max': song.data.get_int('bpm_max'),
'limited': song.data.get_int('limited'),
}
def __format_popn_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'category': song.data.get_str('category'),
}
def __format_reflec_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'category': str(song.data.get_int('folder')),
'musicid': song.data.get_str('chart_id'),
}
def __format_sdvx_song(self, song: Song) -> Dict[str, Any]:
return {
'difficulty': song.data.get_int('difficulty'),
'bpm_min': song.data.get_int('bpm_min'),
'bpm_max': song.data.get_int('bpm_max'),
'limited': song.data.get_int('limited'),
}
def __format_song(self, song: Song) -> Dict[str, Any]:
base = {
'song': str(song.id),
'chart': str(song.chart),
'title': song.name or "",
'artist': song.artist or "",
'genre': song.genre or "",
}
if self.game == GameConstants.DDR:
base.update(self.__format_ddr_song(song))
if self.game == GameConstants.IIDX:
base.update(self.__format_iidx_song(song))
if self.game == GameConstants.JUBEAT:
base.update(self.__format_jubeat_song(song))
if self.game == GameConstants.MUSECA:
base.update(self.__format_museca_song(song))
if self.game == GameConstants.POPN_MUSIC:
base.update(self.__format_popn_song(song))
if self.game == GameConstants.REFLEC_BEAT:
base.update(self.__format_reflec_song(song))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_song(song))
return base
def __format_sdvx_extras(self) -> Dict[str, List[Dict[str, Any]]]:
# Gotta look up the unlock catalog
items = self.data.local.game.get_items(self.game, self.version)
# Format it depending on the version
if self.version == 1:
return {
"purchases": [
{
"catalogid": str(item.id),
"song": str(item.data.get_int("musicid")),
"chart": str(item.data.get_int("chart")),
"price": item.data.get_int("blocks"),
}
for item in items
if item.type == "song_unlock"
],
"appealcards": [],
}
else:
return {
"purchases": [],
"appealcards": [
{
"appealid": str(item.id),
"description": item.data.get_str("description"),
}
for item in items
if item.type == "appealcard"
],
}
def __format_extras(self) -> Dict[str, List[Dict[str, Any]]]:
if self.game == GameConstants.SDVX:
return self.__format_sdvx_extras()
else:
return {}
@property
def music_version(self) -> int:
if self.game == GameConstants.IIDX:
if self.omnimix:
return self.version + DBConstants.OMNIMIX_VERSION_BUMP
else:
return self.version
else:
return self.version
def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]:
# Verify IDs
if idtype != APIConstants.ID_TYPE_SERVER:
raise APIException(
'Unsupported ID for lookup!',
405,
)
# Fetch the songs
songs = self.data.local.music.get_all_songs(self.game, self.music_version)
if self.game == GameConstants.JUBEAT and self.version == VersionConstants.JUBEAT_CLAN:
# There's always a special case. We don't store all music IDs since those in
# the range of 80000301-80000347 are actually the same song, but copy-pasted
# for different prefectures and slightly different charts. So, we need to copy
# that song data so that remote clients can resolve scores for those ID ranges.
additions: List[Song] = []
for song in songs:
if song.id == 80000301:
for idrange in range(80000302, 80000348):
additions.append(
Song(
song.game,
song.version,
idrange,
song.chart,
song.name,
song.artist,
song.genre,
song.data,
)
)
songs.extend(additions)
retval = {
'songs': [self.__format_song(song) for song in songs],
}
# Fetch any optional extras per-game, return
retval.update(self.__format_extras())
return retval

View File

@@ -0,0 +1,131 @@
from typing import Any, Dict, List, Set, Tuple
from bemani.api.exceptions import APIException
from bemani.api.objects.base import BaseObject
from bemani.common import ValidatedDict, GameConstants, APIConstants
from bemani.data import UserID
class ProfileObject(BaseObject):
def __format_ddr_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {
'area': profile.get_int('area', -1) if exact else -1,
}
def __format_iidx_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
qpro = profile.get_dict('qpro')
return {
'area': profile.get_int('pid', -1),
'qpro': {
'head': qpro.get_int('head', -1) if exact else -1,
'hair': qpro.get_int('hair', -1) if exact else -1,
'face': qpro.get_int('face', -1) if exact else -1,
'body': qpro.get_int('body', -1) if exact else -1,
'hand': qpro.get_int('hand', -1) if exact else -1,
}
}
def __format_jubeat_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {}
def __format_museca_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {}
def __format_popn_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {
'character': profile.get_int('chara', -1) if exact else -1,
}
def __format_reflec_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {
'icon': profile.get_dict('config').get_int('icon_id', -1) if exact else -1,
}
def __format_sdvx_profile(self, profile: ValidatedDict, exact: bool) -> Dict[str, Any]:
return {}
def __format_profile(self, cardids: List[str], profile: ValidatedDict, settings: ValidatedDict, exact: bool) -> Dict[str, Any]:
base = {
'name': profile.get_str('name'),
'cards': cardids,
'registered': settings.get_int('first_play_timestamp', -1),
'updated': settings.get_int('last_play_timestamp', -1),
'plays': settings.get_int('total_plays', -1),
'match': 'exact' if exact else 'partial',
}
if self.game == GameConstants.DDR:
base.update(self.__format_ddr_profile(profile, exact))
if self.game == GameConstants.IIDX:
base.update(self.__format_iidx_profile(profile, exact))
if self.game == GameConstants.JUBEAT:
base.update(self.__format_jubeat_profile(profile, exact))
if self.game == GameConstants.MUSECA:
base.update(self.__format_museca_profile(profile, exact))
if self.game == GameConstants.POPN_MUSIC:
base.update(self.__format_popn_profile(profile, exact))
if self.game == GameConstants.REFLEC_BEAT:
base.update(self.__format_reflec_profile(profile, exact))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_profile(profile, exact))
return base
def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]:
# Fetch the profiles
profiles: List[Tuple[UserID, ValidatedDict]] = []
if idtype == APIConstants.ID_TYPE_SERVER:
profiles.extend(self.data.local.user.get_all_profiles(self.game, self.version))
elif idtype == APIConstants.ID_TYPE_SONG:
raise APIException(
'Unsupported ID for lookup!',
405,
)
elif idtype == APIConstants.ID_TYPE_INSTANCE:
raise APIException(
'Unsupported ID for lookup!',
405,
)
elif idtype == APIConstants.ID_TYPE_CARD:
users: Set[UserID] = set()
for cardid in ids:
userid = self.data.local.user.from_cardid(cardid)
if userid is not None:
# Don't duplicate loads for users with multiple card IDs if multiples
# of those IDs are requested.
if userid in users:
continue
users.add(userid)
# We can possibly find another profile for this user. This is important
# in the case that we returned scores for a user that doesn't have a
# profile on a particular version. We allow that on this network, so in
# order to not break remote networks, try our best to return any profile.
profile = self.data.local.user.get_any_profile(self.game, self.version, userid)
if profile is not None:
profiles.append((userid, profile))
else:
raise APIException('Invalid ID type!')
# Now, fetch the users, and filter out profiles belonging to orphaned users
retval: List[Dict[str, Any]] = []
id_to_cards: Dict[UserID, List[str]] = {}
for (userid, profile) in profiles:
if userid not in id_to_cards:
cards = self.data.local.user.get_cards(userid)
if len(cards) == 0:
# Can't add this user, skip the profile
continue
id_to_cards[userid] = cards
# Format the profile and add it
settings = self.data.local.game.get_settings(self.game, userid)
if settings is None:
settings = ValidatedDict({})
retval.append(self.__format_profile(id_to_cards[userid], profile, settings, profile['version'] == self.version))
return retval

View File

@@ -0,0 +1,299 @@
from typing import Any, Dict, List, Set, Tuple
from bemani.api.exceptions import APIException
from bemani.api.objects.base import BaseObject
from bemani.common import GameConstants, VersionConstants, APIConstants, DBConstants
from bemani.data import Score, UserID
class RecordsObject(BaseObject):
def __format_ddr_record(self, record: Score) -> Dict[str, Any]:
halo = {
DBConstants.DDR_HALO_NONE: 'none',
DBConstants.DDR_HALO_GOOD_FULL_COMBO: 'gfc',
DBConstants.DDR_HALO_GREAT_FULL_COMBO: 'fc',
DBConstants.DDR_HALO_PERFECT_FULL_COMBO: 'pfc',
DBConstants.DDR_HALO_MARVELOUS_FULL_COMBO: 'mfc',
}.get(record.data.get_int('halo'), 'none')
rank = {
DBConstants.DDR_RANK_AAA: "AAA",
DBConstants.DDR_RANK_AA_PLUS: "AA+",
DBConstants.DDR_RANK_AA: "AA",
DBConstants.DDR_RANK_AA_MINUS: "AA-",
DBConstants.DDR_RANK_A_PLUS: "A+",
DBConstants.DDR_RANK_A: "A",
DBConstants.DDR_RANK_A_MINUS: "A-",
DBConstants.DDR_RANK_B_PLUS: "B+",
DBConstants.DDR_RANK_B: "B",
DBConstants.DDR_RANK_B_MINUS: "B-",
DBConstants.DDR_RANK_C_PLUS: "C+",
DBConstants.DDR_RANK_C: "C",
DBConstants.DDR_RANK_C_MINUS: "C-",
DBConstants.DDR_RANK_D_PLUS: "D+",
DBConstants.DDR_RANK_D: "D",
DBConstants.DDR_RANK_E: "E",
}.get(record.data.get_int('rank'), 'E')
if self.version == VersionConstants.DDR_ACE:
# DDR Ace is specia
ghost = [int(x) for x in record.data.get_str('ghost')]
else:
if 'trace' not in record.data:
ghost = []
else:
ghost = record.data.get_int_array('trace', len(record.data['trace']))
return {
'rank': rank,
'halo': halo,
'combo': record.data.get_int('combo'),
'ghost': ghost,
}
def __format_iidx_record(self, record: Score) -> Dict[str, Any]:
status = {
DBConstants.IIDX_CLEAR_STATUS_NO_PLAY: 'np',
DBConstants.IIDX_CLEAR_STATUS_FAILED: 'failed',
DBConstants.IIDX_CLEAR_STATUS_ASSIST_CLEAR: 'ac',
DBConstants.IIDX_CLEAR_STATUS_EASY_CLEAR: 'ec',
DBConstants.IIDX_CLEAR_STATUS_CLEAR: 'nc',
DBConstants.IIDX_CLEAR_STATUS_HARD_CLEAR: 'hc',
DBConstants.IIDX_CLEAR_STATUS_EX_HARD_CLEAR: 'exhc',
DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO: 'fc',
}.get(record.data.get_int('clear_status'), 'np')
return {
'status': status,
'miss': record.data.get_int('miss_count', -1),
'ghost': [b for b in record.data.get_bytes('ghost')],
'pgreat': record.data.get_int('pgreats', -1),
'great': record.data.get_int('greats', -1),
}
def __format_jubeat_record(self, record: Score) -> Dict[str, Any]:
status = {
DBConstants.JUBEAT_PLAY_MEDAL_FAILED: 'failed',
DBConstants.JUBEAT_PLAY_MEDAL_CLEARED: 'cleared',
DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_FULL_COMBO: 'nfc',
DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO: 'fc',
DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT: 'nec',
DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT: 'exc',
}.get(record.data.get_int('medal'), 'failed')
if 'ghost' not in record.data:
ghost: List[int] = []
else:
ghost = record.data.get_int_array('ghost', len(record.data['ghost']))
return {
'status': status,
'combo': record.data.get_int('combo', -1),
'ghost': ghost,
}
def __format_museca_record(self, record: Score) -> Dict[str, Any]:
rank = {
DBConstants.MUSECA_GRADE_DEATH: 'death',
DBConstants.MUSECA_GRADE_POOR: 'poor',
DBConstants.MUSECA_GRADE_MEDIOCRE: 'mediocre',
DBConstants.MUSECA_GRADE_GOOD: 'good',
DBConstants.MUSECA_GRADE_GREAT: 'great',
DBConstants.MUSECA_GRADE_EXCELLENT: 'excellent',
DBConstants.MUSECA_GRADE_SUPERB: 'superb',
DBConstants.MUSECA_GRADE_MASTERPIECE: 'masterpiece',
DBConstants.MUSECA_GRADE_PERFECT: 'perfect'
}.get(record.data.get_int('grade'), 'death')
status = {
DBConstants.MUSECA_CLEAR_TYPE_FAILED: 'failed',
DBConstants.MUSECA_CLEAR_TYPE_CLEARED: 'cleared',
DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO: 'fc',
}.get(record.data.get_int('clear_type'), 'failed')
return {
'rank': rank,
'status': status,
'combo': record.data.get_int('combo', -1),
'buttonrate': record.data.get_dict('stats').get_int('btn_rate'),
'longrate': record.data.get_dict('stats').get_int('long_rate'),
'volrate': record.data.get_dict('stats').get_int('vol_rate'),
}
def __format_popn_record(self, record: Score) -> Dict[str, Any]:
status = {
DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED: 'cf',
DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED: 'df',
DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED: 'sf',
DBConstants.POPN_MUSIC_PLAY_MEDAL_EASY_CLEAR: 'ec',
DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_CLEARED: 'cc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_CLEARED: 'dc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_CLEARED: 'sc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO: 'cfc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO: 'dfc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO: 'sfc',
DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT: 'p',
}.get(record.data.get_int('medal'), 'cf')
return {
'status': status,
'combo': record.data.get_int('combo', -1),
}
def __format_reflec_record(self, record: Score) -> Dict[str, Any]:
status = {
DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY: 'np',
DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED: 'failed',
DBConstants.REFLEC_BEAT_CLEAR_TYPE_CLEARED: 'cleared',
DBConstants.REFLEC_BEAT_CLEAR_TYPE_HARD_CLEARED: 'hc',
DBConstants.REFLEC_BEAT_CLEAR_TYPE_S_HARD_CLEARED: 'shc',
}.get(record.data.get_int('clear_type'), 'np')
halo = {
DBConstants.REFLEC_BEAT_COMBO_TYPE_NONE: 'none',
DBConstants.REFLEC_BEAT_COMBO_TYPE_ALMOST_COMBO: 'ac',
DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO: 'fc',
DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST: 'fcaj',
}.get(record.data.get_int('combo_type'), 'none')
return {
'rate': record.data.get_int('achievement_rate'),
'status': status,
'halo': halo,
'combo': record.data.get_int('combo', -1),
'miss': record.data.get_int('miss_count', -1),
}
def __format_sdvx_record(self, record: Score) -> Dict[str, Any]:
status = {
DBConstants.SDVX_CLEAR_TYPE_NO_PLAY: 'np',
DBConstants.SDVX_CLEAR_TYPE_FAILED: 'failed',
DBConstants.SDVX_CLEAR_TYPE_CLEAR: 'cleared',
DBConstants.SDVX_CLEAR_TYPE_HARD_CLEAR: 'hc',
DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN: 'uc',
DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN: 'puc',
}.get(record.data.get_int('clear_type'), 'np')
rank = {
DBConstants.SDVX_GRADE_NO_PLAY: 'E',
DBConstants.SDVX_GRADE_D: 'D',
DBConstants.SDVX_GRADE_C: 'C',
DBConstants.SDVX_GRADE_B: 'B',
DBConstants.SDVX_GRADE_A: 'A',
DBConstants.SDVX_GRADE_A_PLUS: 'A+',
DBConstants.SDVX_GRADE_AA: 'AA',
DBConstants.SDVX_GRADE_AA_PLUS: 'AA+',
DBConstants.SDVX_GRADE_AAA: 'AAA',
DBConstants.SDVX_GRADE_AAA_PLUS: 'AAA+',
DBConstants.SDVX_GRADE_S: 'S',
}.get(record.data.get_int('grade'), 'E')
return {
'status': status,
'rank': rank,
'combo': record.data.get_int('combo', -1),
'buttonrate': record.data.get_dict('stats').get_int('btn_rate'),
'longrate': record.data.get_dict('stats').get_int('long_rate'),
'volrate': record.data.get_dict('stats').get_int('vol_rate'),
}
def __format_record(self, cardids: List[str], record: Score) -> Dict[str, Any]:
base = {
'cards': cardids,
'song': str(record.id),
'chart': str(record.chart),
'points': record.points,
'timestamp': record.timestamp,
'updated': record.update,
}
if self.game == GameConstants.DDR:
base.update(self.__format_ddr_record(record))
if self.game == GameConstants.IIDX:
base.update(self.__format_iidx_record(record))
if self.game == GameConstants.JUBEAT:
base.update(self.__format_jubeat_record(record))
if self.game == GameConstants.MUSECA:
base.update(self.__format_museca_record(record))
if self.game == GameConstants.POPN_MUSIC:
base.update(self.__format_popn_record(record))
if self.game == GameConstants.REFLEC_BEAT:
base.update(self.__format_reflec_record(record))
if self.game == GameConstants.SDVX:
base.update(self.__format_sdvx_record(record))
return base
@property
def music_version(self) -> int:
if self.game == GameConstants.IIDX:
if self.omnimix:
return self.version + DBConstants.OMNIMIX_VERSION_BUMP
else:
return self.version
else:
return self.version
def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]:
since = params.get('since')
until = params.get('until')
# Fetch the scores
records: List[Tuple[UserID, Score]] = []
if idtype == APIConstants.ID_TYPE_SERVER:
# Because of the way this query works, we can't apply since/until to it directly.
# If we did, it would miss higher scores earned before since or after until, and
# incorrectly report records.
records.extend(self.data.local.music.get_all_records(self.game, self.music_version))
elif idtype == APIConstants.ID_TYPE_SONG:
if len(ids) == 1:
songid = int(ids[0])
chart = None
else:
songid = int(ids[0])
chart = int(ids[1])
records.extend(self.data.local.music.get_all_scores(self.game, self.music_version, songid=songid, songchart=chart, since=since, until=until))
elif idtype == APIConstants.ID_TYPE_INSTANCE:
songid = int(ids[0])
chart = int(ids[1])
cardid = ids[2]
userid = self.data.local.user.from_cardid(cardid)
if userid is not None:
score = self.data.local.music.get_score(self.game, self.music_version, userid, songid, chart)
if score is not None:
records.append((userid, score))
elif idtype == APIConstants.ID_TYPE_CARD:
users: Set[UserID] = set()
for cardid in ids:
userid = self.data.local.user.from_cardid(cardid)
if userid is not None:
# Don't duplicate loads for users with multiple card IDs if multiples
# of those IDs are requested.
if userid in users:
continue
users.add(userid)
records.extend([(userid, score) for score in self.data.local.music.get_scores(self.game, self.music_version, userid, since=since, until=until)])
else:
raise APIException('Invalid ID type!')
# Now, fetch the users, and filter out scores belonging to orphaned users
id_to_cards: Dict[UserID, List[str]] = {}
retval: List[Dict[str, Any]] = []
for (userid, record) in records:
# Postfilter for queries that can't filter. This will save on data transferred.
if since is not None:
if record.update < since:
continue
if until is not None:
if record.update >= until:
continue
if userid not in id_to_cards:
cards = self.data.local.user.get_cards(userid)
if len(cards) == 0:
# Can't add this user, skip the score
continue
id_to_cards[userid] = cards
# Format the score and add it
retval.append(self.__format_record(id_to_cards[userid], record))
return retval

View File

@@ -0,0 +1,230 @@
from typing import List, Dict, Tuple, Any
from bemani.api.exceptions import APIException
from bemani.api.objects.base import BaseObject
from bemani.common import APIConstants, DBConstants, GameConstants
from bemani.data import Attempt, UserID
class StatisticsObject(BaseObject):
def __format_statistics(self, stats: Dict[str, Any]) -> Dict[str, Any]:
return {
'cards': [],
'song': str(stats['id']),
'chart': str(stats['chart']),
'plays': stats.get('plays', -1),
'clears': stats.get('clears', -1),
'combos': stats.get('combos', -1),
}
def __format_user_statistics(self, cardids: List[str], stats: Dict[str, Any]) -> Dict[str, Any]:
base = self.__format_statistics(stats)
base['cards'] = cardids
return base
@property
def music_version(self) -> int:
if self.game == GameConstants.IIDX:
if self.omnimix:
return self.version + DBConstants.OMNIMIX_VERSION_BUMP
else:
return self.version
else:
return self.version
def __is_play(self, attempt: Attempt) -> bool:
if self.game in [
GameConstants.DDR,
GameConstants.JUBEAT,
GameConstants.MUSECA,
GameConstants.POPN_MUSIC,
]:
return True
if self.game == GameConstants.IIDX:
return attempt.data.get_int('clear_status') != DBConstants.IIDX_CLEAR_STATUS_NO_PLAY
if self.game == GameConstants.REFLEC_BEAT:
return attempt.data.get_int('clear_type') != DBConstants.REFLEC_BEAT_CLEAR_TYPE_NO_PLAY
if self.game == GameConstants.SDVX:
return attempt.data.get_int('clear_type') != DBConstants.SDVX_CLEAR_TYPE_NO_PLAY
return False
def __is_clear(self, attempt: Attempt) -> bool:
if not self.__is_play(attempt):
return False
if self.game == GameConstants.DDR:
return attempt.data.get_int('rank') != DBConstants.DDR_RANK_E
if self.game == GameConstants.IIDX:
return attempt.data.get_int('clear_status') != DBConstants.IIDX_CLEAR_STATUS_FAILED
if self.game == GameConstants.JUBEAT:
return attempt.data.get_int('medal') != DBConstants.JUBEAT_PLAY_MEDAL_FAILED
if self.game == GameConstants.MUSECA:
return attempt.data.get_int('clear_type') != DBConstants.MUSECA_CLEAR_TYPE_FAILED
if self.game == GameConstants.POPN_MUSIC:
return attempt.data.get_int('medal') not in [
DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FAILED,
DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FAILED,
DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FAILED,
]
if self.game == GameConstants.REFLEC_BEAT:
return attempt.data.get_int('clear_type') != DBConstants.REFLEC_BEAT_CLEAR_TYPE_FAILED
if self.game == GameConstants.SDVX:
return (
attempt.data.get_int('grade') != DBConstants.SDVX_GRADE_NO_PLAY and
attempt.data.get_int('clear_type') not in [
DBConstants.SDVX_CLEAR_TYPE_NO_PLAY,
DBConstants.SDVX_CLEAR_TYPE_FAILED,
]
)
return False
def __is_combo(self, attempt: Attempt) -> bool:
if not self.__is_play(attempt):
return False
if self.game == GameConstants.DDR:
return attempt.data.get_int('halo') != DBConstants.DDR_HALO_NONE
if self.game == GameConstants.IIDX:
return attempt.data.get_int('clear_status') == DBConstants.IIDX_CLEAR_STATUS_FULL_COMBO
if self.game == GameConstants.JUBEAT:
return attempt.data.get_int('medal') in [
DBConstants.JUBEAT_PLAY_MEDAL_FULL_COMBO,
DBConstants.JUBEAT_PLAY_MEDAL_NEARLY_EXCELLENT,
DBConstants.JUBEAT_PLAY_MEDAL_EXCELLENT,
]
if self.game == GameConstants.MUSECA:
return attempt.data.get_int('clear_type') == DBConstants.MUSECA_CLEAR_TYPE_FULL_COMBO
if self.game == GameConstants.POPN_MUSIC:
return attempt.data.get_int('medal') in [
DBConstants.POPN_MUSIC_PLAY_MEDAL_CIRCLE_FULL_COMBO,
DBConstants.POPN_MUSIC_PLAY_MEDAL_DIAMOND_FULL_COMBO,
DBConstants.POPN_MUSIC_PLAY_MEDAL_STAR_FULL_COMBO,
DBConstants.POPN_MUSIC_PLAY_MEDAL_PERFECT,
]
if self.game == GameConstants.REFLEC_BEAT:
return attempt.data.get_int('combo_type') in [
DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO,
DBConstants.REFLEC_BEAT_COMBO_TYPE_FULL_COMBO_ALL_JUST,
]
if self.game == GameConstants.SDVX:
return attempt.data.get_int('clear_type') in [
DBConstants.SDVX_CLEAR_TYPE_ULTIMATE_CHAIN,
DBConstants.SDVX_CLEAR_TYPE_PERFECT_ULTIMATE_CHAIN,
]
return False
def __aggregate_global(self, attempts: List[Attempt]) -> List[Dict[str, Any]]:
stats: Dict[int, Dict[int, Dict[str, int]]] = {}
for attempt in attempts:
if attempt.id not in stats:
stats[attempt.id] = {}
if attempt.chart not in stats[attempt.id]:
stats[attempt.id][attempt.chart] = {
'plays': 0,
'clears': 0,
'combos': 0,
}
if self.__is_play(attempt):
stats[attempt.id][attempt.chart]['plays'] += 1
if self.__is_clear(attempt):
stats[attempt.id][attempt.chart]['clears'] += 1
if self.__is_combo(attempt):
stats[attempt.id][attempt.chart]['combos'] += 1
retval = []
for songid in stats:
for songchart in stats[songid]:
stat = stats[songid][songchart]
stat['id'] = songid
stat['chart'] = songchart
retval.append(self.__format_statistics(stat))
return retval
def __aggregate_local(self, cards: Dict[int, List[str]], attempts: List[Tuple[UserID, Attempt]]) -> List[Dict[str, Any]]:
stats: Dict[UserID, Dict[int, Dict[int, Dict[str, int]]]] = {}
for (userid, attempt) in attempts:
if userid not in stats:
stats[userid] = {}
if attempt.id not in stats[userid]:
stats[userid][attempt.id] = {}
if attempt.chart not in stats[userid][attempt.id]:
stats[userid][attempt.id][attempt.chart] = {
'plays': 0,
'clears': 0,
'combos': 0,
}
if self.__is_play(attempt):
stats[userid][attempt.id][attempt.chart]['plays'] += 1
if self.__is_clear(attempt):
stats[userid][attempt.id][attempt.chart]['clears'] += 1
if self.__is_combo(attempt):
stats[userid][attempt.id][attempt.chart]['combos'] += 1
retval = []
for userid in stats:
for songid in stats[userid]:
for songchart in stats[userid][songid]:
stat = stats[userid][songid][songchart]
stat['id'] = songid
stat['chart'] = songchart
retval.append(self.__format_user_statistics(cards[userid], stat))
return retval
def fetch_v1(self, idtype: str, ids: List[str], params: Dict[str, Any]) -> List[Dict[str, Any]]:
retval: List[Dict[str, Any]] = []
# Fetch the attempts
if idtype == APIConstants.ID_TYPE_SERVER:
retval = self.__aggregate_global(
[attempt[1] for attempt in self.data.local.music.get_all_attempts(self.game, self.music_version)]
)
elif idtype == APIConstants.ID_TYPE_SONG:
if len(ids) == 1:
songid = int(ids[0])
chart = None
else:
songid = int(ids[0])
chart = int(ids[1])
retval = self.__aggregate_global(
[attempt[1] for attempt in self.data.local.music.get_all_attempts(self.game, self.music_version, songid=songid, songchart=chart)]
)
elif idtype == APIConstants.ID_TYPE_INSTANCE:
songid = int(ids[0])
chart = int(ids[1])
cardid = ids[2]
userid = self.data.local.user.from_cardid(cardid)
if userid is not None:
retval = self.__aggregate_local(
{userid: self.data.local.user.get_cards(userid)},
self.data.local.music.get_all_attempts(self.game, self.music_version, songid=songid, songchart=chart, userid=userid)
)
elif idtype == APIConstants.ID_TYPE_CARD:
id_to_cards: Dict[int, List[str]] = {}
attempts: List[Tuple[UserID, Attempt]] = []
for cardid in ids:
userid = self.data.local.user.from_cardid(cardid)
if userid is not None:
# Don't duplicate loads for users with multiple card IDs if multiples
# of those IDs are requested.
if userid in id_to_cards:
continue
id_to_cards[userid] = self.data.local.user.get_cards(userid)
attempts.extend(
self.data.local.music.get_all_attempts(self.game, self.music_version, userid=userid)
)
retval = self.__aggregate_local(id_to_cards, attempts)
else:
raise APIException('Invalid ID type!')
return retval