6 Commits

Author SHA1 Message Date
69e087c0c9 Drop old purchase views and models 2023-05-10 15:49:08 +02:00
24d05dc234 Handle connection error in ws handler 2023-05-06 09:38:09 +02:00
7fd6ffca25 Multistage build 2023-05-03 17:57:46 +02:00
2dd246ee76 Add purchase product errors docs 2023-04-23 10:03:22 +02:00
8ecd0f92df Update readme 2023-04-21 15:19:35 +02:00
1dba9d1424 Merge branch 'dockerize' 2023-04-21 15:05:01 +02:00
11 changed files with 168 additions and 127 deletions

View File

@ -1,12 +1,13 @@
FROM python:3.10.11-alpine3.17 FROM python:3.10.11-alpine3.17 as env-builder
# take arguments # handle optional arguments
ARG INTERNAL_API_PORT ARG INTERNAL_API_PORT=8010
ARG INTERNAL_WS_PORT ARG INTERNAL_WS_PORT=8011
RUN \ RUN \
pip install pip -U && \ apk add --no-cache gcc musl-dev libffi-dev && \
pip install poetry --no-cache-dir pip install pip -U --no-cache-dir --prefer-binary && \
pip install poetry --no-cache-dir --prefer-binary
WORKDIR /app WORKDIR /app
@ -29,6 +30,18 @@ RUN \
# install python libs # install python libs
pip install -r requirements.txt --no-cache-dir --prefer-binary pip install -r requirements.txt --no-cache-dir --prefer-binary
FROM python:3.10.11-alpine3.17 as runner
WORKDIR /app
COPY --from=env-builder /venv /venv
# set python thingies and activate virtual environment
ENV \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
# copy all relevant files # copy all relevant files
COPY ./.docker/* ./ COPY ./.docker/* ./
COPY ./hopper ./hopper COPY ./hopper ./hopper

View File

@ -38,10 +38,11 @@ docker-clean:
docker-build: docker-build:
@docker \ @docker \
build . \ buildx build \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \ --build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \ --build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
-t $(CONTAINER_NAME) --tag $(CONTAINER_NAME) \
.
docker-run: docker-run:
@docker \ @docker \

View File

@ -62,10 +62,59 @@ EndPlayer --> UnlockGame: Unlock game\n for all players
## FairHopper Game Server ## FairHopper Game Server
### Start server as docker container
Build image:
```sh
docker build . -t CONTAINER_NAME
```
Create docker container:
```sh
docker \
create \
--publish EXTERNAL_API_PORT:8010 \
--publish EXTERNAL_WS_PORT:8011 \
--name=CONTAINER_NAME \
IMAGE_NAME
```
Parameters:
- `EXTERNAL_API_PORT` - REST API port
- `EXTERNAL_WS_PORT` - Websockets port
- `CONTAINER_NAME` - FairHopper container name
- `IMAGE_NAME` - FairHopper image name
Start docker container:
```sh
docker start CONTAINER_NAME -d
```
Stop docker container:
```sh
docker stop CONTAINER_NAME
```
Example:
```sh
docker build . -t fairhopper-service
docker \
run \
--publish 8010:8010 \
--publish 8011:8011 \
--name=fairhopper-service \
fairhopper \
--detach
docker start fairhopper-service -d
docker stop fairhopper-service
```
### Start server on local machine
Requirements: Requirements:
- Python 3.10+ - Python 3.10+
### Install virtual envirnonment #### Install virtual envirnonment
Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python. Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python.
@ -79,14 +128,14 @@ Install virtual environment:
poetry install poetry install
``` ```
### Setting up #### Setting up
Copy `settings_template.py` to `settings.py`. Copy `settings_template.py` to `settings.py`.
Edit `settings.py` and customize application. Edit `settings.py` and customize application.
### Starting FairHopper Game Server #### Starting FairHopper Game Server
```sh ```sh
make run make run

View File

@ -8,6 +8,7 @@
<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">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/frontend.js"></script> <script src="js/frontend.js"></script>
@ -19,6 +20,9 @@
<h1 class="mt-1 mb-2"> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client FairHopper Visualisation Client
</h1> </h1>
<button type="button" class="btn btn-primary test-button">
Launch demo modal
</button>
<div id="purchase-container" class="purchase-container d-none"> <div id="purchase-container" class="purchase-container d-none">
<div class="d-flex header"> <div class="d-flex header">
<h3> <h3>
@ -42,6 +46,38 @@
</div> </div>
</div> </div>
</main> </main>
<div class="modal fade" id="player-on-destination-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Player on destination</h5>
</div>
<div class="modal-body">
Player <strong class="player-name"></strong>
reached destination in <strong class="move-count"></strong>
moves.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary">
Finish product selection
</button>
</div>
</div>
</div>
</div>
<script>
document.querySelector(".test-button").onclick = () => {
playerReachedDestination({
player: {
name: "Pero Perić",
move_count: 123,
}
});
}
</script>
</body> </body>
</html> </html>

View File

@ -96,6 +96,17 @@ function renderGameDump(data) {
renderPlayers(data.players); renderPlayers(data.players);
} }
function playerReachedDestination(data) {
console.log(data);
const dlgElement = document.getElementById("player-on-destination-modal");
dlgElement.querySelector(".player-name").textContent = data.player.name;
dlgElement.querySelector(".move-count").textContent = data.player.move_count;
const modal = new bootstrap.Modal(dlgElement);
modal.show();
}
function productPurchaseStart(products, purchaseTimeout) { function productPurchaseStart(products, purchaseTimeout) {
console.log("productPurchaseStart:", products); console.log("productPurchaseStart:", products);
const containerElement = document.getElementById("purchase-container"); const containerElement = document.getElementById("purchase-container");
@ -149,6 +160,9 @@ function wsConnect() {
case "game_dump": case "game_dump":
renderGameDump(wsMessage.data); renderGameDump(wsMessage.data);
break; break;
case "player_on_destination":
playerReachedDestination(wsMessage.data);
break;
case "product_purchase_start": case "product_purchase_start":
productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout); productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout);
break; break;

View File

@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from pydantic import BaseModel as PydanticBaseModel from pydantic import BaseModel as PydanticBaseModel
from hopper.enums import PlayerState from hopper.enums import PlayerState
@ -40,11 +38,6 @@ class DestinationDto(BaseModel):
position: PositionDto position: PositionDto
class ProductDto(BaseModel):
name: str
id: str
description: Optional[str] = None
class StartGameRequestDto(BaseModel): class StartGameRequestDto(BaseModel):
player_name: str player_name: str
@ -68,11 +61,3 @@ class PlayerInfoResponseDto(MovePlayerResponseDto):
class ErrorResponseDto(BaseModel): class ErrorResponseDto(BaseModel):
detail: str detail: str
class GetProductsResponse(BaseModel):
products: list[ProductDto]
class PurchaseProductDto(BaseModel):
product_id: str

View File

@ -6,20 +6,20 @@ from hopper.api.dto import (
DestinationDto, DestinationDto,
ErrorResponseDto, ErrorResponseDto,
GameInfoDto, GameInfoDto,
GetProductsResponse,
MovePlayerResponseDto, MovePlayerResponseDto,
PingResponse, PingResponse,
PlayerInfoResponseDto, PlayerInfoResponseDto,
ProductDto,
PurchaseProductDto,
StartGameRequestDto, StartGameRequestDto,
StartGameResponseDto, StartGameResponseDto,
) )
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, GameLockForMovement, PositionOutOfBounds, PurchaseForbiddenForPlayer from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
)
from hopper.models.player import Player from hopper.models.player import Player
from settings import settings
router = APIRouter() router = APIRouter()
@ -148,41 +148,3 @@ async def move_player(
response.status_code = status.HTTP_200_OK response.status_code = status.HTTP_200_OK
return MovePlayerResponseDto(player=player) return MovePlayerResponseDto(player=player)
@router.get("/products", response_model=GetProductsResponse)
async def get_products() -> GetProductsResponse:
return GetProductsResponse(
products=settings.products,
)
@router.get("/products/{id}", response_model=ProductDto)
async def get_product(id: str) -> ProductDto:
for product in settings.products:
if product.id == id:
return ProductDto.from_orm(product)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)
@router.post("/player/{id}/product/purchase", response_model=ProductDto)
async def purchase_product(
body: PurchaseProductDto,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> ProductDto:
for product in settings.products:
if product.id == body.product_id:
try:
await engine.purchase_product(player=player, product=product)
return ProductDto.from_orm(product)
except PurchaseForbiddenForPlayer:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Purchase forbidden for this player",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)

View File

@ -9,7 +9,6 @@ from hopper.errors import (
Collision, Collision,
GameLockForMovement, GameLockForMovement,
PositionOutOfBounds, PositionOutOfBounds,
PurchaseForbiddenForPlayer,
) )
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
@ -22,7 +21,6 @@ from hopper.models.board import (
create_random_position, create_random_position,
) )
from hopper.models.player import Player, PlayerList, Position from hopper.models.player import Player, PlayerList, Position
from hopper.models.product import Product
from hopper.watchdog import InactivityWatchdog from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer from hopper.ws_server import WSServer
from settings import settings from settings import settings
@ -178,9 +176,7 @@ class GameEngine:
self.game_state = GameState.LOCK_FOR_MOVEMENT self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.send_game_dump() await self.send_game_dump()
await self.ws_server.send_product_purchase_start_message( await self.ws_server.send_player_reached_destination_message(player=player)
player=player, products=settings.products
)
logging.info( logging.info(
f"Starting purchase countdown timer for {settings.purchase_timeout} seconds" f"Starting purchase countdown timer for {settings.purchase_timeout} seconds"
@ -214,16 +210,16 @@ class GameEngine:
await asyncio.sleep(settings.game.PURCHASE_START_DELAY) await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
async def purchase_product(self, player: Player, product: Product) -> None: # async def purchase_product(self, player: Player, product: Product) -> None:
if not player.state == PlayerState.ON_DESTINATION: # if not player.state == PlayerState.ON_DESTINATION:
raise PurchaseForbiddenForPlayer() # raise PurchaseForbiddenForPlayer()
if self._purchase_countdown_timer: # if self._purchase_countdown_timer:
self._purchase_countdown_timer.stop() # self._purchase_countdown_timer.stop()
await self.ws_server.send_product_purchase_done_message( # await self.ws_server.send_product_purchase_done_message(
player=player, product=product # player=player, product=product
) # )
await asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY) # await asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY)
await self.reset_game() # await self.reset_game()
def _reset_player(self, player) -> None: def _reset_player(self, player) -> None:
# move player to start position # move player to start position

View File

@ -12,7 +12,3 @@ class Collision(BaseError):
class GameLockForMovement(BaseError): class GameLockForMovement(BaseError):
... ...
class PurchaseForbiddenForPlayer(BaseError):
...

View File

@ -12,7 +12,6 @@ from hopper.api.dto import (
DestinationDto, DestinationDto,
PlayerDto, PlayerDto,
PositionDto, PositionDto,
ProductDto,
) )
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -34,20 +33,13 @@ class GameDumpDto(BaseModel):
layers: list[LayerDto] layers: list[LayerDto]
class ProductPurchaseStartDto(BaseModel):
player: PlayerDto
products: list[ProductDto]
timeout: int
class ProductPurchaseTimerDto(BaseModel): class ProductPurchaseTimerDto(BaseModel):
time_left: int time_left: int
player: PlayerDto player: PlayerDto
class ProductPurchaseDoneDto(BaseModel): class PlayerReachedDestinationDto(BaseModel):
player: PlayerDto player: PlayerDto
product: Optional[ProductDto] = None
TMessageData = TypeVar("TMessageData", bound=BaseModel) TMessageData = TypeVar("TMessageData", bound=BaseModel)
@ -69,11 +61,6 @@ class WSGameDumpMessage(WSMessage):
data: GameDumpDto data: GameDumpDto
class WSProductPurchaseStartMessage(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductPurchaseTimerTickMessage(WSMessage): class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick" message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto data: ProductPurchaseTimerDto
@ -81,4 +68,8 @@ class WSProductPurchaseTimerTickMessage(WSMessage):
class WSProductPurchaseDoneMessage(WSMessage): class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done" message: str = "product_purchase_done"
data: ProductPurchaseDoneDto
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -5,22 +5,19 @@ from typing import Iterable, Optional
import websockets import websockets
from websockets import WebSocketServerProtocol from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from hopper.models.player import Player from hopper.models.player import Player
from hopper.models.product import Product from hopper.models.product import Product
from hopper.models.ws_dto import ( from hopper.models.ws_dto import (
GameDumpDto, GameDumpDto,
ProductPurchaseDoneDto, PlayerReachedDestinationDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto, ProductPurchaseTimerDto,
WSGameDumpMessage, WSGameDumpMessage,
WSMessage, WSMessage,
WSProductPurchaseDoneMessage, WSPlayerReachedDestinationMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage, WSProductPurchaseTimerTickMessage,
) )
from settings import settings
class WSServer(Thread): class WSServer(Thread):
@ -44,6 +41,10 @@ class WSServer(Thread):
# we're expecting nothing from client, but read if client sends a message # we're expecting nothing from client, but read if client sends a message
await websocket.recv() await websocket.recv()
except ConnectionClosedOK: except ConnectionClosedOK:
logging.info(f"Connection closed OK for client: {websocket.id}")
connected = False
except ConnectionClosedError:
logging.info(f"Connection closed error for client: {websocket.id}")
connected = False connected = False
finally: finally:
self.connected_clients.remove(websocket) self.connected_clients.remove(websocket)
@ -89,14 +90,10 @@ class WSServer(Thread):
message = self._create_game_dump_message() message = self._create_game_dump_message()
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
async def send_product_purchase_start_message( async def send_player_reached_destination_message(self, player: Player) -> None:
self, player: Player, products: Iterable[Product] message = WSPlayerReachedDestinationMessage(
) -> None: data=PlayerReachedDestinationDto(
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
player=player, player=player,
products=products,
timeout=settings.purchase_timeout,
) )
) )
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
@ -115,10 +112,11 @@ class WSServer(Thread):
async def send_product_purchase_done_message( async def send_product_purchase_done_message(
self, player: Player, product: Optional[Product] = None self, player: Player, product: Optional[Product] = None
) -> None: ) -> None:
message = WSProductPurchaseDoneMessage( # message = WSProductPurchaseDoneMessage(
data=ProductPurchaseDoneDto(player=player, product=product), # data=ProductPurchaseDoneDto(player=player, product=product),
) # )
await self.send_message_to_clients(message) # await self.send_message_to_clients(message)
...
async def run_async(self) -> None: async def run_async(self) -> None:
logging.info( logging.info(