4 Commits

Author SHA1 Message Date
ee1ce125ff WS error handling 2023-03-25 15:58:24 +01:00
4b511c0cb8 WS send game state 2023-03-25 15:54:23 +01:00
0f0fe68890 WS DTO assign rework 2023-03-25 15:27:15 +01:00
9aabcf61f4 WS Server 2023-03-25 14:10:33 +01:00
13 changed files with 177 additions and 37 deletions

View File

@ -14,3 +14,7 @@ run-dev:
--port 8010 \ --port 8010 \
--workers=1 \ --workers=1 \
--reload --reload
run-ws:
@poetry run \
python ws_server.py

View File

@ -2,9 +2,6 @@ from __future__ import annotations
from pydantic import BaseModel as PydanticBaseModel from pydantic import BaseModel as PydanticBaseModel
from hopper.models.board import GameBoard
from hopper.models.player import Player, Position
class BaseModel(PydanticBaseModel): class BaseModel(PydanticBaseModel):
class Config: class Config:
@ -19,30 +16,19 @@ class BoardDto(BaseModel):
width: int width: int
height: int height: int
@staticmethod
def from_model(board: GameBoard) -> BoardDto:
return BoardDto.from_orm(board)
class PositionDto(BaseModel): class PositionDto(BaseModel):
x: int x: int
y: int y: int
@staticmethod
def from_model(position: Position) -> PositionDto:
return PositionDto.from_orm(position)
class PlayerDto(BaseModel): class PlayerDto(BaseModel):
uuid: str uuid: str
active: bool
position: PositionDto position: PositionDto
move_count: int move_count: int
move_attempt_count: int move_attempt_count: int
@staticmethod
def from_model(player: Player) -> PlayerDto:
return PlayerDto.from_orm(player)
class DestinationDto(BaseModel): class DestinationDto(BaseModel):
position: PositionDto position: PositionDto

View File

