23 Commits

Author SHA1 Message Date
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
ecffdc5d1e Player state 2023-03-30 13:09:23 +02:00
8a48d61dc9 Multiple test players 2023-03-30 12:05:58 +02:00
33f2220356 Game state 2023-03-30 11:44:20 +02:00
413e395a75 Change terminology game state -> game dump 2023-03-30 11:32:16 +02:00
48cb1a3798 Update readme 2023-03-29 09:35:38 +02:00
988878502c Hide inactive players from board dump 2023-03-28 09:45:49 +02:00
0e8775bd08 FE tweak 2023-03-27 14:28:15 +02:00
b5a49fb53b Add fairhopper-sdk as module 2023-03-27 13:55:52 +02:00
9acaf0c2c0 Remove SDK 2023-03-27 09:53:29 +02:00
870e2deb79 FE tweak 2023-03-27 09:52:51 +02:00
fa2aee881d Cleanups 2023-03-26 23:59:15 +02:00
f74bc9b52e FE tweaks 2023-03-26 20:00:35 +02:00
63e7e0d21c Update readme 2023-03-26 14:58:57 +02:00
4831f1e393 Update readme 2023-03-26 14:42:26 +02:00
806a379253 Demo SDK 2023-03-26 14:37:39 +02:00
f54344a17f Tweak frontend and game logic 2023-03-26 00:37:58 +01:00
3ac07f3072 Optimize views 2023-03-25 18:59:35 +01:00
27 changed files with 745 additions and 219 deletions

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "fairhopper-sdk"]
path = fairhopper-sdk
url = git@gitea.ekirin.com:Intis/fairhopper-sdk.git

115
README.md
View File

