2019-12-08 22:43:49 +01:00
|
|
|
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 typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
from bemani.common import ValidatedDict, Time
|
|
|
|
from bemani.data.mysql.base import BaseData, metadata
|
|
|
|
from bemani.data.types import Achievement, Item, UserID
|
|
|
|
|
|
|
|
"""
|
|
|
|
Table for storing game settings that span multiple versions of the same
|
|
|
|
game, such as play statistics. This table intentionally doesn't have a
|
|
|
|
key on game version, just game string and userid.
|
|
|
|
"""
|
|
|
|
game_settings = Table( # type: ignore
|
|
|
|
'game_settings',
|
|
|
|
metadata,
|
|
|
|
Column('game', String(32), nullable=False),
|
|
|
|
Column('userid', BigInteger(unsigned=True), nullable=False),
|
|
|
|
Column('data', JSON, nullable=False),
|
|
|
|
UniqueConstraint('game', 'userid', name='game_userid'),
|
|
|
|
mysql_charset='utf8mb4',
|
|
|
|
)
|
|
|
|
|
|
|
|
"""
|
|
|
|
Table for storing shop items that are server-side verified.
|
|
|
|
"""
|
|
|
|
catalog = Table( # type: ignore
|
|
|
|
'catalog',
|
|
|
|
metadata,
|
|
|
|
Column('game', String(32), nullable=False),
|
|
|
|
Column('version', Integer, nullable=False),
|
|
|
|
Column('id', Integer, nullable=False),
|
|
|
|
Column('type', String(64), nullable=False),
|
|
|
|
Column('data', JSON, nullable=False),
|
|
|
|
UniqueConstraint('game', 'version', 'id', 'type', name='game_version_id_type'),
|
|
|
|
mysql_charset='utf8mb4',
|
|
|
|
)
|
|
|
|
|
|
|
|
"""
|
|
|
|
Table for storing series achievements that span multiple versions of the same
|
|
|
|
game, such as course scores. This table intentionally doesn't have a
|
|
|
|
key on game version, just game string and userid.
|
|
|
|
"""
|
|
|
|
series_achievement = Table( # type: ignore
|
|
|
|
'series_achievement',
|
|
|
|
metadata,
|
|
|
|
Column('game', String(32), nullable=False),
|
|
|
|
Column('userid', BigInteger(unsigned=True), nullable=False),
|
|
|
|
Column('id', Integer, nullable=False),
|
|
|
|
Column('type', String(64), nullable=False),
|
|
|
|
Column('data', JSON, nullable=False),
|
|
|
|
UniqueConstraint('game', 'userid', 'id', 'type', name='game_userid_id_type'),
|
|
|
|
mysql_charset='utf8mb4',
|
|
|
|
)
|
|
|
|
|
|
|
|
"""
|
|
|
|
Table for storing time-based game settings that aren't tied to a user
|
|
|
|
account, such as dailies, weeklies, etc.
|
|
|
|
"""
|
|
|
|
time_sensitive_settings = Table( # type: ignore
|
|
|
|
'time_sensitive_settings',
|
|
|
|
metadata,
|
|
|
|
Column('game', String(32), nullable=False),
|
|
|
|
Column('version', Integer, nullable=False),
|
|
|
|
Column('name', String(32), nullable=False),
|
|
|
|
Column('start_time', Integer, nullable=False, index=True),
|
|
|
|
Column('end_time', Integer, nullable=False, index=True),
|
|
|
|
Column('data', JSON, nullable=False),
|
|
|
|
UniqueConstraint('game', 'version', 'name', 'start_time', name='game_version_name_start_time'),
|
|
|
|
mysql_charset='utf8mb4',
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class GameData(BaseData):
|
|
|
|
|
|
|
|
def get_settings(self, game: str, userid: UserID) -> Optional[ValidatedDict]:
|
|
|
|
"""
|
|
|
|
Given a game and a user ID, look up game-wide settings as a dictionary.
|
|
|
|
|
|
|
|
It is expected that game classes call this function, and provide a consistent
|
|
|
|
game name from version to version, so game settings can be looked up across
|
|
|
|
all versions in a game series.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifying a game series.
|
|
|
|
userid - Integer identifying a user, as possibly looked up by UserData.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A dictionary representing game settings stored by a game class, or None
|
|
|
|
if there are no settings for this game/user.
|
|
|
|
"""
|
|
|
|
sql = "SELECT data FROM game_settings WHERE game = :game AND userid = :userid"
|
|
|
|
cursor = self.execute(sql, {'game': game, 'userid': userid})
|
|
|
|
|
|
|
|
if cursor.rowcount != 1:
|
|
|
|
# Settings doesn't exist
|
|
|
|
return None
|
|
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
return ValidatedDict(self.deserialize(result['data']))
|
|
|
|
|
|
|
|
def put_settings(self, game: str, userid: UserID, settings: Dict[str, Any]) -> None:
|
|
|
|
"""
|
|
|
|
Given a game and a user ID, save game-wide settings to the DB.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifying a game series.
|
|
|
|
userid - Integer identifying a user.
|
|
|
|
settings - A dictionary of settings that a game wishes to retrieve later.
|
|
|
|
"""
|
|
|
|
# Add settings json to game settings
|
|
|
|
sql = (
|
|
|
|
"INSERT INTO game_settings (game, userid, data) " +
|
|
|
|
"VALUES (:game, :userid, :data) " +
|
|
|
|
"ON DUPLICATE KEY UPDATE data=VALUES(data)"
|
|
|
|
)
|
|
|
|
self.execute(sql, {'game': game, 'userid': userid, 'data': self.serialize(settings)})
|
|
|
|
|
|
|
|
def get_achievement(self, game: str, userid: UserID, achievementid: int, achievementtype: str) -> Optional[ValidatedDict]:
|
|
|
|
"""
|
|
|
|
Given a game/userid and achievement id/type, find that achievement.
|
|
|
|
|
|
|
|
Note that there can be more than one achievement with the same ID and game/userid
|
|
|
|
as long as each one is a different type. Essentially, achievementtype namespaces achievements.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier 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.
|
|
|
|
"""
|
|
|
|
sql = (
|
|
|
|
"SELECT data FROM series_achievement "
|
|
|
|
"WHERE game = :game AND userid = :userid AND id = :id AND type = :type"
|
|
|
|
)
|
|
|
|
cursor = self.execute(sql, {'game': game, 'userid': userid, '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: str, userid: UserID) -> List[Achievement]:
|
|
|
|
"""
|
|
|
|
Given a game/userid, find all achievements
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier 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.
|
|
|
|
"""
|
|
|
|
sql = "SELECT id, type, data FROM series_achievement WHERE game = :game AND userid = :userid"
|
|
|
|
cursor = self.execute(sql, {'game': game, 'userid': userid})
|
|
|
|
|
|
|
|
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: str, userid: UserID, achievementid: int, achievementtype: str, data: Dict[str, Any]) -> None:
|
|
|
|
"""
|
|
|
|
Given a game/userid and achievement id/type, save an achievement.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier 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.
|
|
|
|
"""
|
|
|
|
# Add achievement JSON to achievements
|
|
|
|
sql = (
|
|
|
|
"INSERT INTO series_achievement (game, userid, id, type, data) " +
|
|
|
|
"VALUES (:game, :userid, :id, :type, :data) " +
|
|
|
|
"ON DUPLICATE KEY UPDATE data=VALUES(data)"
|
|
|
|
)
|
|
|
|
self.execute(sql, {'game': game, 'userid': userid, 'id': achievementid, 'type': achievementtype, 'data': self.serialize(data)})
|
|
|
|
|
|
|
|
def get_time_sensitive_settings(self, game: str, version: int, name: str) -> Optional[ValidatedDict]:
|
|
|
|
"""
|
|
|
|
Given a game/version/name, look up the current time-sensitive settings for this game.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier of the game we want settings for.
|
|
|
|
version - Integer identifying the game version we want settings for.
|
|
|
|
name - The name of the setting we are concerned with.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A ValidatedDict of stored settings if the current setting is found, or None otherwise.
|
|
|
|
If settings were found, they are guaranteed to include the attributes 'start_time' and
|
|
|
|
'end_time' which will both be seconds since the unix epoch (UTC).
|
|
|
|
"""
|
|
|
|
sql = (
|
|
|
|
"SELECT data, start_time, end_time FROM time_sensitive_settings WHERE "
|
|
|
|
"game = :game AND version = :version AND name = :name AND start_time <= :time AND end_time > :time"
|
|
|
|
)
|
|
|
|
cursor = self.execute(sql, {'game': game, 'version': version, 'name': name, 'time': Time.now()})
|
|
|
|
if cursor.rowcount != 1:
|
|
|
|
# setting doesn't exist
|
|
|
|
return None
|
|
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
retval = ValidatedDict(self.deserialize(result['data']))
|
|
|
|
retval['start_time'] = result['start_time']
|
|
|
|
retval['end_time'] = result['end_time']
|
|
|
|
return retval
|
|
|
|
|
|
|
|
def get_all_time_sensitive_settings(self, game: str, version: int, name: str) -> List[ValidatedDict]:
|
|
|
|
"""
|
|
|
|
Given a game/version/name, look up all of the time-sensitive settings for this game.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier of the game we want settings for.
|
|
|
|
version - Integer identifying the game version we want settings for.
|
|
|
|
name - The name of the setting we are concerned with.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A list of ValidatedDict of stored settings if there were settings found, or [] otherwise.
|
|
|
|
If settings were found, they are guaranteed to include the attributes 'start_time' and
|
|
|
|
'end_time' which will both be seconds since the unix epoch (UTC).
|
|
|
|
"""
|
|
|
|
sql = (
|
|
|
|
"SELECT data, start_time, end_time FROM time_sensitive_settings WHERE "
|
|
|
|
"game = :game AND version = :version AND name = :name"
|
|
|
|
)
|
|
|
|
cursor = self.execute(sql, {'game': game, 'version': version, 'name': name})
|
|
|
|
if cursor.rowcount == 0:
|
|
|
|
# setting doesn't exist
|
|
|
|
return []
|
|
|
|
|
|
|
|
settings = []
|
|
|
|
for result in cursor.fetchall():
|
|
|
|
retval = ValidatedDict(self.deserialize(result['data']))
|
|
|
|
retval['start_time'] = result['start_time']
|
|
|
|
retval['end_time'] = result['end_time']
|
|
|
|
settings.append(retval)
|
|
|
|
return settings
|
|
|
|
|
|
|
|
def put_time_sensitive_settings(self, game: str, version: int, name: str, settings: Dict[str, Any]) -> None:
|
|
|
|
"""
|
|
|
|
Given a game/version/name and a settings dictionary that contains 'start_time' and 'end_time',
|
|
|
|
as seconds since the unix epoch (UTC), update the DB to store or update this time-sensitive
|
|
|
|
setting. Verifies that start time comes before end time, that there is at least one second in
|
|
|
|
the setting duration, and that this setting doesn't overlap any other setting already present.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier of the game we want settings for.
|
|
|
|
version - Integer identifying the game version we want settings for.
|
|
|
|
name - The name of the setting we are concerned with.
|
|
|
|
settings - A dictionary containing at least 'start_time' and 'end_time'.
|
|
|
|
"""
|
|
|
|
start_time = settings['start_time']
|
|
|
|
end_time = settings['end_time']
|
|
|
|
del settings['start_time']
|
|
|
|
del settings['end_time']
|
|
|
|
|
|
|
|
if start_time > end_time:
|
|
|
|
raise Exception('Start time is greater than end time!')
|
|
|
|
if start_time == end_time:
|
|
|
|
raise Exception('This setting spans zero seconds!')
|
|
|
|
|
|
|
|
# Verify that this isn't overlapping some event.
|
|
|
|
sql = """
|
|
|
|
SELECT start_time, end_time FROM time_sensitive_settings WHERE
|
|
|
|
game = :game AND version = :version AND name = :name AND
|
|
|
|
(
|
|
|
|
(start_time >= :start_time AND start_time < :end_time) OR
|
|
|
|
(end_time > :start_time AND end_time <= :end_time) OR
|
|
|
|
(start_time < :start_time AND end_time > :end_time)
|
|
|
|
)
|
|
|
|
"""
|
|
|
|
cursor = self.execute(
|
|
|
|
sql,
|
|
|
|
{
|
|
|
|
'game': game,
|
|
|
|
'version': version,
|
|
|
|
'name': name,
|
|
|
|
'start_time': start_time,
|
|
|
|
'end_time': end_time,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
for result in cursor.fetchall():
|
|
|
|
if result['start_time'] == start_time and result['end_time'] == end_time:
|
|
|
|
# This is just this event being updated, that's fine.
|
|
|
|
continue
|
2020-01-07 22:29:07 +01:00
|
|
|
raise Exception(f'This event overlaps an existing one with start time {result["start_time"]} and end time {result["end_time"]}')
|
2019-12-08 22:43:49 +01:00
|
|
|
|
|
|
|
# Insert or update this setting
|
|
|
|
sql = (
|
|
|
|
"INSERT INTO time_sensitive_settings (game, version, name, start_time, end_time, data) "
|
|
|
|
"VALUES (:game, :version, :name, :start_time, :end_time, :data) "
|
|
|
|
"ON DUPLICATE KEY UPDATE data=VALUES(data)"
|
|
|
|
)
|
|
|
|
self.execute(
|
|
|
|
sql,
|
|
|
|
{
|
|
|
|
'game': game,
|
|
|
|
'version': version,
|
|
|
|
'name': name,
|
|
|
|
'start_time': start_time,
|
|
|
|
'end_time': end_time,
|
|
|
|
'data': self.serialize(settings),
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
|
|
|
def get_item(self, game: str, version: int, catid: int, cattype: str) -> Optional[ValidatedDict]:
|
|
|
|
"""
|
|
|
|
Given a game/userid and catalog id/type, find that catalog entry.
|
|
|
|
|
|
|
|
Note that there can be more than one catalog entry with the same ID and game/userid
|
|
|
|
as long as each one is a different type. Essentially, cattype namespaces catalog entry.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier of the game looking up this entry.
|
|
|
|
version - Integer identifier of the version looking up this entry.
|
|
|
|
catid - Integer ID, as provided by a game.
|
|
|
|
cattype - The type of catalog entry.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A dictionary as stored by a game class previously, or None if not found.
|
|
|
|
"""
|
|
|
|
sql = (
|
|
|
|
"SELECT data FROM catalog "
|
|
|
|
"WHERE game = :game AND version = :version AND id = :id AND type = :type"
|
|
|
|
)
|
|
|
|
cursor = self.execute(sql, {'game': game, 'version': version, 'id': catid, 'type': cattype})
|
|
|
|
if cursor.rowcount != 1:
|
|
|
|
# entry doesn't exist
|
|
|
|
return None
|
|
|
|
|
|
|
|
result = cursor.fetchone()
|
|
|
|
return ValidatedDict(self.deserialize(result['data']))
|
|
|
|
|
|
|
|
def get_items(self, game: str, version: int) -> List[Item]:
|
|
|
|
"""
|
|
|
|
Given a game/userid, find all items in the catalog.
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
game - String identifier of the game looking up the catalog.
|
|
|
|
version - Integer identifier of the version looking up this catalog.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
A list of Item objects.
|
|
|
|
"""
|
|
|
|
sql = "SELECT id, type, data FROM catalog WHERE game = :game AND version = :version"
|
|
|
|
cursor = self.execute(sql, {'game': game, 'version': version})
|
|
|
|
|
|
|
|
catalog = []
|
|
|
|
for result in cursor.fetchall():
|
|
|
|
catalog.append(
|
|
|
|
Item(
|
|
|
|
result['type'],
|
|
|
|
result['id'],
|
|
|
|
self.deserialize(result['data']),
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
return catalog
|