import random from sqlalchemy import Table, Column, UniqueConstraint # type: ignore from sqlalchemy.types import String, Integer, JSON # type: ignore from sqlalchemy.dialects.mysql import BIGINT as BigInteger # type: ignore from sqlalchemy.exc import IntegrityError # type: ignore from typing import Optional, Dict, List, Tuple, Any from typing_extensions import Final from passlib.hash import pbkdf2_sha512 # type: ignore from bemani.common import ValidatedDict, Profile, GameConstants, Time from bemani.data.mysql.base import BaseData, metadata from bemani.data.remoteuser import RemoteUser from bemani.data.types import User, Achievement, Link, UserID, ArcadeID """ Table representing a user. Each user has a unique ID and a pin which is used with all cards associated with the user's account. Username and password are optional as a user does not need to create a web login to use the network. However, an active user account is required before creating a web login. """ user = Table( "user", metadata, Column("id", Integer, nullable=False, primary_key=True), Column("pin", String(4), nullable=False), Column("username", String(255), unique=True), Column("password", String(255)), Column("email", String(255)), Column("admin", Integer), mysql_charset="utf8mb4", ) """ Table representing a card associated with a user. Users may have zero or more cards associated with them. When a new card is used in a game a new user will be created to associate with a card, but it can later be unlinked. """ card = Table( "card", metadata, Column("id", String(16), nullable=False, unique=True), Column("userid", BigInteger(unsigned=True), nullable=False, index=True), mysql_charset="utf8mb4", ) """ Table representing an extid for a user across a game series. Each game series on the network gets its own extid (8 digit number) for each user. """ extid = Table( "extid", metadata, Column("game", String(32), nullable=False), Column("extid", Integer, nullable=False, unique=True), Column("userid", BigInteger(unsigned=True), nullable=False), UniqueConstraint("game", "userid", name="game_userid"), mysql_charset="utf8mb4", ) """ Table representing a refid for a user. Each unique game on the network will need a refid for each user/game/version they have a profile for. If a user does not have a profile for a particular game, a new and unique refid will be generated for the user. Note that a user might have an extid/refid for a game without a profile, but a user cannot have a profile without an extid/refid. """ refid = Table( "refid", metadata, Column("game", String(32), nullable=False), Column("version", Integer, nullable=False), Column("refid", String(16), nullable=False, unique=True), Column("userid", BigInteger(unsigned=True), nullable=False), UniqueConstraint("game", "version", "userid", name="game_version_userid"), mysql_charset="utf8mb4", ) """ Table for storing JSON profile blobs, indexed by refid. """ profile = Table( "profile", metadata, Column("refid", String(16), nullable=False, unique=True), Column("data", JSON, nullable=False), mysql_charset="utf8mb4", ) """ Table for storing game achievements. An achievement is just a blob of data with a unique ID and type. Games are free to store a JSON blob for each achievement. Examples would be tran medals, event unlocks, items earned, etc. """ achievement = Table( "achievement", metadata, Column("refid", String(16), nullable=False), Column("id", Integer, nullable=False), Column("type", String(64), nullable=False), Column("data", JSON, nullable=False), UniqueConstraint("refid", "id", "type", name="refid_id_type"), mysql_charset="utf8mb4", ) """ Table for storing time-based achievements. A time-based achievement is almost identical to a regular achievement, but you can earn multiple of the same type of achievement at different times, and it matters when you earn it. Games are free to store a JSON blob for each achievement and the blob does not need to be equal across different instances of the same achievement for the same user. Examples would be calorie earnings for DDR. """ time_based_achievement = Table( "time_based_achievement", metadata, Column("refid", String(16), nullable=False), Column("id", Integer, nullable=False), Column("type", String(64), nullable=False), Column("timestamp", Integer, nullable=False, index=True), Column("data", JSON, nullable=False), UniqueConstraint( "refid", "id", "type", "timestamp", name="refid_id_type_timestamp" ), mysql_charset="utf8mb4", ) """ Table for storing a user's PASELI balance, given an arcade. There is no global balance on this network. """ balance = Table( "balance", metadata, Column("userid", BigInteger(unsigned=True), nullable=False), Column("arcadeid", Integer, nullable=False), Column("balance", Integer, nullable=False), UniqueConstraint("userid", "arcadeid", name="userid_arcadeid"), mysql_charset="utf8mb4", ) """ Table for storing links between two users in a game/version, whatever that may be. Typically used for rivals. etc. """ link = Table( "link", metadata, Column("game", String(32), nullable=False), Column("version", Integer, nullable=False), Column("userid", BigInteger(unsigned=True), nullable=False), Column("type", String(64), nullable=False), Column("other_userid", BigInteger(unsigned=True), nullable=False), Column("data", JSON, nullable=False), UniqueConstraint( "game", "version", "userid", "type", "other_userid", name="game_version_userid_type_other_uuserid", ), mysql_charset="utf8mb4", ) class AccountCreationException(Exception): pass class UserData(BaseData): REF_ID_LENGTH: Final[int] = 16 def from_cardid(self, cardid: str) -> Optional[UserID]: """ Given a 16 digit card ID, look up a user ID. Note that this is the E004 number as stored on the card. Not the 16 digit ASCII value on the back. Use CardCipher to convert. Parameters: cardid - 16-digit card ID to look for. Returns: User ID as an integer if found, or None if not. """ # First, look up the user account sql = "SELECT userid FROM card WHERE id = :id" cursor = self.execute(sql, {"id": cardid}) if cursor.rowcount != 1: # Couldn't find a user with this card return None result = cursor.fetchone() return UserID(result["userid"]) def from_username(self, username: str) -> Optional[UserID]: """ Given a username, look up a user ID. Parameters: username - A string representing the user's username. Returns: User ID as an integer if found, or None if not. """ sql = "SELECT id FROM user WHERE username = :username" cursor = self.execute(sql, {"username": username}) if cursor.rowcount != 1: # Couldn't find this username return None result = cursor.fetchone() return UserID(result["id"]) def from_refid( self, game: GameConstants, version: int, refid: str ) -> Optional[UserID]: """ Given a generated RefID, look up a user ID. Note that there is a unique RefID and ExtID for each profile, and both can be used to look up a user. When creating a new profile, we generate a unique RefID and ExtID. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. refid - RefID in question, most likely previously generated by this class. Returns: User ID as an integer if found, or None if not. """ # First, look up the user account sql = "SELECT userid FROM refid WHERE game = :game AND version = :version AND refid = :refid" cursor = self.execute( sql, {"game": game.value, "version": version, "refid": refid} ) if cursor.rowcount != 1: # Couldn't find a user with this refid return None result = cursor.fetchone() return UserID(result["userid"]) def from_extid( self, game: GameConstants, version: int, extid: int ) -> Optional[UserID]: """ Given a generated ExtID, look up a user ID. Note that there is a unique RefID and ExtID for each profile, and both can be used to look up a user. When creating a new profile, we generate a unique RefID and ExtID. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. extid - ExtID in question, most likely previously generated by this class. Returns: User ID as an integer if found, or None if not. """ # First, look up the user account sql = "SELECT userid FROM extid WHERE game = :game AND extid = :extid" cursor = self.execute(sql, {"game": game.value, "extid": extid}) if cursor.rowcount != 1: # Couldn't find a user with this refid return None result = cursor.fetchone() return UserID(result["userid"]) def from_session(self, session: str) -> Optional[UserID]: """ Given a previously-opened session, look up a user ID. Parameters: session - String identifying a session that was opened by create_session. Returns: User ID as an integer if found, or None if the session is expired or doesn't exist. """ userid = self._from_session(session, "userid") if userid is None: return None return UserID(userid) def get_user(self, userid: UserID) -> Optional[User]: """ Given a userid, look up details about the account. Parameters: userid - Integer user ID, as looked up by one of the above functions. Returns: A User object if found, or None otherwise. """ sql = "SELECT username, email, admin FROM user WHERE id = :userid" cursor = self.execute(sql, {"userid": userid}) if cursor.rowcount != 1: # User doesn't exist, but we have a reference? return None result = cursor.fetchone() return User(userid, result["username"], result["email"], result["admin"] == 1) def get_all_users(self) -> List[User]: """ Look up all users in the system. Returns: A list of User objects representing all users. """ sql = "SELECT id, username, email, admin FROM user" cursor = self.execute(sql) return [ User( UserID(result["id"]), result["username"], result["email"], result["admin"] == 1, ) for result in cursor.fetchall() ] def get_all_usernames(self) -> List[str]: """ Look up all valid usernames in the system. Parameters: userid - Integer user ID, as looked up by one of the above functions. Returns: A list of strings representing usernames. """ sql = "SELECT username FROM user WHERE username is not null" cursor = self.execute(sql) return [res["username"] for res in cursor.fetchall()] def get_all_cards(self) -> List[Tuple[str, UserID]]: """ Look up all cards associated with any account. Returns: A list of Tuples representing representing card ID, user ID pairs. """ sql = "SELECT id, userid FROM card" cursor = self.execute(sql) return [ (str(res["id"]).upper(), UserID(res["userid"])) for res in cursor.fetchall() ] def get_cards(self, userid: UserID) -> List[str]: """ Given a userid, look up all cards associated with the account. Parameters: userid - Integer user ID, as looked up by one of the above functions. Returns: A list of strings representing card IDs. """ sql = "SELECT id FROM card WHERE userid = :userid" cursor = self.execute(sql, {"userid": userid}) return [str(res["id"]).upper() for res in cursor.fetchall()] def add_card(self, userid: UserID, cardid: str) -> None: """ Given a user ID and a card ID, link that card with that user. Note that this is the E004 number as stored on the card. Not the 16 digit ASCII value on the back. Use CardCipher to convert. Parameters: userid - Integer user ID, as looked up by one of the above functions. cardid - 16-digit card ID to add. """ sql = "INSERT INTO card (userid, id) VALUES (:userid, :cardid)" self.execute(sql, {"userid": userid, "cardid": cardid}) oldid = RemoteUser.card_to_userid(cardid) if RemoteUser.is_remote(oldid): # Kill any refid/extid that related to this card, since its now associated # with another existing account. sql = "DELETE FROM extid WHERE userid = :oldid" self.execute(sql, {"oldid": oldid}) sql = "DELETE FROM refid WHERE userid = :oldid" self.execute(sql, {"oldid": oldid}) # Point at the new account for any rivals against this card. Note that this # might result in a duplicate rival, but its a very small edge case. sql = "UPDATE link SET other_userid = :newid WHERE other_userid = :oldid" self.execute(sql, {"newid": userid, "oldid": oldid}) def destroy_card(self, userid: UserID, cardid: str) -> None: """ Given a user ID and a card ID, remove the card ID link from that user. Note that this is the E004 number as stored on the card. Not the 16 digit ASCII value on the back. Use CardCipher to convert. Parameters: userid - Integer user ID, as looked up by one of the above functions. cardid - 16-digit card ID to remove. """ sql = "DELETE FROM card WHERE id = :cardid AND userid = :userid LIMIT 1" self.execute(sql, {"cardid": cardid, "userid": userid}) def put_user(self, user: User) -> None: """ Given a user object, update the DB to save new user info. Parameters: user - A user, which has optional values set. """ sql = "UPDATE user SET username = :username, email = :email, admin = :admin WHERE id = :userid" self.execute( sql, { "username": user.username, "email": user.email, "admin": 1 if user.admin else 0, "userid": user.id, }, ) def validate_pin(self, userid: UserID, pin: str) -> bool: """ Given a userid and PIN, validate the PIN. Parameters: userid - Integer user ID, as looked up by one of the above functions. pin - 4 digit string returned by the game for PIN entry. Returns: True if PIN is valid, False otherwise. """ sql = "SELECT pin FROM user WHERE id = :userid" cursor = self.execute(sql, {"userid": userid}) if cursor.rowcount != 1: # User doesn't exist, but we have a reference? return False result = cursor.fetchone() return pin == result["pin"] def update_pin(self, userid: UserID, pin: str) -> None: """ Given a userid and a new PIN, update the PIN for that user. Parameters: userid - Integer user ID, as looked up by one of the above functions. pin - 4 digit string returned by the game for PIN entry. """ sql = "UPDATE user SET pin = :pin WHERE id = :userid" self.execute(sql, {"pin": pin, "userid": userid}) def validate_password(self, userid: UserID, password: str) -> bool: """ Given a password, validate that the password matches the stored hash Parameters: userid - Integer user ID, as looked up by one of the above functions. password - String, plaintext password that will be hashed Returns: True if password is valid, False otherwise. """ sql = "SELECT password FROM user WHERE id = :userid" cursor = self.execute(sql, {"userid": userid}) if cursor.rowcount != 1: # User doesn't exist, but we have a reference? return False result = cursor.fetchone() passhash = result["password"] try: # Verifying the password return pbkdf2_sha512.verify(password, passhash) except (ValueError, TypeError): return False def update_password(self, userid: UserID, password: str) -> None: """ Given a userid and a new password, update the password for that user. Parameters: userid - Integer user ID, as looked up by one of the above functions. password - String, plaintext password that will be hashed """ passhash = pbkdf2_sha512.hash(password) sql = "UPDATE user SET password = :hash WHERE id = :userid" self.execute(sql, {"hash": passhash, "userid": userid}) def get_profile( self, game: GameConstants, version: int, userid: UserID ) -> Optional[Profile]: """ Given a game/version/userid, look up the associated profile. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: A dictionary previously stored by a game class if found, or None otherwise. """ sql = ( "SELECT refid.refid AS refid, extid.extid AS extid, profile.data AS data " + "FROM refid, extid, profile " + "WHERE refid.userid = :userid AND refid.game = :game AND refid.version = :version AND " "extid.userid = refid.userid AND extid.game = refid.game AND profile.refid = refid.refid" ) cursor = self.execute( sql, {"userid": userid, "game": game.value, "version": version} ) if cursor.rowcount != 1: # Profile doesn't exist return None result = cursor.fetchone() return Profile( game, version, result["refid"], result["extid"], self.deserialize(result["data"]), ) def get_any_profile( self, game: GameConstants, version: int, userid: UserID ) -> Optional[Profile]: """ Given a game/version/userid, look up the associated profile. If the profile for that version doesn't exist, try another profile, failing only if there is no profile for any version of this game. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: A dictionary previously stored by a game class if found, or None otherwise. """ played = self.get_games_played(userid, game=game) versions = {p[1] for p in played} if version in versions: return self.get_profile(game, version, userid) elif len(versions) > 0: return self.get_profile(game, max(versions), userid) else: return None def get_any_profiles( self, game: GameConstants, version: int, userids: List[UserID] ) -> List[Tuple[UserID, Optional[Profile]]]: """ Does the exact same thing as get_any_profile but across a list of users instead of one. Provided purely as a convenience function. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userids - List of Integer user IDs, as looked up by one of the above functions. Returns: A List of tuples containing a userid and a dictionary previously stored by a game class if found, or None otherwise. """ if not userids: return [] sql = "SELECT version, userid FROM refid WHERE game = :game AND userid IN :userids AND refid IN (SELECT refid FROM profile)" cursor = self.execute(sql, {"game": game.value, "userids": userids}) profilever: Dict[UserID, int] = {} for result in cursor.fetchall(): tuid = UserID(result["userid"]) tver = result["version"] if tuid not in profilever: # Just assign it the first profile we find profilever[tuid] = tver else: # If the profile for this version exists, prioritize it if tver == version: profilever[tuid] = tver # Only update the profile version with the newest game profile if the game # profile for this version doesn't exist. elif profilever[tuid] != version: profilever[tuid] = max(profilever[tuid], tver) result = [] for uid in userids: if uid not in profilever: result.append((uid, None)) else: result.append((uid, self.get_profile(game, profilever[uid], uid))) return result def get_games_played( self, userid: UserID, game: Optional[GameConstants] = None ) -> List[Tuple[GameConstants, int]]: """ Given a user ID, look up all game/version combos this user has played. Parameters: userid - Integer user ID, as looked up by one of the above functions. game - An optional game series to constrain search to. Returns: A List of Tuples of game, version for each game/version the user has played. """ sql = "SELECT game, version FROM refid WHERE userid = :userid AND refid IN (SELECT refid FROM profile)" vals: Dict[str, Any] = {"userid": userid} if game is not None: sql += " AND game = :game" vals["game"] = game.value cursor = self.execute(sql, vals) profiles = [] for result in cursor.fetchall(): profiles.append((GameConstants(result["game"]), result["version"])) return profiles def get_all_profiles( self, game: GameConstants, version: int ) -> List[Tuple[UserID, Profile]]: """ Given a game/version, look up all user profiles for that game. Parameters: game - Enum value identifier of the game we want all user profiles for. version - Integer version of the game we want all user profiles for. Returns: A list of (UserID, dictionaries) previously stored by a game class for each profile. """ sql = ( "SELECT refid.userid AS userid, refid.refid AS refid, extid.extid AS extid, profile.data AS data " "FROM refid, profile, extid " "WHERE refid.game = :game AND refid.version = :version " "AND refid.refid = profile.refid AND extid.game = refid.game AND extid.userid = refid.userid" ) cursor = self.execute(sql, {"game": game.value, "version": version}) profiles = [] for result in cursor.fetchall(): profiles.append( ( UserID(result["userid"]), Profile( game, version, result["refid"], result["extid"], self.deserialize(result["data"]), ), ) ) return profiles def get_all_players(self, game: GameConstants, version: int) -> List[UserID]: """ Given a game/version, look up all user IDs that played this game/version. Parameters: game - Enum value identifier of the game we want all user profiles for. version - Integer version of the game we want all user profiles for. Returns: A list of UserIDs for users that played this version of this game. """ sql = ( "SELECT refid.userid AS userid FROM refid " "WHERE refid.game = :game AND refid.version = :version" ) cursor = self.execute(sql, {"game": game.value, "version": version}) return [UserID(result["userid"]) for result in cursor.fetchall()] def get_all_achievements( self, game: GameConstants, version: int, achievementid: Optional[int] = None, achievementtype: Optional[str] = None, ) -> List[Tuple[UserID, Achievement]]: """ Given a game/version, find all achievements for all players. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. Returns: A list of (UserID, Achievement) objects. """ sql = ( "SELECT achievement.id AS id, achievement.type AS type, achievement.data AS data, " "refid.userid AS userid FROM achievement, refid WHERE refid.game = :game AND " "refid.version = :version AND refid.refid = achievement.refid" ) params: Dict[str, Any] = {"game": game.value, "version": version} if achievementtype is not None: sql += " AND achievement.type = :type" params["type"] = achievementtype if achievementid is not None: sql += " AND achievement.id = :id" params["id"] = achievementid cursor = self.execute(sql, params) achievements = [] for result in cursor.fetchall(): achievements.append( ( UserID(result["userid"]), Achievement( result["id"], result["type"], None, self.deserialize(result["data"]), ), ) ) return achievements def put_profile( self, game: GameConstants, version: int, userid: UserID, profile: Profile ) -> None: """ Given a game/version/userid, save an associated profile. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. profile - A dictionary that a game class will want to retrieve later. """ refid = self.get_refid(game, version, userid) # Add profile json to game profile sql = ( "INSERT INTO profile (refid, data) " + "VALUES (:refid, :json) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" ) self.execute(sql, {"refid": refid, "json": self.serialize(profile)}) # Update profile details just in case this was a new profile that was just saved. profile.game = game profile.version = version profile.refid = refid if profile.extid == 0: profile.extid = self.get_extid(game, version, userid) def delete_profile(self, game: GameConstants, version: int, userid: UserID) -> None: """ Given a game/version/userid, delete any associated profile. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. """ refid = self.get_refid(game, version, userid) # Delete profile JSON to unlink the profile for this game/version. sql = "DELETE FROM profile WHERE refid = :refid LIMIT 1" self.execute(sql, {"refid": refid}) def get_achievement( self, game: GameConstants, version: int, userid: UserID, achievementid: int, achievementtype: str, ) -> Optional[ValidatedDict]: """ Given a game/version/userid and achievement id/type, find that achievement. Note that there can be more than one achievement with the same ID and game/version/userid as long as each one is a different type. Essentially, achievementtype namespaces achievements. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. achievementid - Integer ID, as provided by a game. achievementtype - The type of achievement. Returns: A dictionary as stored by a game class previously, or None if not found. """ refid = self.get_refid(game, version, userid) sql = "SELECT data FROM achievement WHERE refid = :refid AND id = :id AND type = :type" cursor = self.execute( sql, {"refid": refid, "id": achievementid, "type": achievementtype} ) if cursor.rowcount != 1: # score doesn't exist return None result = cursor.fetchone() return ValidatedDict(self.deserialize(result["data"])) def get_achievements( self, game: GameConstants, version: int, userid: UserID ) -> List[Achievement]: """ Given a game/version/userid, find all achievements Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: A list of Achievement objects. """ refid = self.get_refid(game, version, userid) sql = "SELECT id, type, data FROM achievement WHERE refid = :refid" cursor = self.execute(sql, {"refid": refid}) achievements = [] for result in cursor.fetchall(): achievements.append( Achievement( result["id"], result["type"], None, self.deserialize(result["data"]), ) ) return achievements def put_achievement( self, game: GameConstants, version: int, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any], ) -> None: """ Given a game/version/userid and achievement id/type, save an achievement. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. achievementid - Integer ID, as provided by a game. achievementtype - The type of achievement. data - A dictionary of data that the game wishes to retrieve later. """ refid = self.get_refid(game, version, userid) # Add achievement JSON to achievements sql = ( "INSERT INTO achievement (refid, id, type, data) " + "VALUES (:refid, :id, :type, :data) " + "ON DUPLICATE KEY UPDATE data=VALUES(data)" ) self.execute( sql, { "refid": refid, "id": achievementid, "type": achievementtype, "data": self.serialize(data), }, ) def destroy_achievement( self, game: GameConstants, version: int, userid: UserID, achievementid: int, achievementtype: str, ) -> None: """ Given a game/version/userid and achievement id/type, delete an achievement. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. achievementid - Integer ID, as provided by a game. achievementtype - The type of achievement. """ refid = self.get_refid(game, version, userid) # Nuke the achievement from the user sql = ( "DELETE FROM achievement WHERE refid = :refid AND id = :id AND type = :type" ) self.execute( sql, {"refid": refid, "id": achievementid, "type": achievementtype} ) def get_time_based_achievements( self, game: GameConstants, version: int, userid: UserID, achievementtype: Optional[str] = None, since: Optional[int] = None, until: Optional[int] = None, ) -> List[Achievement]: """ Given a game/version/userid, find all time-based achievements Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. achievementtype - Optional string specifying to constrain to a type of achievement. since - Return achievements since this time (inclusive). until - Return achievements until this time (exclusive). Returns: A list of Achievement objects. """ refid = self.get_refid(game, version, userid) sql = "SELECT id, type, timestamp, data FROM time_based_achievement WHERE refid = :refid" if achievementtype is not None: sql += " AND type = :type" if since is not None: sql += " AND timestamp >= :since" if until is not None: sql += " AND timestamp < :until" cursor = self.execute( sql, {"refid": refid, "type": achievementtype, "since": since, "until": until}, ) achievements = [] for result in cursor.fetchall(): achievements.append( Achievement( result["id"], result["type"], result["timestamp"], self.deserialize(result["data"]), ) ) return achievements def put_time_based_achievement( self, game: GameConstants, version: int, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any], ) -> None: """ Given a game/version/userid and achievement id/type, save a time-based achievement. Assumes that time-based achievements are immutable once saved. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. achievementid - Integer ID, as provided by a game. achievementtype - The type of achievement. data - A dictionary of data that the game wishes to retrieve later. """ refid = self.get_refid(game, version, userid) # Add achievement JSON to achievements sql = ( "INSERT INTO time_based_achievement (refid, id, type, timestamp, data) " + "VALUES (:refid, :id, :type, :ts, :data)" ) self.execute( sql, { "refid": refid, "id": achievementid, "type": achievementtype, "ts": Time.now(), "data": self.serialize(data), }, ) def get_all_time_based_achievements( self, game: GameConstants, version: int ) -> List[Tuple[UserID, Achievement]]: """ Given a game/version, find all time-based achievements for all players. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. Returns: A list of (UserID, Achievement) objects. """ sql = ( "SELECT time_based_achievement.id AS id, time_based_achievement.type AS type, " "time_based_achievement.data AS data, time_based_achievement.timestamp AS timestamp, " "refid.userid AS userid FROM time_based_achievement, refid WHERE refid.game = :game AND " "refid.version = :version AND refid.refid = time_based_achievement.refid" ) cursor = self.execute(sql, {"game": game.value, "version": version}) achievements = [] for result in cursor.fetchall(): achievements.append( ( UserID(result["userid"]), Achievement( result["id"], result["type"], result["timestamp"], self.deserialize(result["data"]), ), ) ) return achievements def get_link( self, game: GameConstants, version: int, userid: UserID, linktype: str, other_userid: UserID, ) -> Optional[ValidatedDict]: """ Given a game/version/userid and link type + other userid, find that link. Note that there can be more than one link with the same user IDs and game/version as long as each one is a different type. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. linktype - The type of link. other_userid - Integer user ID of the account we're linked to. Returns: A dictionary as stored by a game class previously, or None if not found. """ sql = "SELECT data FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" cursor = self.execute( sql, { "game": game.value, "version": version, "userid": userid, "type": linktype, "other_userid": other_userid, }, ) if cursor.rowcount != 1: # score doesn't exist return None result = cursor.fetchone() return ValidatedDict(self.deserialize(result["data"])) def get_links( self, game: GameConstants, version: int, userid: UserID ) -> List[Link]: """ Given a game/version/userid, find all links between this user and other users Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: A list of Link objects. """ sql = "SELECT type, other_userid, data FROM link WHERE game = :game AND version = :version AND userid = :userid" cursor = self.execute( sql, {"game": game.value, "version": version, "userid": userid} ) links = [] for result in cursor.fetchall(): links.append( Link( userid, result["type"], UserID(result["other_userid"]), self.deserialize(result["data"]), ) ) return links def put_link( self, game: GameConstants, version: int, userid: UserID, linktype: str, other_userid: UserID, data: Dict[str, Any], ) -> None: """ Given a game/version/userid and link id + other_userid, save an link. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. linktype - The type of link. other_userid - Integer user ID of the account we're linked to. data - A dictionary of data that the game wishes to retrieve later. """ # Add link JSON to link sql = ( "INSERT INTO link (game, version, userid, type, other_userid, data) " "VALUES (:game, :version, :userid, :type, :other_userid, :data) " "ON DUPLICATE KEY UPDATE data=VALUES(data)" ) self.execute( sql, { "game": game.value, "version": version, "userid": userid, "type": linktype, "other_userid": other_userid, "data": self.serialize(data), }, ) def destroy_link( self, game: GameConstants, version: int, userid: UserID, linktype: str, other_userid: UserID, ) -> None: """ Given a game/version/userid and link id + other_userid, destroy the link. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. linktype - The type of link. other_userid - Integer user ID of the account we're linked to. """ sql = "DELETE FROM link WHERE game = :game AND version = :version AND userid = :userid AND type = :type AND other_userid = :other_userid" self.execute( sql, { "game": game.value, "version": version, "userid": userid, "type": linktype, "other_userid": other_userid, }, ) def get_balance(self, userid: UserID, arcadeid: ArcadeID) -> int: """ Given a user and an arcade ID, look up the user's PASELI balance for that arcade. Parameters: userid - The user ID in question, as looked up by this class. arcadeid - The arcade in question. Returns: The PASELI balance for this user at this arcade. """ sql = "SELECT balance FROM balance WHERE userid = :userid AND arcadeid = :arcadeid" cursor = self.execute(sql, {"userid": userid, "arcadeid": arcadeid}) if cursor.rowcount == 1: result = cursor.fetchone() return result["balance"] else: return 0 def update_balance( self, userid: UserID, arcadeid: ArcadeID, delta: int ) -> Optional[int]: """ Given a user and an arcade ID, update the PASELI balance for that arcade. Parameters: userid - The user ID in question, as looked up by this class. arcadeid - The arcade in question. delta - The value to add (or subtract, if delta is negative). Returns: The new PASELI balance if successful, or None if there wasn't enough to apply the delta. """ sql = ( "INSERT INTO balance (userid, arcadeid, balance) VALUES (:userid, :arcadeid, :delta) " "ON DUPLICATE KEY UPDATE balance = balance + :delta" ) self.execute(sql, {"delta": delta, "userid": userid, "arcadeid": arcadeid}) newbalance = self.get_balance(userid, arcadeid) if newbalance < 0: # Went under while grabbing, put the balance back and return nothing sql = "UPDATE balance SET balance = balance - :delta WHERE userid = :userid AND arcadeid = :arcadeid" self.execute(sql, {"delta": delta, "userid": userid, "arcadeid": arcadeid}) return None return newbalance def get_refid(self, game: GameConstants, version: int, userid: UserID) -> str: """ Given a game/version and user ID, look up the RefID for the profile. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: The RefID associated with the profile for this user. If there isn't one, creates one and returns it, which can be used for creating/looking up a profile in the future. """ sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" cursor = self.execute( sql, {"userid": userid, "game": game.value, "version": version} ) if cursor.rowcount == 1: result = cursor.fetchone() return result["refid"] else: return self.create_refid(game, version, userid) def get_extid(self, game: GameConstants, version: int, userid: UserID) -> int: """ Given a game/version and a user ID, look up the ExtID for the profile. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: The ExtID associated with the profile for this user. If there isn't one, creates one in the same manner as get_refid() above. """ def fetch_extid() -> Optional[int]: sql = "SELECT extid FROM extid WHERE userid = :userid AND game = :game" cursor = self.execute(sql, {"userid": userid, "game": game.value}) if cursor.rowcount == 1: result = cursor.fetchone() return result["extid"] else: return None extid = fetch_extid() if extid is not None: return extid else: self.create_refid(game, version, userid) extid = fetch_extid() if extid is not None: return extid else: raise AccountCreationException() def create_session(self, userid: UserID, expiration: int = (30 * 86400)) -> str: """ Given a user ID, create a session string. Parameters: userid - User ID we wish to start a session for. expiration - Number of seconds before this session is invalid. Returns: A string that can be used as a session ID. """ return self._create_session(userid, "userid", expiration) def destroy_session(self, session: str) -> None: """ Destroy a previously-created session. Parameters: session - A session string as returned from create_session. """ self._destroy_session(session, "userid") def create_refid(self, game: GameConstants, version: int, userid: UserID) -> str: """ Given a game/version/userid, create a RefID and an ExtID if necessary. Note that while this function returns the created RefID, an ExtID is also created and stored in the DB. Both RefID and ExtID are guaranteed to be unique, but the RefID is guaranteed unique for each profile while ExtID is guaranteed unique for each game series/user. Parameters: game - Enum value identifier of the game looking up the user. version - Integer version of the game looking up the user. userid - Integer user ID, as looked up by one of the above functions. Returns: A string RefID value. """ # Create a new extid that is unique while True: extid = random.randint(0, 89999999) + 10000000 sql = "SELECT extid FROM extid WHERE extid = :extid" cursor = self.execute(sql, {"extid": extid}) if cursor.rowcount == 0: break # Use that extid sql = ( "INSERT INTO extid (game, extid, userid) " + "VALUES (:game, :extid, :userid)" ) try: cursor = self.execute( sql, {"game": game.value, "extid": extid, "userid": userid} ) except IntegrityError: # User already has an ExtID for this game series pass # Create a new refid that is unique while True: refid = "".join( random.choice("0123456789ABCDEF") for _ in range(UserData.REF_ID_LENGTH) ) sql = "SELECT refid FROM refid WHERE refid = :refid" cursor = self.execute(sql, {"refid": refid}) if cursor.rowcount == 0: break # Use that refid sql = ( "INSERT INTO refid (game, version, refid, userid) " + "VALUES (:game, :version, :refid, :userid)" ) try: cursor = self.execute( sql, { "game": game.value, "version": version, "refid": refid, "userid": userid, }, ) if cursor.rowcount != 1: raise AccountCreationException() return refid except IntegrityError: # We maybe lost the race? Look up the ID from another creation. Don't call get_refid # because it calls us, so we don't want an infinite loop. sql = "SELECT refid FROM refid WHERE userid = :userid AND game = :game AND version = :version" cursor = self.execute( sql, {"userid": userid, "game": game.value, "version": version} ) if cursor.rowcount == 1: result = cursor.fetchone() return result["refid"] # Shouldn't be possible, but here we are raise AccountCreationException() def create_account(self, cardid: str, pin: str) -> Optional[UserID]: """ Given a Card ID and a PIN, create a new account. Parameters: cardid - 16-digit card ID of the card we are creating an account for. pin - Four digit PIN as entered by the user on a cabinet. Returns: A User ID if creation was successful, or None otherwise. """ # First, create a user account sql = "INSERT INTO user (pin, admin) VALUES (:pin, 0)" cursor = self.execute(sql, {"pin": pin}) if cursor.rowcount != 1: return None userid = cursor.lastrowid # Now, insert the card, tying it to the account sql = "INSERT INTO card (id, userid) VALUES (:cardid, :userid)" cursor = self.execute(sql, {"cardid": cardid, "userid": userid}) if cursor.rowcount != 1: return None # Now, if this user played on a remote network and their profile # was ever fetched locally or they were ever rivaled against, # convert those locally too so that players don't lose rivals # on new account creation. oldid = RemoteUser.card_to_userid(cardid) if RemoteUser.is_remote(oldid): sql = "UPDATE extid SET userid = :newid WHERE userid = :oldid" self.execute(sql, {"newid": userid, "oldid": oldid}) sql = "UPDATE refid SET userid = :newid WHERE userid = :oldid" self.execute(sql, {"newid": userid, "oldid": oldid}) sql = "UPDATE link SET other_userid = :newid WHERE other_userid = :oldid" self.execute(sql, {"newid": userid, "oldid": oldid}) # Finally, return the user ID return userid