38 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
31 changed files with 945 additions and 490 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
/.venv
/settings.py
/requirements.txt
/frontend/js/config.js

50
Dockerfile Normal file
View File

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

View File

@ -1,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:
@poetry run \
@ \
poetry run \
uvicorn \
main:app \
--host 0.0.0.0 \
@ -7,10 +17,48 @@ run:
--workers=1
run-dev:
@poetry run \
@ \
poetry run \
uvicorn \
main:app \
--host 0.0.0.0 \
--port 8010 \
--workers=1 \
--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):$(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

515
README.md
View File

@ -1,5 +1,14 @@
# 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
@ -8,10 +17,10 @@
- Destination: center of a board (W / 2, H / 2)
- Initial player position: Random on board border
- Available moves:
- left
- right
- up
- down
- left
- right
- up
- down
- Optional on-board obstacles
### Rules
@ -24,6 +33,7 @@
## Game States
```plantuml
scale 1024 width
hide empty description
state "Start Game" as StartGame
@ -34,8 +44,7 @@ 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>>
state "Unlock game and restart" as UnlockGame <<end>>
[*] -> StartGame
StartGame -> MovePlayer
@ -45,17 +54,66 @@ DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players
ProductSelected --> UnlockGame: Unlock game\nand restart
SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players
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
#### Install virtual envirnonment
Project uses [Poetry](https://python-poetry.org), ultimate dependency management software for Python.
@ -69,14 +127,14 @@ Install virtual environment:
poetry install
```
### Setting up
#### Setting up
Copy `settings_template.py` to `settings.py`.
Edit `settings.py` and customize application.
### Starting FairHopper Game Server
#### Starting FairHopper Game Server
```sh
make run
@ -92,7 +150,7 @@ To activate virtual environment:
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
@ -100,6 +158,7 @@ WebSockets server runs on port **8011**. To run WS Server on different port, edi
### Architecture
```plantuml
scale 1024 width
actor "Player 1" as P1
actor "Player 2" as P2
actor "Player 3" as P3
@ -110,11 +169,11 @@ package Masterpiece #seashell {
usecase Game as "Game Engine"
usecase WS as "WS Server"
}
usecase Vis as "Visualisation\nService"
usecase Vis as "Flutter\nVisualisation\nService"
}
usecase ExtVis1 as "Visualisation\nService"
usecase ExtVis2 as "Visualisation\nService"
usecase ExtVis1 as "Visualisation\nClient"
usecase ExtVis2 as "Visualisation\nClient"
P1 -left-> API: REST API
P2 -left-> API: REST API
@ -129,6 +188,7 @@ 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"
@ -136,25 +196,70 @@ 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
deactivate WS
Client2 ->o WS: Client connect
activate WS #coral
WS -> Client2: Game state
deactivate
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
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
```
@ -177,87 +282,88 @@ Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
Request body:
```json
{
"player_name": "Pero"
"player_name": "Pero"
}
```
Response body:
```json
{
"board": {
"width": 101,
"height": 101
},
"destination": {
"position": {
"x": 50,
"y": 50
}
},
"player": {
"uuid": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero",
"position": {
"x": 0,
"y": 10
},
"move_count": 0,
"move_attempt_count": 0
}
"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/{uuid}/move/left`
- POST `/player/{uuid}/move/right`
- POST `/player/{uuid}/move/up`
- POST `/player/{uuid}/move/down`
- 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 uuid not valid, probably timeout
- 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": {
"uuid": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
},
"move_count": 10,
"move_attempt_count": 12
}
"player": {
"id": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
},
"move_count": 10,
"move_attempt_count": 12
}
}
```
### Get Player Info
GET `/player/{{uuid}}`
GET `/player/{{id}}`
Request body: None
Response body:
```json
{
"player": {
"uuid": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
},
"move_count": 10,
"move_attempt_count": 12
}
"player": {
"id": "string",
"name": "Pero",
"position": {
"x": 50,
"y": 50
},
"move_count": 10,
"move_attempt_count": 12
}
}
```
@ -268,19 +374,19 @@ 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
}
"playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"board": {
"width": 101,
"height": 101
},
"destinationPosition": {
"x": 50,
"y": 50
},
"playerPosition": {
"x": 0,
"y": 10
}
}
```
@ -289,120 +395,157 @@ Response body:
### WS Data format
- json
```json
{
"message": message_type,
"data": ...
}
```
### Game state structure
URI: `/game-state`
Direction: Game server -> Clients
Message: `game_dump`
Data:
```json
{
"board": {
"width": 21,
"height": 21
},
"destination": {
"position": {
"x": 10,
"y": 10
}
},
"players": [
{
"uuid": "test-player-id",
"name": "Pero",
"active": true,
"position": {
"x": 2,
"y": 2
},
"move_count": 3,
"move_attempt_count": 3
},
{
"uuid": "95962b49-0003-4bf2-b205-71f2590f2318",
"name": "Mirko",
"active": true,
"position": {
"x": 0,
"y": 0
},
"move_count": 15,
"move_attempt_count": 20
}
],
"layers": [
{
"name": "obstacles",
"objects": [
{
"type": "OBSTACLE",
"position": {
"x": 4,
"y": 2
}
},
{
"type": "OBSTACLE",
"position": {
"x": 4,
"y": 13
}
},
{
"type": "OBSTACLE",
"position": {
"x": 18,
"y": 18
}
},
{
"type": "OBSTACLE",
"position": {
"x": 5,
"y": 4
}
},
{
"type": "OBSTACLE",
"position": {
"x": 7,
"y": 10
}
}
]
},
{
"name": "destination",
"objects": [
{
"type": "DESTINATION",
"position": {
"x": 10,
"y": 10
}
}
]
},
{
"name": "players",
"objects": [
{
"type": "PLAYER",
"position": {
"x": 2,
"y": 2
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
}
}
]
}
]
"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`

