12 Commits

Author SHA1 Message Date
afbb3d7436 Cleanup 2023-05-12 20:39:31 +02:00
21a7f111b2 Cleanup from product selection 2023-05-12 20:34:46 +02:00
fb4651ec23 Update readme 2023-05-12 09:30:50 +02:00
6ff6433be3 Update makefile 2023-05-11 21:39:26 +02:00
2653eabb6c Update readme 2023-05-11 21:23:52 +02:00
b56071e2c7 Thread event to stop inactivity WD 2023-05-11 20:01:33 +02:00
78c3286c17 Update docs 2023-05-11 19:42:11 +02:00
b2a132a002 Merge branch 'client-endgame' 2023-05-11 19:38:45 +02:00
d660845d30 Endgame WS messages & docs 2023-05-11 19:36:16 +02:00
76ee207bce Frontend support for product selection 2023-05-11 16:09:32 +02:00
9151aa3e1e Product selection message handler 2023-05-11 15:08:24 +02:00
69e087c0c9 Drop old purchase views and models 2023-05-10 15:49:08 +02:00
19 changed files with 226 additions and 436 deletions

View File

@ -9,7 +9,6 @@ from hopper.models.config import (
Settings,
WSServerSettings,
)
from hopper.models.product import Product
settings = Settings(
game=GameSettings(),
@ -21,14 +20,6 @@ settings = Settings(
inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO,
products=[
Product(name="CocaCola", id="cocacola-id"),
Product(name="Pepsi", id="pepsi-id"),
Product(name="Fanta", id="fanta-id"),
Product(name="Snickers", id="snickers-id"),
Product(name="Mars", id="mars-id"),
Product(name="Burek", id="burek-id"),
],
ws_server=WSServerSettings(
HOST="0.0.0.0",
PORT=int(os.environ.get("FAIRHOPPER_WS_PORT", 8011)),

View File

@ -5,9 +5,11 @@ INTERNAL_WS_PORT=8011
EXTERNAL_API_PORT=8010
EXTERNAL_WS_PORT=8011
timestamp := `/bin/date "+%Y-%m-%d-%H-%M-%S"`
run:
@poetry run \
@ \
poetry run \
uvicorn \
main:app \
--host 0.0.0.0 \
@ -15,7 +17,8 @@ run:
--workers=1
run-dev:
@poetry run \
@ \
poetry run \
uvicorn \
main:app \
--host 0.0.0.0 \
@ -24,7 +27,8 @@ run-dev:
--reload
create-requirements:
@poetry export \
@ \
poetry export \
--without-hashes \
--format=requirements.txt \
> requirements.txt
@ -37,15 +41,17 @@ docker-clean:
docker-build:
@docker \
@ \
docker \
buildx build \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
--tag $(CONTAINER_NAME) \
--tag $(CONTAINER_NAME):$(timestamp) \
.
docker-run:
@docker \
@ \
docker \
run \
--publish $(EXTERNAL_API_PORT):$(INTERNAL_API_PORT) \
--publish $(EXTERNAL_WS_PORT):$(INTERNAL_WS_PORT) \

183
README.md
View File

@ -44,8 +44,7 @@ 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>>
state "Unlock game and restart" as UnlockGame <<end>>
[*] -> StartGame
StartGame -> MovePlayer
@ -55,9 +54,9 @@ DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players
ProductSelected --> UnlockGame: Unlock game\nand restart
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players
EndPlayer --> UnlockGame: Unlock game\nand restart
```
## FairHopper Game Server
@ -170,11 +169,11 @@ package Masterpiece #seashell {
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService"
usecase Vis as "Flutter\nVisualisation\nService"
}
usecase ExtVis1 as "Visualisation\nService"
usecase ExtVis2 as "Visualisation\nService"
usecase ExtVis1 as "Visualisation\nClient"
usecase ExtVis2 as "Visualisation\nClient"
P1 -left-> API: REST API
P2 -left-> API: REST API
@ -204,41 +203,63 @@ Game ->o WS: Send initial state
Client1 ->o WS: Client connect
activate WS #coral
WS -> Client1: Game state
deactivate
deactivate WS
Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
deactivate WS
loop #lightyellow On game state change
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
deactivate
deactivate WS
end
== Product purchase mode ==
== Player reached destination ==
Game -> WS: Purchase start
Game -> Game: Lock game for other players
activate Game #skyblue
Game -> WS: Player reached destination
activate WS #coral
WS o-> Client1: Purchase start
WS o-> Client2: Purchase start
deactivate
WS o-> Client1: Select product
WS o-> Client2: Select product
deactivate WS
deactivate Game
loop #lightyellow Purchase countdown timer
Game ->o WS: Timer count down
loop #lightyellow Product select countdown timer (60s)
Game ->o WS: Timer timeout
activate Game #skyblue
activate WS #coral
WS o-> Client1: Purchase time left
WS o-> Client2: Purchase time left
deactivate
WS o-> Client1: Selection timeout
WS o-> Client2: Selection timeout
deactivate WS
Game -> Game: Unlock game
deactivate Game
end
Game -> WS: Purchase done
Client1 -> Client1: Product selection
activate Client1 #greenyellow
Client1 -> Client1: Dispense product
Client1 ->o WS: Product selected
deactivate Client1
activate WS #coral
WS o-> Client1: Purchase done
WS o-> Client2: Purchase done
deactivate
WS o-> Game: Product selected
activate Game #skyblue
WS o-> Client2: Product selected
deactivate WS
Game -> Game: Unlock game
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
deactivate WS
deactivate Game
```
@ -306,6 +327,7 @@ Response code:
- 403 Forbidden: Player id not valid, probably timeout
- 409 Conflict: Invalid move, obstacle or position out of board
- 422 Unprocessable Content: Validation error
- 423 Locked: Game locked, product selection in progress
Response body:
```json
@ -383,6 +405,8 @@ Response body:
### Game state structure
Direction: Game server -> Clients
Message: `game_dump`
Data:
@ -486,109 +510,42 @@ Data:
}
```
### Product purchase start
### Player reached destination
Message: `product_purchase_start`
Direction: Game server -> Clients
Message: `player_reached_destination`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"id": "2e0f1a50-eaa6-4efd-b0c3-adbf7000eec2",
"name": "Joso",
"active": true,
"position": {
"x": 10,
"y": 10
"x": 5,
"y": 5
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"products": [
{
"name": "CocaCola",
"id": "cocacola-id",
"description": null
},
{
"name": "Pepsi",
"id": "pepsi-id",
"description": null
},
{
"name": "Fanta",
"id": "fanta-id",
"description": null
},
{
"name": "Snickers",
"id": "snickers-id",
"description": null
},
{
"name": "Mars",
"id": "mars-id",
"description": null
},
{
"name": "Burek",
"id": "burek-id",
"description": null
}
],
"timeout": 5
}
```
### Product purchase timer tick
Message: `product_purchase_timer_tick`
Data:
```json
{
"time_left": 4,
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"move_count": 6,
"move_attempt_count": 6,
"state": "ON_DESTINATION"
}
}
```
### Product purchase timer done
### Product selection timeout
Message: `product_purchase_done`
Direction: Game server -> Clients
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"product": {
"name": "CocaCola",
"id": "cocacola-id",
"description": null
}
}
```
Message: `product_selection_timeout`
If product selection timeout occured, product will be null.
Data: `null`
### Product selection done
Message: `product_selection_done`
Direction: Client -> Game server, Game server -> Clients
Data: `null`

View File

@ -34,13 +34,6 @@ POST http://localhost:8010/player/test-player-pero/move/up
POST http://localhost:8010/player/test-player-pero/move/down
###
# purchase product
POST http://localhost:8010/player/test-player-pero/product/purchase
Content-Type: application/json
{
"product_id": "cocacola-id"
}
###
# move Mirko left

View File

@ -8,6 +8,9 @@
<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">
<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/frontend.js"></script>
@ -19,15 +22,6 @@
<h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<div id="purchase-container" class="purchase-container d-none">
<div class="d-flex header">
<h3>
Product selection
</h3>
<h3 id="purchase-countdown" class="countdown"></h3>
</div>
<div id="products-content" class="products-content"></div>
</div>
<div class="row">
<div class="col-10">
<div class="board-container">
@ -42,6 +36,26 @@
</div>
</div>
</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" id="finish-product-selection" data-bs-dismiss="modal">
Finish product selection
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -2,6 +2,9 @@ if (typeof FAIRHOPPER_WS_SERVER === "undefined") {
var FAIRHOPPER_WS_SERVER = "ws://127.0.0.1:8011";
}
let ws = null;
let playerOnDestinationModal = null;
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
@ -87,8 +90,6 @@ function renderDestination(position) {
}
function renderGameDump(data) {
closePurchaseWindow();
createBoard(data.board);
renderObstacles(data.layers);
renderDestination(data.destination.position);
@ -96,46 +97,24 @@ function renderGameDump(data) {
renderPlayers(data.players);
}
function productPurchaseStart(products, purchaseTimeout) {
console.log("productPurchaseStart:", products);
const containerElement = document.getElementById("purchase-container");
const contentElement = document.getElementById("products-content");
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
contentElement.innerHTML = products
.map((product) => {
return `
<div class="card product" id="product-${product.id}">
<img src="img/products/${product.name}.jpeg" class="card-img-topx" alt="${product.name}">
<div class="card-body">
<h5 class="card-title">${product.name}</h5>
</div>
</div>
`;
})
.join("");
containerElement.classList.remove("d-none");
purchaseTimeoutElement.innerText = purchaseTimeout;
function playerReachedDestination(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;
playerOnDestinationModal.show();
}
function productPurchaseTimerTick(timeLeft) {
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
purchaseTimeoutElement.innerText = timeLeft;
function productSelectionTimeout() {
playerOnDestinationModal.hide();
}
function closePurchaseWindow() {
const container = document.getElementById("purchase-container");
container.classList.add("d-none");
}
function productPurchaseDone(product) {
const cardContainer = document.getElementById(`product-${product.id}`);
cardContainer.classList.add("selected");
function productSelectionDone() {
playerOnDestinationModal.hide();
}
function wsConnect() {
console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
let ws = new WebSocket(FAIRHOPPER_WS_SERVER);
ws = new WebSocket(FAIRHOPPER_WS_SERVER);
ws.onopen = () => {
console.log("WS connected");
@ -149,14 +128,14 @@ function wsConnect() {
case "game_dump":
renderGameDump(wsMessage.data);
break;
case "product_purchase_start":
productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout);
case "player_reached_destination":
playerReachedDestination(wsMessage.data);
break;
case "product_purchase_timer_tick":
productPurchaseTimerTick(wsMessage.data.time_left);
case "product_selection_timeout":
productSelectionTimeout();
break;
case "product_purchase_done":
productPurchaseDone(wsMessage.data.product);
case "product_selection_done":
productSelectionDone();
break;
default:
console.error("Unknown message:", wsMessage);
@ -164,6 +143,7 @@ function wsConnect() {
};
ws.onclose = (e) => {
ws = null;
setTimeout(() => {
wsConnect();
}, 1000);
@ -175,6 +155,24 @@ function wsConnect() {
};
}
function finishProductSelection() {
if (!ws) {
return;
}
const wsMessage = {
message: "product_selection_done",
data: null,
};
ws.send(JSON.stringify(wsMessage));
}
window.onload = () => {
const dlgElement = document.getElementById("player-on-destination-modal");
playerOnDestinationModal = new bootstrap.Modal(dlgElement);
document.getElementById("finish-product-selection").onclick = () => {
finishProductSelection();
};
wsConnect();
};

View File

@ -18,7 +18,7 @@ main.main-container {
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
padding-bottom: 0;
}
.cell {
@ -56,44 +56,3 @@ ul.players {
border-style: solid;
border-color: darkred transparent transparent transparent;
}
.purchase-container {
width: 50vw;
position: fixed;
top: 200px;
left: 50%;
padding: 20px;
transform: translateX(-50%);
background-color: darkred;
z-index: 999;
border-radius: 10px;
}
.purchase-container .header {
color: white;
margin-bottom: 20px;
}
.purchase-container .header .countdown {
margin-left: auto;
}
.purchase-container .products-content {
display: grid;
grid-gap: 10px;
grid-template-columns: 1fr 1fr 1fr;
}
.purchase-container .products-content .product.selected {
background-color: pink;
}
.purchase-container .products-content .product .card-title {
text-align: center;
font-size: 12pt;
}
.purchase-container .products-content .product img {
margin: 20px;
max-height: 300px;
}

View File

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

View File

@ -6,12 +6,9 @@ from hopper.api.dto import (
DestinationDto,
ErrorResponseDto,
GameInfoDto,
GetProductsResponse,
MovePlayerResponseDto,
PingResponse,
PlayerInfoResponseDto,
ProductDto,
PurchaseProductDto,
StartGameRequestDto,
StartGameResponseDto,
)
@ -21,10 +18,8 @@ from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.player import Player
from settings import settings
router = APIRouter()
@ -153,58 +148,3 @@ async def move_player(
response.status_code = status.HTTP_200_OK
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,
responses={
status.HTTP_200_OK: {
"model": ProductDto,
"description": "Product purchased",
},
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": "Purchase forbidden for this player",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with id not found, probably kicked out",
},
},
)
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

@ -5,12 +5,7 @@ from typing import Optional
from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
@ -22,7 +17,6 @@ from hopper.models.board import (
create_random_position,
)
from hopper.models.player import Player, PlayerList, Position
from hopper.models.product import Product
from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer
from settings import settings
@ -178,30 +172,21 @@ class GameEngine:
self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.send_game_dump()
await self.ws_server.send_product_purchase_start_message(
player=player, products=settings.products
)
await self.ws_server.send_player_reached_destination_message(player=player)
logging.info(
f"Starting purchase countdown timer for {settings.purchase_timeout} seconds"
f"Starting product selection countdown timer for {settings.purchase_timeout} seconds"
)
def on_purchase_timer_tick(time_left) -> None:
logging.info(f"Purchase countdown timer tick, time left: {time_left}")
asyncio.run(
self.ws_server.send_product_purchase_time_left_message(
player=player, time_left=time_left
)
logging.info(
f"Product selection countdown timer tick, time left: {time_left}"
)
def on_purchase_timer_done() -> None:
logging.info("Ding ding! Purchase countdown timer timeout")
logging.info("Ding ding! Product selection countdown timer timeout")
self._purchase_countdown_timer = None
asyncio.run(
self.ws_server.send_product_purchase_done_message(
player=player, product=None
)
)
asyncio.run(self.ws_server.send_product_selection_timeout_message())
self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump())
@ -214,15 +199,11 @@ class GameEngine:
await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
async def purchase_product(self, player: Player, product: Product) -> None:
if not player.state == PlayerState.ON_DESTINATION:
raise PurchaseForbiddenForPlayer()
async def product_selection_done(self) -> None:
logging.info("Product selection done, unlocking game")
if self._purchase_countdown_timer:
self._purchase_countdown_timer.stop()
await self.ws_server.send_product_purchase_done_message(
player=player, product=product
)
await asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY)
await self.ws_server.send_product_selection_done_message()
await self.reset_game()
def _reset_player(self, player) -> None:

View File

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

View File

@ -14,6 +14,13 @@ BOARD_DUMP_CHARS: dict[ObjectType, str] = {
}
def create_random_position(board_width: int, board_height: int) -> Position:
return Position(
x=random.randint(0, board_width - 1),
y=random.randint(0, board_height - 1),
)
@dataclass
class LayerObject:
type_: ObjectType
@ -102,10 +109,3 @@ class BoardLayout:
)
)
return layers
def create_random_position(board_width: int, board_height: int) -> Position:
return Position(
x=random.randint(0, board_width - 1),
y=random.randint(0, board_height - 1),
)

View File

@ -3,14 +3,12 @@ from dataclasses import dataclass
from typing import List, Optional
from hopper.models.player import Player
from hopper.models.product import Product
@dataclass
class GameSettings:
MOVE_DELAY: float = 0.5 # seconds
PURCHASE_START_DELAY: float = 2 # seconds
PURCHASE_FINISHED_DELAY: float = 2 # seconds
@dataclass
@ -47,5 +45,4 @@ class Settings:
ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO
products: List[Product] = None
debug: Optional[DebugSettings] = None

View File

@ -1,10 +0,0 @@
import uuid
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class Product:
name: str
id: str = field(default_factory=lambda: str(uuid.uuid4()))
description: Optional[str] = None

View File

@ -6,14 +6,7 @@ from typing import Optional, TypeVar
from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
ProductDto,
)
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.enums import ObjectType
@ -34,20 +27,8 @@ class GameDumpDto(BaseModel):
layers: list[LayerDto]
class ProductPurchaseStartDto(BaseModel):
class PlayerReachedDestinationDto(BaseModel):
player: PlayerDto
products: list[ProductDto]
timeout: int
class ProductPurchaseTimerDto(BaseModel):
time_left: int
player: PlayerDto
class ProductPurchaseDoneDto(BaseModel):
player: PlayerDto
product: Optional[ProductDto] = None
TMessageData = TypeVar("TMessageData", bound=BaseModel)
@ -63,22 +44,25 @@ class WSMessage(GenericModel):
def to_str(self) -> str:
return json.dumps(self.dict())
@classmethod
@property
def message_type(cls) -> str:
return cls.__fields__["message"].default
class WSGameDumpMessage(WSMessage):
message: str = "game_dump"
data: GameDumpDto
class WSProductPurchaseStartMessage(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_selection_done"
class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto
class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_selection_timeout"
class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done"
data: ProductPurchaseDoneDto
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -2,7 +2,7 @@ import asyncio
import datetime
import logging
import time
from threading import Thread
from threading import Thread, Event
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
@ -10,18 +10,17 @@ from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: WSServer = None
) -> None:
def __init__(self, players: PlayerList, ws_server: WSServer = None) -> None:
self.players = players
self.ws_server = ws_server
self.stopped = False
self.stop_event = Event()
super().__init__(daemon=True)
def run(self) -> None:
logging.info("Starting inactivity watchdog")
while not self.stopped:
while not self.stop_event.is_set():
self.cleanup_players()
if not self.stop_event.is_set():
time.sleep(settings.inacivity_watchdog.TICK_INTERVAL)
def cleanup_players(self) -> None:
@ -64,4 +63,4 @@ class InactivityWatchdog(Thread):
asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None:
self.stopped = True
self.stop_event.set()

View File

@ -1,26 +1,22 @@
import asyncio
import json
import logging
from threading import Thread
from typing import Iterable, Optional
import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK, ConnectionClosedError
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from hopper.models.player import Player
from hopper.models.product import Product
from hopper.models.ws_dto import (
GameDumpDto,
ProductPurchaseDoneDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto,
PlayerReachedDestinationDto,
WSGameDumpMessage,
WSMessage,
WSProductPurchaseDoneMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage,
WSPlayerReachedDestinationMessage,
WSProductSelectionDoneMessage,
WSProductSelectionTimeoutMessage,
)
from settings import settings
class WSServer(Thread):
@ -29,6 +25,31 @@ class WSServer(Thread):
self.port = port
super().__init__(daemon=True)
async def handle_rcv_message(
self, client: WebSocketServerProtocol, raw_message: str
) -> None:
try:
ws_message = json.loads(raw_message)
except Exception as ex:
logging.error(
f"Error decoding WS message from {client.id} {raw_message}: {ex}"
)
return None
data_message = ws_message.get("message")
if data_message == WSProductSelectionDoneMessage.message_type:
await self.handle_rcv_product_selection_done(client)
async def handle_rcv_product_selection_done(
self, client: WebSocketServerProtocol
) -> None:
logging.info(f"Handle WSProductSelectionDoneMessage: {client.id}")
# avoid circular imports
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
await engine.product_selection_done()
async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket)
@ -42,7 +63,10 @@ class WSServer(Thread):
while connected:
try:
# we're expecting nothing from client, but read if client sends a message
await websocket.recv()
rcv_data = await websocket.recv()
await self.handle_rcv_message(
client=websocket, raw_message=rcv_data
)
except ConnectionClosedOK:
logging.info(f"Connection closed OK for client: {websocket.id}")
connected = False
@ -93,35 +117,20 @@ class WSServer(Thread):
message = self._create_game_dump_message()
await self.send_message_to_clients(message)
async def send_product_purchase_start_message(
self, player: Player, products: Iterable[Product]
) -> None:
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
async def send_player_reached_destination_message(self, player: Player) -> None:
message = WSPlayerReachedDestinationMessage(
data=PlayerReachedDestinationDto(
player=player,
products=products,
timeout=settings.purchase_timeout,
)
)
await self.send_message_to_clients(message)
async def send_product_purchase_time_left_message(
self, player: Player, time_left: int
) -> None:
message = WSProductPurchaseTimerTickMessage(
data=ProductPurchaseTimerDto(
player=player,
time_left=time_left,
)
)
async def send_product_selection_done_message(self) -> None:
message = WSProductSelectionDoneMessage()
await self.send_message_to_clients(message)
async def send_product_purchase_done_message(
self, player: Player, product: Optional[Product] = None
) -> None:
message = WSProductPurchaseDoneMessage(
data=ProductPurchaseDoneDto(player=player, product=product),
)
async def send_product_selection_timeout_message(self) -> None:
message = WSProductSelectionTimeoutMessage()
await self.send_message_to_clients(message)
async def run_async(self) -> None:

View File

@ -9,7 +9,6 @@ from hopper.models.config import (
WSServerSettings,
)
from hopper.models.player import Player, Position
from hopper.models.product import Product
settings = Settings(
game=GameSettings(),
@ -21,14 +20,6 @@ settings = Settings(
inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO,
products=[
Product(name="CocaCola", id="cocacola-id"),
Product(name="Pepsi", id="pepsi-id"),
Product(name="Fanta", id="fanta-id"),
Product(name="Snickers", id="snickers-id"),
Product(name="Mars", id="mars-id"),
Product(name="Burek", id="burek-id"),
],
ws_server=WSServerSettings(),
debug=DebugSettings(
PRINT_BOARD=True,