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 \ --port 8010 \
--workers=1 \ --workers=1 \
--reload --reload
run-ws:
@poetry run \
python ws_server.py

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

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

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,25 +1,29 @@
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()
@ -43,7 +47,9 @@ 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()
@ -58,18 +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()
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)
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:
@ -81,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()
@ -90,13 +99,16 @@ 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!")
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:
@ -113,12 +125,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,
@ -135,16 +149,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

@ -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:
@ -30,7 +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 ws_server: WSServerSettings
log_level: int = logging.INFO
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

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,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.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,13 +1,18 @@
import logging
from hopper.models.config import ( from hopper.models.config import (
BoardSettings, BoardSettings,
GameSettings,
InactivityWatchdogSettings, InactivityWatchdogSettings,
Settings, Settings,
WSServerSettings, WSServerSettings,
) )
settings = Settings( settings = Settings(
game=GameSettings(),
board=BoardSettings(), board=BoardSettings(),
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO,
ws_server=WSServerSettings(), ws_server=WSServerSettings(),
debug=None, 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")