2023-11-22 23:35:47 +01:00
|
|
|
import json
|
|
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
|
|
import platformdirs
|
|
|
|
import requests_cache
|
|
|
|
import psycopg2.extras
|
|
|
|
|
2023-11-23 12:52:56 +01:00
|
|
|
import hanabi.live.hanab_live
|
|
|
|
import hanabi.hanab_game
|
|
|
|
|
2023-11-22 23:35:47 +01:00
|
|
|
import constants
|
2023-11-23 13:18:38 +01:00
|
|
|
import games_db_interface
|
2023-11-22 23:35:47 +01:00
|
|
|
import database
|
2023-11-23 12:52:56 +01:00
|
|
|
import utils
|
2023-11-22 23:35:47 +01:00
|
|
|
from database import conn_manager
|
|
|
|
from log_setup import logger
|
|
|
|
from config import config_manager
|
|
|
|
|
|
|
|
# This ensures that we do not pose too many requests to the website, especially while in development where we
|
|
|
|
# might frequently re-initialize the database.
|
|
|
|
session = requests_cache.CachedSession(
|
|
|
|
platformdirs.user_cache_dir(constants.APP_NAME) + 'hanab.live.requests-cache',
|
|
|
|
urls_expire_after={
|
|
|
|
# Game exports will never change, so cache them forever
|
|
|
|
'hanab.live/export/*': requests_cache.NEVER_EXPIRE
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class GameInfo:
|
2023-11-23 13:38:23 +01:00
|
|
|
def __init__(self, game_id: int, num_players: int, variant_id: int, seed: str, score: int, num_turns: int,
|
|
|
|
user_ids: List[int], normalized_usernames: List[str]):
|
2023-11-22 23:35:47 +01:00
|
|
|
self.game_id = game_id
|
|
|
|
self.num_players = num_players
|
|
|
|
self.variant_id = variant_id
|
|
|
|
self.seed = seed
|
|
|
|
self.score = score
|
|
|
|
self.num_turns = num_turns
|
|
|
|
self.user_ids = user_ids
|
|
|
|
self.normalized_usernames = normalized_usernames
|
|
|
|
|
2023-11-22 23:50:20 +01:00
|
|
|
|
2023-11-22 23:35:47 +01:00
|
|
|
def fetch_games_for_player(username: str, latest_game_id: int):
|
2023-11-22 23:50:20 +01:00
|
|
|
logger.verbose("Fetching games for username {} more recent than id {}".format(username, latest_game_id))
|
2023-11-22 23:35:47 +01:00
|
|
|
url = "https://hanab.live/api/v1/history-full/{}?start={}".format(username, latest_game_id + 1)
|
|
|
|
response = session.get(url)
|
|
|
|
if not response.status_code == 200:
|
|
|
|
err_msg = "Failed to fetch games for username {}, requested URL {}".format(username, url)
|
|
|
|
logger.error(err_msg)
|
|
|
|
raise ConnectionError(err_msg)
|
|
|
|
return json.loads(response.text)
|
|
|
|
|
|
|
|
|
|
|
|
def process_game_entry(game_json: Dict, username_dict: Dict, variant_ids: List[int]) -> Optional[GameInfo]:
|
|
|
|
logger.debug("Processing entry {}".format(game_json))
|
|
|
|
config = config_manager.get_config()
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
# Parse game properties
|
2023-11-22 23:35:47 +01:00
|
|
|
game_id = game_json["id"]
|
|
|
|
players = game_json["playerNames"]
|
|
|
|
num_players = len(players)
|
|
|
|
seed = game_json["seed"]
|
|
|
|
score = game_json["score"]
|
|
|
|
num_turns = game_json["numTurns"]
|
|
|
|
|
|
|
|
game_options = game_json["options"]
|
|
|
|
var_id = game_options["variantID"]
|
|
|
|
|
2023-11-23 12:52:56 +01:00
|
|
|
normalized_usernames = [utils.normalize_username(username) for username in players]
|
2023-11-22 23:35:47 +01:00
|
|
|
|
2023-11-23 12:52:56 +01:00
|
|
|
# Now, check if the game is one that we accept for league
|
|
|
|
|
|
|
|
if not utils.are_game_options_allowed(game_id, game_options):
|
|
|
|
return
|
2023-11-22 23:35:47 +01:00
|
|
|
|
2023-11-23 12:52:56 +01:00
|
|
|
if not utils.is_player_count_allowed(game_id, num_players):
|
2023-11-22 23:35:47 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
if var_id not in variant_ids:
|
|
|
|
logger.debug("Rejected game {} due to invalid variant id {}".format(game_id, var_id))
|
|
|
|
return
|
|
|
|
|
|
|
|
# Everything matches, so we can parse the participants now
|
|
|
|
user_ids = [] # This will be a list of the (league specific) user_id's that played this game.
|
|
|
|
for normalized_username in normalized_usernames:
|
|
|
|
user_id = username_dict.get(normalized_username, None)
|
|
|
|
if user_id is None:
|
|
|
|
logger.debug("Rejected game {} due to unregistered participant {}".format(game_id, normalized_username))
|
|
|
|
return
|
|
|
|
user_ids.append(user_id)
|
|
|
|
|
|
|
|
return GameInfo(game_id, num_players, 0, seed, score, num_turns, user_ids, normalized_usernames)
|
|
|
|
|
|
|
|
|
|
|
|
def fetch_games_for_all_players():
|
|
|
|
logger.info("Fetching new games.")
|
|
|
|
cur = conn_manager.get_new_cursor()
|
|
|
|
cur.execute("SELECT user_accounts.normalized_username, user_accounts.user_id, downloads.latest_game_id "
|
|
|
|
"FROM user_accounts "
|
|
|
|
"LEFT OUTER JOIN downloads "
|
|
|
|
" ON user_accounts.normalized_username = downloads.normalized_username"
|
|
|
|
)
|
|
|
|
# This will be a mapping of normalized username -> user ID that we built from the DB data
|
|
|
|
username_dict = {}
|
|
|
|
|
|
|
|
# This will be a mapping of game id -> JSON data that we get from hanab.live, where we will collect all the
|
|
|
|
# possibly relevant games now
|
|
|
|
games: Dict[int, Dict] = {}
|
|
|
|
for username, user_id, latest_game_id in cur.fetchall():
|
|
|
|
username_dict[username] = user_id
|
|
|
|
# Use the starting id as fallback if we have not downloaded any games for this player yet.
|
|
|
|
if latest_game_id is None:
|
|
|
|
latest_game_id = config_manager.get_config().starting_game_id
|
|
|
|
|
|
|
|
player_games = fetch_games_for_player(username, latest_game_id)
|
|
|
|
for game in player_games:
|
|
|
|
games[game['id']] = game
|
|
|
|
|
2023-11-23 00:06:00 +01:00
|
|
|
logger.info("Found {} potential league game(s) to process.".format(len(games)))
|
|
|
|
|
2023-11-22 23:35:47 +01:00
|
|
|
allowed_variants = database.get_variant_ids()
|
|
|
|
|
|
|
|
# This will hold the processed games that we will add to the database.
|
|
|
|
good_games: Dict[int, GameInfo] = {}
|
|
|
|
|
|
|
|
for game_id, game in games.items():
|
|
|
|
game_info = process_game_entry(game, username_dict, allowed_variants)
|
|
|
|
if game_info is not None:
|
|
|
|
good_games[game_id] = game_info
|
|
|
|
|
2023-11-23 00:06:00 +01:00
|
|
|
logger.verbose("Found {} valid league game(s).".format(len(good_games)))
|
2023-11-22 23:35:47 +01:00
|
|
|
return good_games
|
|
|
|
|
|
|
|
|
|
|
|
def store_new_games(games: Dict[int, GameInfo]):
|
|
|
|
conn = conn_manager.get_connection()
|
|
|
|
cur = conn.cursor()
|
|
|
|
games_vals = []
|
|
|
|
game_participants_vals = []
|
|
|
|
latest_game_ids: Dict[str, int] = {}
|
|
|
|
|
|
|
|
# Now, iterate over all games and convert to tuples for insertion
|
|
|
|
for game in sorted(games.values(), key=lambda game_info: game_info.game_id):
|
|
|
|
tup = (game.game_id, game.num_players, game.variant_id, game.seed, game.score, game.num_turns)
|
|
|
|
games_vals.append(tup)
|
|
|
|
for player_id in game.user_ids:
|
|
|
|
tup = (game.game_id, player_id)
|
|
|
|
game_participants_vals.append(tup)
|
|
|
|
|
|
|
|
# Note that this gives the maximum in the end since we process the games in order
|
|
|
|
for normalized_username in game.normalized_usernames:
|
|
|
|
latest_game_ids[normalized_username] = game.game_id
|
|
|
|
|
|
|
|
# Do the insertions
|
2023-11-22 23:50:20 +01:00
|
|
|
# Notice that on conflict we can just do nothing: In that case, we already have the game in the DB for some reason
|
|
|
|
# (for example, because we forced a download refresh)
|
2023-11-22 23:35:47 +01:00
|
|
|
psycopg2.extras.execute_values(
|
|
|
|
cur,
|
|
|
|
"INSERT INTO games (id, num_players, variant_id, seed, score, num_turns) "
|
2023-11-22 23:50:20 +01:00
|
|
|
"VALUES %s "
|
|
|
|
"ON CONFLICT (id) DO NOTHING",
|
2023-11-22 23:35:47 +01:00
|
|
|
games_vals
|
|
|
|
)
|
|
|
|
psycopg2.extras.execute_values(
|
|
|
|
cur,
|
|
|
|
"INSERT INTO game_participants (game_id, user_id) "
|
2023-11-22 23:50:20 +01:00
|
|
|
"VALUES %s "
|
|
|
|
"ON CONFLICT (game_id, user_id) DO NOTHING",
|
2023-11-22 23:35:47 +01:00
|
|
|
game_participants_vals
|
|
|
|
)
|
2023-11-22 23:50:20 +01:00
|
|
|
# Here, we want to update on insertion conflict
|
2023-11-22 23:35:47 +01:00
|
|
|
psycopg2.extras.execute_values(
|
|
|
|
cur,
|
|
|
|
"INSERT INTO downloads (normalized_username, latest_game_id) "
|
2023-11-22 23:50:20 +01:00
|
|
|
"VALUES %s "
|
|
|
|
"ON CONFLICT (normalized_username) "
|
|
|
|
"DO UPDATE SET (normalized_username, latest_game_id) = (EXCLUDED.normalized_username, EXCLUDED.latest_game_id)",
|
2023-11-22 23:35:47 +01:00
|
|
|
latest_game_ids.items()
|
|
|
|
)
|
2023-11-23 00:06:00 +01:00
|
|
|
logger.info("Added {} game(s) to database.".format(len(games)))
|
2023-11-22 23:50:20 +01:00
|
|
|
# We only commit after performing all insertions. This guarantees that the download table is always in sync
|
|
|
|
# with the actual games stored in the database.
|
2023-11-22 23:35:47 +01:00
|
|
|
conn.commit()
|
2023-11-23 09:28:13 +01:00
|
|
|
|
|
|
|
|
2023-11-23 13:18:38 +01:00
|
|
|
def detailed_fetch_game(game_id: int) -> bool:
|
|
|
|
"""
|
|
|
|
Fetches full game details from the server and stores it in local DB if this game is a league game.
|
|
|
|
@param game_id: Game ID from hanab.live
|
|
|
|
@return: Whether the processed game was accepted as a league game, i.e. inserted into the DB
|
|
|
|
"""
|
2023-11-23 13:38:23 +01:00
|
|
|
logger.verbose("Fetching details on game {}.".format(game_id))
|
2023-11-23 09:28:13 +01:00
|
|
|
url = "https://hanab.live/export/{}".format(game_id)
|
|
|
|
response = session.get(url)
|
|
|
|
if not response.status_code == 200:
|
|
|
|
err_msg = "Failed to fetch game {}, requested URL {}".format(game_id, url)
|
|
|
|
logger.error(err_msg)
|
|
|
|
raise ConnectionError(err_msg)
|
|
|
|
game_json = json.loads(response.text)
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
instance, actions = hanabi.live.hanab_live.parse_json_game(game_json, False)
|
|
|
|
|
|
|
|
game_id = game_json["id"]
|
|
|
|
players = game_json["players"]
|
|
|
|
num_players = len(players)
|
|
|
|
seed = game_json["seed"]
|
|
|
|
game_options = game_json.get("options", {})
|
|
|
|
var_name = game_options.get("variant", "No Variant")
|
|
|
|
|
|
|
|
if not utils.are_game_options_allowed(game_id, game_options):
|
2023-11-23 13:18:38 +01:00
|
|
|
return False
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
if not utils.is_player_count_allowed(game_id, num_players):
|
2023-11-23 13:18:38 +01:00
|
|
|
return False
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
var_id = database.get_variant_id(var_name)
|
|
|
|
if var_id is None:
|
|
|
|
logger.debug("Rejected game {} due to invalid variant id {}".format(game_id, var_id))
|
2023-11-23 13:18:38 +01:00
|
|
|
return False
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
# All game options are ok, now check if the number of players is okay.
|
|
|
|
normalized_usernames = [utils.normalize_username(username) for username in players]
|
|
|
|
user_ids = database.get_user_ids_from_normalized_usernames(normalized_usernames)
|
|
|
|
# The return value here is a str if there was an unregistered participant
|
|
|
|
if type(user_ids) is str:
|
|
|
|
logger.debug("Rejected game {} due to unregistered participant {}".format(game_id, user_ids))
|
2023-11-23 13:18:38 +01:00
|
|
|
return False
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
# Now, we can start to actually process the game details
|
|
|
|
instance, actions = hanabi.live.hanab_live.parse_json_game(game_json, False)
|
|
|
|
game = hanabi.hanab_game.GameState(instance)
|
2023-11-23 13:18:38 +01:00
|
|
|
# In order to figure out the score, we will need to play the game once
|
2023-11-23 12:52:56 +01:00
|
|
|
for action in actions:
|
|
|
|
game.make_action(action)
|
|
|
|
|
|
|
|
conn = conn_manager.get_connection()
|
2023-11-23 09:28:13 +01:00
|
|
|
cur = conn_manager.get_new_cursor()
|
2023-11-23 12:52:56 +01:00
|
|
|
# Insert the game into the database if not present
|
|
|
|
cur.execute("INSERT INTO games "
|
|
|
|
"(id, num_players, variant_id, seed, score, num_turns) "
|
|
|
|
"VALUES "
|
|
|
|
"(%s, %s, %s, %s, %s, %s) "
|
|
|
|
"ON CONFLICT (id) DO NOTHING",
|
|
|
|
(game_id, num_players, var_id, seed, game.score, len(actions)))
|
2023-11-23 12:56:02 +01:00
|
|
|
# TODO Max: Check if len(actions) is the correct number here, in case there was a VTK action, we might want to subtract one
|
2023-11-23 12:52:56 +01:00
|
|
|
|
|
|
|
game_participants_vals = []
|
|
|
|
for seat, user_id in enumerate(user_ids):
|
|
|
|
tup = (game_id, user_id, seat)
|
|
|
|
game_participants_vals.append(tup)
|
|
|
|
|
|
|
|
# This inserts the game participants now.
|
|
|
|
# Note that the participants might already be stored, but not their seat (since we do not know the seat when only
|
|
|
|
# getting a game list from the server on fetching the games played by some player)
|
|
|
|
psycopg2.extras.execute_values(
|
|
|
|
cur,
|
|
|
|
"INSERT INTO game_participants "
|
|
|
|
"(game_id, user_id, seat) "
|
|
|
|
"VALUES %s "
|
|
|
|
"ON CONFLICT (game_id, user_id) DO UPDATE "
|
|
|
|
"SET seat = EXCLUDED.seat",
|
|
|
|
game_participants_vals
|
|
|
|
)
|
|
|
|
# DB is now in a consistent state again: We made sure the game and its participants are added.
|
|
|
|
conn.commit()
|
2023-11-23 13:18:38 +01:00
|
|
|
|
|
|
|
# It remains to store the seed and action data for this game
|
2023-11-23 13:38:23 +01:00
|
|
|
# Note that we store the seed first. This ensures that whenever we have actions stored, we also have the seed stored.
|
2023-11-23 13:18:38 +01:00
|
|
|
games_db_interface.store_deck_for_seed(seed, instance.deck)
|
|
|
|
games_db_interface.store_actions(game_id, actions)
|
2023-11-23 13:38:23 +01:00
|
|
|
logger.debug("Fetched all game details of game {}.".format(game_id))
|
2023-11-23 14:00:25 +01:00
|
|
|
|
|
|
|
# Do some sanity checks that loading the stored data did not change it
|
|
|
|
assert actions == games_db_interface.load_actions(game_id)
|
|
|
|
assert instance.deck == games_db_interface.load_deck(seed)
|
2023-11-23 13:18:38 +01:00
|
|
|
return True
|
2023-11-23 13:38:23 +01:00
|
|
|
|
|
|
|
|
|
|
|
def fetch_all_game_details():
|
|
|
|
logger.info("Fetching detailed game data for all games.")
|
|
|
|
cur = conn_manager.get_new_cursor()
|
|
|
|
|
|
|
|
# Get all games with no actions
|
|
|
|
cur.execute("SELECT id FROM games "
|
|
|
|
"LEFT OUTER JOIN game_actions"
|
|
|
|
" ON games.id = game_actions.game_id "
|
|
|
|
"WHERE game_actions.game_id IS NULL"
|
|
|
|
)
|
|
|
|
for (game_id,) in cur.fetchall():
|
|
|
|
detailed_fetch_game(game_id)
|
|
|
|
|
|
|
|
# Just to make sure, check that we have data on all seeds
|
|
|
|
cur.execute("SELECT id FROM games "
|
|
|
|
"LEFT OUTER JOIN seeds"
|
|
|
|
" ON seeds.seed = games.seed "
|
|
|
|
"WHERE seeds.seed IS NULL"
|
|
|
|
)
|
|
|
|
games_without_seeds = [game_id for (game_id,) in cur.fetchall()]
|
|
|
|
if len(games_without_seeds) != 0:
|
|
|
|
logger.warn("Detected the following games without seed data, but with actions already stored: {}."
|
|
|
|
"Fetching them now".format(", ".join(games_without_seeds))
|
|
|
|
)
|
|
|
|
for game_id in games_without_seeds:
|
|
|
|
detailed_fetch_game(game_id)
|