View File

@ -34,6 +34,8 @@ POST http://localhost:8010/player/test-player-pero/move/up
POST http://localhost:8010/player/test-player-pero/move/down
###
###
# move Mirko left
POST http://localhost:8010/player/test-player-mirko/move/left
###

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -8,12 +8,17 @@
<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>
<div class="container-fluid">
<main class="container-fluid main-container">
<h1 class="mt-1 mb-2">
FairHopper Visualisation Client
</h1>
@ -30,155 +35,27 @@
<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>
<script>
const BOARD_ICONS = {
PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎",
OBSTACLE: "🔥",
DESTINATION: "🏠",
};
function createBoard(board) {
let html = "";
for (let y = 0; y < board.height; y++) {
let colHtml = "";
for (let x = 0; x < board.width; x++) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
}
html += `
<div class="flex-grid">
${colHtml}
</div>
`;
}
document.getElementById("board-content").innerHTML = html;
}
function findCell(position) {
return document.getElementById(`cell-${position.x}-${position.y}`);
}
function renderCellContent(position, content) {
const cell = findCell(position);
if (cell) {
cell.innerText = content;
}
}
function renderPlayerList(players) {
const html = players.filter(player => player.active).map((player) => {
const onDestination = player.state == "ON_DESTINATION";
return `
<li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count})
${onDestination ? "✅" : ""}
</li>
`;
}).join("");
document.getElementById("players-content").innerHTML = html;
}
function renderPlayers(players) {
players.filter(player => player.active).forEach(player => {
const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) {
const html = `
<div class="player-tooltip">${player.name}</div>
${playerIcon}
`;
cell.innerHTML = html;
}
});
}
function getLayerObjectsOfType(layers, type) {
let objects = [];
layers.forEach(layer => {
objects = objects.concat(layer.objects.filter(obj => obj.type === type))
});
return objects;
}
function renderObstacles(layers) {
const objects = getLayerObjectsOfType(layers, "OBSTACLE");
objects.forEach(obj => {
renderCellContent(obj.position, BOARD_ICONS.OBSTACLE);
});
}
function renderDestination(position) {
renderCellContent(position, BOARD_ICONS.DESTINATION);
}
function renderGameDump(data) {
createBoard(data.board);
renderObstacles(data.layers)
renderDestination(data.destination.position);
renderPlayerList(data.players);
renderPlayers(data.players);
}
function productPurchaseStart(products) {
console.log("productPurchaseStart:", products)
}
function productPurchased(product) {
console.log("productPurchased:", product)
}
function productPurchaseDone() {
console.log("productPurchaseDone")
}
function wsConnect() {
let ws = new WebSocket('ws://localhost:8011');
ws.onopen = () => {
console.log("WS connected")
};
ws.onmessage = (e) => {
const wsMessage = JSON.parse(e.data);
console.log("WS message received:", wsMessage)
switch (wsMessage.message) {
case "game_dump":
renderGameDump(wsMessage.data);
break;
case "product_purchase_start":
productPurchaseStart(wsMessage.data)
break;
case "product_purchased":
productPurchased(wsMessage.data)
break;
case "product_purchase_done":
productPurchaseDone()
break;
default:
console.error("Unknown message:", wsMessage)
}
};
ws.onclose = (e) => {
setTimeout(function () {
wsConnect();
}, 1000);
};
ws.onerror = (err) => {
console.error("Socket encountered error:", err.message, "Closing socket");
ws.close();
};
}
window.onload = () => {
wsConnect();
}
</script>
</html>

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();
};

