38 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
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
ed4d61b37b Frontend 2023-03-25 18:45:31 +01:00
1b745c756f Integrated WS server 2023-03-25 17:23:00 +01:00
8971c64713 Update readme 2023-03-25 16:33:49 +01:00
f8506a66ba Update readme 2023-03-25 16:30:17 +01:00
894d2b0707 Player move delay 2023-03-25 16:24:54 +01:00
ee4d841cae Readme update 2023-03-25 16:18:02 +01:00
8bc8a37edd WS game-state URI 2023-03-25 16:17:24 +01:00
245dc75211 Update readme 2023-03-25 16:07:53 +01:00
395457b2db Configurable log level 2023-03-25 16:03:04 +01:00
ee1ce125ff WS error handling 2023-03-25 15:58:24 +01:00
4b511c0cb8 WS send game state 2023-03-25 15:54:23 +01:00
0f0fe68890 WS DTO assign rework 2023-03-25 15:27:15 +01:00
9aabcf61f4 WS Server 2023-03-25 14:10:33 +01:00
29 changed files with 1208 additions and 205 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

243
README.md
View File

@ -21,6 +21,35 @@
- 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
Requirements:
@ -53,7 +82,7 @@ Edit `settings.py` and customize application.
make run
```
By default, JFK runs on port **8010**. To run on other port, start `uvicorn` directly:
By default, FairHopper runs on port **8010**. To run FairHopper on different port, start `uvicorn` directly:
```sh
poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1
```
@ -63,62 +92,94 @@ To activate virtual environment:
poetry shell
```
WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration.
## System overview
### Architecture
```plantuml
scale 1024 width
actor "Player 1" as P1
actor "Player 2" as P2
actor "Player 3" as P3
package Masterpiece {
usecase JFK as "JFK Game Server"
package Masterpiece #seashell {
rectangle "FairHopper Game Server" #lightcyan {
usecase API as "API Server"
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService"
}
P1 -left-> JFK: REST API
P2 -left-> JFK: REST API
P3 -left-> JFK: REST API
JFK --> WS: WebSockets
WS --> Vis: WebSockets
usecase ExtVis1 as "Visualisation\nService"
usecase ExtVis2 as "Visualisation\nService"
P1 -left-> API: REST API
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
```plantuml
participant JFK as "JFK Game Server"
scale 1024 width
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
endbox
participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
JFK ->o WS: Server Connect
activate WS #coral
WS -> JFK: Get game state
activate JFK #yellow
JFK -> WS: Game state
deactivate
deactivate
== Player movement mode ==
Client1 ->o WS: Client Connect
activate WS #coral
Game ->o WS: Send initial state
Client1 ->o WS: Client connect
activate WS #coral
WS -> Client1: Game state
deactivate
deactivate
Client2 ->o WS: Client Connect
activate WS #coral
Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
deactivate
loop #lightyellow On game state change
JFK ->o WS: Game state
loop #lightyellow On game state change
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
deactivate
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
```
@ -129,7 +190,7 @@ end
- Move right
- Move up
- Move down
- Get current position
- Get player info
- Get board info
Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
@ -156,10 +217,11 @@ Response body:
"position": {
"x": 50,
"y": 50
},
}
},
"player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero",
"position": {
"x": 0,
"y": 10
@ -172,10 +234,10 @@ Response body:
### Player Move
POST `/player/{uuid}/move/left`
POST `/player/{uuid}/move/right`
POST `/player/{uuid}/move/up`
POST `/player/{uuid}/move/down`
- POST `/player/{uuid}/move/left`
- POST `/player/{uuid}/move/right`
- POST `/player/{uuid}/move/up`
- POST `/player/{uuid}/move/down`
Request body: None
@ -191,6 +253,7 @@ Response body:
{
"player": {
"uuid": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
@ -212,6 +275,7 @@ Response body:
{
"player": {
"uuid": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
@ -250,45 +314,46 @@ Response body:
### WS Data format
- json
General data format:
```json
{
"command": "command",
"data": {}
}
```
### Game info structure
### Game state structure
Command: `gameInfo`
URI: `/game-state`
Data:
```json
{
"board": {
"width": 101,
"height": 101
"width": 21,
"height": 21
},
"destinationPosition": {
"x": 50,
"y": 50
},
"players": [
{
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero",
"destination": {
"position": {
"x": 0,
"x": 10,
"y": 10
}
},
"players": [
{
"id": "04793b36-0785-4bf3-9396-3585c358cbac",
"name": "Mirko",
"uuid": "test-player-id",
"name": "Pero",
"active": true,
"position": {
"x": 11,
"y": 12
}
"x": 2,
"y": 2
},
"move_count": 3,
"move_attempt_count": 3
},
{
"uuid": "95962b49-0003-4bf2-b205-71f2590f2318",
"name": "Mirko",
"active": true,
"position": {
"x": 0,
"y": 0
},
"move_count": 15,
"move_attempt_count": 20
}
],
"layers": [
@ -296,17 +361,69 @@ Data:
"name": "obstacles",
"objects": [
{
"type": "obstacle",
"type": "OBSTACLE",
"position": {
"x": 15,
"y": 25
"x": 4,
"y": 2
}
},
{
"type": "obstacle",
"type": "OBSTACLE",
"position": {
"x": 33,
"y": 44
"x": 4,
"y": 13
}
},
{
"type": "OBSTACLE",
"position": {
"x": 18,
"y": 18
}
},
{
"type": "OBSTACLE",
"position": {
"x": 5,
"y": 4
}
},
{
"type": "OBSTACLE",
"position": {
"x": 7,
"y": 10
}
}
]
},
{
"name": "destination",
"objects": [
{
"type": "DESTINATION",
"position": {
"x": 10,
"y": 10
}
}
]
},
{
"name": "players",
"objects": [
{
"type": "PLAYER",
"position": {
"x": 2,
"y": 2
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
}
}
]

48
api_tests/requests.http Normal file
View File

@ -0,0 +1,48 @@
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
###
# 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
###

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

46
frontend/index.html Normal file
View File

@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<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">
<div id="board-content"></div>
</div>
</div>
<div class="col-2">
<h3 class="pb-2 border-bottom">
Players
</h3>
<ul class="players" id="players-content"></ul>
</div>
</div>
</main>
</body>
</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();
};

94
frontend/styles.css Normal file
View File

@ -0,0 +1,94 @@
body {
background-color: whitesmoke;
}
main.container {
position: relative;
}
.board-container {
background-color: white;
border: 1px solid black;
}
.flex-grid {
display: flex;
justify-content: space-between;
grid-gap: 2px;
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell {
flex: 1;
aspect-ratio: 1;
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

@ -1,11 +1,26 @@
from hopper.engine import GameEngine, GameEngineFactory
from typing import Optional
game_engine: GameEngine
from hopper.engine import GameEngine, GameEngineFactory
from hopper.ws_server import WSServer
from settings import settings
game_engine: Optional[GameEngine] = None
def create_game_engine() -> GameEngine:
global game_engine
game_engine = GameEngineFactory.create_default()
if game_engine:
raise RuntimeError("Can't call create_game_engine() more than once!")
ws_server = WSServer(
host=settings.ws_server.HOST,
port=settings.ws_server.PORT,
)
ws_server.start()
game_engine = GameEngineFactory.create_default(ws_server=ws_server)
return game_engine

View File

@ -1,9 +1,10 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel as PydanticBaseModel
from hopper.models.board import GameBoard
from hopper.models.player import Player, Position
from hopper.enums import PlayerState
class BaseModel(PydanticBaseModel):
@ -19,35 +20,31 @@ class BoardDto(BaseModel):
width: int
height: int
@staticmethod
def from_model(board: GameBoard) -> BoardDto:
return BoardDto.from_orm(board)
class PositionDto(BaseModel):
x: int
y: int
@staticmethod
def from_model(position: Position) -> PositionDto:
return PositionDto.from_orm(position)
class PlayerDto(BaseModel):
uuid: str
name: str
active: bool
position: PositionDto
move_count: int
move_attempt_count: int
@staticmethod
def from_model(player: Player) -> PlayerDto:
return PlayerDto.from_orm(player)
state: PlayerState
class DestinationDto(BaseModel):
position: PositionDto
class ProductDto(BaseModel):
name: str
uuid: str
description: Optional[str] = None
class StartGameRequestDto(BaseModel):
player_name: str
@ -71,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

@ -3,25 +3,41 @@ from starlette import status
from hopper.api.dependencies import get_game_engine
from hopper.api.dto import (
BoardDto,
DestinationDto,
ErrorResponseDto,
GameInfoDto,
GetProductsResponse,
MovePlayerResponseDto,
PingResponse,
PlayerDto,
PlayerInfoResponseDto,
PositionDto,
ProductDto,
PurchaseProductDto,
StartGameRequestDto,
StartGameResponseDto,
)
from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds, PurchaseForbiddenForPlayer
from hopper.models.player import Player
from settings import settings
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)
async def ping() -> PingResponse:
return PingResponse(
@ -34,25 +50,27 @@ async def get_game_info(
engine: GameEngine = Depends(get_game_engine),
) -> GameInfoDto:
return GameInfoDto(
board=BoardDto.from_model(engine.board),
board=engine.board,
destination=DestinationDto(
position=PositionDto.from_model(engine.board.destination.position)
position=engine.board.destination.position,
),
)
@router.post("/game", response_model=StartGameResponseDto)
@router.post(
"/game", response_model=StartGameResponseDto, status_code=status.HTTP_201_CREATED
)
async def start_game(
body: StartGameRequestDto,
engine: GameEngine = Depends(get_game_engine),
) -> StartGameResponseDto:
new_player = engine.start_game(player_name=body.player_name)
new_player = await engine.start_game_for_player(player_name=body.player_name)
return StartGameResponseDto(
board=BoardDto.from_model(engine.board),
player=PlayerDto.from_model(new_player),
board=engine.board,
player=new_player,
destination=DestinationDto(
position=PositionDto.from_model(engine.board.destination.position)
position=engine.board.destination.position,
),
)
@ -60,23 +78,21 @@ async def start_game(
@router.get(
"/player/{uuid}",
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(
uuid: str,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> 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=PlayerDto.from_model(player))
return PlayerInfoResponseDto(player=player)
@router.post(
@ -90,33 +106,30 @@ async def get_player_info(
},
status.HTTP_403_FORBIDDEN: {
"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: {
"model": ErrorResponseDto,
"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(
uuid: str,
direction: Direction,
response: Response,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> 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:
move_result = engine.move_player(player, direction)
move_result = await engine.move_player(player, direction)
except PositionOutOfBounds:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Position out of bounds"
@ -125,8 +138,52 @@ async def move_player(
raise HTTPException(
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:
response.status_code = status.HTTP_200_OK
return MovePlayerResponseDto(player=PlayerDto.from_model(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

@ -1,34 +0,0 @@
GET http://localhost:8010/ping
###
# create new game
POST http://localhost:8010/game
{
"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
###

31
hopper/countdown_timer.py Normal file
View File

@ -0,0 +1,31 @@
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 and not self.stop_event.is_set():
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,19 @@
import asyncio
import logging
import random
from typing import Optional
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, PositionOutOfBounds
from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
Destination,
GameBoard,
Layer,
@ -12,21 +22,48 @@ 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) -> 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.game_state = GameState.RUNNING
self.__debug_print_board()
def dump_board(self) -> list[list[str]]:
dump = self.board.dump()
for player in self.players:
show_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
]
@ -42,31 +79,39 @@ class GameEngine:
def _start_inactivity_watchdog(self) -> None:
if not self._inacivity_watchdog:
self._inacivity_watchdog = InactivityWatchdog(
players=self.players, daemon=True
players=self.players,
ws_server=self.ws_server,
)
self._inacivity_watchdog.start()
def start_game(self, player_name: str) -> Player:
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=Position(0, 0),
position=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED,
)
self.players.append(player)
logging.info(f"Starting new game for player: {player}")
self.__debug_print_board()
await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player
def move_player(self, player: Player, direction: Direction) -> PlayerMoveResult:
player.reset_timeout()
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
player.move_attempt_count += 1
def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y)
if direction == Direction.LEFT:
new_position.x -= 1
elif direction == Direction.RIGHT:
@ -77,34 +122,117 @@ class GameEngine:
new_position.y += 1
else:
raise ValueError(f"Unhandled direction: {direction}")
return new_position
if not self.position_in_board_bounds(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.state = PlayerState.MOVING
if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position):
if self._colided_with_obstacle(new_position):
raise Collision()
player.position = new_position
player.move_count += 1
if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!")
if self._is_player_on_destination(player):
player.state = PlayerState.ON_DESTINATION
await self._player_on_destination(player)
return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board()
await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
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
def position_in_board_bounds(self, position: Position) -> bool:
def _position_in_board_bounds(self, position: Position) -> bool:
return (
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
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.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:
return BoardLayout(board=self.board, players=self.players)
class GameEngineFactory:
@staticmethod
@ -112,11 +240,12 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: WSServer = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
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")
for _ in range(obstacle_count):
@ -128,26 +257,29 @@ class GameEngineFactory:
)
board.layers.append(obstacle_layer)
game = GameEngine(board=board)
GameEngineFactory.__add_test_player(game.players)
game = GameEngine(
board=board,
ws_server=ws_server,
)
GameEngineFactory.__add_test_players(game.players)
return game
@staticmethod
def create_default() -> GameEngine:
def create_default(
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT,
obstacle_count=settings.board.OBSTACLE_COUNT,
ws_server=ws_server,
)
@staticmethod
def __add_test_player(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER):
def __add_test_players(players: PlayerList) -> None:
if not settings.debug:
return
player = Player(
name="Pero",
uuid="test-player-id",
position=Position(2, 2),
)
for player in settings.debug.PLAYERS:
players.append(player)
logging.info(f"Test player created: {player}")

View File

@ -9,12 +9,25 @@ class Direction(Enum):
class ObjectType(str, Enum):
NONE = auto()
OBSTACLE = auto()
PLAYER = auto()
DESTINATION = auto()
NONE = "NONE"
OBSTACLE = "OBSTACLE"
PLAYER = "PLAYER"
DESTINATION = "DESTINATION"
class PlayerMoveResult(Enum):
OK = 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,11 @@ class PositionOutOfBounds(BaseError):
class Collision(BaseError):
...
class GameLockForMovement(BaseError):
...
class PurchaseForbiddenForPlayer(BaseError):
...

View File

@ -1,5 +1,14 @@
import logging
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
class GameSettings:
MOVE_DELAY: float = 0.5 # seconds
@dataclass
@ -16,14 +25,25 @@ class InactivityWatchdogSettings:
TICK_INTERVAL: int = 1 # seconds
@dataclass
class WSServerSettings:
HOST: str = "localhost"
PORT: int = 8011
@dataclass
class DebugSettings:
PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False
PLAYERS: Optional[List[Player]] = None
@dataclass
class Settings:
game: GameSettings
board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO
products: Optional[List[Product]] = None
debug: Optional[DebugSettings] = None

View File

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

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

84
hopper/models/ws_dto.py Normal file
View File

@ -0,0 +1,84 @@
from __future__ import annotations
import json
from typing import Optional, TypeVar
from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
ProductDto,
)
from hopper.enums import ObjectType
class LayerObjectDto(BaseModel):
type: ObjectType = Field(..., alias="type_")
position: PositionDto
class LayerDto(BaseModel):
name: str
objects: list[LayerObjectDto]
class GameDumpDto(BaseModel):
board: BoardDto
destination: DestinationDto
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
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

@ -1,17 +1,22 @@
import asyncio
import datetime
import logging
import time
from threading import Thread
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
class InactivityWatchdog(Thread):
def __init__(self, players: PlayerList, *args, **kwargs) -> None:
def __init__(
self, players: PlayerList, ws_server: WSServer = None
) -> None:
self.players = players
self.ws_server = ws_server
self.stopped = False
super().__init__(*args, **kwargs)
super().__init__(daemon=True)
def run(self) -> None:
logging.info("Starting inactivity watchdog")
@ -28,20 +33,35 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT
)
send_game_dump = False
for player in self.players:
if player.active and player.last_seen < inactivity_threshold:
if (
player.can_be_deactivated
and player.active
and player.last_seen < inactivity_threshold
):
player.active = False
logging.info(f"Player {player} set as inactive")
send_game_dump = True
# safe remove from list
n = 0
while n < len(self.players):
player = self.players[n]
if player.last_seen < kick_threshold:
if player.can_be_deactivated and player.last_seen < kick_threshold:
self.players.pop(n)
logging.info(f"Player {player} kicked out")
send_game_dump = True
else:
n += 1
if send_game_dump:
self.send_game_dump()
def send_game_dump(self):
logging.info("Sending WS game dump")
asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None:
self.stopped = True

137
hopper/ws_server.py Normal file
View File

@ -0,0 +1,137 @@
import asyncio
import logging
from threading import Thread
from typing import Iterable, Optional
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,
WSProductPurchaseDoneMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage,
)
from settings import settings
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:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
# 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
while connected:
try:
# we're expecting nothing from client, but read if client sends a message
await websocket.recv()
except ConnectionClosedOK:
connected = False
finally:
self.connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}")
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
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
game_dump = GameDumpDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
return WSGameDumpMessage(data=game_dump)
async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol
) -> None:
"""Send game dump to the client"""
message = self._create_game_dump_message()
logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message.to_str())
async def send_game_dump(self) -> None:
"""Broadcast game state to all connected clients"""
message = self._create_game_dump_message()
await self.send_message_to_clients(message)
async def send_product_purchase_start_message(
self, player: Player, products: Iterable[Product]
) -> None:
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
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, 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:
logging.info(
f"Starting FairHopper Websockets Server on {self.host}:{self.port}"
)
async with websockets.serve(
ws_handler=self.handler,
host=self.host,
port=self.port,
):
await asyncio.Future() # run forever
def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async())

View File

@ -4,12 +4,13 @@ from fastapi import FastAPI
from hopper.api.dependencies import create_game_engine
from hopper.api.views import router
from settings import settings
logging.basicConfig(
level=logging.DEBUG,
level=settings.log_level,
format="%(asctime)s %(levelname)s - %(message)s",
)
logging.info("JFK Game server started.")
logging.info("FairHopper Game Server started.")
app = FastAPI()
app.include_router(router, tags=["Game API"])

View File

@ -1,7 +1,19 @@
from hopper.models.config import BoardSettings, InactivityWatchdogSettings, Settings
import logging
from hopper.models.config import (
BoardSettings,
GameSettings,
InactivityWatchdogSettings,
Settings,
WSServerSettings,
)
settings = Settings(
game=GameSettings(),
board=BoardSettings(),
inacivity_watchdog=InactivityWatchdogSettings(),
log_level=logging.INFO,
ws_server=WSServerSettings(),
purchase_timeout=10,
debug=None,
)