4 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
21 changed files with 484 additions and 244 deletions

View File

@ -100,6 +100,7 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi
### Architecture
```plantuml
scale 1024 width
actor "Player 1" as P1
actor "Player 2" as P2
actor "Player 3" as P3
@ -129,6 +130,7 @@ WS --> ExtVis2: WS Game State
### WebSockets
```plantuml
scale 1024 width
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server"
@ -136,6 +138,8 @@ endbox
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
== Player movement mode ==
Game ->o WS: Send initial state
Client1 ->o WS: Client connect
@ -155,6 +159,27 @@ loop #lightyellow On game state change
WS o-> Client2: Game state
deactivate
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
###
# purchase product
POST http://localhost:8010/player/test-player-pero/product/purchase
Content-Type: application/json
{
"product_uuid": "cocacola-id"
}
###
# move Mirko 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"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css">
<script src="js/frontend.js"></script>
<title>FairHopper Visualisation Client</title>
</head>
<body>
<div class="container-fluid">
<main class="container-fluid container">
<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">
@ -30,155 +40,7 @@
<ul class="players" id="players-content"></ul>
</div>
</div>
</div>
</main>
</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 renderGameDump(data) {
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
function productPurchaseStart(products) {
console.log("productPurchaseStart:", products)
}
function productPurchased(product) {
console.log("productPurchased:", product)
}
function productPurchaseDone() {
console.log("productPurchaseDone")
}
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)
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();
}
</script>
</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;
}
main.container {
position: relative;
}
.board-container {
background-color: white;
border: 1px solid black;
@ -40,7 +44,6 @@ ul.players {
color: white;
background-color: darkred;
border-radius: 5px;
z-index: 1000;
}
.player-tooltip::after {
@ -53,3 +56,39 @@ 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 .card-title {
text-align: center;
}
.purchase-container .products-content .product img {
margin: 20px;
max-height: 300px;
}

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel as PydanticBaseModel
from hopper.enums import PlayerState
@ -38,6 +40,11 @@ class DestinationDto(BaseModel):
position: PositionDto
class ProductDto(BaseModel):
name: str
uuid: str
description: Optional[str] = None
class StartGameRequestDto(BaseModel):
player_name: str
@ -61,3 +68,11 @@ class PlayerInfoResponseDto(MovePlayerResponseDto):
class ErrorResponseDto(BaseModel):
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,
ErrorResponseDto,
GameInfoDto,
GetProductsResponse,
MovePlayerResponseDto,
PingResponse,
PlayerInfoResponseDto,
ProductDto,
PurchaseProductDto,
StartGameRequestDto,
StartGameResponseDto,
)
from hopper.engine import GameEngine
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 settings import settings
router = APIRouter()
@ -144,3 +148,42 @@ 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/{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):
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:
self.seconds = seconds
self.stop_event = Event()
self.callback = callback
self.timer_tick_callback = timer_tick_callback
self.timer_done_callback = timer_done_callback
super().__init__(daemon=True)
def run(self) -> None:
cnt = self.seconds
while cnt and not self.stop_event.is_set():
cnt -= 1
time_left = self.seconds
while time_left and not self.stop_event.is_set():
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:
self.callback()
if time_left == 0 and self.timer_done_callback:
self.timer_done_callback()
def stop(self) -> None:
self.stop_event.set()

View File

@ -5,8 +5,12 @@ 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
from hopper.interfaces import SendGameDumpInterface
from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
@ -18,20 +22,37 @@ 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
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:
def __init__(
self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None
) -> None:
def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self.board = board
self.ws_server = ws_server
self.players = PlayerList()
self._inacivity_watchdog = 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]]:
dump = self.board.dump()
@ -63,15 +84,21 @@ class GameEngine:
)
self._inacivity_watchdog.start()
def reset_game(self) -> None:
async def send_game_dump(self):
self.__debug_print_board()
await self.ws_server.send_game_dump()
async def reset_game(self) -> None:
self.__debug_print_board()
self.game_state = GameState.RUNNING
self.players.clear()
await self.send_game_dump()
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(),
position=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED,
)
self.players.append(player)
@ -79,27 +106,10 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}")
self.__debug_print_board()
if self.ws_server:
await self.ws_server.send_game_dump()
await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
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:
new_position = Position(position.x, position.y)
if direction == Direction.LEFT:
@ -146,10 +156,8 @@ class GameEngine:
await self._player_on_destination(player)
return PlayerMoveResult.DESTINATION_REACHED
if self.ws_server:
await self.ws_server.send_game_dump()
await self.send_game_dump()
self.__debug_print_board()
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK
@ -168,25 +176,59 @@ class GameEngine:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.ws_server.send_game_dump()
self.__debug_print_board()
await self.send_game_dump()
await self.ws_server.send_product_purchase_message(products=settings.products)
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())
logging.info(f"Starting purchase countdown timer for {settings.purchase_timeout} seconds")
self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout,
callback=self._on_purchase_timeout,
timer_tick_callback=on_purchase_timer_tick,
timer_done_callback=on_purchase_timer_done,
)
self._purchase_countdown_timer.start()
def _on_purchase_timeout(self) -> None:
logging.info("Ding ding! Purchase countdown timer timeout")
self._purchase_countdown_timer = None
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()
asyncio.run(self.ws_server.send_product_purchase_done_message(product=None))
self.game_state = GameState.RUNNING
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:
return BoardLayout(board=self.board, players=self.players)
@ -198,7 +240,7 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[SendGameDumpInterface] = None,
ws_server: WSServer = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
@ -224,7 +266,7 @@ class GameEngineFactory:
@staticmethod
def create_default(
ws_server: Optional[SendGameDumpInterface] = None,
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,

View File

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

View File

@ -1,16 +0,0 @@
from typing import Iterable, Optional, Protocol
from hopper.models.product import Product
class SendGameDumpInterface(Protocol):
async def send_game_dump(self) -> None:
...
async def send_product_purchase_message(self, products: Iterable[Product]) -> None:
...
async def send_product_purchase_done_message(
self, product: Optional[Product] = None
) -> None:
...

View File

@ -1,8 +1,10 @@
from dataclasses import dataclass, field
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

@ -6,9 +6,15 @@ from typing import Optional, TypeVar
from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
ProductDto,
)
from hopper.enums import ObjectType
from hopper.models.product import Product
class LayerObjectDto(BaseModel):
@ -21,27 +27,26 @@ class LayerDto(BaseModel):
objects: list[LayerObjectDto]
class ProductDto(BaseModel):
name: str
uuid: str
class GameDumpPlayerDto(PlayerDto):
...
class GameDumpDto(BaseModel):
board: BoardDto
destination: DestinationDto
players: list[GameDumpPlayerDto]
players: list[PlayerDto]
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
@ -64,11 +69,16 @@ class WSGameDumpMessage(WSMessage):
data: GameDumpDto
class WSProductPurchaseStart(WSMessage):
class WSProductPurchaseStartMessage(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductPurchaseDone(WSMessage):
class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto
class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done"
data: ProductPurchaseDoneDto

View File

@ -3,16 +3,15 @@ import datetime
import logging
import time
from threading import Thread
from typing import Optional
from hopper.interfaces import SendGameDumpInterface
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: Optional[SendGameDumpInterface] = None
self, players: PlayerList, ws_server: WSServer = None
) -> None:
self.players = players
self.ws_server = ws_server
@ -61,8 +60,6 @@ class InactivityWatchdog(Thread):
self.send_game_dump()
def send_game_dump(self):
if not self.ws_server:
return
logging.info("Sending WS game dump")
asyncio.run(self.ws_server.send_game_dump())

View File

@ -7,16 +7,20 @@ import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK
from hopper.models.player import Player
from hopper.models.product import Product
from hopper.models.ws_dto import (
GameDumpDto,
ProductPurchaseDoneDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto,
WSGameDumpMessage,
WSMessage,
WSProductPurchaseDone,
WSProductPurchaseStart,
WSProductPurchaseDoneMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage,
)
from settings import settings
class WSServer(Thread):
@ -85,16 +89,35 @@ class WSServer(Thread):
message = self._create_game_dump_message()
await self.send_message_to_clients(message)
async def send_product_purchase_message(self, products: Iterable[Product]) -> None:
message = WSProductPurchaseStart(
data=ProductPurchaseStartDto(products=products)
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,
)
)
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,
)
)
await self.send_message_to_clients(message)
async def send_product_purchase_done_message(
self, product: Optional[Product] = None
self, player: Player, product: Optional[Product] = None
) -> None:
message = WSProductPurchaseDone(data=ProductPurchaseDoneDto(product=product))
message = WSProductPurchaseDoneMessage(
data=ProductPurchaseDoneDto(player=player, product=product),
)
await self.send_message_to_clients(message)
async def run_async(self) -> None: