48 Commits

Author SHA1 Message Date
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
34 changed files with 1473 additions and 466 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

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

628
README.md
View File

@ -1,5 +1,14 @@
# FairHopper # FairHopper
## Useful links
- [Frontend](https://fairhopper.mjerenja.com)
- [API](https://api.fairhopper.mjerenja.com)
- [API Docs](https://api.fairhopper.mjerenja.com/docs)
- [FairHopper](https://gitea.ekirin.com/Intis/fairhopper)
- [FairHopper SDK](https://gitea.ekirin.com/Intis/fairhopper-sdk)
- Websockets: wss://fairhopper.mjerenja.com/ws
## Game ## Game
### Overview ### Overview
@ -8,10 +17,10 @@
- Destination: center of a board (W / 2, H / 2) - Destination: center of a board (W / 2, H / 2)
- Initial player position: Random on board border - Initial player position: Random on board border
- Available moves: - Available moves:
- left - left
- right - right
- up - up
- down - down
- Optional on-board obstacles - Optional on-board obstacles
### Rules ### Rules
@ -21,12 +30,91 @@
- 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 "End Game" as EndGame <<end>>
state "Unlock game" as UnlockGame <<end>>
[*] -> StartGame
StartGame -> MovePlayer
MovePlayer <-- MovePlayer: NO
MovePlayer --> DestinationReached: YES
DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players
```
## FairHopper Game Server ## FairHopper Game Server
### 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,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
@ -63,7 +151,7 @@ To activate virtual environment:
poetry shell poetry shell
``` ```
WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration. WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration.
## System overview ## System overview
@ -71,56 +159,86 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi
### Architecture ### Architecture
```plantuml ```plantuml
scale 1024 width
actor "Player 1" as P1 actor "Player 1" as P1
actor "Player 2" as P2 actor "Player 2" as P2
actor "Player 3" as P3 actor "Player 3" as P3
package Masterpiece { package Masterpiece #seashell {
usecase Game as "FairHopper\nGame Server" rectangle "FairHopper Game Server" #lightcyan {
usecase WS as "WS Server" usecase API as "API Server"
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService" usecase Vis as "Visualisation\nService"
} }
P1 -left-> Game: REST API usecase ExtVis1 as "Visualisation\nService"
P2 -left-> Game: REST API usecase ExtVis2 as "Visualisation\nService"
P3 -left-> Game: REST API
Game --> WS: WebSockets P1 -left-> API: REST API
WS --> Vis: WebSockets P2 -left-> API: REST API
P3 -left-> API: REST API
API --> Game
Game --> WS: Game State
WS --> Vis: WS Game State
WS --> ExtVis1: WS Game State
WS --> ExtVis2: WS Game State
``` ```
### WebSockets ### WebSockets
```plantuml ```plantuml
participant Game as "FairHopper\nGame Server" scale 1024 width
box "FairHopper Game Server" #lightcyan
participant Game as "Game Engine"
participant WS as "WS Server" participant WS as "WS Server"
participant Client1 as "Visualisation\nClient 1" endbox
participant Client2 as "Visualisation\nClient 2" participant Client1 as "Visualisation\nClient 1"
participant Client2 as "Visualisation\nClient 2"
Game ->o WS: Server Connect == Player movement mode ==
activate WS #coral
WS -> Game: Get game state
activate Game #yellow
Game -> WS: Game state
deactivate
deactivate
Client1 ->o WS: Client Connect Game ->o WS: Send initial state
activate WS #coral
WS -> Client1: Game state
deactivate
Client2 ->o WS: Client Connect Client1 ->o WS: Client connect
activate WS #coral activate WS #coral
WS -> Client2: Game state WS -> Client1: Game state
deactivate deactivate
loop #lightyellow On game state change Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
loop #lightyellow On game state change
Game ->o WS: Game state Game ->o WS: Game state
activate WS #coral activate WS #coral
WS o-> Client1: Game state WS o-> Client1: Game state
WS o-> Client2: Game state WS o-> Client2: Game state
deactivate deactivate
end end
== Product purchase mode ==
Game -> WS: Purchase start
activate WS #coral
WS o-> Client1: Purchase start
WS o-> Client2: Purchase start
deactivate
loop #lightyellow Purchase countdown timer
Game ->o WS: Timer count down
activate WS #coral
WS o-> Client1: Purchase time left
WS o-> Client2: Purchase time left
deactivate
end
Game -> WS: Purchase done
activate WS #coral
WS o-> Client1: Purchase done
WS o-> Client2: Purchase done
deactivate
``` ```
@ -131,7 +249,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).
@ -143,87 +261,87 @@ Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
Request body: Request body:
```json ```json
{ {
"player_name": "Pero" "player_name": "Pero"
} }
``` ```
Response body: Response body:
```json ```json
{ {
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destination": { "destination": {
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
} }
}, },
"player": { "player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 0, "x": 0,
"y": 10 "y": 10
}, },
"move_count": 0, "move_count": 0,
"move_attempt_count": 0 "move_attempt_count": 0
} }
} }
``` ```
### Player Move ### Player Move
- POST `/player/{uuid}/move/left` - POST `/player/{id}/move/left`
- POST `/player/{uuid}/move/right` - POST `/player/{id}/move/right`
- POST `/player/{uuid}/move/up` - POST `/player/{id}/move/up`
- POST `/player/{uuid}/move/down` - POST `/player/{id}/move/down`
Request body: None Request body: None
Response code: Response code:
- 200 OK: Destination reached - 200 OK: Destination reached
- 201 Created: Player moved successfully - 201 Created: Player moved successfully
- 403 Forbidden: Player uuid not valid, probably timeout - 403 Forbidden: Player id not valid, probably timeout
- 409 Conflict: Invalid move, obstacle or position out of board - 409 Conflict: Invalid move, obstacle or position out of board
- 422 Unprocessable Content: Validation error - 422 Unprocessable Content: Validation error
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
### Get Player Info ### Get Player Info
GET `/player/{{uuid}}` GET `/player/{{id}}`
Request body: None Request body: None
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"uuid": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
@ -234,19 +352,19 @@ GET `/game`
Response body: Response body:
```json ```json
{ {
"playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destinationPosition": { "destinationPosition": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"playerPosition": { "playerPosition": {
"x": 0, "x": 0,
"y": 10 "y": 10
} }
} }
``` ```
@ -255,120 +373,222 @@ Response body:
### WS Data format ### WS Data format
- json - json
```json
{
"message": message_type,
"data": ...
}
```
### Game state structure ### Game state structure
URI: `/game-state` Message: `game_dump`
Data: Data:
```json ```json
{ {
"board": { "board": {
"width": 21, "width": 10,
"height": 21 "height": 10
}, },
"destination": { "destination": {
"position": { "position": {
"x": 10, "x": 5,
"y": 10 "y": 5
} }
}, },
"players": [ "players": [
{ {
"uuid": "test-player-id", "id": "test-player-pero",
"name": "Pero", "name": "Pero",
"active": true, "active": true,
"position": { "position": {
"x": 2, "x": 3,
"y": 2 "y": 3
}, },
"move_count": 3, "move_count": 0,
"move_attempt_count": 3 "move_attempt_count": 0,
}, "state": "CREATED"
{ },
"uuid": "95962b49-0003-4bf2-b205-71f2590f2318", {
"name": "Mirko", "id": "test-player-mirko",
"active": true, "name": "Mirko",
"position": { "active": true,
"x": 0, "position": {
"y": 0 "x": 4,
}, "y": 4
"move_count": 15, },
"move_attempt_count": 20 "move_count": 0,
} "move_attempt_count": 0,
], "state": "CREATED"
"layers": [ }
{ ],
"name": "obstacles", "layers": [
"objects": [ {
{ "name": "obstacles",
"type": "OBSTACLE", "objects": [
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 2 "position": {
} "x": 0,
}, "y": 6
{ }
"type": "OBSTACLE", },
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 13 "position": {
} "x": 5,
}, "y": 1
{ }
"type": "OBSTACLE", },
"position": { {
"x": 18, "type": "OBSTACLE",
"y": 18 "position": {
} "x": 1,
}, "y": 6
{ }
"type": "OBSTACLE", }
"position": { ]
"x": 5, },
"y": 4 {
} "name": "destination",
}, "objects": [
{ {
"type": "OBSTACLE", "type": "DESTINATION",
"position": { "position": {
"x": 7, "x": 5,
"y": 10 "y": 5
} }
} }
] ]
}, },
{ {
"name": "destination", "name": "players",
"objects": [ "objects": [
{ {
"type": "DESTINATION", "type": "PLAYER",
"position": { "position": {
"x": 10, "x": 3,
"y": 10 "y": 3
} }
} },
] {
}, "type": "PLAYER",
{ "position": {
"name": "players", "x": 4,
"objects": [ "y": 4
{ }
"type": "PLAYER", }
"position": { ]
"x": 2, }
"y": 2 ]
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
}
}
]
}
]
} }
``` ```
### Product purchase start
Message: `product_purchase_start`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"products": [
{
"name": "CocaCola",
"id": "cocacola-id",
"description": null
},
{
"name": "Pepsi",
"id": "pepsi-id",
"description": null
},
{
"name": "Fanta",
"id": "fanta-id",
"description": null
},
{
"name": "Snickers",
"id": "snickers-id",
"description": null
},
{
"name": "Mars",
"id": "mars-id",
"description": null
},
{
"name": "Burek",
"id": "burek-id",
"description": null
}
],
"timeout": 5
}
```
### Product purchase timer tick
Message: `product_purchase_timer_tick`
Data:
```json
{
"time_left": 4,
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
}
}
```
### Product purchase timer done
Message: `product_purchase_done`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"active": true,
"position": {
"x": 10,
"y": 10
},
"move_count": 1,
"move_attempt_count": 1,
"state": "ON_DESTINATION"
},
"product": {
"name": "CocaCola",
"id": "cocacola-id",
"description": null
}
}
```
If product selection timeout occured, product will be null.

48
api_tests/requests.http Normal file
View File

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

1
fairhopper-sdk Submodule

Submodule fairhopper-sdk added at fd71fa276c

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -6,15 +6,32 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<title>Document</title> <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> </head>
<body> <body>
<div class="container-fluid"> <main class="container-fluid main-container">
<h1>FairHopper WS Client</h1> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
<button type="button" class="btn btn-primary test-button">
Launch demo modal
</button>
<div id="purchase-container" class="purchase-container d-none">
<div class="d-flex header">
<h3>
Product selection
</h3>
<h3 id="purchase-countdown" class="countdown"></h3>
</div>
<div id="products-content" class="products-content"></div>
</div>
<div class="row"> <div class="row">
<div class="col-10"> <div class="col-10">
<div class="board-container"> <div class="board-container">
@ -22,99 +39,45 @@
</div> </div>
</div> </div>
<div class="col-2"> <div class="col-2">
<h3>Players</h3> <h3 class="pb-2 border-bottom">
<ul id="players-content"></ul> Players
</h3>
<ul class="players" id="players-content"></ul>
</div>
</div>
</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">
Finish product selection
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<script>
document.querySelector(".test-button").onclick = () => {
playerReachedDestination({
player: {
name: "Pero Perić",
move_count: 123,
}
});
}
</script>
</body> </body>
<script>
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 renderCellContent(x, y, content) {
const cell = document.getElementById(`cell-${x}-${y}`);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.map((player) => {
return `
<li>${player.name} (${player.move_count})</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.forEach(player => {
renderCellContent(player.position.x, player.position.y, "😎");
});
}
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.x, obj.position.y, "🔥");
});
}
function renderDestination(position) {
renderCellContent(position.x, position.y, "🏠");
}
window.onload = function () {
const ws = new WebSocket('ws://localhost:8011/bla-tra');
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
console.log("message received:", data)
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
ws.onopen = function () {
console.log("open");
}
ws.onclose = function () {
console.log("close");
}
ws.onerror = function () {
console.log("error");
}
}
</script>
</html> </html>

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

