9 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
15 changed files with 390 additions and 157 deletions

View File

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

154
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
},
"players": [
{
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero",
"destination": {
"position": {
"x": 0,
"x": 10,
"y": 10
}
},
"players": [
{
"id": "04793b36-0785-4bf3-9396-3585c358cbac",
"name": "Mirko",
"uuid": "test-player-id",
"name": "Pero",
"active": true,
"position": {
"x": 11,
"y": 12
}
"x": 2,
"y": 2
},
"move_count": 3,
"move_attempt_count": 3
},
{
"uuid": "95962b49-0003-4bf2-b205-71f2590f2318",
"name": "Mirko",
"active": true,
"position": {
"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

@ -24,6 +24,7 @@ class PositionDto(BaseModel):
class PlayerDto(BaseModel):
uuid: str
name: str
active: bool
position: PositionDto
move_count: int

View File

@ -15,7 +15,6 @@ from hopper.api.dto import (
from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.ws_client import ws_send_game_state
router = APIRouter()

View File

@ -1,25 +1,29 @@
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,
LayerObject,
ObjectType,
create_random_position, BoardLayout,
create_random_position,
)
from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog
from hopper.ws_client import ws_send_game_state
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()
@ -43,7 +47,9 @@ 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()
@ -58,18 +64,19 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}")
self.__debug_print_board()
await ws_send_game_state()
if self.ws_server:
await self.ws_server.send_game_state()
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()
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:
@ -81,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()
@ -90,13 +99,16 @@ class GameEngine:
player.position = new_position
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):
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:
@ -113,12 +125,14 @@ class GameEngine:
def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players)
class GameEngineFactory:
@staticmethod
def create(
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[WSServer] = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
@ -135,16 +149,20 @@ 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

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:
@ -30,7 +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

@ -3,15 +3,19 @@ import datetime
import logging
import time
from threading import Thread
from typing import Optional
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
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)
@ -57,8 +61,10 @@ class InactivityWatchdog(Thread):
self.send_game_state()
def send_game_state(self):
if not self.ws_server:
return
logging.info("Sending WS game state")
asyncio.run(ws_send_game_state())
asyncio.run(self.ws_server.send_game_state())
def stop(self) -> None:
self.stopped = True

View File

@ -1,34 +0,0 @@
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}")

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,13 +1,18 @@
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,
)

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=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")