From d717a9df36c4b56e0401baa3447be4dbbee1d3ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Tue, 9 Jan 2024 15:10:15 +0100 Subject: [PATCH 01/14] introduce endgames table --- install/database_schema.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/install/database_schema.sql b/install/database_schema.sql index f468e43..fb66f2a 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -359,3 +359,19 @@ CREATE TABLE user_statistics ( average_game_moves REAL GENERATED ALWAYS AS (CASE WHEN games_played != 0 THEN CAST(total_game_moves AS REAL) / games_played ELSE NULL END) STORED, PRIMARY KEY (user_id, variant_type) ); + + +DROP TABLE IF EXISTS endgames; +CREATE TABLE endgames ( + game_id INTEGER NOT NULL REFERENCES games (id), + turn SMALLINT NOT NULL, + /** + * We want to be able to store probabilities for different actions that can be taken. + * We use the same encoding as in the game_actions table, except that for clues, we do not store values. + */ + action_type SMALLINT NOT NULL, + action_target SMALLINT NOT NULL, + enumerator INTEGER NOT NULL, + denominator INTEGER NOT NULL, + PRIMARY KEY (game_id, turn, action_type, action_target) +); \ No newline at end of file From 25cfd06f1bf74ccd84f34ebfd82ca26e91a806f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Fri, 12 Jan 2024 20:20:23 +0100 Subject: [PATCH 02/14] change database format --- install/database_schema.sql | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/install/database_schema.sql b/install/database_schema.sql index fb66f2a..f8f73a9 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -363,15 +363,19 @@ CREATE TABLE user_statistics ( DROP TABLE IF EXISTS endgames; CREATE TABLE endgames ( - game_id INTEGER NOT NULL REFERENCES games (id), - turn SMALLINT NOT NULL, + game_id INTEGER REFERENCES games (id), + turn SMALLINT, /** * We want to be able to store probabilities for different actions that can be taken. - * We use the same encoding as in the game_actions table, except that for clues, we do not store values. + * Action type can be + 0 for play actions + 1 for discard actions + 2 for clues */ - action_type SMALLINT NOT NULL, - action_target SMALLINT NOT NULL, - enumerator INTEGER NOT NULL, - denominator INTEGER NOT NULL, - PRIMARY KEY (game_id, turn, action_type, action_target) + action_type SMALLINT CHECK (0 <= action_type AND action_type <= 2), + suit_index SMALLINT, /* 0 for clue actions */ + rank SMALLINT, /* 0 for clue actions */ + enumerator INTEGER NOT NULL CHECK (enumerator >= 0), + denominator INTEGER NOT NULL CHECK (denominator > 0), + PRIMARY KEY (game_id, turn, action_type, suit_index, rank) ); \ No newline at end of file From 5c6c8a6b14e4c89a764bb4fc9e24ef5fff2808cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 13 Jan 2024 14:27:45 +0100 Subject: [PATCH 03/14] Add functions to analyze endgames with external program --- install/database_schema.sql | 4 + src/constants.py | 6 +- src/endgames.py | 163 ++++++++++++++++++++++++++++++++++++ src/games_db_interface.py | 22 ++++- src/stats.py | 2 +- 5 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 src/endgames.py diff --git a/install/database_schema.sql b/install/database_schema.sql index f8f73a9..5850250 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -373,6 +373,10 @@ CREATE TABLE endgames ( 2 for clues */ action_type SMALLINT CHECK (0 <= action_type AND action_type <= 2), + /** + We store cards as (suit_index, rank) here for uniqueness of representation. + If we want to refer to known trash, we will use (0,0) as representation. + */ suit_index SMALLINT, /* 0 for clue actions */ rank SMALLINT, /* 0 for clue actions */ enumerator INTEGER NOT NULL CHECK (enumerator >= 0), diff --git a/src/constants.py b/src/constants.py index 24ca63f..cfe049d 100644 --- a/src/constants.py +++ b/src/constants.py @@ -48,5 +48,9 @@ USER_HISTORY_CACHE_TIME = 60 * 60 # Fraction of seeds which is assumed to be unwinnable UNWINNABLE_SEED_FRACTION = 0.02 - WEBSITE_OUTPUT_DIRECTORY = 'build' + +ENDGAME_MAX_DRAW_PILE_SIZE = 15 +ENDGAME_MEMORY_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB of memory +# In seconds +ENDGAME_TIMEOUT = 10 diff --git a/src/endgames.py b/src/endgames.py new file mode 100644 index 0000000..6743b11 --- /dev/null +++ b/src/endgames.py @@ -0,0 +1,163 @@ +import json +import subprocess +import re +import resource +from typing import List, Dict, Tuple +from dataclasses import dataclass +from pathlib import Path + +import platformdirs +import psycopg2.extras + +import hanabi.hanab_game +import hanabi.constants +import hanabi.live.compress + +import constants +import games_db_interface +from database import conn_manager + + +@dataclass +class EndgameAction: + turn: int + action_type: hanabi.hanab_game.ActionType + card: hanabi.hanab_game.DeckCard + enumerator: int + denominator: int + + +def analyze_game_from_db(game_id: int, refresh_cache=False): + # In order to pass the game replay to the endgame analyzer (a C++ program), we use the hanab.live json format. + # In order to avoid to need to use the /export endpoint of hanab.live, we create the json replay ourselves from + # the information stored in the database. Note that this is partially lossy, since the GameState class we use here + # does not support stuff like notes or player names, but we don't care about this anyway and can pass the relevant + # information, i.e. deck, number of players and actions. + # We can additionally use the cache dir + filename = Path(platformdirs.user_cache_dir(constants.APP_NAME)) / "replays" / "{}.json".format(game_id) + if not filename.exists() or refresh_cache: + filename.parent.mkdir(exist_ok=True, parents=True) + game, variant_name = games_db_interface.load_game(game_id) + game_json = game.to_json() + game_json["options"]["variant"] = variant_name + + with open(filename, 'w') as f: + f.write(json.dumps(game_json)) + + return analyze_endgame_from_file(str(filename)) + + +def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]: + """ + Analyzes endgame of replay specified in given file using appropriate time and memory limits. + @param filename: Name of file containing replay of game in hanab.live format + @return: List of all evaluated actions and return code why evaluation finished: + 0 if evaluation completed within specified time and memory + 1 if evaluation ran into timeout + 2 if evaluation ran out of memory + + No guarantee can be made on what actions are actually evaluated, these might be more or less depending on + timeouts and/or resource limitation. + @raise + """ + raw_output: bytes + return_code = 0 + args = [ + './endgame-analyzer', '--file', filename, + '--draw-pile-size', str(constants.ENDGAME_MAX_DRAW_PILE_SIZE), '--list-actions', '--quiet' + ] + try: + result = subprocess.run( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=constants.ENDGAME_TIMEOUT, + preexec_fn=set_memory_limit + ) + if result.returncode != 0: + # 2 is the return code to report that the specified game state is not reachable + # In this case, there is nothing to analyze, so we will return an empty list and normal program termination. + if result.returncode == 2: + return [], 0 + # 3 is the return code used by the subprocess to indicate an out of memory exception + # Since we intentionally limited the memory, this is actually not an exception for us, + # we will simply parse the results we have and report the OOM exception. + if result.returncode == 3: + return_code = 2 + else: + raise RuntimeError( + "Abnormal program termination of endgame-analyzer subprocess: Call of\n" + "{}\n" + "resulted in returncode '{}' and stderr\n" + "{}" + .format( + " ".join(args), + result.returncode, + result.stderr.decode('utf-8') + ) + ) + raw_output = result.stdout + except subprocess.TimeoutExpired as time_err: + return_code = 1 + raw_output = time_err.stdout + + output = raw_output.decode('utf-8') + + pattern = r"Turn (?P\d+), (?P\w+)(?:\s(?P\w\w))?: (?P\d+)/(?P\d+)" + + return [parse_match(match.groupdict()) for match in re.finditer(pattern, output)], return_code + + +def set_memory_limit(): + resource.setrlimit(resource.RLIMIT_DATA, (constants.ENDGAME_MEMORY_BYTES, constants.ENDGAME_MEMORY_BYTES)) + + +def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction]) -> None: + values = [] + for action in endgame_actions: + values.append((game_id, action.turn, action.action_type.value, action.card.suitIndex, action.card.rank, action.enumerator, action.denominator)) + + conn = conn_manager.get_connection() + cur = conn.cursor() + psycopg2.extras.execute_values( + cur, + "INSERT INTO endgames " + "VALUES %s " + "ON CONFLICT (game_id, turn, action_type, suit_index, rank) " + "DO UPDATE " + "SET (enumerator, denominator) = (EXCLUDED.enumerator, EXCLUDED.denominator)", + values + ) + conn.commit() + + +def parse_match(action: Dict) -> EndgameAction: + turn = action["turn"] + action_type = parse_action_type(action["type"]) + card = hanabi.hanab_game.DeckCard(0, 0) if action["card"] is None else parse_card(action["card"]) + enumerator = action["enumerator"] + denominator = action["denominator"] + return EndgameAction(turn, action_type, card, enumerator, denominator) + + +def parse_action_type(action_type: str) -> hanabi.hanab_game.ActionType: + match action_type: + case "play": + return hanabi.hanab_game.ActionType.Play + case "discard": + return hanabi.hanab_game.ActionType.Discard + case "clue": + return hanabi.hanab_game.ActionType.ColorClue + raise ValueError("Failed to parse action type: {}".format(action_type)) + + +def parse_card(card: str) -> hanabi.hanab_game.DeckCard: + assert len(card) == 2 + if card == "kt": + return hanabi.hanab_game.DeckCard(0, 0) + else: + rank = int(card[1]) + suit = hanabi.constants.COLOR_INITIALS.index(card[0]) + assert suit is not None + return hanabi.hanab_game.DeckCard(suit, rank) + diff --git a/src/games_db_interface.py b/src/games_db_interface.py index e42e6b8..ab52b68 100644 --- a/src/games_db_interface.py +++ b/src/games_db_interface.py @@ -81,10 +81,15 @@ def load_deck(seed: str) -> List[hanabi.hanab_game.DeckCard]: return deck -def load_game(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action]]: +def load_game_parts(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hanabi.hanab_game.Action], str]: + """ + Loads information on game from database + @param game_id: ID of game + @return: Instance (i.e. deck + settings) of game, list of actions, variant name + """ cur = conn_manager.get_new_cursor() cur.execute( - "SELECT games.num_players, games.seed, variants.clue_starved " + "SELECT games.num_players, games.seed, variants.clue_starved, variants.name " "FROM games " "INNER JOIN variants" " ON games.variant_id = variants.id " @@ -98,10 +103,19 @@ def load_game(game_id: int) -> Tuple[hanabi.hanab_game.HanabiInstance, List[hana raise ValueError(err_msg) # Unpack results now - (num_players, seed, clue_starved) = res + (num_players, seed, clue_starved, variant_name) = res actions = load_actions(game_id) deck = load_deck(seed) instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved) - return instance, actions + return instance, actions, variant_name + + +def load_game(game_id: int) -> Tuple[hanabi.hanab_game.GameState, str]: + instance, actions, variant_name = load_game_parts(game_id) + game = hanabi.hanab_game.GameState(instance) + for action in actions: + game.make_action(action) + return game, variant_name + diff --git a/src/stats.py b/src/stats.py index 2734080..74b563f 100644 --- a/src/stats.py +++ b/src/stats.py @@ -93,7 +93,7 @@ def analyze_replay(instance: hanab_game.HanabiInstance, actions: List[hanab_game def analyze_game_and_store_stats(game_id: int): logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id)) - instance, actions = games_db_interface.load_game(game_id) + instance, actions, _ = games_db_interface.load_game_parts(game_id) analysis = analyze_replay(instance, actions) cur = conn_manager.get_new_cursor() From bfe83d4f438419a4487cb06d47727a60d2ab12c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 13 Jan 2024 14:32:48 +0100 Subject: [PATCH 04/14] load endgame actions from db --- src/endgames.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/endgames.py b/src/endgames.py index 6743b11..b45eeaa 100644 --- a/src/endgames.py +++ b/src/endgames.py @@ -131,6 +131,26 @@ def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction]) -> conn.commit() +def load_endgame_actions(game_id: int) -> List[EndgameAction]: + cur = conn_manager.get_new_cursor() + cur.execute( + "SELECT (turn, action_type, suit_index, rank, enumerator, denominator) " + "FROM endgames " + "WHERE game_id = %s " + "ORDER BY turn ASC, action_type ASC, suit_index ASC, rank ASC", + (game_id,) + ) + ret = [] + for (turn, action_type, suit_index, rank, enumerator, denominator) in cur.fetchall(): + ret.append(EndgameAction( + turn, + hanabi.hanab_game.ActionType(action_type), + hanabi.hanab_game.DeckCard(suit_index, rank), + enumerator, denominator) + ) + return ret + + def parse_match(action: Dict) -> EndgameAction: turn = action["turn"] action_type = parse_action_type(action["type"]) From 20f4cfc67ed1719fb73f1aba070958c8cefc62cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 13 Jan 2024 14:35:24 +0100 Subject: [PATCH 05/14] convenience function --- src/endgames.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/endgames.py b/src/endgames.py index b45eeaa..4407b93 100644 --- a/src/endgames.py +++ b/src/endgames.py @@ -27,7 +27,13 @@ class EndgameAction: denominator: int -def analyze_game_from_db(game_id: int, refresh_cache=False): +def analyze_and_store_game(game_id: int) -> int: + actions, return_code = analyze_game_from_db(game_id) + store_endgame_actions(game_id, actions) + return return_code + + +def analyze_game_from_db(game_id: int, refresh_cache=False) -> Tuple[List[EndgameAction], int]: # In order to pass the game replay to the endgame analyzer (a C++ program), we use the hanab.live json format. # In order to avoid to need to use the /export endpoint of hanab.live, we create the json replay ourselves from # the information stored in the database. Note that this is partially lossy, since the GameState class we use here From 816bf0d940b1301581e456f71fd1ae64d67417b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 13 Jan 2024 15:35:49 +0100 Subject: [PATCH 06/14] add worker thread for endgame analysis --- install/database_schema.sql | 17 ++++++++++ src/constants.py | 6 ++-- src/endgames.py | 64 +++++++++++++++++++++++++++++++++---- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/install/database_schema.sql b/install/database_schema.sql index 5850250..7b1d2fe 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -382,4 +382,21 @@ CREATE TABLE endgames ( enumerator INTEGER NOT NULL CHECK (enumerator >= 0), denominator INTEGER NOT NULL CHECK (denominator > 0), PRIMARY KEY (game_id, turn, action_type, suit_index, rank) +); + +/** + We store separately whether we analyzed a certain game already and what the termination reason for the analysis was: + 0 if evaluation completed within specified time and memory + 1 if evaluation ran into timeout + 2 if evaluation was empty because state is unreachable + 3 if evaluation ran out of memory + This is also necessary because for some endgames, because in case 2 we will not have data, + simply because the game replay ended too early. + To avoid re-analyzing these seeds, we mark all seeds analyzed in this table. +*/ +DROP TABLE IF EXISTS endgames_analyzed; +CREATE TABLE endgames_analyzed ( + game_id INTEGER REFERENCES games (id), + termination_reason SMALLINT NOT NULL, + PRIMARY KEY (game_id) ); \ No newline at end of file diff --git a/src/constants.py b/src/constants.py index cfe049d..dd0e165 100644 --- a/src/constants.py +++ b/src/constants.py @@ -50,7 +50,7 @@ UNWINNABLE_SEED_FRACTION = 0.02 WEBSITE_OUTPUT_DIRECTORY = 'build' -ENDGAME_MAX_DRAW_PILE_SIZE = 15 +ENDGAME_MAX_DRAW_PILE_SIZE = 15 # Not interested in game states with more than 15 cards, this should be enough. ENDGAME_MEMORY_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB of memory -# In seconds -ENDGAME_TIMEOUT = 10 +ENDGAME_TIMEOUT_SECONDS = 60 * 15 # 15 Minutes per game by default +ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES = 5 # Re-query database every 5 minutes diff --git a/src/endgames.py b/src/endgames.py index 4407b93..67a8d42 100644 --- a/src/endgames.py +++ b/src/endgames.py @@ -2,12 +2,14 @@ import json import subprocess import re import resource +import time from typing import List, Dict, Tuple from dataclasses import dataclass from pathlib import Path import platformdirs import psycopg2.extras +import psycopg2.errors import hanabi.hanab_game import hanabi.constants @@ -16,6 +18,7 @@ import hanabi.live.compress import constants import games_db_interface from database import conn_manager +from log_setup import logger @dataclass @@ -29,7 +32,7 @@ class EndgameAction: def analyze_and_store_game(game_id: int) -> int: actions, return_code = analyze_game_from_db(game_id) - store_endgame_actions(game_id, actions) + store_endgame_actions(game_id, actions, return_code) return return_code @@ -60,7 +63,8 @@ def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]: @return: List of all evaluated actions and return code why evaluation finished: 0 if evaluation completed within specified time and memory 1 if evaluation ran into timeout - 2 if evaluation ran out of memory + 2 if evaluation was empty because state is unreachable + 3 if evaluation ran out of memory No guarantee can be made on what actions are actually evaluated, these might be more or less depending on timeouts and/or resource limitation. @@ -77,19 +81,19 @@ def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]: args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - timeout=constants.ENDGAME_TIMEOUT, + timeout=constants.ENDGAME_TIMEOUT_SECONDS, preexec_fn=set_memory_limit ) if result.returncode != 0: # 2 is the return code to report that the specified game state is not reachable # In this case, there is nothing to analyze, so we will return an empty list and normal program termination. if result.returncode == 2: - return [], 0 + return [], 2 # 3 is the return code used by the subprocess to indicate an out of memory exception # Since we intentionally limited the memory, this is actually not an exception for us, # we will simply parse the results we have and report the OOM exception. if result.returncode == 3: - return_code = 2 + return_code = 3 else: raise RuntimeError( "Abnormal program termination of endgame-analyzer subprocess: Call of\n" @@ -107,7 +111,8 @@ def analyze_endgame_from_file(filename: str) -> Tuple[List[EndgameAction], int]: return_code = 1 raw_output = time_err.stdout - output = raw_output.decode('utf-8') + # It could be that we got no output. In that case, we also cannot parse anything + output = raw_output.decode('utf-8') if raw_output else "" pattern = r"Turn (?P\d+), (?P\w+)(?:\s(?P\w\w))?: (?P\d+)/(?P\d+)" @@ -118,11 +123,14 @@ def set_memory_limit(): resource.setrlimit(resource.RLIMIT_DATA, (constants.ENDGAME_MEMORY_BYTES, constants.ENDGAME_MEMORY_BYTES)) -def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction]) -> None: +def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction], result_code) -> None: values = [] for action in endgame_actions: values.append((game_id, action.turn, action.action_type.value, action.card.suitIndex, action.card.rank, action.enumerator, action.denominator)) + # Remove duplicates (even though we expect none), otherwise this causes errors on insertion. + values = list(set(values)) + conn = conn_manager.get_connection() cur = conn.cursor() psycopg2.extras.execute_values( @@ -134,6 +142,15 @@ def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction]) -> "SET (enumerator, denominator) = (EXCLUDED.enumerator, EXCLUDED.denominator)", values ) + # Mark this game as analyzed. + cur.execute( + "INSERT INTO endgames_analyzed " + "VALUES (%s, %s) " + "ON CONFLICT (game_id) " + "DO UPDATE " + "SET termination_reason = EXCLUDED.termination_reason", + (game_id, result_code) + ) conn.commit() @@ -187,3 +204,36 @@ def parse_card(card: str) -> hanabi.hanab_game.DeckCard: assert suit is not None return hanabi.hanab_game.DeckCard(suit, rank) + +def work_thread(): + """ + Will continuously query database to analyze endgames. + @return: + """ + conn = conn_manager.get_connection() + cur = conn.cursor() + while True: + cur.execute( + "SELECT games.id " + "FROM games " + "LEFT OUTER JOIN endgames_analyzed " + " ON endgames_analyzed.game_id = games.id " + "WHERE endgames_analyzed.termination_reason IS NULL " + "ORDER BY games.league_id DESC " + "LIMIT 1", + (False,) + ) + res = cur.fetchone() + if res is None: + logger.info("No game found to analyze. Going to sleep for {} Minutes".format( + constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES) + ) + time.sleep(60 * constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES) + else: + (game_id, ) = res + logger.info("Analyisng endgame of {}".format(game_id)) + return_code = analyze_and_store_game(game_id) + print("Finished endgame analysis of {}: Returncode {}".format(game_id, return_code)) + + +work_thread() From 60aa757ba03eff38eb542004e51e1a8b16e147f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 13 Jan 2024 15:38:57 +0100 Subject: [PATCH 07/14] expand cli for endgames --- hanabi-league | 7 +++++++ src/endgames.py | 5 +---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/hanabi-league b/hanabi-league index 0784af4..8706593 100755 --- a/hanabi-league +++ b/hanabi-league @@ -16,10 +16,15 @@ import fetch_games import ratings import stats import render_site +import endgames from log_setup import logger +def subcommand_analyze_endgames(): + endgames.work_thread() + + def subcommand_init(force: bool, no_fetch_variants: bool): tables = database.get_existing_tables() if len(tables) > 0 and not force: @@ -101,6 +106,7 @@ def get_parser() -> argparse.ArgumentParser: subparsers.add_parser('process-ratings', help="Process ratings of all games.") subparsers.add_parser('process-stats', help="Process statistics for all players.") subparsers.add_parser('generate-site', help="Generate the website from the DB.") + subparsers.add_parser('analyze-endgames', help="Run endgame analysis on games in DB. Resource intensive!") subparsers.add_parser('run', help="Run the automatic suite: Fetch + process games and render site.") fetch_parser = subparsers.add_parser('fetch', help='Fetch new data.') @@ -122,6 +128,7 @@ def main(): 'generate-site': subcommand_generate_site, 'fetch': subcommand_fetch, 'run': subcommand_run, + 'analyze-endgames': subcommand_analyze_endgames }[args.command] if args.verbose: diff --git a/src/endgames.py b/src/endgames.py index 67a8d42..c775778 100644 --- a/src/endgames.py +++ b/src/endgames.py @@ -231,9 +231,6 @@ def work_thread(): time.sleep(60 * constants.ENDGAME_ANALYSIS_QUERY_INTERVAL_MINUTES) else: (game_id, ) = res - logger.info("Analyisng endgame of {}".format(game_id)) + logger.info("Analyzing endgame of game {}".format(game_id)) return_code = analyze_and_store_game(game_id) print("Finished endgame analysis of {}: Returncode {}".format(game_id, return_code)) - - -work_thread() From 1f8d0b867c199bda7dfe3f5eba9b8c39150f7aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 00:52:27 +0100 Subject: [PATCH 08/14] Render endgame statistics to website --- css/leaderboards.css | 4 ++ install/database_schema.sql | 4 +- src/endgames.py | 20 +++++++++- src/render_site.py | 77 ++++++++++++++++++++++++++++++++++++- templates/game.html | 55 ++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 templates/game.html diff --git a/css/leaderboards.css b/css/leaderboards.css index 8f3539d..4453efe 100644 --- a/css/leaderboards.css +++ b/css/leaderboards.css @@ -85,3 +85,7 @@ body { .history-bullets ul { flex-direction: column; } + +table, th, td { + border: 1px solid black; +} diff --git a/install/database_schema.sql b/install/database_schema.sql index 7b1d2fe..a768648 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -379,8 +379,8 @@ CREATE TABLE endgames ( */ suit_index SMALLINT, /* 0 for clue actions */ rank SMALLINT, /* 0 for clue actions */ - enumerator INTEGER NOT NULL CHECK (enumerator >= 0), - denominator INTEGER NOT NULL CHECK (denominator > 0), + enumerator BIGINT NOT NULL CHECK (enumerator >= 0), + denominator BIGINT NOT NULL CHECK (denominator > 0), PRIMARY KEY (game_id, turn, action_type, suit_index, rank) ); diff --git a/src/endgames.py b/src/endgames.py index c775778..3c35faf 100644 --- a/src/endgames.py +++ b/src/endgames.py @@ -29,6 +29,10 @@ class EndgameAction: enumerator: int denominator: int + @property + def win_rate(self): + return self.enumerator / self.denominator + def analyze_and_store_game(game_id: int) -> int: actions, return_code = analyze_game_from_db(game_id) @@ -157,7 +161,7 @@ def store_endgame_actions(game_id: int, endgame_actions: List[EndgameAction], re def load_endgame_actions(game_id: int) -> List[EndgameAction]: cur = conn_manager.get_new_cursor() cur.execute( - "SELECT (turn, action_type, suit_index, rank, enumerator, denominator) " + "SELECT turn, action_type, suit_index, rank, enumerator, denominator " "FROM endgames " "WHERE game_id = %s " "ORDER BY turn ASC, action_type ASC, suit_index ASC, rank ASC", @@ -205,6 +209,20 @@ def parse_card(card: str) -> hanabi.hanab_game.DeckCard: return hanabi.hanab_game.DeckCard(suit, rank) +def print_action_type(action_type: hanabi.hanab_game.ActionType) -> str: + match action_type: + case hanabi.hanab_game.ActionType.Play: + return "Play" + case hanabi.hanab_game.ActionType.Discard: + return "Discard" + case hanabi.hanab_game.ActionType.RankClue: + return "Clue" + case hanabi.hanab_game.ActionType.ColorClue: + return "Clue" + case _: + return "Unknown Action" + + def work_thread(): """ Will continuously query database to analyze endgames. diff --git a/src/render_site.py b/src/render_site.py index 2f3127e..1361108 100644 --- a/src/render_site.py +++ b/src/render_site.py @@ -17,11 +17,11 @@ import constants import config import utils from dataclasses import dataclass +import endgames from database import conn_manager - @dataclass class PlayerEntry: player_name: str @@ -713,6 +713,78 @@ def build_unique_variants(variant_rows: List[VariantRow]): return [row for row in variant_rows if row.num_players == config.config_manager.get_config().min_player_count] +def render_game_pages(env: jinja2.Environment, out_dir: Path): + endgames = get_endgame_page_data() + template = env.get_template("game.html") + + for game_id, data in endgames.items(): + rendered_template = template.render( + total_games_played=get_total_games(), + total_players=get_num_players(), + latest_run=datetime.datetime.now().isoformat(), + game_id=game_id, + data=data + ) + output_file = out_dir / 'game' / str(game_id) / 'index.html' + output_file.parent.mkdir(exist_ok=True, parents=True) + with open(output_file, 'w') as f: + f.write(rendered_template) + + +def format_endgame_action(endgame_action: endgames.EndgameAction): + + return "{} {}: {}/{} ~ {}".format( + endgames.print_action_type(endgame_action.action_type), + endgame_action.card, + endgame_action.enumerator, + endgame_action.denominator, + endgame_action.action_type.value + ) + + +@dataclass +class EndgameActionRow: + description: str + enumerator: int + denominator: int + + @property + def win_rate(self): + return round(100 * self.enumerator / self.denominator, 3) + + +def convert_endgame_action(endgame_action: endgames.EndgameAction) -> EndgameActionRow: + description = "{} {}".format(endgames.print_action_type(endgame_action.action_type), endgame_action.card) + return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator) + + +def get_endgame_page_data(): + cur = conn_manager.get_new_cursor() + cur.execute( + "SELECT game_id " + "FROM games " + "LEFT OUTER JOIN endgames_analyzed " + " ON endgames_analyzed.game_id = games.id " + "WHERE termination_reason IS NOT NULL" + ) + ret = {} + for (game_id, ) in cur.fetchall(): + ret[game_id] = [] + actions = endgames.load_endgame_actions(game_id) + while len(actions) > 0: + cur_turn = actions[0].turn + actions_this_turn: List[endgames.EndgameAction] = [] + while len(actions) > 0 and actions[0].turn == cur_turn: + action, *actions = actions + actions_this_turn.append(action) + actions_this_turn.sort(key=lambda a: -a.win_rate) + best_action, *other_actions = [convert_endgame_action(a) for a in actions_this_turn] + ret[game_id].append( + (cur_turn, best_action, other_actions) + ) + return ret + + def render_main_site(env: jinja2.Environment, out_dir: Path): rating_lists = get_rating_lists() streak_lists = get_streak_list() @@ -842,7 +914,8 @@ def render_all(): render_main_site(env, out_dir) render_player_pages(env, out_dir) render_variant_pages(env, out_dir) + render_game_pages(env, out_dir) if __name__ == "__main__": - render_all() \ No newline at end of file + render_all() diff --git a/templates/game.html b/templates/game.html new file mode 100644 index 0000000..46eb338 --- /dev/null +++ b/templates/game.html @@ -0,0 +1,55 @@ +{% extends "layout.html" %} + +{% block navbar %} + +{% endblock %} + +{% block content %} +
+
+
+

+ Endgame Statistics for game {{game_id}} +

+ + + + + + + + {% for (turn, best_action, other_actions) in data %} + + + + + + + {% for action in other_actions %} + + + + + + {% endfor %} + {% endfor %} +
TurnActionFractional ProbabilityProbability
{{ turn }}{{ best_action.description }}{{ best_action.enumerator }}/{{ best_action.denominator }}{{ best_action.win_rate }}%
{{ action.description }}{{ action.enumerator }}/{{ action.denominator }}{{ action.win_rate }}%
+
+
+
+ +{% endblock %} From e4635461c11c2902c7ca927617577d9f3ea32a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 12:57:59 +0100 Subject: [PATCH 09/14] fix table style --- css/leaderboards.css | 2 +- templates/game.html | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/css/leaderboards.css b/css/leaderboards.css index 4453efe..6ee25b6 100644 --- a/css/leaderboards.css +++ b/css/leaderboards.css @@ -86,6 +86,6 @@ body { flex-direction: column; } -table, th, td { +.endgame-table td, .endgame-table th { border: 1px solid black; } diff --git a/templates/game.html b/templates/game.html index 46eb338..cdd6754 100644 --- a/templates/game.html +++ b/templates/game.html @@ -25,7 +25,8 @@

Endgame Statistics for game {{game_id}}

- + Replay Link +
From 96c6fc0df2465a26f0355768e16044540fa0076a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 13:11:02 +0100 Subject: [PATCH 10/14] improve endgame table output --- deps/py-hanabi | 2 +- src/render_site.py | 8 +++++++- templates/game.html | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/deps/py-hanabi b/deps/py-hanabi index 3ac51d5..51e09cd 160000 --- a/deps/py-hanabi +++ b/deps/py-hanabi @@ -1 +1 @@ -Subproject commit 3ac51d574e65aff9b3420fdebd467d7b98ea1d28 +Subproject commit 51e09cd94393de64e07191d6ca544139417acb3b diff --git a/src/render_site.py b/src/render_site.py index 1361108..ee57017 100644 --- a/src/render_site.py +++ b/src/render_site.py @@ -754,7 +754,13 @@ class EndgameActionRow: def convert_endgame_action(endgame_action: endgames.EndgameAction) -> EndgameActionRow: - description = "{} {}".format(endgames.print_action_type(endgame_action.action_type), endgame_action.card) + action_str = endgames.print_action_type(endgame_action.action_type) + target_str: str + if action_str != "Clue": + target_str = " {}".format(endgame_action.card) + else: + target_str = "" + description = action_str + target_str return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator) diff --git a/templates/game.html b/templates/game.html index cdd6754..e92a3d3 100644 --- a/templates/game.html +++ b/templates/game.html @@ -23,9 +23,19 @@

- Endgame Statistics for game {{game_id}} + Statistics for game {{game_id}}

- Replay Link + +

+ Endgame Analysis table +

Turn Action
@@ -35,7 +45,7 @@ {% for (turn, best_action, other_actions) in data %} - + From 145142c4a9a812c49f54e81ac58a3f87d57cdcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 13:38:18 +0100 Subject: [PATCH 11/14] improve game sites --- src/render_site.py | 45 ++++++++++++++++++++++++++++++++++++--------- templates/game.html | 6 +++--- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/render_site.py b/src/render_site.py index ee57017..46be44a 100644 --- a/src/render_site.py +++ b/src/render_site.py @@ -12,12 +12,14 @@ import requests_cache import platformdirs import stats +import hanabi.hanab_game import constants import config import utils from dataclasses import dataclass import endgames +import games_db_interface from database import conn_manager @@ -747,21 +749,36 @@ class EndgameActionRow: description: str enumerator: int denominator: int + marked: bool = False @property def win_rate(self): return round(100 * self.enumerator / self.denominator, 3) -def convert_endgame_action(endgame_action: endgames.EndgameAction) -> EndgameActionRow: +def convert_endgame_action(endgame_action: endgames.EndgameAction, game: hanabi.hanab_game.GameState, action: hanabi.hanab_game.Action) -> EndgameActionRow: action_str = endgames.print_action_type(endgame_action.action_type) target_str: str - if action_str != "Clue": + if endgame_action.action_type not in [hanabi.hanab_game.ActionType.ColorClue, hanabi.hanab_game.ActionType.RankClue]: target_str = " {}".format(endgame_action.card) else: target_str = "" description = action_str + target_str - return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator) + + marked = False + # To simplify comparisons, we only work with color clues here. Endgame actions always consist of color clues. + if action.type == hanabi.hanab_game.ActionType.RankClue: + action.type = hanabi.hanab_game.ActionType.ColorClue + if endgame_action.action_type == action.type: + if endgame_action.action_type == hanabi.hanab_game.ActionType.ColorClue: + marked = True + else: + game_target = game.instance.deck[action.target] + if game.is_trash(game_target): + game_target = hanabi.hanab_game.DeckCard(0, 0) + if endgame_action.card == game_target: + marked = True + return EndgameActionRow(description, endgame_action.enumerator, endgame_action.denominator, marked) def get_endgame_page_data(): @@ -776,15 +793,25 @@ def get_endgame_page_data(): ret = {} for (game_id, ) in cur.fetchall(): ret[game_id] = [] - actions = endgames.load_endgame_actions(game_id) - while len(actions) > 0: - cur_turn = actions[0].turn - actions_this_turn: List[endgames.EndgameAction] = [] - while len(actions) > 0 and actions[0].turn == cur_turn: + instance, actions, _ = games_db_interface.load_game_parts(game_id) + game = hanabi.hanab_game.GameState(instance) + + endgame_actions = endgames.load_endgame_actions(game_id) + while len(endgame_actions) > 0: + # Move to current turn and update game + cur_turn = endgame_actions[0].turn + # Note the -1 here since turns on hanab.live start to count at 1 + while len(game.actions) < cur_turn - 1: action, *actions = actions + game.make_action(action) + assert len(actions) > 0 + + actions_this_turn: List[endgames.EndgameAction] = [] + while len(endgame_actions) > 0 and endgame_actions[0].turn == cur_turn: + action, *endgame_actions = endgame_actions actions_this_turn.append(action) actions_this_turn.sort(key=lambda a: -a.win_rate) - best_action, *other_actions = [convert_endgame_action(a) for a in actions_this_turn] + best_action, *other_actions = [convert_endgame_action(a, game, actions[0]) for a in actions_this_turn] ret[game_id].append( (cur_turn, best_action, other_actions) ) diff --git a/templates/game.html b/templates/game.html index e92a3d3..38a34e2 100644 --- a/templates/game.html +++ b/templates/game.html @@ -45,14 +45,14 @@ {% for (turn, best_action, other_actions) in data %} - - + + {% for action in other_actions %} - + From cf9a81979a39231ae203e79cdd2d363ef32feb92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 13:48:49 +0100 Subject: [PATCH 12/14] generate game pages also for games with no endgame analysis --- src/render_site.py | 50 +++++++++++++++++++++++---------------------- templates/game.html | 7 +++++++ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/render_site.py b/src/render_site.py index 46be44a..973e0d4 100644 --- a/src/render_site.py +++ b/src/render_site.py @@ -784,37 +784,39 @@ def convert_endgame_action(endgame_action: endgames.EndgameAction, game: hanabi. def get_endgame_page_data(): cur = conn_manager.get_new_cursor() cur.execute( - "SELECT game_id " + "SELECT games.id, termination_reason " "FROM games " "LEFT OUTER JOIN endgames_analyzed " " ON endgames_analyzed.game_id = games.id " - "WHERE termination_reason IS NOT NULL" ) ret = {} - for (game_id, ) in cur.fetchall(): - ret[game_id] = [] - instance, actions, _ = games_db_interface.load_game_parts(game_id) - game = hanabi.hanab_game.GameState(instance) + for (game_id, termination_reason) in cur.fetchall(): + if termination_reason is not None: + ret[game_id] = [] + instance, actions, _ = games_db_interface.load_game_parts(game_id) + game = hanabi.hanab_game.GameState(instance) - endgame_actions = endgames.load_endgame_actions(game_id) - while len(endgame_actions) > 0: - # Move to current turn and update game - cur_turn = endgame_actions[0].turn - # Note the -1 here since turns on hanab.live start to count at 1 - while len(game.actions) < cur_turn - 1: - action, *actions = actions - game.make_action(action) - assert len(actions) > 0 + endgame_actions = endgames.load_endgame_actions(game_id) + while len(endgame_actions) > 0: + # Move to current turn and update game + cur_turn = endgame_actions[0].turn + # Note the -1 here since turns on hanab.live start to count at 1 + while len(game.actions) < cur_turn - 1: + action, *actions = actions + game.make_action(action) + assert len(actions) > 0 - actions_this_turn: List[endgames.EndgameAction] = [] - while len(endgame_actions) > 0 and endgame_actions[0].turn == cur_turn: - action, *endgame_actions = endgame_actions - actions_this_turn.append(action) - actions_this_turn.sort(key=lambda a: -a.win_rate) - best_action, *other_actions = [convert_endgame_action(a, game, actions[0]) for a in actions_this_turn] - ret[game_id].append( - (cur_turn, best_action, other_actions) - ) + actions_this_turn: List[endgames.EndgameAction] = [] + while len(endgame_actions) > 0 and endgame_actions[0].turn == cur_turn: + action, *endgame_actions = endgame_actions + actions_this_turn.append(action) + actions_this_turn.sort(key=lambda a: -a.win_rate) + best_action, *other_actions = [convert_endgame_action(a, game, actions[0]) for a in actions_this_turn] + ret[game_id].append( + (cur_turn, best_action, other_actions) + ) + else: + ret[game_id] = None return ret diff --git a/templates/game.html b/templates/game.html index 38a34e2..fe04892 100644 --- a/templates/game.html +++ b/templates/game.html @@ -36,6 +36,7 @@

Endgame Analysis table

+ {% if data %}
Turn
{{ turn }}{{ turn }} {{ best_action.description }} {{ best_action.enumerator }}/{{ best_action.denominator }} {{ best_action.win_rate }}%
{{ turn }}{{ best_action.description }}{{ turn }}{% if best_action.marked %}{% endif %}{{ best_action.description }}{% if best_action.marked %}{% endif %} {{ best_action.enumerator }}/{{ best_action.denominator }} {{ best_action.win_rate }}%
{{ action.description }}{% if action.marked %}{% endif %}{{ action.description }}{% if action.marked %}{% endif %} {{ action.enumerator }}/{{ action.denominator }} {{ action.win_rate }}%
@@ -59,6 +60,12 @@ {% endfor %} {% endfor %}
Turn
+ {% else %} + Currently, there is no endgame analysis available for this game. Since the computation is resource extensive, + this might take a while, also depending on how many other games have been played recently. +
+ Come back later to check again. + {% endif %} From e6d24d7b7b12a1a21138267f0f0f495581dac837 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sun, 14 Jan 2024 13:49:37 +0100 Subject: [PATCH 13/14] link to game sites instead of replays --- templates/stats_table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/stats_table.html b/templates/stats_table.html index 2febb06..657d104 100644 --- a/templates/stats_table.html +++ b/templates/stats_table.html @@ -72,7 +72,7 @@ var table_{{div_id}} = new Tabulator("#table-{{div_id}}", { layout: "fitDataStretch", columns: [ {title: "Game", field: "game_id", formatter: "link", formatterParams:{ - urlPrefix: "https://hanab.live/replay/", + urlPrefix: "/game/", target:"_blank" }}, {title: "Played", field: "datetime_finished", formatter: "datetime", formatterParams:{ From eb833db3ce8422298fd43981ee8c7b55fff0f665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 20 Jan 2024 13:32:06 +0100 Subject: [PATCH 14/14] change database schema: include winrate --- install/database_schema.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/install/database_schema.sql b/install/database_schema.sql index a768648..d2b88db 100644 --- a/install/database_schema.sql +++ b/install/database_schema.sql @@ -381,6 +381,7 @@ CREATE TABLE endgames ( rank SMALLINT, /* 0 for clue actions */ enumerator BIGINT NOT NULL CHECK (enumerator >= 0), denominator BIGINT NOT NULL CHECK (denominator > 0), + chance REAL GENERATED ALWAYS AS (CAST(enumerator AS REAL) / denominator) STORED, PRIMARY KEY (game_id, turn, action_type, suit_index, rank) );