diff --git a/.gitignore b/.gitignore index b1ebe40..2a8a13d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ +*.iml __pychache__ -*.pyc \ No newline at end of file +*.pyc +*.log \ No newline at end of file diff --git a/app.py b/app.py index fc99723..e43aca7 100644 --- a/app.py +++ b/app.py @@ -1,97 +1,175 @@ -from threading import Lock +import logging -from flask import Flask, request, session -from flask_socketio import Namespace, join_room, leave_room, SocketIO, emit, rooms, close_room, disconnect +from flask import Flask, request, send_file, session, Response +from flask_socketio import Namespace, SocketIO, emit, join_room -from common import config +from common import config, db +from models import BingoGame, GameState, GameMode, User app = Flask(__name__) -app.config['SECRET_KEY'] = 'secret!' +app.config['SECRET_KEY'] = config["FLASK_SECRET"] -# TODO: test mode mode "threading", "eventlet" or "gevent" -socketio = SocketIO(app, async_mode=None) +socketio = SocketIO(app, async_mode="eventlet") + +logger = logging.getLogger("default") + + +# TODO: alphanum room +# TODO: alphanum name w/max len -# TODO: wth is that?! -thread = None -thread_lock = Lock() @app.route("/") def page_index(): - return "Hello, world" + return send_file("web/index.html") -def background_thread(): - """Example of how to send server generated events to clients.""" - count = 0 - while True: - socketio.sleep(10) - count += 1 - socketio.emit('my_response', - {'data': 'Server generated event', 'count': count}, - namespace='/test') +@app.route("/") +def play(room): + return send_file("web/room.html") -# When class-based namespaces are used, any events received by the server are dispatched to a method named as the -# event name with the on_ prefix. For example, event my_event will be handled by a method named on_my_event +def log(message, contents, room=None): + if config["VERBOSE"]: + logger.info("<%s|%s> [%s] %s:%s" % ( + request.remote_addr, request.user_agent, room if room else "~", message, str(contents))) + else: + logger.info("[%s] %s:%s" % (room if room else "~", message, str(contents))) + class BingoNamespace(Namespace): + def __init__(self): + super().__init__("/socket") - def on_my_event(self, message): - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': message['data'], 'count': session['receive_count']}) + def on_get_end_message(self): + log("get_end_message", {}) + emit("end_message", { + "text": "Game has ended, replay?" + }) - def on_my_broadcast_event(self, message): - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': message['data'], 'count': session['receive_count']}, - broadcast=True) + def on_cell_click(self, message): + room = message["room"] + log("cell_click", message, room) + + user = db.get_user(message["oid"]) + card = db.get_card(message["card"]) + cell = card.cells[message["cidx"]] + + if not cell.checked or card.last_cell == message["cidx"]: + cell.checked = not cell.checked + card.last_cell = message["cidx"] + db.save_card(card) + + emit("card_state", { + "card": card.serialize() + }, room=room) + + if card.moves_until_win() == 0: + game = db.get_game(room) + game.winners.append(user.oid) + + if game.should_end(): + game.state = GameState.ENDED + emit("game_state", {"state": game.state.name}) + db.save_game(game) + + def on_get_card(self, message): + room = message["room"] + log("get_card", message, room) + + user = db.get_user(message["oid"]) + game = db.get_game(room) + + if room in user.cards: + card = db.get_card(user.cards[room]) + else: + card = game.generate_card() + user.cards[room] = card.oid + db.save_card(card) + db.save_user(user) + + emit("card_state", { + "card": card.serialize(), + "parent": user.name + }, room=room) + + emit("get_card_rsp", { + "card": card.serialize(), + "parent": user.name + }) + + for player in game.players: + if player != user.oid: + other_user = db.get_user(player) + if room in other_user.cards: + other_card = db.get_card(other_user.cards[room]) + emit("card_state", {"card": other_card.serialize(), "parent": other_user.name}) + + def on_create_game(self, message): + room = message["room"] + log("create_game", message, room) + + game = db.get_game(room) + if game.state is GameState.CREATING: + game.state = GameState.PLAYING + game.mode = GameMode[message["mode"]] + game.pool = message["pool"] + db.save_game(game) + + emit("game_state", { + "state": game.state.name, + }, room=room) + + emit("create_game_rsp", { + "created": True, + }) def on_join(self, message): - join_room(message['room']) - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': 'In rooms: ' + ', '.join(rooms()), - 'count': session['receive_count']}) + log("join", message) - def on_leave(self, message): - leave_room(message['room']) - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': 'In rooms: ' + ', '.join(rooms()), - 'count': session['receive_count']}) + room = message["room"] - def on_close_room(self, message): - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', {'data': 'Room ' + message['room'] + ' is closing.', - 'count': session['receive_count']}, - room=message['room']) - close_room(message['room']) + user = None + if "oid" in message: + user = db.get_user(message["oid"]) + if not user: + emit("join_rsp", { + "ok": False + }) + return - def on_my_room_event(self, message): - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': message['data'], 'count': session['receive_count']}, - room=message['room']) + if not user: + user = User(name=message["name"]) + db.save_user(user) + session["user"] = user.oid - def on_disconnect_request(self): - session['receive_count'] = session.get('receive_count', 0) + 1 - emit('my_response', - {'data': 'Disconnected!', 'count': session['receive_count']}) - disconnect() + game = db.get_game(message["room"]) + if not game: + game = BingoGame(room, user.oid) - def on_my_ping(self): - emit('my_pong') + join_room(room) + game.players.add(user.oid) + db.save_game(game) + + # TODO: Is this useful? + emit("room_join", { + "name": user.name + }, room=room) + + emit("join_rsp", { + "ok": True, + "state": game.state.name, + "oid": user.oid + }) def on_connect(self): - global thread - with thread_lock: - if thread is None: - thread = socketio.start_background_task(background_thread) - emit('my_response', {'data': 'Connected', 'count': 0}) + pass def on_disconnect(self): - print('Client disconnected', request.sid) + pass + + +socketio.on_namespace(BingoNamespace()) +db.flush() if __name__ == "__main__": socketio.run( diff --git a/bingo.iml b/bingo.iml deleted file mode 100644 index ad3c0a3..0000000 --- a/bingo.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/common.py b/common.py index 7a76906..25bfbda 100644 --- a/common.py +++ b/common.py @@ -1,7 +1,24 @@ +import logging +import sys +from logging import FileHandler, StreamHandler + +from models import DB + config = { "API_PORT": 3000, "API_HOST": "0.0.0.0", "REDIS_HOST": "localhost", "REDIS_PORT": 6379, + "FLASK_SECRET": "secret!", + "VERBOSE": False, } +logger = logging.getLogger("default") +logger.setLevel(logging.DEBUG) +formatter = logging.Formatter('%(asctime)s %(levelname)-5s %(message)s') +file_handler = FileHandler("bingo.log") +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) +logger.addHandler(StreamHandler(sys.stdout)) + +db = DB() diff --git a/models.py b/models.py index 55c762b..2487a7b 100644 --- a/models.py +++ b/models.py @@ -1,8 +1,11 @@ +from enum import Enum from uuid import uuid4 +import random +import math import redis import json -from common import config +import common class BingoCell: @@ -15,6 +18,9 @@ class BingoCell: def serialize(self): return self.__dict__ + def __repr__(self): + return self.text + @staticmethod def deserialize(j): return BingoCell( @@ -24,67 +30,184 @@ class BingoCell: ) -class Row: - - class BingoCard: - def __init__(self, size, cells=None, oid=None): + def __init__(self, size, cells=None, oid=None, last_cell=None): if cells is None: - self.cells = [] - else: - self.cells = cells + cells = [] + self.cells = cells if oid is None: - self.oid = uuid4().hex - else: - self.oid = oid + oid = uuid4().hex + self.oid = oid self.size = size + self.last_cell = last_cell def serialize(self): return { "oid": self.oid, - "cells": [c.serialize() for c in self.cells] + "cells": tuple(c.serialize() for c in self.cells), + "size": self.size, + "last_cell": self.last_cell, + "moves_until_win": self.moves_until_win(), } - def __getitem__(self, col): + def moves_until_win(self): + return min( + *(sum(1 for c in self._row(row) if not c.checked) for row in range(0, self.size)), + *(sum(1 for c in self._col(col) if not c.checked) for col in range(0, self.size)), + sum(1 for c in self._diag_left() if not c.checked), + sum(1 for c in self._diag_right() if not c.checked), + ) + def _row(self, idx): + return self.cells[idx * self.size:idx * self.size + self.size] + + def _col(self, idx): + return [self.cells[c] for c in range(0, len(self.cells)) if c % self.size == idx] + + def _diag_left(self): + return [self.cells[c] for c in range(0, len(self.cells), self.size + 1)] + + def _diag_right(self): + return [self.cells[c] for c in range(self.size - 1, len(self.cells) - 1, self.size - 1)] @staticmethod - def deserialize(text): - j = json.loads(text) - return BingoCard(cells=[ - BingoCell.deserialize(c) for c in j["cells"] - ]) + def deserialize(j): + return BingoCard( + cells=tuple(BingoCell.deserialize(c) for c in j["cells"]), + size=j["size"], + oid=j["oid"], + last_cell=j["last_cell"] + ) + + +class GameMode(Enum): + FREE = "free" + + +class GameState(Enum): + CREATING = "creating" + PLAYING = "playing" + ENDED = "ended" + + +class BingoGame: + def __init__(self, room, admin, mode=GameMode.FREE, pool=None, state=GameState.CREATING, + players=None, winners=None): + self.room = room + self.mode = mode + self.admin = admin + if pool is None: + pool = [] + self.pool = pool + self.state = state + if players is None: + players = set() + self.players = players + if winners is None: + winners = [] + self.winners = winners + + def should_end(self): + # TODO: add winner count + return len(self.winners) > 0 + + def generate_card(self): + # TODO: customizable maximum size + size = math.floor(math.sqrt(len(self.pool))) + items = random.sample(self.pool, k=size * size) + return BingoCard(size, cells=[BingoCell(x) for x in items]) + + def serialize(self): + return { + "room": self.room, + "mode": self.mode.name, + "admin": self.admin, + "state": self.state.name, + "pool": self.pool, + "players": list(self.players), + "winners": self.winners, + } + + @staticmethod + def deserialize(j): + return BingoGame( + room=j["room"], + mode=GameMode[j["mode"]], + pool=j["pool"], + admin=j["admin"], + state=GameState[j["state"]], + players=set(j["players"]), + winners=j["winners"] + ) + + +class User: + def __init__(self, name, oid=None, cards=None): + if cards is None: + cards = {} + if oid is None: + oid = uuid4().hex + self.name = name + self.oid = oid + self.cards = cards + + @staticmethod + def deserialize(j): + return User( + name=j["name"], + oid=j["oid"], + cards=j["cards"], + ) + + def serialize(self): + return self.__dict__ class DB: - _prefix = "bingo:" def __init__(self): self._rdb = redis.Redis( - host=config["REDIS_HOST"], - port=config["REDIS_PORT"] + host=common.config["REDIS_HOST"], + port=common.config["REDIS_PORT"] ) def flush(self): - self._rdb.delete(self._rdb.keys(DB._prefix + "*")) + keys = self._rdb.keys(DB._prefix + "*") + if keys: + self._rdb.delete(*keys) def _get(self, name): - return self._rdb.get(DB._prefix + name) + text = self._rdb.get(DB._prefix + name) + # print(" %s = %s" % (name, text)) + if text: + return json.loads(text) def _set(self, name, value): - return self._rdb.set(DB._prefix + name, value) + self._rdb.set(DB._prefix + name, json.dumps(value, separators=(",", ":"))) + # print(" %s -> %s" % (name, value)) + # self._rdb.expire(DB._prefix + name, 3600 * 24 * 14) def get_card(self, oid): - return BingoCard.deserialize(self._get(oid)) + j = self._get(oid) + if j: + return BingoCard.deserialize(j) def save_card(self, card): self._set(card.oid, card.serialize()) + def get_game(self, room): + j = self._get("game:" + room) + if j: + return BingoGame.deserialize(j) -c = BingoCard( - size=4, - cells=[ - BingoCell("test") -]) -print(c.serialize()) + def save_game(self, game: BingoGame): + self._set("game:" + game.room, game.serialize()) + + def get_user(self, oid): + j = self._get(oid) + if j: + return User.deserialize(j) + + def save_user(self, user): + self._set(user.oid, user.serialize()) diff --git a/web/index.html b/web/index.html index c052140..d89db76 100644 --- a/web/index.html +++ b/web/index.html @@ -2,12 +2,67 @@ - Test + TODO: title + +
+

Bingo

+
+ +
+
+ +
+ TODO: info +
\ No newline at end of file diff --git a/web/room.html b/web/room.html new file mode 100644 index 0000000..7d50187 --- /dev/null +++ b/web/room.html @@ -0,0 +1,484 @@ + + + + + Test + + + + + +
+ + + + + + + + + + + \ No newline at end of file