# 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), )