diff --git a/api_tests/requests.http b/api_tests/requests.http index 4043131..cfc5543 100644 --- a/api_tests/requests.http +++ b/api_tests/requests.http @@ -15,21 +15,25 @@ GET http://localhost:8010/game ### # get player info -GET http://localhost:8010/player/test-player-id +GET http://localhost:8010/player/test-player-pero ### # 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 -POST http://localhost:8010/player/test-player-id/move/right +POST http://localhost:8010/player/test-player-pero/move/right ### # 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 -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 ### diff --git a/frontend/index.html b/frontend/index.html index 239d4b4..6a4c50a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -70,10 +70,11 @@ function renderPlayerList(players) { const html = players.filter(player => player.active).map((player) => { + const onDestination = player.state == "ON_DESTINATION"; return ` -
  • +
  • ${player.name} (${player.move_count}) - ${player.reached_destination ? "✅" : ""} + ${onDestination ? "✅" : ""}
  • `; }).join(""); @@ -83,7 +84,8 @@ function renderPlayers(players) { players.filter(player => player.active).forEach(player => { 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) { const html = `
    ${player.name}
    diff --git a/hopper/api/dto.py b/hopper/api/dto.py index 4e34537..5d4cb18 100644 --- a/hopper/api/dto.py +++ b/hopper/api/dto.py @@ -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): diff --git a/hopper/api/views.py b/hopper/api/views.py index e6a31cf..00d1d5d 100644 --- a/hopper/api/views.py +++ b/hopper/api/views.py @@ -14,7 +14,7 @@ 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() @@ -60,7 +60,7 @@ 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, @@ -112,6 +112,10 @@ async def get_player_info( "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( @@ -130,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 diff --git a/hopper/countdown_timer.py b/hopper/countdown_timer.py new file mode 100644 index 0000000..c7786c0 --- /dev/null +++ b/hopper/countdown_timer.py @@ -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() diff --git a/hopper/engine.py b/hopper/engine.py index 7654c00..4f9e51e 100644 --- a/hopper/engine.py +++ b/hopper/engine.py @@ -3,7 +3,8 @@ import logging import random from typing import Optional -from hopper.enums import Direction, PlayerMoveResult, GameState +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 ( @@ -29,8 +30,8 @@ class GameEngine: self.ws_server = ws_server self.players = PlayerList() self._inacivity_watchdog = None - self.__debug_print_board() - self.game_state = GameState.RUNNING + self._purchase_countdown_timer: Optional[CountdownTimer] = None + self.reset_game() def dump_board(self) -> list[list[str]]: dump = self.board.dump() @@ -63,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=self._create_player_start_position(), + state=PlayerState.CREATED, ) self.players.append(player) @@ -115,17 +121,17 @@ class GameEngine: player.reset_timeout() if self.game_state == GameState.LOCK_FOR_MOVEMENT: - raise GameLockForMovement("Player reached destination. Can't move now.") + raise GameLockForMovement("Player reached destination. Can't move anymore.") # player will not be able to move once they reach the destination - if player.reached_destination: - self.game_state = GameState.LOCK_FOR_MOVEMENT + 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): raise PositionOutOfBounds() @@ -137,7 +143,7 @@ class GameEngine: player.move_count += 1 if self._is_player_on_destination(player): - player.reached_destination = True + player.state = PlayerState.ON_DESTINATION logging.info(f"Player {player} reached destination!") if self.ws_server: @@ -145,7 +151,8 @@ class GameEngine: 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 await asyncio.sleep(settings.game.MOVE_DELAY) @@ -163,6 +170,10 @@ class GameEngine: 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) diff --git a/hopper/enums.py b/hopper/enums.py index 5b37e9d..26586ba 100644 --- a/hopper/enums.py +++ b/hopper/enums.py @@ -26,7 +26,8 @@ class GameState(Enum): ENDGAME = auto() -class PlayerState(Enum): - PLAYING = auto() - ON_DESTINATION = auto() - INACTIVE = auto() +class PlayerState(str, Enum): + CREATED = "CREATED" + MOVING = "MOVING" + ON_DESTINATION = "ON_DESTINATION" + INACTIVE = "INACTIVE" diff --git a/hopper/models/config.py b/hopper/models/config.py index 5dfb3a3..85141b6 100644 --- a/hopper/models/config.py +++ b/hopper/models/config.py @@ -42,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 diff --git a/hopper/models/player.py b/hopper/models/player.py index ce6c053..5d5010d 100644 --- a/hopper/models/player.py +++ b/hopper/models/player.py @@ -3,6 +3,8 @@ import uuid from dataclasses import dataclass, field from typing import Optional +from hopper.enums import PlayerState + @dataclass class Position: @@ -20,9 +22,9 @@ 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 - reached_destination: bool = False def reset_timeout(self) -> None: self.last_seen = datetime.datetime.now() diff --git a/hopper/models/ws_dto.py b/hopper/models/ws_dto.py index c6fd72a..40bead7 100644 --- a/hopper/models/ws_dto.py +++ b/hopper/models/ws_dto.py @@ -16,7 +16,7 @@ class LayerDto(BaseModel): objects: list[LayerObjectDto] class GameDumpPlayerDto(PlayerDto): - reached_destination: bool + ... class GameDumpDto(BaseModel): diff --git a/settings_template.py b/settings_template.py index b80c4ba..e1ad9c9 100644 --- a/settings_template.py +++ b/settings_template.py @@ -14,5 +14,6 @@ settings = Settings( inacivity_watchdog=InactivityWatchdogSettings(), log_level=logging.INFO, ws_server=WSServerSettings(), + purchase_timeout=10, debug=None, )