Compare commits

..

34 commits

Author SHA1 Message Date
a944bda70e
CLI: add option to decompress hanab.live JSON links
This allows easy conversion of shortened JSON links
to the full-fledged JSON game format, which can be
used with other programs
2024-06-04 00:02:57 +02:00
1c656de615
Adjust games_db_interface to database format
Also adjust the check_game method to use new interface.
2024-03-19 14:57:11 +01:00
6651ef9145
update download url 2024-03-18 14:58:07 +01:00
8716ac7514
add unidecode to requiremenents 2024-03-18 14:34:15 +01:00
247576da0e
adjust seed solving code 2024-03-18 14:09:25 +01:00
9ef1add7ab
bugfix: allow 3-suit variants 2024-03-18 14:09:25 +01:00
7a6e62b8d9
add cli option to start seed solving 2024-03-18 14:09:25 +01:00
afecc6f63d
Rework database schema: Replicate server and store all info 2024-03-18 14:09:25 +01:00
51e09cd943
represent r0 as kt 2024-01-14 13:09:41 +01:00
3ac51d574e
fix clue cost in ClueStarved variants 2023-12-08 12:25:03 +01:00
daea750535
Support taking general actions also in general game 2023-11-23 12:30:50 +01:00
d9afe3bff4
Support parsing JSON games without variant
This enables to parse games without requiring DB access
and a full specification of all the variants.
The downside is that in such games, legal clues cannot
be modeled perfectly, but for theoretic analysis of the game
this is still irrelevant.
2023-11-23 11:44:31 +01:00
40baa59bd3
add method to check for criticality of card 2023-11-10 12:05:23 +01:00
c0e63fe17e
fix import path 2023-11-10 12:05:14 +01:00
c00c88974c
fix to_json function 2023-11-10 01:17:49 +01:00
9200371e3a
add missing to_json mehods 2023-08-08 14:10:17 +02:00
511c3bc7c6
better return type 2023-08-08 14:06:44 +02:00
3ffdfc10a5
fix bug 2023-08-08 13:59:28 +02:00
5a2329fa0b
add export to json of games 2023-08-08 13:53:48 +02:00
330baff33c
fix bug expecting seed 2023-08-08 12:23:42 +02:00
e8a6b83d43
add draw pile method 2023-08-08 12:19:16 +02:00
e8f3405d58
increment version 2023-08-08 12:16:09 +02:00
cd94f6fa68
add method to parse game from json 2023-08-08 12:12:42 +02:00
c65489655d
change src folder structure 2023-08-08 12:10:59 +02:00
ffadd53935
fix 2023-08-08 11:37:48 +02:00
d2bb254f31
fix 2023-08-08 11:37:28 +02:00
e0d5f46a7f
rename into src folder 2023-08-08 11:36:31 +02:00
ee58a2fb8d
add pyproject.toml 2023-08-08 11:34:47 +02:00
a85504cc1c
adjust README: easier setup of DB 2023-08-02 11:50:08 +02:00
fb3f25b890
document installation of SAT solver 2023-07-27 16:22:14 +02:00
881c21cc9c
make shebang use env 2023-07-27 16:14:31 +02:00
193564bfd6
fix typo in README 2023-07-27 15:45:20 +02:00
0525bd4768
update README 2023-07-27 15:44:39 +02:00
2f4a16995a
remove unneeded text file 2023-07-11 21:54:13 +02:00
31 changed files with 653 additions and 246 deletions

View file

