4 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
16 changed files with 145 additions and 72 deletions

View File

@ -104,7 +104,6 @@ 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 #seashell { package Masterpiece #seashell {
rectangle "FairHopper Game Server" #lightcyan { rectangle "FairHopper Game Server" #lightcyan {
usecase API as "API Server" usecase API as "API Server"

View File

@ -15,21 +15,25 @@ GET http://localhost:8010/game
### ###
# get player info # get player info
GET http://localhost:8010/player/test-player-id GET http://localhost:8010/player/test-player-pero
### ###
# move player left # move player left
POST http://localhost:8010/player/test-player-id/move/left POST http://localhost:8010/player/test-player-pero/move/left
### ###
# move player right # move player right
POST http://localhost:8010/player/test-player-id/move/right POST http://localhost:8010/player/test-player-pero/move/right
### ###
# move player up # move player up
POST http://localhost:8010/player/test-player-id/move/up POST http://localhost:8010/player/test-player-pero/move/up
### ###
# move player down # move player down
POST http://localhost:8010/player/test-player-id/move/down POST http://localhost:8010/player/test-player-pero/move/down
###
# move Mirko left
POST http://localhost:8010/player/test-player-mirko/move/left
### ###

View File

@ -70,10 +70,11 @@
function renderPlayerList(players) { function renderPlayerList(players) {
const html = players.filter(player => player.active).map((player) => { const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return ` return `
<li class="${player.reached_destination ? "text-success" : ""}"> <li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count}) ${player.name} (${player.move_count})
${player.reached_destination ? "✅" : ""} ${onDestination ? "✅" : ""}
</li> </li>
`; `;
}).join(""); }).join("");
@ -83,7 +84,8 @@
function renderPlayers(players) { function renderPlayers(players) {
players.filter(player => player.active).forEach(player => { players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position); const cell = findCell(player.position);
const playerIcon = player.reached_destination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER; const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) { if (cell) {
const html = ` const html = `
<div class="player-tooltip">${player.name}</div> <div class="player-tooltip">${player.name}</div>

View File

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

View File

@ -14,7 +14,7 @@ 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, GameLockForMovement, PositionOutOfBounds
from hopper.models.player import Player from hopper.models.player import Player
router = APIRouter() router = APIRouter()
@ -60,7 +60,7 @@ async def start_game(
body: StartGameRequestDto, body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto: ) -> 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( return StartGameResponseDto(
board=engine.board, board=engine.board,
@ -112,6 +112,10 @@ async def get_player_info(
"model": ErrorResponseDto, "model": ErrorResponseDto,
"description": " Position out of bounds or collision with an object", "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( async def move_player(
@ -130,6 +134,11 @@ async def move_player(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Collision with an object" 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: if move_result == PlayerMoveResult.DESTINATION_REACHED:
response.status_code = status.HTTP_200_OK response.status_code = status.HTTP_200_OK

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

@ -3,9 +3,10 @@ import logging
import random import random
from typing import Optional from typing import Optional
from hopper.enums import Direction, PlayerMoveResult from hopper.countdown_timer import CountdownTimer
from hopper.errors import Collision, PositionOutOfBounds from hopper.enums import Direction, PlayerMoveResult, GameState, PlayerState
from hopper.interfaces import SendGameStateInterface from hopper.errors import Collision, PositionOutOfBounds, GameLockForMovement
from hopper.interfaces import SendGameDumpInterface
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -23,13 +24,14 @@ from settings import settings
class GameEngine: class GameEngine:
def __init__( def __init__(
self, board: GameBoard, ws_server: Optional[SendGameStateInterface] = None self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None
) -> None: ) -> None:
self.board = board self.board = board
self.ws_server = ws_server self.ws_server = ws_server
self.players = PlayerList() self.players = PlayerList()
self._inacivity_watchdog = None 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]]: def dump_board(self) -> list[list[str]]:
dump = self.board.dump() dump = self.board.dump()
@ -62,11 +64,16 @@ class GameEngine:
) )
self._inacivity_watchdog.start() 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() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
position=self._create_player_start_position(), position=self._create_player_start_position(),
state=PlayerState.CREATED,
) )
self.players.append(player) self.players.append(player)
@ -74,7 +81,7 @@ class GameEngine:
self.__debug_print_board() self.__debug_print_board()
if self.ws_server: 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) await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
@ -113,14 +120,18 @@ class GameEngine:
) -> PlayerMoveResult: ) -> PlayerMoveResult:
player.reset_timeout() 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 # player will not be able to move once they reach the destination
if player.reached_destination: if player.state == PlayerState.ON_DESTINATION:
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}") logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction) new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1 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() raise PositionOutOfBounds()
@ -132,15 +143,16 @@ class GameEngine:
player.move_count += 1 player.move_count += 1
if self._is_player_on_destination(player): if self._is_player_on_destination(player):
player.reached_destination = True player.state = PlayerState.ON_DESTINATION
logging.info(f"Player {player} reached destination!") 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_dump()
self.__debug_print_board() self.__debug_print_board()
if player.reached_destination: if player.state == PlayerState.ON_DESTINATION:
self.game_state = GameState.LOCK_FOR_MOVEMENT
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
await asyncio.sleep(settings.game.MOVE_DELAY) await asyncio.sleep(settings.game.MOVE_DELAY)
@ -158,6 +170,10 @@ class GameEngine:
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 _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: def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players) return BoardLayout(board=self.board, players=self.players)
@ -168,7 +184,7 @@ 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[SendGameStateInterface] = None, ws_server: Optional[SendGameDumpInterface] = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
@ -189,12 +205,12 @@ class GameEngineFactory:
board=board, board=board,
ws_server=ws_server, ws_server=ws_server,
) )
GameEngineFactory.__add_test_player(game.players) GameEngineFactory.__add_test_players(game.players)
return game return game
@staticmethod @staticmethod
def create_default( def create_default(
ws_server: Optional[SendGameStateInterface] = None, ws_server: Optional[SendGameDumpInterface] = None,
) -> GameEngine: ) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,
@ -204,18 +220,10 @@ class GameEngineFactory:
) )
@staticmethod @staticmethod
def __add_test_player(players: PlayerList) -> None: def __add_test_players(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER): if not settings.debug:
return return
player = Player( for player in settings.debug.PLAYERS:
name="Pero",
uuid="test-player-id",
position=Position(
settings.debug.TEST_PLAYER_START_X,
settings.debug.TEST_PLAYER_START_Y,
),
can_be_deactivated=False,
)
players.append(player) players.append(player)
logging.info(f"Test player created: {player}") logging.info(f"Test player created: {player}")

