# vim: set fileencoding=utf-8 import copy from abc import ABC from typing import Any, Dict, Iterator, List, Optional, Set, Tuple, cast from flask_caching import Cache from bemani.common import GameConstants, Profile, ValidatedDict, ID from bemani.data import Data, Config, Score, Attempt, Link, Song, UserID, RemoteUser class FrontendBase(ABC): """ All subclasses should override this attribute with the string the game series uses in the DB. """ game: GameConstants """ If a subclass wishes to constrain music searches to a particular version, this should be set. If this is left blank, music operations such as records and attempts will pull from all versions of the game. """ version: Optional[int] = None """ List of valid chart integers. Should be overridden by the game. """ valid_charts: List[int] = [] """ List of valid rival type strings. Should be overridden by the game. """ valid_rival_types: List[str] = [] def __init__(self, data: Data, config: Config, cache: Cache) -> None: self.data = data self.config = config self.cache = cache def make_index(self, songid: int, chart: int) -> str: return f'{songid}-{chart}' def get_duplicate_id(self, musicid: int, chart: int) -> Optional[Tuple[int, int]]: return None def format_score(self, userid: UserID, score: Score) -> Dict[str, Any]: return { 'userid': str(userid), 'songid': score.id, 'chart': score.chart, 'plays': score.plays, 'points': score.points, } def format_top_score(self, userid: UserID, score: Score) -> Dict[str, Any]: return self.format_score(userid, score) def format_attempt(self, userid: UserID, attempt: Attempt) -> Dict[str, Any]: return { 'userid': str(userid), 'songid': attempt.id, 'chart': attempt.chart, 'timestamp': attempt.timestamp, 'raised': attempt.new_record, 'points': attempt.points, } def format_rival(self, link: Link, profile: Profile) -> Dict[str, Any]: return { 'type': link.type, 'userid': str(link.other_userid), 'remote': RemoteUser.is_remote(link.other_userid), } def format_profile(self, profile: Profile, playstats: ValidatedDict) -> Dict[str, Any]: return { 'name': profile.get_str('name'), 'extid': ID.format_extid(profile.extid), 'first_play_time': playstats.get_int('first_play_timestamp'), 'last_play_time': playstats.get_int('last_play_timestamp'), } def format_song(self, song: Song) -> Dict[str, Any]: return { 'name': song.name, 'artist': song.artist, 'genre': song.genre, } def merge_song(self, existing: Dict[str, Any], new: Song) -> Dict[str, Any]: return existing def round_to_ten(self, elems: List[Any]) -> List[Any]: num = len(elems) if num % 10 == 0: return elems else: return elems[:-(num % 10)] def all_games(self) -> Iterator[Tuple[GameConstants, int, str]]: """ Override this to return an interator based on a game series factory. """ def get_all_songs(self, force_db_load: bool=False) -> Dict[int, Dict[str, Any]]: if not force_db_load: cached_songs = self.cache.get(f'{self.game.value}.sorted_songs') if cached_songs is not None: # Not sure why mypy insists that this is a str instead of Any. return cast(Dict[int, Dict[str, Any]], cached_songs) # Find all songs in the game, process notecounts and difficulties songs: Dict[int, Dict[str, Any]] = {} for song in self.data.local.music.get_all_songs(self.game, self.version): if song.chart not in self.valid_charts: # No beginner chart support continue if song.id not in songs: songs[song.id] = self.format_song(song) else: songs[song.id] = self.merge_song(songs[song.id], song) self.cache.set(f'{self.game.value}.sorted_songs', songs, timeout=600) return songs def get_all_player_info(self, userids: List[UserID], limit: Optional[int]=None, allow_remote: bool=False) -> Dict[UserID, Dict[int, Dict[str, Any]]]: info: Dict[UserID, Dict[int, Dict[str, Any]]] = {} playstats: Dict[UserID, ValidatedDict] = {} # Find all versions of the users' profiles, sorted newest to oldest. versions = sorted([version for (game, version, name) in self.all_games()], reverse=True) for userid in userids: info[userid] = {} userlimit = limit for version in versions: if allow_remote: profile = self.data.remote.user.get_profile(self.game, version, userid) else: profile = self.data.local.user.get_profile(self.game, version, userid) if profile is not None: if userid not in playstats: stats = self.data.local.game.get_settings(self.game, userid) if stats is None: stats = ValidatedDict() playstats[userid] = stats info[userid][version] = self.format_profile(profile, playstats[userid]) info[userid][version]['remote'] = RemoteUser.is_remote(userid) # Exit out if we've hit the limit if userlimit is not None: userlimit = userlimit - 1 if userlimit == 0: break return info def get_latest_player_info(self, userids: List[UserID]) -> Dict[UserID, Dict[str, Any]]: # Grab the latest profile for each user all_info = self.get_all_player_info(userids, 1) info = {} for userid in userids: for version in all_info[userid]: info[userid] = all_info[userid][version] break return info def get_all_players(self) -> Dict[UserID, Dict[str, Any]]: userids: Set[UserID] = set() versions = [version for (game, version, name) in self.all_games()] for version in versions: userids.update(self.data.local.user.get_all_players(self.game, version)) return self.get_latest_player_info(list(userids)) def get_network_scores(self, limit: Optional[int]=None) -> Dict[str, Any]: userids: List[UserID] = [] # Find all attempts across all games attempts = [ attempt for attempt in self.data.local.music.get_all_attempts(game=self.game, version=self.version, limit=limit) if attempt[0] is not None ] for attempt in attempts: if attempt[0] not in userids: userids.append(attempt[0]) return { 'attempts': sorted( [self.format_attempt(attempt[0], attempt[1]) for attempt in attempts], reverse=True, key=lambda attempt: (attempt['timestamp'], attempt['songid'], attempt['chart']), ), 'players': self.get_latest_player_info(userids), } def get_network_records(self) -> Dict[str, Any]: records: Dict[str, Tuple[UserID, Score]] = {} userids: List[UserID] = [] # Find all high-scores across all games highscores = self.data.local.music.get_all_records(game=self.game, version=self.version) for score in highscores: index = self.make_index(score[1].id, score[1].chart) if index not in records: records[index] = score if score[0] not in userids: userids.append(score[0]) # Also take care of duplicate IDs (revivals, omnimix, etc) alternate = self.get_duplicate_id(score[1].id, score[1].chart) if alternate is not None: altid, altchart = alternate index = self.make_index(altid, altchart) if index not in records: newscore = copy.deepcopy(score) newscore[1].id = altid newscore[1].chart = altchart records[index] = newscore return { 'records': [ self.format_score(records[index][0], records[index][1]) for index in records ], 'players': self.get_latest_player_info(userids), } def get_scores(self, userid: UserID, limit: Optional[int]=None) -> List[Dict[str, Any]]: # Find all attempts across all games attempts = [ attempt for attempt in self.data.local.music.get_all_attempts(game=self.game, version=self.version, userid=userid, limit=limit) if attempt[0] is not None ] return sorted( [self.format_attempt(None, attempt[1]) for attempt in attempts], reverse=True, key=lambda attempt: (attempt['timestamp'], attempt['songid'], attempt['chart']), ) def get_records(self, userid: UserID) -> List[Dict[str, Any]]: records: Dict[str, Tuple[UserID, Score]] = {} # Find all high-scores across all games highscores = self.data.local.music.get_all_scores(game=self.game, version=self.version, userid=userid) for score in highscores: index = self.make_index(score[1].id, score[1].chart) if index not in records: records[index] = score else: current_score = records[index][1].points current_plays = records[index][1].plays new_score = score[1].points new_plays = score[1].plays if new_score > current_score: records[index] = score records[index][1].plays += current_plays else: records[index][1].plays += new_plays # Copy over records to duplicate IDs, such as revivals indexes = [index for index in records] for index in indexes: alternate = self.get_duplicate_id(records[index][1].id, records[index][1].chart) if alternate is not None: altid, altchart = alternate newindex = self.make_index(altid, altchart) if newindex not in records: newscore = copy.deepcopy(score) newscore[1].id = altid newscore[1].chart = altchart records[newindex] = newscore return [self.format_score(None, records[index][1]) for index in records] def get_top_scores(self, musicid: int) -> Dict[str, Any]: scores = self.data.local.music.get_all_scores(game=self.game, version=self.version, songid=musicid) userids: List[UserID] = [] for score in scores: if score[1].chart not in self.valid_charts: # No beginner chart support continue if score[0] not in userids: userids.append(score[0]) for score in scores: # See if this is a legacy ID if score[1].id != musicid: alternative = self.get_duplicate_id(score[1].id, score[1].chart) if alternative is None: continue oldid, oldchart = alternative if oldid == musicid: score[1].id = oldid score[1].chart = oldchart return { 'topscores': [ self.format_top_score(score[0], score[1]) for score in scores if score[1].chart in self.valid_charts ], 'players': self.get_latest_player_info(userids), } def get_rivals(self, userid: UserID) -> Tuple[Dict[int, List[Dict[str, Any]]], Dict[UserID, Dict[int, Dict[str, Any]]]]: rivals = {} userids = set() versions = [version for (game, version, name) in self.all_games()] profiles = {} for version in versions: profile = self.data.local.user.get_profile(self.game, version, userid) if profile is None: # No profile for this version, so no rivals either. continue profiles[version] = profile rivals[version] = [ link for link in self.data.local.user.get_links(self.game, version, userid) if link.type in self.valid_rival_types ] for rival in rivals[version]: userids.add(rival.other_userid) return ( {version: [self.format_rival(rival, profiles[version]) for rival in rivals[version]] for version in rivals}, self.get_all_player_info(list(userids), allow_remote=True), )