27 Commits

Author SHA1 Message Date
b2a132a002 Merge branch 'client-endgame' 2023-05-11 19:38:45 +02:00
d660845d30 Endgame WS messages & docs 2023-05-11 19:36:16 +02:00
76ee207bce Frontend support for product selection 2023-05-11 16:09:32 +02:00
9151aa3e1e Product selection message handler 2023-05-11 15:08:24 +02:00
69e087c0c9 Drop old purchase views and models 2023-05-10 15:49:08 +02:00
24d05dc234 Handle connection error in ws handler 2023-05-06 09:38:09 +02:00
7fd6ffca25 Multistage build 2023-05-03 17:57:46 +02:00
2dd246ee76 Add purchase product errors docs 2023-04-23 10:03:22 +02:00
8ecd0f92df Update readme 2023-04-21 15:19:35 +02:00
1dba9d1424 Merge branch 'dockerize' 2023-04-21 15:05:01 +02:00
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
22 changed files with 721 additions and 429 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

50
Dockerfile Normal file
View File

@ -0,0 +1,50 @@
FROM python:3.10.11-alpine3.17 as env-builder
# handle optional arguments
ARG INTERNAL_API_PORT=8010
ARG INTERNAL_WS_PORT=8011
RUN \
apk add --no-cache gcc musl-dev libffi-dev && \
pip install pip -U --no-cache-dir --prefer-binary && \
pip install poetry --no-cache-dir --prefer-binary
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
FROM python:3.10.11-alpine3.17 as runner
WORKDIR /app
COPY --from=env-builder /venv /venv
# set python thingies and activate virtual environment
ENV \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/venv/bin:$PATH"
# 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,37 @@ 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 \
buildx build \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
--tag $(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

257
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
@ -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
@ -52,10 +62,59 @@ EndPlayer --> UnlockGame: Unlock game\n for all players
## FairHopper Game Server ## FairHopper Game Server
### Start server as docker container
Build image:
```sh
docker build . -t CONTAINER_NAME
```
Create docker container:
```sh
docker \
create \
--publish EXTERNAL_API_PORT:8010 \
--publish EXTERNAL_WS_PORT:8011 \
--name=CONTAINER_NAME \
IMAGE_NAME
```
Parameters:
- `EXTERNAL_API_PORT` - REST API port
- `EXTERNAL_WS_PORT` - Websockets port
- `CONTAINER_NAME` - FairHopper container name
- `IMAGE_NAME` - FairHopper image name
Start docker container:
```sh
docker start CONTAINER_NAME -d
```
Stop docker container:
```sh
docker stop CONTAINER_NAME
```
Example:
```sh
docker build . -t fairhopper-service
docker \
run \
--publish 8010:8010 \
--publish 8011:8011 \
--name=fairhopper-service \
fairhopper \
--detach
docker start fairhopper-service -d
docker stop fairhopper-service
```
### Start server on local machine
Requirements: Requirements:
- Python 3.10+ - Python 3.10+
### Install virtual envirnonment #### Install virtual envirnonment
Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python. Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python.
@ -69,14 +128,14 @@ Install virtual environment:
poetry install poetry install
``` ```
### Setting up #### Setting up
Copy `settings_template.py` to `settings.py`. Copy `settings_template.py` to `settings.py`.
Edit `settings.py` and customize application. Edit `settings.py` and customize application.
### Starting FairHopper Game Server #### Starting FairHopper Game Server
```sh ```sh
make run make run
@ -111,11 +170,11 @@ package Masterpiece #seashell {
usecase Game as "Game Engine" usecase Game as "Game Engine"
usecase WS as "WS Server" usecase WS as "WS Server"
} }
usecase Vis as "Visualisation\nService" usecase Vis as "Flutter\nVisualisation\nService"
} }
usecase ExtVis1 as "Visualisation\nService" usecase ExtVis1 as "Visualisation\nClient"
usecase ExtVis2 as "Visualisation\nService" usecase ExtVis2 as "Visualisation\nClient"
P1 -left-> API: REST API P1 -left-> API: REST API
P2 -left-> API: REST API P2 -left-> API: REST API
@ -145,41 +204,58 @@ Game ->o WS: Send initial state
Client1 ->o WS: Client connect Client1 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client1: Game state WS -> Client1: Game state
deactivate deactivate WS
Client2 ->o WS: Client connect Client2 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client2: Game state WS -> Client2: Game state
deactivate deactivate WS
loop #lightyellow On game state change loop #lightyellow On game state change
Game ->o WS: Game state Game ->o WS: Game state
activate WS #coral activate WS #coral
WS o-> Client1: Game state WS o-> Client1: Game state
WS o-> Client2: Game state WS o-> Client2: Game state
deactivate deactivate WS
end end
== Product purchase mode == == Player reached destination ==
Game -> WS: Purchase start Game -> Game: Lock game for other players
activate Game
Game -> WS: Player reached destination
activate WS #coral activate WS #coral
WS o-> Client1: Purchase start WS o-> Client1: Select product
WS o-> Client2: Purchase start WS o-> Client2: Select product
deactivate deactivate WS
deactivate Game
loop #lightyellow Purchase countdown timer loop #lightyellow Product select countdown timer (60s)
Game ->o WS: Timer count down Game ->o WS: Timer timeout
activate Game
activate WS #coral activate WS #coral
WS o-> Client1: Purchase time left WS o-> Client1: Selection timeout
WS o-> Client2: Purchase time left WS o-> Client2: Selection timeout
deactivate deactivate WS
Game -> Game: Unlock game
deactivate Game
end end
Game -> WS: Purchase done
Client1 -> WS: Product selected
activate WS #coral activate WS #coral
WS o-> Client1: Purchase done WS o-> Game: Product selected
WS o-> Client2: Purchase done activate Game
deactivate WS o-> Client2: Product selected
deactivate WS
Game -> Game: Unlock game
Game -> WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
deactivate WS
deactivate Game
``` ```
@ -220,7 +296,7 @@ Response body:
} }
}, },
"player": { "player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 0, "x": 0,
@ -234,17 +310,17 @@ Response body:
### 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
@ -252,7 +328,7 @@ Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
@ -266,7 +342,7 @@ Response body:
### Get Player Info ### Get Player Info
GET `/player/{{uuid}}` GET `/player/{{id}}`
Request body: None Request body: None
@ -274,7 +350,7 @@ Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
@ -314,46 +390,57 @@ Response body:
### WS Data format ### WS Data format
- json - json
```json
{
"message": message_type,
"data": ...
}
```
### Game state structure ### Game state structure
URI: `/game-state` Direction: Game server -> Clients
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", "id": "test-player-mirko",
"name": "Mirko", "name": "Mirko",
"active": true, "active": true,
"position": { "position": {
"x": 0, "x": 4,
"y": 0 "y": 4
}, },
"move_count": 15, "move_count": 0,
"move_attempt_count": 20 "move_attempt_count": 0,
"state": "CREATED"
} }
], ],
"layers": [ "layers": [
@ -363,36 +450,22 @@ Data:
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 4, "x": 0,
"y": 2 "y": 6
}
},
{
"type": "OBSTACLE",
"position": {
"x": 4,
"y": 13
}
},
{
"type": "OBSTACLE",
"position": {
"x": 18,
"y": 18
} }
}, },
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 5, "x": 5,
"y": 4 "y": 1
} }
}, },
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 7, "x": 1,
"y": 10 "y": 6
} }
} }
] ]
@ -403,8 +476,8 @@ Data:
{ {
"type": "DESTINATION", "type": "DESTINATION",
"position": { "position": {
"x": 10, "x": 5,
"y": 10 "y": 5
} }
} }
] ]
@ -415,15 +488,15 @@ Data:
{ {
"type": "PLAYER", "type": "PLAYER",
"position": { "position": {
"x": 2, "x": 3,
"y": 2 "y": 3
} }
}, },
{ {
"type": "PLAYER", "type": "PLAYER",
"position": { "position": {
"x": 0, "x": 4,
"y": 0 "y": 4
} }
} }
] ]
@ -431,3 +504,43 @@ Data:
] ]
} }
``` ```
### Player reached destination
Direction: Game server -> Clients
Message: `player_reached_destination`
Data:
```json
{
"player": {
"id": "2e0f1a50-eaa6-4efd-b0c3-adbf7000eec2",
"name": "Joso",
"active": true,
"position": {
"x": 5,
"y": 5
},
"move_count": 6,
"move_attempt_count": 6,
"state": "ON_DESTINATION"
}
}
```
### Product selection timeout
Direction: Game server -> Clients
Message: `product_selection_timeout`
Data: `null`
### Product selection done
Message: `product_selection_done`
Direction: Client -> Game server, Game server -> Clients
Data: `null`

