Add functions to analyze endgames with external program

This commit is contained in:
Maximilian Keßler 2024-01-13 14:27:45 +01:00
parent 25cfd06f1b
commit 5c6c8a6b14
Signed by: max
GPG key ID: BCC5A619923C0BA5
5 changed files with 191 additions and 6 deletions

View file

@ -373,6 +373,10 @@ CREATE TABLE endgames (
2 for clues 2 for clues
*/ */
action_type SMALLINT CHECK (0 <= action_type AND action_type <= 2), 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 */ suit_index SMALLINT, /* 0 for clue actions */
rank SMALLINT, /* 0 for clue actions */ rank SMALLINT, /* 0 for clue actions */
enumerator INTEGER NOT NULL CHECK (enumerator >= 0), enumerator INTEGER NOT NULL CHECK (enumerator >= 0),

View file

@ -48,5 +48,9 @@ USER_HISTORY_CACHE_TIME = 60 * 60
# Fraction of seeds which is assumed to be unwinnable # Fraction of seeds which is assumed to be unwinnable
UNWINNABLE_SEED_FRACTION = 0.02 UNWINNABLE_SEED_FRACTION = 0.02
WEBSITE_OUTPUT_DIRECTORY = 'build' 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

163
src/endgames.py Normal file
View file

@ -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<turn>\d+), (?P<type>\w+)(?:\s(?P<card>\w\w))?: (?P<enumerator>\d+)/(?P<denominator>\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)

View file

@ -81,10 +81,15 @@ def load_deck(seed: str) -> List[hanabi.hanab_game.DeckCard]:
return deck 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 = conn_manager.get_new_cursor()
cur.execute( cur.execute(
"SELECT games.num_players, games.seed, variants.clue_starved " "SELECT games.num_players, games.seed, variants.clue_starved, variants.name "
"FROM games " "FROM games "
"INNER JOIN variants" "INNER JOIN variants"
" ON games.variant_id = variants.id " " 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) raise ValueError(err_msg)
# Unpack results now # Unpack results now
(num_players, seed, clue_starved) = res (num_players, seed, clue_starved, variant_name) = res
actions = load_actions(game_id) actions = load_actions(game_id)
deck = load_deck(seed) deck = load_deck(seed)
instance = hanabi.hanab_game.HanabiInstance(deck, num_players, clue_starved=clue_starved) 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

View file

@ -93,7 +93,7 @@ def analyze_replay(instance: hanab_game.HanabiInstance, actions: List[hanab_game
def analyze_game_and_store_stats(game_id: int): def analyze_game_and_store_stats(game_id: int):
logger.verbose("Analysing game {} for BDRs and lost crits".format(game_id)) 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) analysis = analyze_replay(instance, actions)
cur = conn_manager.get_new_cursor() cur = conn_manager.get_new_cursor()