552 lines
9.8 KiB
Markdown
552 lines
9.8 KiB
Markdown
# 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
|
||
|
||
### Overview
|
||
|
||
- Rectangle board W × H
|
||
- Destination: center of a board (W / 2, H / 2)
|
||
- Initial player position: Random on board border
|
||
- Available moves:
|
||
- left
|
||
- right
|
||
- up
|
||
- down
|
||
- Optional on-board obstacles
|
||
|
||
### Rules
|
||
- Goal: Reach the goal destination
|
||
- Player can't move out of board
|
||
- Player can't move if destination position contains obstacle
|
||
- 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
|
||
|
||
### 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:
|
||
- Python 3.10+
|
||
|
||
#### Install virtual envirnonment
|
||
|
||
Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python.
|
||
|
||
Install Poetry:
|
||
```sh
|
||
pip install poetry
|
||
```
|
||
|
||
Install virtual environment:
|
||
```sh
|
||
poetry install
|
||
```
|
||
|
||
#### Setting up
|
||
|
||
Copy `settings_template.py` to `settings.py`.
|
||
|
||
Edit `settings.py` and customize application.
|
||
|
||
|
||
#### Starting FairHopper Game Server
|
||
|
||
```sh
|
||
make run
|
||
```
|
||
|
||
By default, FairHopper runs on port **8010**. To run FairHopper on different port, start `uvicorn` directly:
|
||
```sh
|
||
poetry run uvicorn main:app --host 0.0.0.0 --port 8010 --workers=1
|
||
```
|
||
|
||
To activate virtual environment:
|
||
```sh
|
||
poetry shell
|
||
```
|
||
|
||
WebSockets server runs on port **8011**. To run WS Server on different port, edit `settings.py` configuration.
|
||
|
||
|
||
## System overview
|
||
|
||
### Architecture
|
||
|
||
```plantuml
|
||
scale 1024 width
|
||
actor "Player 1" as P1
|
||
actor "Player 2" as P2
|
||
actor "Player 3" as P3
|
||
|
||
package Masterpiece #seashell {
|
||
rectangle "FairHopper Game Server" #lightcyan {
|
||
usecase API as "API Server"
|
||
usecase Game as "Game Engine"
|
||
usecase WS as "WS Server"
|
||
}
|
||
usecase Vis as "Flutter\nVisualisation\nService"
|
||
}
|
||
|
||
usecase ExtVis1 as "Visualisation\nClient"
|
||
usecase ExtVis2 as "Visualisation\nClient"
|
||
|
||
P1 -left-> API: REST API
|
||
P2 -left-> API: REST API
|
||
P3 -left-> API: REST API
|
||
API --> Game
|
||
Game --> WS: Game State
|
||
WS --> Vis: WS Game State
|
||
WS --> ExtVis1: WS Game State
|
||
WS --> ExtVis2: WS Game State
|
||
```
|
||
|
||
### WebSockets
|
||
|
||
```plantuml
|
||
scale 1024 width
|
||
box "FairHopper Game Server" #lightcyan
|
||
participant Game as "Game Engine"
|
||
participant WS as "WS Server"
|
||
endbox
|
||
participant Client1 as "Visualisation\nClient 1"
|
||
participant Client2 as "Visualisation\nClient 2"
|
||
|
||
== Player movement mode ==
|
||
|
||
Game ->o WS: Send initial state
|
||
|
||
Client1 ->o WS: Client connect
|
||
activate WS #coral
|
||
WS -> Client1: Game state
|
||
deactivate WS
|
||
|
||
Client2 ->o WS: Client connect
|
||
activate WS #coral
|
||
WS -> Client2: Game state
|
||
deactivate WS
|
||
|
||
loop #lightyellow On game state change
|
||
Game ->o WS: Game state
|
||
activate WS #coral
|
||
WS o-> Client1: Game state
|
||
WS o-> Client2: Game state
|
||
deactivate WS
|
||
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
|
||
```
|
||
|
||
|
||
## REST API
|
||
|
||
- Start game
|
||
- Move left
|
||
- Move right
|
||
- Move up
|
||
- Move down
|
||
- Get player info
|
||
- Get board info
|
||
|
||
Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
|
||
|
||
### Start game
|
||
|
||
**Endpoint**: POST `/game`
|
||
|
||
Request body:
|
||
```json
|
||
{
|
||
"player_name": "Pero"
|
||
}
|
||
```
|
||
|
||
Response body:
|
||
```json
|
||
{
|
||
"board": {
|
||
"width": 101,
|
||
"height": 101
|
||
},
|
||
"destination": {
|
||
"position": {
|
||
"x": 50,
|
||
"y": 50
|
||
}
|
||
},
|
||
"player": {
|
||
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
|
||
"name": "Pero",
|
||
"position": {
|
||
"x": 0,
|
||
"y": 10
|
||
},
|
||
"move_count": 0,
|
||
"move_attempt_count": 0
|
||
}
|
||
}
|
||
```
|
||
|
||
### Player Move
|
||
|
||
- POST `/player/{id}/move/left`
|
||
- POST `/player/{id}/move/right`
|
||
- POST `/player/{id}/move/up`
|
||
- POST `/player/{id}/move/down`
|
||
|
||
Request body: None
|
||
|
||
Response code:
|
||
- 200 OK: Destination reached
|
||
- 201 Created: Player moved successfully
|
||
- 403 Forbidden: Player id not valid, probably timeout
|
||
- 409 Conflict: Invalid move, obstacle or position out of board
|
||
- 422 Unprocessable Content: Validation error
|
||
- 423 Locked: Game locked, product selection in progress
|
||
|
||
Response body:
|
||
```json
|
||
{
|
||
"player": {
|
||
"id": "string",
|
||
"name": "Pero",
|
||
"position": {
|
||
"x": 50,
|
||
"y": 50
|
||
},
|
||
"move_count": 10,
|
||
"move_attempt_count": 12
|
||
}
|
||
}
|
||
```
|
||
|
||
### Get Player Info
|
||
|
||
GET `/player/{{id}}`
|
||
|
||
Request body: None
|
||
|
||
Response body:
|
||
```json
|
||
{
|
||
"player": {
|
||
"id": "string",
|
||
"name": "Pero",
|
||
"position": {
|
||
"x": 50,
|
||
"y": 50
|
||
},
|
||
"move_count": 10,
|
||
"move_attempt_count": 12
|
||
}
|
||
}
|
||
```
|
||
|
||
### Get Game Info
|
||
|
||
GET `/game`
|
||
|
||
Response body:
|
||
```json
|
||
{
|
||
"playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
|
||
"board": {
|
||
"width": 101,
|
||
"height": 101
|
||
},
|
||
"destinationPosition": {
|
||
"x": 50,
|
||
"y": 50
|
||
},
|
||
"playerPosition": {
|
||
"x": 0,
|
||
"y": 10
|
||
}
|
||
}
|
||
```
|
||
|
||
## WebSockets
|
||
|
||
### WS Data format
|
||
- json
|
||
|
||
```json
|
||
{
|
||
"message": message_type,
|
||
"data": ...
|
||
}
|
||
```
|
||
|
||
|
||
### Game state structure
|
||
|
||
Direction: Game server -> Clients
|
||
|
||
Message: `game_dump`
|
||
|
||
Data:
|
||
```json
|
||
{
|
||
"board": {
|
||
"width": 10,
|
||
"height": 10
|
||
},
|
||
"destination": {
|
||
"position": {
|
||
"x": 5,
|
||
"y": 5
|
||
}
|
||
},
|
||
"players": [
|
||
{
|
||
"id": "test-player-pero",
|
||
"name": "Pero",
|
||
"active": true,
|
||
"position": {
|
||
"x": 3,
|
||
"y": 3
|
||
},
|
||
"move_count": 0,
|
||
"move_attempt_count": 0,
|
||
"state": "CREATED"
|
||
},
|
||
{
|
||
"id": "test-player-mirko",
|
||
"name": "Mirko",
|
||
"active": true,
|
||
"position": {
|
||
"x": 4,
|
||
"y": 4
|
||
},
|
||
"move_count": 0,
|
||
"move_attempt_count": 0,
|
||
"state": "CREATED"
|
||
}
|
||
],
|
||
"layers": [
|
||
{
|
||
"name": "obstacles",
|
||
"objects": [
|
||
{
|
||
"type": "OBSTACLE",
|
||
"position": {
|
||
"x": 0,
|
||
"y": 6
|
||
}
|
||
},
|
||
{
|
||
"type": "OBSTACLE",
|
||
"position": {
|
||
"x": 5,
|
||
"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
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 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`
|