23 Commits

Author SHA1 Message Date
95015aeb3a JS config fallback 2023-04-21 13:31:57 +02:00
21a69c7515 API port through env variable 2023-04-21 12:03:42 +02:00
82259f4522 Docker config 2023-04-21 11:27:48 +02:00
53dbc47553 Initial 2023-04-21 10:23:17 +02:00
aac949275d Update readme 2023-04-21 08:10:34 +02:00
476d186e7e Update readme 2023-04-21 08:09:38 +02:00
60c0256354 Update readme 2023-04-20 13:18:41 +02:00
eebe1090d3 External frontend config 2023-04-20 13:07:13 +02:00
1d2db6e16b External frontend config 2023-04-20 13:05:25 +02:00
34a970e550 Frontend js tweaks 2023-04-16 22:32:11 +02:00
e46edcc821 Create requirements.txt 2023-04-16 22:16:13 +02:00
9a2b5befd3 Purchase delays 2023-04-11 17:34:59 +02:00
9425e0fff0 Purchase product return 2023-04-10 20:03:15 +02:00
d4d03b78f9 Update readme 2023-04-02 20:24:58 +02:00
c30529c087 Merge branch 'rename-uuid-to-id' 2023-03-31 17:21:08 +02:00
80c7c80451 uuid -> id 2023-03-31 17:20:23 +02:00
d45aca6c30 uuid -> id 2023-03-31 17:16:00 +02:00
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
32 changed files with 1080 additions and 414 deletions

11
.docker/run.sh Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
PORT=${FAIRHOPPER_API_PORT=8010}
echo "Starting FairHopper game server on port ${PORT}"
uvicorn \
main:app \
--host 0.0.0.0 \
--port ${PORT} \
--workers=1

40
.docker/settings.py Normal file
View File

@ -0,0 +1,40 @@
import os
import logging
from hopper.models.config import (
BoardSettings,
DebugSettings,
GameSettings,
InactivityWatchdogSettings,
Settings,
WSServerSettings,
)
from hopper.models.product import Product
settings = Settings(
game=GameSettings(),
board=BoardSettings(
WIDTH=20,
HEIGHT=20,
OBSTACLE_COUNT=0,
),
inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO,
products=[
Product(name="CocaCola", id="cocacola-id"),
Product(name="Pepsi", id="pepsi-id"),
Product(name="Fanta", id="fanta-id"),
Product(name="Snickers", id="snickers-id"),
Product(name="Mars", id="mars-id"),
Product(name="Burek", id="burek-id"),
],
ws_server=WSServerSettings(
HOST="0.0.0.0",
PORT=int(os.environ.get("FAIRHOPPER_WS_PORT", 8011)),
),
debug=DebugSettings(
PRINT_BOARD=True,
PLAYERS=[],
),
)

2
.gitignore vendored
View File

