From 7fe4911a3ae8163bca1d8884172bebc1d6c0826a Mon Sep 17 00:00:00 2001 From: Han Woo Date: Thu, 1 Feb 2024 13:12:21 +0800 Subject: [PATCH] [#37] feature: emit events on difficulty and character update - http server emits corresponding event on completing the operations to socket.io server - socket.io server simply forwards the game-state messages to corresponding players of a given game Signed-off-by: T.H. --- backend/app/adapter/api.py | 9 +++- backend/app/adapter/event_emitter.py | 33 +++++++++++-- backend/app/adapter/sio_srv.py | 46 +++++++++++++----- backend/app/constant.py | 2 + backend/app/dto.py | 18 +++++++ backend/app/usecase/config_game.py | 4 ++ backend/tests/e2e/test_socketio.py | 62 +++++++++++++++++++++++++ backend/tests/unit/usecase/test_game.py | 2 +- 8 files changed, 159 insertions(+), 17 deletions(-) diff --git a/backend/app/adapter/api.py b/backend/app/adapter/api.py index d8ca7c4..dd5a6df 100644 --- a/backend/app/adapter/api.py +++ b/backend/app/adapter/api.py @@ -75,7 +75,10 @@ async def read_unselected_investigators(game_id: str): @_router.patch("/games/{game_id}/investigator", status_code=200) async def switch_investigator(game_id: str, req: UpdateInvestigatorDto): - uc = SwitchInvestigatorUseCase(shared_context["repository"]) + uc = SwitchInvestigatorUseCase( + shared_context["repository"], + evt_emitter=shared_context["evt_emit"], + ) try: await uc.execute(game_id, req.player_id, req.investigator) except GameError as e: @@ -85,7 +88,9 @@ async def switch_investigator(game_id: str, req: UpdateInvestigatorDto): @_router.patch("/games/{game_id}/difficulty", response_model=UpdateCommonRespDto) async def update_game_difficulty(game_id: str, req: UpdateDifficultyDto): uc = UpdateGameDifficultyUseCase( - repository=shared_context["repository"], settings=shared_context["settings"] + repository=shared_context["repository"], + evt_emitter=shared_context["evt_emit"], + settings=shared_context["settings"], ) try: response = await uc.execute(game_id, req.level) diff --git a/backend/app/adapter/event_emitter.py b/backend/app/adapter/event_emitter.py index 3bf3aa8..33e7987 100644 --- a/backend/app/adapter/event_emitter.py +++ b/backend/app/adapter/event_emitter.py @@ -1,8 +1,10 @@ +from typing import Dict import logging import socketio +from app.dto import Investigator, Difficulty from app.config import LOG_FILE_PATH, RTC_HOST, RTC_PORT -from app.constant import RealTimeCommConst as RtcConst +from app.constant import RealTimeCommConst as RtcConst, GameRtcEvent from app.domain import Game _logger = logging.getLogger(__name__) @@ -14,6 +16,14 @@ class AbsEventEmitter: async def create_game(self, game: Game): raise NotImplementedError("AbsEventEmitter.create_game") + async def switch_character( + self, game_id: str, player_id: str, new_invstg: Investigator + ): + raise NotImplementedError("AbsEventEmitter.switch_character") + + async def update_difficulty(self, game_id: str, level: Difficulty): + raise NotImplementedError("AbsEventEmitter.update_difficulty") + class SocketIoEventEmitter(AbsEventEmitter): def __init__(self): @@ -23,12 +33,29 @@ def __init__(self): async def create_game(self, game: Game): data = {"gameID": game.id, "players": [p.id for p in game.players]} + await self.do_emit(data, evt=RtcConst.EVENTS.NEW_ROOM) + + async def switch_character( + self, game_id: str, player_id: str, new_invstg: Investigator + ): + data = { + "gameID": game_id, + "player_id": player_id, + "investigator": new_invstg.value, + } + await self.do_emit(data, evt=RtcConst.EVENTS.CHARACTER) + + async def update_difficulty(self, game_id: str, level: Difficulty): + data = {"gameID": game_id, "level": level.value} + await self.do_emit(data, evt=RtcConst.EVENTS.DIFFICULTY) + + async def do_emit(self, data: Dict, evt: GameRtcEvent): try: if not self._client.connected: await self._client.connect(self._url, namespace=self._namespace) - await self._client.emit(RtcConst.EVENTS.NEW_ROOM.value, data=data) + await self._client.emit(evt.value, data=data) except Exception as e: - _logger.error("send new-room event: %s", e) + _logger.error("emit event: %s", e) # TODO, reconnect if connection inactive async def deinit(self): diff --git a/backend/app/adapter/sio_srv.py b/backend/app/adapter/sio_srv.py index 8886379..8498164 100644 --- a/backend/app/adapter/sio_srv.py +++ b/backend/app/adapter/sio_srv.py @@ -6,12 +6,18 @@ import socketio from hypercorn.config import Config from hypercorn.asyncio import serve -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError -from app.constant import RealTimeCommConst as RtcConst +from app.constant import RealTimeCommConst as RtcConst, GameRtcEvent from app.config import RTC_HOST, RTC_PORT, LOG_FILE_PATH from app.adapter.repository import get_rtc_room_repository -from app.dto import RtcRoomMsgData, ChatMsgData, RtcInitMsgData +from app.dto import ( + RtcRoomMsgData, + ChatMsgData, + RtcInitMsgData, + RtcCharacterMsgData, + RtcDifficultyMsgData, +) from app.domain import GameError, GameErrorCodes, GameFuncCodes srv = socketio.AsyncServer(async_mode="asgi") @@ -27,22 +33,24 @@ _repo = get_rtc_room_repository() -@srv.on(RtcConst.EVENTS.CHAT.value, namespace=RtcConst.NAMESPACE) -async def _forward_chat_msg(sid, data: Dict): +async def _generic_forward_msg(sid, data: Dict, evt: GameRtcEvent, msgtype: BaseModel): try: - ChatMsgData(**data) + validated = msgtype(**data) await srv.emit( - RtcConst.EVENTS.CHAT.value, + evt.value, data, namespace=RtcConst.NAMESPACE, - room=data["gameID"], + room=validated.gameID, skip_sid=sid, ) except ValidationError as e: error = e.errors(include_url=False, include_input=False) - await srv.emit( - RtcConst.EVENTS.CHAT.value, data=error, namespace=RtcConst.NAMESPACE, to=sid - ) + await srv.emit(evt.value, data=error, namespace=RtcConst.NAMESPACE, to=sid) + + +@srv.on(RtcConst.EVENTS.CHAT.value, namespace=RtcConst.NAMESPACE) +async def _forward_chat_msg(sid, data: Dict): + await _generic_forward_msg(sid, data, evt=RtcConst.EVENTS.CHAT, msgtype=ChatMsgData) async def check_room_exist(repo, data: RtcInitMsgData): @@ -136,6 +144,22 @@ async def _new_game_room(sid, data: Dict): _logger.error("%s", e) +@srv.on(RtcConst.EVENTS.CHARACTER.value, namespace=RtcConst.NAMESPACE) +async def _player_switch_character(sid, data: Dict): + # TODO, ensure this event is sent by authorized http server + await _generic_forward_msg( + sid, data, evt=RtcConst.EVENTS.CHARACTER, msgtype=RtcCharacterMsgData + ) + + +@srv.on(RtcConst.EVENTS.DIFFICULTY.value, namespace=RtcConst.NAMESPACE) +async def _update_game_difficulty(sid, data: Dict): + # TODO, ensure this event is sent by authorized http server + await _generic_forward_msg( + sid, data, evt=RtcConst.EVENTS.DIFFICULTY, msgtype=RtcDifficultyMsgData + ) + + def gen_srv_task(host: str): cfg = Config() cfg.bind = [host] diff --git a/backend/app/constant.py b/backend/app/constant.py index 0ec9f13..b287d66 100644 --- a/backend/app/constant.py +++ b/backend/app/constant.py @@ -10,6 +10,8 @@ class GameRtcEvent(Enum): DEINIT = "deinit" CHAT = "chat" NEW_ROOM = "new_room" + CHARACTER = "character" + DIFFICULTY = "difficulty" class RealTimeCommConst: diff --git a/backend/app/dto.py b/backend/app/dto.py index 171dddb..cd4f0e5 100644 --- a/backend/app/dto.py +++ b/backend/app/dto.py @@ -7,11 +7,13 @@ class PlayerDto(BaseModel): + model_config = ConfigDict(extra="forbid") id: str nickname: str class CreateGameReqDto(BaseModel): + model_config = ConfigDict(extra="forbid") players: List[PlayerDto] @@ -37,6 +39,7 @@ class Investigator(Enum): class SingleInvestigatorDto(BaseModel): + model_config = ConfigDict(extra="forbid") investigator: Investigator @@ -44,6 +47,7 @@ class SingleInvestigatorDto(BaseModel): class UpdateInvestigatorDto(BaseModel): + model_config = ConfigDict(extra="forbid") investigator: Investigator player_id: str @@ -58,6 +62,7 @@ class Difficulty(Enum): class UpdateDifficultyDto(BaseModel): + model_config = ConfigDict(extra="forbid") level: Difficulty @@ -84,3 +89,16 @@ class RtcInitMsgData(BaseModel): player: PlayerDto gameID: str client: str # client session ID + + +class RtcCharacterMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + gameID: str + investigator: Investigator + player_id: str + + +class RtcDifficultyMsgData(BaseModel): + model_config = ConfigDict(extra="forbid") + gameID: str + level: Difficulty diff --git a/backend/app/usecase/config_game.py b/backend/app/usecase/config_game.py index 195935a..7295a36 100644 --- a/backend/app/usecase/config_game.py +++ b/backend/app/usecase/config_game.py @@ -71,6 +71,8 @@ async def execute(self, game_id: str, player_id: str, new_invstg: Investigator): game.switch_character(player_id, new_invstg) await self.repository.save(game) + if self.evt_emitter: + await self.evt_emitter.switch_character(game_id, player_id, new_invstg) class UpdateGameDifficultyUseCase(AbstractUseCase): @@ -83,5 +85,7 @@ async def execute(self, game_id: str, level: Difficulty) -> UpdateCommonRespDto: ) game.update_difficulty(level) await self.repository.save(game) + if self.evt_emitter: + await self.evt_emitter.update_difficulty(game_id, level) message = "Update Game {} Difficulty Successfully".format(game.id) return UpdateCommonRespDto(message=message) diff --git a/backend/tests/e2e/test_socketio.py b/backend/tests/e2e/test_socketio.py index 9bd7eb8..ce7d751 100644 --- a/backend/tests/e2e/test_socketio.py +++ b/backend/tests/e2e/test_socketio.py @@ -104,6 +104,19 @@ async def verify_chat(self, expect_sender, expect_error: Dict = None): self._msg_log.append(item) assert self.messages_log == expect_sender.messages_log + async def verify_character_update(self, expect_player, expect_character: str): + evts: List = await self._sio_client.receive(timeout=3) + assert len(evts) == 2 + assert evts[0] == RtcConst.EVENTS.CHARACTER.value + assert evts[1]["player_id"] == expect_player.player_id + assert evts[1]["investigator"] == expect_character + + async def verify_difficulty(self, expect: str): + evts: List = await self._sio_client.receive(timeout=3) + assert len(evts) == 2 + assert evts[0] == RtcConst.EVENTS.DIFFICULTY.value + assert evts[1]["level"] == expect + class MockiHttpServer(MockiAbstractClient): async def new_room(self, room_id: str, members: List[str]): @@ -115,6 +128,14 @@ async def new_room(self, room_id: str, members: List[str]): }, ) + async def switch_character(self, room_id: str, player: str, character: str): + data = {"player_id": player, "gameID": room_id, "investigator": character} + await self._sio_client.emit(RtcConst.EVENTS.CHARACTER.value, data=data) + + async def set_difficulty(self, room_id: str, level: str): + data = {"level": level, "gameID": room_id} + await self._sio_client.emit(RtcConst.EVENTS.DIFFICULTY.value, data=data) + class TestRealTimeComm: @pytest.mark.asyncio @@ -205,3 +226,44 @@ async def test_enter_room(self): await client.leave(room_id="c0034") await client.disconnect() await http_server.disconnect() + + @pytest.mark.asyncio + async def test_forward_game_state_msg(self): + http_server = MockiHttpServer(RtcConst.NAMESPACE) + clients = [ + MockClient(nickname="Fabio", player_id="u0017"), + MockClient(nickname="Von Mc", player_id="u0018"), + ] + game_room = "a0020" + + await http_server.connect(SERVER_URL) + await http_server.new_room(game_room, members=[c.player_id for c in clients]) + for client in clients: + await client.connect(SERVER_URL) + await client.join(room_id=game_room) + + await clients[0].verify_join(clients[:], True) + await clients[1].verify_join(clients[1:], True) + + await http_server.switch_character( + game_room, clients[1].player_id, character="magician" + ) + await clients[0].verify_character_update(clients[1], "magician") + await clients[1].verify_character_update(clients[1], "magician") + await http_server.switch_character( + game_room, clients[0].player_id, character="driver" + ) + await clients[0].verify_character_update(clients[0], "driver") + await clients[1].verify_character_update(clients[0], "driver") + + await http_server.set_difficulty(game_room, level="expert") + await clients[0].verify_difficulty("expert") + await clients[1].verify_difficulty("expert") + await http_server.set_difficulty(game_room, level="standard") + await clients[0].verify_difficulty("standard") + await clients[1].verify_difficulty("standard") + + for client in clients: + await client.leave(room_id=game_room) + await client.disconnect() + await http_server.disconnect() diff --git a/backend/tests/unit/usecase/test_game.py b/backend/tests/unit/usecase/test_game.py index 28c3b35..55e0c3b 100644 --- a/backend/tests/unit/usecase/test_game.py +++ b/backend/tests/unit/usecase/test_game.py @@ -121,6 +121,6 @@ async def test_ok(self): repository = MockGameRepository(mock_fetched=mockgame) settings = {"host": "unit.test.app.com"} data = UpdateDifficultyDto(level="standard") - uc = UpdateGameDifficultyUseCase(repository, settings) + uc = UpdateGameDifficultyUseCase(repository, settings=settings) resp = await uc.execute(mockgame.id, data.level) assert resp.message is not None