Prepare for upload to Github

This commit is contained in:
Smaug123
2018-01-01 17:56:21 +00:00
parent 30b9f068ce
commit c661484138
8 changed files with 355 additions and 68 deletions

View File

@@ -0,0 +1 @@
__all__ = ("cache", "hanabi")

View File

@@ -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)

View File

@@ -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]

View File

@@ -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)

View File

@@ -0,0 +1,4 @@
pyyaml
flask
flask-restful
flask-limiter

7
LICENSE Normal file
View File

@@ -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.

56
README Normal file
View File

@@ -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/<id>`
### GET
Download a complete dump of the specified game in its current state.
## `/game/<id>/<player>`
### GET
Download the currently-visible state of the game from the perspective of the
given player.
## `/discard/<game>/<player>`
### 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/<game>/<player>`
### 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)

View File

@@ -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/<int:game_id>',
'/game/<int:game_id>/<string:player>')
HanabiWeb.hanabi.Discard.method_decorators.append(limiter.limit("10 per minute"))
api.add_resource(HanabiWeb.hanabi.Discard,
'/discard/<int:game_id>/<string:player>')
HanabiWeb.hanabi.PlayCard.method_decorators.append(limiter.limit("10 per minute"))
api.add_resource(HanabiWeb.hanabi.PlayCard,
'/play/<int:game_id>/<string:player>')
if __name__ == "__main__":
app.run(debug=True)