@ -4,3 +4,5 @@ __pycache__
/env /env
/.venv /.venv
/settings.py /settings.py
/requirements.txt
/frontend/js/config.js

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM python:3.10.11-alpine3.17
# take arguments
ARG INTERNAL_API_PORT
ARG INTERNAL_WS_PORT
RUN \
pip install pip -U && \
pip install poetry --no-cache-dir
WORKDIR /app
COPY pyproject.toml .
COPY poetry.lock .
# create virtual environment
RUN python -m venv /venv
# set python thingies, set environment variables and activate virtual environment
ENV \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FAIRHOPPER_API_PORT=${INTERNAL_API_PORT} \
FAIRHOPPER_WS_PORT=${INTERNAL_WS_PORT} \
PATH="/venv/bin:$PATH"
RUN \
# dump python dependencies into requirements file
poetry export --without-hashes --format=requirements.txt > requirements.txt && \
# install python libs
pip install -r requirements.txt --no-cache-dir --prefer-binary
# copy all relevant files
COPY ./.docker/* ./
COPY ./hopper ./hopper
COPY ./main.py .
ENTRYPOINT [ "/app/run.sh" ]

View File

@ -1,3 +1,11 @@
IMAGE_NAME=fairhopper-service
CONTAINER_NAME=fairhopper-service
INTERNAL_API_PORT=8010
INTERNAL_WS_PORT=8011
EXTERNAL_API_PORT=8010
EXTERNAL_WS_PORT=8011
run: run:
@poetry run \ @poetry run \
uvicorn \ uvicorn \
@ -14,3 +22,36 @@ run-dev:
--port 8010 \ --port 8010 \
--workers=1 \ --workers=1 \
--reload --reload
create-requirements:
@poetry export \
--without-hashes \
--format=requirements.txt \
> requirements.txt
docker-clean:
@echo "> Removing container $(CONTAINER_NAME)"
- @docker rm $(CONTAINER_NAME)
@echo "> Removing image $(CONTAINER_NAME)"
- @docker image rm $(CONTAINER_NAME)
docker-build:
@docker \
build . \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
-t $(CONTAINER_NAME)
docker-run:
@docker \
run \
--publish $(EXTERNAL_API_PORT):$(INTERNAL_API_PORT) \
--publish $(EXTERNAL_WS_PORT):$(INTERNAL_WS_PORT) \
--name=$(CONTAINER_NAME) \
$(IMAGE_NAME) \
--detach
docker-clean-build:
make clean
make build

483
README.md
View File

@ -1,5 +1,14 @@
# FairHopper # FairHopper
## Useful links
- [Frontend](https://fairhopper.mjerenja.com)
- [API](https://api.fairhopper.mjerenja.com)
- [API Docs](https://api.fairhopper.mjerenja.com/docs)
- [FairHopper](https://gitea.ekirin.com/Intis/fairhopper)
- [FairHopper SDK](https://gitea.ekirin.com/Intis/fairhopper-sdk)
- Websockets: wss://fairhopper.mjerenja.com/ws
## Game ## Game
### Overview ### Overview
@ -8,10 +17,10 @@
- Destination: center of a board (W / 2, H / 2) - Destination: center of a board (W / 2, H / 2)
- Initial player position: Random on board border - Initial player position: Random on board border
- Available moves: - Available moves:
- left - left
- right - right
- up - up
- down - down
- Optional on-board obstacles - Optional on-board obstacles
### Rules ### Rules
@ -24,6 +33,7 @@
## Game States ## Game States
```plantuml ```plantuml
scale 1024 width
hide empty description hide empty description
state "Start Game" as StartGame state "Start Game" as StartGame
@ -92,7 +102,7 @@ To activate virtual environment:
poetry shell poetry shell
``` ```
WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration. WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration.
## System overview ## System overview
@ -100,6 +110,7 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi
### Architecture ### Architecture
```plantuml ```plantuml
scale 1024 width
actor "Player 1" as P1 actor "Player 1" as P1
actor "Player 2" as P2 actor "Player 2" as P2
actor "Player 3" as P3 actor "Player 3" as P3
@ -129,6 +140,7 @@ WS --> ExtVis2: WS Game State
### WebSockets ### WebSockets
```plantuml ```plantuml
scale 1024 width
box "FairHopper Game Server" #lightcyan box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine" participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
@ -136,6 +148,8 @@ endbox
participant Client1 as "Visualisation\nClient 1" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2" participant Client2 as "Visualisation\nClient 2"
== Player movement mode ==
Game ->o WS: Send initial state Game ->o WS: Send initial state
Client1 ->o WS: Client connect Client1 ->o WS: Client connect
@ -155,6 +169,27 @@ loop #lightyellow On game state change
WS o-> Client2: Game state WS o-> Client2: Game state
deactivate deactivate
end end
== Product purchase mode ==
Game -> WS: Purchase start
activate WS #coral
WS o-> Client1: Purchase start
WS o-> Client2: Purchase start
deactivate
loop #lightyellow Purchase countdown timer
Game ->o WS: Timer count down
activate WS #coral
WS o-> Client1: Purchase time left
WS o-> Client2: Purchase time left
deactivate
end
Game -> WS: Purchase done
activate WS #coral
WS o-> Client1: Purchase done
WS o-> Client2: Purchase done
deactivate
``` ```
@ -177,87 +212,87 @@ Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
Request body: Request body:
```json ```json
{ {
"player_name": "Pero" "player_name": "Pero"
} }
``` ```
Response body: Response body:
```json ```json
{ {
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destination": { "destination": {
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
} }
}, },
"player": { "player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 0, "x": 0,
"y": 10 "y": 10
}, },
"move_count": 0, "move_count": 0,
"move_attempt_count": 0 "move_attempt_count": 0
} }
} }
``` ```
### Player Move ### Player Move
- POST `/player/{uuid}/move/left` - POST `/player/{id}/move/left`
- POST `/player/{uuid}/move/right` - POST `/player/{id}/move/right`
- POST `/player/{uuid}/move/up` - POST `/player/{id}/move/up`
- POST `/player/{uuid}/move/down` - POST `/player/{id}/move/down`
Request body: None Request body: None
Response code: Response code:
- 200 OK: Destination reached - 200 OK: Destination reached
- 201 Created: Player moved successfully - 201 Created: Player moved successfully
- 403 Forbidden: Player uuid not valid, probably timeout - 403 Forbidden: Player id not valid, probably timeout
- 409 Conflict: Invalid move, obstacle or position out of board - 409 Conflict: Invalid move, obstacle or position out of board
- 422 Unprocessable Content: Validation error - 422 Unprocessable Content: Validation error
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
### Get Player Info ### Get Player Info
GET `/player/{{uuid}}` GET `/player/{{id}}`
Request body: None Request body: None
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
@ -268,19 +303,19 @@ GET `/game`
Response body: Response body:
```json ```json
{ {
"playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destinationPosition": { "destinationPosition": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"playerPosition": { "playerPosition": {
"x": 0, "x": 0,
"y": 10 "y": 10
} }
} }
``` ```
@ -289,120 +324,222 @@ Response body:
### WS Data format ### WS Data format
- json - json
```json
{
"message": message_type,
"data": ...
}
```
### Game state structure ### Game state structure
URI: `/game-state` Message: `game_dump`
Data: Data:
```json ```json
{ {
"board": { "board": {
"width": 21, "width": 10,
"height": 21 "height": 10
}, },
"destination": { "destination": {
"position": { "position": {
"x": 10, "x": 5,
"y": 10 "y": 5
} }
}, },
"players": [ "players": [
{ {
"uuid": "test-player-id", "id": "test-player-pero",
"name": "Pero", "name": "Pero",
"active": true, "active": true,
"position": { "position": {
"x": 2, "x": 3,
"y": 2 "y": 3
}, },
"move_count": 3, "move_count": 0,
"move_attempt_count": 3 "move_attempt_count": 0,
}, "state": "CREATED"
{ },
"uuid": "95962b49-0003-4bf2-b205-71f2590f2318", {
"name": "Mirko", "id": "test-player-mirko",
"active": true, "name": "Mirko",
"position": { "active": true,
"x": 0, "position": {
"y": 0 "x": 4,
}, "y": 4
"move_count": 15, },
"move_attempt_count": 20 "move_count": 0,
} "move_attempt_count": 0,
], "state": "CREATED"
"layers": [ }
{ ],
"name": "obstacles", "layers": [
"objects": [ {
{ "name": "obstacles",
"type": "OBSTACLE", "objects": [
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 2 "position": {
} "x": 0,
}, "y": 6
{ }
"type": "OBSTACLE", },
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 13 "position": {
} "x": 5,
}, "y": 1
{ }
"type": "OBSTACLE", },
"position": { {
"x": 18, "type": "OBSTACLE",
"y": 18 "position": {
} "x": 1,
}, "y": 6
{ }
"type": "OBSTACLE", }
"position": { ]
"x": 5, },
"y": 4 {
} "name": "destination",
}, "objects": [
{ {
"type": "OBSTACLE", "type": "DESTINATION",
"position": { "position": {
"x": 7, "x": 5,
"y": 10 "y": 5
} }
} }
] ]
}, },
{ {
"name": "destination", "name": "players",
"objects": [ "objects": [
{ {
"type": "DESTINATION", "type": "PLAYER",
"position": { "position": {
"x": 10, "x": 3,
"y": 10 "y": 3
} }
} },
] {
}, "type": "PLAYER",
{ "position": {
"name": "players", "x": 4,
"objects": [ "y": 4
{ }
"type": "PLAYER", }
"position": { ]
"x": 2, }
"y": 2 ]
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
}
}
]
}
]
} }
``` ```
### Product purchase start
Message: `product_purchase_start`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"products": [
{
"name": "CocaCola",
"id": "cocacola-id",
"description": null
},
{
"name": "Pepsi",
"id": "pepsi-id",
"description": null
},
{
"name": "Fanta",
"id": "fanta-id",
"description": null
},
{
"name": "Snickers",
"id": "snickers-id",
"description": null
},
{
"name": "Mars",
"id": "mars-id",
"description": null
},
{
"name": "Burek",
"id": "burek-id",
"description": null
}
],
"timeout": 5
}
```
### Product purchase timer tick
Message: `product_purchase_timer_tick`
Data:
```json
{
"time_left": 4,
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
}
}
```
### Product purchase timer done
Message: `product_purchase_done`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"product": {
"name": "CocaCola",
"id": "cocacola-id",
"description": null
}
}
```
If product selection timeout occured, product will be null.

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -8,15 +8,26 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<script src="js/config.js"></script>
<script src="js/frontend.js"></script>
<title>FairHopper Visualisation Client</title> <title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<div class="container-fluid"> <main class="container-fluid main-container">
<h1 class="mt-1 mb-2"> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client FairHopper Visualisation Client
</h1> </h1>
<div id="purchase-container" class="purchase-container d-none">
<div class="d-flex header">
<h3>
Product selection
</h3>
<h3 id="purchase-countdown" class="countdown"></h3>
</div>
<div id="products-content" class="products-content"></div>
</div>
<div class="row"> <div class="row">
<div class="col-10"> <div class="col-10">
<div class="board-container"> <div class="board-container">
@ -30,134 +41,7 @@
<ul class="players" id="players-content"></ul> <ul class="players" id="players-content"></ul>
</div> </div>
</div> </div>
</div> </main>
</body> </body>
<script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return `
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach(layer => {
objects = objects.concat(layer.objects.filter(obj => obj.type === type))
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
function renderGameDump(data) {
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
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;
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> </html>

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

@ -0,0 +1,180 @@
if (typeof FAIRHOPPER_WS_SERVER === "undefined") {
var FAIRHOPPER_WS_SERVER = "ws://127.0.0.1:8011";
}
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) {
document.getElementById("players-content").innerHTML = 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("");
}
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) {
cell.innerHTML = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
}
});
}
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) {
closePurchaseWindow();
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");
contentElement.innerHTML = products
.map((product) => {
return `
<div class="card product" id="product-${product.id}">
<img src="img/products/${product.name}.jpeg" class="card-img-topx" alt="${product.name}">
<div class="card-body">
<h5 class="card-title">${product.name}</h5>
</div>
</div>
`;
})
.join("");
containerElement.classList.remove("d-none");
purchaseTimeoutElement.innerText = purchaseTimeout;
}
function productPurchaseTimerTick(timeLeft) {
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
purchaseTimeoutElement.innerText = timeLeft;
}
function closePurchaseWindow() {
const container = document.getElementById("purchase-container");
container.classList.add("d-none");
}
function productPurchaseDone(product) {
const cardContainer = document.getElementById(`product-${product.id}`);
cardContainer.classList.add("selected");
}
function wsConnect() {
console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
let ws = new WebSocket(FAIRHOPPER_WS_SERVER);
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_purchase_done":
productPurchaseDone(wsMessage.data.product);
break;
default:
console.error("Unknown message:", wsMessage);
}
};
ws.onclose = (e) => {
setTimeout(() => {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
};

View File

@ -2,6 +2,10 @@ body {
background-color: whitesmoke; background-color: whitesmoke;
} }
main.main-container {
position: relative;
}
.board-container { .board-container {
background-color: white; background-color: white;
border: 1px solid black; border: 1px solid black;
@ -40,7 +44,6 @@ ul.players {
color: white; color: white;
background-color: darkred; background-color: darkred;
border-radius: 5px; border-radius: 5px;
z-index: 1000;
} }
.player-tooltip::after { .player-tooltip::after {
@ -53,3 +56,44 @@ ul.players {
border-style: solid; border-style: solid;
border-color: darkred transparent transparent transparent; border-color: darkred transparent transparent transparent;
} }
.purchase-container {
width: 50vw;
position: fixed;
top: 200px;
left: 50%;
padding: 20px;
transform: translateX(-50%);
background-color: darkred;
z-index: 999;
border-radius: 10px;
}
.purchase-container .header {
color: white;
margin-bottom: 20px;
}
.purchase-container .header .countdown {
margin-left: auto;
}
.purchase-container .products-content {
display: grid;
grid-gap: 10px;
grid-template-columns: 1fr 1fr 1fr;
}
.purchase-container .products-content .product.selected {
background-color: pink;
}
.purchase-container .products-content .product .card-title {
text-align: center;
font-size: 12pt;
}
.purchase-container .products-content .product img {
margin: 20px;
max-height: 300px;
}

View File

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

View File

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

View File

@ -6,22 +6,26 @@ from hopper.api.dto import (
DestinationDto, DestinationDto,
ErrorResponseDto, ErrorResponseDto,
GameInfoDto, GameInfoDto,
GetProductsResponse,
MovePlayerResponseDto, MovePlayerResponseDto,
PingResponse, PingResponse,
PlayerInfoResponseDto, PlayerInfoResponseDto,
ProductDto,
PurchaseProductDto,
StartGameRequestDto, StartGameRequestDto,
StartGameResponseDto, StartGameResponseDto,
) )
from hopper.engine import GameEngine from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds, PurchaseForbiddenForPlayer
from hopper.models.player import Player from hopper.models.player import Player
from settings import settings
router = APIRouter() router = APIRouter()
def get_player(uuid: str, engine: GameEngine = Depends(get_game_engine)) -> Player: def get_player(id: str, engine: GameEngine = Depends(get_game_engine)) -> Player:
player = engine.players.find(uuid) player = engine.players.find(id)
if player is None: if player is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Player not found" status_code=status.HTTP_404_NOT_FOUND, detail="Player not found"
@ -72,7 +76,7 @@ async def start_game(
@router.get( @router.get(
"/player/{uuid}", "/player/{id}",
response_model=PlayerInfoResponseDto, response_model=PlayerInfoResponseDto,
responses={ responses={
status.HTTP_403_FORBIDDEN: { status.HTTP_403_FORBIDDEN: {
@ -81,7 +85,7 @@ async def start_game(
}, },
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out", "description": " Player with id not found, probably kicked out",
}, },
}, },
) )
@ -92,7 +96,7 @@ async def get_player_info(
@router.post( @router.post(
"/player/{uuid}/move/{direction}", "/player/{id}/move/{direction}",
response_model=MovePlayerResponseDto, response_model=MovePlayerResponseDto,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
responses={ responses={
@ -106,7 +110,7 @@ async def get_player_info(
}, },
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out", "description": " Player with id not found, probably kicked out",
}, },
status.HTTP_409_CONFLICT: { status.HTTP_409_CONFLICT: {
"model": ErrorResponseDto, "model": ErrorResponseDto,
@ -144,3 +148,41 @@ async def move_player(
response.status_code = status.HTTP_200_OK response.status_code = status.HTTP_200_OK
return MovePlayerResponseDto(player=player) return MovePlayerResponseDto(player=player)
@router.get("/products", response_model=GetProductsResponse)
async def get_products() -> GetProductsResponse:
return GetProductsResponse(
products=settings.products,
)
@router.get("/products/{id}", response_model=ProductDto)
async def get_product(id: str) -> ProductDto:
for product in settings.products:
if product.id == id:
return ProductDto.from_orm(product)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)
@router.post("/player/{id}/product/purchase", response_model=ProductDto)
async def purchase_product(
body: PurchaseProductDto,
engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player),
) -> ProductDto:
for product in settings.products:
if product.id == body.product_id:
try:
await engine.purchase_product(player=player, product=product)
return ProductDto.from_orm(product)
except PurchaseForbiddenForPlayer:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Purchase forbidden for this player",
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
)

View File

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

View File

@ -5,8 +5,12 @@ from typing import Optional
from hopper.countdown_timer import CountdownTimer from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds from hopper.errors import (
from hopper.interfaces import SendGameDumpInterface Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -18,20 +22,37 @@ from hopper.models.board import (
create_random_position, create_random_position,
) )
from hopper.models.player import Player, PlayerList, Position from hopper.models.player import Player, PlayerList, Position
from hopper.models.product import Product
from hopper.watchdog import InactivityWatchdog from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer
from settings import settings from settings import settings
def create_player_start_position(board_width: int, board_height: int) -> Position:
"""Create random position somewhere on the board border"""
border_len = (board_width + board_height) * 2
rnd_position = random.randint(0, border_len - 1)
if rnd_position < board_width * 2:
x = rnd_position % board_width
y = 0 if rnd_position < board_width else board_height - 1
else:
rnd_position -= 2 * board_width
x = 0 if rnd_position < board_height else board_width - 1
y = rnd_position % board_height
return Position(x=x, y=y)
class GameEngine: class GameEngine:
def __init__( def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None
) -> None:
self.board = board self.board = board
self.ws_server = ws_server self.ws_server = ws_server
self.players = PlayerList() self.players = PlayerList()
self._inacivity_watchdog = None self._inacivity_watchdog = None
self._purchase_countdown_timer: Optional[CountdownTimer] = None self._purchase_countdown_timer: Optional[CountdownTimer] = None
self.reset_game() self.game_state = GameState.RUNNING
self.__debug_print_board()
def dump_board(self) -> list[list[str]]: def dump_board(self) -> list[list[str]]:
dump = self.board.dump() dump = self.board.dump()
@ -60,19 +81,24 @@ class GameEngine:
self._inacivity_watchdog = InactivityWatchdog( self._inacivity_watchdog = InactivityWatchdog(
players=self.players, players=self.players,
ws_server=self.ws_server, ws_server=self.ws_server,
daemon=True,
) )
self._inacivity_watchdog.start() self._inacivity_watchdog.start()
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.__debug_print_board()
self.game_state = GameState.RUNNING self.game_state = GameState.RUNNING
self.players.clear()
await self.send_game_dump()
async def start_game_for_player(self, player_name: str) -> Player: async def start_game_for_player(self, player_name: str) -> Player:
self._start_inactivity_watchdog() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
position=self._create_player_start_position(), position=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED, state=PlayerState.CREATED,
) )
self.players.append(player) self.players.append(player)
@ -80,27 +106,10 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}") logging.info(f"Starting new game for player: {player}")
self.__debug_print_board() self.__debug_print_board()
if self.ws_server: await self.send_game_dump()
await self.ws_server.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY) await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
def _create_player_start_position(self) -> Position:
"""Create random position somewhere on the board border"""
border_len = (self.board.width + self.board.height) * 2
rnd_position = random.randint(0, border_len - 1)
if rnd_position < self.board.width * 2:
x = rnd_position % self.board.width
y = 0 if rnd_position < self.board.width else self.board.height - 1
else:
rnd_position -= 2 * self.board.width
x = 0 if rnd_position < self.board.height else self.board.width - 1
y = rnd_position % self.board.height
return Position(x=x, y=y)
def _move_position(self, position: Position, direction: Direction) -> Position: def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y) new_position = Position(position.x, position.y)
if direction == Direction.LEFT: if direction == Direction.LEFT:
@ -144,20 +153,12 @@ class GameEngine:
if self._is_player_on_destination(player): if self._is_player_on_destination(player):
player.state = PlayerState.ON_DESTINATION player.state = PlayerState.ON_DESTINATION
self._player_on_destination(player) await self._player_on_destination(player)
logging.info(f"Player {player} reached destination!")
if self.ws_server:
await self.ws_server.send_game_dump()
self.__debug_print_board()
if player.state == PlayerState.ON_DESTINATION:
self.game_state = GameState.LOCK_FOR_MOVEMENT
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
await asyncio.sleep(settings.game.MOVE_DELAY) await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK return PlayerMoveResult.OK
def _is_player_on_destination(self, player: Player) -> bool: def _is_player_on_destination(self, player: Player) -> bool:
@ -171,19 +172,66 @@ class GameEngine:
def _colided_with_obstacle(self, position: Position) -> bool: def _colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None return self.board.get_object_at_position(position) is not None
def _player_on_destination(self, player: Player) -> None: async def _player_on_destination(self, player: Player) -> None:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT self.game_state = GameState.LOCK_FOR_MOVEMENT
logging.info(f"Starting purchase countdown timer for {settings.purchase_timeout} seconds") 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( self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout, 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() self._purchase_countdown_timer.start()
def _on_purchase_timeout(self) -> None: await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
logging.info("Ding ding! Purchase countdown timer timeout")
self._purchase_countdown_timer = None async def purchase_product(self, player: Player, product: Product) -> None:
self.game_state = GameState.RUNNING 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 asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY)
await self.reset_game()
def _reset_player(self, player) -> None:
# move player to start position
player.position = create_player_start_position(
self.board.width, self.board.height
)
player.state = PlayerState.CREATED
player.last_seen = None
def get_board_layout(self) -> BoardLayout: def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players) return BoardLayout(board=self.board, players=self.players)
@ -195,7 +243,7 @@ class GameEngineFactory:
board_width: int, board_width: int,
board_height: int, board_height: int,
obstacle_count: int = 0, obstacle_count: int = 0,
ws_server: Optional[SendGameDumpInterface] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
@ -221,7 +269,7 @@ class GameEngineFactory:
@staticmethod @staticmethod
def create_default( def create_default(
ws_server: Optional[SendGameDumpInterface] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,

View File

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

View File

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

View File

@ -9,6 +9,8 @@ from hopper.models.product import Product
@dataclass @dataclass
class GameSettings: class GameSettings:
MOVE_DELAY: float = 0.5 # seconds MOVE_DELAY: float = 0.5 # seconds
PURCHASE_START_DELAY: float = 2 # seconds
PURCHASE_FINISHED_DELAY: float = 2 # seconds
@dataclass @dataclass
@ -27,7 +29,7 @@ class InactivityWatchdogSettings:
@dataclass @dataclass
class WSServerSettings: class WSServerSettings:
HOST: str = "localhost" HOST: str = "127.0.0.1"
PORT: int = 8011 PORT: int = 8011
@ -43,7 +45,7 @@ class Settings:
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO log_level: int = logging.INFO
products: Optional[List[Product]] = None products: List[Product] = None
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

View File

@ -15,7 +15,7 @@ class Position:
@dataclass @dataclass
class Player: class Player:
name: str name: str
uuid: str = field(default_factory=lambda: str(uuid.uuid4())) id: str = field(default_factory=lambda: str(uuid.uuid4()))
position: Position = field(default_factory=lambda: Position(0, 0)) position: Position = field(default_factory=lambda: Position(0, 0))
move_count: int = 0 move_count: int = 0
move_attempt_count: int = 0 move_attempt_count: int = 0
@ -31,8 +31,8 @@ class Player:
class PlayerList(list[Player]): class PlayerList(list[Player]):
def find(self, uuid: str) -> Optional[Player]: def find(self, id: str) -> Optional[Player]:
for player in self: for player in self:
if player.uuid == uuid: if player.id == id:
return player return player
return None return None

View File

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

View File

@ -1,12 +1,19 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import TypeVar, Generic from typing import Optional, TypeVar
from pydantic import Field from pydantic import Field
from pydantic.generics import GenericModel 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.enums import ObjectType
@ -20,23 +27,35 @@ class LayerDto(BaseModel):
objects: list[LayerObjectDto] objects: list[LayerObjectDto]
class GameDumpPlayerDto(PlayerDto):
...
class GameDumpDto(BaseModel): class GameDumpDto(BaseModel):
board: BoardDto board: BoardDto
destination: DestinationDto destination: DestinationDto
players: list[GameDumpPlayerDto] players: list[PlayerDto]
layers: list[LayerDto] layers: list[LayerDto]
class ProductPurchaseStartDto(BaseModel):
player: PlayerDto
products: list[ProductDto]
timeout: int
class ProductPurchaseTimerDto(BaseModel):
time_left: int
player: PlayerDto
class ProductPurchaseDoneDto(BaseModel):
player: PlayerDto
product: Optional[ProductDto] = None
TMessageData = TypeVar("TMessageData", bound=BaseModel) TMessageData = TypeVar("TMessageData", bound=BaseModel)
class WSMessage(GenericModel): class WSMessage(GenericModel):
message: str message: str
data: TMessageData data: Optional[TMessageData] = None
def __str__(self) -> str: def __str__(self) -> str:
return self.to_str() return self.to_str()
@ -48,3 +67,18 @@ class WSMessage(GenericModel):
class WSGameDumpMessage(WSMessage): class WSGameDumpMessage(WSMessage):
message: str = "game_dump" message: str = "game_dump"
data: GameDumpDto data: GameDumpDto
class WSProductPurchaseStartMessage(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto
class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done"
data: ProductPurchaseDoneDto

View File

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

View File

@ -1,16 +1,34 @@
import asyncio import asyncio
import logging import logging
from threading import Thread from threading import Thread
from typing import Iterable, Optional
import websockets import websockets
from websockets import WebSocketServerProtocol from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK from websockets.exceptions import ConnectionClosedOK
from hopper.models.ws_dto import GameDumpDto, WSGameDumpMessage from hopper.models.player import Player
from hopper.models.product import Product
from hopper.models.ws_dto import (
GameDumpDto,
ProductPurchaseDoneDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto,
WSGameDumpMessage,
WSMessage,
WSProductPurchaseDoneMessage,
WSProductPurchaseStartMessage,
WSProductPurchaseTimerTickMessage,
)
from settings import settings from settings import settings
class WSServer(Thread): class WSServer(Thread):
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
super().__init__(daemon=True)
async def handler(self, websocket: WebSocketServerProtocol) -> None: async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client""" """New handler instance spawns for each connected client"""
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
@ -31,6 +49,19 @@ class WSServer(Thread):
self.connected_clients.remove(websocket) self.connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}") logging.info(f"Remove client: {websocket.id}")
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: 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
@ -55,25 +86,49 @@ class WSServer(Thread):
async def send_game_dump(self) -> None: async def send_game_dump(self) -> None:
"""Broadcast game state to all connected clients""" """Broadcast game state to all connected clients"""
if not self.connected_clients:
return
message = self._create_game_dump_message() message = self._create_game_dump_message()
logging.debug( await self.send_message_to_clients(message)
f"Sending game dump to clients: {self.connected_clients}: {message}"
async def send_product_purchase_start_message(
self, player: Player, products: Iterable[Product]
) -> None:
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
player=player,
products=products,
timeout=settings.purchase_timeout,
)
) )
for client in self.connected_clients: await self.send_message_to_clients(message)
await client.send(message)
async def send_product_purchase_time_left_message(
self, player: Player, time_left: int
) -> None:
message = WSProductPurchaseTimerTickMessage(
data=ProductPurchaseTimerDto(
player=player,
time_left=time_left,
)
)
await self.send_message_to_clients(message)
async def send_product_purchase_done_message(
self, player: Player, product: Optional[Product] = None
) -> None:
message = WSProductPurchaseDoneMessage(
data=ProductPurchaseDoneDto(player=player, product=product),
)
await self.send_message_to_clients(message)
async def run_async(self) -> None: async def run_async(self) -> None:
logging.info( logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}" f"Starting FairHopper Websockets Server on {self.host}:{self.port}"
) )
async with websockets.serve( async with websockets.serve(
ws_handler=self.handler, ws_handler=self.handler,
host=settings.ws_server.HOST, host=self.host,
port=settings.ws_server.PORT, port=self.port,
): ):
await asyncio.Future() # run forever await asyncio.Future() # run forever

51
sdk/demo.py Normal file
View File

@ -0,0 +1,51 @@
import random
from fh_sdk import Direction, FairHopper, Position
import math
HOST = "http://localhost"
PORT = 8010
def calc_angle(position1: Position, position2: Position) -> float:
x1, y1 = position1.x, position1.y
x2, y2 = position2.x, position2.y
return math.atan2(y2 - y1, x2 - x1) * (180 / math.pi)
fh = FairHopper(host=HOST, port=PORT)
res = fh.ping()
game = fh.start_game(player_name=f"Mirko {random.randint(0, 9999)}")
print(game.player.position)
quit()
res = fh.get_game_info()
print(">>>>>", res)
res = fh.get_player_info("XX")
print(">>>>>", res)
position = game.player.position
dest_position = game.destination.position
# p1 = PositionDto(x=0, y=20)
# p2 = PositionDto(x=10, y=10)
# angle = calc_angle(p1, p2)
# print(angle)
# quit()
for _ in range(10):
angle = calc_angle(position, dest_position) + 180
if 0 <= angle < 90:
direction = Direction.RIGHT
elif 90 <= angle <= 180:
direction = Direction.DOWN
elif 180 <= angle <= 270:
direction = Direction.RIGHT
else:
direction = Direction.UP
print(position, dest_position, int(angle), direction)
move_response = fh.move(game.player.id, direction)
position = move_response.player.position

View File

@ -2,18 +2,49 @@ import logging
from hopper.models.config import ( from hopper.models.config import (
BoardSettings, BoardSettings,
DebugSettings,
GameSettings, GameSettings,
InactivityWatchdogSettings, InactivityWatchdogSettings,
Settings, Settings,
WSServerSettings, WSServerSettings,
) )
from hopper.models.player import Player, Position
from hopper.models.product import Product
settings = Settings( settings = Settings(
game=GameSettings(), game=GameSettings(),
board=BoardSettings(), board=BoardSettings(
WIDTH=20,
HEIGHT=20,
OBSTACLE_COUNT=10,
),
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO, log_level=logging.INFO,
products=[
Product(name="CocaCola", id="cocacola-id"),
Product(name="Pepsi", id="pepsi-id"),
Product(name="Fanta", id="fanta-id"),
Product(name="Snickers", id="snickers-id"),
Product(name="Mars", id="mars-id"),
Product(name="Burek", id="burek-id"),
],
ws_server=WSServerSettings(), ws_server=WSServerSettings(),
purchase_timeout=10, debug=DebugSettings(
debug=None, PRINT_BOARD=True,
PLAYERS=[
Player(
name="Pero",
id="test-player-pero",
position=Position(x=9, y=10),
can_be_deactivated=False,
),
Player(
name="Mirko",
id="test-player-mirko",
position=Position(x=10, y=5),
can_be_deactivated=False,
),
],
),
) )