View File

@ -2,6 +2,10 @@ body {
background-color: whitesmoke;
}
main.main-container {
position: relative;
}
.board-container {
background-color: white;
border: 1px solid black;
@ -14,7 +18,7 @@ body {
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
padding-bottom: 0;
}
.cell {
@ -40,7 +44,6 @@ ul.players {
color: white;
background-color: darkred;
border-radius: 5px;
z-index: 1000;
}
.player-tooltip::after {

View File

@ -25,7 +25,7 @@ class PositionDto(BaseModel):
class PlayerDto(BaseModel):
uuid: str
id: str
name: str
active: bool
position: PositionDto

View File

@ -14,14 +14,18 @@ from hopper.api.dto import (
)
from hopper.engine import GameEngine
from hopper.enums import Direction, PlayerMoveResult
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.errors import (
Collision,
GameLockForMovement,
PositionOutOfBounds,
)
from hopper.models.player import Player
router = APIRouter()
def get_player(uuid: str, engine: GameEngine = Depends(get_game_engine)) -> Player:
player = engine.players.find(uuid)
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"
@ -72,16 +76,16 @@ async def start_game(
@router.get(
"/player/{uuid}",
"/player/{id}",
response_model=PlayerInfoResponseDto,
responses={
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player inactive",
"description": "Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
"description": "Player with id not found, probably kicked out",
},
},
)
@ -92,7 +96,7 @@ async def get_player_info(
@router.post(
"/player/{uuid}/move/{direction}",
"/player/{id}/move/{direction}",
response_model=MovePlayerResponseDto,
status_code=status.HTTP_201_CREATED,
responses={
@ -102,19 +106,19 @@ async def get_player_info(
},
status.HTTP_403_FORBIDDEN: {
"model": ErrorResponseDto,
"description": " Player inactive",
"description": "Player inactive",
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponseDto,
"description": " Player with uuid not found, probably kicked out",
"description": "Player with id not found, probably kicked out",
},
status.HTTP_409_CONFLICT: {
"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.",
"description": "Player reached destination. Can't move anymore.",
},
},
)

View File

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

View File

@ -6,7 +6,6 @@ from typing import Optional
from hopper.countdown_timer import CountdownTimer
from hopper.enums import Direction, GameState, PlayerMoveResult, PlayerState
from hopper.errors import Collision, GameLockForMovement, PositionOutOfBounds
from hopper.interfaces import SendGameDumpInterface
from hopper.models.board import (
BOARD_DUMP_CHARS,
BoardLayout,
@ -19,19 +18,35 @@ from hopper.models.board import (
)
from hopper.models.player import Player, PlayerList, Position
from hopper.watchdog import InactivityWatchdog
from hopper.ws_server import WSServer
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:
def __init__(
self, board: GameBoard, ws_server: Optional[SendGameDumpInterface] = None
) -> None:
def __init__(self, board: GameBoard, ws_server: WSServer = None) -> None:
self.board = board
self.ws_server = ws_server
self.players = PlayerList()
self._inacivity_watchdog = None
self._purchase_countdown_timer: Optional[CountdownTimer] = None
self.reset_game()
self.game_state = GameState.RUNNING
self.__debug_print_board()
def dump_board(self) -> list[list[str]]:
dump = self.board.dump()
@ -63,15 +78,21 @@ class GameEngine:
)
self._inacivity_watchdog.start()
def reset_game(self) -> None:
async def send_game_dump(self):
self.__debug_print_board()
await self.ws_server.send_game_dump()
async def reset_game(self) -> None:
self.__debug_print_board()
self.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()
player = Player(
name=player_name,
position=self._create_player_start_position(),
position=create_player_start_position(self.board.width, self.board.height),
state=PlayerState.CREATED,
)
self.players.append(player)
@ -79,27 +100,10 @@ class GameEngine:
logging.info(f"Starting new game for player: {player}")
self.__debug_print_board()
if self.ws_server:
await self.ws_server.send_game_dump()
await self.send_game_dump()
await asyncio.sleep(settings.game.MOVE_DELAY)
return player
def _create_player_start_position(self) -> Position:
"""Create random position somewhere on the board border"""
border_len = (self.board.width + self.board.height) * 2
rnd_position = random.randint(0, border_len - 1)
if rnd_position < self.board.width * 2:
x = rnd_position % self.board.width
y = 0 if rnd_position < self.board.width else self.board.height - 1
else:
rnd_position -= 2 * self.board.width
x = 0 if rnd_position < self.board.height else self.board.width - 1
y = rnd_position % self.board.height
return Position(x=x, y=y)
def _move_position(self, position: Position, direction: Direction) -> Position:
new_position = Position(position.x, position.y)
if direction == Direction.LEFT:
@ -146,10 +150,8 @@ class GameEngine:
await self._player_on_destination(player)
return PlayerMoveResult.DESTINATION_REACHED
if self.ws_server:
await self.ws_server.send_game_dump()
await self.send_game_dump()
self.__debug_print_board()
await asyncio.sleep(settings.game.MOVE_DELAY)
return PlayerMoveResult.OK
@ -168,25 +170,49 @@ class GameEngine:
logging.info(f"Player {player} reached destination!")
self.game_state = GameState.LOCK_FOR_MOVEMENT
await self.ws_server.send_game_dump()
self.__debug_print_board()
await self.send_game_dump()
await self.ws_server.send_product_purchase_message(products=settings.products)
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())
logging.info(f"Starting purchase countdown timer for {settings.purchase_timeout} seconds")
self._purchase_countdown_timer = CountdownTimer(
seconds=settings.purchase_timeout,
callback=self._on_purchase_timeout,
timer_tick_callback=on_purchase_timer_tick,
timer_done_callback=on_purchase_timer_done,
)
self._purchase_countdown_timer.start()
def _on_purchase_timeout(self) -> None:
logging.info("Ding ding! Purchase countdown timer timeout")
self._purchase_countdown_timer = None
await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
asyncio.run(self.ws_server.send_product_purchase_done_message(product=None))
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()
self.game_state = GameState.RUNNING
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:
return BoardLayout(board=self.board, players=self.players)
@ -198,7 +224,7 @@ class GameEngineFactory:
board_width: int,
board_height: int,
obstacle_count: int = 0,
ws_server: Optional[SendGameDumpInterface] = None,
ws_server: WSServer = None,
) -> GameEngine:
board = GameBoard(
width=board_width,
@ -224,7 +250,7 @@ class GameEngineFactory:
@staticmethod
def create_default(
ws_server: Optional[SendGameDumpInterface] = None,
ws_server: WSServer = None,
) -> GameEngine:
return GameEngineFactory.create(
board_width=settings.board.WIDTH,

View File

@ -1,16 +0,0 @@
from typing import Iterable, Optional, Protocol
from hopper.models.product import Product
class SendGameDumpInterface(Protocol):
async def send_game_dump(self) -> None:
...
async def send_product_purchase_message(self, products: Iterable[Product]) -> None:
...
async def send_product_purchase_done_message(
self, product: Optional[Product] = None
) -> None:
...

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
class LayerObject:
type_: ObjectType
@ -102,10 +109,3 @@ class BoardLayout:
)
)
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

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

View File

@ -15,7 +15,7 @@ class Position:
@dataclass
class Player:
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))
move_count: int = 0
move_attempt_count: int = 0
@ -31,8 +31,8 @@ class Player:
class PlayerList(list[Player]):
def find(self, uuid: str) -> Optional[Player]:
def find(self, id: str) -> Optional[Player]:
for player in self:
if player.uuid == uuid:
if player.id == id:
return player
return None

