17 Commits

Author SHA1 Message Date
ecffdc5d1e Player state 2023-03-30 13:09:23 +02:00
8a48d61dc9 Multiple test players 2023-03-30 12:05:58 +02:00
33f2220356 Game state 2023-03-30 11:44:20 +02:00
413e395a75 Change terminology game state -> game dump 2023-03-30 11:32:16 +02:00
48cb1a3798 Update readme 2023-03-29 09:35:38 +02:00
988878502c Hide inactive players from board dump 2023-03-28 09:45:49 +02:00
0e8775bd08 FE tweak 2023-03-27 14:28:15 +02:00
b5a49fb53b Add fairhopper-sdk as module 2023-03-27 13:55:52 +02:00
9acaf0c2c0 Remove SDK 2023-03-27 09:53:29 +02:00
870e2deb79 FE tweak 2023-03-27 09:52:51 +02:00
fa2aee881d Cleanups 2023-03-26 23:59:15 +02:00
f74bc9b52e FE tweaks 2023-03-26 20:00:35 +02:00
63e7e0d21c Update readme 2023-03-26 14:58:57 +02:00
4831f1e393 Update readme 2023-03-26 14:42:26 +02:00
806a379253 Demo SDK 2023-03-26 14:37:39 +02:00
f54344a17f Tweak frontend and game logic 2023-03-26 00:37:58 +01:00
3ac07f3072 Optimize views 2023-03-25 18:59:35 +01:00
20 changed files with 441 additions and 194 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "fairhopper-sdk"]
path = fairhopper-sdk
url = git@gitea.ekirin.com:Intis/fairhopper-sdk.git

View File

