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

View File

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

View File

@ -3,15 +3,12 @@ from starlette import status
from hopper.api.dependencies import get_game_engine
from hopper.api.dto import (
BoardDto,
DestinationDto,
ErrorResponseDto,
GameInfoDto,
MovePlayerResponseDto,
PingResponse,
PlayerDto,
PlayerInfoResponseDto,
PositionDto,
StartGameRequestDto,
StartGameResponseDto,
)
@ -34,9 +31,9 @@ async def get_game_info(
engine: GameEngine = Depends(get_game_engine),
) -> GameInfoDto:
return GameInfoDto(
board=BoardDto.from_model(engine.board),
board=engine.board,
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,
engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto:
new_player = engine.start_game(player_name=body.player_name)
new_player = await engine.start_game(player_name=body.player_name)
return StartGameResponseDto(
board=BoardDto.from_model(engine.board),
player=PlayerDto.from_model(new_player),
board=engine.board,
player=new_player,
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,
detail="Player kicked out due to inactivity",
)
return PlayerInfoResponseDto(player=PlayerDto.from_model(player))
return PlayerInfoResponseDto(player=player)
@router.post(
@ -116,7 +113,7 @@ async def move_player(
)
try:
move_result = engine.move_player(player, direction)
move_result = await engine.move_player(player, direction)
except PositionOutOfBounds:
raise HTTPException(
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:
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
POST http://localhost:8010/game
Content-Type: application/json
{
"player_name": "Mirko"

View File

@ -1,9 +1,12 @@
import asyncio
import logging
from typing import Optional
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
Destination,
GameBoard,
Layer,
@ -13,12 +16,14 @@ from hopper.models.board import (
)
from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer
from settings import settings
class GameEngine:
def __init__(self, board: GameBoard) -> None:
def __init__(self, board: GameBoard, ws_server: Optional[WSServer] = None) -> None:
self.board = board
self.ws_server = ws_server
self.players = PlayerList()
self._inacivity_watchdog = None
self.__debug_print_board()
@ -42,11 +47,13 @@ class GameEngine:
def _start_inactivity_watchdog(self) -> None:
if not self._inacivity_watchdog:
self._inacivity_watchdog = InactivityWatchdog(
players=self.players, daemon=True
players=self.players,
ws_server=self.ws_server,
daemon=True,
)
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()
player = Player(
name=player_name,
@ -57,16 +64,19 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}")
self.__debug_print_board()
if self.ws_server:
await self.ws_server.send_game_state()
return player
def move_player(self, player: Player, direction: Direction) -> PlayerMoveResult:
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
player.move_attempt_count += 1
if direction == Direction.LEFT:
new_position.x -= 1
elif direction == Direction.RIGHT:
@ -78,6 +88,8 @@ class GameEngine:
else:
raise ValueError(f"Unhandled direction: {direction}")
player.move_attempt_count += 1
if not self.position_in_board_bounds(new_position):
raise PositionOutOfBounds()
@ -87,11 +99,16 @@ class GameEngine:
player.position = new_position
player.move_count += 1
if self.ws_server:
await self.ws_server.send_game_state()
if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board()
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK
def is_player_on_destination(self, player: Player) -> bool:
@ -105,6 +122,9 @@ class GameEngine:
def colided_with_obstacle(self, position: Position) -> bool:
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:
@staticmethod
@ -112,6 +132,7 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[WSServer] = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
@ -128,26 +149,32 @@ class GameEngineFactory:
)
board.layers.append(obstacle_layer)
game = GameEngine(board=board)
game = GameEngine(
board=board,
ws_server=ws_server,
)
GameEngineFactory.__add_test_player(game.players)
return game
@staticmethod
def create_default() -> GameEngine:
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT,
obstacle_count=settings.board.OBSTACLE_COUNT,
ws_server=ws_server,
)
@staticmethod
def __add_test_player(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER):
return
player = Player(
name="Pero",
uuid="test-player-id",
position=Position(2, 2),
can_be_deactivated=False,
)
players.append(player)
logging.info(f"Test player created: {player}")

View File

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

View File

@ -1,6 +1,10 @@
import logging
from dataclasses import dataclass
from typing import Optional
@dataclass
class GameSettings:
MOVE_DELAY: int = 0.5 # seconds
@dataclass
class BoardSettings:
@ -16,6 +20,12 @@ class InactivityWatchdogSettings:
TICK_INTERVAL: int = 1 # seconds
@dataclass
class WSServerSettings:
HOST: str = "localhost"
PORT: int = 8011
@dataclass
class DebugSettings:
PRINT_BOARD: bool = False
@ -24,6 +34,9 @@ class DebugSettings:
@dataclass
class Settings:
game: GameSettings
board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings
log_level: int = logging.INFO
debug: Optional[DebugSettings] = None

View File

@ -21,6 +21,7 @@ class Player:
default_factory=lambda: datetime.datetime.now()
)
active: bool = True
can_be_deactivated: bool = True
def reset_timeout(self) -> None:
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 logging
import time
from threading import Thread
from typing import Optional
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
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.ws_server = ws_server
self.stopped = False
super().__init__(*args, **kwargs)
@ -28,20 +34,37 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT
)
send_game_state = False
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
logging.info(f"Player {player} set as inactive")
send_game_state = True
# safe remove from list
n = 0
while n < len(self.players):
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)
logging.info(f"Player {player} kicked out")
send_game_state = True
else:
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:
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.views import router
from settings import settings
logging.basicConfig(
level=logging.DEBUG,
level=settings.log_level,
format="%(asctime)s %(levelname)s - %(message)s",
)
logging.info("JFK Game server started.")
logging.info("FairHopper Game Server started.")
app = FastAPI()
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(
game=GameSettings(),
board=BoardSettings(),
inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO,
ws_server=WSServerSettings(),
debug=None,
)