@ -21,6 +21,35 @@
- Move timeout: 10s. Game is finished if timeout ocurrs. - Move timeout: 10s. Game is finished if timeout ocurrs.
## Game States
```plantuml
hide empty description
state "Start Game" as StartGame
state "Move" as MovePlayer: Destination reached?
state "Destination Reached" as DestinationReached
state "Product Selection" as ProductSelection: Enable product selection for winning player
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>>
[*] -> StartGame
StartGame -> MovePlayer
MovePlayer <-- MovePlayer: NO
MovePlayer --> DestinationReached: YES
DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players
```
## FairHopper Game Server ## FairHopper Game Server
Requirements: Requirements:
@ -71,56 +100,86 @@ 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
package Masterpiece { package Masterpiece #seashell {
usecase Game as "FairHopper\nGame Server" rectangle "FairHopper Game Server" #lightcyan {
usecase WS as "WS Server" usecase API as "API Server"
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService" usecase Vis as "Visualisation\nService"
} }
P1 -left-> Game: REST API usecase ExtVis1 as "Visualisation\nService"
P2 -left-> Game: REST API usecase ExtVis2 as "Visualisation\nService"
P3 -left-> Game: REST API
Game --> WS: WebSockets P1 -left-> API: REST API
WS --> Vis: WebSockets P2 -left-> API: REST API
P3 -left-> API: REST API
API --> Game
Game --> WS: Game State
WS --> Vis: WS Game State
WS --> ExtVis1: WS Game State
WS --> ExtVis2: WS Game State
``` ```
### WebSockets ### WebSockets
```plantuml ```plantuml
participant Game as "FairHopper\nGame Server" scale 1024 width
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1" endbox
participant Client2 as "Visualisation\nClient 2" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
Game ->o WS: Server Connect == Player movement mode ==
activate WS #coral
WS -> Game: Get game state
activate Game #yellow
Game -> WS: Game state
deactivate
deactivate
Client1 ->o WS: Client Connect Game ->o WS: Send initial state
activate WS #coral
WS -> Client1: Game state
deactivate
Client2 ->o WS: Client Connect Client1 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client2: Game state WS -> Client1: Game state
deactivate deactivate
loop #lightyellow On game state change Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
loop #lightyellow On game state change
Game ->o WS: Game state Game ->o WS: Game state
activate WS #coral activate WS #coral
WS o-> Client1: Game state WS o-> Client1: Game state
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
``` ```
@ -131,7 +190,7 @@ end
- Move right - Move right
- Move up - Move up
- Move down - Move down
- Get current position - Get player info
- Get board info - Get board info
Check REST API interface on [FastAPI docs](http://localhost:8010/docs). Check REST API interface on [FastAPI docs](http://localhost:8010/docs).

39
api_tests/requests.http Normal file
View File

@ -0,0 +1,39 @@
GET http://localhost:8010/ping
###
# create new game
POST http://localhost:8010/game
Content-Type: application/json
{
"player_name": "Mirko"
}
###
# get game info
GET http://localhost:8010/game
###
# get player info
GET http://localhost:8010/player/test-player-pero
###
# move player left
POST http://localhost:8010/player/test-player-pero/move/left
###
# move player right
POST http://localhost:8010/player/test-player-pero/move/right
###
# move player up
POST http://localhost:8010/player/test-player-pero/move/up
###
# move player down
POST http://localhost:8010/player/test-player-pero/move/down
###
# move Mirko left
POST http://localhost:8010/player/test-player-mirko/move/left
###

1
fairhopper-sdk Submodule

Submodule fairhopper-sdk added at 10290dba54

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,13 +8,24 @@
<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">
<title>Document</title>
<title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<div class="container-fluid"> <main class="container-fluid container">
<h1>FairHopper WS Client</h1> <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="row">
<div class="col-10"> <div class="col-10">
<div class="board-container"> <div class="board-container">
@ -22,14 +33,23 @@
</div> </div>
</div> </div>
<div class="col-2"> <div class="col-2">
<h3>Players</h3> <h3 class="pb-2 border-bottom">
<ul id="players-content"></ul> Players
</h3>
<ul class="players" id="players-content"></ul>
</div> </div>
</div> </div>
</div> </main>
</body> </body>
<script> <script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) { function createBoard(board) {
let html = ""; let html = "";
for (let y = 0; y < board.height; y++) { for (let y = 0; y < board.height; y++) {
@ -46,25 +66,42 @@
document.getElementById("board-content").innerHTML = html; document.getElementById("board-content").innerHTML = html;
} }
function renderCellContent(x, y, content) { function findCell(position) {
const cell = document.getElementById(`cell-${x}-${y}`); return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) { if (cell) {
cell.innerText = content; cell.innerText = content;
} }
} }
function renderPlayerList(players) { function renderPlayerList(players) {
const html = players.map((player) => { const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return ` return `
<li>${player.name} (${player.move_count})</li> <li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`; `;
}).join(""); }).join("");
document.getElementById("players-content").innerHTML = html; document.getElementById("players-content").innerHTML = html;
} }
function renderPlayers(players) { function renderPlayers(players) {
players.forEach(player => { players.filter(player => player.active).forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎"); 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;
}
}); });
} }
@ -79,42 +116,105 @@
function renderObstacles(layers) { function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE"); const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => { objects.forEach(obj => {
renderCellContent(obj.position.x, obj.position.y, "🔥"); renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
}); });
} }
function renderDestination(position) { function renderDestination(position) {
renderCellContent(position.x, position.y, "🏠"); renderCellContent(position, BOARD_ICONS.DESTINATION);
} }
window.onload = function () { function renderGameDump(data) {
const ws = new WebSocket('ws://localhost:8011/bla-tra'); createBoard(data.board);
renderObstacles(data.layers)
ws.onmessage = function (event) { renderDestination(data.destination.position);
const data = JSON.parse(event.data); renderPlayerList(data.players);
console.log("message received:", data) renderPlayers(data.players);
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
ws.onopen = function () {
console.log("open");
}
ws.onclose = function () {
console.log("close");
}
ws.onerror = function () {
console.log("error");
}
} }
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();
}
</script> </script>
</html> </html>

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;
@ -13,8 +17,78 @@ body {
grid-gap: 2px; grid-gap: 2px;
padding-bottom: 2px; padding-bottom: 2px;
} }
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell { .cell {
flex: 1; flex: 1;
text-align: center; aspect-ratio: 1;
background-color: beige; background-color: beige;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
ul.players {
list-style-type: none;
padding-left: 0;
}
.player-tooltip {
position: absolute;
margin-bottom: 50px;
font-size: 8pt;
padding: 2px 10px;
color: white;
background-color: darkred;
border-radius: 5px;
}
.player-tooltip::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
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

@ -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

@ -2,6 +2,8 @@ from __future__ import annotations
from pydantic import BaseModel as PydanticBaseModel from pydantic import BaseModel as PydanticBaseModel
from hopper.enums import PlayerState
class BaseModel(PydanticBaseModel): class BaseModel(PydanticBaseModel):
class Config: class Config:
@ -29,6 +31,7 @@ class PlayerDto(BaseModel):
position: PositionDto position: PositionDto
move_count: int move_count: int
move_attempt_count: int move_attempt_count: int
state: PlayerState
class DestinationDto(BaseModel): class DestinationDto(BaseModel):

View File

@ -14,11 +14,26 @@ from hopper.api.dto import (
) )
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, PositionOutOfBounds from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.models.player import Player
router = APIRouter() router = APIRouter()
def get_player(uuid: str, engine: GameEngine = Depends(get_game_engine)) -> Player:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Player kicked out due to inactivity",
)
return player
@router.get("/ping", response_model=PingResponse) @router.get("/ping", response_model=PingResponse)
async def ping() -> PingResponse: async def ping() -> PingResponse:
return PingResponse( return PingResponse(
@ -38,12 +53,14 @@ async def get_game_info(
) )
@router.post("/game", response_model=StartGameResponseDto) @router.post(
"/game", response_model=StartGameResponseDto, status_code=status.HTTP_201_CREATED
)
async def start_game( async def start_game(
body: StartGameRequestDto, body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto: ) -> StartGameResponseDto:
new_player = await engine.start_game(player_name=body.player_name) new_player = await engine.start_game_for_player(player_name=body.player_name)
return StartGameResponseDto( return StartGameResponseDto(
board=engine.board, board=engine.board,
@ -57,22 +74,20 @@ async def start_game(
@router.get( @router.get(
"/player/{uuid}", "/player/{uuid}",
response_model=PlayerInfoResponseDto, response_model=PlayerInfoResponseDto,
status_code=status.HTTP_201_CREATED, responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
},
},
) )
async def get_player_info( async def get_player_info(
uuid: str, player: Player = Depends(get_player),
engine: GameEngine = Depends(get_game_engine),
) -> MovePlayerResponseDto: ) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
return PlayerInfoResponseDto(player=player) return PlayerInfoResponseDto(player=player)
@ -87,31 +102,28 @@ async def get_player_info(
}, },
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
"description": " Player uuid not valid, probably due to inactivity", "description": " Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
}, },
status.HTTP_409_CONFLICT: { status.HTTP_409_CONFLICT: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
"description": " Position out of bounds or collision with an object", "description": " Position out of bounds or collision with an object",
}, },
status.HTTP_423_LOCKED: {
"model": ErrorResponseDto,
"description": " Player reached destination. Can't move anymore.",
},
}, },
) )
async def move_player( async def move_player(
uuid: str,
direction: Direction, direction: Direction,
response: Response, response: Response,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> MovePlayerResponseDto: ) -> MovePlayerResponseDto:
player = engine.players.find(uuid)
if player is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
)
if not player.active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Player kicked out due to inactivity",
)
try: try:
move_result = await engine.move_player(player, direction) move_result = await engine.move_player(player, direction)
except PositionOutOfBounds: except PositionOutOfBounds:
@ -122,6 +134,11 @@ async def move_player(
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Collision with an object" status_code=status.HTTP_409_CONFLICT, detail="Collision with an object"
) )
except GameLockForMovement:
raise HTTPException(
status_code=status.HTTP_423_LOCKED,
detail="Player reached destination. Can't move anymore.",
)
if move_result == PlayerMoveResult.DESTINATION_REACHED: if move_result == PlayerMoveResult.DESTINATION_REACHED:
response.status_code = status.HTTP_200_OK response.status_code = status.HTTP_200_OK

