diff --git a/backend/app/adapter/api.py b/backend/app/adapter/api.py index dd5a6df..ddba572 100644 --- a/backend/app/adapter/api.py +++ b/backend/app/adapter/api.py @@ -14,12 +14,14 @@ UpdateInvestigatorDto, UpdateDifficultyDto, UpdateCommonRespDto, + GameStartDto, ) from app.usecase import ( CreateGameUseCase, GetAvailableInvestigatorsUseCase, SwitchInvestigatorUseCase, UpdateGameDifficultyUseCase, + GameStartUseCase, ) from app.config import LOG_FILE_PATH, REST_HOST, REST_PORT from app.domain import GameError @@ -99,6 +101,18 @@ async def update_game_difficulty(game_id: str, req: UpdateDifficultyDto): return GameErrorHTTPResponse(e) +@_router.patch("/games/{game_id}/start", status_code=200) +async def start_game(game_id: str, req: GameStartDto): + uc = GameStartUseCase( + repository=shared_context["repository"], + evt_emitter=shared_context["evt_emit"], + ) + try: + await uc.execute(game_id, req.player_id) + except GameError as e: + return GameErrorHTTPResponse(e) + + @asynccontextmanager async def lifetime_server_context(app: FastAPI): # TODO, parameters should be in separate python module or `json` , `toml` file diff --git a/backend/app/adapter/event_emitter.py b/backend/app/adapter/event_emitter.py index aaf8923..74a7aff 100644 --- a/backend/app/adapter/event_emitter.py +++ b/backend/app/adapter/event_emitter.py @@ -2,7 +2,13 @@ import logging import socketio -from app.dto import Investigator, Difficulty, RtcCharacterMsgData, RtcDifficultyMsgData +from app.dto import ( + Investigator, + Difficulty, + RtcCharacterMsgData, + RtcDifficultyMsgData, + RtcGameStartMsgData, +) from app.config import LOG_FILE_PATH, RTC_HOST, RTC_PORT from app.constant import RealTimeCommConst as RtcConst, GameRtcEvent from app.domain import Game @@ -45,6 +51,10 @@ async def update_difficulty(self, game_id: str, level: Difficulty): data = RtcDifficultyMsgData.serialize(game_id, level) await self.do_emit(data, evt=RtcConst.EVENTS.DIFFICULTY) + async def start_game(self, game_id: str, player_id: str): + data = RtcGameStartMsgData.serialize(game_id, player_id) + await self.do_emit(data, evt=RtcConst.EVENTS.GAME_START) + async def do_emit(self, data: Union[Dict, bytes], evt: GameRtcEvent): try: if not self._client.connected: diff --git a/backend/app/adapter/sio_srv.py b/backend/app/adapter/sio_srv.py index ba8b695..07ed719 100644 --- a/backend/app/adapter/sio_srv.py +++ b/backend/app/adapter/sio_srv.py @@ -17,6 +17,7 @@ RtcInitMsgData, RtcCharacterMsgData, RtcDifficultyMsgData, + RtcGameStartMsgData, ) from app.domain import GameError, GameErrorCodes, GameFuncCodes @@ -177,6 +178,17 @@ async def _update_game_difficulty(sid, data: Dict): ) +@srv.on(RtcConst.EVENTS.GAME_START.value, namespace=RtcConst.NAMESPACE) +async def _start_game(sid, data: bytes): + # TODO, ensure this event is sent by authorized http server + await _generic_forward_msg( + sid, + data, + evt=RtcConst.EVENTS.GAME_START, + validator=RtcGameStartMsgData.deserialize, + ) + + def gen_srv_task(host: str): cfg = Config() cfg.bind = [host] diff --git a/backend/app/constant.py b/backend/app/constant.py index b287d66..e477d22 100644 --- a/backend/app/constant.py +++ b/backend/app/constant.py @@ -12,6 +12,7 @@ class GameRtcEvent(Enum): NEW_ROOM = "new_room" CHARACTER = "character" DIFFICULTY = "difficulty" + GAME_START = "game_start" class RealTimeCommConst: diff --git a/backend/app/domain/game.py b/backend/app/domain/game.py index 382ba07..c5cbdc1 100644 --- a/backend/app/domain/game.py +++ b/backend/app/domain/game.py @@ -18,6 +18,7 @@ class GameErrorCodes(Enum): INVESTIGATOR_CHOSEN = (1003, 409) INVALID_PLAYER = (1004, 422) GAME_NOT_FOUND = (1005, 404) + PLAYER_ALREADY_STARTED = (1006, 400) class GameFuncCodes(Enum): @@ -26,6 +27,7 @@ class GameFuncCodes(Enum): SWITCH_CHARACTER = 1003 CLEAR_CHARACTER_SELECTION = 1004 UPDATE_DIFFICULTY = 1005 + START_GAME = 1006 ## TODO, rename the following members USE_CASE_EXECUTE = 1099 RTC_ENDPOINT = 1098 ## for real-time communication like socket.io server endpoint @@ -121,6 +123,11 @@ def switch_character(self, player_id: str, new_invstg: Investigator): e_code=GameErrorCodes.INVALID_PLAYER, fn_code=GameFuncCodes.SWITCH_CHARACTER, ) + if player.started: + raise GameError( + e_code=GameErrorCodes.PLAYER_ALREADY_STARTED, + fn_code=GameFuncCodes.SWITCH_CHARACTER, + ) old_invstg = player.get_investigator() try: if old_invstg: @@ -140,3 +147,23 @@ def filter_unselected_investigators(self, num: int) -> List[Investigator]: def update_difficulty(self, difficulty: Difficulty): self._difficulty = difficulty + + def start(self, player_id: str): + player = self.get_player(player_id) + if player is None: + raise GameError( + e_code=GameErrorCodes.INVALID_PLAYER, + fn_code=GameFuncCodes.START_GAME, + ) + if player.get_investigator() is None: + raise GameError( + e_code=GameErrorCodes.INVALID_INVESTIGATOR, + fn_code=GameFuncCodes.START_GAME, + ) + player.start() + all_started = all([p.started for p in self.players]) + if all_started: + pass + # TODO + # - initialize all types of cards, card deck, map (number of cultists in each + # location), player status e.g. sanity points diff --git a/backend/app/domain/player.py b/backend/app/domain/player.py index 0e5aeb6..38480f2 100644 --- a/backend/app/domain/player.py +++ b/backend/app/domain/player.py @@ -1,15 +1,27 @@ +from typing import Optional +from app.dto import Investigator + + class Player: def __init__(self, id: str, nickname: str): self._id = id - self._nickname = nickname - self._investigator = None + self._nickname: str = nickname + self._investigator: Optional[Investigator] = None + self._rdy_start: bool = False - def set_investigator(self, investigator): - self._investigator = investigator + def set_investigator(self, value: Investigator): + self._investigator = value - def get_investigator(self): + def get_investigator(self) -> Optional[Investigator]: return self._investigator @property def id(self): return self._id + + @property + def started(self) -> bool: + return self._rdy_start + + def start(self): + self._rdy_start = True diff --git a/backend/app/dto/__init__.py b/backend/app/dto/__init__.py index 80ff264..19a90f7 100644 --- a/backend/app/dto/__init__.py +++ b/backend/app/dto/__init__.py @@ -8,6 +8,7 @@ Investigator as InvestigatorFbs, DifficultyConfig, Difficulty as DifficultyFbs, + GameStart as GameStartFbs, ) # data transfer objects (DTO) in the application @@ -136,6 +137,11 @@ class UpdateCommonRespDto(BaseModel): message: str +class GameStartDto(BaseModel): + model_config = ConfigDict(extra="forbid") + player_id: str + + class RtcRoomMsgData(BaseModel): model_config = ConfigDict(extra="forbid") gameID: str @@ -211,3 +217,28 @@ def deserialize(data: bytes): game_id = obj.GameId().decode("utf-8") lvl = Difficulty.from_fbs(obj.Level()) return RtcDifficultyMsgData(gameID=game_id, level=lvl) + + +class RtcGameStartMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + gameID: str + player_id: str + + def serialize(game_id: str, player_id: str) -> bytes: + builder = flatbuffers.Builder(128) + game_id = builder.CreateString(game_id) + player_id = builder.CreateString(player_id) + GameStartFbs.Start(builder) + GameStartFbs.AddGameId(builder, game_id) + GameStartFbs.AddPlayerId(builder, player_id) + starter = GameStartFbs.End(builder) + builder.Finish(starter) + serial = builder.Output() # byte-array + return bytes(serial) + + def deserialize(data: bytes): + buf = bytearray(data) + obj = GameStartFbs.GameStart.GetRootAs(buf, offset=0) + game_id = obj.GameId().decode("utf-8") + player = obj.PlayerId().decode("utf-8") + return RtcGameStartMsgData(gameID=game_id, player_id=player) diff --git a/backend/app/dto/rtc.fbs b/backend/app/dto/rtc.fbs index d6ca9ec..eb6eec1 100644 --- a/backend/app/dto/rtc.fbs +++ b/backend/app/dto/rtc.fbs @@ -18,3 +18,8 @@ table DifficultyConfig { level: Difficulty; } +table GameStart { + game_id: string (required); + player_id: string (required); +} + diff --git a/backend/app/dto/rtc/GameStart.py b/backend/app/dto/rtc/GameStart.py new file mode 100644 index 0000000..1d32833 --- /dev/null +++ b/backend/app/dto/rtc/GameStart.py @@ -0,0 +1,78 @@ +# automatically generated by the FlatBuffers compiler, do not modify + +# namespace: + +import flatbuffers +from flatbuffers.compat import import_numpy + +np = import_numpy() + + +class GameStart(object): + __slots__ = ["_tab"] + + @classmethod + def GetRootAs(cls, buf, offset=0): + n = flatbuffers.encode.Get(flatbuffers.packer.uoffset, buf, offset) + x = GameStart() + x.Init(buf, n + offset) + return x + + @classmethod + def GetRootAsGameStart(cls, buf, offset=0): + """This method is deprecated. Please switch to GetRootAs.""" + return cls.GetRootAs(buf, offset) + + # GameStart + def Init(self, buf, pos): + self._tab = flatbuffers.table.Table(buf, pos) + + # GameStart + def GameId(self): + o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(4)) + if o != 0: + return self._tab.String(o + self._tab.Pos) + return None + + # GameStart + def PlayerId(self): + o = flatbuffers.number_types.UOffsetTFlags.py_type(self._tab.Offset(6)) + if o != 0: + return self._tab.String(o + self._tab.Pos) + return None + + +def GameStartStart(builder): + builder.StartObject(2) + + +def Start(builder): + GameStartStart(builder) + + +def GameStartAddGameId(builder, gameId): + builder.PrependUOffsetTRelativeSlot( + 0, flatbuffers.number_types.UOffsetTFlags.py_type(gameId), 0 + ) + + +def AddGameId(builder, gameId): + GameStartAddGameId(builder, gameId) + + +def GameStartAddPlayerId(builder, playerId): + builder.PrependUOffsetTRelativeSlot( + 1, flatbuffers.number_types.UOffsetTFlags.py_type(playerId), 0 + ) + + +def AddPlayerId(builder, playerId): + GameStartAddPlayerId(builder, playerId) + + +def GameStartEnd(builder): + return builder.EndObject() + + +def End(builder): + return GameStartEnd(builder) diff --git a/backend/app/usecase/__init__.py b/backend/app/usecase/__init__.py index 605a785..d369bac 100644 --- a/backend/app/usecase/__init__.py +++ b/backend/app/usecase/__init__.py @@ -3,4 +3,5 @@ GetAvailableInvestigatorsUseCase, # noqa: F401 SwitchInvestigatorUseCase, # noqa: F401 UpdateGameDifficultyUseCase, # noqa: F401 + GameStartUseCase, # noqa: F401 ) diff --git a/backend/app/usecase/config_game.py b/backend/app/usecase/config_game.py index 7295a36..64426cb 100644 --- a/backend/app/usecase/config_game.py +++ b/backend/app/usecase/config_game.py @@ -89,3 +89,17 @@ async def execute(self, game_id: str, level: Difficulty) -> UpdateCommonRespDto: await self.evt_emitter.update_difficulty(game_id, level) message = "Update Game {} Difficulty Successfully".format(game.id) return UpdateCommonRespDto(message=message) + + +class GameStartUseCase(AbstractUseCase): + async def execute(self, game_id: str, player_id: str): + game = await self.repository.get_game(game_id) + if game is None: + raise GameError( + e_code=GameErrorCodes.GAME_NOT_FOUND, + fn_code=GameFuncCodes.USE_CASE_EXECUTE, + ) + game.start(player_id) + await self.repository.save(game) + if self.evt_emitter: + await self.evt_emitter.start_game(game_id, player_id) diff --git a/backend/tests/e2e/test_game.py b/backend/tests/e2e/test_game.py index 375b922..4c10394 100644 --- a/backend/tests/e2e/test_game.py +++ b/backend/tests/e2e/test_game.py @@ -88,10 +88,35 @@ def test_update_game_difficulty_ok(self, test_client): message = respbody.get("message") assert message == "Update Game {} Difficulty Successfully".format(game_id) - def test_update_game_difficulty_nonexist_game(self, test_client): - url = "/games/{}/difficulty" - reqbody = {"level": "standard"} - response = test_client.patch(url.format("xxxxx"), headers={}, json=reqbody) + @pytest.mark.parametrize( + "method, url_pattern, reqbody", + [ + ( + "patch", + "/games/{}/investigator", + {"investigator": "hunter", "player_id": "8964"}, + ), + ("patch", "/games/{}/difficulty", {"level": "standard"}), + ("patch", "/games/{}/start", {"player_id": "996"}), + ], + ) + def test_update_game_state_nonexist( + self, test_client, method, url_pattern, reqbody + ): + response = test_client.request( + method, url_pattern.format("xxxxx"), headers={}, json=reqbody + ) assert response.status_code == 404 error_detail = response.json() assert error_detail["reason"] == GameErrorCodes.GAME_NOT_FOUND.value[0] + + def test_game_start_ok(self, test_client): + game_id = self.create_game_common(test_client) + response = test_client.patch( + "/games/{}/start".format(game_id), headers={}, json={"player_id": "9487"} + ) + assert response.status_code == 200 + response = test_client.patch( + "/games/{}/start".format(game_id), headers={}, json={"player_id": "9527"} + ) + assert response.status_code == 200 diff --git a/backend/tests/e2e/test_socketio.py b/backend/tests/e2e/test_socketio.py index f348d69..fec0943 100644 --- a/backend/tests/e2e/test_socketio.py +++ b/backend/tests/e2e/test_socketio.py @@ -5,7 +5,13 @@ from app.config import RTC_HOST, RTC_PORT from app.constant import RealTimeCommConst as RtcConst -from app.dto import Investigator, Difficulty, RtcCharacterMsgData, RtcDifficultyMsgData +from app.dto import ( + Investigator, + Difficulty, + RtcCharacterMsgData, + RtcDifficultyMsgData, + RtcGameStartMsgData, +) SERVER_URL = "http://%s:%s" % (RTC_HOST, RTC_PORT) @@ -120,6 +126,13 @@ async def verify_difficulty(self, expect: str): obj = RtcDifficultyMsgData.deserialize(evts[1]) assert obj.level.value == expect + async def verify_game_start(self, expect_player): + evts: List = await self._sio_client.receive(timeout=3) + assert len(evts) == 2 + assert evts[0] == RtcConst.EVENTS.GAME_START.value + obj = RtcGameStartMsgData.deserialize(evts[1]) + assert obj.player_id == expect_player.player_id + class MockiHttpServer(MockiAbstractClient): async def new_room(self, room_id: str, members: List[str]): @@ -141,6 +154,10 @@ async def set_difficulty(self, room_id: str, level: Difficulty): data = RtcDifficultyMsgData.serialize(room_id, level) await self._sio_client.emit(RtcConst.EVENTS.DIFFICULTY.value, data=data) + async def confirm_start(self, room_id: str, player: str): + data = RtcGameStartMsgData.serialize(room_id, player) + await self._sio_client.emit(RtcConst.EVENTS.GAME_START.value, data=data) + class TestRealTimeComm: @pytest.mark.asyncio @@ -268,6 +285,13 @@ async def test_forward_game_state_msg(self): await clients[0].verify_difficulty("standard") await clients[1].verify_difficulty("standard") + await http_server.confirm_start(game_room, clients[1].player_id) + await clients[0].verify_game_start(clients[1]) + await clients[1].verify_game_start(clients[1]) + await http_server.confirm_start(game_room, clients[0].player_id) + await clients[0].verify_game_start(clients[0]) + await clients[1].verify_game_start(clients[0]) + for client in clients: await client.leave(room_id=game_room) await client.disconnect() diff --git a/backend/tests/unit/test_investigator.py b/backend/tests/unit/test_investigator.py index 7430327..aefd3c2 100644 --- a/backend/tests/unit/test_investigator.py +++ b/backend/tests/unit/test_investigator.py @@ -216,3 +216,24 @@ def test_switch_to_unselected(game, ut_data): game.switch_character(d["player_id"], d["expect"]) player = game.get_player(d["player_id"]) assert player.get_investigator() == d["actual"] + + +def test_switch_and_game_start(game): + setup_data = [ + PlayerDto(id="8e1u3g", nickname="Hawk"), + PlayerDto(id="e1u30B", nickname="Pelican"), + ] + assert game.add_players(player_dtos=setup_data) is None + for p in game.players: + assert p.get_investigator() is None + + with pytest.raises(GameError) as e: + game.start("8e1u3g") + assert e.value == GameErrorCodes.INVALID_INVESTIGATOR + + game.switch_character("8e1u3g", Investigator.OCCULTIST) + game.start("8e1u3g") + + with pytest.raises(GameError) as e: + game.switch_character("8e1u3g", Investigator.DRIVER) + assert e.value == GameErrorCodes.PLAYER_ALREADY_STARTED diff --git a/backend/tests/unit/test_player.py b/backend/tests/unit/test_player.py index b1c7a2a..dabc537 100644 --- a/backend/tests/unit/test_player.py +++ b/backend/tests/unit/test_player.py @@ -19,3 +19,11 @@ def test_get_investigator(player): def test_not_set(player): assert player.get_investigator() is None + + +def test_started(player): + assert player.started is False + player.start() + assert player.started is True + player.start() + assert player.started is True diff --git a/doc/api/swagger.yaml b/doc/api/swagger.yaml index 01a45ed..11f16ea 100644 --- a/doc/api/swagger.yaml +++ b/doc/api/swagger.yaml @@ -156,6 +156,7 @@ paths: - investigator-already-chosen - invalid-player - invalid-investigator + - player-alreaady-started example: investigator-already-chosen /games/{gameId}/difficulty: @@ -203,25 +204,20 @@ paths: required: true schema: type: string + requestBody: + description: JSON object that specifies player ID + required: true + content: + application/json: + schema: + type: object + properties: + player_id: + type: string + example: e2355667-e89b-12d3-a456-426614174000 responses: '200': - description: Player readiness status, return list of player IDs who are ready - content: - application/json: - example: - ready: ["player-id-01", "player-id-02"] - not_ready: ["player-id-03", "player-id-04"] - schema: - type: object - properties: - ready: - type: array - items: - type: string - not_ready: - type: array - items: - type: string + description: Player is ready '400': description: Bad Request content: @@ -235,6 +231,7 @@ paths: type: string enum: - invalid-player + - invalid-investigator - invalid-game-id example: invalid-player