8 Commits

Author SHA1 Message Date
28a981980f Product purchase 2023-03-31 13:06:27 +02:00
e1e77aba96 Optimizations 2023-03-31 12:19:48 +02:00
659ca82d74 Send player info with product purchase data 2023-03-31 11:51:05 +02:00
210a6aff7c Producs on FE 2023-03-31 10:19:21 +02:00
c9707c0523 Threads creation optimization 2023-03-30 21:13:54 +02:00
059408242c Send purchase state 2023-03-30 21:09:11 +02:00
6111d07f09 WSMessage object 2023-03-30 18:53:24 +02:00
b80130d942 Purchase timeout 2023-03-30 13:16:25 +02:00
23 changed files with 592 additions and 209 deletions

View File

@ -100,6 +100,7 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi
### Architecture ### Architecture
```plantuml ```plantuml
scale 1024 width
actor "Player 1" as P1 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
@ -129,6 +130,7 @@ WS --> ExtVis2: WS Game State
### WebSockets ### WebSockets
```plantuml ```plantuml
scale 1024 width
box "FairHopper Game Server" #lightcyan box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine" participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
@ -136,6 +138,8 @@ endbox
participant Client1 as "Visualisation\nClient 1" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2" participant Client2 as "Visualisation\nClient 2"
== Player movement mode ==
Game ->o WS: Send initial state Game ->o WS: Send initial state
Client1 ->o WS: Client connect Client1 ->o WS: Client connect
@ -155,6 +159,27 @@ loop #lightyellow On game state change
WS o-> Client2: Game state WS o-> Client2: Game state
deactivate deactivate
end end
== Product purchase mode ==
Game -> WS: Purchase start
activate WS #coral
WS o-> Client1: Purchase start
WS o-> Client2: Purchase start
deactivate
loop #lightyellow Purchase countdown timer
Game ->o WS: Timer count down
activate WS #coral
WS o-> Client1: Purchase time left
WS o-> Client2: Purchase time left
deactivate
end
Game -> WS: Purchase done
activate WS #coral
WS o-> Client1: Purchase done
WS o-> Client2: Purchase done
deactivate
``` ```

View File