View File

@ -1,35 +0,0 @@
GET http://localhost:8010/ping
###
# create new game
POST http://localhost:8010/game
Content-Type: application/json
{
"player_name": "Mirko"
}
###
# get game info
GET http://localhost:8010/game
###
# get player info
GET http://localhost:8010/player/test-player-id
###
# move player left
POST http://localhost:8010/player/test-player-id/move/left
###
# move player right
POST http://localhost:8010/player/test-player-id/move/right
###
# move player up
POST http://localhost:8010/player/test-player-id/move/up
###
# move player down
POST http://localhost:8010/player/test-player-id/move/down
###

28
hopper/countdown_timer.py Normal file
View File

@ -0,0 +1,28 @@
import time
from threading import Event, Thread
from typing import Callable, Optional
class CountdownTimer(Thread):
def __init__(
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.timer_tick_callback = timer_tick_callback
self.timer_done_callback = timer_done_callback
super().__init__(daemon=True)
def run(self) -> None:
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:
self.timer_tick_callback(time_left)
if time_left == 0 and self.timer_done_callback:
self.timer_done_callback()
def stop(self) -> None:
self.stop_event.set()

View File

@ -1,9 +1,11 @@
import asyncio import asyncio
import logging import logging
import random
from typing import Optional from typing import Optional
from hopper.enums import Direction, PlayerMoveResult from hopper.countdown_timer import CountdownTimer
from hopper.errors import Collision, PositionOutOfBounds from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -21,20 +23,27 @@ from settings import settings
class GameEngine: class GameEngine:
def __init__(self, board: GameBoard, ws_server: Optional[WSServer] = None) -> None: def __init__(self, board: GameBoard, ws_server: WSServer = 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.__debug_print_board() self._purchase_countdown_timer: Optional[CountdownTimer] = None
self.reset_game()
def dump_board(self) -> list[list[str]]: def dump_board(self) -> list[list[str]]:
dump = self.board.dump() dump = self.board.dump()
for player in self.players: for player in self.players:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[ show_player = (
ObjectType.PLAYER player.active
] and player.position.y < len(dump)
and player.position.x < len(dump[player.position.y])
)
if show_player:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
return dump return dump
@ -49,15 +58,19 @@ 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 start_game(self, player_name: str) -> Player: def reset_game(self) -> None:
self.__debug_print_board()
self.game_state = GameState.RUNNING
async def start_game_for_player(self, player_name: str) -> Player:
self._start_inactivity_watchdog() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
position=Position(0, 0), position=self._create_player_start_position(),
state=PlayerState.CREATED,
) )
self.players.append(player) self.players.append(player)
@ -65,18 +78,33 @@ class GameEngine:
self.__debug_print_board() self.__debug_print_board()
if self.ws_server: if self.ws_server:
await self.ws_server.send_game_state() await self.ws_server.send_game_dump()
#!!!!!!!!!!!!!!!
await self._player_on_destination(player)
#!!!!!!!!!!!!!!!
await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
async def move_player( def _create_player_start_position(self) -> Position:
self, player: Player, direction: Direction """Create random position somewhere on the board border"""
) -> PlayerMoveResult: border_len = (self.board.width + self.board.height) * 2
player.reset_timeout() rnd_position = random.randint(0, border_len - 1)
new_position = Position(player.position.x, player.position.y) if rnd_position < self.board.width * 2:
logging.info(f"Player {player} move to {direction}") 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: if direction == Direction.LEFT:
new_position.x -= 1 new_position.x -= 1
elif direction == Direction.RIGHT: elif direction == Direction.RIGHT:
@ -87,41 +115,97 @@ class GameEngine:
new_position.y += 1 new_position.y += 1
else: else:
raise ValueError(f"Unhandled direction: {direction}") raise ValueError(f"Unhandled direction: {direction}")
return new_position
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
if self.game_state == GameState.LOCK_FOR_MOVEMENT:
raise GameLockForMovement("Player reached destination. Can't move anymore.")
# player will not be able to move once they reach the destination
if player.state == PlayerState.ON_DESTINATION:
return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1 player.move_attempt_count += 1
player.state = PlayerState.MOVING
if not self.position_in_board_bounds(new_position): if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds() raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position): if self._colided_with_obstacle(new_position):
raise Collision() raise Collision()
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
if self.ws_server: if self._is_player_on_destination(player):
await self.ws_server.send_game_state() player.state = PlayerState.ON_DESTINATION
await self._player_on_destination(player)
if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board() if self.ws_server:
await self.ws_server.send_game_dump()
self.__debug_print_board()
await asyncio.sleep(settings.game.MOVE_DELAY) 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:
return player.position == self.board.destination.position return player.position == self.board.destination.position
def position_in_board_bounds(self, position: Position) -> bool: def _position_in_board_bounds(self, position: Position) -> bool:
return ( return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height 0 <= position.x < self.board.width and 0 <= position.y < self.board.height
) )
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
async def _player_on_destination(self, player: Player) -> None:
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.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:
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
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()
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)
@ -132,12 +216,12 @@ 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[WSServer] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
height=board_height, height=board_height,
destination=Destination(Position(board_height // 2, board_height // 2)), destination=Destination(Position(board_width // 2, board_height // 2)),
) )
obstacle_layer = Layer(name="obstacles") obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count): for _ in range(obstacle_count):
@ -153,11 +237,13 @@ class GameEngineFactory:
board=board, board=board,
ws_server=ws_server, ws_server=ws_server,
) )
GameEngineFactory.__add_test_player(game.players) GameEngineFactory.__add_test_players(game.players)
return game return game
@staticmethod @staticmethod
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine: def create_default(
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT, board_height=settings.board.HEIGHT,
@ -166,15 +252,10 @@ class GameEngineFactory:
) )
@staticmethod @staticmethod
def __add_test_player(players: PlayerList) -> None: def __add_test_players(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER): if not settings.debug:
return return
player = Player( for player in settings.debug.PLAYERS:
name="Pero", players.append(player)
uuid="test-player-id", logging.info(f"Test player created: {player}")
position=Position(2, 2),
can_be_deactivated=False,
)
players.append(player)
logging.info(f"Test player created: {player}")

