From a97b17a04230dd38c21ddfa14155e1a3c69f51f9 Mon Sep 17 00:00:00 2001 From: olalyska Date: Sun, 26 Nov 2023 15:30:10 +0100 Subject: [PATCH 01/27] hotfix --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index 0cd86f2..3070442 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: depends_on: - api # restart: always + # comment volumes: - ./frontend/src:/frontend/src/ - ./frontend/public:/frontend/public/ From f148a1171bd9f41856013ad8554fd52e22d4888a Mon Sep 17 00:00:00 2001 From: olalyska Date: Sun, 26 Nov 2023 15:34:15 +0100 Subject: [PATCH 02/27] feat: added creating deck and princess card --- backend/src/game.py | 62 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index b4f8e1f..abbf8bb 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -1,3 +1,4 @@ +import random from enum import IntEnum @@ -10,14 +11,14 @@ class Card(IntEnum): FIVE_PRINCE = 5 SIX_CHANCELLOR = 6 SEVEN_KING = 7 - EIGHT_COUNTLESS = 8 + EIGHT_COUNTESS = 8 NINE_PRINCESS = 9 class Player: def __init__(self): self._deck = [] - self._rejected = [] + self._played_cards = [] self.is_killed = False self.can_be_chosen = True self.points = 0 @@ -31,6 +32,9 @@ def prepare_for_new_round(self): self.is_killed = False self.can_be_chosen = True + def get_card_from_deck(self, deck: list): + pass + class Game: _remaining_cards = [] @@ -41,13 +45,26 @@ def __init__(self, num_players: int) -> None: self._remaining_cards = [] self._new_round() - def move(self, card: Card, action: str) -> None: - raise NotImplementedError() + def move(self, card: Card, action: str) -> bool: + current_player = self.players[self.player_counter] + match card: + case card.NINE_PRINCESS: + if action == "PLAY_CARD": + current_player.is_killed = True + current_player.can_be_chosen = False + return True + else: + return False + case other: + raise NotImplementedError() + + # raise NotImplementedError() def switch_current_player(self) -> None: while True: self.player_counter = (self.player_counter + 1) % len(self.players) if not self.players[self.player_counter].is_killed: + self.players[self.player_counter].get_card_from_deck() break def get_current_player(self) -> int: @@ -60,10 +77,37 @@ def _new_round(self) -> None: for player in self.players: player.is_killed = False - self._remaining_cards = self._shuffle_cards() + self._remaining_cards = self._create_new_deck() for player in self.players: - player.deck.append(self._remaining_cards.pop()) - - def _shuffle_cards(self) -> list[Card]: - raise NotImplementedError() + # player.deck.append(self._remaining_cards.pop()) + pass + + def _create_new_deck(self) -> list[Card]: + full_deck = [] + + # add all kinds of cards to deck in correct amounts + full_deck.append(Card(9)) + full_deck.append(Card(8)) + full_deck.append(Card(7)) + for i in range(2): + full_deck.append(Card(6)) + for i in range(2): + full_deck.append(Card(5)) + for i in range(2): + full_deck.append(Card(4)) + for i in range(2): + full_deck.append(Card(3)) + for i in range(2): + full_deck.append(Card(2)) + for i in range(6): + full_deck.append(Card(1)) + for i in range(2): + full_deck.append(Card(0)) + + # get the deck ready for round(shuffle and remove one card) + random.shuffle(full_deck) + full_deck.pop() + self._remaining_cards = full_deck + + # raise NotImplementedError() From 73a3f9ff07a20b8b4b8ff31a56b550478eaf73e7 Mon Sep 17 00:00:00 2001 From: Franciszek Kaczmarek Date: Thu, 16 Nov 2023 20:55:28 +0100 Subject: [PATCH 03/27] Add react-router-dom and create basic routing for BrowserPage and LobbyPage --- frontend/public/vite.svg | 1 - .../src/{App.tsx => Pages/BrowserPage.tsx} | 4 +- frontend/src/Pages/LobbyPage.tsx | 7 ++ frontend/src/assets/react.svg | 1 - frontend/src/main.tsx | 17 ++- package-lock.json | 102 ++++++++++++++++++ package.json | 5 + 7 files changed, 127 insertions(+), 10 deletions(-) delete mode 100644 frontend/public/vite.svg rename frontend/src/{App.tsx => Pages/BrowserPage.tsx} (95%) create mode 100644 frontend/src/Pages/LobbyPage.tsx delete mode 100644 frontend/src/assets/react.svg create mode 100644 package-lock.json create mode 100644 package.json diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/Pages/BrowserPage.tsx similarity index 95% rename from frontend/src/App.tsx rename to frontend/src/Pages/BrowserPage.tsx index 9a3c263..f254f26 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/Pages/BrowserPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; // przykładowy komponent nie przywiązujmy się do niego -function App() { +export default function BrowserPage() { const [count, setCount] = useState(0); const handleCount = () => { @@ -31,5 +31,3 @@ function App() { ); } - -export default App; diff --git a/frontend/src/Pages/LobbyPage.tsx b/frontend/src/Pages/LobbyPage.tsx new file mode 100644 index 0000000..9ab7c58 --- /dev/null +++ b/frontend/src/Pages/LobbyPage.tsx @@ -0,0 +1,7 @@ +export default function LobbyPage() { + return ( + <> +
Lobby
+ + ); +} diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 73b96a7..d2d317d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,14 +1,21 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App.tsx"; +import { Route, createBrowserRouter, RouterProvider, createRoutesFromElements } from "react-router-dom"; import "./index.css"; +import BrowserPage from "./Pages/BrowserPage.tsx"; +import LobbyPage from "./Pages/LobbyPage.tsx"; -// w tym miejscu będzie trzeba zrobić routing do różnych komponentów aplikacji -// np. do wyszukiwarki gier, gry, do lobby, do ekranu logowania, etc. -// najlepiej przy użyciu react-router-dom +const router = createBrowserRouter( + createRoutesFromElements( + + } /> + } /> + + ) +); ReactDOM.createRoot(document.getElementById("root")!).render( - + ); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..aa31ebd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "love_letter", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "react-router-dom": "^6.18.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.11.0.tgz", + "integrity": "sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "peer": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/react": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", + "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", + "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.0" + }, + "peerDependencies": { + "react": "^18.2.0" + } + }, + "node_modules/react-router": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz", + "integrity": "sha512-vk2y7Dsy8wI02eRRaRmOs9g2o+aE72YCx5q9VasT1N9v+lrdB79tIqrjMfByHiY5+6aYkH2rUa5X839nwWGPDg==", + "dependencies": { + "@remix-run/router": "1.11.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.18.0.tgz", + "integrity": "sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw==", + "dependencies": { + "@remix-run/router": "1.11.0", + "react-router": "6.18.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/scheduler": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", + "integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..66b675c --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "react-router-dom": "^6.18.0" + } +} From fa6714887b4f4edee3343e8ff90f6e8688c9b57a Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:54:45 +0100 Subject: [PATCH 04/27] LifoQueue, code refactoring, changes points to score --- backend/src/game.py | 74 +++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index abbf8bb..fc76214 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -1,5 +1,6 @@ import random from enum import IntEnum +from queue import LifoQueue class Card(IntEnum): @@ -21,7 +22,7 @@ def __init__(self): self._played_cards = [] self.is_killed = False self.can_be_chosen = True - self.points = 0 + self.score = 0 def increment_score(self): self.points += 1 @@ -32,18 +33,22 @@ def prepare_for_new_round(self): self.is_killed = False self.can_be_chosen = True - def get_card_from_deck(self, deck: list): - pass + def get_card_from_deck(self, deck: LifoQueue): + self._deck.append(deck.get()) class Game: - _remaining_cards = [] - - def __init__(self, num_players: int) -> None: - self.players = [Player() for _ in range(num_players)] - self.player_counter = 0 - self._remaining_cards = [] - self._new_round() + def __init__( + self, num_players: int, name: str, player_names: list[str] + ) -> None: + self.players: list[Player] = [ + Player(name, i) for i in range(num_players) + ] + self.max_players: int = num_players + self.player_counter: int = 0 + self.status: str = "not_started" + self.name: str = name + self._remaining_cards: LifoQueue = LifoQueue() def move(self, card: Card, action: str) -> bool: current_player = self.players[self.player_counter] @@ -55,16 +60,18 @@ def move(self, card: Card, action: str) -> bool: return True else: return False - case other: + case _: raise NotImplementedError() # raise NotImplementedError() def switch_current_player(self) -> None: while True: - self.player_counter = (self.player_counter + 1) % len(self.players) + self.player_counter = (self.player_counter + 1) % self.max_players if not self.players[self.player_counter].is_killed: - self.players[self.player_counter].get_card_from_deck() + self.players[self.player_counter].get_card_from_deck( + self._remaining_cards + ) break def get_current_player(self) -> int: @@ -87,27 +94,30 @@ def _create_new_deck(self) -> list[Card]: full_deck = [] # add all kinds of cards to deck in correct amounts - full_deck.append(Card(9)) - full_deck.append(Card(8)) - full_deck.append(Card(7)) - for i in range(2): - full_deck.append(Card(6)) - for i in range(2): - full_deck.append(Card(5)) - for i in range(2): - full_deck.append(Card(4)) - for i in range(2): - full_deck.append(Card(3)) - for i in range(2): - full_deck.append(Card(2)) - for i in range(6): - full_deck.append(Card(1)) - for i in range(2): - full_deck.append(Card(0)) + full_deck.append(Card.NINE_PRINCESS) + full_deck.append(Card.EIGHT_COUNTESS) + full_deck.append(Card.SEVEN_KING) + for _ in range(2): + full_deck.append(Card.SIX_CHANCELLOR) + for _ in range(2): + full_deck.append(Card.FIVE_PRINCE) + for _ in range(2): + full_deck.append(Card.FOUR_HANDMAID) + for _ in range(2): + full_deck.append(Card.THREE_BARON) + for _ in range(2): + full_deck.append(Card.TWO_PRIEST) + for _ in range(6): + full_deck.append(Card.ONE_GUARD) + for _ in range(2): + full_deck.append(Card.ZERO_SPY) # get the deck ready for round(shuffle and remove one card) random.shuffle(full_deck) full_deck.pop() - self._remaining_cards = full_deck - # raise NotImplementedError() + # LifoQueue for faster card gaining + self._remaining_cards = LifoQueue() + for card in full_deck: + self._remaining_cards.put(card) + From 8fbee9675fb180d0a913c43fa2b77b56ef1c55d2 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:55:36 +0100 Subject: [PATCH 05/27] New constructor for player --- backend/src/game.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index fc76214..9d39a6a 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -17,7 +17,12 @@ class Card(IntEnum): class Player: - def __init__(self): + def __init__(self, identifier: str, name: str = None): + self._identifier = identifier + if name is None: + self._name = identifier + else: + self._name = name self._deck = [] self._played_cards = [] self.is_killed = False @@ -25,7 +30,7 @@ def __init__(self): self.score = 0 def increment_score(self): - self.points += 1 + self.score += 1 def prepare_for_new_round(self): self._deck = [] From e26fad32a09fb47c4ede95f396ada64486b5257c Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:56:13 +0100 Subject: [PATCH 06/27] New method: start_game --- backend/src/game.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/backend/src/game.py b/backend/src/game.py index 9d39a6a..50e387a 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -82,6 +82,14 @@ def switch_current_player(self) -> None: def get_current_player(self) -> int: return self.player_counter + def start_game(self): + if self.status == "not_started": + self._new_round() + self.status = "started" + self.max_players = len(self.players) + else: + raise TypeError("Game has been started or was terminated") + def is_terminal(self) -> bool: raise NotImplementedError() From 0f00fb843e70821da740f6672852131f3d8936f2 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 18:56:57 +0100 Subject: [PATCH 07/27] Convertion Game and Player to dict --- backend/src/game.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index 50e387a..df36869 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -41,6 +41,16 @@ def prepare_for_new_round(self): def get_card_from_deck(self, deck: LifoQueue): self._deck.append(deck.get()) + def __dict__(self): + d_player = dict() + d_player["id"] = self._identifier + d_player["name"] = self._name + d_player["score"] = self.score + d_player["played_cards"] = self._played_cards + d_player["alive"] = not self.is_killed + d_player["how_many_cards"] = len(self._deck) + return d_player + class Game: def __init__( @@ -68,8 +78,6 @@ def move(self, card: Card, action: str) -> bool: case _: raise NotImplementedError() - # raise NotImplementedError() - def switch_current_player(self) -> None: while True: self.player_counter = (self.player_counter + 1) % self.max_players @@ -134,3 +142,16 @@ def _create_new_deck(self) -> list[Card]: for card in full_deck: self._remaining_cards.put(card) + def __dict__(self): + d_game = dict() + d_game["id"] = self.name + d_game["name"] = self.name + d_game["status"] = self.status + d_game["current_player"] = self.get_current_player() + + # append players to the game dict + d_game["players"] = [] + for player in self.players: + d_game["players"].append(dict(player)) + + return d_game From 0f2fd95fbdb36272afbb7d520b9220ecaf4ffe48 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 20:18:35 +0100 Subject: [PATCH 08/27] Changed to PriorityQueue due to of Chancellor --- backend/src/game.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index df36869..cfd8497 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -1,6 +1,6 @@ import random from enum import IntEnum -from queue import LifoQueue +from queue import PriorityQueue class Card(IntEnum): @@ -38,7 +38,7 @@ def prepare_for_new_round(self): self.is_killed = False self.can_be_chosen = True - def get_card_from_deck(self, deck: LifoQueue): + def get_card_from_deck(self, deck: PriorityQueue): self._deck.append(deck.get()) def __dict__(self): @@ -63,7 +63,7 @@ def __init__( self.player_counter: int = 0 self.status: str = "not_started" self.name: str = name - self._remaining_cards: LifoQueue = LifoQueue() + self._remaining_cards: PriorityQueue = PriorityQueue() def move(self, card: Card, action: str) -> bool: current_player = self.players[self.player_counter] @@ -137,8 +137,8 @@ def _create_new_deck(self) -> list[Card]: random.shuffle(full_deck) full_deck.pop() - # LifoQueue for faster card gaining - self._remaining_cards = LifoQueue() + # PriorityQueue for faster card gaining + self._remaining_cards = PriorityQueue() for card in full_deck: self._remaining_cards.put(card) From 58c788ce466463f8b51f6ae65fd989e162fa2617 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 20:32:29 +0100 Subject: [PATCH 09/27] New interface for adding new players to the game --- backend/src/game.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index cfd8497..5fa3922 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -53,12 +53,8 @@ def __dict__(self): class Game: - def __init__( - self, num_players: int, name: str, player_names: list[str] - ) -> None: - self.players: list[Player] = [ - Player(name, i) for i in range(num_players) - ] + def __init__(self, num_players: int, name: str) -> None: + self.players: list[Player] = [] self.max_players: int = num_players self.player_counter: int = 0 self.status: str = "not_started" @@ -87,6 +83,15 @@ def switch_current_player(self) -> None: ) break + def add_new_player(self, name: str) -> None: + if self.status in ["started", "terminated"]: + raise ValueError("Game has been started or was terminated") + if name in self._get_players_identifiers(): + raise ValueError(f"Player with identifier={name} already exists") + + player = Player(name) + self.players.append(player) + def get_current_player(self) -> int: return self.player_counter @@ -96,7 +101,7 @@ def start_game(self): self.status = "started" self.max_players = len(self.players) else: - raise TypeError("Game has been started or was terminated") + raise ValueError("Game has been started or was terminated") def is_terminal(self) -> bool: raise NotImplementedError() @@ -108,8 +113,7 @@ def _new_round(self) -> None: self._remaining_cards = self._create_new_deck() for player in self.players: - # player.deck.append(self._remaining_cards.pop()) - pass + player.get_card_from_deck(self._remaining_cards) def _create_new_deck(self) -> list[Card]: full_deck = [] @@ -142,6 +146,9 @@ def _create_new_deck(self) -> list[Card]: for card in full_deck: self._remaining_cards.put(card) + def _get_players_identifiers(self) -> tuple[str]: + return (p._identifier for p in self.players) + def __dict__(self): d_game = dict() d_game["id"] = self.name From 58467d7688bd098b9fbd0c4e99d708fe48eae626 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:17:51 +0100 Subject: [PATCH 10/27] Introduced type GameStatus --- backend/src/custom_types.py | 10 ++++++++++ backend/src/game.py | 17 +++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 backend/src/custom_types.py diff --git a/backend/src/custom_types.py b/backend/src/custom_types.py new file mode 100644 index 0000000..8035d46 --- /dev/null +++ b/backend/src/custom_types.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class GameStatus(Enum): + NOT_STARTED = 0 + STARTED = 1 + TERMINATED = 2 + + def __str__(self): + return str(self.name).lower() diff --git a/backend/src/game.py b/backend/src/game.py index 5fa3922..4192e5b 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -2,6 +2,8 @@ from enum import IntEnum from queue import PriorityQueue +from custom_types import GameStatus + class Card(IntEnum): ZERO_SPY = 0 @@ -57,7 +59,7 @@ def __init__(self, num_players: int, name: str) -> None: self.players: list[Player] = [] self.max_players: int = num_players self.player_counter: int = 0 - self.status: str = "not_started" + self.status: GameStatus = GameStatus.NOT_STARTED self.name: str = name self._remaining_cards: PriorityQueue = PriorityQueue() @@ -84,9 +86,9 @@ def switch_current_player(self) -> None: break def add_new_player(self, name: str) -> None: - if self.status in ["started", "terminated"]: + if self.status in [GameStatus.STARTED, GameStatus.TERMINATED]: raise ValueError("Game has been started or was terminated") - if name in self._get_players_identifiers(): + if self.does_player_exist(name): raise ValueError(f"Player with identifier={name} already exists") player = Player(name) @@ -95,10 +97,13 @@ def add_new_player(self, name: str) -> None: def get_current_player(self) -> int: return self.player_counter + def does_player_exist(self, name: str) -> bool: + return name in self._get_players_identifiers() + def start_game(self): - if self.status == "not_started": + if self.status == GameStatus.NOT_STARTED: self._new_round() - self.status = "started" + self.status = GameStatus.STARTED self.max_players = len(self.players) else: raise ValueError("Game has been started or was terminated") @@ -153,7 +158,7 @@ def __dict__(self): d_game = dict() d_game["id"] = self.name d_game["name"] = self.name - d_game["status"] = self.status + d_game["status"] = str(self.status) d_game["current_player"] = self.get_current_player() # append players to the game dict From 5fc54676f278bc8530c6b8bf2854cdc10d0651e8 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:19:37 +0100 Subject: [PATCH 11/27] WS Reconnect; block connect after game start --- backend/src/game.py | 13 ++++++++ backend/src/game_manager.py | 55 +++++++++++++++++++++++++------- backend/src/routers/websocket.py | 2 +- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index 4192e5b..1c2c90c 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -100,6 +100,19 @@ def get_current_player(self) -> int: def does_player_exist(self, name: str) -> bool: return name in self._get_players_identifiers() + def remove_player(self, name: str): + if self.status == GameStatus.NOT_STARTED: + was_removed = False + for player in self.players: + if player._identifier == name: + was_removed = True + self.players.remove(player) + break + if not was_removed: + raise ValueError("Player with this identifier was not found") + else: + raise ValueError("Game has been started or was terminated") + def start_game(self): if self.status == GameStatus.NOT_STARTED: self._new_round() diff --git a/backend/src/game_manager.py b/backend/src/game_manager.py index f58e96e..2767153 100644 --- a/backend/src/game_manager.py +++ b/backend/src/game_manager.py @@ -1,5 +1,6 @@ from collections import defaultdict +from custom_types import GameStatus from fastapi import WebSocket from game import Game @@ -10,27 +11,39 @@ class GameManager: """ def __init__(self): - self.connections: dict = defaultdict(dict) - self.games: dict = defaultdict(dict) + self.connections: dict[str, list[WebSocket]] = defaultdict(dict) + self.games: dict[str, Game] = defaultdict(dict) async def connect( - self, websocket: WebSocket, room_name: str, client_id: int - ): + self, websocket: WebSocket, room_name: str, client_id: str + ) -> None: await websocket.accept() - if ( - self.connections[room_name] == {} - or len(self.connections[room_name]) == 0 - ): + + if self.connections[room_name] == {}: self.connections[room_name] = [] - # TODO: Support for any number of players - self.games[room_name] = Game(4) + # TODO: Support for any number of players, not only four + self.games[room_name] = Game(4, room_name) + + game: Game = self.games[room_name] + + match game.status: + case GameStatus.NOT_STARTED: + game.add_new_player(client_id) + if self._should_start_the_game(game, room_name): + game.start_game() + case GameStatus.STARTED: + if game.does_player_exist(client_id) == False: + websocket.close() + case GameStatus.TERMINATED: + websocket.close() + websocket.scope["room_name"] = room_name websocket.scope["client_id"] = client_id self.connections[room_name].append(websocket) async def handle_message( self, creator_id: int, room_name: str, message: str - ): + ) -> None: game: Game = self.games[room_name] websockets: list[WebSocket] = self.connections[room_name] @@ -45,5 +58,23 @@ def get_members(self, room_name): except Exception: return None - def remove(self, websocket: WebSocket, room_name: str): + async def remove(self, websocket: WebSocket) -> None: + client_name = websocket.scope["client_id"] + room_name = websocket.scope["room_name"] + game: Game = self.games[room_name] + try: + game.remove_player(client_name) + except ValueError: + pass self.connections[room_name].remove(websocket) + await websocket.close() + + # TODO: Support for ready/unready + def _should_start_the_game(self, game: Game, room_name: str) -> bool: + num_connections = len(self.connections[room_name]) + num_max_players = game.max_players + + return ( + num_connections == num_max_players + and game.status == GameStatus.NOT_STARTED + ) diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index 10a8716..4ef0fb0 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -15,4 +15,4 @@ async def websocket_endpoint( data = await websocket.receive_text() await manager.handle_message(room_id, client_id, data) except WebSocketDisconnect: - await websocket.close() + await manager.remove(websocket) From 82fd4698f716e5c91bec9e92a39d0e56180cf3fa Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 2 Dec 2023 23:24:36 +0100 Subject: [PATCH 12/27] Fix websockets --- backend/src/game_manager.py | 32 ++++++++++++++++++++------------ backend/src/routers/websocket.py | 2 +- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/backend/src/game_manager.py b/backend/src/game_manager.py index 2767153..730d421 100644 --- a/backend/src/game_manager.py +++ b/backend/src/game_manager.py @@ -1,7 +1,7 @@ from collections import defaultdict from custom_types import GameStatus -from fastapi import WebSocket +from fastapi import WebSocket, WebSocketDisconnect from game import Game @@ -26,16 +26,23 @@ async def connect( game: Game = self.games[room_name] + should_ws_be_closed = False match game.status: case GameStatus.NOT_STARTED: - game.add_new_player(client_id) - if self._should_start_the_game(game, room_name): - game.start_game() + try: + game.add_new_player(client_id) + if self._should_start_the_game(game, room_name): + game.start_game() + except ValueError: + should_ws_be_closed = True case GameStatus.STARTED: if game.does_player_exist(client_id) == False: - websocket.close() + should_ws_be_closed = True case GameStatus.TERMINATED: - websocket.close() + should_ws_be_closed = True + + if should_ws_be_closed: + raise WebSocketDisconnect websocket.scope["room_name"] = room_name websocket.scope["client_id"] = client_id @@ -59,15 +66,16 @@ def get_members(self, room_name): return None async def remove(self, websocket: WebSocket) -> None: - client_name = websocket.scope["client_id"] - room_name = websocket.scope["room_name"] - game: Game = self.games[room_name] try: + client_name = websocket.scope["client_id"] + room_name = websocket.scope["room_name"] + game: Game = self.games[room_name] game.remove_player(client_name) - except ValueError: + self.connections[room_name].remove(websocket) + except (ValueError, KeyError): pass - self.connections[room_name].remove(websocket) - await websocket.close() + finally: + await websocket.close() # TODO: Support for ready/unready def _should_start_the_game(self, game: Game, room_name: str) -> bool: diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index 4ef0fb0..5e9fdad 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -9,8 +9,8 @@ async def websocket_endpoint( websocket: WebSocket, room_id: str, client_id: str ): - await manager.connect(websocket, room_id, client_id) try: + await manager.connect(websocket, room_id, client_id) while True: data = await websocket.receive_text() await manager.handle_message(room_id, client_id, data) From 3079beb8d92045ccbf1801b3261ae6e1111cb1f3 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sun, 3 Dec 2023 16:18:09 +0100 Subject: [PATCH 13/27] Now absolute path starts with src --- README.md | 15 +++++++++++++++ backend/Dockerfile | 2 +- backend/src/game.py | 12 +++++++++++- backend/src/game_manager.py | 5 +++-- backend/src/main.py | 5 +++-- backend/src/routers/api.py | 6 +++++- backend/src/routers/websocket.py | 3 ++- docker-compose.yml | 3 +-- 8 files changed, 41 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c4bab60..16534af 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,21 @@ To run the project, type docker-compose up ``` +## Run tests on backend + +Make sure you have installed PyTest. + +To run tests on the backend, being on `project root directory`, type +```bash +python3 -m pytest +``` + +To run coverage tests, after installing `coverage` package and being on +`project root directory`, type +```bash +coverage run -m pytest +``` + Ports: - frontend: 3000 - backend: 8000 \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index a1cedea..3b6fccc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,4 +6,4 @@ COPY requirements.txt ./requirements.txt RUN pip install --no-cache-dir --upgrade -r requirements.txt -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/backend/src/game.py b/backend/src/game.py index 1c2c90c..331662b 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -2,7 +2,7 @@ from enum import IntEnum from queue import PriorityQueue -from custom_types import GameStatus +from src.custom_types import GameStatus class Card(IntEnum): @@ -121,6 +121,16 @@ def start_game(self): else: raise ValueError("Game has been started or was terminated") + def to_json(self, player_name: str = None): + res = dict(self) + + if player_name is None: + for player in self._get_players_identifiers(): + pass + else: + res["your_cards"] = [] + return res + def is_terminal(self) -> bool: raise NotImplementedError() diff --git a/backend/src/game_manager.py b/backend/src/game_manager.py index 730d421..d49656c 100644 --- a/backend/src/game_manager.py +++ b/backend/src/game_manager.py @@ -1,8 +1,9 @@ from collections import defaultdict -from custom_types import GameStatus from fastapi import WebSocket, WebSocketDisconnect -from game import Game + +from src.custom_types import GameStatus +from src.game import Game class GameManager: diff --git a/backend/src/main.py b/backend/src/main.py index e938ce0..5a52e53 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,7 +1,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routers.api import router as api_router -from routers.websocket import router as websocket_router + +from src.routers.api import router as api_router +from src.routers.websocket import router as websocket_router app = FastAPI() diff --git a/backend/src/routers/api.py b/backend/src/routers/api.py index 8c31f01..bd7f60d 100644 --- a/backend/src/routers/api.py +++ b/backend/src/routers/api.py @@ -12,7 +12,11 @@ @router.get("/api/v1/games") async def get_games(started: bool): if started == False: - games = [game for game in games_data if game["players"] < game["max_players"]] + games = [ + game + for game in games_data + if game["players"] < game["max_players"] + ] return {"games": games} else: return {"message": "all games has already started"} diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index 5e9fdad..c7814d3 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from game_manager import GameManager + +from src.game_manager import GameManager router = APIRouter() manager = GameManager() diff --git a/docker-compose.yml b/docker-compose.yml index 3070442..158c497 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "8000:8000" volumes: - - ./backend/src:/backend/ + - ./backend/src:/backend/src/ # restart: always ui: @@ -21,7 +21,6 @@ services: depends_on: - api # restart: always - # comment volumes: - ./frontend/src:/frontend/src/ - ./frontend/public:/frontend/public/ From 7e3eac1fbbd496a3a66076e0ac5f826f0b1cd4f1 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sun, 3 Dec 2023 16:18:40 +0100 Subject: [PATCH 14/27] Initial isort --- backend/.isort.cfg | 4 ++++ backend/pyproject.toml | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 backend/.isort.cfg create mode 100644 backend/pyproject.toml diff --git a/backend/.isort.cfg b/backend/.isort.cfg new file mode 100644 index 0000000..b8cfcea --- /dev/null +++ b/backend/.isort.cfg @@ -0,0 +1,4 @@ +[settings] +known_first_party = ["src"] +line_length = 79 +profile = "black" \ No newline at end of file diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ab14e8f --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,3 @@ +[tool.isort] +py_version=311 +line-length=79 \ No newline at end of file From add926679babaec414df4064e505e40f76f5ef2b Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sun, 3 Dec 2023 16:19:04 +0100 Subject: [PATCH 15/27] base for testing --- backend/requirements.txt | 2 ++ backend/src/__init__.py | 0 backend/tests/__init__.py | 0 backend/tests/test_api.py | 2 ++ backend/tests/test_custom_types.py | 14 ++++++++++++++ backend/tests/test_game.py | 0 6 files changed, 18 insertions(+) create mode 100644 backend/src/__init__.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_custom_types.py create mode 100644 backend/tests/test_game.py diff --git a/backend/requirements.txt b/backend/requirements.txt index c824b3a..19a7b6c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,9 +3,11 @@ anyio==3.7.1 click==8.1.7 fastapi==0.104.1 h11==0.14.0 +httpx==0.25.2 idna==3.4 pydantic==2.5.1 pydantic_core==2.14.3 +pytest==7.4.3 sniffio==1.3.0 starlette==0.27.0 typing_extensions==4.8.0 diff --git a/backend/src/__init__.py b/backend/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..4e248b2 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,2 @@ +from src.main import app + diff --git a/backend/tests/test_custom_types.py b/backend/tests/test_custom_types.py new file mode 100644 index 0000000..39a5818 --- /dev/null +++ b/backend/tests/test_custom_types.py @@ -0,0 +1,14 @@ +from src.custom_types import GameStatus + + +class TestGameStatusToString: + status = None + + def test_not_started_str(self): + assert str(GameStatus.NOT_STARTED) == "not_started" + + def test_started_str(self): + assert str(GameStatus.STARTED) == "started" + + def test_terminated_str(self): + assert str(GameStatus.TERMINATED) == "terminated" diff --git a/backend/tests/test_game.py b/backend/tests/test_game.py new file mode 100644 index 0000000..e69de29 From ca3768da0e9690adbd3989a39550c83aedb2b92b Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sun, 3 Dec 2023 16:33:23 +0100 Subject: [PATCH 16/27] TestClient --- backend/tests/test_api.py | 3 +++ backend/tests/test_websockets.py | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 backend/tests/test_websockets.py diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4e248b2..767f688 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,2 +1,5 @@ +from fastapi.testclient import TestClient + from src.main import app +client = TestClient(app) diff --git a/backend/tests/test_websockets.py b/backend/tests/test_websockets.py new file mode 100644 index 0000000..767f688 --- /dev/null +++ b/backend/tests/test_websockets.py @@ -0,0 +1,5 @@ +from fastapi.testclient import TestClient + +from src.main import app + +client = TestClient(app) From 14628f339295b495c3d6486d96fd6d804b2d9667 Mon Sep 17 00:00:00 2001 From: olalyska Date: Wed, 6 Dec 2023 21:53:59 +0100 Subject: [PATCH 17/27] added find_player method --- backend/src/game.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/game.py b/backend/src/game.py index 5fa3922..3886fe2 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -95,6 +95,12 @@ def add_new_player(self, name: str) -> None: def get_current_player(self) -> int: return self.player_counter + def find_player(self, chosen_player_identifier: str) -> Player | None: + for player in self.players: + if player._identifier == chosen_player_identifier: + return player + return None + def start_game(self): if self.status == "not_started": self._new_round() From fbc7261bca262425180c0c71765842f9519a4ae1 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Wed, 6 Dec 2023 23:25:34 +0100 Subject: [PATCH 18/27] Separated GameManager instance to src.dependencies --- backend/src/dependencies.py | 3 +++ backend/src/routers/api.py | 2 ++ backend/src/routers/websocket.py | 3 +-- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 backend/src/dependencies.py diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py new file mode 100644 index 0000000..bc7d0f3 --- /dev/null +++ b/backend/src/dependencies.py @@ -0,0 +1,3 @@ +from src.game_manager import GameManager + +game_manager = GameManager() diff --git a/backend/src/routers/api.py b/backend/src/routers/api.py index bd7f60d..f3279f5 100644 --- a/backend/src/routers/api.py +++ b/backend/src/routers/api.py @@ -1,5 +1,7 @@ from fastapi import APIRouter +from src.dependencies import game_manager + router = APIRouter() games_data = [ diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index c7814d3..9b88d2a 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -1,9 +1,8 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect -from src.game_manager import GameManager +from src.dependencies import game_manager as manager router = APIRouter() -manager = GameManager() @router.websocket("/ws/{room_id}/{client_id}") From 430b6297f31c8554c4483575419812675d9d1f0d Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 02:14:26 +0100 Subject: [PATCH 19/27] Dependency "get_manager"; new websocket endpoints --- backend/src/dependencies.py | 8 +++++++- backend/src/routers/api.py | 2 +- backend/src/routers/websocket.py | 23 ++++++++++++++++------- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index bc7d0f3..2d1ae1c 100644 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -1,3 +1,9 @@ from src.game_manager import GameManager -game_manager = GameManager() + +async def get_manager() -> GameManager: + try: + yield get_manager.manager + except AttributeError: + get_manager.manager = GameManager() + yield get_manager.manager diff --git a/backend/src/routers/api.py b/backend/src/routers/api.py index f3279f5..1d2370c 100644 --- a/backend/src/routers/api.py +++ b/backend/src/routers/api.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from src.dependencies import game_manager +from src.dependencies import get_manager router = APIRouter() diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index 9b88d2a..1cdf36d 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -1,18 +1,27 @@ -from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import Annotated -from src.dependencies import game_manager as manager +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect + +from src.dependencies import get_manager +from src.game_manager import GameManager router = APIRouter() -@router.websocket("/ws/{room_id}/{client_id}") +@router.websocket("/ws/{room_name}") +@router.websocket("/ws/{room_name}/{password}") async def websocket_endpoint( - websocket: WebSocket, room_id: str, client_id: str + websocket: WebSocket, + manager: Annotated[GameManager, Depends(get_manager)], ): + room_name = websocket.path_params["room_name"] + password = websocket.path_params.get("password", None) + try: - await manager.connect(websocket, room_id, client_id) + await manager.connect(websocket, room_name, password) + client_id = websocket.scope["client_id"] while True: - data = await websocket.receive_text() - await manager.handle_message(room_id, client_id, data) + data = await websocket.receive_json() + await manager.handle_message(room_name, client_id, data) except WebSocketDisconnect: await manager.remove(websocket) From e97449e267f4f015b3b5e5cb746e2d4b9a922ca6 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 02:15:12 +0100 Subject: [PATCH 20/27] Timed tests --- backend/pytest.ini | 2 ++ backend/requirements.txt | 1 + 2 files changed, 3 insertions(+) create mode 100644 backend/pytest.ini diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..184a477 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +execution_timeout = 2.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 19a7b6c..e56de62 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,6 +8,7 @@ idna==3.4 pydantic==2.5.1 pydantic_core==2.14.3 pytest==7.4.3 +pytest-timeouts==1.2.1 sniffio==1.3.0 starlette==0.27.0 typing_extensions==4.8.0 From a19e89f2a4ebd05d1f554634f64c68855fa1e5ca Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 02:15:26 +0100 Subject: [PATCH 21/27] Test connections --- backend/tests/test_websockets.py | 57 +++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/backend/tests/test_websockets.py b/backend/tests/test_websockets.py index 767f688..4cdf1cf 100644 --- a/backend/tests/test_websockets.py +++ b/backend/tests/test_websockets.py @@ -1,5 +1,60 @@ +import pytest from fastapi.testclient import TestClient +from fastapi.websockets import WebSocketDisconnect +from src.dependencies import get_manager +from src.game_manager import GameManager from src.main import app -client = TestClient(app) + +async def override_manager() -> GameManager: + manager = GameManager() + manager.add_new_game(max_players=2, room_name="foo", password=None) + manager.add_new_game(max_players=2, room_name="bar", password="123") + return manager + + +app.dependency_overrides[get_manager] = override_manager + + +@pytest.fixture(name="client") +def client_fixture(): + client = TestClient(app) + yield client + + +@pytest.fixture(name="identity1") +def get_identity1(): + return {"action": {"type": "IDENTITY", "payload": ["imma_user"]}} + + +class TestWebsocketConnection: + def test_connect_to_non_existing_room( + self, client: TestClient, identity1: dict + ): + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/ws/dummy1") as ws: + ws.send_json(identity1) + ws.receive_json() + + def test_connect_using_wrong_password( + self, client: TestClient, identity1: dict + ): + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect("/ws/bar/wrong_password") as ws: + ws.send_json(identity1) + ws.receive_json() + + def test_successfully_connect_to_existing_room_with_password( + self, client: TestClient, identity1: dict + ): + with client.websocket_connect("/ws/bar/123") as ws: + ws.send_json(identity1) + ws.receive_json() + + def test_successfully_connect_to_existing_room_without_password( + self, client: TestClient, identity1: dict + ): + with client.websocket_connect("/ws/foo") as ws: + ws.send_json(identity1) + ws.receive_json() From f87b6a346e072260781595ddbea4e61321b6f4d1 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 13:57:40 +0100 Subject: [PATCH 22/27] Tests for two people with the same identity and three connections where 2 allowed --- backend/tests/test_websockets.py | 70 ++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/backend/tests/test_websockets.py b/backend/tests/test_websockets.py index 4cdf1cf..df38e10 100644 --- a/backend/tests/test_websockets.py +++ b/backend/tests/test_websockets.py @@ -8,10 +8,14 @@ async def override_manager() -> GameManager: - manager = GameManager() - manager.add_new_game(max_players=2, room_name="foo", password=None) - manager.add_new_game(max_players=2, room_name="bar", password="123") - return manager + try: + yield override_manager.manager + except AttributeError: + manager = GameManager() + manager.add_new_game(max_players=2, room_name="foo", password=None) + manager.add_new_game(max_players=2, room_name="bar", password="123") + override_manager.manager = manager + yield override_manager.manager app.dependency_overrides[get_manager] = override_manager @@ -19,42 +23,76 @@ async def override_manager() -> GameManager: @pytest.fixture(name="client") def client_fixture(): - client = TestClient(app) - yield client + yield TestClient(app) -@pytest.fixture(name="identity1") -def get_identity1(): +@pytest.fixture(name="identity") +def single_identity_fixture(): return {"action": {"type": "IDENTITY", "payload": ["imma_user"]}} +@pytest.fixture(name="identities") +def multiple_identities_fixture(): + return [ + {"action": {"type": "IDENTITY", "payload": ["alpha"]}}, + {"action": {"type": "IDENTITY", "payload": ["beta"]}}, + {"action": {"type": "IDENTITY", "payload": ["gamma"]}}, + ] + + class TestWebsocketConnection: def test_connect_to_non_existing_room( - self, client: TestClient, identity1: dict + self, client: TestClient, identity: dict ): with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/ws/dummy1") as ws: - ws.send_json(identity1) + ws.send_json(identity) ws.receive_json() def test_connect_using_wrong_password( - self, client: TestClient, identity1: dict + self, client: TestClient, identity: dict ): with pytest.raises(WebSocketDisconnect): with client.websocket_connect("/ws/bar/wrong_password") as ws: - ws.send_json(identity1) + ws.send_json(identity) ws.receive_json() def test_successfully_connect_to_existing_room_with_password( - self, client: TestClient, identity1: dict + self, client: TestClient, identity: dict ): with client.websocket_connect("/ws/bar/123") as ws: - ws.send_json(identity1) + ws.send_json(identity) ws.receive_json() def test_successfully_connect_to_existing_room_without_password( - self, client: TestClient, identity1: dict + self, client: TestClient, identity: dict ): with client.websocket_connect("/ws/foo") as ws: - ws.send_json(identity1) + ws.send_json(identity) ws.receive_json() + + def test_two_same_identities(self, client: TestClient, identity: dict): + URL = "/ws/foo" + with ( + client.websocket_connect(URL) as ws1, + client.websocket_connect(URL) as ws2, + ): + ws1.send_json(identity) + with pytest.raises(WebSocketDisconnect): + ws2.send_json(identity) + ws2.receive_json() + + def test_connect_more_than_allowed_connections_to_the_same_room( + self, client: TestClient, identities: list[dict] + ): + URL = "/ws/foo" + with ( + client.websocket_connect(URL) as ws1, + client.websocket_connect(URL) as ws2, + client.websocket_connect(URL) as ws3, + ): + ws1.send_json(identities[0]) + ws2.send_json(identities[1]) + with pytest.raises(WebSocketDisconnect): + ws3.send_json(identities[2]) + ws3.receive_json() From 57a3ffa02b636d92cb04ff6f960d62526c284b30 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 19:00:42 +0100 Subject: [PATCH 23/27] UseCase: /ws//; /ws/ --- backend/src/game_manager.py | 76 ++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 17 deletions(-) diff --git a/backend/src/game_manager.py b/backend/src/game_manager.py index d49656c..9a4ab89 100644 --- a/backend/src/game_manager.py +++ b/backend/src/game_manager.py @@ -16,34 +16,33 @@ def __init__(self): self.games: dict[str, Game] = defaultdict(dict) async def connect( - self, websocket: WebSocket, room_name: str, client_id: str + self, websocket: WebSocket, room_name: str, password: str | None ) -> None: await websocket.accept() - if self.connections[room_name] == {}: - self.connections[room_name] = [] - # TODO: Support for any number of players, not only four - self.games[room_name] = Game(4, room_name) + game: Game = self.games.get(room_name, None) + if game is None: + raise WebSocketDisconnect - game: Game = self.games[room_name] + data = await websocket.receive_json() + self._authenticate_user(websocket, data) + client_id = websocket.scope["client_id"] - should_ws_be_closed = False match game.status: case GameStatus.NOT_STARTED: try: - game.add_new_player(client_id) + game.add_new_player(client_id, password) if self._should_start_the_game(game, room_name): game.start_game() except ValueError: - should_ws_be_closed = True + raise WebSocketDisconnect case GameStatus.STARTED: - if game.does_player_exist(client_id) == False: - should_ws_be_closed = True + if not self._is_player_reconnecting(client_id, game): + raise WebSocketDisconnect case GameStatus.TERMINATED: - should_ws_be_closed = True + raise WebSocketDisconnect - if should_ws_be_closed: - raise WebSocketDisconnect + await websocket.send_json(game.to_dict()) websocket.scope["room_name"] = room_name websocket.scope["client_id"] = client_id @@ -60,24 +59,67 @@ async def handle_message( pass ws.send_json({"data": message}) + def add_new_game( + self, max_players: int, room_name: str, password: str | None + ) -> None: + if room_name in self.games: + raise ValueError( + f"Game with the name {room_name} was already created" + ) + if max_players not in range(2, 7): + raise ValueError( + f"Invalid number of players: should be within [2-6]" + ) + game: Game = Game(max_players, room_name, password) + self.games[room_name] = game + self.connections[room_name] = [] + def get_members(self, room_name): try: return self.connections[room_name] except Exception: return None - async def remove(self, websocket: WebSocket) -> None: + async def disconnect(self, websocket: WebSocket) -> None: try: + self.connections[room_name].remove(websocket) client_name = websocket.scope["client_id"] room_name = websocket.scope["room_name"] game: Game = self.games[room_name] game.remove_player(client_name) - self.connections[room_name].remove(websocket) - except (ValueError, KeyError): + except (ValueError, KeyError, UnboundLocalError): pass finally: await websocket.close() + def _authenticate_user(self, websocket: WebSocket, data: dict): + """ + Fetches JSON in the format + { + "action": { + "type": "IDENTITY", + "payload": [] + } + } + then applies websocket.scope['client_id'] = + """ + action = data.get("action", None) + if action is None: + raise WebSocketDisconnect + + action_type = str(action.get("type", "")).upper() + if action_type != "IDENTITY": + raise WebSocketDisconnect + + payload = action.get("payload", None) + if type(payload) is not list or len(payload) != 1: + raise WebSocketDisconnect + + websocket.scope["client_id"] = payload[0] + + def _is_player_reconnecting(self, client_id: str, game: Game) -> bool: + return game.does_player_exist(client_id) + # TODO: Support for ready/unready def _should_start_the_game(self, game: Game, room_name: str) -> bool: num_connections = len(self.connections[room_name]) From 86d4e5530e959779d6e91efd252c98d45240a6b4 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 19:01:32 +0100 Subject: [PATCH 24/27] manager.disconnect in place of remove --- backend/src/routers/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/routers/websocket.py b/backend/src/routers/websocket.py index 1cdf36d..0b9d91d 100644 --- a/backend/src/routers/websocket.py +++ b/backend/src/routers/websocket.py @@ -24,4 +24,4 @@ async def websocket_endpoint( data = await websocket.receive_json() await manager.handle_message(room_name, client_id, data) except WebSocketDisconnect: - await manager.remove(websocket) + await manager.disconnect(websocket) From 534d5aa7f1231e12b39089a19be73efb9762ec2e Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 19:05:30 +0100 Subject: [PATCH 25/27] to_dict in place of __dict__; fix create_new_deck; simplify remove_player --- backend/src/game.py | 52 +++++++++++++++++---------------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index e716ef7..0b0c633 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -47,7 +47,7 @@ def discard(self, card: Card): self._deck.pop(card) return card - def __dict__(self): + def to_dict(self): d_player = dict() d_player["id"] = self._identifier d_player["name"] = self._name @@ -263,14 +263,11 @@ def does_player_exist(self, name: str) -> bool: def remove_player(self, name: str): if self.status == GameStatus.NOT_STARTED: - was_removed = False - for player in self.players: - if player._identifier == name: - was_removed = True - self.players.remove(player) - break - if not was_removed: + player = self.find_player(name) + if player is None: raise ValueError("Player with this identifier was not found") + else: + self.players.remove(player) else: raise ValueError("Game has been started or was terminated") @@ -282,15 +279,19 @@ def start_game(self): else: raise ValueError("Game has been started or was terminated") - def to_json(self, player_name: str = None): - res = dict(self) + def to_dict(self, player_name: str = None): + d_game = dict() + d_game["id"] = self.name + d_game["name"] = self.name + d_game["status"] = str(self.status) + d_game["current_player"] = self.get_current_player() - if player_name is None: - for player in self._get_players_identifiers(): - pass - else: - res["your_cards"] = [] - return res + # append players to the game dict + d_game["players"] = [] + for player in self.players: + d_game["players"].append(player.to_dict()) + + return d_game def is_terminal(self) -> bool: raise NotImplementedError() @@ -298,13 +299,14 @@ def is_terminal(self) -> bool: def _new_round(self) -> None: for player in self.players: player.is_killed = False + player.is_protected = False - self._remaining_cards = self._create_new_deck() + self._create_new_deck() for player in self.players: player.get_card_from_deck(self._remaining_cards) - def _create_new_deck(self) -> list[Card]: + def _create_new_deck(self) -> None: full_deck = [] # add all kinds of cards to deck in correct amounts @@ -340,17 +342,3 @@ def card_back_to_remainig_cards(self, card: Card): def _get_players_identifiers(self) -> tuple[str]: return (p._identifier for p in self.players) - - def __dict__(self): - d_game = dict() - d_game["id"] = self.name - d_game["name"] = self.name - d_game["status"] = str(self.status) - d_game["current_player"] = self.get_current_player() - - # append players to the game dict - d_game["players"] = [] - for player in self.players: - d_game["players"].append(dict(player)) - - return d_game From e033713b2fc2ca8016e7db3912c8eb1423853781 Mon Sep 17 00:00:00 2001 From: Szymon Siemieniuk <36564363+siemieniuk@users.noreply.github.com> Date: Sat, 9 Dec 2023 19:06:34 +0100 Subject: [PATCH 26/27] new __init__; new add_new_player --- backend/src/game.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/backend/src/game.py b/backend/src/game.py index 0b0c633..5708f0e 100644 --- a/backend/src/game.py +++ b/backend/src/game.py @@ -59,12 +59,16 @@ def to_dict(self): class Game: - def __init__(self, num_players: int, name: str) -> None: + def __init__( + self, max_players: int, name: str, password: str | None = None + ) -> None: + assert max_players <= 6 and max_players >= 2 self.players: list[Player] = [] - self.max_players: int = num_players + self.max_players: int = max_players self.player_counter: int = 0 self.status: GameStatus = GameStatus.NOT_STARTED self.name: str = name + self.password: str | None = password self._remaining_cards: PriorityQueue = PriorityQueue() def move(self, card: Card, action: list[str]) -> bool: @@ -240,11 +244,15 @@ def switch_current_player(self) -> None: ) break - def add_new_player(self, name: str) -> None: + def add_new_player(self, name: str, password: str) -> None: + if password != self.password: + raise ValueError("Password does not match") if self.status in [GameStatus.STARTED, GameStatus.TERMINATED]: raise ValueError("Game has been started or was terminated") if self.does_player_exist(name): raise ValueError(f"Player with identifier={name} already exists") + if len(self.players) == self.max_players: + raise ValueError("Tried to exceed maximum number of players") player = Player(name) self.players.append(player) From 74e622fe5fa75f162125831cdd7eb3e723f88803 Mon Sep 17 00:00:00 2001 From: MaciejWojt Date: Wed, 13 Dec 2023 11:55:09 +0100 Subject: [PATCH 27/27] added create room endpoint --- backend/src/routers/api.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/src/routers/api.py b/backend/src/routers/api.py index 1d2370c..ced1b9c 100644 --- a/backend/src/routers/api.py +++ b/backend/src/routers/api.py @@ -1,5 +1,4 @@ -from fastapi import APIRouter - +from fastapi import APIRouter, Body, HTTPException from src.dependencies import get_manager router = APIRouter() @@ -22,3 +21,17 @@ async def get_games(started: bool): return {"games": games} else: return {"message": "all games has already started"} + + +@router.post("/api/v1/games") +async def create_game( + room_name: str = Body(...), + max_players: int = Body(...), + password: str = Body(None), +): + try: + manager = await get_manager().__anext__() + manager.add_new_game(max_players, room_name, password) + return {"status": "Game created successfully"} + except ValueError as e: + raise HTTPException(status_code=403, detail=str(e))