View File

@ -1,8 +0,0 @@
from dataclasses import dataclass, field
import uuid
@dataclass
class Product:
name: str
uuid: str = field(default_factory=lambda: str(uuid.uuid4()))

View File

@ -8,7 +8,6 @@ from pydantic.generics import GenericModel
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.enums import ObjectType
from hopper.models.product import Product
class LayerObjectDto(BaseModel):
@ -21,28 +20,15 @@ class LayerDto(BaseModel):
objects: list[LayerObjectDto]
class ProductDto(BaseModel):
name: str
uuid: str
class GameDumpPlayerDto(PlayerDto):
...
class GameDumpDto(BaseModel):
board: BoardDto
destination: DestinationDto
players: list[GameDumpPlayerDto]
players: list[PlayerDto]
layers: list[LayerDto]
class ProductPurchaseStartDto(BaseModel):
products: list[ProductDto]
class ProductPurchaseDoneDto(BaseModel):
product: Optional[ProductDto] = None
class PlayerReachedDestinationDto(BaseModel):
player: PlayerDto
TMessageData = TypeVar("TMessageData", bound=BaseModel)
@ -58,17 +44,25 @@ class WSMessage(GenericModel):
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 WSProductPurchaseStart(WSMessage):
message: str = "product_purchase_start"
data: ProductPurchaseStartDto
class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_selection_done"
class WSProductPurchaseDone(WSMessage):
message: str = "product_purchase_done"
data: ProductPurchaseDoneDto
class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_selection_timeout"
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -2,28 +2,26 @@ import asyncio
import datetime
import logging
import time
from threading import Thread
from typing import Optional
from threading import Thread, Event
from hopper.interfaces import SendGameDumpInterface
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: Optional[SendGameDumpInterface] = None
) -> None:
def __init__(self, players: PlayerList, ws_server: WSServer = None) -> None:
self.players = players
self.ws_server = ws_server
self.stopped = False
self.stop_event = Event()
super().__init__(daemon=True)
def run(self) -> None:
logging.info("Starting inactivity watchdog")
while not self.stopped:
while not self.stop_event.is_set():
self.cleanup_players()
time.sleep(settings.inacivity_watchdog.TICK_INTERVAL)
if not self.stop_event.is_set():
time.sleep(settings.inacivity_watchdog.TICK_INTERVAL)
def cleanup_players(self) -> None:
now = datetime.datetime.now()
@ -61,10 +59,8 @@ class InactivityWatchdog(Thread):
self.send_game_dump()
def send_game_dump(self):
if not self.ws_server:
return
logging.info("Sending WS game dump")
asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None:
self.stopped = True
self.stop_event.set()

