from sqlalchemy import Table, Column, UniqueConstraint from sqlalchemy.exc import IntegrityError from sqlalchemy.types import String, Integer, JSON from sqlalchemy.dialects.mysql import BIGINT as BigInteger from typing import Optional, Dict, List, Tuple, Any from bemani.common import GameConstants, Time from bemani.data.exceptions import ScoreSaveException from bemani.data.mysql.base import BaseData, metadata from bemani.data.types import Score, Attempt, Song, UserID """ Table for storing a score for a particular game. This is keyed by userid and musicid, as a user can only have one score for a particular song/chart combo. This has a JSON blob for any data the game wishes to store, such as points, medals, ghost, etc. Note that this is NOT keyed by game song id and chart, but by an internal musicid managed by the music table. This is so we can support keeping the same score across multiple games, even if the game changes the ID it refers to the song by. """ score = Table( "score", metadata, Column("id", Integer, nullable=False, primary_key=True), Column("userid", BigInteger(unsigned=True), nullable=False), Column("musicid", Integer, nullable=False, index=True), Column("points", Integer, nullable=False, index=True), Column("timestamp", Integer, nullable=False, index=True), Column("update", Integer, nullable=False, index=True), Column("lid", Integer, nullable=False, index=True), Column("data", JSON, nullable=False), UniqueConstraint("userid", "musicid", name="userid_musicid"), mysql_charset="utf8mb4", ) """ Table for storing score history for a particular game. Every entry that is stored or updated in score will be written into this table as well, for looking up history over time. """ score_history = Table( "score_history", metadata, Column("id", Integer, nullable=False, primary_key=True), Column("userid", BigInteger(unsigned=True), nullable=False), Column("musicid", Integer, nullable=False, index=True), Column("points", Integer, nullable=False), Column("timestamp", Integer, nullable=False, index=True), Column("lid", Integer, nullable=False, index=True), Column("new_record", Integer, nullable=False), Column("data", JSON, nullable=False), UniqueConstraint("userid", "musicid", "timestamp", name="userid_musicid_timestamp"), mysql_charset="utf8mb4", ) """ Table for storing the mapping between game songid/chart and musicid for the score and score_history table. To find scores, you will want to join this table with the score table where id = score.musicid and game/version/songid/chart matches. NOTE that it is expected to see the same songid/chart present multiple times as long as the game version changes. In this way, a song which is in multiple versions of the game can be found when playing each version. """ music = Table( "music", metadata, Column("id", Integer, nullable=False, index=True), Column("songid", Integer, nullable=False), Column("chart", Integer, nullable=False), Column("game", String(32), nullable=False, index=True), Column("version", Integer, nullable=False, index=True), Column("name", String(255)), Column("artist", String(255)), Column("genre", String(255)), Column("data", JSON), UniqueConstraint("songid", "chart", "game", "version", name="songid_chart_game_version"), mysql_charset="utf8mb4", ) class MusicData(BaseData): def __get_musicid(self, game: GameConstants, version: int, songid: int, songchart: int) -> int: """ Given a game/version/songid/chart, look up the unique music ID for this song. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. songid - ID of the song according to the game. songchart - Chart number according to the game. Returns: Integer representing music ID if found or raises an exception otherwise. """ sql = "SELECT id FROM music WHERE songid = :songid AND chart = :chart AND game = :game AND version = :version" cursor = self.execute( sql, { "songid": songid, "chart": songchart, "game": game.value, "version": version, }, ) if cursor.rowcount != 1: # music doesn't exist raise Exception(f"Song {songid} chart {songchart} doesn't exist for game {game} version {version}") result = cursor.fetchone() return result["id"] def put_score( self, game: GameConstants, version: int, userid: UserID, songid: int, songchart: int, location: int, points: int, data: Dict[str, Any], new_record: bool, timestamp: Optional[int] = None, ) -> None: """ Given a game/version/song/chart and user ID, save a new/updated high score. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. songid - ID of the song according to the game. songchart - Chart number according to the game. location - Machine ID where this score was earned. points - Points obtained on this song. data - Data that the game wishes to record along with the score. new_record - Whether this score was a new record or not. timestamp - Optional integer specifying when the high score happened. """ # First look up the song/chart from the music DB musicid = self.__get_musicid(game, version, songid, songchart) ts = timestamp if timestamp is not None else Time.now() # Add to user score if new_record: # We want to update the timestamp/location to now if its a new record. sql = """ INSERT INTO `score` (`userid`, `musicid`, `points`, `data`, `timestamp`, `update`, `lid`) VALUES (:userid, :musicid, :points, :data, :timestamp, :update, :location) ON DUPLICATE KEY UPDATE data = VALUES(data), points = VALUES(points), `update` = VALUES(`update`), timestamp = VALUES(timestamp), lid = VALUES(lid) """ else: # We don't want to add the timestamp of the record since it wasn't a new high score. # We also don't want to update thet location since this wasn't a new record. sql = """ INSERT INTO `score` (`userid`, `musicid`, `points`, `data`, `timestamp`, `update`, `lid`) VALUES (:userid, :musicid, :points, :data, :timestamp, :update, :location) ON DUPLICATE KEY UPDATE data = VALUES(data), points = VALUES(points), `update` = VALUES(`update`) """ self.execute( sql, { "userid": userid, "musicid": musicid, "points": points, "data": self.serialize(data), "timestamp": ts, "update": ts, "location": location, }, ) def put_attempt( self, game: GameConstants, version: int, userid: Optional[UserID], songid: int, songchart: int, location: int, points: int, data: Dict[str, Any], new_record: bool, timestamp: Optional[int] = None, ) -> None: """ Given a game/version/song/chart and user ID, save a single score attempt. Note that this is different than put_score above, because a user may have only one score per song/chart in a given game, but they can have as many history entries as times played. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. songid - ID of the song according to the game. songchart - Chart number according to the game. location - Machine ID where this score was earned. points - Points obtained on this song. data - Optional data that the game wishes to record along with the score. new_record - Whether this score was a new record or not. timestamp - Optional integer specifying when the attempt happened. """ # First look up the song/chart from the music DB musicid = self.__get_musicid(game, version, songid, songchart) ts = timestamp if timestamp is not None else Time.now() # Add to score history sql = """ INSERT INTO `score_history` (userid, musicid, timestamp, lid, new_record, points, data) VALUES (:userid, :musicid, :timestamp, :location, :new_record, :points, :data) """ try: self.execute( sql, { "userid": userid if userid is not None else 0, "musicid": musicid, "timestamp": ts, "location": location, "new_record": 1 if new_record else 0, "points": points, "data": self.serialize(data), }, ) except IntegrityError: raise ScoreSaveException( f"There is already an attempt by {userid if userid is not None else 0} for music id {musicid} at {ts}" ) def get_score( self, game: GameConstants, version: int, userid: UserID, songid: int, songchart: int, ) -> Optional[Score]: """ Look up a user's previous high score. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. songid - ID of the song according to the game. songchart - Chart number according to the game. Returns: The optional data stored by the game previously, or None if no score exists. """ sql = """ SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, score.lid AS lid, ( SELECT COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = :userid ) AS plays, score.points AS points, score.data AS data FROM score, music WHERE score.userid = :userid AND score.musicid = music.id AND music.game = :game AND music.version = :version AND music.songid = :songid AND music.chart = :songchart """ cursor = self.execute( sql, { "userid": userid, "game": game.value, "version": version, "songid": songid, "songchart": songchart, }, ) if cursor.rowcount != 1: # score doesn't exist return None result = cursor.fetchone() return Score( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["update"], result["lid"], result["plays"], self.deserialize(result["data"]), ) def get_score_by_key(self, game: GameConstants, version: int, key: int) -> Optional[Tuple[UserID, Score]]: """ Look up previous high score by key. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. key - Integer representing a unique key fetched in a previous Score lookup. Returns: The optional data stored by the game previously, or None if no score exists. """ sql = """ SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, score.userid AS userid, score.lid AS lid, ( SELECT COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = score.userid ) AS plays, score.points AS points, score.data AS data FROM score, music WHERE score.id = :scorekey AND score.musicid = music.id AND music.game = :game AND music.version = :version """ cursor = self.execute( sql, { "game": game.value, "version": version, "scorekey": key, }, ) if cursor.rowcount != 1: # score doesn't exist return None result = cursor.fetchone() return ( UserID(result["userid"]), Score( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["update"], result["lid"], result["plays"], self.deserialize(result["data"]), ), ) def get_scores( self, game: GameConstants, version: int, userid: UserID, since: Optional[int] = None, until: Optional[int] = None, ) -> List[Score]: """ Look up all of a user's previous high scores. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. Returns: A list of Score objects representing all high scores for a game. """ sql = """ SELECT music.songid AS songid, music.chart AS chart, score.id AS scorekey, score.timestamp AS timestamp, score.update AS `update`, score.lid AS lid, ( select COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = music.id AND score_history.userid = :userid ) AS plays, score.points AS points, score.data AS data FROM score, music WHERE score.userid = :userid AND score.musicid = music.id AND music.game = :game AND music.version = :version """ if since is not None: sql = sql + " AND score.update >= :since" if until is not None: sql = sql + " AND score.update < :until" cursor = self.execute( sql, { "userid": userid, "game": game.value, "version": version, "since": since, "until": until, }, ) return [ Score( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["update"], result["lid"], result["plays"], self.deserialize(result["data"]), ) for result in cursor ] def get_most_played(self, game: GameConstants, version: int, userid: UserID, count: int) -> List[Tuple[int, int]]: """ Look up a user's most played songs. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. count - Number of scores to look up. Returns: A list of tuples, containing the songid and the number of plays across all charts for that song. """ sql = """ SELECT music.songid AS songid, COUNT(score_history.timestamp) AS plays FROM score_history, music WHERE score_history.userid = :userid AND score_history.musicid = music.id AND music.game = :game AND music.version = :version GROUP BY songid ORDER BY plays DESC LIMIT :count """ cursor = self.execute( sql, {"userid": userid, "game": game.value, "version": version, "count": count}, ) return [(result["songid"], result["plays"]) for result in cursor] def get_last_played(self, game: GameConstants, version: int, userid: UserID, count: int) -> List[Tuple[int, int]]: """ Look up a user's last played songs. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userid - Integer representing a user. Usually looked up with UserData. count - Number of scores to look up. Returns: A list of tuples, containing the songid and the last played time for this song. """ sql = """ SELECT DISTINCT(music.songid) AS songid, score_history.timestamp AS timestamp FROM score_history, music WHERE score_history.userid = :userid AND score_history.musicid = music.id AND music.game = :game AND music.version = :version ORDER BY timestamp DESC LIMIT :count """ cursor = self.execute( sql, {"userid": userid, "game": game.value, "version": version, "count": count}, ) return [(result["songid"], result["timestamp"]) for result in cursor] def get_hit_chart( self, game: GameConstants, version: int, count: int, days: Optional[int] = None, ) -> List[Tuple[int, int]]: """ Look up a game's most played songs. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. count - Number of scores to look up. Returns: A list of tuples, containing the songid and the number of plays across all charts for that song. """ sql = """ SELECT music.songid AS songid, COUNT(score_history.timestamp) AS plays FROM score_history, music WHERE score_history.musicid = music.id AND music.game = :game AND music.version = :version """ timestamp: Optional[int] = None if days is not None: # Only select the last X days of hit chart sql = sql + "AND score_history.timestamp > :timestamp " timestamp = Time.now() - (Time.SECONDS_IN_DAY * days) sql = sql + "GROUP BY songid ORDER BY plays DESC LIMIT :count" cursor = self.execute( sql, { "game": game.value, "version": version, "count": count, "timestamp": timestamp, }, ) return [(result["songid"], result["plays"]) for result in cursor] def get_song( self, game: GameConstants, version: int, songid: int, songchart: int, ) -> Optional[Song]: """ Given a game/version/songid/chart, look up the name, artist and genre of that song. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. songid - Integer representing the ID (from the game) for this song. songchart - Integer representing the chart for this song. Returns: A Song object representing the song details """ sql = """ SELECT music.name AS name, music.artist AS artist, music.genre AS genre, music.data AS data FROM music WHERE music.game = :game AND music.version = :version AND music.songid = :songid AND music.chart = :songchart """ cursor = self.execute( sql, { "game": game.value, "version": version, "songid": songid, "songchart": songchart, }, ) if cursor.rowcount != 1: # music doesn't exist return None result = cursor.fetchone() return Song( game, version, songid, songchart, result["name"], result["artist"], result["genre"], self.deserialize(result["data"]), ) def get_all_songs( self, game: GameConstants, version: Optional[int] = None, ) -> List[Song]: """ Given a game and a version, look up all song/chart combos associated with that game. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. Returns: A list of Song objects detailing the song information for each song. """ sql = """ SELECT version, songid, chart, name, artist, genre, data FROM music WHERE music.game = :game """ params: Dict[str, Any] = {"game": game.value} if version is not None: sql += " AND music.version = :version" params["version"] = version else: sql += " ORDER BY music.version DESC" cursor = self.execute(sql, params) return [ Song( game, result["version"], result["songid"], result["chart"], result["name"], result["artist"], result["genre"], self.deserialize(result["data"]), ) for result in cursor ] def get_all_scores( self, game: GameConstants, version: Optional[int] = None, userid: Optional[UserID] = None, songid: Optional[int] = None, songchart: Optional[int] = None, since: Optional[int] = None, until: Optional[int] = None, ) -> List[Tuple[UserID, Score]]: """ Look up all of a game's high scores for all users. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. Returns: A list of UserID, Score objects representing all high scores for a game. """ # First, construct the queries for grabbing the songid/chart if version is not None: songidquery = ( "SELECT songid FROM music WHERE music.id = score.musicid AND game = :game AND version = :version" ) chartquery = ( "SELECT chart FROM music WHERE music.id = score.musicid AND game = :game AND version = :version" ) else: songidquery = ( "SELECT songid FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1" ) chartquery = ( "SELECT chart FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1" ) # Select statement for getting play count playselect = "SELECT COUNT(timestamp) FROM score_history WHERE score_history.musicid = score.musicid AND score_history.userid = score.userid" # Now, construct the inner select statement so we can choose which scores we care about innerselect = "SELECT DISTINCT(id) FROM music WHERE game = :game" if version is not None: innerselect = innerselect + " AND version = :version" if songid is not None: innerselect = innerselect + " AND songid = :songid" if songchart is not None: innerselect = innerselect + " AND chart = :songchart" # Finally, construct the full query sql = f""" SELECT ({songidquery}) AS songid, ({chartquery}) AS chart, id AS scorekey, points, timestamp, `update`, lid, data, userid, ({playselect}) AS plays FROM score WHERE musicid IN ({innerselect}) """ # Now, limit the query if userid is not None: sql = sql + " AND userid = :userid" if since is not None: sql = sql + " AND score.update >= :since" if until is not None: sql = sql + " AND score.update < :until" # Now, query itself cursor = self.execute( sql, { "game": game.value, "version": version, "userid": userid, "songid": songid, "songchart": songchart, "since": since, "until": until, }, ) # Objectify result return [ ( UserID(result["userid"]), Score( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["update"], result["lid"], result["plays"], self.deserialize(result["data"]), ), ) for result in cursor ] def get_all_records( self, game: GameConstants, version: Optional[int] = None, userlist: Optional[List[UserID]] = None, locationlist: Optional[List[int]] = None, ) -> List[Tuple[UserID, Score]]: """ Look up all of a game's records, only returning the top score for each song. For score ties, king-of-the-hill rules are in effect, so for two players with an identical top score, the player that got the score last wins. If a list of user IDs is given, we will only look up records pertaining to those users. So if another user has a higher record, we will ignore this. This can be used to display area-local high scores, etc. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. userlist - List of UserIDs to limit the search to. locationlist - A list of location IDs to limit searches to. Returns: A list of UserID, Score objects representing all high scores for a game. """ # First, construct the queries for grabbing the songid/chart if version is not None: songidquery = ( "SELECT songid FROM music WHERE music.id = score.musicid AND game = :game AND version = :version" ) chartquery = ( "SELECT chart FROM music WHERE music.id = score.musicid AND game = :game AND version = :version" ) else: songidquery = ( "SELECT songid FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1" ) chartquery = ( "SELECT chart FROM music WHERE music.id = score.musicid AND game = :game ORDER BY version DESC LIMIT 1" ) # Next, get a list of all songs that were played given the input criteria musicid_sql = ( "SELECT DISTINCT(score.musicid) FROM score, music WHERE score.musicid = music.id AND music.game = :game" ) params: Dict[str, Any] = {"game": game.value} if version is not None: musicid_sql = musicid_sql + " AND music.version = :version" params["version"] = version # Figure out where the record was earned if locationlist is not None: if len(locationlist) == 0: # We don't have any locations, but SQL will shit the bed, so lets add a default one. locationlist.append(-1) location_sql = "AND score.lid IN :locationlist" params["locationlist"] = tuple(locationlist) else: location_sql = "" # Figure out who got the record if userlist is not None: if len(userlist) == 0: # We don't have any users, but SQL will shit the bed, so lets add a fake one. userlist.append(UserID(-1)) user_sql = f"SELECT userid FROM score WHERE score.musicid = played.musicid AND score.userid IN :userlist {location_sql} ORDER BY points DESC, timestamp DESC LIMIT 1" params["userlist"] = tuple(userlist) else: user_sql = f"SELECT userid FROM score WHERE score.musicid = played.musicid {location_sql} ORDER BY points DESC, timestamp DESC LIMIT 1" records_sql = f""" SELECT ({user_sql}) AS userid, musicid FROM ({musicid_sql}) played """ # Now, join it up against the score and music table to grab the info we need sql = f""" SELECT ({songidquery}) AS songid, ({chartquery}) AS chart, score.points AS points, score.userid AS userid, score.id AS scorekey, score.data AS data, score.timestamp AS timestamp, score.update AS `update`, score.lid AS lid, ( SELECT COUNT(score_history.timestamp) FROM score_history WHERE score_history.musicid = score.musicid ) AS plays FROM score, ({records_sql}) records WHERE records.userid = score.userid AND records.musicid = score.musicid """ cursor = self.execute(sql, params) return [ ( UserID(result["userid"]), Score( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["update"], result["lid"], result["plays"], self.deserialize(result["data"]), ), ) for result in cursor ] def get_attempt_by_key(self, game: GameConstants, version: int, key: int) -> Optional[Tuple[UserID, Attempt]]: """ Look up a previous attempt by key. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. key - Integer representing a unique key fetched in a previous Attempt lookup. Returns: The optional data stored by the game previously, or None if no score exists. """ sql = """ SELECT music.songid AS songid, music.chart AS chart, score_history.id AS scorekey, score_history.timestamp AS timestamp, score_history.userid AS userid, score_history.lid AS lid, score_history.new_record AS new_record, score_history.points AS points, score_history.data AS data FROM score_history, music WHERE score_history.id = :scorekey AND score_history.musicid = music.id AND music.game = :game AND music.version = :version """ cursor = self.execute( sql, { "game": game.value, "version": version, "scorekey": key, }, ) if cursor.rowcount != 1: # score doesn't exist return None result = cursor.fetchone() return ( UserID(result["userid"]), Attempt( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["lid"], result["new_record"] == 1, self.deserialize(result["data"]), ), ) def get_all_attempts( self, game: GameConstants, version: Optional[int] = None, userid: Optional[UserID] = None, songid: Optional[int] = None, songchart: Optional[int] = None, timelimit: Optional[int] = None, limit: Optional[int] = None, offset: Optional[int] = None, ) -> List[Tuple[Optional[UserID], Attempt]]: """ Look up all of the attempts to score for a particular game. Parameters: game - Enum value representing a game series. version - Integer representing which version of the game. Returns: A list of UserID, Attempt objects representing all score attempts for a game, sorted newest to oldest attempts. """ # First, construct the queries for grabbing the songid/chart if version is not None: songidquery = "SELECT songid FROM music WHERE music.id = score_history.musicid AND game = :game AND version = :version" chartquery = ( "SELECT chart FROM music WHERE music.id = score_history.musicid AND game = :game AND version = :version" ) else: songidquery = "SELECT songid FROM music WHERE music.id = score_history.musicid AND game = :game ORDER BY version DESC LIMIT 1" chartquery = "SELECT chart FROM music WHERE music.id = score_history.musicid AND game = :game ORDER BY version DESC LIMIT 1" # Now, construct the inner select statement so we can choose which scores we care about innerselect = "SELECT DISTINCT(id) FROM music WHERE game = :game" if version is not None: innerselect = innerselect + " AND version = :version" if songid is not None: innerselect = innerselect + " AND songid = :songid" if songchart is not None: innerselect = innerselect + " AND chart = :songchart" # Finally, construct the full query sql = f""" SELECT ({songidquery}) AS songid, ({chartquery}) AS chart, id AS scorekey, timestamp, points, new_record, lid, data, userid FROM score_history WHERE musicid IN ({innerselect}) """ # Now, limit the query if userid is not None: sql = sql + " AND userid = :userid" if timelimit is not None: sql = sql + " AND timestamp >= :timestamp" sql = sql + " ORDER BY timestamp DESC" if limit is not None: sql = sql + " LIMIT :limit" if offset is not None: sql = sql + " OFFSET :offset" # Now, query itself cursor = self.execute( sql, { "game": game.value, "version": version, "userid": userid, "songid": songid, "songchart": songchart, "timestamp": timelimit, "limit": limit, "offset": offset, }, ) # Now objectify the attempts return [ ( UserID(result["userid"]) if result["userid"] > 0 else None, Attempt( result["scorekey"], result["songid"], result["chart"], result["points"], result["timestamp"], result["lid"], result["new_record"] == 1, self.deserialize(result["data"]), ), ) for result in cursor ]