Compare commits
9 Commits
ee1ce125ff
...
integrated
| Author | SHA1 | Date | |
|---|---|---|---|
| ed4d61b37b | |||
| 1b745c756f | |||
| 8971c64713 | |||
| f8506a66ba | |||
| 894d2b0707 | |||
| ee4d841cae | |||
| 8bc8a37edd | |||
| 245dc75211 | |||
| 395457b2db |
4
Makefile
4
Makefile
@ -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
148
README.md
@ -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
120
frontend/index.html
Normal 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}"> </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
20
frontend/styles.css
Normal 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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
80
hopper/ws_server.py
Normal 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())
|
||||||
5
main.py
5
main.py
@ -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"])
|
||||||
|
|||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
54
ws_server.py
54
ws_server.py
@ -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")
|
|
||||||
Reference in New Issue
Block a user