View File

@ -18,3 +18,16 @@ class ObjectType(str, Enum):
class PlayerMoveResult(Enum): class PlayerMoveResult(Enum):
OK = auto() OK = auto()
DESTINATION_REACHED = auto() DESTINATION_REACHED = auto()
class GameState(Enum):
RUNNING = auto()
LOCK_FOR_MOVEMENT = auto()
ENDGAME = auto()
class PlayerState(str, Enum):
CREATED = "CREATED"
MOVING = "MOVING"
ON_DESTINATION = "ON_DESTINATION"
INACTIVE = "INACTIVE"

View File

@ -8,3 +8,7 @@ class PositionOutOfBounds(BaseError):
class Collision(BaseError): class Collision(BaseError):
... ...
class GameLockForMovement(BaseError):
...

View File

@ -1,10 +1,15 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import List, Optional
from hopper.models.player import Player
from hopper.models.product import Product
@dataclass @dataclass
class GameSettings: class GameSettings:
MOVE_DELAY: int = 0.5 # seconds MOVE_DELAY: float = 0.5 # seconds
@dataclass @dataclass
class BoardSettings: class BoardSettings:
@ -29,7 +34,7 @@ class WSServerSettings:
@dataclass @dataclass
class DebugSettings: class DebugSettings:
PRINT_BOARD: bool = False PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False PLAYERS: Optional[List[Player]] = None
@dataclass @dataclass
@ -38,5 +43,7 @@ class Settings:
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings ws_server: WSServerSettings
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