View File

@ -1,21 +1,21 @@
import asyncio
import json
import logging
from threading import Thread
from typing import Iterable, Optional
import websockets
from websockets import WebSocketServerProtocol
from websockets.exceptions import ConnectionClosedOK
from websockets.exceptions import ConnectionClosedError, ConnectionClosedOK
from hopper.models.product import Product
from hopper.models.player import Player
from hopper.models.ws_dto import (
GameDumpDto,
ProductPurchaseDoneDto,
ProductPurchaseStartDto,
PlayerReachedDestinationDto,
WSGameDumpMessage,
WSMessage,
WSProductPurchaseDone,
WSProductPurchaseStart,
WSPlayerReachedDestinationMessage,
WSProductSelectionDoneMessage,
WSProductSelectionTimeoutMessage,
)
@ -25,6 +25,31 @@ class WSServer(Thread):
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)
@ -38,8 +63,15 @@ class WSServer(Thread):
while connected:
try:
# we're expecting nothing from client, but read if client sends a message
await websocket.recv()
rcv_data = await websocket.recv()
await self.handle_rcv_message(
client=websocket, raw_message=rcv_data
)
except ConnectionClosedOK:
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)
@ -85,16 +117,20 @@ class WSServer(Thread):
message = self._create_game_dump_message()
await self.send_message_to_clients(message)
async def send_product_purchase_message(self, products: Iterable[Product]) -> None:
message = WSProductPurchaseStart(
data=ProductPurchaseStartDto(products=products)
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_purchase_done_message(
self, product: Optional[Product] = None
) -> None:
message = WSProductPurchaseDone(data=ProductPurchaseDoneDto(product=product))
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:

51
sdk/demo.py Normal file
View File

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

View File

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