13 Commits

Author SHA1 Message Date
ed4d61b37b Frontend 2023-03-25 18:45:31 +01:00
1b745c756f Integrated WS server 2023-03-25 17:23:00 +01:00
8971c64713 Update readme 2023-03-25 16:33:49 +01:00
f8506a66ba Update readme 2023-03-25 16:30:17 +01:00
894d2b0707 Player move delay 2023-03-25 16:24:54 +01:00
ee4d841cae Readme update 2023-03-25 16:18:02 +01:00
8bc8a37edd WS game-state URI 2023-03-25 16:17:24 +01:00
245dc75211 Update readme 2023-03-25 16:07:53 +01:00
395457b2db Configurable log level 2023-03-25 16:03:04 +01:00
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
16 changed files with 466 additions and 93 deletions

148
README.md
View File

@ -53,7 +53,7 @@ Edit `settings.py` and customize application.
make run make run
``` ```
By default, JFK runs on port **8010**. To run on other port, start `uvicorn` directly: By default, FairHopper runs on port **8010**. To run FairHopper on different port, start `uvicorn` directly:
```sh ```sh
poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1 poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1
``` ```
@ -63,6 +63,8 @@ To activate virtual environment:
poetry shell poetry shell
``` ```
WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration.
## System overview ## System overview
@ -74,31 +76,31 @@ actor "Player 2" as P2
actor "Player 3" as P3 actor "Player 3" as P3
package Masterpiece { package Masterpiece {
usecase JFK as "JFK Game Server" usecase Game as "FairHopper\nGame Server"
usecase WS as "WS Server" usecase WS as "WS Server"
usecase Vis as "Visualisation\nService" usecase Vis as "Visualisation\nService"
} }
P1 -left-> JFK: REST API P1 -left-> Game: REST API
P2 -left-> JFK: REST API P2 -left-> Game: REST API
P3 -left-> JFK: REST API P3 -left-> Game: REST API
JFK --> WS: WebSockets Game --> WS: WebSockets
WS --> Vis: WebSockets WS --> Vis: WebSockets
``` ```
### WebSockets ### WebSockets
```plantuml ```plantuml
participant JFK as "JFK Game Server" participant Game as "FairHopper\nGame Server"
participant WS as "WS Server" participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2" participant Client2 as "Visualisation\nClient 2"
JFK ->o WS: Server Connect Game ->o WS: Server Connect
activate WS #coral activate WS #coral
WS -> JFK: Get game state WS -> Game: Get game state
activate JFK #yellow activate Game #yellow
JFK -> WS: Game state Game -> WS: Game state
deactivate deactivate
deactivate deactivate
@ -113,7 +115,7 @@ WS --> Vis: WebSockets
deactivate deactivate
loop #lightyellow On game state change loop #lightyellow On game state change
JFK ->o WS: Game state Game ->o WS: Game state
activate WS #coral activate WS #coral
WS o-> Client1: Game state WS o-> Client1: Game state
WS o-> Client2: Game state WS o-> Client2: Game state
@ -156,10 +158,11 @@ Response body:
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, }
}, },
"player": { "player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero",
"position": { "position": {
"x": 0, "x": 0,
"y": 10 "y": 10
@ -172,10 +175,10 @@ Response body:
### Player Move ### Player Move
POST `/player/{uuid}/move/left` - POST `/player/{uuid}/move/left`
POST `/player/{uuid}/move/right` - POST `/player/{uuid}/move/right`
POST `/player/{uuid}/move/up` - POST `/player/{uuid}/move/up`
POST `/player/{uuid}/move/down` - POST `/player/{uuid}/move/down`
Request body: None Request body: None
@ -191,6 +194,7 @@ Response body:
{ {
"player": { "player": {
"uuid": "string", "uuid": "string",
"name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
@ -212,6 +216,7 @@ Response body:
{ {
"player": { "player": {
"uuid": "string", "uuid": "string",
"name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
@ -250,45 +255,46 @@ Response body:
### WS Data format ### WS Data format
- json - json
General data format:
```json
{
"command": "command",
"data": {}
}
```
### Game info structure ### Game state structure
Command: `gameInfo` URI: `/game-state`
Data: Data:
```json ```json
{ {
"board": { "board": {
"width": 101, "width": 21,
"height": 101 "height": 21
}, },
"destinationPosition": { "destination": {
"x": 50, "position": {
"y": 50 "x": 10,
"y": 10
}
}, },
"players": [ "players": [
{ {
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "uuid": "test-player-id",
"name": "Pero", "name": "Pero",
"active": true,
"position": { "position": {
"x": 0, "x": 2,
"y": 10 "y": 2
} },
"move_count": 3,
"move_attempt_count": 3
}, },
{ {
"id": "04793b36-0785-4bf3-9396-3585c358cbac", "uuid": "95962b49-0003-4bf2-b205-71f2590f2318",
"name": "Mirko", "name": "Mirko",
"active": true,
"position": { "position": {
"x": 11, "x": 0,
"y": 12 "y": 0
} },
"move_count": 15,
"move_attempt_count": 20
} }
], ],
"layers": [ "layers": [
@ -296,17 +302,69 @@ Data:
"name": "obstacles", "name": "obstacles",
"objects": [ "objects": [
{ {
"type": "obstacle", "type": "OBSTACLE",
"position": { "position": {
"x": 15, "x": 4,
"y": 25 "y": 2
} }
}, },
{ {
"type": "obstacle", "type": "OBSTACLE",
"position": { "position": {
"x": 33, "x": 4,
"y": 44 "y": 13
}
},
{
"type": "OBSTACLE",
"position": {
"x": 18,
"y": 18
}
},
{
"type": "OBSTACLE",
"position": {
"x": 5,
"y": 4
}
},
{
"type": "OBSTACLE",
"position": {
"x": 7,
"y": 10
}
}
]
},
{
"name": "destination",
"objects": [
{
"type": "DESTINATION",
"position": {
"x": 10,
"y": 10
}
}
]
},
{
"name": "players",
"objects": [
{
"type": "PLAYER",
"position": {
"x": 2,
"y": 2
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
} }
} }
] ]

120
frontend/index.html Normal file
View File

@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css">
<title>Document</title>
</head>
<body>
<div class="container-fluid">
<h1>FairHopper WS Client</h1>
<div class="row">
<div class="col-10">
<div class="board-container">
<div id="board-content"></div>
</div>
</div>
<div class="col-2">
<h3>Players</h3>
<ul id="players-content"></ul>
</div>
</div>
</div>
</body>
<script>
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function renderCellContent(x, y, content) {
const cell = document.getElementById(`cell-${x}-${y}`);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.map((player) => {
return `
<li>${player.name} (${player.move_count})</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎");
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach(layer => {
objects = objects.concat(layer.objects.filter(obj => obj.type === type))
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position.x, obj.position.y, "🔥");
});
}
function renderDestination(position) {
renderCellContent(position.x, position.y, "🏠");
}
window.onload = function () {
const ws = new WebSocket('ws://localhost:8011/bla-tra');
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log("message received:", data)
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
ws.onopen = function () {
console.log("open");
}
ws.onclose = function () {
console.log("close");
}
ws.onerror = function () {
console.log("error");
}
}
</script>
</html>

20
frontend/styles.css Normal file
View File

@ -0,0 +1,20 @@
body {
background-color: whitesmoke;
}
.board-container {
background-color: white;
border: 1px solid black;
}
.flex-grid {
display: flex;
justify-content: space-between;
grid-gap: 2px;
padding-bottom: 2px;
}
.cell {
flex: 1;
text-align: center;
background-color: beige;
}

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

@ -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,20 @@ 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
name: 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,15 +3,12 @@ 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,
) )
@ -34,9 +31,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 +43,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 +73,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 +113,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 +126,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

@ -1,9 +1,12 @@
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,
@ -13,12 +16,14 @@ from hopper.models.board import (
) )
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_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()
@ -42,11 +47,13 @@ class GameEngine:
def _start_inactivity_watchdog(self) -> None: def _start_inactivity_watchdog(self) -> None:
if not self._inacivity_watchdog: if not self._inacivity_watchdog:
self._inacivity_watchdog = InactivityWatchdog( self._inacivity_watchdog = InactivityWatchdog(
players=self.players, daemon=True players=self.players,
ws_server=self.ws_server,
daemon=True,
) )
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,16 +64,19 @@ 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()
if self.ws_server:
await self.ws_server.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)
logging.info(f"Player {player} move to {direction}") logging.info(f"Player {player} move to {direction}")
player.move_attempt_count += 1
if direction == Direction.LEFT: if direction == Direction.LEFT:
new_position.x -= 1 new_position.x -= 1
elif direction == Direction.RIGHT: elif direction == Direction.RIGHT:
@ -78,6 +88,8 @@ class GameEngine:
else: else:
raise ValueError(f"Unhandled direction: {direction}") raise ValueError(f"Unhandled direction: {direction}")
player.move_attempt_count += 1
if not self.position_in_board_bounds(new_position): if not self.position_in_board_bounds(new_position):
raise PositionOutOfBounds() raise PositionOutOfBounds()
@ -87,11 +99,16 @@ class GameEngine:
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
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!")
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board() self.__debug_print_board()
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK return PlayerMoveResult.OK
def is_player_on_destination(self, player: Player) -> bool: def is_player_on_destination(self, player: Player) -> bool:
@ -105,6 +122,9 @@ 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
@ -112,6 +132,7 @@ class GameEngineFactory:
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,
@ -128,26 +149,32 @@ 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
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

@ -1,6 +1,10 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
@dataclass
class GameSettings:
MOVE_DELAY: int = 0.5 # seconds
@dataclass @dataclass
class BoardSettings: class BoardSettings:
@ -16,6 +20,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
@ -24,6 +34,9 @@ class DebugSettings:
@dataclass @dataclass
class Settings: class Settings:
game: GameSettings
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings
log_level: int = logging.INFO
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,15 +1,21 @@
import asyncio
import datetime 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_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)
@ -28,20 +34,37 @@ 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):
if not self.ws_server:
return
logging.info("Sending WS game state")
asyncio.run(self.ws_server.send_game_state())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stopped = True

80
hopper/ws_server.py Normal file
View File

@ -0,0 +1,80 @@
import asyncio
import json
import logging
from threading import Thread
import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK
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 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)
connected = True
while connected:
try:
message = await websocket.recv()
except ConnectionClosedOK:
connected = False
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.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

@ -4,12 +4,13 @@ from fastapi import FastAPI
from hopper.api.dependencies import create_game_engine from hopper.api.dependencies import create_game_engine
from hopper.api.views import router from hopper.api.views import router
from settings import settings
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=settings.log_level,
format="%(asctime)s %(levelname)s - %(message)s", format="%(asctime)s %(levelname)s - %(message)s",
) )
logging.info("JFK Game server started.") logging.info("FairHopper Game Server started.")
app = FastAPI() app = FastAPI()
app.include_router(router, tags=["Game API"]) app.include_router(router, tags=["Game API"])

View File

@ -1,7 +1,18 @@
from hopper.models.config import BoardSettings, InactivityWatchdogSettings, Settings import logging
from hopper.models.config import (
BoardSettings,
GameSettings,
InactivityWatchdogSettings,
Settings,
WSServerSettings,
)
settings = Settings( settings = Settings(
game=GameSettings(),
board=BoardSettings(), board=BoardSettings(),
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO,
ws_server=WSServerSettings(),
debug=None, debug=None,
) )