Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API endpoint for starting a game #58

Merged
merged 3 commits into from
Feb 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions backend/app/adapter/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion backend/app/adapter/event_emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions backend/app/adapter/sio_srv.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
RtcInitMsgData,
RtcCharacterMsgData,
RtcDifficultyMsgData,
RtcGameStartMsgData,
)
from app.domain import GameError, GameErrorCodes, GameFuncCodes

Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions backend/app/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class GameRtcEvent(Enum):
NEW_ROOM = "new_room"
CHARACTER = "character"
DIFFICULTY = "difficulty"
GAME_START = "game_start"


class RealTimeCommConst:
Expand Down
27 changes: 27 additions & 0 deletions backend/app/domain/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
22 changes: 17 additions & 5 deletions backend/app/domain/player.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions backend/app/dto/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
Investigator as InvestigatorFbs,
DifficultyConfig,
Difficulty as DifficultyFbs,
GameStart as GameStartFbs,
)

# data transfer objects (DTO) in the application
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
5 changes: 5 additions & 0 deletions backend/app/dto/rtc.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ table DifficultyConfig {
level: Difficulty;
}

table GameStart {
game_id: string (required);
player_id: string (required);
}

78 changes: 78 additions & 0 deletions backend/app/dto/rtc/GameStart.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions backend/app/usecase/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
GetAvailableInvestigatorsUseCase, # noqa: F401
SwitchInvestigatorUseCase, # noqa: F401
UpdateGameDifficultyUseCase, # noqa: F401
GameStartUseCase, # noqa: F401
)
14 changes: 14 additions & 0 deletions backend/app/usecase/config_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
33 changes: 29 additions & 4 deletions backend/tests/e2e/test_game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading