From a1345a197629968f2b24227b9f00a72911a0cc3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Thu, 25 May 2023 16:59:49 +0200 Subject: [PATCH 01/14] implement some utils to query from game class --- hanabi.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/hanabi.py b/hanabi.py index ed14e59..9dcd92b 100644 --- a/hanabi.py +++ b/hanabi.py @@ -114,6 +114,8 @@ class HanabiInstance: self.player_names = constants.PLAYER_NAMES[:self.num_players] self.deck_size = len(self.deck) + self.initial_pace = self.deck_size - 5 * self.num_suits - self.num_players * (self.hand_size - 1) + # # maximum number of moves in any game that can achieve max score each suit gives 15 moves, as we can play # and discard 5 cards each and give 5 clues. dark suits only give 5 moves, since no discards are added number # of cards that remain in players hands after end of game. they cost 2 turns each, since we cannot discard @@ -155,8 +157,7 @@ class GameState: self.strikes = 0 self.clues = 8 self.turn = 0 - self.pace = self.instance.deck_size - 5 * self.instance.num_suits - self.instance.num_players * ( - self.instance.hand_size - 1) + self.pace = self.instance.initial_pace self.remaining_extra_turns = self.instance.num_players + 1 self.trash = [] @@ -181,6 +182,7 @@ class GameState: else: self.strikes += 1 self.trash.append(self.instance.deck[card_idx]) + self.pace -= 1 self.actions.append(Action(ActionType.Play, target=card_idx)) self._replace(card_idx, allow_not_present=self.instance.deck_plays and (card_idx == self.deck_size - 1)) self._make_turn() @@ -233,7 +235,7 @@ class GameState: return self.over or self.is_known_lost() def is_won(self): - return self.score == 5 * instance.num_suits + return self.score == self.instance.max_score def is_known_lost(self): return self.in_lost_state @@ -273,6 +275,27 @@ class GameState: } } + # Query helpers for implementing bots + def copy_holders(self, card: DeckCard, exclude_player: Optional[int]): + return [ + player for player in range(self.num_players) + if player != exclude_player and card in self.hands[player] + ] + + @staticmethod + def in_strict_order(player_a, player_b, player_c): + """ + Check whether the three given players sit in order, where equality is not allowed + :param player_a: + :param player_b: + :param player_c: + :return: + """ + return player_a < player_b < player_c or player_b < player_c < player_a or player_c < player_a < player_b + + def is_in_extra_round(self): + return self.remaining_extra_turns <= self.instance.num_players + # Private helpers # increments turn counter and tracks extra round From a427a2575e9db889afb2d56711e49e8eb7e61ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Thu, 25 May 2023 17:00:18 +0200 Subject: [PATCH 02/14] start impl of better greedy solver --- greedy_solver.py | 105 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/greedy_solver.py b/greedy_solver.py index 45678f5..448aa0c 100755 --- a/greedy_solver.py +++ b/greedy_solver.py @@ -3,6 +3,7 @@ import collections import sys from enum import Enum from log_setup import logger +from typing import Tuple, List, Optional from time import sleep from hanabi import DeckCard, Action, ActionType, GameState, HanabiInstance @@ -14,7 +15,8 @@ class CardType(Enum): Trash = 0 Playable = 1 Critical = 2 - Dispensable = 3 + DuplicateVisible = 3 + UniqueVisible = 4 class CardState(): @@ -31,8 +33,10 @@ class CardState(): return "Playable ({}) with weight {}".format(self.card, self.weight) case CardType.Critical: return "Critical ({})".format(self.card) - case CardType.Dispensable: - return "Dispensable ({}) with weight {}".format(self.card, self.weight) + case CardType.DuplicateVisible: + return "Useful (duplicate visible) ({}) with weight {}".format(self.card, self.weight) + case CardType.UniqueVisible: + return "Useful (unique visible) ({}) with weight {}".format(self.card, self.weight) # TODO @@ -45,7 +49,92 @@ def card_type(game_state, card): elif card.rank == 5 or card in game_state.trash: return CardType.Critical else: - return CardType.Dispensable + visible_cards = sum((game_state.hands[player] for player in range(game_state.num_players)), []) + if visible_cards.count(card) >= 2: + return CardType.DuplicateVisible + else: + return CardType.UniqueVisible + + +class WeightedCard: + def __init__(self, card, weight: Optional[int] = None): + self.card = card + self.weight = weight + + def __repr__(self): + return "{} with weight {}".format(self.card, self.weight) + + +class HandState: + def __init__(self, player: int, game_state: GameState): + self.trash = [] + self.playable = [] + self.critical = [] + self.dupes = [] + self.uniques = [] + for card in game_state.hands[player]: + match card_type(game_state, card): + case CardType.Trash: + self.trash.append(WeightedCard(card)) + case CardType.Playable: + if card not in map(lambda c: c.card, self.playable): + self.playable.append(WeightedCard(card)) + else: + self.trash.append(card) + case CardType.Critical: + self.critical.append(WeightedCard(card)) + case CardType.UniqueVisible: + self.uniques.append(WeightedCard(card)) + case CardType.DuplicateVisible: + copy = next((w for w in self.dupes if w.card == card), None) + if copy is not None: + self.dupes.remove(copy) + self.critical.append(copy) + self.trash.append(card) + else: + self.dupes.append(WeightedCard(card)) + self.playable.sort(key=lambda c: c.card.rank) + self.dupes.sort(key=lambda c: c.card.rank) + self.uniques.sort(key=lambda c: c.card.rank) + if len(self.trash) > 0: + self.best_discard = self.trash[0] + self.discard_badness = 0 + elif len(self.dupes) > 0: + self.best_discard = self.dupes[0] + self.discard_badness = 8 - game_state.num_players + elif len(self.uniques) > 0: + self.best_discard = self.uniques[-1] + self.discard_badness = 80 - 10 * self.best_discard.card.rank + elif len(self.playable) > 0: + self.best_discard = self.playable[-1] + self.discard_badness = 80 - 10 * self.best_discard.card.rank + else: + assert len(self.critical) > 0, "Programming error." + self.best_discard = self.critical[-1] + self.discard_badness = 600 - 100*self.best_discard.card.rank + + def num_useful_cards(self): + return len(self.dupes) + len(self.uniques) + len(self.playable) + len(self.critical) + + +class CheatingStrategy: + def __init__(self, game_state: GameState): + self.game_state = game_state + + def make_move(self): + hand_states = [HandState(player, self.game_state) for player in range(self.game_state.num_players)] + + modified_pace = self.game_state.pace - sum( + 1 for state in hand_states if len(state.trash) == self.game_state.hand_size + ) + + cur_hand = hand_states[self.game_state.turn] + + print([state.__dict__ for state in hand_states]) + print(self.game_state.pace) + exit(0) + + class GreedyStrategy(): @@ -157,7 +246,7 @@ class GreedyStrategy(): def run_deck(instance: HanabiInstance) -> GameState: gs = GameState(instance) - strat = GreedyStrategy(gs) + strat = CheatingStrategy(gs) while not gs.is_over(): strat.make_move() return gs @@ -190,3 +279,9 @@ def run_samples(num_players, sample_size): logger.info("Won {} ({}%) and lost {} ({}%) from sample of {} test games using greedy strategy.".format( won, round(100 * won / sample_size, 2), lost, round(100 * lost / sample_size, 2), sample_size )) + + +if __name__ == "__main__": + for p in range(2, 6): + run_samples(p, int(sys.argv[1])) + print() From e43b062ddad4bc61f54c2b7008da32ff6f92de2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Thu, 25 May 2023 17:00:53 +0200 Subject: [PATCH 03/14] add txt file with strategy --- cheating_strategy | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 cheating_strategy diff --git a/cheating_strategy b/cheating_strategy new file mode 100644 index 0000000..15ec50b --- /dev/null +++ b/cheating_strategy @@ -0,0 +1,54 @@ +card types: +trash, playable, useful (dispensable), critical + + +pace := #(cards left in deck) + #players - #(cards left to play) +modified_pace := pace - #(players without useful cards) +endgame := #(cards left to play) - #(cards left in deck) = #players - pace + -> endgame >= 0 iff pace <= #players +in_endgame := endgame >= 0 + +discard_badness(card) := + 1 if trash + 8 - #players if card useful but duplicate visible # TODO: should probably account for rank of card as well, currently, lowest one is chosen + 80 - 10*rank if card is not critical but currently unique # this ensures we prefer to discard higher ranked cards + 600 - 100*rank if only criticals in hand # essentially not relevant, since we are currently only optimizing for full score + + +Algorithm: + +if (have playable card): + if (in endgame) and not (in extraround): + stall in the following situations: + - we have exactly one useful card, it is a 5, and a copy of each useful card is visible + - we have exactly one useful card, it is a 4, the player with the matching 5 has another critical card to play + - we have exactly one useful card (todo: maybe use critical here?), the deck has size 1, someone else has 2 crits + - we have exactly one playable card, it is a 4, and a further useful card, but the playable is redistributable in the following sense: + the other playing only has this one useful card, and the player holding the matching 5 sits after the to-be-redistributed player + - sth else that seems messy and is currently not understood, ignored for now + TODO: maybe introduce some midgame stalls here, since we know the deck? + play a card, matching the first of the following criteria. if several cards match, recurse with this set of cards + - if in extraround, play crit + - if in second last round and we have 2 crits, play crit + - play card with lowest rank + - play a critical card + - play unique card, i.e. not visible + - lowest suit index (for determinancy) + +if 8 hints: + give a hint + +if 0 hints: + discard card with lowest badness + +stall in the following situations: + - #(cards in deck) == 2 and (card of rank 3 or lower is missing) and we have the connecting card + - #clues >= 8 - #(useful cards in hand), there are useful cards in the deck and either: + - the next player has no useful cards at all + - we have two more crits than the next player and they have trash + - we are in endgame and the deck only contains one card + - it is possible that no-one discards in the following round and we are not waiting for a card whose rank is smaller than pace // TODO: this feels like a weird condition + +discard if (discard badness) + #hints < 10 + +stall if someone has a better discard From 19501aa4da8b1490c32f93292eabeac3620006db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:21:55 +0200 Subject: [PATCH 04/14] hanabi.py: raise error if to-be-replaced card is not in hand --- hanabi.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hanabi.py b/hanabi.py index 9dcd92b..9dc2eed 100644 --- a/hanabi.py +++ b/hanabi.py @@ -103,6 +103,7 @@ class HanabiInstance: self.fives_give_clue = fives_give_clue self.deck_plays = deck_plays, self.all_or_nothing = all_or_nothing + assert not self.all_or_nothing, "All or nothing not implemented" # normalize deck indices for (idx, card) in enumerate(self.deck): @@ -145,7 +146,7 @@ class HanabiInstance: class GameState: - def __init__(self, instance: HanabiInstance): + def __init__(self, instance: HanabiInstance, starting_player: int = 0): # will not be modified self.instance = instance @@ -156,7 +157,7 @@ class GameState: self.stacks = [0 for i in range(0, self.instance.num_suits)] self.strikes = 0 self.clues = 8 - self.turn = 0 + self.turn = starting_player self.pace = self.instance.initial_pace self.remaining_extra_turns = self.instance.num_players + 1 self.trash = [] @@ -310,7 +311,7 @@ class GameState: # replaces the specified card (has to be in current player's hand) with the next card of the deck (if nonempty) def _replace(self, card_idx, allow_not_present: bool = False): try: - idx_in_hand = next((i for (i, card) in enumerate(self.cur_hand) if card.deck_index == card_idx), None) + idx_in_hand = next((i for (i, card) in enumerate(self.cur_hand) if card.deck_index == card_idx)) except StopIteration: if not allow_not_present: raise From 205380d1fa43b41a997b225cd5e0eca9ae3ae13c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:22:37 +0200 Subject: [PATCH 05/14] database: add entry for starting_player --- database/__init__.py | 1 + database/games_seeds_schema.sql | 23 ++++++++++++----------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/database/__init__.py b/database/__init__.py index e69de29..a5e7fa9 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -0,0 +1 @@ +from .database import cur, conn \ No newline at end of file diff --git a/database/games_seeds_schema.sql b/database/games_seeds_schema.sql index 411b1aa..01b22e0 100644 --- a/database/games_seeds_schema.sql +++ b/database/games_seeds_schema.sql @@ -12,17 +12,18 @@ CREATE INDEX seeds_variant_idx ON seeds (variant_id); DROP TABLE IF EXISTS games CASCADE; CREATE TABLE games ( - id INT PRIMARY KEY, - seed TEXT NOT NULL REFERENCES seeds, - num_players SMALLINT NOT NULL, - score SMALLINT NOT NULL, - variant_id SMALLINT NOT NULL, - deck_plays BOOLEAN, - one_extra_card BOOLEAN, - one_less_card BOOLEAN, - all_or_nothing BOOLEAN, - num_turns SMALLINT, - actions TEXT + id INT PRIMARY KEY, + seed TEXT NOT NULL REFERENCES seeds, + num_players SMALLINT NOT NULL, + starting_player SMALLINT NOT NULL DEFAULT 0, + score SMALLINT NOT NULL, + variant_id SMALLINT NOT NULL, + deck_plays BOOLEAN, + one_extra_card BOOLEAN, + one_less_card BOOLEAN, + all_or_nothing BOOLEAN, + num_turns SMALLINT, + actions TEXT ); CREATE INDEX games_seed_score_idx ON games (seed, score); CREATE INDEX games_var_seed_idx ON games (variant_id, seed); From 9f952c231f5a228acc3e997f8f1d79beae57c551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:23:05 +0200 Subject: [PATCH 06/14] download: support instances with some special options --- download_data.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/download_data.py b/download_data.py index e77e6c7..3affd68 100644 --- a/download_data.py +++ b/download_data.py @@ -42,6 +42,7 @@ def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Opti one_extra_card = options.get('oneExtraCard', False) one_less_card = options.get('oneLessCard', False) all_or_nothing = options.get('allOrNothing', False) + starting_player = options.get('startingPlayer', 0) actions = [Action.from_json(action) for action in game_json.get('actions', [])] deck = [DeckCard.from_json(card) for card in game_json.get('deck', None)] @@ -49,11 +50,18 @@ def detailed_export_game(game_id: int, score: Optional[int] = None, var_id: Opti assert seed is not None, assert_msg if score is None: - if deck_plays or one_less_card or one_extra_card or all_or_nothing: - # TODO: need to incorporate extra options here regarding hand size etc - raise RuntimeError('Not implemented.') # need to play through the game once to find out its score - game = HanabLiveGameState(HanabLiveInstance(deck, num_players, var_id)) + game = HanabLiveGameState( + HanabLiveInstance( + deck, num_players, var_id, + deck_plays=deck_plays, + one_less_card=one_less_card, + one_extra_card=one_extra_card, + all_or_nothing=all_or_nothing + ), + starting_player + ) + print(game.instance.hand_size, game.instance.num_players) for action in actions: game.make_action(action) score = game.score From 2ca79dfc6c768a82b0f52bc21562571c046c8128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:23:29 +0200 Subject: [PATCH 07/14] hanabi instances: support differnt starting players --- hanab_live.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hanab_live.py b/hanab_live.py index 3bd0cab..0b4821b 100644 --- a/hanab_live.py +++ b/hanab_live.py @@ -41,8 +41,8 @@ class HanabLiveInstance(hanabi.HanabiInstance): class HanabLiveGameState(hanabi.GameState): - def __init__(self, instance: HanabLiveInstance): - super().__init__(instance) + def __init__(self, instance: HanabLiveInstance, starting_player: int = 0): + super().__init__(instance, starting_player) self.instance: HanabLiveInstance = instance def make_action(self, action): From 75b0f95e0b71004f9bacce4f1f5f88f1ce40289e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:23:49 +0200 Subject: [PATCH 08/14] add max score to variants --- variants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/variants.py b/variants.py index b9c75ff..3dd95e6 100644 --- a/variants.py +++ b/variants.py @@ -218,6 +218,10 @@ class Variant: return True return suit.color_touches(self.colors[value]) + @property + def max_score(self): + return self.num_suits * 5 + @staticmethod def from_db(var_id): cur.execute( From 9f0f85b604343c9d4352f499558298e287d07785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:24:11 +0200 Subject: [PATCH 09/14] deck analysis: check for dark cards at bottom of deck --- deck_analyzer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deck_analyzer.py b/deck_analyzer.py index e3a1855..a476f8c 100644 --- a/deck_analyzer.py +++ b/deck_analyzer.py @@ -12,6 +12,7 @@ class InfeasibilityType(Enum): OutOfPace = 0 # idx denotes index of last card drawn before being forced to reduce pace, value denotes how bad pace is OutOfHandSize = 1 # idx denotes index of last card drawn before being forced to discard a crit NotTrivial = 2 + CritAtBottom = 3 class InfeasibilityReason(): @@ -26,6 +27,9 @@ class InfeasibilityReason(): return "Deck runs out of pace ({}) after drawing card {}".format(self.value, self.index) case InfeasibilityType.OutOfHandSize: return "Deck runs out of hand size after drawing card {}".format(self.index) + case InfeasibilityType.CritAtBottom: + return "Deck has crit non-5 at bottom (index {})".format(self.index) + def analyze_suit(occurrences): # denotes the indexes of copies we can use wlog @@ -97,6 +101,9 @@ def analyze_card_usage(instance: HanabiInstance): def analyze(instance: HanabiInstance, find_non_trivial=False) -> InfeasibilityReason | None: + if instance.deck[-1].rank != 5 and instance.deck[-1].suitIndex + instance.num_dark_suits >= instance.num_suits: + return InfeasibilityReason(InfeasibilityType.CritAtBottom, instance.deck_size - 1) + # we will sweep through the deck and pretend that we instantly play all cards # as soon as we have them (and recurse this) # this allows us to detect standard pace issue arguments From 673e5841a8f9409e768501ed729dfabc9f91d22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:24:37 +0200 Subject: [PATCH 10/14] update instance finder to new db. supports timeouts now --- instance_finder.py | 194 ++++++++++++++++++++++++--------------------- 1 file changed, 103 insertions(+), 91 deletions(-) diff --git a/instance_finder.py b/instance_finder.py index d9d9c02..bd6b635 100644 --- a/instance_finder.py +++ b/instance_finder.py @@ -1,17 +1,24 @@ +from typing import Optional +import pebble.concurrent +import concurrent.futures + +import traceback + from sat import solve_sat -from database import conn -from download_data import export_game -from variants import VARIANTS, variant_name +from database.database import conn, cur +from download_data import detailed_export_game from alive_progress import alive_bar from compress import decompress_deck, link -import concurrent.futures +from hanabi import HanabiInstance from threading import Lock from time import perf_counter from greedy_solver import GameState, GreedyStrategy -from log_setup.logger_setup import logger +from log_setup import logger from deck_analyzer import analyze, InfeasibilityReason +from variants import Variant + +MAX_PROCESSES = 6 -MAX_PROCESSES=4 def update_seeds_db(): cur2 = conn.cursor() @@ -33,51 +40,45 @@ def update_seeds_db(): def get_decks_of_seeds(): - cur = conn.cursor() cur2 = conn.cursor() - cur.execute("SELECT seed FROM seeds WHERE deck is NULL") - for (seed,) in cur: - cur2.execute("SELECT id FROM games WHERE seed = (%s)", (seed,)) + cur.execute("SELECT seed, variant_id FROM seeds WHERE deck is NULL") + for (seed, variant_id) in cur: + cur2.execute("SELECT id FROM games WHERE seed = (%s) LIMIT 1", (seed,)) (game_id,) = cur2.fetchone() - print("Exporting game {} for seed {}.".format(game_id, seed)) - export_game(game_id) + logger.verbose("Exporting game {} for seed {}.".format(game_id, seed)) + detailed_export_game(game_id, var_id=variant_id, seed_exists=True) conn.commit() -def update_trivially_feasible_games(): - cur = conn.cursor() - for var in VARIANTS: - cur.execute("SELECT seed FROM seeds WHERE variant_id = (%s) AND feasible is null", (var['id'],)) - seeds = cur.fetchall() - print('Checking variant {} (id {}), found {} seeds to check...'.format(var['name'], var['id'], len(seeds))) - - with alive_bar(total=len(seeds), title='{} ({})'.format(var['name'], var['id'])) as bar: - for (seed,) in seeds: - cur.execute("SELECT id, deck_plays, one_extra_card, one_less_card, all_or_nothing " - "FROM games WHERE score = (%s) AND seed = (%s) ORDER BY id;", - (5 * len(var['suits']), seed) - ) - res = cur.fetchall() - print("Checking seed {}: {:3} results".format(seed, len(res))) - for (game_id, a, b, c, d) in res: - if None in [a,b,c,d]: - print(' Game {} not found in database, exporting...'.format(game_id)) - succ, valid = export_game(game_id) - if not succ: - print('Error exporting game {}.'.format(game_id)) - continue - else: - valid = not any([a,b,c,d]) - print(' Game {} already in database, valid: {}'.format(game_id, valid)) - if valid: - print('Seed {:10} (variant {} / {}) found to be feasible via game {:6}'.format(seed, var['id'], var['name'], game_id)) - cur.execute("UPDATE seeds SET feasible = (%s) WHERE seed = (%s)", (True, seed)) - conn.commit() - break - else: - print(' Cheaty game found') - bar() +def update_trivially_feasible_games(variant_id): + variant: Variant = Variant.from_db(variant_id) + cur.execute("SELECT seed FROM seeds WHERE variant_id = (%s) AND feasible is null", (variant_id,)) + seeds = cur.fetchall() + print('Checking variant {} (id {}), found {} seeds to check...'.format(variant.name, variant_id, len(seeds))) + with alive_bar(total=len(seeds), title='{} ({})'.format(variant.name, variant_id)) as bar: + for (seed,) in seeds: + cur.execute("SELECT id, deck_plays, one_extra_card, one_less_card, all_or_nothing " + "FROM games WHERE score = (%s) AND seed = (%s) ORDER BY id;", + (variant.max_score, seed) + ) + res = cur.fetchall() + logger.debug("Checking seed {}: {:3} results".format(seed, len(res))) + for (game_id, a, b, c, d) in res: + if None in [a, b, c, d]: + logger.debug(' Game {} not found in database, exporting...'.format(game_id)) + detailed_export_game(game_id, var_id=variant_id) + else: + logger.debug(' Game {} already in database'.format(game_id, valid)) + valid = not any([a, b, c, d]) + if valid: + logger.verbose('Seed {:10} (variant {}) found to be feasible via game {:6}'.format(seed, variant_id, game_id)) + cur.execute("UPDATE seeds SET feasible = (%s) WHERE seed = (%s)", (True, seed)) + conn.commit() + break + else: + logger.verbose(' Cheaty game found') + bar() def get_decks_for_all_seeds(): @@ -103,90 +104,101 @@ def get_decks_for_all_seeds(): mutex = Lock() -def solve_instance(num_players, deck): + +def solve_instance(instance: HanabiInstance): # first, sanity check on running out of pace - result = analyze(deck, num_players) + result = analyze(instance) if result is not None: assert type(result) == InfeasibilityReason - logger.info("found infeasible deck") + logger.debug("found infeasible deck") return False, None, None - for num_remaining_cards in [0, 5, 10, 20, 30]: -# logger.info("trying with {} remaining cards".format(num_remaining_cards)) - game = GameState(num_players, deck) + for num_remaining_cards in [0, 20]: + # logger.info("trying with {} remaining cards".format(num_remaining_cards)) + game = GameState(instance) strat = GreedyStrategy(game) # make a number of greedy moves while not game.is_over() and not game.is_known_lost(): if num_remaining_cards != 0 and game.progress == game.deck_size - num_remaining_cards: - break # stop solution here + break # stop solution here strat.make_move() - + # check if we won already if game.is_won(): -# print("won with greedy strat") + # print("won with greedy strat") return True, game, num_remaining_cards # now, apply sat solver if not game.is_over(): - logger.info("continuing greedy sol with SAT") + logger.debug("continuing greedy sol with SAT") solvable, sol = solve_sat(game) - if solvable: + if solvable is None: return True, sol, num_remaining_cards - logger.info("No success with {} remaining cards, reducing number of greedy moves, failed attempt was: {}".format(num_remaining_cards, link(game.to_json()))) -# print("Aborting trying with greedy strat") - logger.info("Starting full SAT solver") - game = GameState(num_players, deck) + logger.debug( + "No success with {} remaining cards, reducing number of greedy moves, failed attempt was: {}".format( + num_remaining_cards, link(game))) + # print("Aborting trying with greedy strat") + logger.debug("Starting full SAT solver") + game = GameState(instance) a, b = solve_sat(game) - return a, b, 99 + return a, b, instance.draw_pile_size -def solve_seed(seed, num_players, deck_compressed, var_id): +@pebble.concurrent.process(timeout=150) +def solve_seed_with_timeout(seed, num_players, deck_compressed, var_name: Optional[str] = None): try: + logger.verbose("Starting to solve seed {}".format(seed)) deck = decompress_deck(deck_compressed) t0 = perf_counter() - solvable, solution, num_remaining_cards = solve_instance(num_players, deck) + solvable, solution, num_remaining_cards = solve_instance(HanabiInstance(deck, num_players)) t1 = perf_counter() - logger.info("Solved instance {} in {} seconds".format(seed, round(t1-t0, 2))) + logger.verbose("Solved instance {} in {} seconds: {}".format(seed, round(t1 - t0, 2), solvable)) mutex.acquire() if solvable is not None: - lcur = conn.cursor() - lcur.execute("UPDATE seeds SET feasible = (%s) WHERE seed = (%s)", (solvable, seed)) + cur.execute("UPDATE seeds SET feasible = (%s) WHERE seed = (%s)", (solvable, seed)) conn.commit() + mutex.release() if solvable == True: - with open("remaining_cards.txt", "a") as f: - f.write("Success with {} cards left in draw by greedy solver on seed {}: {}\n".format(num_remaining_cards, seed ,link(solution.to_json()))) + logger.verbose("Success with {} cards left in draw by greedy solver on seed {}: {}\n".format( + num_remaining_cards, seed, link(solution)) + ) elif solvable == False: - logger.info("seed {} was not solvable".format(seed)) - with open('infeasible_instances.txt', 'a') as f: - f.write('{}-player, seed {:10}, {}\n'.format(num_players, seed, variant_name(var_id))) + logger.debug("seed {} was not solvable".format(seed)) + logger.debug('{}-player, seed {:10}, {}\n'.format(num_players, seed, var_name)) elif solvable is None: - logger.info("seed {} skipped".format(seed)) + logger.verbose("seed {} skipped".format(seed)) else: raise Exception("Programming Error") - mutex.release() - except Exception: - traceback.format_exc() + except Exception as e: print("exception in subprocess:") + traceback.print_exc() -def solve_unknown_seeds(): - cur = conn.cursor() - for var in VARIANTS: - cur.execute("SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) AND feasible IS NULL AND deck IS NOT NULL", (var['id'],)) - res = cur.fetchall() - -# for r in res: -# solve_seed(r[0], r[1], r[2], var['id']) - - with concurrent.futures.ProcessPoolExecutor(max_workers=MAX_PROCESSES) as executor: - fs = [executor.submit(solve_seed, r[0], r[1], r[2], var['id']) for r in res] - with alive_bar(len(res), title='Seed solving on {}'.format(var['name'])) as bar: - for f in concurrent.futures.as_completed(fs): - bar() - break +def solve_seed(seed, num_players, deck_compressed, var_name: Optional[str] = None): + f = solve_seed_with_timeout(seed, num_players, deck_compressed, var_name) + try: + return f.result() + except TimeoutError: + logger.verbose("Solving on seed {} timed out".format(seed)) + return -solve_unknown_seeds() +def solve_unknown_seeds(variant_id, variant_name: Optional[str] = None): + cur.execute("SELECT seed, num_players, deck FROM seeds WHERE variant_id = (%s) AND feasible IS NULL", (variant_id,)) + res = cur.fetchall() + + # for r in res: + # solve_seed(r[0], r[1], r[2], variant_name) + + with concurrent.futures.ProcessPoolExecutor(max_workers=MAX_PROCESSES) as executor: + fs = [executor.submit(solve_seed, r[0], r[1], r[2], variant_name) for r in res] + with alive_bar(len(res), title='Seed solving on {}'.format(variant_name)) as bar: + for f in concurrent.futures.as_completed(fs): + bar() + + +update_trivially_feasible_games(0) +solve_unknown_seeds(0, "No Variant") \ No newline at end of file From a91a6db47e6d57111a25b36b4b4be2e4a0fc77a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:25:07 +0200 Subject: [PATCH 11/14] greedy_solver: add back Dispensable CardType to allow old greedy solver to function --- greedy_solver.py | 1 + 1 file changed, 1 insertion(+) diff --git a/greedy_solver.py b/greedy_solver.py index 448aa0c..a864817 100755 --- a/greedy_solver.py +++ b/greedy_solver.py @@ -12,6 +12,7 @@ from database.database import conn class CardType(Enum): + Dispensable = -1 Trash = 0 Playable = 1 Critical = 2 From 4f4ec7e7c20483a7687b8b4288d263c30020ee7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:25:19 +0200 Subject: [PATCH 12/14] update requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c36a39a..480456b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ psycopg2 alive_progress argparse verboselogs +pebble From e85b7948c68c2f7d79f87bdce6b0ceb2e2166fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:25:25 +0200 Subject: [PATCH 13/14] update test file --- test.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test.py b/test.py index e24c54a..e6c212d 100644 --- a/test.py +++ b/test.py @@ -14,6 +14,28 @@ from database.database import conn, cur from database.init_database import init_database_tables, populate_static_tables +def find_double_dark_games(): + cur.execute("SELECT variants.id, variants.name, count(suits.id) from variants " + "inner join variant_suits on variants.id = variant_suits.variant_id " + "left join suits on suits.id = variant_suits.suit_id " + "where suits.dark = (%s) " + "group by variants.id " + "order by count(suits.id), variants.id", + (True,) + ) + cur2 = conn.cursor() + r = [] + for (var_id, var_name, num_dark_suits) in cur.fetchall(): + if num_dark_suits == 2: + cur2.execute("select count(*) from games where variant_id = (%s)", (var_id,)) + games = cur2.fetchone()[0] + cur2.execute("select count(*) from seeds where variant_id = (%s)", (var_id, )) + r.append((var_name, games, cur2.fetchone()[0])) + l = sorted(r, key=lambda e: -e[1]) + for (name, games, seeds) in l: + print("{}: {} games on {} seeds".format(name, games, seeds)) + + def test_suits(): suit = Suit.from_db(55) print(suit.__dict__) @@ -52,6 +74,8 @@ def export_all_seeds(): if __name__ == "__main__": + find_double_dark_games() + exit(0) var_id = 964532 export_all_seeds() exit(0) From 8615e25c2149a4ad5525a9cb1893989615e5000b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20Ke=C3=9Fler?= Date: Sat, 24 Jun 2023 17:25:54 +0200 Subject: [PATCH 14/14] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 510837f..887779f 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ remaining_cards.txt lost_seeds.txt utils.py infeasible_instances.txt +verbose_log.txt +debug_log.txt