View File

@ -3,6 +3,8 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from hopper.enums import PlayerState
@dataclass @dataclass
class Position: class Position:
@ -20,6 +22,7 @@ class Player:
last_seen: datetime.datetime = field( last_seen: datetime.datetime = field(
default_factory=lambda: datetime.datetime.now() default_factory=lambda: datetime.datetime.now()
) )
state: PlayerState = PlayerState.CREATED
active: bool = True active: bool = True
can_be_deactivated: bool = True can_be_deactivated: bool = True

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,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Optional, TypeVar
from pydantic import Field 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
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -16,8 +20,64 @@ class LayerDto(BaseModel):
objects: list[LayerObjectDto] objects: list[LayerObjectDto]
class GameStateDto(BaseModel): class ProductDto(BaseModel):
name: str
uuid: str
description: Optional[str] = None
class GameDumpDto(BaseModel):
board: BoardDto board: BoardDto
destination: DestinationDto destination: DestinationDto
players: list[PlayerDto] 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,7 +3,6 @@ import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread
from typing import Optional
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_server import WSServer from hopper.ws_server import WSServer
@ -12,12 +11,12 @@ from settings import settings
class InactivityWatchdog(Thread): class InactivityWatchdog(Thread):
def __init__( def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs self, players: PlayerList, ws_server: WSServer = None
) -> 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")
@ -34,7 +33,7 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT seconds=settings.inacivity_watchdog.KICK_TIMEOUT
) )
send_game_state = False send_game_dump = False
for player in self.players: for player in self.players:
if ( if (
@ -44,7 +43,7 @@ class InactivityWatchdog(Thread):
): ):
player.active = False player.active = False
logging.info(f"Player {player} set as inactive") logging.info(f"Player {player} set as inactive")
send_game_state = True send_game_dump = True
# safe remove from list # safe remove from list
n = 0 n = 0
@ -53,18 +52,16 @@ class InactivityWatchdog(Thread):
if player.can_be_deactivated and player.last_seen < kick_threshold: if player.can_be_deactivated and player.last_seen < kick_threshold:
self.players.pop(n) self.players.pop(n)
logging.info(f"Player {player} kicked out") logging.info(f"Player {player} kicked out")
send_game_state = True send_game_dump = True
else: else:
n += 1 n += 1
if send_game_state: if send_game_dump:
self.send_game_state() self.send_game_dump()
def send_game_state(self): def send_game_dump(self):
if not self.ws_server: logging.info("Sending WS game dump")
return asyncio.run(self.ws_server.send_game_dump())
logging.info("Sending WS game state")
asyncio.run(self.ws_server.send_game_state())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stopped = True

