68 Commits

Author SHA1 Message Date
afbb3d7436 Cleanup 2023-05-12 20:39:31 +02:00
21a7f111b2 Cleanup from product selection 2023-05-12 20:34:46 +02:00
fb4651ec23 Update readme 2023-05-12 09:30:50 +02:00
6ff6433be3 Update makefile 2023-05-11 21:39:26 +02:00
2653eabb6c Update readme 2023-05-11 21:23:52 +02:00
b56071e2c7 Thread event to stop inactivity WD 2023-05-11 20:01:33 +02:00
78c3286c17 Update docs 2023-05-11 19:42:11 +02:00
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
28a981980f Product purchase 2023-03-31 13:06:27 +02:00
e1e77aba96 Optimizations 2023-03-31 12:19:48 +02:00
659ca82d74 Send player info with product purchase data 2023-03-31 11:51:05 +02:00
210a6aff7c Producs on FE 2023-03-31 10:19:21 +02:00
c9707c0523 Threads creation optimization 2023-03-30 21:13:54 +02:00
059408242c Send purchase state 2023-03-30 21:09:11 +02:00
6111d07f09 WSMessage object 2023-03-30 18:53:24 +02:00
b80130d942 Purchase timeout 2023-03-30 13:16:25 +02:00
ecffdc5d1e Player state 2023-03-30 13:09:23 +02:00
8a48d61dc9 Multiple test players 2023-03-30 12:05:58 +02:00
33f2220356 Game state 2023-03-30 11:44:20 +02:00
413e395a75 Change terminology game state -> game dump 2023-03-30 11:32:16 +02:00
48cb1a3798 Update readme 2023-03-29 09:35:38 +02:00
988878502c Hide inactive players from board dump 2023-03-28 09:45:49 +02:00
0e8775bd08 FE tweak 2023-03-27 14:28:15 +02:00
b5a49fb53b Add fairhopper-sdk as module 2023-03-27 13:55:52 +02:00
9acaf0c2c0 Remove SDK 2023-03-27 09:53:29 +02:00
870e2deb79 FE tweak 2023-03-27 09:52:51 +02:00
fa2aee881d Cleanups 2023-03-26 23:59:15 +02:00
f74bc9b52e FE tweaks 2023-03-26 20:00:35 +02:00
63e7e0d21c Update readme 2023-03-26 14:58:57 +02:00
4831f1e393 Update readme 2023-03-26 14:42:26 +02:00
806a379253 Demo SDK 2023-03-26 14:37:39 +02:00
f54344a17f Tweak frontend and game logic 2023-03-26 00:37:58 +01:00
3ac07f3072 Optimize views 2023-03-25 18:59:35 +01:00
ed4d61b37b Frontend 2023-03-25 18:45:31 +01:00
1b745c756f Integrated WS server 2023-03-25 17:23:00 +01:00
8971c64713 Update readme 2023-03-25 16:33:49 +01:00
f8506a66ba Update readme 2023-03-25 16:30:17 +01:00
894d2b0707 Player move delay 2023-03-25 16:24:54 +01:00
ee4d841cae Readme update 2023-03-25 16:18:02 +01:00
8bc8a37edd WS game-state URI 2023-03-25 16:17:24 +01:00
245dc75211 Update readme 2023-03-25 16:07:53 +01:00
395457b2db Configurable log level 2023-03-25 16:03:04 +01:00
37 changed files with 1470 additions and 392 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

31
.docker/settings.py Normal file
View File