@ -0,0 +1,194 @@
if (typeof FAIRHOPPER_WS_SERVER === "undefined") {
var FAIRHOPPER_WS_SERVER = "ws://127.0.0.1:8011";
}
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
document.getElementById("players-content").innerHTML = players
.filter((player) => player.active)
.map((player) => {
const onDestination = player.state === "ON_DESTINATION";
return `
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
})
.join("");
}
function renderPlayers(players) {
players
.filter((player) => player.active)
.forEach((player) => {
const cell = findCell(player.position);
const onDestination = player.state === "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
cell.innerHTML = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
}
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach((layer) => {
objects = objects.concat(layer.objects.filter((obj) => obj.type === type));
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach((obj) => {
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
function renderGameDump(data) {
closePurchaseWindow();
createBoard(data.board);
renderObstacles(data.layers);
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
function playerReachedDestination(data) {
console.log(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;
const modal = new bootstrap.Modal(dlgElement);
modal.show();
}
function productPurchaseStart(products, purchaseTimeout) {
console.log("productPurchaseStart:", products);
const containerElement = document.getElementById("purchase-container");
const contentElement = document.getElementById("products-content");
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
contentElement.innerHTML = products
.map((product) => {
return `
<div class="card product" id="product-${product.id}">
<img src="img/products/${product.name}.jpeg" class="card-img-topx" alt="${product.name}">
<div class="card-body">
<h5 class="card-title">${product.name}</h5>
</div>
</div>
`;
})
.join("");
containerElement.classList.remove("d-none");
purchaseTimeoutElement.innerText = purchaseTimeout;
}
function productPurchaseTimerTick(timeLeft) {
const purchaseTimeoutElement = document.getElementById("purchase-countdown");
purchaseTimeoutElement.innerText = timeLeft;
}
function closePurchaseWindow() {
const container = document.getElementById("purchase-container");
container.classList.add("d-none");
}
function productPurchaseDone(product) {
const cardContainer = document.getElementById(`product-${product.id}`);
cardContainer.classList.add("selected");
}
function wsConnect() {
console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
let ws = new WebSocket(FAIRHOPPER_WS_SERVER);
ws.onopen = () => {
console.log("WS connected");
};
ws.onmessage = (e) => {
const wsMessage = JSON.parse(e.data);
console.log("WS message received:", wsMessage);
switch (wsMessage.message) {
case "game_dump":
renderGameDump(wsMessage.data);
break;
case "player_on_destination":
playerReachedDestination(wsMessage.data);
break;
case "product_purchase_start":
productPurchaseStart(wsMessage.data.products, wsMessage.data.timeout);
break;
case "product_purchase_timer_tick":
productPurchaseTimerTick(wsMessage.data.time_left);
break;
case "product_purchase_done":
productPurchaseDone(wsMessage.data.product);
break;
default:
console.error("Unknown message:", wsMessage);
}
};
ws.onclose = (e) => {
setTimeout(() => {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
};

View File

@ -2,6 +2,10 @@ body {
background-color: whitesmoke; background-color: whitesmoke;
} }
main.main-container {
position: relative;
}
.board-container { .board-container {
background-color: white; background-color: white;
border: 1px solid black; border: 1px solid black;
@ -13,8 +17,83 @@ body {
grid-gap: 2px; grid-gap: 2px;
padding-bottom: 2px; padding-bottom: 2px;
} }
.flex-grid:last-of-type {
padding-bottom: 0px;
}
.cell { .cell {
flex: 1; flex: 1;
text-align: center; aspect-ratio: 1;
background-color: beige; background-color: beige;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
ul.players {
list-style-type: none;
padding-left: 0;
}
.player-tooltip {
position: absolute;
margin-bottom: 50px;
font-size: 8pt;
padding: 2px 10px;
color: white;
background-color: darkred;
border-radius: 5px;
}
.player-tooltip::after {
content: " ";
position: absolute;
top: 100%; /* At the bottom of the tooltip */
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: darkred transparent transparent transparent;
}
.purchase-container {
width: 50vw;
position: fixed;
top: 200px;
left: 50%;
padding: 20px;
transform: translateX(-50%);
background-color: darkred;
z-index: 999;
border-radius: 10px;
}
.purchase-container .header {
color: white;
margin-bottom: 20px;
}
.purchase-container .header .countdown {
margin-left: auto;
}
.purchase-container .products-content {
display: grid;
grid-gap: 10px;
grid-template-columns: 1fr 1fr 1fr;
}
.purchase-container .products-content .product.selected {
background-color: pink;
}
.purchase-container .products-content .product .card-title {
text-align: center;
font-size: 12pt;
}
.purchase-container .products-content .product img {
margin: 20px;
max-height: 300px;
} }

View File

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

View File

@ -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,12 +25,13 @@ 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
move_count: int move_count: int
move_attempt_count: int move_attempt_count: int
state: PlayerState
class DestinationDto(BaseModel): class DestinationDto(BaseModel):

View File

@ -14,11 +14,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 (
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(
@ -38,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,
@ -55,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={
@ -87,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:
@ -122,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,9 +1,15 @@
import asyncio import asyncio
import logging import logging
import random
from typing import Optional from typing import Optional
from hopper.enums import Direction, PlayerMoveResult from hopper.countdown_timer import CountdownTimer
from hopper.errors import Collision, PositionOutOfBounds from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
)
from hopper.models.board import ( from hopper.models.board import (
BOARD_DUMP_CHARS, BOARD_DUMP_CHARS,
BoardLayout, BoardLayout,
@ -20,21 +26,45 @@ 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, ws_server: Optional[WSServer] = None) -> None: def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self.board = board self.board = board
self.ws_server = ws_server self.ws_server = ws_server
self.players = PlayerList() self.players = PlayerList()
self._inacivity_watchdog = None self._inacivity_watchdog = None
self._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:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[ show_player = (
ObjectType.PLAYER player.active
] and player.position.y < len(dump)
and player.position.x < len(dump[player.position.y])
)
if show_player:
dump[player.position.y][player.position.x] = BOARD_DUMP_CHARS[
ObjectType.PLAYER
]
return dump return dump
@ -49,34 +79,37 @@ class GameEngine:
self._inacivity_watchdog = InactivityWatchdog( self._inacivity_watchdog = InactivityWatchdog(
players=self.players, players=self.players,
ws_server=self.ws_server, ws_server=self.ws_server,
daemon=True,
) )
self._inacivity_watchdog.start() self._inacivity_watchdog.start()
async def start_game(self, player_name: str) -> Player: 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()
if self.ws_server: await self.send_game_dump()
await self.ws_server.send_game_state() await asyncio.sleep(settings.game.MOVE_DELAY)
return player return player
async def move_player( def _move_position(self, position: Position, direction: Direction) -> Position:
self, player: Player, direction: Direction new_position = Position(position.x, position.y)
) -> PlayerMoveResult:
player.reset_timeout()
new_position = Position(player.position.x, player.position.y)
logging.info(f"Player {player} move to {direction}")
if direction == Direction.LEFT: if direction == Direction.LEFT:
new_position.x -= 1 new_position.x -= 1
elif direction == Direction.RIGHT: elif direction == Direction.RIGHT:
@ -87,41 +120,115 @@ class GameEngine:
new_position.y += 1 new_position.y += 1
else: else:
raise ValueError(f"Unhandled direction: {direction}") raise ValueError(f"Unhandled direction: {direction}")
return new_position
async def move_player(
self, player: Player, direction: Direction
) -> PlayerMoveResult:
player.reset_timeout()
if self.game_state == GameState.LOCK_FOR_MOVEMENT:
raise GameLockForMovement("Player reached destination. Can't move anymore.")
# player will not be able to move once they reach the destination
if player.state == PlayerState.ON_DESTINATION:
return PlayerMoveResult.DESTINATION_REACHED
logging.info(f"Player {player} move to {direction}")
new_position = self._move_position(player.position, direction)
player.move_attempt_count += 1 player.move_attempt_count += 1
player.state = PlayerState.MOVING
if not self.position_in_board_bounds(new_position): if not self._position_in_board_bounds(new_position):
raise PositionOutOfBounds() raise PositionOutOfBounds()
if self.colided_with_obstacle(new_position): if self._colided_with_obstacle(new_position):
raise Collision() raise Collision()
player.position = new_position player.position = new_position
player.move_count += 1 player.move_count += 1
if self.ws_server: if self._is_player_on_destination(player):
await self.ws_server.send_game_state() player.state = PlayerState.ON_DESTINATION
await self._player_on_destination(player)
if self.is_player_on_destination(player):
logging.info(f"Player {player} reached destination!")
return PlayerMoveResult.DESTINATION_REACHED return PlayerMoveResult.DESTINATION_REACHED
self.__debug_print_board() await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY) await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK return PlayerMoveResult.OK
def is_player_on_destination(self, player: Player) -> bool: def _is_player_on_destination(self, player: Player) -> bool:
return player.position == self.board.destination.position return player.position == self.board.destination.position
def position_in_board_bounds(self, position: Position) -> bool: def _position_in_board_bounds(self, position: Position) -> bool:
return ( return (
0 <= position.x < self.board.width and 0 <= position.y < self.board.height 0 <= position.x < self.board.width and 0 <= position.y < self.board.height
) )
def colided_with_obstacle(self, position: Position) -> bool: def _colided_with_obstacle(self, position: Position) -> bool:
return self.board.get_object_at_position(position) is not None return self.board.get_object_at_position(position) is not None
async def _player_on_destination(self, player: Player) -> None:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.send_game_dump()
await self.ws_server.send_player_reached_destination_message(player=player)
logging.info(
f"Starting purchase countdown timer for {settings.purchase_timeout} seconds"
)
def on_purchase_timer_tick(time_left) -> None:
logging.info(f"Purchase countdown timer tick, time left: {time_left}")
asyncio.run(
self.ws_server.send_product_purchase_time_left_message(
player=player, time_left=time_left
)
)
def on_purchase_timer_done() -> None:
logging.info("Ding ding! Purchase countdown timer timeout")
self._purchase_countdown_timer = None
asyncio.run(
self.ws_server.send_product_purchase_done_message(
player=player, product=None
)
)
self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump())
self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout,
timer_tick_callback=on_purchase_timer_tick,
timer_done_callback=on_purchase_timer_done,
)
self._purchase_countdown_timer.start()
await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
# async def purchase_product(self, player: Player, product: Product) -> None:
# if not player.state == PlayerState.ON_DESTINATION:
# raise PurchaseForbiddenForPlayer()
# if self._purchase_countdown_timer:
# self._purchase_countdown_timer.stop()
# await self.ws_server.send_product_purchase_done_message(
# player=player, product=product
# )
# await asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY)
# await self.reset_game()
def _reset_player(self, player) -> None:
# move player to start position
player.position = create_player_start_position(
self.board.width, self.board.height
)
player.state = PlayerState.CREATED
player.last_seen = None
def get_board_layout(self) -> BoardLayout: def get_board_layout(self) -> BoardLayout:
return BoardLayout(board=self.board, players=self.players) return BoardLayout(board=self.board, players=self.players)
@ -132,12 +239,12 @@ class GameEngineFactory:
board_width: int, board_width: int,
board_height: int, board_height: int,
obstacle_count: int = 0, obstacle_count: int = 0,
ws_server: Optional[WSServer] = None, ws_server: WSServer = None,
) -> GameEngine: ) -> GameEngine:
board = GameBoard( board = GameBoard(
width=board_width, width=board_width,
height=board_height, height=board_height,
destination=Destination(Position(board_height // 2, board_height // 2)), destination=Destination(Position(board_width // 2, board_height // 2)),
) )
obstacle_layer = Layer(name="obstacles") obstacle_layer = Layer(name="obstacles")
for _ in range(obstacle_count): for _ in range(obstacle_count):
@ -153,11 +260,13 @@ class GameEngineFactory:
board=board, board=board,
ws_server=ws_server, ws_server=ws_server,
) )
GameEngineFactory.__add_test_player(game.players) GameEngineFactory.__add_test_players(game.players)
return game return game
@staticmethod @staticmethod
def create_default(ws_server: Optional[WSServer] = None) -> GameEngine: def create_default(
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create( return GameEngineFactory.create(
board_width=settings.board.WIDTH, board_width=settings.board.WIDTH,
board_height=settings.board.HEIGHT, board_height=settings.board.HEIGHT,
@ -166,15 +275,10 @@ class GameEngineFactory:
) )
@staticmethod @staticmethod
def __add_test_player(players: PlayerList) -> None: def __add_test_players(players: PlayerList) -> None:
if not (settings.debug and settings.debug.CREATE_TEST_PLAYER): if not settings.debug:
return return
player = Player( for player in settings.debug.PLAYERS:
name="Pero", players.append(player)
uuid="test-player-id", logging.info(f"Test player created: {player}")
position=Position(2, 2),
can_be_deactivated=False,
)
players.append(player)
logging.info(f"Test player created: {player}")

View File

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

View File

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

View File

@ -1,10 +1,17 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import List, Optional
from hopper.models.player import Player
from hopper.models.product import Product
@dataclass @dataclass
class GameSettings: class GameSettings:
MOVE_DELAY: int = 0.5 # seconds MOVE_DELAY: float = 0.5 # seconds
PURCHASE_START_DELAY: float = 2 # seconds
PURCHASE_FINISHED_DELAY: float = 2 # seconds
@dataclass @dataclass
class BoardSettings: class BoardSettings:
@ -22,14 +29,14 @@ 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
@ -38,5 +45,7 @@ class Settings:
board: BoardSettings board: BoardSettings
inacivity_watchdog: InactivityWatchdogSettings inacivity_watchdog: InactivityWatchdogSettings
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO log_level: int = logging.INFO
products: List[Product] = None
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None

View File

@ -3,6 +3,8 @@ import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
from hopper.enums import PlayerState
@dataclass @dataclass
class Position: class Position:
@ -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

10
hopper/models/product.py Normal file
View File

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

View File

@ -1,8 +1,18 @@
from __future__ import annotations from __future__ import annotations
from pydantic import Field import json
from typing import Optional, TypeVar
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
)
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -16,8 +26,50 @@ 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 ProductPurchaseTimerDto(BaseModel):
time_left: int
player: PlayerDto
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())
class WSGameDumpMessage(WSMessage):
message: str = "game_dump"
data: GameDumpDto
class WSProductPurchaseTimerTickMessage(WSMessage):
message: str = "product_purchase_timer_tick"
data: ProductPurchaseTimerDto
class WSProductPurchaseDoneMessage(WSMessage):
message: str = "product_purchase_done"
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -3,7 +3,6 @@ import datetime
import logging import logging
import time import time
from threading import Thread from threading import Thread
from typing import Optional
from hopper.models.player import PlayerList from hopper.models.player import PlayerList
from hopper.ws_server import WSServer from hopper.ws_server import WSServer
@ -12,12 +11,12 @@ from settings import settings
class InactivityWatchdog(Thread): class InactivityWatchdog(Thread):
def __init__( def __init__(
self, players: PlayerList, ws_server: Optional[WSServer] = None, *args, **kwargs self, players: PlayerList, ws_server: WSServer = None
) -> None: ) -> None:
self.players = players self.players = players
self.ws_server = ws_server self.ws_server = ws_server
self.stopped = False self.stopped = False
super().__init__(*args, **kwargs) super().__init__(daemon=True)
def run(self) -> None: def run(self) -> None:
logging.info("Starting inactivity watchdog") logging.info("Starting inactivity watchdog")
@ -34,7 +33,7 @@ class InactivityWatchdog(Thread):
seconds=settings.inacivity_watchdog.KICK_TIMEOUT seconds=settings.inacivity_watchdog.KICK_TIMEOUT
) )
send_game_state = False send_game_dump = False
for player in self.players: for player in self.players:
if ( if (
@ -44,7 +43,7 @@ class InactivityWatchdog(Thread):
): ):
player.active = False player.active = False
logging.info(f"Player {player} set as inactive") logging.info(f"Player {player} set as inactive")
send_game_state = True send_game_dump = True
# safe remove from list # safe remove from list
n = 0 n = 0
@ -53,18 +52,16 @@ class InactivityWatchdog(Thread):
if player.can_be_deactivated and player.last_seen < kick_threshold: if player.can_be_deactivated and player.last_seen < kick_threshold:
self.players.pop(n) self.players.pop(n)
logging.info(f"Player {player} kicked out") logging.info(f"Player {player} kicked out")
send_game_state = True send_game_dump = True
else: else:
n += 1 n += 1
if send_game_state: if send_game_dump:
self.send_game_state() self.send_game_dump()
def send_game_state(self): def send_game_dump(self):
if not self.ws_server: logging.info("Sending WS game dump")
return asyncio.run(self.ws_server.send_game_dump())
logging.info("Sending WS game state")
asyncio.run(self.ws_server.send_game_state())
def stop(self) -> None: def stop(self) -> None:
self.stopped = True self.stopped = True

View File

@ -1,80 +1,135 @@
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.ws_dto import GameStateDto from hopper.models.player import Player
from settings import settings from hopper.models.product import Product
from hopper.models.ws_dto import (
GameDumpDto,
PlayerReachedDestinationDto,
ProductPurchaseTimerDto,
WSGameDumpMessage,
WSMessage,
WSPlayerReachedDestinationMessage,
WSProductPurchaseTimerTickMessage,
)
class WSServer(Thread): class WSServer(Thread):
def __init__(self, *args, **kwargs) -> None: def __init__(self, host: str, port: int) -> None:
self.connected_clients = set[WebSocketServerProtocol]() self.host = host
super().__init__(*args, **kwargs) self.port = port
super().__init__(daemon=True)
async def handler(self, websocket: WebSocketServerProtocol) -> None: async def handler(self, websocket: WebSocketServerProtocol) -> None:
"""New handler instance spawns for each connected client"""
self.connected_clients.add(websocket) self.connected_clients.add(websocket)
logging.info(f"Add client: {websocket.id}") logging.info(f"Add client: {websocket.id}")
try: try:
await self.send_game_state_to_client(websocket) # send initial game dump to connected client
await self.send_game_dump_to_client(websocket)
# loop and do nothing while client is connected
connected = True connected = True
while connected: while connected:
try: try:
message = await websocket.recv() # we're expecting nothing from client, but read if client sends a message
await websocket.recv()
except ConnectionClosedOK: except ConnectionClosedOK:
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)
logging.info(f"Remove client: {websocket.id}") logging.info(f"Remove client: {websocket.id}")
def _create_game_state_message(self) -> str: async def send_message_to_client(
self, client: WebSocketServerProtocol, message: WSMessage
) -> None:
message_str = message.to_str()
logging.debug(
f"Sending message {message.message} to clients: {self.connected_clients}: {message_str}"
)
await client.send(message_str)
async def send_message_to_clients(self, message: WSMessage) -> None:
for client in self.connected_clients:
await self.send_message_to_client(client, message)
def _create_game_dump_message(self) -> WSGameDumpMessage:
# avoid circular imports # avoid circular imports
from hopper.api.dependencies import get_game_engine from hopper.api.dependencies import get_game_engine
engine = get_game_engine() engine = get_game_engine()
game_state = GameStateDto( game_dump = GameDumpDto(
board=engine.board, board=engine.board,
destination=engine.board.destination, destination=engine.board.destination,
players=engine.players, players=engine.players,
layers=engine.get_board_layout().layers, layers=engine.get_board_layout().layers,
) )
return json.dumps(game_state.dict()) return WSGameDumpMessage(data=game_dump)
async def send_game_state_to_client( async def send_game_dump_to_client(
self, websocket: WebSocketServerProtocol self, websocket: WebSocketServerProtocol
) -> None: ) -> None:
message = self._create_game_state_message() """Send game dump to the client"""
logging.debug(f"Sending game state to client: {websocket.id}") message = self._create_game_dump_message()
await websocket.send(message) logging.debug(f"Sending game dump to client: {websocket.id}")
await websocket.send(message.to_str())
async def send_game_state(self) -> None: async def send_game_dump(self) -> None:
if not self.connected_clients: """Broadcast game state to all connected clients"""
return message = self._create_game_dump_message()
await self.send_message_to_clients(message)
message = self._create_game_state_message() async def send_player_reached_destination_message(self, player: Player) -> None:
logging.debug( message = WSPlayerReachedDestinationMessage(
f"Sending game state to clients: {self.connected_clients}: {message}" data=PlayerReachedDestinationDto(
player=player,
)
) )
for client in self.connected_clients: await self.send_message_to_clients(message)
await client.send(message)
async def send_product_purchase_time_left_message(
self, player: Player, time_left: int
) -> None:
message = WSProductPurchaseTimerTickMessage(
data=ProductPurchaseTimerDto(
player=player,
time_left=time_left,
)
)
await self.send_message_to_clients(message)
async def send_product_purchase_done_message(
self, player: Player, product: Optional[Product] = None
) -> None:
# message = WSProductPurchaseDoneMessage(
# data=ProductPurchaseDoneDto(player=player, product=product),
# )
# await self.send_message_to_clients(message)
...
async def run_async(self) -> None: async def run_async(self) -> None:
logging.info( logging.info(
f"Starting FairHopper Websockets Server on {settings.ws_server.HOST}:{settings.ws_server.PORT}" f"Starting FairHopper Websockets Server on {self.host}:{self.port}"
) )
async with websockets.serve( async with websockets.serve(
ws_handler=self.handler, ws_handler=self.handler,
host=settings.ws_server.HOST, host=self.host,
port=settings.ws_server.PORT, port=self.port,
): ):
await asyncio.Future() # run forever await asyncio.Future() # run forever
def run(self) -> None: def run(self) -> None:
self.connected_clients = set[WebSocketServerProtocol]()
asyncio.run(self.run_async()) asyncio.run(self.run_async())

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,17 +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(),
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,
),
],
),
) )