diff --git a/HanabiWeb/__init__.py b/HanabiWeb/__init__.py index e69de29..62ca77e 100644 --- a/HanabiWeb/__init__.py +++ b/HanabiWeb/__init__.py @@ -0,0 +1 @@ +__all__ = ("cache", "hanabi") diff --git a/HanabiWeb/cache.py b/HanabiWeb/cache.py index 4eae570..eebaaba 100644 --- a/HanabiWeb/cache.py +++ b/HanabiWeb/cache.py @@ -4,15 +4,25 @@ Utility functions for storing and retrieving Hanabi server data. import yaml +from . import card + # Field names for each field -_players = "players" -_hands = "hands" -_discards = "discards" -_knowledge = "knowledge" -_lives = "lives" +players_key = "players" +hands_key = "hands" +discards_key = "discards" +knowledge_key = "knowledge" +lives_key = "lives" +deck_key = "deck" +played_key = "played" -_fieldnames = [_players, _hands, _discards, _knowledge, _lives] +_fieldnames = (players_key, + hands_key, + discards_key, + knowledge_key, + lives_key, + played_key, + deck_key) class GameDataStore: @@ -25,11 +35,25 @@ class GameDataStore: def get(self): with open(self.filepath) as f: - data = yaml.safe_load(f) + data = yaml.load(f) + return data + + def get_from_perspective(self, player): + """ + Get the state of the game as seen by the given player. + """ + data = self.get() + if player not in data[players_key]: + err = "Player {} not in player list for this game.".format(player) + raise ValueError(err) + # The given player can't see their own hand, or the deck, but can see + # everything else. + del data[hands_key][player] + del data[deck_key] return data def replace(self, data): - with open(self.filepath) as f: + with open(self.filepath, "w") as f: yaml.dump(data, f) def replace_field(self, field, data): @@ -37,22 +61,27 @@ class GameDataStore: existing[field] = data self.replace(existing) - def append_to_field(self, field, data): - existing = self.get() - existing[field].append(data) - self.replace(existing) - - def add_player(self, player_name): - self.append_to_field(_players, player_name) - def create(self, players): """ Create a new Hanabi game, storing the data in the given file. - :return: """ - data = {_players: players, - _hands: [], - _discards: [], - _knowledge: {"used": 0, "available": 8}, - _lives: {"used": 0, "available": 3}} + deck_arrangement = card.get_deck_arrangement() + data = {players_key: players, + hands_key: {p: [] for p in players}, + discards_key: [], + knowledge_key: {"used": 0, "available": 8}, + lives_key: {"used": 0, "available": 3}, + deck_key: deck_arrangement, + played_key: []} + + # Deal out the cards + if len(players) == 2 or len(players) == 3: + cards_per_person = 5 + elif len(players) == 4 or len(players) == 5: + cards_per_person = 4 + + for p in players: + for _ in range(cards_per_person): + data[hands_key][p].append(data[deck_key].pop()) + self.replace(data) diff --git a/HanabiWeb/card.py b/HanabiWeb/card.py index 11c30cb..583db67 100644 --- a/HanabiWeb/card.py +++ b/HanabiWeb/card.py @@ -1,20 +1,58 @@ import enum +import random -@enum.unique() +@enum.unique class HanabiColour(enum.Enum): - Red = enum.auto() - Green = enum.auto() - White = enum.auto() - Yellow = enum.auto() - Blue = enum.auto() + Red = 1 + Green = 2 + White = 3 + Yellow = 4 + Blue = 5 -class HanabiCard: +class HanabiCard(dict): def __str__(self): return "{} {}".format(self.rank, self.colour) - def __init__(self): - self.colour = None - self.rank = None + def __repr__(self): + return "HanabiCard({}, {})".format(self.get('colour'), self.get('rank')) + def __init__(self, colour=None, rank=None): + dict.__init__(self) + self['colour'] = colour + self['rank'] = rank + + +def _random_derangement(n): + """ + Return a tuple random derangement of (0, 1, 2, ..., n-1). + """ + while True: + v = list(range(n)) + for j in range(n - 1, -1, -1): + p = random.randint(0, j) + if v[p] == j: + break + else: + v[j], v[p] = v[p], v[j] + else: + if v[0] != 0: + return tuple(v) + + +def get_deck_arrangement(): + """ + Return a derangement of the cards in the deck. + """ + deck_length = (3 + 2 * 3 + 1) * 5 + all_cards = [HanabiCard(colour=c, rank=r) + for c in HanabiColour.__members__ + for r in range(1, 6)] + all_cards.extend([HanabiCard(colour=c, rank=r) + for c in HanabiColour.__members__ + for r in range(1, 5)]) + all_cards.extend([HanabiCard(colour=c, rank=1) + for c in HanabiColour.__members__]) + derangement = _random_derangement(deck_length) + return [all_cards[i] for i in derangement] \ No newline at end of file diff --git a/HanabiWeb/hanabi.py b/HanabiWeb/hanabi.py index 94a39ae..6b128ac 100644 --- a/HanabiWeb/hanabi.py +++ b/HanabiWeb/hanabi.py @@ -1,16 +1,16 @@ import os import re -from flask import Flask -from flask_restful import Resource, Api, abort - -from . import hanabi_cache - -app = Flask(__name__) -api = Api(app) +from flask_restful import Resource, abort, reqparse +from . import cache +from . import card _DATA_STORES = os.path.join(os.path.expanduser('~'), '.hanabigames') +_EXTENSION = '.han' + + +_colours = card.HanabiColour.__members__.keys() def _validate_game_id(game_id): @@ -23,16 +23,46 @@ def _validate_game_id(game_id): abort(403, message="Malformed game ID {}".format(game_id)) +def _validate_game_exists(game_id): + """ + Test whether a game exists. If not, raise 404 Not Found. + + This fully trusts game_id, and is not safe on unsanitised input. + """ + data_path = _game_data_path(game_id) + if not os.path.exists(data_path): + abort(404, message="Game {} not found.".format(game_id)) + + +def _validate_player_in_game(data, player): + """ + Test whether the player is in the given game. + """ + players = data[cache.players] + if player not in players: + abort(400, + message="Player {} not found in game".format(player)) + + def _game_data_path(game_id): """ Find the path to the data file for a given game. This fully trusts game_id, and is not safe on unsanitised input. """ - return os.path.join(_DATA_STORES, "{}.dat".format(game_id)) + return os.path.join(_DATA_STORES, "{}{}".format(game_id, _EXTENSION)) -def ls(directory): +def ls(directory, create=False): + """ + List the contents of a directory, optionally creating it first. + + If create is falsy and the directory does not exist, then an exception + is raised. + """ + if create and not os.path.exists(directory): + os.mkdir(directory) + onlyfiles = [f for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))] @@ -40,28 +70,116 @@ def ls(directory): def _get_new_game_index(): - files = ls(_DATA_STORES) - indices = [int(name.rstrip('.dat')) + """ + Get an ID suitable for a new game, which does not clash with any others. + """ + files = ls(_DATA_STORES, create=True) + indices = [int(name.rstrip(_EXTENSION)) for name in files - if re.match(r"[0-9]+\.dat$", name)] + if re.match(r"[0-9]+{}$".format(re.escape(_EXTENSION)), name)] if not indices: return 0 indices.sort() return indices[-1] + 1 -class Hand(Resource): - def get(self, game_id, player_id): - return {'hello': 'world'} +def _can_play(already_played, attempt_card): + """ + Return True iff attempt_card can be played given that played are the played + cards. + """ + ordered_play = {c: 0 for c in card.HanabiColour.__members__} + print(already_played) + for c in card.HanabiColour.__members__: + played_in_col = [p['rank'] for p in already_played if p['colour'] == c] + if played_in_col: + ordered_play[c] = sorted(played_in_col)[-1] + + if ordered_play[attempt_card['colour']] + 1 == attempt_card['rank']: + return True + return False -class Play(Resource): - def post(self, game_id, player_id, card): - pass +class Discard(Resource): + def post(self, game_id, player): + """ + Expects card_index as data. + :param game_id: + :param player: + :return: + """ + _validate_game_id(game_id) + _validate_game_exists(game_id) + + data_path = _game_data_path(game_id) + data_store = cache.GameDataStore(data_path) + data = data_store.get() + player_hand = data[cache.hands_key][player] + + parser = reqparse.RequestParser() + parser.add_argument('card_index', type=int) + args = parser.parse_args() + + if args.card_index < 0 or args.card_index >= len(player_hand): + abort(400, message="Card {} not valid.".format(args.card_index)) + + # Discard the card with given index. + drawn_card = data[cache.deck_key].pop() + data[cache.discards_key].append(player_hand[args.card_index]) + player_hand[args.card_index] = drawn_card + if data[cache.knowledge_key]['used'] != 0: + data[cache.knowledge_key]['used'] -= 1 + data[cache.knowledge_key]['available'] += 1 + + data_store.replace(data) + + return True + + +class PlayCard(Resource): + def post(self, game_id, player): + """ + Expects card_index as data. + """ + _validate_game_id(game_id) + _validate_game_exists(game_id) + + data_path = _game_data_path(game_id) + data_store = cache.GameDataStore(data_path) + data = data_store.get() + player_hand = data[cache.hands_key][player] + + parser = reqparse.RequestParser() + parser.add_argument('card_index', type=int) + args = parser.parse_args() + + if args.card_index < 0 or args.card_index >= len(player_hand): + abort(400, message="Card {} not valid.".format(args.card_index)) + + # Play the card with given index. + card_to_play = player_hand[args.card_index] + if _can_play(data[cache.played_key], card_to_play): + data[cache.played_key].append(player_hand[args.card_index]) + retval = True + else: + retval = False + data[cache.discards_key].append(player_hand[args.card_index]) + if data[cache.lives_key]["available"] > 0: + data[cache.lives_key]["used"] += 1 + data[cache.lives_key]["available"] -= 1 + if data[cache.lives_key]["available"] <= 0: + return "All lives exhausted. Game over." + + drawn_card = data[cache.deck_key].pop() + player_hand[args.card_index] = drawn_card + + data_store.replace(data) + + return retval class Game(Resource): - def get(self, game_id, player_id=None): + def get(self, game_id, player=None): """ Return the state of the game as viewed by the given player. @@ -69,7 +187,7 @@ class Game(Resource): spectator. :param game_id: Lookup ID for the given game. - :param player_id: Lookup ID for a certain player in this game. + :param player: Lookup ID for a certain player in this game. :return: Dictionary of game state. {players: [players], hands: {player1: [cards], player2: [cards]}, @@ -78,29 +196,30 @@ class Game(Resource): lives: {used: 0, available: 3}} """ _validate_game_id(game_id) + _validate_game_exists(game_id) + data_path = _game_data_path(game_id) + data = cache.GameDataStore(data_path) - data = hanabi_cache.GameDataStore(data_path) - - if player_id is None: + if player is None: return data.get() - def put(self, players): + return data.get_from_perspective(player) + + def put(self): """ Create a new game, returning the game ID. """ + parser = reqparse.RequestParser() + parser.add_argument('players', type=str, + help='Player names, comma-separated') + args = parser.parse_args() + new_id = _get_new_game_index() data_path = _game_data_path(new_id) - data = hanabi_cache.GameDataStore(data_path) - data.create(players) + data = cache.GameDataStore(data_path) + data.create(args['players'].split(',')) + + return {'id': new_id} -api.add_resource(Hand, '/hanabi') - - -@app.route("/hanabi") -def hello(): - return "Hello, World!" - -if __name__ == "__main__": - app.run(debug=True) \ No newline at end of file diff --git a/HanabiWeb/requirements.txt b/HanabiWeb/requirements.txt index e69de29..9d741b7 100644 --- a/HanabiWeb/requirements.txt +++ b/HanabiWeb/requirements.txt @@ -0,0 +1,4 @@ +pyyaml +flask +flask-restful +flask-limiter diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bb3ce34 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2018, Patrick Stevens + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..fc4ff7c --- /dev/null +++ b/README @@ -0,0 +1,56 @@ +This is a state tracker for the game of [Hanabi]. It exposes a REST API for +interacting with the state of multiple games. + +# Current state +At the moment, the system can track for multiple games: + + * Cards in each player's hand + * Cards in the deck + * Cards successfully played + * Cards discarded/unsuccessfully played + * Fuse tokens + * Information tokens + +# API exposed +## `/game` +### PUT +Create a new game, returning the game ID as `{id: 3}`, for instance. + +## `/game/` +### GET +Download a complete dump of the specified game in its current state. + +## `/game//` +### GET +Download the currently-visible state of the game from the perspective of the +given player. + +## `/discard//` +### POST +Have the specified player make the "discard" move in the specified game. + +This will regain a knowledge token, if there are any to be gained, and will +draw a new card. + +Supply the data `card_index=0`, for example, where `0` is the index of the card +to be discarded. (Card order is maintained strictly, so `0` refers to the first +card from the left in one's hand.) + +## `/play//` +### PLAY +Have the specified player attempt to play a card in the specified game. + +If the play is unsuccessful, a life will be lost. + +Supply the data `card_index=0`, for example, where `0` is the index of the card +to be played. (Card order is maintained strictly, so `0` refers to the first +card from the left in one's hand.) + +# Future work ideas + + * Make the data storage format version-aware. + * Track which information has been revealed about each specific card. + * Decide/implement a way to tell the players about moves which have been made. + * Log the game history. + +[Hanabi]: https://en.wikipedia.org/wiki/Hanabi_(card_game) \ No newline at end of file diff --git a/server.py b/server.py index 7b2420d..03ec391 100644 --- a/server.py +++ b/server.py @@ -1 +1,34 @@ -__author__ = 'Patrick' +import flask_limiter.util + +from flask import Flask +from flask_limiter import Limiter +from flask_restful import Api + +import HanabiWeb.hanabi + + +app = Flask(__name__) +api = Api(app) +limiter = Limiter(app, + key_func=flask_limiter.util.get_remote_address, + default_limits=["300 per day", "10 per minute"]) + + +HanabiWeb.hanabi.Game.method_decorators.append(limiter.limit("4 per minute")) +api.add_resource(HanabiWeb.hanabi.Game, + '/game', + '/game/', + '/game//') + +HanabiWeb.hanabi.Discard.method_decorators.append(limiter.limit("10 per minute")) +api.add_resource(HanabiWeb.hanabi.Discard, + '/discard//') + +HanabiWeb.hanabi.PlayCard.method_decorators.append(limiter.limit("10 per minute")) +api.add_resource(HanabiWeb.hanabi.PlayCard, + '/play//') + + +if __name__ == "__main__": + app.run(debug=True) +