@ -0,0 +1,31 @@
import os
import logging
from hopper.models.config import (
BoardSettings,
DebugSettings,
GameSettings,
InactivityWatchdogSettings,
Settings,
WSServerSettings,
)
settings = Settings(
game=GameSettings(),
board=BoardSettings(
WIDTH=20,
HEIGHT=20,
OBSTACLE_COUNT=0,
),
inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO,
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

3
.gitmodules vendored Normal file
View File

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

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,5 +1,15 @@
IMAGE_NAME=fairhopper-service
CONTAINER_NAME=fairhopper-service
INTERNAL_API_PORT=8010
INTERNAL_WS_PORT=8011
EXTERNAL_API_PORT=8010
EXTERNAL_WS_PORT=8011
timestamp := `/bin/date "+%Y-%m-%d-%H-%M-%S"`
run: run:
@poetry run \ @ \
poetry run \
uvicorn \ uvicorn \
main:app \ main:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
@ -7,7 +17,8 @@ run:
--workers=1 --workers=1
run-dev: run-dev:
@poetry run \ @ \
poetry run \
uvicorn \ uvicorn \
main:app \ main:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
@ -15,6 +26,39 @@ run-dev:
--workers=1 \ --workers=1 \
--reload --reload
run-ws: create-requirements:
@poetry run \ @ \
python ws_server.py 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):$(timestamp) \
.
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