@ -48,23 +48,27 @@ source venv/bin/activate
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
You need to install PostgreSQL on your system, for installation instructions refer to your distribution.
Create a new database and user, for example:
```
$ sudo -iu postgres
$ psql
# CREATE DATABASE "hanab-live";
# \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;
# CREATE USER hanabi WITH PASSWORD 'Insert password here';
# CREATE DATABASE "hanab-live" with owner "hanabi";
```
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`,
on POSIX systems this should be `~/.config/hanabi-suit/config.yaml`.
on POSIX systems this should be `~/.config/hanabi-suite/config.yaml`.
## Usage of stuff that already works:

View file

@ -1,58 +0,0 @@
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

View file

@ -1,49 +0,0 @@
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)
);

View file

@ -1,81 +0,0 @@
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.")

View file

@ -1,4 +1,4 @@
#! /bin/python3
#! /usr//bin/env python3
"""
Short executable file to start the command-line-interface for the hanabi package.

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[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"

View file

@ -10,3 +10,5 @@ verboselogs
pebble
platformdirs
PyYAML
cython==0.29.36
unidecode

View file

@ -1,4 +1,5 @@
import argparse
import json
from typing import Optional
import verboselogs
@ -8,6 +9,7 @@ from hanabi.live import variants
from hanabi.live import check_game
from hanabi.live import download_data
from hanabi.live import compress
from hanabi.live import instance_finder
from hanabi.database import init_database
from hanabi.database import global_db_connection_manager
@ -37,6 +39,13 @@ 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):
tables = init_database.get_existing_tables()
if len(tables) > 0 and not force:
@ -77,6 +86,11 @@ def subcommand_download(
logger.info("Successfully exported games for all variants")
def subcommand_solve(var_id):
instance_finder.solve_unknown_seeds(var_id, '')
def subcommand_gen_config():
global_db_connection_manager.create_config_file()
@ -129,6 +143,15 @@ def add_config_gen_subparser(subparsers):
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:
parser = argparse.ArgumentParser(
prog='hanabi_suite',
@ -141,6 +164,8 @@ def main_parser() -> argparse.ArgumentParser:
add_analyze_subparser(subparsers)
add_download_subparser(subparsers)
add_config_gen_subparser(subparsers)
add_solve_subparser(subparsers)
add_decompress_subparser(subparsers)
return parser
@ -151,7 +176,9 @@ def hanabi_cli():
'analyze': subcommand_analyze,
'init': subcommand_init,
'download': subcommand_download,
'gen-config': subcommand_gen_config
'gen-config': subcommand_gen_config,
'solve': subcommand_solve,
'decompress': subcommand_decompress
}[args.command]
if args.command != 'gen-config':

View file

@ -0,0 +1,129 @@
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

View file

@ -0,0 +1,154 @@
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)
);

View file

@ -192,7 +192,7 @@ def _populate_variants(variants):
def _download_json_files():
logger.verbose("Downloading JSON files for suits and variants from github...")
base_url = "https://raw.githubusercontent.com/Hanabi-Live/hanabi-live/main/packages/data/src/json"
base_url = "https://raw.githubusercontent.com/Hanabi-Live/hanabi-live/main/packages/game/src/json"
cache_dir = Path(platformdirs.user_cache_dir(constants.APP_NAME))
cache_dir.mkdir(parents=True, exist_ok=True)
data = {}
@ -204,7 +204,7 @@ def _download_json_files():
url = base_url + "/" + file.name
response = requests.get(url)
if not response.status_code == 200:
err_msg = "Could not download initialization file {} from github (tried url {})".format(filename, url)
err_msg = "Could not download initialization file {} from github (tried url {})".format(file.name, url)
logger.error(err_msg)
raise RuntimeError(err_msg)
file.write_text(response.text)

View file

@ -25,6 +25,12 @@ class DeckCard:
raise ParseError("No rank specified in deck_card")
return DeckCard(suit_index, rank)
def to_json(self):
return {
"suitIndex": self.suitIndex,
"rank": self.rank
}
def colorize(self):
color = ["green", "blue", "magenta", "yellow", "white", "cyan"][self.suitIndex]
return colored(str(self), color)
@ -33,6 +39,8 @@ class DeckCard:
return self.suitIndex == other.suitIndex and self.rank == other.rank
def __repr__(self):
if self.suitIndex == 0 and self.rank == 0:
return "kt"
return constants.COLOR_INITIALS[self.suitIndex] + str(self.rank)
def __hash__(self):
@ -84,6 +92,13 @@ class Action:
action_value
)
def to_json(self):
return {
"type": self.type.value,
"target": self.target,
"value": self.value
}
def __repr__(self):
match self.type:
case ActionType.Play:
@ -239,6 +254,21 @@ class GameState:
self.clues -= 1
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
@property
def num_players(self):
@ -264,6 +294,10 @@ class GameState:
def deck_size(self):
return self.instance.deck_size
@property
def draw_pile_size(self):
return self.deck_size - self.progress
# Properties of GameState
def is_over(self):
@ -287,6 +321,23 @@ class GameState:
# 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):
for (player, hand) in enumerate(self.hands):
if card in hand:
@ -301,9 +352,9 @@ class GameState:
)
)
return {
"deck": self.instance.deck,
"deck": [card.to_json() for card in self.instance.deck],
"players": self.instance.player_names,
"actions": self.actions,
"actions": [action.to_json() for action in self.actions],
"first_player": 0,
"options": {
"variant": "No Variant",

View file

@ -8,6 +8,8 @@ from hanabi.live import hanab_live
from hanabi.live import compress
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
# and a replay achieving maximum score while following the replay for the first (T-1) turns:
@ -19,24 +21,15 @@ from hanabi.solvers import sat
def check_game(game_id: int) -> Tuple[int, hanab_game.GameState]:
logger.debug("Analysing game {}".format(game_id))
with database.conn.cursor() as cur:
cur.execute("SELECT games.num_players, deck, actions, score, games.variant_id, starting_player FROM games "
"INNER JOIN seeds ON seeds.seed = games.seed "
cur.execute("SELECT games.num_players, score, games.variant_id, starting_player FROM games "
"WHERE games.id = (%s)",
(game_id,)
)
res = cur.fetchone()
if res is None:
raise ValueError("No game associated with id {} in database.".format(game_id))
(num_players, compressed_deck, compressed_actions, score, variant_id, starting_player) = res
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
)
(num_players, score, variant_id, starting_player) = res
instance, actions = games_db_interface.load_game_parts(game_id)
# check if the instance is already won
if instance.max_score == score:

View file

@ -177,7 +177,7 @@ def compress_game_state(state: Union[hanab_game.GameState, hanab_live.HanabLiveG
return with_dashes
def decompress_game_state(game_str: str) -> hanab_game.GameState:
def decompress_game_state(game_str: str) -> hanab_live.HanabLiveGameState:
game_str = game_str.replace("-", "")
parts = game_str.split(",")
if not len(parts) == 3:
@ -211,8 +211,8 @@ def decompress_game_state(game_str: str) -> hanab_game.GameState:
except ValueError:
raise ValueError("Expected variant id, found: {}".format(variant_id))
instance = hanab_game.HanabiInstance(deck, num_players)
game = hanab_game.GameState(instance)
instance = hanab_live.HanabLiveInstance(deck, num_players, variant_id)
game = hanab_live.HanabLiveGameState(instance)
# TODO: game is not in consistent state
game.actions = actions

View file

@ -1,18 +1,21 @@
import alive_progress
from typing import Dict, Optional
from typing import Dict, Optional, List
import psycopg2.errors
import psycopg2.extras
import platformdirs
import unidecode
from hanabi import hanab_game
from hanabi import constants
from hanabi import logger
from hanabi import database
from hanabi.live import site_api
from hanabi.live import compress
from hanabi.live import variants
from hanabi.live import hanab_live
from hanabi.database import games_db_interface
class GameExportError(ValueError):
@ -49,6 +52,29 @@ 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(
game_id: int
@ -94,13 +120,20 @@ def detailed_export_game(
options = game_json.get('options', {})
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)
empty_clues = options.get('emptyClues', 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)
starting_player = options.get('startingPlayer', 0)
try:
actions = [hanab_game.Action.from_json(action) for action in game_json.get('actions', [])]
except hanab_game.ParseError as e:
@ -131,44 +164,53 @@ def detailed_export_game(
game.make_action(action)
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:
database.cur.execute(
"INSERT INTO seeds (seed, num_players, starting_player, variant_id, deck)"
"VALUES (%s, %s, %s, %s, %s)"
"INSERT INTO seeds (seed, num_players, starting_player, variant_id)"
"VALUES (%s, %s, %s, %s)"
"ON CONFLICT (seed) DO NOTHING",
(seed, num_players, starting_player, var_id, compressed_deck)
(seed, num_players, starting_player, var_id)
)
logger.debug("New seed {} imported.".format(seed))
games_db_interface.store_deck_for_seed(seed, deck)
database.cur.execute(
"INSERT INTO games ("
"id, num_players, score, seed, variant_id, deck_plays, one_extra_card, one_less_card,"
"all_or_nothing, detrimental_characters, actions"
"id, num_players, starting_player, variant_id, timed, time_base, time_per_turn, speedrun, card_cycle, "
"deck_plays, empty_clues, one_extra_card, one_less_card,"
"all_or_nothing, detrimental_characters, seed, score"
")"
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)"
"ON CONFLICT (id) DO UPDATE SET ("
"deck_plays, one_extra_card, one_less_card, all_or_nothing, actions, detrimental_characters"
"timed, time_base, time_per_turn, speedrun, card_cycle, deck_plays, empty_clues, one_extra_card,"
"all_or_nothing, detrimental_characters"
") = ("
"EXCLUDED.deck_plays, EXCLUDED.one_extra_card, EXCLUDED.one_less_card, EXCLUDED.all_or_nothing,"
"EXCLUDED.actions, EXCLUDED.detrimental_characters"
"EXCLUDED.timed, EXCLUDED.time_base, EXCLUDED.time_per_turn, EXCLUDED.speedrun, EXCLUDED.card_cycle, "
"EXCLUDED.deck_plays, EXCLUDED.empty_clues, EXCLUDED.one_extra_card,"
"EXCLUDED.all_or_nothing, EXCLUDED.detrimental_characters"
")",
(
game_id, num_players, score, seed, var_id, deck_plays, one_extra_card, one_less_card,
all_or_nothing, detrimental_characters, compressed_actions
game_id, num_players, starting_player, var_id, timed, time_base, time_per_turn, speedrun, card_cycle,
deck_plays, empty_clues, one_extra_card, one_less_card,
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))
@ -192,6 +234,8 @@ def _process_game_row(game: Dict, var_id, export_all_games: bool = False):
return
# raise GameExportInvalidNumberOfPlayersError(game_id, num_players, users)
# Ensure users in database and find out their ids
if export_all_games:
detailed_export_game(game_id, score=score, var_id=var_id)
logger.debug("Imported game {}".format(game_id))
@ -212,6 +256,20 @@ def _process_game_row(game: Dict, var_id, export_all_games: bool = False):
database.cur.execute("ROLLBACK TO seed_insert")
detailed_export_game(game_id, score=score, var_id=var_id)
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))

View file

@ -0,0 +1,146 @@
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.")

View file

@ -93,9 +93,8 @@ mutex = threading.Lock()
def solve_instance(instance: hanab_game.HanabiInstance):
# first, sanity check on running out of pace
result = deck_analyzer.analyze(instance)
if result is not None:
assert type(result) == deck_analyzer.InfeasibilityReason
logger.debug("found infeasible deck")
if len(result) != 0:
logger.info("found infeasible deck by foreward analysis")
return False, None, None
for num_remaining_cards in [0, 20]:
# logger.info("trying with {} remaining cards".format(num_remaining_cards))

View file