@ -34,6 +34,15 @@ POST http://localhost:8010/player/test-player-pero/move/up
POST http://localhost:8010/player/test-player-pero/move/down 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_uuid": "cocacola-id"
}
###
# move Mirko left # move Mirko left
POST http://localhost:8010/player/test-player-mirko/move/left POST http://localhost:8010/player/test-player-mirko/move/left
### ###

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -8,15 +8,25 @@
<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="js/frontend.js"></script>
<title>FairHopper Visualisation Client</title> <title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<div class="container-fluid"> <main class="container-fluid container">
<h1 class="mt-1 mb-2"> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client FairHopper Visualisation Client
</h1> </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="row">
<div class="col-10"> <div class="col-10">
<div class="board-container"> <div class="board-container">
@ -30,125 +40,7 @@
<ul class="players" id="players-content"></ul> <ul class="players" id="players-content"></ul>
</div> </div>
</div> </div>
</div> </main>
</body> </body>
<script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return `
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach(layer => {
objects = objects.concat(layer.objects.filter(obj => obj.type === type))
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
function wsConnect() {
let ws = new WebSocket('ws://localhost:8011');
ws.onopen = () => {
console.log("WS connected")
};
ws.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log("WS message received:", data)
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
};
ws.onclose = (e) => {
setTimeout(function () {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
}
</script>
</html> </html>

179
frontend/js/frontend.js Normal file
View File

@ -0,0 +1,179 @@
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players
.filter((player) => player.active)
.map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return `
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
})
.join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players
.filter((player) => player.active)
.forEach((player) => {
const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach((layer) => {
objects = objects.concat(layer.objects.filter((obj) => obj.type === type));
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach((obj) => {
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
function renderGameDump(data) {
createBoard(data.board);
renderObstacles(data.layers);
renderDestination(data.destination.position);
renderPlayerList(data.players);
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");
const html = products
.map((product) => {
return `
<div class="card product">
<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("");
contentElement.innerHTML = html;
containerElement.classList.remove("d-none");
purchaseTimeoutElement.innerText = purchaseTimeout;
}
function productPurchaseTimerTick(timeLeft) {
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
purchaseTimeoutElement.innerText = timeLeft;
}
function productPurchased(product) {
console.log("productPurchased:", product);
}
function productPurchaseDone() {
console.log("productPurchaseDone");
const container = document.getElementById("purchase-container");
container.classList.add("d-none");
}
function wsConnect() {
let ws = new WebSocket("ws://localhost:8011");
ws.onopen = () => {
console.log("WS connected");
};
ws.onmessage = (e) => {
const wsMessage = JSON.parse(e.data);
console.log("WS message received:", wsMessage);
switch (wsMessage.message) {
case "game_dump":
renderGameDump(wsMessage.data);
break;
case "product_purchase_start":
productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout);
break;
case "product_purchase_timer_tick":
productPurchaseTimerTick(wsMessage.data.time_left);
break;
case "product_purchased":
productPurchased(wsMessage.data);
break;
case "product_purchase_done":
productPurchaseDone();
break;
default:
console.error("Unknown message:", wsMessage);
}
};
ws.onclose = (e) => {
setTimeout(function () {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
};

View File

@ -2,6 +2,10 @@ body {
background-color: whitesmoke; background-color: whitesmoke;
} }
main.container {
position: relative;
}
.board-container { .board-container {
background-color: white; background-color: white;
border: 1px solid black; border: 1px solid black;
@ -40,7 +44,6 @@ ul.players {
color: white; color: white;
background-color: darkred; background-color: darkred;
border-radius: 5px; border-radius: 5px;
z-index: 1000;
} }
.player-tooltip::after { .player-tooltip::after {
@ -53,3 +56,39 @@ ul.players {
border-style: solid; border-style: solid;
border-color: darkred transparent transparent transparent; 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 .card-title {
text-align: center;
}
.purchase-container .products-content .product img {
margin: 20px;
max-height: 300px;
}

View File

@ -2,6 +2,7 @@ from typing import Optional
from hopper.engine import GameEngine, GameEngineFactory from hopper.engine import GameEngine, GameEngineFactory
from hopper.ws_server import WSServer from hopper.ws_server import WSServer
from settings import settings
game_engine: Optional[GameEngine] = None game_engine: Optional[GameEngine] = None
@ -12,7 +13,10 @@ def create_game_engine() -> GameEngine:
if game_engine: if game_engine:
raise RuntimeError("Can't call create_game_engine() more than once!") raise RuntimeError("Can't call create_game_engine() more than once!")
ws_server = WSServer(daemon=True) ws_server = WSServer(
host=settings.ws_server.HOST,
port=settings.ws_server.PORT,
)
ws_server.start() ws_server.start()
game_engine = GameEngineFactory.create_default(ws_server=ws_server) game_engine = GameEngineFactory.create_default(ws_server=ws_server)

View File

@ -1,5 +1,7 @@
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
@ -38,6 +40,11 @@ class DestinationDto(BaseModel):
position: PositionDto position: PositionDto
class ProductDto(BaseModel):
name: str
uuid: str
description: Optional[str] = None
class StartGameRequestDto(BaseModel): class StartGameRequestDto(BaseModel):
player_name: str player_name: str
@ -61,3 +68,11 @@ class PlayerInfoResponseDto(MovePlayerResponseDto):
class ErrorResponseDto(BaseModel): class ErrorResponseDto(BaseModel):
detail: str detail: str
class GetProductsResponse(BaseModel):
products: list[ProductDto]
class PurchaseProductDto(BaseModel):
product_uuid: str

View File

@ -6,16 +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 from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds, PurchaseForbiddenForPlayer
from hopper.models.player import Player from hopper.models.player import Player
from settings import settings
router = APIRouter() router = APIRouter()
@ -144,3 +148,42 @@ 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/{uuid}", response_model=ProductDto)
async def get_product(uuid: str) -> ProductDto:
for product in settings.products:
if product.uuid == uuid:
return ProductDto.from_orm(product)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)
@router.post("/player/{uuid}/product/purchase")
async def purchase_product(
body: PurchaseProductDto,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
):
for product in settings.products:
if product.uuid == body.product_uuid:
try:
await engine.purchase_product(player=player, product=product)
except PurchaseForbiddenForPlayer:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Purchase forbidden for this player",
)
break
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)

View File

@ -5,21 +5,27 @@ from typing import Callable, Optional
class CountdownTimer(Thread): class CountdownTimer(Thread):
def __init__( def __init__(
self, seconds: int, callback: Optional[Callable[[], None]] = None self,
seconds: int,
timer_tick_callback: Optional[Callable[[int], None]] = None,
timer_done_callback: Optional[Callable[[], None]] = None,
) -> None: ) -> None:
self.seconds = seconds self.seconds = seconds
self.stop_event = Event() self.stop_event = Event()
self.callback = callback self.timer_tick_callback = timer_tick_callback
super().__init__() self.timer_done_callback = timer_done_callback
super().__init__(daemon=True)
def run(self) -> None: def run(self) -> None:
cnt = self.seconds time_left = self.seconds
while cnt > 0 and not self.stop_event.is_set(): while time_left and not self.stop_event.is_set():
cnt -= 1
time.sleep(1) time.sleep(1)
time_left -= 1
if self.timer_tick_callback and not self.stop_event.is_set():
self.timer_tick_callback(time_left)
if cnt == 0 and self.callback: if time_left == 0 and self.timer_done_callback:
self.callback() self.timer_done_callback()
def stop(self) -> None: def stop(self) -> None:
self.stop_event.set() self.stop_event.set()

View File

@ -4,9 +4,13 @@ import random
from typing import Optional from typing import Optional
from hopper.countdown_timer import CountdownTimer from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, PlayerMoveResult, GameState, PlayerState from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import Collision, PositionOutOfBounds, GameLockForMovement from hopper.errors import (
from hopper.interfaces import SendGameDumpInterface Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -18,20 +22,37 @@ 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 settings import settings from settings import settings
def create_player_start_position(board_width: int, board_height: int) -> Position:
"""Create random position somewhere on the board border"""
border_len = (board_width + board_height) * 2
rnd_position = random.randint(0, border_len - 1)
if rnd_position < board_width * 2:
x = rnd_position % board_width
y = 0 if rnd_position < board_width else board_height - 1
else:
rnd_position -= 2 * board_width
x = 0 if rnd_position < board_height else board_width - 1
y = rnd_position % board_height
return Position(x=x, y=y)
class GameEngine: class GameEngine:
def __init__( def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = 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._purchase_countdown_timer: Optional[CountdownTimer] = None self._purchase_countdown_timer: Optional[CountdownTimer] = None
self.reset_game() self.game_state = GameState.RUNNING
self.__debug_print_board()
def dump_board(self) -> list[list[str]]: def dump_board(self) -> list[list[str]]:
dump = self.board.dump() dump = self.board.dump()
@ -60,19 +81,24 @@ class GameEngine:
self._inacivity_watchdog = InactivityWatchdog( self._inacivity_watchdog = InactivityWatchdog(
players=self.players, players=self.players,
ws_server=self.ws_server, ws_server=self.ws_server,
daemon=True,
) )
self._inacivity_watchdog.start() self._inacivity_watchdog.start()
async def send_game_dump(self):
self.__debug_print_board()
await self.ws_server.send_game_dump()
async def reset_game(self) -> None: async def reset_game(self) -> None:
self.__debug_print_board() self.__debug_print_board()
self.game_state = GameState.RUNNING self.game_state = GameState.RUNNING
self.players.clear()
await self.send_game_dump()
async def start_game_for_player(self, player_name: str) -> Player: 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=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED, state=PlayerState.CREATED,
) )
self.players.append(player) self.players.append(player)
@ -80,27 +106,10 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}") logging.info(f"Starting new game for player: {player}")
self.__debug_print_board() self.__debug_print_board()
if self.ws_server: await self.send_game_dump()
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
def _create_player_start_position(self) -> Position:
"""Create random position somewhere on the board border"""
border_len = (self.board.width + self.board.height) * 2
rnd_position = random.randint(0, border_len - 1)
if rnd_position < self.board.width * 2:
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: def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y) new_position = Position(position.x, position.y)
if direction == Direction.LEFT: if direction == Direction.LEFT:
@ -144,19 +153,12 @@ class GameEngine:
if self._is_player_on_destination(player): if self._is_player_on_destination(player):
player.state = PlayerState.ON_DESTINATION player.state = PlayerState.ON_DESTINATION
logging.info(f"Player {player} reached destination!") await self._player_on_destination(player)
if self.ws_server:
await self.ws_server.send_game_dump()
self.__debug_print_board()
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 self.send_game_dump()
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:
@ -170,9 +172,63 @@ 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: async def _player_on_destination(self, player: Player) -> None:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT self.game_state = GameState.LOCK_FOR_MOVEMENT
self._purchase_countdown_timer = CountdownTimer() await self.send_game_dump()
await self.ws_server.send_product_purchase_start_message(
player=player, products=settings.products
)
logging.info(
f"Starting purchase 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
)
)
def on_purchase_timer_done() -> None:
logging.info("Ding ding! Purchase countdown timer timeout")
self._purchase_countdown_timer = None
asyncio.run(
self.ws_server.send_product_purchase_done_message(
player=player, product=None
)
)
self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump())
self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout,
timer_tick_callback=on_purchase_timer_tick,
timer_done_callback=on_purchase_timer_done,
)
self._purchase_countdown_timer.start()
async def purchase_product(self, player: Player, product: Product) -> None:
if not player.state == PlayerState.ON_DESTINATION:
raise PurchaseForbiddenForPlayer()
if self._purchase_countdown_timer:
self._purchase_countdown_timer.stop()
await self.ws_server.send_product_purchase_done_message(
player=player, product=product
)
await self.reset_game()
def _reset_player(self, player) -> None:
# move player to start position
player.position = create_player_start_position(
self.board.width, self.board.height
)
player.state = PlayerState.CREATED
player.last_seen = None
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)
@ -184,7 +240,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[SendGameDumpInterface] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
@ -210,7 +266,7 @@ class GameEngineFactory:
@staticmethod @staticmethod
def create_default( def create_default(
ws_server: Optional[SendGameDumpInterface] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,

View File

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

View File

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

View File

@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
from hopper.models.player import Player from hopper.models.player import Player
from hopper.models.product import Product
@dataclass @dataclass
@ -44,4 +45,5 @@ class Settings:
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO log_level: int = logging.INFO
products: Optional[List[Product]] = None
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

10
hopper/models/product.py Normal file
View File

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

View File

@ -1,8 +1,19 @@
from __future__ import annotations from __future__ import annotations
from pydantic import Field import json
from typing import Optional, TypeVar
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
ProductDto,
)
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -15,12 +26,59 @@ class LayerDto(BaseModel):
name: str name: str
objects: list[LayerObjectDto] objects: list[LayerObjectDto]
class GameDumpPlayerDto(PlayerDto):
...
class GameDumpDto(BaseModel): class GameDumpDto(BaseModel):
board: BoardDto board: BoardDto
destination: DestinationDto destination: DestinationDto
players: list[GameDumpPlayerDto] players: list[PlayerDto]
layers: list[LayerDto] layers: list[LayerDto]
class ProductPurchaseStartDto(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)
class WSMessage(GenericModel):
message: str
data: Optional[TMessageData] = None
def __str__(self) -> str:
return self.to_str()
def to_str(self) -> str:
return json.dumps(self.dict())
class WSGameDumpMessage(WSMessage):
message: str = "game_dump"
data: GameDumpDto
class WSProductPurchaseStartMessage(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto
class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done"
data: ProductPurchaseDoneDto

View File

@ -3,25 +3,20 @@ import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread
from typing import Optional
from hopper.interfaces import SendGameDumpInterface
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, self, players: PlayerList, ws_server: WSServer = None
players: PlayerList,
ws_server: Optional[SendGameDumpInterface] = None,
*args,
**kwargs,
) -> None: ) -> None:
self.players = players self.players = players
self.ws_server = ws_server self.ws_server = ws_server
self.stopped = False self.stopped = False
super().__init__(*args, **kwargs) super().__init__(daemon=True)
def run(self) -> None: def run(self) -> None:
logging.info("Starting inactivity watchdog") logging.info("Starting inactivity watchdog")
@ -65,8 +60,6 @@ class InactivityWatchdog(Thread):
self.send_game_dump() self.send_game_dump()
def send_game_dump(self): def send_game_dump(self):
if not self.ws_server:
return
logging.info("Sending WS game dump") logging.info("Sending WS game dump")
asyncio.run(self.ws_server.send_game_dump()) asyncio.run(self.ws_server.send_game_dump())

View File

@ -1,17 +1,34 @@
import asyncio import asyncio
import json
import logging import logging
from threading import Thread from threading import Thread
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 ConnectionClosedOK
from hopper.models.ws_dto import GameDumpDto from hopper.models.player import Player
from hopper.models.product import Product
from hopper.models.ws_dto import (
GameDumpDto,
ProductPurchaseDoneDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto,
WSGameDumpMessage,
WSMessage,
WSProductPurchaseDoneMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage,
)
from settings import settings from settings import settings
class WSServer(Thread): class WSServer(Thread):
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
super().__init__(daemon=True)
async def handler(self, websocket: WebSocketServerProtocol) -> None: async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client""" """New handler instance spawns for each connected client"""
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
@ -32,7 +49,20 @@ 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_dump_message(self) -> str: async def send_message_to_client(
self, client: WebSocketServerProtocol, message: WSMessage
) -> None:
message_str = message.to_str()
logging.debug(
f"Sending message {message.message} to clients: {self.connected_clients}: {message_str}"
)
await client.send(message_str)
async def send_message_to_clients(self, message: WSMessage) -> None:
for client in self.connected_clients:
await self.send_message_to_client(client, message)
def _create_game_dump_message(self) -> WSGameDumpMessage:
# avoid circular imports # avoid circular imports
from hopper.api.dependencies import get_game_engine from hopper.api.dependencies import get_game_engine
@ -44,7 +74,7 @@ class WSServer(Thread):
players=engine.players, players=engine.players,
layers=engine.get_board_layout().layers, layers=engine.get_board_layout().layers,
) )
return json.dumps(game_dump.dict()) return WSGameDumpMessage(data=game_dump)
async def send_game_dump_to_client( async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol self, websocket: WebSocketServerProtocol
@ -52,29 +82,53 @@ class WSServer(Thread):
"""Send game dump to the client""" """Send game dump to the client"""
message = self._create_game_dump_message() message = self._create_game_dump_message()
logging.debug(f"Sending game dump to client: {websocket.id}") logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message) await websocket.send(message.to_str())
async def send_game_dump(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:
return
message = self._create_game_dump_message() message = self._create_game_dump_message()
logging.debug( await self.send_message_to_clients(message)
f"Sending game dump to clients: {self.connected_clients}: {message}"
async def send_product_purchase_start_message(
self, player: Player, products: Iterable[Product]
) -> None:
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
player=player,
products=products,
timeout=settings.purchase_timeout,
)
) )
for client in self.connected_clients: await self.send_message_to_clients(message)
await client.send(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,
)
)
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),
)
await self.send_message_to_clients(message)
async def run_async(self) -> None: async def run_async(self) -> None:
logging.info( logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}" f"Starting FairHopper Websockets Server on {self.host}:{self.port}"
) )
async with websockets.serve( async with websockets.serve(
ws_handler=self.handler, ws_handler=self.handler,
host=settings.ws_server.HOST, host=self.host,
port=settings.ws_server.PORT, port=self.port,
): ):
await asyncio.Future() # run forever await asyncio.Future() # run forever