@ -3,21 +3,19 @@ from starlette import status
from hopper.api.dependencies import get_game_engine from hopper.api.dependencies import get_game_engine
from hopper.api.dto import ( from hopper.api.dto import (
BoardDto,
DestinationDto, DestinationDto,
ErrorResponseDto, ErrorResponseDto,
GameInfoDto, GameInfoDto,
MovePlayerResponseDto, MovePlayerResponseDto,
PingResponse, PingResponse,
PlayerDto,
PlayerInfoResponseDto, PlayerInfoResponseDto,
PositionDto,
StartGameRequestDto, StartGameRequestDto,
StartGameResponseDto, StartGameResponseDto,
) )
from hopper.engine import GameEngine from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds from hopper.errors import Collision, PositionOutOfBounds
from hopper.ws_client import ws_send_game_state
router = APIRouter() router = APIRouter()
@ -34,9 +32,9 @@ async def get_game_info(
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
) -> GameInfoDto: ) -> GameInfoDto:
return GameInfoDto( return GameInfoDto(
board=BoardDto.from_model(engine.board), board=engine.board,
destination=DestinationDto( destination=DestinationDto(
position=PositionDto.from_model(engine.board.destination.position) position=engine.board.destination.position,
), ),
) )
@ -46,13 +44,13 @@ async def start_game(
body: StartGameRequestDto, body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto: ) -> StartGameResponseDto:
new_player = engine.start_game(player_name=body.player_name) new_player = await engine.start_game(player_name=body.player_name)
return StartGameResponseDto( return StartGameResponseDto(
board=BoardDto.from_model(engine.board), board=engine.board,
player=PlayerDto.from_model(new_player), player=new_player,
destination=DestinationDto( destination=DestinationDto(
position=PositionDto.from_model(engine.board.destination.position) position=engine.board.destination.position,
), ),
) )
@ -76,7 +74,7 @@ async def get_player_info(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity", detail="Player kicked out due to inactivity",
) )
return PlayerInfoResponseDto(player=PlayerDto.from_model(player)) return PlayerInfoResponseDto(player=player)
@router.post( @router.post(
@ -116,7 +114,7 @@ async def move_player(
) )
try: try:
move_result = engine.move_player(player, direction) move_result = await engine.move_player(player, direction)
except PositionOutOfBounds: except PositionOutOfBounds:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Position out of bounds" status_code=status.HTTP_409_CONFLICT, detail="Position out of bounds"
@ -129,4 +127,4 @@ async def move_player(
if move_result == PlayerMoveResult.DESTINATION_REACHED: if move_result == PlayerMoveResult.DESTINATION_REACHED:
response.status_code = status.HTTP_200_OK response.status_code = status.HTTP_200_OK
return MovePlayerResponseDto(player=PlayerDto.from_model(player)) return MovePlayerResponseDto(player=player)

View File

@ -3,6 +3,7 @@ GET http://localhost:8010/ping
# create new game # create new game
POST http://localhost:8010/game POST http://localhost:8010/game
Content-Type: application/json
{ {
"player_name": "Mirko" "player_name": "Mirko"

View File

@ -9,10 +9,11 @@ from hopper.models.board import (
Layer, Layer,
LayerObject, LayerObject,
ObjectType, ObjectType,
create_random_position, create_random_position, BoardLayout,
) )
from hopper.models.player import Player, PlayerList, Position from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog from hopper.watchdog import InactivityWatchdog
from hopper.ws_client import ws_send_game_state
from settings import settings from settings import settings
@ -46,7 +47,7 @@ class GameEngine:
) )
self._inacivity_watchdog.start() self._inacivity_watchdog.start()
def start_game(self, player_name: str) -> Player: async def start_game(self, player_name: str) -> Player:
self._start_inactivity_watchdog() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
@ -57,9 +58,11 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}") logging.info(f"Starting new game for player: {player}")
self.__debug_print_board() self.__debug_print_board()
await ws_send_game_state()
return player return player
def move_player(self, player: Player, direction: Direction) -> PlayerMoveResult: async def move_player(self, player: Player, direction: Direction) -> PlayerMoveResult:
player.reset_timeout() player.reset_timeout()
new_position = Position(player.position.x, player.position.y) new_position = Position(player.position.x, player.position.y)
@ -87,6 +90,8 @@ class GameEngine:
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
await ws_send_game_state()
if self.is_player_on_destination(player): if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!") logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
@ -105,6 +110,8 @@ class GameEngine:
def colided_with_obstacle(self, position: Position) -> bool: def colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None return self.board.get_object_at_position(position) is not None
def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players)
class GameEngineFactory: class GameEngineFactory:
@staticmethod @staticmethod
@ -144,10 +151,12 @@ class GameEngineFactory:
def __add_test_player(players: PlayerList) -> None: def __add_test_player(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER): if not (settings.debug and settings.debug.CREATE_TEST_PLAYER):
return return
player = Player( player = Player(
name="Pero", name="Pero",
uuid="test-player-id", uuid="test-player-id",
position=Position(2, 2), position=Position(2, 2),
can_be_deactivated=False,
) )
players.append(player) players.append(player)
logging.info(f"Test player created: {player}") logging.info(f"Test player created: {player}")

View File

@ -9,10 +9,10 @@ class Direction(Enum):
class ObjectType(str, Enum): class ObjectType(str, Enum):
NONE = auto() NONE = "NONE"
OBSTACLE = auto() OBSTACLE = "OBSTACLE"
PLAYER = auto() PLAYER = "PLAYER"
DESTINATION = auto() DESTINATION = "DESTINATION"
class PlayerMoveResult(Enum): class PlayerMoveResult(Enum):

View File

@ -16,6 +16,12 @@ class InactivityWatchdogSettings:
TICK_INTERVAL: int = 1 # seconds TICK_INTERVAL: int = 1 # seconds
@dataclass
class WSServerSettings:
HOST: str = "localhost"
PORT: int = 8011
@dataclass @dataclass
class DebugSettings: class DebugSettings:
PRINT_BOARD: bool = False PRINT_BOARD: bool = False
@ -26,4 +32,5 @@ class DebugSettings:
class Settings: class Settings:
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

View File