@ -21,6 +21,35 @@
- Move timeout: 10s. Game is finished if timeout ocurrs.
## Game States
```plantuml
hide empty description
state "Start Game" as StartGame
state "Move" as MovePlayer: Destination reached?
state "Destination Reached" as DestinationReached
state "Product Selection" as ProductSelection: Enable product selection for winning player
state "Product Selected" as ProductSelected
state "Selection Timeout" as SelectionTimeout
state "End Player's Game" as EndPlayer
state "Lock Game" as LockGame <<end>>
state "End Game" as EndGame <<end>>
state "Unlock game" as UnlockGame <<end>>
[*] -> StartGame
StartGame -> MovePlayer
MovePlayer <-- MovePlayer: NO
MovePlayer --> DestinationReached: YES
DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players
```
## FairHopper Game Server
Requirements:
@ -75,46 +104,51 @@ actor "Player 1" as P1
actor "Player 2" as P2
actor "Player 3" as P3
package Masterpiece {
usecase Game as "FairHopper\nGame Server"
usecase WS as "WS Server"
package Masterpiece #seashell {
rectangle "FairHopper Game Server" #lightcyan {
usecase API as "API Server"
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService"
}
P1 -left-> Game: REST API
P2 -left-> Game: REST API
P3 -left-> Game: REST API
Game --> WS: WebSockets
WS --> Vis: WebSockets
usecase ExtVis1 as "Visualisation\nService"
usecase ExtVis2 as "Visualisation\nService"
P1 -left-> API: REST API
P2 -left-> API: REST API
P3 -left-> API: REST API
API --> Game
Game --> WS: Game State
WS --> Vis: WS Game State
WS --> ExtVis1: WS Game State
WS --> ExtVis2: WS Game State
```
### WebSockets
```plantuml
participant Game as "FairHopper\nGame Server"
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
endbox
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
Game ->o WS: Server Connect
activate WS #coral
WS -> Game: Get game state
activate Game #yellow
Game -> WS: Game state
deactivate
deactivate
Game ->o WS: Send initial state
Client1 ->o WS: Client Connect
activate WS #coral
WS -> Client1: Game state
deactivate
Client1 ->o WS: Client connect
activate WS #coral
WS -> Client1: Game state
deactivate
Client2 ->o WS: Client Connect
activate WS #coral
WS -> Client2: Game state
deactivate
Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
loop #lightyellow On game state change
loop #lightyellow On game state change
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
@ -131,7 +165,7 @@ end
- Move right
- Move up
- Move down
- Get current position
- Get player info
- Get board info
Check REST API interface on [FastAPI docs](http://localhost:8010/docs).

39
api_tests/requests.http Normal file
View File

@ -0,0 +1,39 @@
GET http://localhost:8010/ping
###
# create new game
POST http://localhost:8010/game
Content-Type: application/json
{
"player_name": "Mirko"
}
###
# get game info
GET http://localhost:8010/game
###
# get player info
GET http://localhost:8010/player/test-player-pero
###
# move player left
POST http://localhost:8010/player/test-player-pero/move/left
###
# move player right
POST http://localhost:8010/player/test-player-pero/move/right
###
# move player up
POST http://localhost:8010/player/test-player-pero/move/up
###
# move player down
POST http://localhost:8010/player/test-player-pero/move/down
###
# move Mirko left
POST http://localhost:8010/player/test-player-mirko/move/left
###

1
fairhopper-sdk Submodule

Submodule fairhopper-sdk added at 10290dba54

View File

@ -8,13 +8,15 @@
<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>
<title>FairHopper Visualisation Client</title>
</head>
<body>
<div class="container-fluid">
<h1>FairHopper WS Client</h1>
<h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<div class="row">
<div class="col-10">
<div class="board-container">
@ -22,14 +24,23 @@
</div>
</div>
<div class="col-2">
<h3>Players</h3>
<ul id="players-content"></ul>
<h3 class="pb-2 border-bottom">
Players
</h3>
<ul class="players" id="players-content"></ul>
</div>
</div>
</div>
</body>
<script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
@ -46,25 +57,42 @@
document.getElementById("board-content").innerHTML = html;
}
function renderCellContent(x, y, content) {
const cell = document.getElementById(`cell-${x}-${y}`);
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.map((player) => {
const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return `
<li>${player.name} (${player.move_count})</li>
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎");
players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
@ -79,41 +107,47 @@
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position.x, obj.position.y, "🔥");
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position.x, position.y, "🏠");
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
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)
function wsConnect() {
let ws = new WebSocket('ws://localhost:8011');
ws.onopen = () => {
console.log("WS connected")
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log("WS 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 = (e) => {
setTimeout(function () {
wsConnect();
}, 1000);
};
ws.onclose = function () {
console.log("close");
}
ws.onerror = function () {
console.log("error");
}
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
}
</script>

View File

@ -13,8 +13,43 @@ body {
grid-gap: 2px;
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell {
flex: 1;
text-align: center;
aspect-ratio: 1;
background-color: beige;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
ul.players {
list-style-type: none;
padding-left: 0;
}
.player-tooltip {
position: absolute;
margin-bottom: 50px;
font-size: 8pt;
padding: 2px 10px;
color: white;
background-color: darkred;
border-radius: 5px;
z-index: 1000;
}
.player-tooltip::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: darkred transparent transparent transparent;
}

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from pydantic import BaseModel as PydanticBaseModel
from hopper.enums import PlayerState
class BaseModel(PydanticBaseModel):
class Config:
@ -29,6 +31,7 @@ class PlayerDto(BaseModel):
position: PositionDto
move_count: int
move_attempt_count: int
state: PlayerState
class DestinationDto(BaseModel):

View File

@ -14,11 +14,26 @@ from hopper.api.dto import (
)
from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.models.player import Player
router = APIRouter()
def get_player(uuid: str, engine: GameEngine = Depends(get_game_engine)) -> Player:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Player kicked out due to inactivity",
)
return player
@router.get("/ping", response_model=PingResponse)
async def ping() -> PingResponse:
return PingResponse(
@ -38,12 +53,14 @@ async def get_game_info(
)
@router.post("/game", response_model=StartGameResponseDto)
@router.post(
"/game", response_model=StartGameResponseDto, status_code=status.HTTP_201_CREATED
)
async def start_game(
body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto:
new_player = await engine.start_game(player_name=body.player_name)
new_player = await engine.start_game_for_player(player_name=body.player_name)
return StartGameResponseDto(
board=engine.board,
@ -57,22 +74,20 @@ async def start_game(
@router.get(
"/player/{uuid}",
response_model=PlayerInfoResponseDto,
status_code=status.HTTP_201_CREATED,
responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
},
},
)
async def get_player_info(
uuid: str,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
return PlayerInfoResponseDto(player=player)
@ -87,31 +102,28 @@ async def get_player_info(
},
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player uuid not valid, probably due to inactivity",
"description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
},
status.HTTP_409_CONFLICT: {
"model": ErrorResponseDto,
"description": " Position out of bounds or collision with an object",
},
status.HTTP_423_LOCKED: {
"model": ErrorResponseDto,
"description": " Player reached destination. Can't move anymore.",
},
},
)
async def move_player(
uuid: str,
direction: Direction,
response: Response,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
try:
move_result = await engine.move_player(player, direction)
except PositionOutOfBounds:
@ -122,6 +134,11 @@ async def move_player(
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Collision with an object"
)
except GameLockForMovement:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="Player reached destination. Can't move anymore.",
)
if move_result == PlayerMoveResult.DESTINATION_REACHED:
response.status_code = status.HTTP_200_OK

View File

@ -1,35 +0,0 @@
GET http://localhost:8010/ping
###
# create new game
POST http://localhost:8010/game
Content-Type: application/json
{
"player_name": "Mirko"
}
###
# get game info
GET http://localhost:8010/game
###
# get player info
GET http://localhost:8010/player/test-player-id
###
# move player left
POST http://localhost:8010/player/test-player-id/move/left
###
# move player right
POST http://localhost:8010/player/test-player-id/move/right
###
# move player up
POST http://localhost:8010/player/test-player-id/move/up
###
# move player down
POST http://localhost:8010/player/test-player-id/move/down
###

25
hopper/countdown_timer.py Normal file
View File

@ -0,0 +1,25 @@
import time
from threading import Event, Thread
from typing import Callable, Optional
class CountdownTimer(Thread):
def __init__(
self, seconds: int, callback: Optional[Callable[[], None]] = None
) -> None:
self.seconds = seconds
self.stop_event = Event()
self.callback = callback
super().__init__()
def run(self) -> None:
cnt = self.seconds
while cnt > 0 and not self.stop_event.is_set():
cnt -= 1
time.sleep(1)
if cnt == 0 and self.callback:
self.callback()
def stop(self) -> None:
self.stop_event.set()

View File

@ -1,9 +1,12 @@
import asyncio
import logging
import random
from typing import Optional
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, PlayerMoveResult, GameState, PlayerState
from hopper.errors import Collision, PositionOutOfBounds, GameLockForMovement
from hopper.interfaces import SendGameDumpInterface
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
@ -16,25 +19,33 @@ 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, ws_server: Optional[WSServer] = None) -> None:
def __init__(
self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None
) -> None:
self.board = board
self.ws_server = ws_server
self.players = PlayerList()
self._inacivity_watchdog = None
self.__debug_print_board()
self._purchase_countdown_timer: Optional[CountdownTimer] = None
self.reset_game()
def dump_board(self) -> list[list[str]]:
dump = self.board.dump()
for player in self.players:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
show_player = (
player.active
and player.position.y < len(dump)
and player.position.x < len(dump[player.position.y])
)
if show_player:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
return dump
@ -53,11 +64,16 @@ class GameEngine:
)
self._inacivity_watchdog.start()
async def start_game(self, player_name: str) -> Player:
async def reset_game(self) -> None:
self.__debug_print_board()
self.game_state = GameState.RUNNING
async def start_game_for_player(self, player_name: str) -> Player:
self._start_inactivity_watchdog()
player = Player(
name=player_name,
position=Position(0, 0),
position=self._create_player_start_position(),
state=PlayerState.CREATED,
)
self.players.append(player)
@ -65,18 +81,28 @@ class GameEngine:
self.__debug_print_board()
if self.ws_server:
await self.ws_server.send_game_state()
await self.ws_server.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
def _create_player_start_position(self) -> Position:
"""Create random position somewhere on the board border"""
border_len = (self.board.width + self.board.height) * 2
rnd_position = random.randint(0, border_len - 1)
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
if rnd_position < self.board.width * 2:
x = rnd_position % self.board.width
y = 0 if rnd_position < self.board.width else self.board.height - 1
else:
rnd_position -= 2 * self.board.width
x = 0 if rnd_position < self.board.height else self.board.width - 1
y = rnd_position % self.board.height
return Position(x=x, y=y)
def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y)
if direction == Direction.LEFT:
new_position.x -= 1
elif direction == Direction.RIGHT:
@ -87,41 +113,67 @@ class GameEngine:
new_position.y += 1
else:
raise ValueError(f"Unhandled direction: {direction}")
return new_position
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
if self.game_state == GameState.LOCK_FOR_MOVEMENT:
raise GameLockForMovement("Player reached destination. Can't move anymore.")
# player will not be able to move once they reach the destination
if player.state == PlayerState.ON_DESTINATION:
return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1
player.state = PlayerState.MOVING
if not self.position_in_board_bounds(new_position):
if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position):
if self._colided_with_obstacle(new_position):
raise Collision()
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):
if self._is_player_on_destination(player):
player.state = PlayerState.ON_DESTINATION
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED
if self.ws_server:
await self.ws_server.send_game_dump()
self.__debug_print_board()
if player.state == PlayerState.ON_DESTINATION:
self.game_state = GameState.LOCK_FOR_MOVEMENT
return PlayerMoveResult.DESTINATION_REACHED
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK
def is_player_on_destination(self, player: Player) -> bool:
def _is_player_on_destination(self, player: Player) -> bool:
return player.position == self.board.destination.position
def position_in_board_bounds(self, position: Position) -> bool:
def _position_in_board_bounds(self, position: Position) -> bool:
return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height
)
def colided_with_obstacle(self, position: Position) -> bool:
def _colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None
def _player_on_destination(self, player: Player) -> None:
self.game_state = GameState.LOCK_FOR_MOVEMENT
self._purchase_countdown_timer = CountdownTimer()
def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players)
@ -132,12 +184,12 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[WSServer] = None,
ws_server: Optional[SendGameDumpInterface] = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
height=board_height,
destination=Destination(Position(board_height // 2, board_height // 2)),
destination=Destination(Position(board_width // 2, board_height // 2)),
)
obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count):
@ -153,11 +205,13 @@ class GameEngineFactory:
board=board,
ws_server=ws_server,
)
GameEngineFactory.__add_test_player(game.players)
GameEngineFactory.__add_test_players(game.players)
return game
@staticmethod
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine:
def create_default(
ws_server: Optional[SendGameDumpInterface] = None,
) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT,
@ -166,15 +220,10 @@ class GameEngineFactory:
)
@staticmethod
def __add_test_player(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER):
def __add_test_players(players: PlayerList) -> None:
if not settings.debug:
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}")
for player in settings.debug.PLAYERS:
players.append(player)
logging.info(f"Test player created: {player}")

View File

@ -18,3 +18,16 @@ class ObjectType(str, Enum):
class PlayerMoveResult(Enum):
OK = auto()
DESTINATION_REACHED = auto()
class GameState(Enum):
RUNNING = auto()
LOCK_FOR_MOVEMENT = auto()
ENDGAME = auto()
class PlayerState(str, Enum):
CREATED = "CREATED"
MOVING = "MOVING"
ON_DESTINATION = "ON_DESTINATION"
INACTIVE = "INACTIVE"

View File

@ -8,3 +8,7 @@ class PositionOutOfBounds(BaseError):
class Collision(BaseError):
...
class GameLockForMovement(BaseError):
...

6
hopper/interfaces.py Normal file
View File

@ -0,0 +1,6 @@
from typing import Protocol
class SendGameDumpInterface(Protocol):
async def send_game_dump(self) -> None:
...

View File

@ -1,10 +1,14 @@
import logging
from dataclasses import dataclass
from typing import Optional
from typing import List, Optional
from hopper.models.player import Player
@dataclass
class GameSettings:
MOVE_DELAY: int = 0.5 # seconds
MOVE_DELAY: float = 0.5 # seconds
@dataclass
class BoardSettings:
@ -29,7 +33,7 @@ class WSServerSettings:
@dataclass
class DebugSettings:
PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False
PLAYERS: Optional[List[Player]] = None
@dataclass
@ -38,5 +42,6 @@ class Settings:
board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO
debug: Optional[DebugSettings] = None

View File

@ -3,6 +3,8 @@ import uuid
from dataclasses import dataclass, field
from typing import Optional
from hopper.enums import PlayerState
@dataclass
class Position:
@ -20,6 +22,7 @@ class Player:
last_seen: datetime.datetime = field(
default_factory=lambda: datetime.datetime.now()
)
state: PlayerState = PlayerState.CREATED
active: bool = True
can_be_deactivated: bool = True

View File

@ -15,9 +15,12 @@ class LayerDto(BaseModel):
name: str
objects: list[LayerObjectDto]
class GameDumpPlayerDto(PlayerDto):
...
class GameStateDto(BaseModel):
class GameDumpDto(BaseModel):
board: BoardDto
destination: DestinationDto
players: list[PlayerDto]
players: list[GameDumpPlayerDto]
layers: list[LayerDto]

View File

@ -5,14 +5,18 @@ import time
from threading import Thread
from typing import Optional
from hopper.interfaces import SendGameDumpInterface
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs
self,
players: PlayerList,
ws_server: Optional[SendGameDumpInterface] = None,
*args,
**kwargs,
) -> None:
self.players = players
self.ws_server = ws_server
@ -34,7 +38,7 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT
)
send_game_state = False
send_game_dump = False
for player in self.players:
if (
@ -44,7 +48,7 @@ class InactivityWatchdog(Thread):
):
player.active = False
logging.info(f"Player {player} set as inactive")
send_game_state = True
send_game_dump = True
# safe remove from list
n = 0
@ -53,18 +57,18 @@ class InactivityWatchdog(Thread):
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
send_game_dump = True
else:
n += 1
if send_game_state:
self.send_game_state()
if send_game_dump:
self.send_game_dump()
def send_game_state(self):
def send_game_dump(self):
if not self.ws_server:
return
logging.info("Sending WS game state")
asyncio.run(self.ws_server.send_game_state())
logging.info("Sending WS game dump")
asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None:
self.stopped = True

View File

@ -7,59 +7,61 @@ import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK
from hopper.models.ws_dto import GameStateDto
from hopper.models.ws_dto import GameDumpDto
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:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
await self.send_game_state_to_client(websocket)
# send initial game dump to connected client
await self.send_game_dump_to_client(websocket)
# loop and do nothing while client is connected
connected = True
while connected:
try:
message = await websocket.recv()
# we're expecting nothing from client, but read if client sends a 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:
def _create_game_dump_message(self) -> str:
# avoid circular imports
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
game_state = GameStateDto(
game_dump = GameDumpDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
return json.dumps(game_state.dict())
return json.dumps(game_dump.dict())
async def send_game_state_to_client(
async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol
) -> None:
message = self._create_game_state_message()
logging.debug(f"Sending game state to client: {websocket.id}")
"""Send game dump to the client"""
message = self._create_game_dump_message()
logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message)
async def send_game_state(self) -> None:
async def send_game_dump(self) -> None:
"""Broadcast game state to all connected clients"""
if not self.connected_clients:
return
message = self._create_game_state_message()
message = self._create_game_dump_message()
logging.debug(
f"Sending game state to clients: {self.connected_clients}: {message}"
f"Sending game dump to clients: {self.connected_clients}: {message}"
)
for client in self.connected_clients:
await client.send(message)
@ -77,4 +79,5 @@ class WSServer(Thread):
await asyncio.Future() # run forever
def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async())

View File

@ -14,5 +14,6 @@ settings = Settings(
inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO,
ws_server=WSServerSettings(),
purchase_timeout=10,
debug=None,
)