View File

@ -1,80 +1,137 @@
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 GameStateDto 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, *args, **kwargs) -> None: def __init__(self, host: str, port: int) -> None:
self.connected_clients = set[WebSocketServerProtocol]() self.host = host
super().__init__(*args, **kwargs) 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"""
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}") logging.info(f"Add client: {websocket.id}")
try: try:
await self.send_game_state_to_client(websocket) # send initial game dump to connected client
await self.send_game_dump_to_client(websocket)
# loop and do nothing while client is connected
connected = True connected = True
while connected: while connected:
try: try:
message = await websocket.recv() # we're expecting nothing from client, but read if client sends a message
await websocket.recv()
except ConnectionClosedOK: except ConnectionClosedOK:
connected = False connected = False
finally: finally:
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_state_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
engine = get_game_engine() engine = get_game_engine()
game_state = GameStateDto( game_dump = GameDumpDto(
board=engine.board, board=engine.board,
destination=engine.board.destination, destination=engine.board.destination,
players=engine.players, players=engine.players,
layers=engine.get_board_layout().layers, layers=engine.get_board_layout().layers,
) )
return json.dumps(game_state.dict()) return WSGameDumpMessage(data=game_dump)
async def send_game_state_to_client( async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol self, websocket: WebSocketServerProtocol
) -> None: ) -> None:
message = self._create_game_state_message() """Send game dump to the client"""
logging.debug(f"Sending game state to client: {websocket.id}") message = self._create_game_dump_message()
await websocket.send(message) logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message.to_str())
async def send_game_state(self) -> None: async def send_game_dump(self) -> None:
if not self.connected_clients: """Broadcast game state to all connected clients"""
return message = self._create_game_dump_message()
await self.send_message_to_clients(message)
message = self._create_game_state_message() async def send_product_purchase_start_message(
logging.debug( self, player: Player, products: Iterable[Product]
f"Sending game state to clients: {self.connected_clients}: {message}" ) -> 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
def run(self) -> None: def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async()) asyncio.run(self.run_async())

View File

@ -14,5 +14,6 @@ settings = Settings(
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO, log_level=logging.INFO,
ws_server=WSServerSettings(), ws_server=WSServerSettings(),
purchase_timeout=10,
debug=None, debug=None,
) )