13 Commits

Author SHA1 Message Date
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
14 changed files with 293 additions and 119 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. - 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 ## FairHopper Game Server
Requirements: Requirements:
@ -75,46 +104,52 @@ actor "Player 1" as P1
actor "Player 2" as P2 actor "Player 2" as P2
actor "Player 3" as P3 actor "Player 3" as P3
package Masterpiece {
usecase Game as "FairHopper\nGame Server" package Masterpiece #seashell {
usecase WS as "WS Server" 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" usecase Vis as "Visualisation\nService"
} }
P1 -left-> Game: REST API usecase ExtVis1 as "Visualisation\nService"
P2 -left-> Game: REST API usecase ExtVis2 as "Visualisation\nService"
P3 -left-> Game: REST API
Game --> WS: WebSockets P1 -left-> API: REST API
WS --> Vis: WebSockets 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 ### WebSockets
```plantuml ```plantuml
participant Game as "FairHopper\nGame Server" box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1" endbox
participant Client2 as "Visualisation\nClient 2" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
Game ->o WS: Server Connect Game ->o WS: Send initial state
activate WS #coral
WS -> Game: Get game state
activate Game #yellow
Game -> WS: Game state
deactivate
deactivate
Client1 ->o WS: Client Connect Client1 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client1: Game state WS -> Client1: Game state
deactivate deactivate
Client2 ->o WS: Client Connect Client2 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client2: Game state WS -> Client2: Game state
deactivate deactivate
loop #lightyellow On game state change loop #lightyellow On game state change
Game ->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
@ -131,7 +166,7 @@ end
- Move right - Move right
- Move up - Move up
- Move down - Move down
- Get current position - Get player info
- Get board info - Get board info
Check REST API interface on [FastAPI docs](http://localhost:8010/docs). Check REST API interface on [FastAPI docs](http://localhost:8010/docs).

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" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<title>Document</title>
<title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<div class="container-fluid"> <div class="container-fluid">
<h1>FairHopper WS Client</h1> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<div class="row"> <div class="row">
<div class="col-10"> <div class="col-10">
<div class="board-container"> <div class="board-container">
@ -22,14 +24,23 @@
</div> </div>
</div> </div>
<div class="col-2"> <div class="col-2">
<h3>Players</h3> <h3 class="pb-2 border-bottom">
<ul id="players-content"></ul> Players
</h3>
<ul class="players" id="players-content"></ul>
</div> </div>
</div> </div>
</div> </div>
</body> </body>
<script> <script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) { function createBoard(board) {
let html = ""; let html = "";
for (let y = 0; y < board.height; y++) { for (let y = 0; y < board.height; y++) {
@ -46,25 +57,40 @@
document.getElementById("board-content").innerHTML = html; document.getElementById("board-content").innerHTML = html;
} }
function renderCellContent(x, y, content) { function findCell(position) {
const cell = document.getElementById(`cell-${x}-${y}`); return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) { if (cell) {
cell.innerText = content; cell.innerText = content;
} }
} }
function renderPlayerList(players) { function renderPlayerList(players) {
const html = players.map((player) => { const html = players.filter(player => player.active).map((player) => {
return ` return `
<li>${player.name} (${player.move_count})</li> <li class="${player.reached_destination ? "text-success" : ""}">
${player.name} (${player.move_count})
${player.reached_destination ? "✅" : ""}
</li>
`; `;
}).join(""); }).join("");
document.getElementById("players-content").innerHTML = html; document.getElementById("players-content").innerHTML = html;
} }
function renderPlayers(players) { function renderPlayers(players) {
players.forEach(player => { players.filter(player => player.active).forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎"); const cell = findCell(player.position);
const playerIcon = player.reached_destination ? 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 +105,47 @@
function renderObstacles(layers) { function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE"); const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => { objects.forEach(obj => {
renderCellContent(obj.position.x, obj.position.y, "🔥"); renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
}); });
} }
function renderDestination(position) { 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) { function wsConnect() {
const data = JSON.parse(event.data); let ws = new WebSocket('ws://localhost:8011');
console.log("message received:", data) ws.onopen = () => {
console.log("WS connected")
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log("WS message received:", data)
createBoard(data.board); createBoard(data.board);
renderObstacles(data.layers) renderObstacles(data.layers)
renderDestination(data.destination.position); renderDestination(data.destination.position);
renderPlayerList(data.players); renderPlayerList(data.players);
renderPlayers(data.players); renderPlayers(data.players);
} };
ws.onopen = function () { ws.onclose = (e) => {
console.log("open"); setTimeout(function () {
} wsConnect();
}, 1000);
};
ws.onclose = function () { ws.onerror = (err) => {
console.log("close"); console.error("Socket encountered error:", err.message, "Closing socket");
} ws.close();
};
ws.onerror = function () {
console.log("error");
}
} }
window.onload = () => {
wsConnect();
}
</script> </script>