View File

@ -39,7 +39,7 @@ POST http://localhost:8010/player/test-player-pero/product/purchase
Content-Type: application/json Content-Type: application/json
{ {
"product_uuid": "cocacola-id" "product_id": "cocacola-id"
} }
### ###

View File

@ -8,13 +8,17 @@
<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="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js"
integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V"
crossorigin="anonymous"></script>
<script src="js/config.js"></script>
<script src="js/frontend.js"></script> <script src="js/frontend.js"></script>
<title>FairHopper Visualisation Client</title> <title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<main class="container-fluid container"> <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>
@ -41,6 +45,26 @@
</div> </div>
</div> </div>
</main> </main>
<div class="modal fade" id="player-on-destination-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Player on destination</h5>
</div>
<div class="modal-body">
Player <strong class="player-name"></strong>
reached destination in <strong class="move-count"></strong>
moves.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="finish-product-selection" data-bs-dismiss="modal">
Finish product selection
</button>
</div>
</div>
</div>
</div>
</body> </body>
</html> </html>

View File

@ -1,3 +1,10 @@
if (typeof FAIRHOPPER_WS_SERVER === "undefined") {
var FAIRHOPPER_WS_SERVER = "ws://127.0.0.1:8011";
}
let ws = null;
let playerOnDestinationModal = null;
const BOARD_ICONS = { const BOARD_ICONS = {
PLAYER: "😀", PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎", PLAYER_ON_DESTINATION: "😎",
@ -33,10 +40,10 @@ function renderCellContent(position, content) {
} }
function renderPlayerList(players) { function renderPlayerList(players) {
const html = players document.getElementById("players-content").innerHTML = players
.filter((player) => player.active) .filter((player) => player.active)
.map((player) => { .map((player) => {
const onDestination = player.state == "ON_DESTINATION"; const onDestination = player.state === "ON_DESTINATION";
return ` return `
<li class="${onDestination ? "text-success" : ""}"> <li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count}) ${player.name} (${player.move_count})
@ -45,7 +52,6 @@ function renderPlayerList(players) {
`; `;
}) })
.join(""); .join("");
document.getElementById("players-content").innerHTML = html;
} }
function renderPlayers(players) { function renderPlayers(players) {
@ -53,14 +59,13 @@ function renderPlayers(players) {
.filter((player) => player.active) .filter((player) => player.active)
.forEach((player) => { .forEach((player) => {
const cell = findCell(player.position); const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION"; const onDestination = player.state === "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER; const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) { if (cell) {
const html = ` cell.innerHTML = `
<div class="player-tooltip">${player.name}</div> <div class="player-tooltip">${player.name}</div>
${playerIcon} ${playerIcon}
`; `;
cell.innerHTML = html;
} }
}); });
} }
@ -92,47 +97,25 @@ function renderGameDump(data) {
renderPlayers(data.players); renderPlayers(data.players);
} }
function productPurchaseStart(products, purchaseTimeout) { function playerReachedDestination(data) {
console.log("productPurchaseStart:", products); const dlgElement = document.getElementById("player-on-destination-modal");
const containerElement = document.getElementById("purchase-container"); dlgElement.querySelector(".player-name").textContent = data.player.name;
const contentElement = document.getElementById("products-content"); dlgElement.querySelector(".move-count").textContent = data.player.move_count;
const purchaseTimeoutElement = document.getElementById("purchase-countdown"); playerOnDestinationModal.show();
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) { function productSelectionTimeout() {
const purchaseTimeoutElement = document.getElementById("purchase-countdown"); playerOnDestinationModal.hide();
purchaseTimeoutElement.innerText = timeLeft;
} }
function productPurchased(product) { function productSelectionDone() {
console.log("productPurchased:", product); playerOnDestinationModal.hide();
}
function productPurchaseDone() {
console.log("productPurchaseDone");
const container = document.getElementById("purchase-container");
container.classList.add("d-none");
} }
function wsConnect() { function wsConnect() {
let ws = new WebSocket("ws://localhost:8011"); console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
ws = new WebSocket(FAIRHOPPER_WS_SERVER);
ws.onopen = () => { ws.onopen = () => {
console.log("WS connected"); console.log("WS connected");
}; };
@ -145,17 +128,14 @@ function wsConnect() {
case "game_dump": case "game_dump":
renderGameDump(wsMessage.data); renderGameDump(wsMessage.data);
break; break;
case "product_purchase_start": case "player_reached_destination":
productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout); playerReachedDestination(wsMessage.data);
break; break;
case "product_purchase_timer_tick": case "product_selection_timeout":
productPurchaseTimerTick(wsMessage.data.time_left); productSelectionTimeout();
break; break;
case "product_purchased": case "product_selection_done":
productPurchased(wsMessage.data); productSelectionDone();
break;
case "product_purchase_done":
productPurchaseDone();
break; break;
default: default:
console.error("Unknown message:", wsMessage); console.error("Unknown message:", wsMessage);
@ -163,7 +143,8 @@ function wsConnect() {
}; };
ws.onclose = (e) => { ws.onclose = (e) => {
setTimeout(function () { ws = null;
setTimeout(() => {
wsConnect(); wsConnect();
}, 1000); }, 1000);
}; };
@ -174,6 +155,24 @@ function wsConnect() {
}; };
} }
function finishProductSelection() {
if (!ws) {
return;
}
const wsMessage = {
message: "product_selection_done",
data: null,
};
ws.send(JSON.stringify(wsMessage));
}
window.onload = () => { window.onload = () => {
const dlgElement = document.getElementById("player-on-destination-modal");
playerOnDestinationModal = new bootstrap.Modal(dlgElement);
document.getElementById("finish-product-selection").onclick = () => {
finishProductSelection();
};
wsConnect(); wsConnect();
}; };