@ -21,6 +21,7 @@ class Player:
default_factory=lambda: datetime.datetime.now() default_factory=lambda: datetime.datetime.now()
) )
active: bool = True active: bool = True
can_be_deactivated: bool = True
def reset_timeout(self) -> None: def reset_timeout(self) -> None:
self.last_seen = datetime.datetime.now() self.last_seen = datetime.datetime.now()

23
hopper/models/ws_dto.py Normal file
View File

@ -0,0 +1,23 @@
from __future__ import annotations
from pydantic import Field
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.enums import ObjectType
class LayerObjectDto(BaseModel):
type: ObjectType = Field(..., alias="type_")
position: PositionDto
class LayerDto(BaseModel):
name: str
objects: list[LayerObjectDto]
class GameStateDto(BaseModel):
board: BoardDto
destination: DestinationDto
players: list[PlayerDto]
layers: list[LayerDto]

View File

@ -1,9 +1,11 @@
import asyncio
import datetime import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_client import ws_send_game_state
from settings import settings from settings import settings
@ -28,20 +30,35 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT seconds=settings.inacivity_watchdog.KICK_TIMEOUT
) )
send_game_state = False
for player in self.players: for player in self.players:
if player.active and player.last_seen < inactivity_threshold: if (
player.can_be_deactivated
and player.active
and player.last_seen < inactivity_threshold
):
player.active = False player.active = False
logging.info(f"Player {player} set as inactive") logging.info(f"Player {player} set as inactive")
send_game_state = True
# safe remove from list # safe remove from list
n = 0 n = 0
while n < len(self.players): while n < len(self.players):
player = self.players[n] player = self.players[n]
if player.last_seen < kick_threshold: if player.can_be_deactivated and player.last_seen < kick_threshold:
self.players.pop(n) self.players.pop(n)
logging.info(f"Player {player} kicked out") logging.info(f"Player {player} kicked out")
send_game_state = True
else: else:
n += 1 n += 1
if send_game_state:
self.send_game_state()
def send_game_state(self):
logging.info("Sending WS game state")
asyncio.run(ws_send_game_state())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stopped = True

34
hopper/ws_client.py Normal file
View File

@ -0,0 +1,34 @@
import json
import logging
from contextlib import asynccontextmanager
import websockets
from hopper.models.ws_dto import GameStateDto
from settings import settings
@asynccontextmanager
async def create_ws_client() -> websockets.WebSocketServerProtocol:
ws_uri = f"ws://{settings.ws_server.HOST}:{settings.ws_server.PORT}"
async with websockets.connect(uri=ws_uri) as websocket:
yield websocket
async def ws_send_game_state() -> None:
# avoid circular imports
from hopper.api.dependencies import get_game_engine
try:
async with create_ws_client() as websocket:
engine = get_game_engine()
game_state = GameStateDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
await websocket.send(json.dumps(game_state.dict()))
except OSError as ex:
logging.error(f"Error sending WS state: {ex}")

View File

@ -1,7 +1,13 @@
from hopper.models.config import BoardSettings, InactivityWatchdogSettings, Settings from hopper.models.config import (
BoardSettings,
InactivityWatchdogSettings,
Settings,
WSServerSettings,
)
settings = Settings( settings = Settings(
board=BoardSettings(), board=BoardSettings(),
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
ws_server=WSServerSettings(),
debug=None, debug=None,
) )

54
ws_server.py Normal file
View File

@ -0,0 +1,54 @@
import asyncio
import logging
import websockets
from websockets import WebSocketServerProtocol, broadcast
from settings import settings
connected_clients = set[WebSocketServerProtocol]()
def setup_logging() -> None:
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)s - %(message)s",
)
async def ws_handler(websocket: WebSocketServerProtocol):
connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
async for message in websocket:
logging.debug(f"Received message: {message}")
broadcast_clients = [client for client in connected_clients if client.id != websocket.id]
if broadcast_clients:
logging.debug(f"Broadcast message to clients: {broadcast_clients}")
broadcast(connected_clients, message)
finally:
connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}")
async def main():
setup_logging()
logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}"
)
async with websockets.serve(
ws_handler=ws_handler,
host=settings.ws_server.HOST,
port=settings.ws_server.PORT,
):
await asyncio.Future() # run forever
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logging.info(f"FairHopper Websockets Server terminated")