View File

@ -13,8 +13,43 @@ body {
grid-gap: 2px; grid-gap: 2px;
padding-bottom: 2px; padding-bottom: 2px;
} }
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell { .cell {
flex: 1; flex: 1;
text-align: center; aspect-ratio: 1;
background-color: beige; 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

@ -15,10 +15,25 @@ 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.models.player import Player
router = APIRouter() 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) @router.get("/ping", response_model=PingResponse)
async def ping() -> PingResponse: async def ping() -> PingResponse:
return PingResponse( return PingResponse(
@ -38,7 +53,9 @@ 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( async def start_game(
body: StartGameRequestDto, body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
@ -57,22 +74,20 @@ async def start_game(
@router.get( @router.get(
"/player/{uuid}", "/player/{uuid}",
response_model=PlayerInfoResponseDto, 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( async def get_player_info(
uuid: str, player: Player = Depends(get_player),
engine: GameEngine = Depends(get_game_engine),
) -> MovePlayerResponseDto: ) -> 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) return PlayerInfoResponseDto(player=player)
@ -87,7 +102,11 @@ async def get_player_info(
}, },
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto, "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: { status.HTTP_409_CONFLICT: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
@ -96,22 +115,11 @@ async def get_player_info(
}, },
) )
async def move_player( async def move_player(
uuid: str,
direction: Direction, direction: Direction,
response: Response, response: Response,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto: ) -> 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: try:
move_result = await engine.move_player(player, direction) move_result = await engine.move_player(player, direction)
except PositionOutOfBounds: except PositionOutOfBounds:

View File

@ -1,9 +1,11 @@
import asyncio import asyncio
import logging import logging
import random
from typing import Optional 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.interfaces import SendGameStateInterface
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -16,12 +18,13 @@ from hopper.models.board import (
) )
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_server import WSServer
from settings import settings from settings import settings
class GameEngine: class GameEngine:
def __init__(self, board: GameBoard, ws_server: Optional[WSServer] = None) -> None: def __init__(
self, board: GameBoard, ws_server: Optional[SendGameStateInterface] = None
) -> None:
self.board = board self.board = board
self.ws_server = ws_server self.ws_server = ws_server
self.players = PlayerList() self.players = PlayerList()
@ -32,9 +35,15 @@ class GameEngine:
dump = self.board.dump() dump = self.board.dump()
for player in self.players: for player in self.players:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[ show_player = (
ObjectType.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 return dump
@ -57,7 +66,7 @@ class GameEngine:
self._start_inactivity_watchdog() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
position=Position(0, 0), position=self._create_player_start_position(),
) )
self.players.append(player) self.players.append(player)
@ -67,16 +76,26 @@ class GameEngine:
if self.ws_server: if self.ws_server:
await self.ws_server.send_game_state() await self.ws_server.send_game_state()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
async def move_player( def _create_player_start_position(self) -> Position:
self, player: Player, direction: Direction """Create random position somewhere on the board border"""
) -> PlayerMoveResult: border_len = (self.board.width + self.board.height) * 2
player.reset_timeout() rnd_position = random.randint(0, border_len - 1)
new_position = Position(player.position.x, player.position.y) if rnd_position < self.board.width * 2:
logging.info(f"Player {player} move to {direction}") 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: if direction == Direction.LEFT:
new_position.x -= 1 new_position.x -= 1
elif direction == Direction.RIGHT: elif direction == Direction.RIGHT:
@ -87,39 +106,56 @@ class GameEngine:
new_position.y += 1 new_position.y += 1
else: else:
raise ValueError(f"Unhandled direction: {direction}") raise ValueError(f"Unhandled direction: {direction}")
return new_position
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
# player will not be able to move once they reach the destination
if player.reached_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.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()
if self.colided_with_obstacle(new_position): if self._colided_with_obstacle(new_position):
raise Collision() raise Collision()
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
if self._is_player_on_destination(player):
player.reached_destination = True
logging.info(f"Player {player} reached destination!")
if self.ws_server: if self.ws_server:
await self.ws_server.send_game_state() 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() self.__debug_print_board()
if player.reached_destination:
return PlayerMoveResult.DESTINATION_REACHED
await asyncio.sleep(settings.game.MOVE_DELAY) 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:
return player.position == self.board.destination.position 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 ( return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height 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 return self.board.get_object_at_position(position) is not None
def get_board_layout(self) -> BoardLayout: def get_board_layout(self) -> BoardLayout:
@ -132,12 +168,12 @@ class GameEngineFactory:
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, ws_server: Optional[SendGameStateInterface] = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
height=board_height, 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") obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count): for _ in range(obstacle_count):
@ -157,7 +193,9 @@ class GameEngineFactory:
return game return game
@staticmethod @staticmethod
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine: def create_default(
ws_server: Optional[SendGameStateInterface] = 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,
@ -173,7 +211,10 @@ class GameEngineFactory:
player = Player( player = Player(
name="Pero", name="Pero",
uuid="test-player-id", uuid="test-player-id",
position=Position(2, 2), position=Position(
settings.debug.TEST_PLAYER_START_X,
settings.debug.TEST_PLAYER_START_Y,
),
can_be_deactivated=False, can_be_deactivated=False,
) )
players.append(player) players.append(player)

6
hopper/interfaces.py Normal file
View File

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

View File

@ -4,7 +4,7 @@ from typing import Optional
@dataclass @dataclass
class GameSettings: class GameSettings:
MOVE_DELAY: int = 0.5 # seconds MOVE_DELAY: float = 0.5 # seconds
@dataclass @dataclass
class BoardSettings: class BoardSettings:
@ -30,6 +30,8 @@ class WSServerSettings:
class DebugSettings: class DebugSettings:
PRINT_BOARD: bool = False PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False CREATE_TEST_PLAYER: bool = False
TEST_PLAYER_START_X: int = 0
TEST_PLAYER_START_Y: int = 0
@dataclass @dataclass

View File

@ -22,6 +22,7 @@ class Player:
) )
active: bool = True active: bool = True
can_be_deactivated: bool = True can_be_deactivated: bool = True
reached_destination: bool = False
def reset_timeout(self) -> None: def reset_timeout(self) -> None:
self.last_seen = datetime.datetime.now() self.last_seen = datetime.datetime.now()

View File

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

View File

@ -5,14 +5,18 @@ import time
from threading import Thread from threading import Thread
from typing import Optional from typing import Optional
from hopper.interfaces import SendGameStateInterface
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings from settings import settings
class InactivityWatchdog(Thread): class InactivityWatchdog(Thread):
def __init__( def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs self,
players: PlayerList,
ws_server: Optional[SendGameStateInterface] = None,
*args,
**kwargs,
) -> None: ) -> None:
self.players = players self.players = players
self.ws_server = ws_server self.ws_server = ws_server

View File

@ -12,20 +12,20 @@ from settings import settings
class WSServer(Thread): 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: async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}") logging.info(f"Add client: {websocket.id}")
try: try:
# send initial game state to connected client
await self.send_game_state_to_client(websocket) await self.send_game_state_to_client(websocket)
# loop and do nothing while client is connected
connected = True connected = True
while connected: while connected:
try: try:
message = await websocket.recv() # we're expecting nothing from client, but read if client sends a message
await websocket.recv()
except ConnectionClosedOK: except ConnectionClosedOK:
connected = False connected = False
finally: finally:
@ -49,11 +49,13 @@ class WSServer(Thread):
async def send_game_state_to_client( async def send_game_state_to_client(
self, websocket: WebSocketServerProtocol self, websocket: WebSocketServerProtocol
) -> None: ) -> None:
"""Send game state to the client"""
message = self._create_game_state_message() message = self._create_game_state_message()
logging.debug(f"Sending game state to client: {websocket.id}") logging.debug(f"Sending game state to client: {websocket.id}")
await websocket.send(message) await websocket.send(message)
async def send_game_state(self) -> None: async def send_game_state(self) -> None:
"""Broadcast game state to all connected clients"""
if not self.connected_clients: if not self.connected_clients:
return return
@ -77,4 +79,5 @@ class WSServer(Thread):
await asyncio.Future() # run forever await asyncio.Future() # run forever
def run(self) -> None: def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async()) asyncio.run(self.run_async())