View File

@ -2,7 +2,7 @@ body {
background-color: whitesmoke; background-color: whitesmoke;
} }
main.container { main.main-container {
position: relative; position: relative;
} }
@ -84,8 +84,13 @@ ul.players {
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
} }
.purchase-container .products-content .product.selected {
background-color: pink;
}
.purchase-container .products-content .product .card-title { .purchase-container .products-content .product .card-title {
text-align: center; text-align: center;
font-size: 12pt;
} }
.purchase-container .products-content .product img { .purchase-container .products-content .product img {

View File

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

View File

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

View File

@ -5,12 +5,7 @@ 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 ( from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
Collision,
GameLockForMovement,
PositionOutOfBounds,
PurchaseForbiddenForPlayer,
)
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -22,7 +17,6 @@ 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 hopper.ws_server import WSServer
from settings import settings from settings import settings
@ -178,29 +172,20 @@ class GameEngine:
self.game_state = GameState.LOCK_FOR_MOVEMENT self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.send_game_dump() await self.send_game_dump()
await self.ws_server.send_product_purchase_start_message( await self.ws_server.send_player_reached_destination_message(player=player)
player=player, products=settings.products
)
logging.info( logging.info(
f"Starting purchase countdown timer for {settings.purchase_timeout} seconds" f"Starting product selection countdown timer for {settings.purchase_timeout} seconds"
) )
def on_purchase_timer_tick(time_left) -> None: def on_purchase_timer_tick(time_left) -> None:
logging.info(f"Purchase countdown timer tick, time left: {time_left}") logging.info(f"Product selection 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: def on_purchase_timer_done() -> None:
logging.info("Ding ding! Purchase countdown timer timeout") logging.info("Ding ding! Product selection countdown timer timeout")
self._purchase_countdown_timer = None self._purchase_countdown_timer = None
asyncio.run( asyncio.run(
self.ws_server.send_product_purchase_done_message( self.ws_server.send_product_selection_timeout_message()
player=player, product=None
)
) )
self.game_state = GameState.RUNNING self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump()) asyncio.run(self.send_game_dump())
@ -212,14 +197,13 @@ class GameEngine:
) )
self._purchase_countdown_timer.start() self._purchase_countdown_timer.start()
async def purchase_product(self, player: Player, product: Product) -> None: await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
if not player.state == PlayerState.ON_DESTINATION:
raise PurchaseForbiddenForPlayer() async def product_selection_done(self) -> None:
logging.info("Product selection done, unlocking game")
if self._purchase_countdown_timer: if self._purchase_countdown_timer:
self._purchase_countdown_timer.stop() self._purchase_countdown_timer.stop()
await self.ws_server.send_product_purchase_done_message( await self.ws_server.send_product_selection_done_message()
player=player, product=product
)
await self.reset_game() await self.reset_game()
def _reset_player(self, player) -> None: def _reset_player(self, player) -> None:

View File

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

View File

@ -9,6 +9,7 @@ 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
@dataclass @dataclass
@ -27,7 +28,7 @@ class InactivityWatchdogSettings:
@dataclass @dataclass
class WSServerSettings: class WSServerSettings:
HOST: str = "localhost" HOST: str = "127.0.0.1"
PORT: int = 8011 PORT: int = 8011
@ -45,5 +46,5 @@ class Settings:
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO log_level: int = logging.INFO
products: Optional[List[Product]] = None 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

@ -6,5 +6,5 @@ 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 description: Optional[str] = None

View File

@ -6,14 +6,7 @@ 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 ( from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
ProductDto,
)
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -34,20 +27,8 @@ class GameDumpDto(BaseModel):
layers: list[LayerDto] layers: list[LayerDto]
class ProductPurchaseStartDto(BaseModel): class PlayerReachedDestinationDto(BaseModel):
player: PlayerDto 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)
@ -63,22 +44,25 @@ class WSMessage(GenericModel):
def to_str(self) -> str: def to_str(self) -> str:
return json.dumps(self.dict()) return json.dumps(self.dict())
@classmethod
@property
def message_type(cls) -> str:
return cls.__fields__["message"].default
class WSGameDumpMessage(WSMessage): class WSGameDumpMessage(WSMessage):
message: str = "game_dump" message: str = "game_dump"
data: GameDumpDto data: GameDumpDto
class WSProductPurchaseStartMessage(WSMessage): class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_purchase_start" message: str = "product_selection_done"
data: ProductPurchaseStartDto
class WSProductPurchaseTimerTickMessage(WSMessage): class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_purchase_timer_tick" message: str = "product_selection_timeout"
data: ProductPurchaseTimerDto
class WSProductPurchaseDoneMessage(WSMessage): class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "product_purchase_done" message: str = "player_reached_destination"
data: ProductPurchaseDoneDto data: PlayerReachedDestinationDto