367
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
@ -21,12 +30,90 @@
- Move timeout: 10s. Game is finished if timeout ocurrs. - Move timeout: 10s. Game is finished if timeout ocurrs.
## Game States
```plantuml
scale 1024 width
hide empty description
state "Start Game" as StartGame
state "Move" as MovePlayer: Destination reached?
state "Destination Reached" as DestinationReached
state "Product Selection" as ProductSelection: Enable product selection for winning player
state "Product Selected" as ProductSelected
state "Selection Timeout" as SelectionTimeout
state "End Player's Game" as EndPlayer
state "Lock Game" as LockGame <<end>>
state "Unlock game and restart" as UnlockGame <<end>>
[*] -> StartGame
StartGame -> MovePlayer
MovePlayer <-- MovePlayer: NO
MovePlayer --> DestinationReached: YES
DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> UnlockGame: Unlock game\nand restart
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\nand restart
```
## 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.
@ -40,20 +127,20 @@ 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
``` ```
By default, JFK runs on port **8010**. To run on other port, start `uvicorn` directly: By default, FairHopper runs on port **8010**. To run FairHopper on different port, start `uvicorn` directly:
```sh ```sh
poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1 poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1
``` ```
@ -63,62 +150,116 @@ 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.
## System overview ## System overview
### Architecture ### Architecture
```plantuml ```plantuml
scale 1024 width
actor "Player 1" as P1 actor "Player 1" as P1
actor "Player 2" as P2 actor "Player 2" as P2
actor "Player 3" as P3 actor "Player 3" as P3
package Masterpiece { package Masterpiece #seashell {
usecase JFK as "JFK Game Server" rectangle "FairHopper Game Server" #lightcyan {
usecase API as "API Server"
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"
} }
P1 -left-> JFK: REST API usecase ExtVis1 as "Visualisation\nClient"
P2 -left-> JFK: REST API usecase ExtVis2 as "Visualisation\nClient"
P3 -left-> JFK: REST API
JFK --> WS: WebSockets P1 -left-> API: REST API
WS --> Vis: WebSockets P2 -left-> API: REST API
P3 -left-> API: REST API
API --> Game
Game --> WS: Game State
WS --> Vis: WS Game State
WS --> ExtVis1: WS Game State
WS --> ExtVis2: WS Game State
``` ```
### WebSockets ### WebSockets
```plantuml ```plantuml
participant JFK as "JFK Game Server" scale 1024 width
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1" endbox
participant Client2 as "Visualisation\nClient 2" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
JFK ->o WS: Server Connect == Player movement mode ==
activate WS #coral
WS -> JFK: Get game state
activate JFK #yellow
JFK -> WS: Game state
deactivate
deactivate
Client1 ->o WS: Client Connect Game ->o WS: Send initial state
activate WS #coral
Client1 ->o WS: Client connect
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
JFK ->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
== Player reached destination ==
Game -> Game: Lock game for other players
activate Game #skyblue
Game -> WS: Player reached destination
activate WS #coral
WS o-> Client1: Select product
WS o-> Client2: Select product
deactivate WS
deactivate Game
loop #lightyellow Product select countdown timer (60s)
Game ->o WS: Timer timeout
activate Game #skyblue
activate WS #coral
WS o-> Client1: Selection timeout
WS o-> Client2: Selection timeout
deactivate WS
Game -> Game: Unlock game
deactivate Game
end
Client1 -> Client1: Product selection
activate Client1 #greenyellow
Client1 -> Client1: Dispense product
Client1 ->o WS: Product selected
deactivate Client1
activate WS #coral
WS o-> Game: Product selected
activate Game #skyblue
WS o-> Client2: Product selected
deactivate WS
Game -> Game: Unlock game
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
deactivate WS
deactivate Game
``` ```
@ -129,7 +270,7 @@ end
- Move right - Move right
- Move up - Move up
- Move down - Move down
- Get current position - Get player info
- Get board info - Get board info
Check REST API interface on [FastAPI docs](http://localhost:8010/docs). Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
@ -156,10 +297,11 @@ Response body:
"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",
"position": { "position": {
"x": 0, "x": 0,
"y": 10 "y": 10
@ -172,25 +314,27 @@ 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
- 423 Locked: Game locked, product selection in progress
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
@ -203,7 +347,7 @@ Response body:
### Get Player Info ### Get Player Info
GET `/player/{{uuid}}` GET `/player/{{id}}`
Request body: None Request body: None
@ -211,7 +355,8 @@ Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
@ -250,45 +395,57 @@ Response body:
### WS Data format ### WS Data format
- json - json
General data format:
```json ```json
{ {
"command": "command", "message": message_type,
"data": {} "data": ...
} }
``` ```
### Game info structure
Command: `gameInfo` ### Game state structure
Direction: Game server -> Clients
Message: `game_dump`
Data: Data:
```json ```json
{ {
"board": { "board": {
"width": 101, "width": 10,
"height": 101 "height": 10
}, },
"destinationPosition": { "destination": {
"x": 50, "position": {
"y": 50 "x": 5,
"y": 5
}
}, },
"players": [ "players": [
{ {
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "id": "test-player-pero",
"name": "Pero", "name": "Pero",
"active": true,
"position": { "position": {
"x": 0, "x": 3,
"y": 10 "y": 3
} },
"move_count": 0,
"move_attempt_count": 0,
"state": "CREATED"
}, },
{ {
"id": "04793b36-0785-4bf3-9396-3585c358cbac", "id": "test-player-mirko",
"name": "Mirko", "name": "Mirko",
"active": true,
"position": { "position": {
"x": 11, "x": 4,
"y": 12 "y": 4
} },
"move_count": 0,
"move_attempt_count": 0,
"state": "CREATED"
} }
], ],
"layers": [ "layers": [
@ -296,17 +453,55 @@ Data:
"name": "obstacles", "name": "obstacles",
"objects": [ "objects": [
{ {
"type": "obstacle", "type": "OBSTACLE",
"position": { "position": {
"x": 15, "x": 0,
"y": 25 "y": 6
} }
}, },
{ {
"type": "obstacle", "type": "OBSTACLE",
"position": { "position": {
"x": 33, "x": 5,
"y": 44 "y": 1
}
},
{
"type": "OBSTACLE",
"position": {
"x": 1,
"y": 6
}
}
]
},
{
"name": "destination",
"objects": [
{
"type": "DESTINATION",
"position": {
"x": 5,
"y": 5
}
}
]
},
{
"name": "players",
"objects": [
{
"type": "PLAYER",
"position": {
"x": 3,
"y": 3
}
},
{
"type": "PLAYER",
"position": {
"x": 4,
"y": 4
} }
} }
] ]
@ -314,3 +509,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`

41
api_tests/requests.http Normal file
View File

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

1
fairhopper-sdk Submodule

Submodule fairhopper-sdk added at ed8f93d7d0

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

61
frontend/index.html Normal file
View File

@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css">
<script src="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>
<title>FairHopper Visualisation Client</title>
</head>
<body>
<main class="container-fluid main-container">
<h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<div class="row">
<div class="col-10">
<div class="board-container">
<div id="board-content"></div>
</div>
</div>
<div class="col-2">
<h3 class="pb-2 border-bottom">
Players
</h3>
<ul class="players" id="players-content"></ul>
</div>
</div>
</main>
<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>
</html>

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

@ -0,0 +1,178 @@
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 = {
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) {
createBoard(data.board);
renderObstacles(data.layers);
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
function playerReachedDestination(data) {
const dlgElement = document.getElementById("player-on-destination-modal");
dlgElement.querySelector(".player-name").textContent = data.player.name;
dlgElement.querySelector(".move-count").textContent = data.player.move_count;
playerOnDestinationModal.show();
}
function productSelectionTimeout() {
playerOnDestinationModal.hide();
}
function productSelectionDone() {
playerOnDestinationModal.hide();
}
function wsConnect() {
console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
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 "player_reached_destination":
playerReachedDestination(wsMessage.data);
break;
case "product_selection_timeout":
productSelectionTimeout();
break;
case "product_selection_done":
productSelectionDone();
break;
default:
console.error("Unknown message:", wsMessage);
}
};
ws.onclose = (e) => {
ws = null;
setTimeout(() => {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
function finishProductSelection() {
if (!ws) {
return;
}
const wsMessage = {
message: "product_selection_done",
data: null,
};
ws.send(JSON.stringify(wsMessage));
}
window.onload = () => {
const dlgElement = document.getElementById("player-on-destination-modal");
playerOnDestinationModal = new bootstrap.Modal(dlgElement);
document.getElementById("finish-product-selection").onclick = () => {
finishProductSelection();
};
wsConnect();
};

58
frontend/styles.css Normal file
View File

@ -0,0 +1,58 @@
body {
background-color: whitesmoke;
}
main.main-container {
position: relative;
}
.board-container {
background-color: white;
border: 1px solid black;
}
.flex-grid {
display: flex;
justify-content: space-between;
grid-gap: 2px;
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0;
}
.cell {
flex: 1;
aspect-ratio: 1;
background-color: beige;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
ul.players {
list-style-type: none;
padding-left: 0;
}
.player-tooltip {
position: absolute;
margin-bottom: 50px;
font-size: 8pt;
padding: 2px 10px;
color: white;
background-color: darkred;
border-radius: 5px;
}
.player-tooltip::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: darkred transparent transparent transparent;
}

View File

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

View File

@ -2,6 +2,8 @@ from __future__ import annotations
from pydantic import BaseModel as PydanticBaseModel from pydantic import BaseModel as PydanticBaseModel
from hopper.enums import PlayerState
class BaseModel(PydanticBaseModel): class BaseModel(PydanticBaseModel):
class Config: class Config:
@ -23,11 +25,13 @@ class PositionDto(BaseModel):
class PlayerDto(BaseModel): class PlayerDto(BaseModel):
uuid: str id: str
name: str
active: bool active: bool
position: PositionDto position: PositionDto
move_count: int move_count: int
move_attempt_count: int move_attempt_count: int
state: PlayerState
class DestinationDto(BaseModel): class DestinationDto(BaseModel):

View File

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

View File

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

31
hopper/countdown_timer.py Normal file
View File

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

View File

@ -1,33 +1,63 @@
import asyncio
import logging import logging
import random
from typing import Optional
from hopper.enums import Direction, PlayerMoveResult from hopper.countdown_timer import CountdownTimer
from hopper.errors import Collision, PositionOutOfBounds from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout,
Destination, Destination,
GameBoard, GameBoard,
Layer, Layer,
LayerObject, LayerObject,
ObjectType, ObjectType,
create_random_position, BoardLayout, create_random_position,
) )
from hopper.models.player import Player, PlayerList, Position from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog from hopper.watchdog import InactivityWatchdog
from hopper.ws_client import ws_send_game_state 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__(self, board: GameBoard) -> None: def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self.board = board self.board = board
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.game_state = GameState.RUNNING
self.__debug_print_board() 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()
for player in self.players: for player in self.players:
show_player = (
player.active
and player.position.y < len(dump)
and player.position.x < len(dump[player.position.y])
)
if show_player:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[ dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER ObjectType.PLAYER
] ]
@ -43,33 +73,39 @@ class GameEngine:
def _start_inactivity_watchdog(self) -> None: def _start_inactivity_watchdog(self) -> None:
if not self._inacivity_watchdog: if not self._inacivity_watchdog:
self._inacivity_watchdog = InactivityWatchdog( self._inacivity_watchdog = InactivityWatchdog(
players=self.players, daemon=True players=self.players,
ws_server=self.ws_server,
) )
self._inacivity_watchdog.start() self._inacivity_watchdog.start()
async def start_game(self, player_name: str) -> Player: async def send_game_dump(self):
self.__debug_print_board()
await self.ws_server.send_game_dump()
async def reset_game(self) -> None:
self.__debug_print_board()
self.game_state = GameState.RUNNING
self.players.clear()
await self.send_game_dump()
async def start_game_for_player(self, player_name: str) -> Player:
self._start_inactivity_watchdog() self._start_inactivity_watchdog()
player = Player( player = Player(
name=player_name, name=player_name,
position=Position(0, 0), position=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED,
) )
self.players.append(player) self.players.append(player)
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()
await ws_send_game_state() await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
async def move_player(self, player: Player, direction: Direction) -> PlayerMoveResult: def _move_position(self, position: Position, direction: Direction) -> Position:
player.reset_timeout() new_position = Position(position.x, position.y)
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
player.move_attempt_count += 1
if direction == Direction.LEFT: if direction == Direction.LEFT:
new_position.x -= 1 new_position.x -= 1
elif direction == Direction.RIGHT: elif direction == Direction.RIGHT:
@ -80,50 +116,120 @@ class GameEngine:
new_position.y += 1 new_position.y += 1
else: else:
raise ValueError(f"Unhandled direction: {direction}") raise ValueError(f"Unhandled direction: {direction}")
return new_position
if not self.position_in_board_bounds(new_position): async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
if self.game_state == GameState.LOCK_FOR_MOVEMENT:
raise GameLockForMovement("Player reached destination. Can't move anymore.")
# player will not be able to move once they reach the destination
if player.state == PlayerState.ON_DESTINATION:
return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1
player.state = PlayerState.MOVING
if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds() raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position): if self._colided_with_obstacle(new_position):
raise Collision() raise Collision()
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
await ws_send_game_state() if self._is_player_on_destination(player):
player.state = PlayerState.ON_DESTINATION
if self.is_player_on_destination(player): await self._player_on_destination(player)
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board() 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:
return player.position == self.board.destination.position return player.position == self.board.destination.position
def position_in_board_bounds(self, position: Position) -> bool: def _position_in_board_bounds(self, position: Position) -> bool:
return ( return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height 0 <= position.x < self.board.width and 0 <= position.y < self.board.height
) )
def colided_with_obstacle(self, position: Position) -> bool: def _colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None return self.board.get_object_at_position(position) is not None
async def _player_on_destination(self, player: Player) -> None:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.send_game_dump()
await self.ws_server.send_player_reached_destination_message(player=player)
logging.info(
f"Starting product selection countdown timer for {settings.purchase_timeout} seconds"
)
def on_purchase_timer_tick(time_left) -> None:
logging.info(
f"Product selection countdown timer tick, time left: {time_left}"
)
def on_purchase_timer_done() -> None:
logging.info("Ding ding! Product selection countdown timer timeout")
self._purchase_countdown_timer = None
asyncio.run(self.ws_server.send_product_selection_timeout_message())
self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump())
self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout,
timer_tick_callback=on_purchase_timer_tick,
timer_done_callback=on_purchase_timer_done,
)
self._purchase_countdown_timer.start()
await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
async def product_selection_done(self) -> None:
logging.info("Product selection done, unlocking game")
if self._purchase_countdown_timer:
self._purchase_countdown_timer.stop()
await self.ws_server.send_product_selection_done_message()
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)
class GameEngineFactory: class GameEngineFactory:
@staticmethod @staticmethod
def create( def create(
board_width: int, board_width: int,
board_height: int, board_height: int,
obstacle_count: int = 0, obstacle_count: int = 0,
ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
height=board_height, height=board_height,
destination=Destination(Position(board_height // 2, board_height // 2)), destination=Destination(Position(board_width // 2, board_height // 2)),
) )
obstacle_layer = Layer(name="obstacles") obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count): for _ in range(obstacle_count):
@ -135,28 +241,29 @@ class GameEngineFactory:
) )
board.layers.append(obstacle_layer) board.layers.append(obstacle_layer)
game = GameEngine(board=board) game = GameEngine(
GameEngineFactory.__add_test_player(game.players) board=board,
ws_server=ws_server,
)
GameEngineFactory.__add_test_players(game.players)
return game return game
@staticmethod @staticmethod
def create_default() -> GameEngine: def create_default(
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT, board_height=settings.board.HEIGHT,
obstacle_count=settings.board.OBSTACLE_COUNT, obstacle_count=settings.board.OBSTACLE_COUNT,
ws_server=ws_server,
) )
@staticmethod @staticmethod
def __add_test_player(players: PlayerList) -> None: def __add_test_players(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER): if not settings.debug:
return return
player = Player( for player in settings.debug.PLAYERS:
name="Pero",
uuid="test-player-id",
position=Position(2, 2),
can_be_deactivated=False,
)
players.append(player) players.append(player)
logging.info(f"Test player created: {player}") logging.info(f"Test player created: {player}")

View File

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

View File

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

View File

@ -14,6 +14,13 @@ BOARD_DUMP_CHARS: dict[ObjectType, str] = {
} }
def create_random_position(board_width: int, board_height: int) -> Position:
return Position(
x=random.randint(0, board_width - 1),
y=random.randint(0, board_height - 1),
)
@dataclass @dataclass
class LayerObject: class LayerObject:
type_: ObjectType type_: ObjectType
@ -102,10 +109,3 @@ class BoardLayout:
) )
) )
return layers return layers
def create_random_position(board_width: int, board_height: int) -> Position:
return Position(
x=random.randint(0, board_width - 1),
y=random.randint(0, board_height - 1),
)

View File

@ -1,5 +1,14 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import List, Optional
from hopper.models.player import Player
@dataclass
class GameSettings:
MOVE_DELAY: float = 0.5 # seconds
PURCHASE_START_DELAY: float = 2 # seconds
@dataclass @dataclass
@ -18,19 +27,22 @@ class InactivityWatchdogSettings:
@dataclass @dataclass
class WSServerSettings: class WSServerSettings:
HOST: str = "localhost" HOST: str = "127.0.0.1"
PORT: int = 8011 PORT: int = 8011
@dataclass @dataclass
class DebugSettings: class DebugSettings:
PRINT_BOARD: bool = False PRINT_BOARD: bool = False
CREATE_TEST_PLAYER: bool = False PLAYERS: Optional[List[Player]] = None
@dataclass @dataclass
class Settings: class Settings:
game: GameSettings
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

View File

@ -3,6 +3,8 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from hopper.enums import PlayerState
@dataclass @dataclass
class Position: class Position:
@ -13,13 +15,14 @@ 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
last_seen: datetime.datetime = field( last_seen: datetime.datetime = field(
default_factory=lambda: datetime.datetime.now() default_factory=lambda: datetime.datetime.now()
) )
state: PlayerState = PlayerState.CREATED
active: bool = True active: bool = True
can_be_deactivated: bool = True can_be_deactivated: bool = True
@ -28,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,6 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json
from typing import Optional, TypeVar
from pydantic import Field from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -16,8 +20,49 @@ class LayerDto(BaseModel):
objects: list[LayerObjectDto] objects: list[LayerObjectDto]
class GameStateDto(BaseModel): class GameDumpDto(BaseModel):
board: BoardDto board: BoardDto
destination: DestinationDto destination: DestinationDto
players: list[PlayerDto] players: list[PlayerDto]
layers: list[LayerDto] layers: list[LayerDto]
class PlayerReachedDestinationDto(BaseModel):
player: PlayerDto
TMessageData = TypeVar("TMessageData", bound=BaseModel)
class WSMessage(GenericModel):
message: str
data: Optional[TMessageData] = None
def __str__(self) -> str:
return self.to_str()
def to_str(self) -> str:
return json.dumps(self.dict())
@classmethod
@property
def message_type(cls) -> str:
return cls.__fields__["message"].default
class WSGameDumpMessage(WSMessage):
message: str = "game_dump"
data: GameDumpDto
class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_selection_done"
class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_selection_timeout"
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -2,23 +2,25 @@ import asyncio
import datetime import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread, Event
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_client import ws_send_game_state from hopper.ws_server import WSServer
from settings import settings from settings import settings
class InactivityWatchdog(Thread): class InactivityWatchdog(Thread):
def __init__(self, players: PlayerList, *args, **kwargs) -> None: def __init__(self, players: PlayerList, ws_server: WSServer = None) -> None:
self.players = players self.players = players
self.stopped = False self.ws_server = ws_server
super().__init__(*args, **kwargs) self.stop_event = Event()
super().__init__(daemon=True)
def run(self) -> None: def run(self) -> None:
logging.info("Starting inactivity watchdog") logging.info("Starting inactivity watchdog")
while not self.stopped: while not self.stop_event.is_set():
self.cleanup_players() self.cleanup_players()
if not self.stop_event.is_set():
time.sleep(settings.inacivity_watchdog.TICK_INTERVAL) time.sleep(settings.inacivity_watchdog.TICK_INTERVAL)
def cleanup_players(self) -> None: def cleanup_players(self) -> None:
@ -30,7 +32,7 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT seconds=settings.inacivity_watchdog.KICK_TIMEOUT
) )
send_game_state = False send_game_dump = False
for player in self.players: for player in self.players:
if ( if (
@ -40,7 +42,7 @@ class InactivityWatchdog(Thread):
): ):
player.active = False player.active = False
logging.info(f"Player {player} set as inactive") logging.info(f"Player {player} set as inactive")
send_game_state = True send_game_dump = True
# safe remove from list # safe remove from list
n = 0 n = 0
@ -49,16 +51,16 @@ class InactivityWatchdog(Thread):
if player.can_be_deactivated and player.last_seen < kick_threshold: if player.can_be_deactivated and player.last_seen < kick_threshold:
self.players.pop(n) self.players.pop(n)
logging.info(f"Player {player} kicked out") logging.info(f"Player {player} kicked out")
send_game_state = True send_game_dump = True
else: else:
n += 1 n += 1
if send_game_state: if send_game_dump:
self.send_game_state() self.send_game_dump()
def send_game_state(self): def send_game_dump(self):
logging.info("Sending WS game state") logging.info("Sending WS game dump")
asyncio.run(ws_send_game_state()) asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stop_event.set()

View File

@ -1,34 +0,0 @@
import json
import logging
from contextlib import asynccontextmanager
import websockets
from hopper.models.ws_dto import GameStateDto
from settings import settings
@asynccontextmanager
async def create_ws_client() -> websockets.WebSocketServerProtocol:
ws_uri = f"ws://{settings.ws_server.HOST}:{settings.ws_server.PORT}"
async with websockets.connect(uri=ws_uri) as websocket:
yield websocket
async def ws_send_game_state() -> None:
# avoid circular imports
from hopper.api.dependencies import get_game_engine
try:
async with create_ws_client() as websocket:
engine = get_game_engine()
game_state = GameStateDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
await websocket.send(json.dumps(game_state.dict()))
except OSError as ex:
logging.error(f"Error sending WS state: {ex}")

150
hopper/ws_server.py Normal file
View File

@ -0,0 +1,150 @@
import asyncio
import json
import logging
from threading import Thread
import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from hopper.models.player import Player
from hopper.models.ws_dto import (
GameDumpDto,
PlayerReachedDestinationDto,
WSGameDumpMessage,
WSMessage,
WSPlayerReachedDestinationMessage,
WSProductSelectionDoneMessage,
WSProductSelectionTimeoutMessage,
)
class WSServer(Thread):
def __init__(self, host: str, port: int) -> None:
self.host = host
self.port = port
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:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
# send initial game dump to connected client
await self.send_game_dump_to_client(websocket)
# loop and do nothing while client is connected
connected = True
while connected:
try:
# we're expecting nothing from client, but read if client sends a message
rcv_data = await websocket.recv()
await self.handle_rcv_message(
client=websocket, raw_message=rcv_data
)
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
finally:
self.connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}")
async def send_message_to_client(
self, client: WebSocketServerProtocol, message: WSMessage
) -> None:
message_str = message.to_str()
logging.debug(
f"Sending message {message.message} to clients: {self.connected_clients}: {message_str}"
)
await client.send(message_str)
async def send_message_to_clients(self, message: WSMessage) -> None:
for client in self.connected_clients:
await self.send_message_to_client(client, message)
def _create_game_dump_message(self) -> WSGameDumpMessage:
# avoid circular imports
from hopper.api.dependencies import get_game_engine
engine = get_game_engine()
game_dump = GameDumpDto(
board=engine.board,
destination=engine.board.destination,
players=engine.players,
layers=engine.get_board_layout().layers,
)
return WSGameDumpMessage(data=game_dump)
async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol
) -> None:
"""Send game dump to the client"""
message = self._create_game_dump_message()
logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message.to_str())
async def send_game_dump(self) -> None:
"""Broadcast game state to all connected clients"""
message = self._create_game_dump_message()
await self.send_message_to_clients(message)
async def send_player_reached_destination_message(self, player: Player) -> None:
message = WSPlayerReachedDestinationMessage(
data=PlayerReachedDestinationDto(
player=player,
)
)
await self.send_message_to_clients(message)
async def send_product_selection_done_message(self) -> None:
message = WSProductSelectionDoneMessage()
await self.send_message_to_clients(message)
async def send_product_selection_timeout_message(self) -> None:
message = WSProductSelectionTimeoutMessage()
await self.send_message_to_clients(message)
async def run_async(self) -> None:
logging.info(
f"Starting FairHopper Websockets Server on {self.host}:{self.port}"
)
async with websockets.serve(
ws_handler=self.handler,
host=self.host,
port=self.port,
):
await asyncio.Future() # run forever
def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async())

View File

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

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

@ -1,13 +1,41 @@
import logging
from hopper.models.config import ( from hopper.models.config import (
BoardSettings, BoardSettings,
DebugSettings,
GameSettings,
InactivityWatchdogSettings, InactivityWatchdogSettings,
Settings, Settings,
WSServerSettings, WSServerSettings,
) )
from hopper.models.player import Player, Position
settings = Settings( settings = Settings(
board=BoardSettings(), game=GameSettings(),
board=BoardSettings(
WIDTH=20,
HEIGHT=20,
OBSTACLE_COUNT=10,
),
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5,
log_level=logging.INFO,
ws_server=WSServerSettings(), ws_server=WSServerSettings(),
debug=None, debug=DebugSettings(
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,
),
],
),
) )

View File

@ -1,54 +0,0 @@
import asyncio
import logging
import websockets
from websockets import WebSocketServerProtocol, broadcast
from settings import settings
connected_clients = set[WebSocketServerProtocol]()
def setup_logging() -> None:
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s %(levelname)s - %(message)s",
)
async def ws_handler(websocket: WebSocketServerProtocol):
connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}")
try:
async for message in websocket:
logging.debug(f"Received message: {message}")
broadcast_clients = [client for client in connected_clients if client.id != websocket.id]
if broadcast_clients:
logging.debug(f"Broadcast message to clients: {broadcast_clients}")
broadcast(connected_clients, message)
finally:
connected_clients.remove(websocket)
logging.info(f"Remove client: {websocket.id}")
async def main():
setup_logging()
logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}"
)
async with websockets.serve(
ws_handler=ws_handler,
host=settings.ws_server.HOST,
port=settings.ws_server.PORT,
):
await asyncio.Future() # run forever
if __name__ == "__main__":
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
logging.info(f"FairHopper Websockets Server terminated")