Integrated WS server

This commit is contained in:
Eden Kirin
2023-03-25 17:23:00 +01:00
parent 8971c64713
commit 1b745c756f
8 changed files with 117 additions and 108 deletions

View File

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

View File

@ -1,11 +1,22 @@
from hopper.engine import GameEngine, GameEngineFactory from typing import Optional
game_engine: GameEngine from hopper.engine import GameEngine, GameEngineFactory
from hopper.ws_server import WSServer
game_engine: Optional[GameEngine] = None
def create_game_engine() -> GameEngine: def create_game_engine() -> GameEngine:
global game_engine global game_engine
game_engine = GameEngineFactory.create_default()
if game_engine:
raise RuntimeError("Can't call create_game_engine() more than once!")
ws_server = WSServer(daemon=True)
ws_server.start()
game_engine = GameEngineFactory.create_default(ws_server=ws_server)
return game_engine return game_engine

View File

@ -15,7 +15,6 @@ from hopper.api.dto import (
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()

View File

@ -1,26 +1,29 @@
import asyncio import asyncio
import logging import logging
from typing import Optional
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.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout,
Destination, Destination,
GameBoard, GameBoard,
Layer, Layer,
LayerObject, LayerObject,
ObjectType, ObjectType,
create_random_position, BoardLayout, create_random_position,
) )
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 hopper.ws_server import WSServer
from settings import settings from settings import settings
class GameEngine: class GameEngine:
def __init__(self, board: GameBoard) -> None: def __init__(self, board: GameBoard, ws_server: Optional[WSServer] = None) -> None:
self.board = board self.board = board
self.ws_server = ws_server
self.players = PlayerList() self.players = PlayerList()
self._inacivity_watchdog = None self._inacivity_watchdog = None
self.__debug_print_board() self.__debug_print_board()
@ -59,11 +62,14 @@ 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() if self.ws_server:
await self.ws_server.send_game_state()
return player return player
async 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)
@ -91,7 +97,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.ws_server:
await self.ws_server.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!")
@ -116,12 +123,14 @@ class GameEngine:
def get_board_layout(self) -> BoardLayout: def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players) return BoardLayout(board=self.board, players=self.players)
class GameEngineFactory: class GameEngineFactory:
@staticmethod @staticmethod
def create( def create(
board_width: int, board_width: int,
board_height: int, board_height: int,
obstacle_count: int = 0, obstacle_count: int = 0,
ws_server: Optional[WSServer] = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
@ -138,16 +147,20 @@ class GameEngineFactory:
) )
board.layers.append(obstacle_layer) board.layers.append(obstacle_layer)
game = GameEngine(board=board) game = GameEngine(
board=board,
ws_server=ws_server,
)
GameEngineFactory.__add_test_player(game.players) GameEngineFactory.__add_test_player(game.players)
return game return game
@staticmethod @staticmethod
def create_default() -> GameEngine: def create_default(ws_server: Optional[WSServer] = None) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT, board_height=settings.board.HEIGHT,
obstacle_count=settings.board.OBSTACLE_COUNT, obstacle_count=settings.board.OBSTACLE_COUNT,
ws_server=ws_server,
) )
@staticmethod @staticmethod

View File

@ -3,15 +3,19 @@ import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread
from typing import Optional
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_client import ws_send_game_state from hopper.ws_server import WSServer
from settings import settings from settings import settings
class InactivityWatchdog(Thread): class InactivityWatchdog(Thread):
def __init__(self, players: PlayerList, *args, **kwargs) -> None: def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs
) -> None:
self.players = players self.players = players
self.ws_server = ws_server
self.stopped = False self.stopped = False
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -57,8 +61,10 @@ class InactivityWatchdog(Thread):
self.send_game_state() self.send_game_state()
def send_game_state(self): def send_game_state(self):
if not self.ws_server:
return
logging.info("Sending WS game state") logging.info("Sending WS game state")
asyncio.run(ws_send_game_state()) asyncio.run(self.ws_server.send_game_state())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stopped = True

View File

@ -1,35 +0,0 @@
import json
import logging
from contextlib import asynccontextmanager
import websockets
from websockets.exceptions import ConnectionClosed
from hopper.models.ws_dto import GameStateDto
from settings import settings
@asynccontextmanager
async def create_ws_client(path: str) -> websockets.WebSocketServerProtocol:
ws_uri = f"ws://{settings.ws_server.HOST}:{settings.ws_server.PORT}{path}"
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("/game-state") 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, ConnectionClosed) as ex:
logging.error(f"Error sending WS state: {ex}")

73
hopper/ws_server.py Normal file
View File

@ -0,0 +1,73 @@
import asyncio
import json
import logging
from threading import Thread
import websockets
from websockets import WebSocketServerProtocol
from hopper.models.ws_dto import GameStateDto
from settings import settings
class WSServer(Thread):
def __init__(self, *args, **kwargs) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
super().__init__(*args, **kwargs)
async def ws_handler(self, websocket: WebSocketServerProtocol) -> None:
self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
await self.send_game_state_to_client(websocket)
finally:
self.connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}")
def _create_game_state_message(self) -> str:
# avoid circular imports
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
game_state = GameStateDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
return json.dumps(game_state.dict())
async def send_game_state_to_client(
self, websocket: WebSocketServerProtocol
) -> None:
message = self._create_game_state_message()
logging.debug(f"Sending game state to client: {websocket.id}")
await websocket.send(message)
async def send_game_state(self) -> None:
if not self.connected_clients:
return
message = self._create_game_state_message()
logging.debug(
f"Sending game state to clients: {self.connected_clients}: {message}"
)
for client in self.connected_clients:
await client.send(message)
async def run_async(self) -> None:
logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}"
)
async with websockets.serve(
ws_handler=self.ws_handler,
host=settings.ws_server.HOST,
port=settings.ws_server.PORT,
):
await asyncio.Future() # run forever
def run(self) -> None:
asyncio.run(self.run_async())

View File

@ -1,54 +0,0 @@
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=settings.log_level,
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 on {websocket.path}: {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")