View File

@ -1,26 +1,22 @@
import asyncio import asyncio
import json
import logging import logging
from threading import Thread from threading import Thread
from typing import Iterable, Optional
import websockets import websockets
from websockets import WebSocketServerProtocol from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from hopper.models.player import Player from hopper.models.player import Player
from hopper.models.product import Product
from hopper.models.ws_dto import ( from hopper.models.ws_dto import (
GameDumpDto, GameDumpDto,
ProductPurchaseDoneDto, PlayerReachedDestinationDto,
ProductPurchaseStartDto,
ProductPurchaseTimerDto,
WSGameDumpMessage, WSGameDumpMessage,
WSMessage, WSMessage,
WSProductPurchaseDoneMessage, WSPlayerReachedDestinationMessage,
WSProductPurchaseStartMessage, WSProductSelectionDoneMessage,
WSProductPurchaseTimerTickMessage, WSProductSelectionTimeoutMessage,
) )
from settings import settings
class WSServer(Thread): class WSServer(Thread):
@ -29,6 +25,31 @@ class WSServer(Thread):
self.port = port self.port = port
super().__init__(daemon=True) super().__init__(daemon=True)
async def handle_rcv_message(
self, client: WebSocketServerProtocol, raw_message: str
) -> None:
try:
ws_message = json.loads(raw_message)
except Exception as ex:
logging.error(
f"Error decoding WS message from {client.id} {raw_message}: {ex}"
)
return None
data_message = ws_message.get("message")
if data_message == WSProductSelectionDoneMessage.message_type:
await self.handle_rcv_product_selection_done(client)
async def handle_rcv_product_selection_done(
self, client: WebSocketServerProtocol
) -> None:
logging.info(f"Handle WSProductSelectionDoneMessage: {client.id}")
# avoid circular imports
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
await engine.product_selection_done()
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)
@ -42,8 +63,15 @@ class WSServer(Thread):
while connected: while connected:
try: try:
# we're expecting nothing from client, but read if client sends a message # we're expecting nothing from client, but read if client sends a message
await websocket.recv() rcv_data = await websocket.recv()
await self.handle_rcv_message(
client=websocket, raw_message=rcv_data
)
except ConnectionClosedOK: except ConnectionClosedOK:
logging.info(f"Connection closed OK for client: {websocket.id}")
connected = False
except ConnectionClosedError:
logging.info(f"Connection closed error for client: {websocket.id}")
connected = False connected = False
finally: finally:
self.connected_clients.remove(websocket) self.connected_clients.remove(websocket)
@ -89,35 +117,20 @@ class WSServer(Thread):
message = self._create_game_dump_message() message = self._create_game_dump_message()
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
async def send_product_purchase_start_message( async def send_player_reached_destination_message(self, player: Player) -> None:
self, player: Player, products: Iterable[Product] message = WSPlayerReachedDestinationMessage(
) -> None: data=PlayerReachedDestinationDto(
message = WSProductPurchaseStartMessage(
data=ProductPurchaseStartDto(
player=player, player=player,
products=products,
timeout=settings.purchase_timeout,
) )
) )
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
async def send_product_purchase_time_left_message( async def send_product_selection_done_message(self) -> None:
self, player: Player, time_left: int message = WSProductSelectionDoneMessage()
) -> None:
message = WSProductPurchaseTimerTickMessage(
data=ProductPurchaseTimerDto(
player=player,
time_left=time_left,
)
)
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
async def send_product_purchase_done_message( async def send_product_selection_timeout_message(self) -> None:
self, player: Player, product: Optional[Product] = None message = WSProductSelectionTimeoutMessage()
) -> None:
message = WSProductPurchaseDoneMessage(
data=ProductPurchaseDoneDto(player=player, product=product),
)
await self.send_message_to_clients(message) await self.send_message_to_clients(message)
async def run_async(self) -> None: async def run_async(self) -> None:

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,
),
],
),
) )