View File

@ -18,3 +18,16 @@ class ObjectType(str, Enum):
class PlayerMoveResult(Enum): class PlayerMoveResult(Enum):
OK = auto() OK = auto()
DESTINATION_REACHED = 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 Collision(BaseError):
... ...
class GameLockForMovement(BaseError):
...

View File

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

View File

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

View File

@ -3,6 +3,8 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from hopper.enums import PlayerState
@dataclass @dataclass
class Position: class Position:
@ -20,9 +22,9 @@ class Player:
last_seen: datetime.datetime = field( last_seen: datetime.datetime = field(
default_factory=lambda: datetime.datetime.now() default_factory=lambda: datetime.datetime.now()
) )
state: PlayerState = PlayerState.CREATED
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,12 +15,12 @@ class LayerDto(BaseModel):
name: str name: str
objects: list[LayerObjectDto] objects: list[LayerObjectDto]
class GameStatePlayerDto(PlayerDto): class GameDumpPlayerDto(PlayerDto):
reached_destination: bool ...
class GameStateDto(BaseModel): class GameDumpDto(BaseModel):
board: BoardDto board: BoardDto
destination: DestinationDto destination: DestinationDto
players: list[GameStatePlayerDto] players: list[GameDumpPlayerDto]
layers: list[LayerDto] layers: list[LayerDto]

View File

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

View File

@ -7,7 +7,7 @@ import websockets
from websockets import WebSocketServerProtocol from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK from websockets.exceptions import ConnectionClosedOK
from hopper.models.ws_dto import GameStateDto from hopper.models.ws_dto import GameDumpDto
from settings import settings from settings import settings
@ -18,8 +18,8 @@ class WSServer(Thread):
logging.info(f"Add client: {websocket.id}") logging.info(f"Add client: {websocket.id}")
try: try:
# send initial game state to connected client # send initial game dump to connected client
await self.send_game_state_to_client(websocket) await self.send_game_dump_to_client(websocket)
# loop and do nothing while client is connected # loop and do nothing while client is connected
connected = True connected = True
while connected: while connected:
@ -32,36 +32,36 @@ class WSServer(Thread):
self.connected_clients.remove(websocket) self.connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}") logging.info(f"Remove client: {websocket.id}")
def _create_game_state_message(self) -> str: def _create_game_dump_message(self) -> str:
# avoid circular imports # avoid circular imports
from hopper.api.dependencies import get_game_engine from hopper.api.dependencies import get_game_engine
engine = get_game_engine() engine = get_game_engine()
game_state = GameStateDto( game_dump = GameDumpDto(
board=engine.board, board=engine.board,
destination=engine.board.destination, destination=engine.board.destination,
players=engine.players, players=engine.players,
layers=engine.get_board_layout().layers, 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 self, websocket: WebSocketServerProtocol
) -> None: ) -> None:
"""Send game state to the client""" """Send game dump to the client"""
message = self._create_game_state_message() message = self._create_game_dump_message()
logging.debug(f"Sending game state to client: {websocket.id}") logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message) 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""" """Broadcast game state to all connected clients"""
if not self.connected_clients: if not self.connected_clients:
return return
message = self._create_game_state_message() message = self._create_game_dump_message()
logging.debug( 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: for client in self.connected_clients:
await client.send(message) await client.send(message)

View File

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