Compare commits
No commits in common. "main" and "cheating-bot" have entirely different histories.
main
...
cheating-b
31 changed files with 246 additions and 653 deletions
20
README.md
20
README.md
|
@ -48,27 +48,23 @@ source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### SAT-solver
|
|
||||||
After installing python, you should have installed `pysmt` with it, an interface to use SAT-solvers with python.
|
|
||||||
We still need a solver, for this, run
|
|
||||||
```
|
|
||||||
$ pysmt-install --help
|
|
||||||
// Pick a solver, e.g. z3 works
|
|
||||||
$ pysmt-install --z3
|
|
||||||
```
|
|
||||||
|
|
||||||
### PostgreSQL
|
### PostgreSQL
|
||||||
You need to install PostgreSQL on your system, for installation instructions refer to your distribution.
|
You need to install PostgreSQL on your system, for installation instructions refer to your distribution.
|
||||||
Create a new database and user, for example:
|
Create a new database and user, for example:
|
||||||
```
|
```
|
||||||
$ sudo -iu postgres
|
$ sudo -iu postgres
|
||||||
$ psql
|
$ psql
|
||||||
# CREATE USER hanabi WITH PASSWORD 'Insert password here';
|
# CREATE DATABASE "hanab-live";
|
||||||
# CREATE DATABASE "hanab-live" with owner "hanabi";
|
# \c hanab-live
|
||||||
|
# CREATE USER hanabi WITH PASSWORD '1234';
|
||||||
|
# GRANT ALL PRIVILEGES ON DATABASE "hanab-live" TO hanabi;
|
||||||
|
# GRANT USAGE ON SCHEMA public TO hanabi;
|
||||||
|
# GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO hanabi;
|
||||||
|
# GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO hanabi;
|
||||||
```
|
```
|
||||||
Put the connection parameters in a config file (for the format, see `example_config.yaml`).
|
Put the connection parameters in a config file (for the format, see `example_config.yaml`).
|
||||||
This should be located at your system default for the application `hanabi-suite`,
|
This should be located at your system default for the application `hanabi-suite`,
|
||||||
on POSIX systems this should be `~/.config/hanabi-suite/config.yaml`.
|
on POSIX systems this should be `~/.config/hanabi-suit/config.yaml`.
|
||||||
|
|
||||||
|
|
||||||
## Usage of stuff that already works:
|
## Usage of stuff that already works:
|
||||||
|
|
58
cheating_strategy
Normal file
58
cheating_strategy
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
Just some preliminary notes on how to implement a cheating bot
|
||||||
|
Based on https://github.com/fpvandoorn/hanabi
|
||||||
|
|
||||||
|
|
||||||
|
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
|
|
@ -1,5 +1,4 @@
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import verboselogs
|
import verboselogs
|
||||||
|
@ -9,7 +8,6 @@ from hanabi.live import variants
|
||||||
from hanabi.live import check_game
|
from hanabi.live import check_game
|
||||||
from hanabi.live import download_data
|
from hanabi.live import download_data
|
||||||
from hanabi.live import compress
|
from hanabi.live import compress
|
||||||
from hanabi.live import instance_finder
|
|
||||||
from hanabi.database import init_database
|
from hanabi.database import init_database
|
||||||
from hanabi.database import global_db_connection_manager
|
from hanabi.database import global_db_connection_manager
|
||||||
|
|
||||||
|
@ -39,13 +37,6 @@ def subcommand_analyze(game_id: int, download: bool = False):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def subcommand_decompress(game_link: str):
|
|
||||||
parts = game_link.split('replay-json/')
|
|
||||||
game_str = parts[-1].rstrip('/')
|
|
||||||
game = compress.decompress_game_state(game_str)
|
|
||||||
print(json.dumps(game.to_json()))
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_init(force: bool, populate: bool):
|
def subcommand_init(force: bool, populate: bool):
|
||||||
tables = init_database.get_existing_tables()
|
tables = init_database.get_existing_tables()
|
||||||
if len(tables) > 0 and not force:
|
if len(tables) > 0 and not force:
|
||||||
|
@ -86,11 +77,6 @@ def subcommand_download(
|
||||||
logger.info("Successfully exported games for all variants")
|
logger.info("Successfully exported games for all variants")
|
||||||
|
|
||||||
|
|
||||||
def subcommand_solve(var_id):
|
|
||||||
instance_finder.solve_unknown_seeds(var_id, '')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def subcommand_gen_config():
|
def subcommand_gen_config():
|
||||||
global_db_connection_manager.create_config_file()
|
global_db_connection_manager.create_config_file()
|
||||||
|
|
||||||
|
@ -143,15 +129,6 @@ def add_config_gen_subparser(subparsers):
|
||||||
parser = subparsers.add_parser('gen-config', help='Generate config file at default location')
|
parser = subparsers.add_parser('gen-config', help='Generate config file at default location')
|
||||||
|
|
||||||
|
|
||||||
def add_solve_subparser(subparsers):
|
|
||||||
parser = subparsers.add_parser('solve', help='Seed solving')
|
|
||||||
parser.add_argument('--var_id', type=int, help='Variant id to solve instances from.', default=0)
|
|
||||||
|
|
||||||
def add_decompress_subparser(subparsers):
|
|
||||||
parser = subparsers.add_parser('decompress', help='Decompress a hanab.live JSON-encoded replay link')
|
|
||||||
parser.add_argument('game_link', type=str)
|
|
||||||
|
|
||||||
|
|
||||||
def main_parser() -> argparse.ArgumentParser:
|
def main_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog='hanabi_suite',
|
prog='hanabi_suite',
|
||||||
|
@ -164,8 +141,6 @@ def main_parser() -> argparse.ArgumentParser:
|
||||||
add_analyze_subparser(subparsers)
|
add_analyze_subparser(subparsers)
|
||||||
add_download_subparser(subparsers)
|
add_download_subparser(subparsers)
|
||||||
add_config_gen_subparser(subparsers)
|
add_config_gen_subparser(subparsers)
|
||||||
add_solve_subparser(subparsers)
|
|
||||||
add_decompress_subparser(subparsers)
|
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
@ -176,9 +151,7 @@ def hanabi_cli():
|
||||||
'analyze': subcommand_analyze,
|
'analyze': subcommand_analyze,
|
||||||
'init': subcommand_init,
|
'init': subcommand_init,
|
||||||
'download': subcommand_download,
|
'download': subcommand_download,
|
||||||
'gen-config': subcommand_gen_config,
|
'gen-config': subcommand_gen_config
|
||||||
'solve': subcommand_solve,
|
|
||||||
'decompress': subcommand_decompress
|
|
||||||
}[args.command]
|
}[args.command]
|
||||||
|
|
||||||
if args.command != 'gen-config':
|
if args.command != 'gen-config':
|
49
hanabi/database/games_seeds_schema.sql
Normal file
49
hanabi/database/games_seeds_schema.sql
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
DROP TABLE IF EXISTS seeds CASCADE;
|
||||||
|
CREATE TABLE seeds (
|
||||||
|
seed TEXT NOT NULL PRIMARY KEY,
|
||||||
|
num_players SMALLINT NOT NULL,
|
||||||
|
variant_id SMALLINT NOT NULL,
|
||||||
|
deck VARCHAR(62) NOT NULL,
|
||||||
|
starting_player SMALLINT NOT NULL DEFAULT 0,
|
||||||
|
feasible BOOLEAN DEFAULT NULL,
|
||||||
|
max_score_theoretical SMALLINT
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
detrimental_characters 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);
|
||||||
|
CREATE INDEX games_player_idx ON games (num_players);
|
||||||
|
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS score_upper_bounds;
|
||||||
|
CREATE TABLE score_upper_bounds (
|
||||||
|
seed TEXT NOT NULL REFERENCES seeds ON DELETE CASCADE,
|
||||||
|
score_upper_bound SMALLINT NOT NULL,
|
||||||
|
reason SMALLINT NOT NULL,
|
||||||
|
UNIQUE (seed, reason)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS score_lower_bounds;
|
||||||
|
CREATE TABLE score_lower_bounds (
|
||||||
|
seed TEXT NOT NULL REFERENCES seeds ON DELETE CASCADE,
|
||||||
|
score_lower_bound SMALLINT NOT NULL,
|
||||||
|
game_id INT REFERENCES games ON DELETE CASCADE,
|
||||||
|
actions TEXT,
|
||||||
|
CHECK (num_nonnulls(game_id, actions) = 1)
|
||||||
|
);
|
|
@ -192,7 +192,7 @@ def _populate_variants(variants):
|
||||||
|
|
||||||
def _download_json_files():
|
def _download_json_files():
|
||||||
logger.verbose("Downloading JSON files for suits and variants from github...")
|
logger.verbose("Downloading JSON files for suits and variants from github...")
|
||||||
base_url = "https://raw.githubusercontent.com/Hanabi-Live/hanabi-live/main/packages/game/src/json"
|
base_url = "https://raw.githubusercontent.com/Hanabi-Live/hanabi-live/main/packages/data/src/json"
|
||||||
cache_dir = Path(platformdirs.user_cache_dir(constants.APP_NAME))
|
cache_dir = Path(platformdirs.user_cache_dir(constants.APP_NAME))
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
data = {}
|
data = {}
|
||||||
|
@ -204,7 +204,7 @@ def _download_json_files():
|
||||||
url = base_url + "/" + file.name
|
url = base_url + "/" + file.name
|
||||||
response = requests.get(url)
|
response = requests.get(url)
|
||||||
if not response.status_code == 200:
|
if not response.status_code == 200:
|
||||||
err_msg = "Could not download initialization file {} from github (tried url {})".format(file.name, url)
|
err_msg = "Could not download initialization file {} from github (tried url {})".format(filename, url)
|
||||||
logger.error(err_msg)
|
logger.error(err_msg)
|
||||||
raise RuntimeError(err_msg)
|
raise RuntimeError(err_msg)
|
||||||
file.write_text(response.text)
|
file.write_text(response.text)
|
|
@ -25,12 +25,6 @@ class DeckCard:
|
||||||
raise ParseError("No rank specified in deck_card")
|
raise ParseError("No rank specified in deck_card")
|
||||||
return DeckCard(suit_index, rank)
|
return DeckCard(suit_index, rank)
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"suitIndex": self.suitIndex,
|
|
||||||
"rank": self.rank
|
|
||||||
}
|
|
||||||
|
|
||||||
def colorize(self):
|
def colorize(self):
|
||||||
color = ["green", "blue", "magenta", "yellow", "white", "cyan"][self.suitIndex]
|
color = ["green", "blue", "magenta", "yellow", "white", "cyan"][self.suitIndex]
|
||||||
return colored(str(self), color)
|
return colored(str(self), color)
|
||||||
|
@ -39,8 +33,6 @@ class DeckCard:
|
||||||
return self.suitIndex == other.suitIndex and self.rank == other.rank
|
return self.suitIndex == other.suitIndex and self.rank == other.rank
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.suitIndex == 0 and self.rank == 0:
|
|
||||||
return "kt"
|
|
||||||
return constants.COLOR_INITIALS[self.suitIndex] + str(self.rank)
|
return constants.COLOR_INITIALS[self.suitIndex] + str(self.rank)
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
|
@ -92,13 +84,6 @@ class Action:
|
||||||
action_value
|
action_value
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"type": self.type.value,
|
|
||||||
"target": self.target,
|
|
||||||
"value": self.value
|
|
||||||
}
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
match self.type:
|
match self.type:
|
||||||
case ActionType.Play:
|
case ActionType.Play:
|
||||||
|
@ -254,21 +239,6 @@ class GameState:
|
||||||
self.clues -= 1
|
self.clues -= 1
|
||||||
self._make_turn()
|
self._make_turn()
|
||||||
|
|
||||||
def make_action(self, action):
|
|
||||||
match action.type:
|
|
||||||
case ActionType.ColorClue | ActionType.RankClue:
|
|
||||||
assert self.clues >= 1
|
|
||||||
self.actions.append(action)
|
|
||||||
self.clues -= 1
|
|
||||||
self._make_turn()
|
|
||||||
# TODO: could check that the clue specified is in fact legal
|
|
||||||
case ActionType.Play:
|
|
||||||
self.play(action.target)
|
|
||||||
case ActionType.Discard:
|
|
||||||
self.discard(action.target)
|
|
||||||
case ActionType.EndGame | ActionType.VoteTerminate:
|
|
||||||
self.over = True
|
|
||||||
|
|
||||||
# Forward some properties of the underlying instance
|
# Forward some properties of the underlying instance
|
||||||
@property
|
@property
|
||||||
def num_players(self):
|
def num_players(self):
|
||||||
|
@ -294,10 +264,6 @@ class GameState:
|
||||||
def deck_size(self):
|
def deck_size(self):
|
||||||
return self.instance.deck_size
|
return self.instance.deck_size
|
||||||
|
|
||||||
@property
|
|
||||||
def draw_pile_size(self):
|
|
||||||
return self.deck_size - self.progress
|
|
||||||
|
|
||||||
# Properties of GameState
|
# Properties of GameState
|
||||||
|
|
||||||
def is_over(self):
|
def is_over(self):
|
||||||
|
@ -321,23 +287,6 @@ class GameState:
|
||||||
|
|
||||||
# Utilities
|
# Utilities
|
||||||
|
|
||||||
def is_playable(self, card: DeckCard):
|
|
||||||
return self.stacks[card.suitIndex] + 1 == card.rank
|
|
||||||
|
|
||||||
def is_trash(self, card: DeckCard):
|
|
||||||
return self.stacks[card.suitIndex] >= card.rank
|
|
||||||
|
|
||||||
def is_critical(self, card: DeckCard):
|
|
||||||
if card.rank == 5:
|
|
||||||
return True
|
|
||||||
if self.is_trash(card):
|
|
||||||
return False
|
|
||||||
count = 0
|
|
||||||
for hand in self.hands:
|
|
||||||
count += hand.count(card)
|
|
||||||
count += self.deck[self.progress:].count(card)
|
|
||||||
return count == 1
|
|
||||||
|
|
||||||
def holding_players(self, card):
|
def holding_players(self, card):
|
||||||
for (player, hand) in enumerate(self.hands):
|
for (player, hand) in enumerate(self.hands):
|
||||||
if card in hand:
|
if card in hand:
|
||||||
|
@ -352,9 +301,9 @@ class GameState:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"deck": [card.to_json() for card in self.instance.deck],
|
"deck": self.instance.deck,
|
||||||
"players": self.instance.player_names,
|
"players": self.instance.player_names,
|
||||||
"actions": [action.to_json() for action in self.actions],
|
"actions": self.actions,
|
||||||
"first_player": 0,
|
"first_player": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"variant": "No Variant",
|
"variant": "No Variant",
|
|
@ -8,8 +8,6 @@ from hanabi.live import hanab_live
|
||||||
from hanabi.live import compress
|
from hanabi.live import compress
|
||||||
from hanabi.solvers import sat
|
from hanabi.solvers import sat
|
||||||
|
|
||||||
from hanabi.database import games_db_interface
|
|
||||||
|
|
||||||
|
|
||||||
# returns minimal number T of turns (from game) after which instance was infeasible
|
# returns minimal number T of turns (from game) after which instance was infeasible
|
||||||
# and a replay achieving maximum score while following the replay for the first (T-1) turns:
|
# and a replay achieving maximum score while following the replay for the first (T-1) turns:
|
||||||
|
@ -21,15 +19,24 @@ from hanabi.database import games_db_interface
|
||||||
def check_game(game_id: int) -> Tuple[int, hanab_game.GameState]:
|
def check_game(game_id: int) -> Tuple[int, hanab_game.GameState]:
|
||||||
logger.debug("Analysing game {}".format(game_id))
|
logger.debug("Analysing game {}".format(game_id))
|
||||||
with database.conn.cursor() as cur:
|
with database.conn.cursor() as cur:
|
||||||
cur.execute("SELECT games.num_players, score, games.variant_id, starting_player FROM games "
|
cur.execute("SELECT games.num_players, deck, actions, score, games.variant_id, starting_player FROM games "
|
||||||
|
"INNER JOIN seeds ON seeds.seed = games.seed "
|
||||||
"WHERE games.id = (%s)",
|
"WHERE games.id = (%s)",
|
||||||
(game_id,)
|
(game_id,)
|
||||||
)
|
)
|
||||||
res = cur.fetchone()
|
res = cur.fetchone()
|
||||||
if res is None:
|
if res is None:
|
||||||
raise ValueError("No game associated with id {} in database.".format(game_id))
|
raise ValueError("No game associated with id {} in database.".format(game_id))
|
||||||
(num_players, score, variant_id, starting_player) = res
|
(num_players, compressed_deck, compressed_actions, score, variant_id, starting_player) = res
|
||||||
instance, actions = games_db_interface.load_game_parts(game_id)
|
deck = compress.decompress_deck(compressed_deck)
|
||||||
|
actions = compress.decompress_actions(compressed_actions)
|
||||||
|
|
||||||
|
instance = hanab_live.HanabLiveInstance(
|
||||||
|
deck,
|
||||||
|
num_players,
|
||||||
|
variant_id=variant_id,
|
||||||
|
starting_player=starting_player
|
||||||
|
)
|
||||||
|
|
||||||
# check if the instance is already won
|
# check if the instance is already won
|
||||||
if instance.max_score == score:
|
if instance.max_score == score:
|
|
@ -177,7 +177,7 @@ def compress_game_state(state: Union[hanab_game.GameState, hanab_live.HanabLiveG
|
||||||
return with_dashes
|
return with_dashes
|
||||||
|
|
||||||
|
|
||||||
def decompress_game_state(game_str: str) -> hanab_live.HanabLiveGameState:
|
def decompress_game_state(game_str: str) -> hanab_game.GameState:
|
||||||
game_str = game_str.replace("-", "")
|
game_str = game_str.replace("-", "")
|
||||||
parts = game_str.split(",")
|
parts = game_str.split(",")
|
||||||
if not len(parts) == 3:
|
if not len(parts) == 3:
|
||||||
|
@ -211,8 +211,8 @@ def decompress_game_state(game_str: str) -> hanab_live.HanabLiveGameState:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise ValueError("Expected variant id, found: {}".format(variant_id))
|
raise ValueError("Expected variant id, found: {}".format(variant_id))
|
||||||
|
|
||||||
instance = hanab_live.HanabLiveInstance(deck, num_players, variant_id)
|
instance = hanab_game.HanabiInstance(deck, num_players)
|
||||||
game = hanab_live.HanabLiveGameState(instance)
|
game = hanab_game.GameState(instance)
|
||||||
|
|
||||||
# TODO: game is not in consistent state
|
# TODO: game is not in consistent state
|
||||||
game.actions = actions
|
game.actions = actions
|
|
@ -1,21 +1,18 @@
|
||||||
import alive_progress
|
import alive_progress
|
||||||
from typing import Dict, Optional, List
|
from typing import Dict, Optional
|
||||||
|
|
||||||
import psycopg2.errors
|
import psycopg2.errors
|
||||||
import psycopg2.extras
|
|
||||||
import platformdirs
|
import platformdirs
|
||||||
import unidecode
|
|
||||||
|
|
||||||
from hanabi import hanab_game
|
from hanabi import hanab_game
|
||||||
from hanabi import constants
|
from hanabi import constants
|
||||||
from hanabi import logger
|
from hanabi import logger
|
||||||
from hanabi import database
|
from hanabi import database
|
||||||
from hanabi.live import site_api
|
from hanabi.live import site_api
|
||||||
|
from hanabi.live import compress
|
||||||
from hanabi.live import variants
|
from hanabi.live import variants
|
||||||
from hanabi.live import hanab_live
|
from hanabi.live import hanab_live
|
||||||
|
|
||||||
from hanabi.database import games_db_interface
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GameExportError(ValueError):
|
class GameExportError(ValueError):
|
||||||
|
@ -52,29 +49,6 @@ class GameExportInvalidNumberOfPlayersError(GameExportInvalidFormatError):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_users_in_db_and_get_ids(usernames: List[str]):
|
|
||||||
normalized_usernames = [unidecode.unidecode(username) for username in usernames]
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
database.cur,
|
|
||||||
"INSERT INTO users (username, normalized_username)"
|
|
||||||
"VALUES %s "
|
|
||||||
"ON CONFLICT (username) DO NOTHING ",
|
|
||||||
zip(usernames, normalized_usernames)
|
|
||||||
)
|
|
||||||
|
|
||||||
# To only do one DB query, we sort by the normalized username.
|
|
||||||
ids = []
|
|
||||||
for username in usernames:
|
|
||||||
database.cur.execute(
|
|
||||||
"SELECT id FROM users "
|
|
||||||
"WHERE username = %s",
|
|
||||||
(username,)
|
|
||||||
)
|
|
||||||
(id, ) = database.cur.fetchone()
|
|
||||||
ids.append(id)
|
|
||||||
|
|
||||||
return ids
|
|
||||||
|
|
||||||
#
|
#
|
||||||
def detailed_export_game(
|
def detailed_export_game(
|
||||||
game_id: int
|
game_id: int
|
||||||
|
@ -120,19 +94,12 @@ def detailed_export_game(
|
||||||
|
|
||||||
options = game_json.get('options', {})
|
options = game_json.get('options', {})
|
||||||
var_id = var_id or variants.variant_id(options.get('variant', 'No Variant'))
|
var_id = var_id or variants.variant_id(options.get('variant', 'No Variant'))
|
||||||
timed = options.get('timed', False)
|
|
||||||
time_base = options.get('timeBase', 0)
|
|
||||||
time_per_turn = options.get('timePerTurn', 0)
|
|
||||||
speedrun = options.get('speedrun', False)
|
|
||||||
card_cycle = options.get('cardCycle', False)
|
|
||||||
deck_plays = options.get('deckPlays', False)
|
deck_plays = options.get('deckPlays', False)
|
||||||
empty_clues = options.get('emptyClues', False)
|
|
||||||
one_extra_card = options.get('oneExtraCard', False)
|
one_extra_card = options.get('oneExtraCard', False)
|
||||||
one_less_card = options.get('oneLessCard', False)
|
one_less_card = options.get('oneLessCard', False)
|
||||||
all_or_nothing = options.get('allOrNothing', False)
|
all_or_nothing = options.get('allOrNothing', False)
|
||||||
detrimental_characters = options.get('detrimentalCharacters', False)
|
|
||||||
|
|
||||||
starting_player = options.get('startingPlayer', 0)
|
starting_player = options.get('startingPlayer', 0)
|
||||||
|
detrimental_characters = options.get('detrimentalCharacters', False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
|
actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
|
||||||
|
@ -164,53 +131,44 @@ def detailed_export_game(
|
||||||
game.make_action(action)
|
game.make_action(action)
|
||||||
score = game.score
|
score = game.score
|
||||||
|
|
||||||
|
try:
|
||||||
|
compressed_deck = compress.compress_deck(deck)
|
||||||
|
except compress.InvalidFormatError as e:
|
||||||
|
logger.error("Failed to compress deck while exporting game {}: {}".format(game_id, deck))
|
||||||
|
raise GameExportInvalidFormatError(game_id, "Failed to compress deck") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
compressed_actions = compress.compress_actions(actions)
|
||||||
|
except compress.InvalidFormatError as e:
|
||||||
|
logger.error("Failed to compress actions while exporting game {}".format(game_id))
|
||||||
|
raise GameExportInvalidFormatError(game_id, "Failed to compress actions") from e
|
||||||
|
|
||||||
if not seed_exists:
|
if not seed_exists:
|
||||||
database.cur.execute(
|
database.cur.execute(
|
||||||
"INSERT INTO seeds (seed, num_players, starting_player, variant_id)"
|
"INSERT INTO seeds (seed, num_players, starting_player, variant_id, deck)"
|
||||||
"VALUES (%s, %s, %s, %s)"
|
"VALUES (%s, %s, %s, %s, %s)"
|
||||||
"ON CONFLICT (seed) DO NOTHING",
|
"ON CONFLICT (seed) DO NOTHING",
|
||||||
(seed, num_players, starting_player, var_id)
|
(seed, num_players, starting_player, var_id, compressed_deck)
|
||||||
)
|
)
|
||||||
logger.debug("New seed {} imported.".format(seed))
|
logger.debug("New seed {} imported.".format(seed))
|
||||||
|
|
||||||
games_db_interface.store_deck_for_seed(seed, deck)
|
|
||||||
|
|
||||||
database.cur.execute(
|
database.cur.execute(
|
||||||
"INSERT INTO games ("
|
"INSERT INTO games ("
|
||||||
"id, num_players, starting_player, variant_id, timed, time_base, time_per_turn, speedrun, card_cycle, "
|
"id, num_players, score, seed, variant_id, deck_plays, one_extra_card, one_less_card,"
|
||||||
"deck_plays, empty_clues, one_extra_card, one_less_card,"
|
"all_or_nothing, detrimental_characters, actions"
|
||||||
"all_or_nothing, detrimental_characters, seed, score"
|
|
||||||
")"
|
")"
|
||||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
|
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
|
||||||
"ON CONFLICT (id) DO UPDATE SET ("
|
"ON CONFLICT (id) DO UPDATE SET ("
|
||||||
"timed, time_base, time_per_turn, speedrun, card_cycle, deck_plays, empty_clues, one_extra_card,"
|
"deck_plays, one_extra_card, one_less_card, all_or_nothing, actions, detrimental_characters"
|
||||||
"all_or_nothing, detrimental_characters"
|
|
||||||
") = ("
|
") = ("
|
||||||
"EXCLUDED.timed, EXCLUDED.time_base, EXCLUDED.time_per_turn, EXCLUDED.speedrun, EXCLUDED.card_cycle, "
|
"EXCLUDED.deck_plays, EXCLUDED.one_extra_card, EXCLUDED.one_less_card, EXCLUDED.all_or_nothing,"
|
||||||
"EXCLUDED.deck_plays, EXCLUDED.empty_clues, EXCLUDED.one_extra_card,"
|
"EXCLUDED.actions, EXCLUDED.detrimental_characters"
|
||||||
"EXCLUDED.all_or_nothing, EXCLUDED.detrimental_characters"
|
|
||||||
")",
|
")",
|
||||||
(
|
(
|
||||||
game_id, num_players, starting_player, var_id, timed, time_base, time_per_turn, speedrun, card_cycle,
|
game_id, num_players, score, seed, var_id, deck_plays, one_extra_card, one_less_card,
|
||||||
deck_plays, empty_clues, one_extra_card, one_less_card,
|
all_or_nothing, detrimental_characters, compressed_actions
|
||||||
all_or_nothing, detrimental_characters, seed, score
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert participants into database
|
|
||||||
ids = ensure_users_in_db_and_get_ids(players)
|
|
||||||
game_participant_values = []
|
|
||||||
for index, user_id in enumerate(ids):
|
|
||||||
game_participant_values.append((game_id, user_id, index))
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
database.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_participant_values
|
|
||||||
)
|
|
||||||
|
|
||||||
games_db_interface.store_actions(game_id, actions)
|
|
||||||
|
|
||||||
logger.debug("Imported game {}".format(game_id))
|
logger.debug("Imported game {}".format(game_id))
|
||||||
|
|
||||||
|
|
||||||
|
@ -234,8 +192,6 @@ def _process_game_row(game: Dict, var_id, export_all_games: bool = False):
|
||||||
return
|
return
|
||||||
# raise GameExportInvalidNumberOfPlayersError(game_id, num_players, users)
|
# raise GameExportInvalidNumberOfPlayersError(game_id, num_players, users)
|
||||||
|
|
||||||
# Ensure users in database and find out their ids
|
|
||||||
|
|
||||||
if export_all_games:
|
if export_all_games:
|
||||||
detailed_export_game(game_id, score=score, var_id=var_id)
|
detailed_export_game(game_id, score=score, var_id=var_id)
|
||||||
logger.debug("Imported game {}".format(game_id))
|
logger.debug("Imported game {}".format(game_id))
|
||||||
|
@ -256,20 +212,6 @@ def _process_game_row(game: Dict, var_id, export_all_games: bool = False):
|
||||||
database.cur.execute("ROLLBACK TO seed_insert")
|
database.cur.execute("ROLLBACK TO seed_insert")
|
||||||
detailed_export_game(game_id, score=score, var_id=var_id)
|
detailed_export_game(game_id, score=score, var_id=var_id)
|
||||||
database.cur.execute("RELEASE seed_insert")
|
database.cur.execute("RELEASE seed_insert")
|
||||||
|
|
||||||
# Insert participants into database
|
|
||||||
ids = ensure_users_in_db_and_get_ids(users)
|
|
||||||
game_participant_values = []
|
|
||||||
for index, user_id in enumerate(ids):
|
|
||||||
game_participant_values.append((game_id, user_id, index))
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
database.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_participant_values
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
logger.debug("Imported game {}".format(game_id))
|
logger.debug("Imported game {}".format(game_id))
|
||||||
|
|
||||||
|
|
81
hanabi/live/hanab_live.py
Normal file
81
hanabi/live/hanab_live.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from hanabi import hanab_game
|
||||||
|
from hanabi import constants
|
||||||
|
from hanabi.live import variants
|
||||||
|
|
||||||
|
|
||||||
|
class HanabLiveInstance(hanab_game.HanabiInstance):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
deck: List[hanab_game.DeckCard],
|
||||||
|
num_players: int,
|
||||||
|
variant_id: int,
|
||||||
|
one_extra_card: bool = False,
|
||||||
|
one_less_card: bool = False,
|
||||||
|
*args, **kwargs
|
||||||
|
):
|
||||||
|
assert 2 <= num_players <= 6
|
||||||
|
hand_size = constants.HAND_SIZES[num_players]
|
||||||
|
if one_less_card:
|
||||||
|
hand_size -= 1
|
||||||
|
if one_extra_card:
|
||||||
|
hand_size += 1
|
||||||
|
|
||||||
|
super().__init__(deck, num_players, hand_size=hand_size, *args, **kwargs)
|
||||||
|
self.variant_id = variant_id
|
||||||
|
self.variant = variants.Variant.from_db(self.variant_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def select_standard_variant_id(instance: hanab_game.HanabiInstance):
|
||||||
|
err_msg = "Hanabi instance not supported by hanab.live, cannot convert to HanabLiveInstance: "
|
||||||
|
assert 3 <= instance.num_suits <= 6, \
|
||||||
|
err_msg + "Illegal number of suits ({}) found, must be in range [3,6]".format(instance.num_suits)
|
||||||
|
assert 0 <= instance.num_dark_suits <= 2, \
|
||||||
|
err_msg + "Illegal number of dark suits ({}) found, must be in range [0,2]".format(instance.num_dark_suits)
|
||||||
|
assert 4 <= instance.num_suits - instance.num_dark_suits, \
|
||||||
|
err_msg + "Illegal ratio of dark suits to suits, can have at most {} dark suits with {} total suits".format(
|
||||||
|
max(instance.num_suits - 4, 0), instance.num_suits
|
||||||
|
)
|
||||||
|
return constants.VARIANT_IDS_STANDARD_DISTRIBUTIONS[instance.num_suits][instance.num_dark_suits]
|
||||||
|
|
||||||
|
|
||||||
|
class HanabLiveGameState(hanab_game.GameState):
|
||||||
|
def __init__(self, instance: HanabLiveInstance):
|
||||||
|
super().__init__(instance)
|
||||||
|
self.instance: HanabLiveInstance = instance
|
||||||
|
|
||||||
|
def make_action(self, action):
|
||||||
|
match action.type:
|
||||||
|
case hanab_game.ActionType.ColorClue | hanab_game.ActionType.RankClue:
|
||||||
|
assert(self.clues > 0)
|
||||||
|
self.actions.append(action)
|
||||||
|
self.clues -= self.instance.clue_increment
|
||||||
|
self._make_turn()
|
||||||
|
# TODO: could check that the clue specified is in fact legal
|
||||||
|
case hanab_game.ActionType.Play:
|
||||||
|
self.play(action.target)
|
||||||
|
case hanab_game.ActionType.Discard:
|
||||||
|
self.discard(action.target)
|
||||||
|
case hanab_game.ActionType.EndGame | hanab_game.ActionType.VoteTerminate:
|
||||||
|
self.over = True
|
||||||
|
|
||||||
|
def _waste_clue(self) -> hanab_game.Action:
|
||||||
|
for player in range(self.turn + 1, self.turn + self.num_players):
|
||||||
|
for card in self.hands[player % self.num_players]:
|
||||||
|
for rank in self.instance.variant.ranks:
|
||||||
|
if self.instance.variant.rank_touches(card, rank):
|
||||||
|
return hanab_game.Action(
|
||||||
|
hanab_game.ActionType.RankClue,
|
||||||
|
player % self.num_players,
|
||||||
|
rank
|
||||||
|
)
|
||||||
|
for color in range(self.instance.variant.num_colors):
|
||||||
|
if self.instance.variant.color_touches(card, color):
|
||||||
|
return hanab_game.Action(
|
||||||
|
hanab_game.ActionType.ColorClue,
|
||||||
|
player % self.num_players,
|
||||||
|
color
|
||||||
|
)
|
||||||
|
raise RuntimeError("Current game state did not permit any legal clue."
|
||||||
|
"This case is incredibly rare and currently not handled.")
|
|
@ -93,8 +93,9 @@ mutex = threading.Lock()
|
||||||
def solve_instance(instance: hanab_game.HanabiInstance):
|
def solve_instance(instance: hanab_game.HanabiInstance):
|
||||||
# first, sanity check on running out of pace
|
# first, sanity check on running out of pace
|
||||||
result = deck_analyzer.analyze(instance)
|
result = deck_analyzer.analyze(instance)
|
||||||
if len(result) != 0:
|
if result is not None:
|
||||||
logger.info("found infeasible deck by foreward analysis")
|
assert type(result) == deck_analyzer.InfeasibilityReason
|
||||||
|
logger.debug("found infeasible deck")
|
||||||
return False, None, None
|
return False, None, None
|
||||||
for num_remaining_cards in [0, 20]:
|
for num_remaining_cards in [0, 20]:
|
||||||
# logger.info("trying with {} remaining cards".format(num_remaining_cards))
|
# logger.info("trying with {} remaining cards".format(num_remaining_cards))
|
|
@ -1,4 +1,4 @@
|
||||||
#! /usr//bin/env python3
|
#! /bin/python3
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Short executable file to start the command-line-interface for the hanabi package.
|
Short executable file to start the command-line-interface for the hanabi package.
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
[project]
|
|
||||||
name = "hanabi"
|
|
||||||
version = "1.1.5"
|
|
||||||
description = "Hanabi interface"
|
|
||||||
readme = "README.md"
|
|
||||||
license = { file = "LICENSE" }
|
|
||||||
keywords = [ "hanabi" ]
|
|
||||||
authors = [
|
|
||||||
{ name = "Maximilian Keßler", email = "git@maximilian-kessler.de" }
|
|
||||||
]
|
|
||||||
dependencies = [
|
|
||||||
"requests",
|
|
||||||
"requests_cache",
|
|
||||||
"pysmt",
|
|
||||||
"termcolor",
|
|
||||||
"more_itertools",
|
|
||||||
"psycopg2",
|
|
||||||
"alive_progress",
|
|
||||||
"argparse",
|
|
||||||
"verboselogs",
|
|
||||||
"pebble",
|
|
||||||
"platformdirs",
|
|
||||||
"PyYAML",
|
|
||||||
"cython==0.29.36"
|
|
||||||
]
|
|
||||||
|
|
||||||
[project.urls]
|
|
||||||
"Homepage" = "https://gitlab.com/kesslermaximilian/hanabi"
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["setuptools>=43.0.0", "wheel"]
|
|
||||||
build-backend = "setuptools.build_meta"
|
|
|
@ -10,5 +10,3 @@ verboselogs
|
||||||
pebble
|
pebble
|
||||||
platformdirs
|
platformdirs
|
||||||
PyYAML
|
PyYAML
|
||||||
cython==0.29.36
|
|
||||||
unidecode
|
|
||||||
|
|
|
@ -1,129 +0,0 @@
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
import psycopg2.extras
|
|
||||||
|
|
||||||
import hanabi.hanab_game
|
|
||||||
import hanabi.live.hanab_live
|
|
||||||
from hanabi import logger
|
|
||||||
|
|
||||||
from hanabi.database import conn, cur
|
|
||||||
|
|
||||||
|
|
||||||
def store_actions(game_id: int, actions: List[hanabi.hanab_game.Action]):
|
|
||||||
vals = []
|
|
||||||
for turn, action in enumerate(actions):
|
|
||||||
vals.append((game_id, turn, action.type.value, action.target, action.value or 0))
|
|
||||||
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
cur,
|
|
||||||
"INSERT INTO game_actions (game_id, turn, type, target, value) "
|
|
||||||
"VALUES %s "
|
|
||||||
"ON CONFLICT (game_id, turn) "
|
|
||||||
"DO NOTHING",
|
|
||||||
vals
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def store_deck_for_seed(seed: str, deck: List[hanabi.hanab_game.DeckCard]):
|
|
||||||
vals = []
|
|
||||||
for index, card in enumerate(deck):
|
|
||||||
vals.append((seed, index, card.suitIndex, card.rank))
|
|
||||||
|
|
||||||
psycopg2.extras.execute_values(
|
|
||||||
cur,
|
|
||||||
"INSERT INTO decks (seed, deck_index, suit_index, rank) "
|
|
||||||
"VALUES %s "
|
|
||||||
"ON CONFLICT (seed, deck_index) DO UPDATE SET "
|
|
||||||
"(suit_index, rank) = (excluded.suit_index, excluded.rank)",
|
|
||||||
vals
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def load_actions(game_id: int) -> List[hanabi.hanab_game.Action]:
|
|
||||||
cur.execute("SELECT type, target, value FROM game_actions "
|
|
||||||
"WHERE game_id = %s "
|
|
||||||
"ORDER BY turn ASC",
|
|
||||||
(game_id,))
|
|
||||||
actions = []
|
|
||||||
for action_type, target, value in cur.fetchall():
|
|
||||||
actions.append(
|
|
||||||
hanabi.hanab_game.Action(hanabi.hanab_game.ActionType(action_type), target, value)
|
|
||||||
)
|
|
||||||
if len(actions) == 0:
|
|
||||||
err_msg = "Failed to load actions for game id {} from DB: No actions stored.".format(game_id)
|
|
||||||
logger.error(err_msg)
|
|
||||||
raise ValueError(err_msg)
|
|
||||||
return actions
|
|
||||||
|
|
||||||
|
|
||||||
def load_deck(seed: str) -> List[hanabi.hanab_game.DeckCard]:
|
|
||||||
cur.execute("SELECT deck_index, suit_index, rank FROM decks "
|
|
||||||
"WHERE seed = %s "
|
|
||||||
"ORDER BY deck_index ASC",
|
|
||||||
(seed,)
|
|
||||||
)
|
|
||||||
deck = []
|
|
||||||
for index, (card_index, suit_index, rank) in enumerate(cur.fetchall()):
|
|
||||||
assert index == card_index
|
|
||||||
deck.append(
|
|
||||||
hanabi.hanab_game.DeckCard(suit_index, rank, card_index)
|
|
||||||
)
|
|
||||||
if len(deck) == 0:
|
|
||||||
err_msg = "Failed to load deck for seed {} from DB: No cards stored.".format(seed)
|
|
||||||
logger.error(err_msg)
|
|
||||||
raise ValueError(err_msg)
|
|
||||||
return deck
|
|
||||||
|
|
||||||
|
|
||||||
def load_game_parts(game_id: int) -> Tuple[hanabi.live.hanab_live.HanabLiveInstance, List[hanabi.hanab_game.Action]]:
|
|
||||||
"""
|
|
||||||
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.execute(
|
|
||||||
"SELECT "
|
|
||||||
"games.num_players, games.seed, games.one_extra_card, games.one_less_card, games.deck_plays, "
|
|
||||||
"games.all_or_nothing,"
|
|
||||||
"variants.clue_starved, variants.name, variants.id, variants.throw_it_in_a_hole "
|
|
||||||
"FROM games "
|
|
||||||
"INNER JOIN variants"
|
|
||||||
" ON games.variant_id = variants.id "
|
|
||||||
"WHERE games.id = %s",
|
|
||||||
(game_id,)
|
|
||||||
)
|
|
||||||
res = cur.fetchone()
|
|
||||||
if res is None:
|
|
||||||
err_msg = "Failed to retrieve game details of game {}.".format(game_id)
|
|
||||||
logger.error(err_msg)
|
|
||||||
raise ValueError(err_msg)
|
|
||||||
|
|
||||||
# Unpack results now
|
|
||||||
(num_players, seed, one_extra_card, one_less_card, deck_plays, all_or_nothing, clue_starved, variant_name, variant_id, throw_it_in_a_hole) = res
|
|
||||||
|
|
||||||
actions = load_actions(game_id)
|
|
||||||
deck = load_deck(seed)
|
|
||||||
|
|
||||||
instance = hanabi.live.hanab_live.HanabLiveInstance(
|
|
||||||
deck=deck,
|
|
||||||
num_players=num_players,
|
|
||||||
variant_id=variant_id,
|
|
||||||
one_extra_card=one_extra_card,
|
|
||||||
one_less_card=one_less_card,
|
|
||||||
fives_give_clue=not throw_it_in_a_hole,
|
|
||||||
deck_plays=deck_plays,
|
|
||||||
all_or_nothing=all_or_nothing,
|
|
||||||
clue_starved=clue_starved
|
|
||||||
)
|
|
||||||
return instance, actions
|
|
||||||
|
|
||||||
|
|
||||||
def load_game(game_id: int) -> hanabi.live.hanab_live.HanabLiveGameState:
|
|
||||||
instance, actions = load_game_parts(game_id)
|
|
||||||
game = hanabi.live.hanab_live.HanabLiveGameState(instance)
|
|
||||||
for action in actions:
|
|
||||||
game.make_action(action)
|
|
||||||
return game
|
|
||||||
|
|
|
@ -1,154 +0,0 @@
|
||||||
DROP TABLE IF EXISTS users CASCADE;
|
|
||||||
CREATE TABLE users (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE,
|
|
||||||
normalized_username TEXT NOT NULL UNIQUE
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS seeds CASCADE;
|
|
||||||
CREATE TABLE seeds (
|
|
||||||
seed TEXT NOT NULL PRIMARY KEY,
|
|
||||||
num_players SMALLINT NOT NULL,
|
|
||||||
variant_id SMALLINT NOT NULL,
|
|
||||||
starting_player SMALLINT NOT NULL DEFAULT 0,
|
|
||||||
feasible BOOLEAN DEFAULT NULL,
|
|
||||||
max_score_theoretical SMALLINT
|
|
||||||
);
|
|
||||||
CREATE INDEX seeds_variant_idx ON seeds (variant_id);
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS decks CASCADE;
|
|
||||||
CREATE TABLE decks (
|
|
||||||
seed TEXT REFERENCES seeds (seed),
|
|
||||||
/* Order of card in deck*/
|
|
||||||
deck_index SMALLINT NOT NULL,
|
|
||||||
/* Suit */
|
|
||||||
suit_index SMALLINT NOT NULL,
|
|
||||||
/* Rank */
|
|
||||||
rank SMALLINT NOT NULL,
|
|
||||||
PRIMARY KEY (seed, deck_index)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS games CASCADE;
|
|
||||||
CREATE TABLE games (
|
|
||||||
id INT PRIMARY KEY,
|
|
||||||
num_players SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
starting_player SMALLINT NOT NULL DEFAULT 0,
|
|
||||||
|
|
||||||
variant_id SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
timed BOOLEAN,
|
|
||||||
time_base INTEGER,
|
|
||||||
time_per_turn INTEGER,
|
|
||||||
speedrun BOOLEAN,
|
|
||||||
card_cycle BOOLEAN,
|
|
||||||
deck_plays BOOLEAN,
|
|
||||||
empty_clues BOOLEAN,
|
|
||||||
one_extra_card BOOLEAN,
|
|
||||||
one_less_card BOOLEAN,
|
|
||||||
all_or_nothing BOOLEAN,
|
|
||||||
detrimental_characters BOOLEAN,
|
|
||||||
|
|
||||||
seed TEXT NOT NULL REFERENCES seeds,
|
|
||||||
score SMALLINT NOT NULL,
|
|
||||||
num_turns SMALLINT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX games_seed_score_idx ON games (seed, score);
|
|
||||||
CREATE INDEX games_var_seed_idx ON games (variant_id, seed);
|
|
||||||
CREATE INDEX games_player_idx ON games (num_players);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS game_participants CASCADE;
|
|
||||||
CREATE TABLE game_participants (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
game_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
seat SMALLINT NOT NULL, /* Needed for the "GetNotes()" function */
|
|
||||||
FOREIGN KEY (game_id) REFERENCES games (id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT game_participants_unique UNIQUE (game_id, user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
DROP FUNCTION IF EXISTS delete_game_of_deleted_participant;
|
|
||||||
CREATE FUNCTION delete_game_of_deleted_participant() RETURNS TRIGGER AS $_$
|
|
||||||
BEGIN
|
|
||||||
DELETE FROM games WHERE games.id = OLD.game_id;
|
|
||||||
RETURN OLD;
|
|
||||||
END $_$ LANGUAGE 'plpgsql';
|
|
||||||
|
|
||||||
CREATE TRIGGER delete_game_upon_participant_deletion
|
|
||||||
AFTER DELETE ON game_participants
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE PROCEDURE delete_game_of_deleted_participant();
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS game_participant_notes CASCADE;
|
|
||||||
CREATE TABLE game_participant_notes (
|
|
||||||
game_participant_id INTEGER NOT NULL,
|
|
||||||
card_order SMALLINT NOT NULL, /* "order" is a reserved word in PostgreSQL. */
|
|
||||||
note TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (game_participant_id) REFERENCES game_participants (id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (game_participant_id, card_order)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS game_actions CASCADE;
|
|
||||||
CREATE TABLE game_actions (
|
|
||||||
game_id INTEGER NOT NULL,
|
|
||||||
turn SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Corresponds to the "DatabaseGameActionType" enum.
|
|
||||||
*
|
|
||||||
* - 0 - play
|
|
||||||
* - 1 - discard
|
|
||||||
* - 2 - color clue
|
|
||||||
* - 3 - rank clue
|
|
||||||
* - 4 - game over
|
|
||||||
*/
|
|
||||||
type SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* - If a play or a discard, corresponds to the order of the the card that was played/discarded.
|
|
||||||
* - If a clue, corresponds to the index of the player that received the clue.
|
|
||||||
* - If a game over, corresponds to the index of the player that caused the game to end or -1 if
|
|
||||||
* the game was terminated by the server.
|
|
||||||
*/
|
|
||||||
target SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* - If a play or discard, then 0 (as NULL). It uses less database space and reduces code
|
|
||||||
* complexity to use a value of 0 for NULL than to use a SQL NULL:
|
|
||||||
* https://dev.mysql.com/doc/refman/8.0/en/data-size.html
|
|
||||||
* - If a color clue, then 0 if red, 1 if yellow, etc.
|
|
||||||
* - If a rank clue, then 1 if 1, 2 if 2, etc.
|
|
||||||
* - If a game over, then the value corresponds to the "endCondition" values in "constants.go".
|
|
||||||
*/
|
|
||||||
value SMALLINT NOT NULL,
|
|
||||||
|
|
||||||
FOREIGN KEY (game_id) REFERENCES games (id) ON DELETE CASCADE,
|
|
||||||
PRIMARY KEY (game_id, turn)
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS score_upper_bounds;
|
|
||||||
CREATE TABLE score_upper_bounds (
|
|
||||||
seed TEXT NOT NULL REFERENCES seeds ON DELETE CASCADE,
|
|
||||||
score_upper_bound SMALLINT NOT NULL,
|
|
||||||
reason SMALLINT NOT NULL,
|
|
||||||
UNIQUE (seed, reason)
|
|
||||||
);
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS score_lower_bounds;
|
|
||||||
CREATE TABLE score_lower_bounds (
|
|
||||||
seed TEXT NOT NULL REFERENCES seeds ON DELETE CASCADE,
|
|
||||||
score_lower_bound SMALLINT NOT NULL,
|
|
||||||
game_id INT REFERENCES games ON DELETE CASCADE,
|
|
||||||
actions TEXT,
|
|
||||||
CHECK (num_nonnulls(game_id, actions) = 1)
|
|
||||||
);
|
|
|
@ -1,146 +0,0 @@
|
||||||
from typing import List, Dict, Tuple
|
|
||||||
|
|
||||||
from hanabi.hanab_game import Action, ParseError
|
|
||||||
from hanabi import hanab_game
|
|
||||||
from hanabi import constants
|
|
||||||
from hanabi.live import variants
|
|
||||||
|
|
||||||
|
|
||||||
class HanabLiveInstance(hanab_game.HanabiInstance):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
deck: List[hanab_game.DeckCard],
|
|
||||||
num_players: int,
|
|
||||||
variant_id: int,
|
|
||||||
one_extra_card: bool = False,
|
|
||||||
one_less_card: bool = False,
|
|
||||||
*args, **kwargs
|
|
||||||
):
|
|
||||||
self.one_extra_card = one_extra_card
|
|
||||||
self.one_less_card = one_less_card
|
|
||||||
assert 2 <= num_players <= 6
|
|
||||||
hand_size = constants.HAND_SIZES[num_players]
|
|
||||||
if one_less_card:
|
|
||||||
hand_size -= 1
|
|
||||||
if one_extra_card:
|
|
||||||
hand_size += 1
|
|
||||||
|
|
||||||
super().__init__(deck, num_players, hand_size=hand_size, *args, **kwargs)
|
|
||||||
self.variant_id = variant_id
|
|
||||||
self.variant = variants.Variant.from_db(self.variant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def select_standard_variant_id(instance: hanab_game.HanabiInstance):
|
|
||||||
err_msg = "Hanabi instance not supported by hanab.live, cannot convert to HanabLiveInstance: "
|
|
||||||
assert 3 <= instance.num_suits <= 6, \
|
|
||||||
err_msg + "Illegal number of suits ({}) found, must be in range [3,6]".format(instance.num_suits)
|
|
||||||
assert 0 <= instance.num_dark_suits <= 2, \
|
|
||||||
err_msg + "Illegal number of dark suits ({}) found, must be in range [0,2]".format(instance.num_dark_suits)
|
|
||||||
assert 4 <= max(instance.num_suits, 4) - instance.num_dark_suits, \
|
|
||||||
err_msg + "Illegal ratio of dark suits to suits, can have at most {} dark suits with {} total suits".format(
|
|
||||||
max(instance.num_suits - 4, 0), instance.num_suits
|
|
||||||
)
|
|
||||||
return constants.VARIANT_IDS_STANDARD_DISTRIBUTIONS[instance.num_suits][instance.num_dark_suits]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_json_game(game_json: Dict, as_hanab_live_instance: bool = True) \
|
|
||||||
-> Tuple[HanabLiveInstance | hanab_game.HanabiInstance, List[Action]]:
|
|
||||||
game_id = game_json.get('id', None)
|
|
||||||
players = game_json.get('players', [])
|
|
||||||
num_players = len(players)
|
|
||||||
if num_players < 2 or num_players > 6:
|
|
||||||
raise ParseError(num_players)
|
|
||||||
|
|
||||||
options = game_json.get('options', {})
|
|
||||||
var_name = options.get('variant', 'No Variant')
|
|
||||||
deck_plays = options.get('deckPlays', False)
|
|
||||||
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)
|
|
||||||
detrimental_characters = options.get('detrimentalCharacters', False)
|
|
||||||
|
|
||||||
try:
|
|
||||||
actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
|
|
||||||
except hanab_game.ParseError as e:
|
|
||||||
raise ParseError("Failed to parse actions") from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
deck = [hanab_game.DeckCard.from_json(card) for card in game_json.get('deck', None)]
|
|
||||||
except hanab_game.ParseError as e:
|
|
||||||
raise ParseError("Failed to parse deck") from e
|
|
||||||
|
|
||||||
if detrimental_characters:
|
|
||||||
raise NotImplementedError(
|
|
||||||
"detrimental characters not supported, cannot determine score of game {}".format(game_id)
|
|
||||||
)
|
|
||||||
if as_hanab_live_instance:
|
|
||||||
var_id = variants.variant_id(var_name)
|
|
||||||
return 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=starting_player
|
|
||||||
), actions
|
|
||||||
else:
|
|
||||||
hand_size = constants.HAND_SIZES[num_players]
|
|
||||||
if one_less_card:
|
|
||||||
hand_size -= 1
|
|
||||||
if one_extra_card:
|
|
||||||
hand_size += 1
|
|
||||||
|
|
||||||
clue_starved = 'Clue Starved' in var_name
|
|
||||||
|
|
||||||
return hanab_game.HanabiInstance(
|
|
||||||
deck, num_players, hand_size,
|
|
||||||
clue_starved=clue_starved,
|
|
||||||
deck_plays=deck_plays,
|
|
||||||
all_or_nothing=all_or_nothing,
|
|
||||||
starting_player=starting_player
|
|
||||||
), actions
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class HanabLiveGameState(hanab_game.GameState):
|
|
||||||
def __init__(self, instance: HanabLiveInstance):
|
|
||||||
super().__init__(instance)
|
|
||||||
self.instance: HanabLiveInstance = instance
|
|
||||||
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"actions": [action.to_json() for action in self.actions],
|
|
||||||
"deck": [card.to_json() for card in self.deck],
|
|
||||||
"players": ["Alice", "Bob", "Cathy", "Donald", "Emily", "Frank"][:self.num_players],
|
|
||||||
"notes": [[]] * self.num_players,
|
|
||||||
"options": {
|
|
||||||
"variant": self.instance.variant_id,
|
|
||||||
"deckPlays": self.instance.deck_plays,
|
|
||||||
"oneExtraCard": self.instance.one_extra_card,
|
|
||||||
"oneLessCard": self.instance.one_less_card,
|
|
||||||
"allOrNothing": self.instance.all_or_nothing,
|
|
||||||
"startingPlayer": self.instance.starting_player
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def _waste_clue(self) -> hanab_game.Action:
|
|
||||||
for player in range(self.turn + 1, self.turn + self.num_players):
|
|
||||||
for card in self.hands[player % self.num_players]:
|
|
||||||
for rank in self.instance.variant.ranks:
|
|
||||||
if self.instance.variant.rank_touches(card, rank):
|
|
||||||
return hanab_game.Action(
|
|
||||||
hanab_game.ActionType.RankClue,
|
|
||||||
player % self.num_players,
|
|
||||||
rank
|
|
||||||
)
|
|
||||||
for color in range(self.instance.variant.num_colors):
|
|
||||||
if self.instance.variant.color_touches(card, color):
|
|
||||||
return hanab_game.Action(
|
|
||||||
hanab_game.ActionType.ColorClue,
|
|
||||||
player % self.num_players,
|
|
||||||
color
|
|
||||||
)
|
|
||||||
raise RuntimeError("Current game state did not permit any legal clue."
|
|
||||||
"This case is incredibly rare and currently not handled.")
|
|
Loading…
Reference in a new issue