15 Commits

Author SHA1 Message Date
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
13 changed files with 459 additions and 205 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

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
FROM python:3.10.11-alpine3.17
# take arguments
ARG INTERNAL_API_PORT
ARG INTERNAL_WS_PORT
RUN \
pip install pip -U && \
pip install poetry --no-cache-dir
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
# 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,36 @@ 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 \
build . \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
-t $(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

192
README.md
View File

@ -1,5 +1,14 @@
# FairHopper # FairHopper
## Useful links
- [Frontend](https://fairhopper.mjerenja.com)
- [API](https://api.fairhopper.mjerenja.com)
- [API Docs](https://api.fairhopper.mjerenja.com/docs)
- [FairHopper](https://gitea.ekirin.com/Intis/fairhopper)
- [FairHopper SDK](https://gitea.ekirin.com/Intis/fairhopper-sdk)
- Websockets: wss://fairhopper.mjerenja.com/ws
## Game ## Game
### Overview ### Overview
@ -24,6 +33,7 @@
## Game States ## Game States
```plantuml ```plantuml
scale 1024 width
hide empty description hide empty description
state "Start Game" as StartGame state "Start Game" as StartGame
@ -314,46 +324,55 @@ 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": [
{ {
"id": "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"
}, },
{ {
"id": "95962b49-0003-4bf2-b205-71f2590f2318", "id": "test-player-mirko",
"name": "Mirko", "name": "Mirko",
"active": true, "active": true,
"position": { "position": {
"x": 0, "x": 4,
"y": 0 "y": 4
}, },
"move_count": 15, "move_count": 0,
"move_attempt_count": 20 "move_attempt_count": 0,
"state": "CREATED"
} }
], ],
"layers": [ "layers": [
@ -363,36 +382,22 @@ Data:
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 4, "x": 0,
"y": 2 "y": 6
}
},
{
"type": "OBSTACLE",
"position": {
"x": 4,
"y": 13
}
},
{
"type": "OBSTACLE",
"position": {
"x": 18,
"y": 18
} }
}, },
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 5, "x": 5,
"y": 4 "y": 1
} }
}, },
{ {
"type": "OBSTACLE", "type": "OBSTACLE",
"position": { "position": {
"x": 7, "x": 1,
"y": 10 "y": 6
} }
} }
] ]
@ -403,8 +408,8 @@ Data:
{ {
"type": "DESTINATION", "type": "DESTINATION",
"position": { "position": {
"x": 10, "x": 5,
"y": 10 "y": 5
} }
} }
] ]
@ -415,15 +420,15 @@ Data:
{ {
"type": "PLAYER", "type": "PLAYER",
"position": { "position": {
"x": 2, "x": 3,
"y": 2 "y": 3
} }
}, },
{ {
"type": "PLAYER", "type": "PLAYER",
"position": { "position": {
"x": 0, "x": 4,
"y": 0 "y": 4
} }
} }
] ]
@ -431,3 +436,110 @@ Data:
] ]
} }
``` ```
### 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.

View File

@ -8,13 +8,14 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<script src="js/config.js"></script>
<script src="js/frontend.js"></script> <script src="js/frontend.js"></script>
<title>FairHopper Visualisation Client</title> <title>FairHopper Visualisation Client</title>
</head> </head>
<body> <body>
<main class="container-fluid container"> <main class="container-fluid main-container">
<h1 class="mt-1 mb-2"> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client FairHopper Visualisation Client
</h1> </h1>

View File

@ -1,3 +1,7 @@
if (typeof FAIRHOPPER_WS_SERVER === "undefined") {
var FAIRHOPPER_WS_SERVER = "ws://127.0.0.1:8011";
}
const BOARD_ICONS = { const BOARD_ICONS = {
PLAYER: "😀", PLAYER: "😀",
PLAYER_ON_DESTINATION: "😎", PLAYER_ON_DESTINATION: "😎",
@ -33,10 +37,10 @@ function renderCellContent(position, content) {
} }
function renderPlayerList(players) { function renderPlayerList(players) {
const html = players document.getElementById("players-content").innerHTML = players
.filter((player) => player.active) .filter((player) => player.active)
.map((player) => { .map((player) => {
const onDestination = player.state == "ON_DESTINATION"; const onDestination = player.state === "ON_DESTINATION";
return ` return `
<li class="${onDestination ? "text-success" : ""}"> <li class="${onDestination ? "text-success" : ""}">
${player.name} (${player.move_count}) ${player.name} (${player.move_count})
@ -45,7 +49,6 @@ function renderPlayerList(players) {
`; `;
}) })
.join(""); .join("");
document.getElementById("players-content").innerHTML = html;
} }
function renderPlayers(players) { function renderPlayers(players) {
@ -53,14 +56,13 @@ function renderPlayers(players) {
.filter((player) => player.active) .filter((player) => player.active)
.forEach((player) => { .forEach((player) => {
const cell = findCell(player.position); const cell = findCell(player.position);
const onDestination = player.state == "ON_DESTINATION"; const onDestination = player.state === "ON_DESTINATION";
const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER; const playerIcon = onDestination ? BOARD_ICONS.PLAYER_ON_DESTINATION : BOARD_ICONS.PLAYER;
if (cell) { if (cell) {
const html = ` cell.innerHTML = `
<div class="player-tooltip">${player.name}</div> <div class="player-tooltip">${player.name}</div>
${playerIcon} ${playerIcon}
`; `;
cell.innerHTML = html;
} }
}); });
} }
@ -85,6 +87,8 @@ function renderDestination(position) {
} }
function renderGameDump(data) { function renderGameDump(data) {
closePurchaseWindow();
createBoard(data.board); createBoard(data.board);
renderObstacles(data.layers); renderObstacles(data.layers);
renderDestination(data.destination.position); renderDestination(data.destination.position);
@ -98,10 +102,10 @@ function productPurchaseStart(products, purchaseTimeout) {
const contentElement = document.getElementById("products-content"); const contentElement = document.getElementById("products-content");
const purchaseTimeoutElement = document.getElementById("purchase-countdown"); const purchaseTimeoutElement = document.getElementById("purchase-countdown");
const html = products contentElement.innerHTML = products
.map((product) => { .map((product) => {
return ` return `
<div class="card product"> <div class="card product" id="product-${product.id}">
<img src="img/products/${product.name}.jpeg" class="card-img-topx" alt="${product.name}"> <img src="img/products/${product.name}.jpeg" class="card-img-topx" alt="${product.name}">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">${product.name}</h5> <h5 class="card-title">${product.name}</h5>
@ -110,8 +114,6 @@ function productPurchaseStart(products, purchaseTimeout) {
`; `;
}) })
.join(""); .join("");
contentElement.innerHTML = html;
containerElement.classList.remove("d-none"); containerElement.classList.remove("d-none");
purchaseTimeoutElement.innerText = purchaseTimeout; purchaseTimeoutElement.innerText = purchaseTimeout;
} }
@ -121,18 +123,20 @@ function productPurchaseTimerTick(timeLeft) {
purchaseTimeoutElement.innerText = timeLeft; purchaseTimeoutElement.innerText = timeLeft;
} }
function productPurchased(product) { function closePurchaseWindow() {
console.log("productPurchased:", product);
}
function productPurchaseDone() {
console.log("productPurchaseDone");
const container = document.getElementById("purchase-container"); const container = document.getElementById("purchase-container");
container.classList.add("d-none"); container.classList.add("d-none");
} }
function productPurchaseDone(product) {
const cardContainer = document.getElementById(`product-${product.id}`);
cardContainer.classList.add("selected");
}
function wsConnect() { function wsConnect() {
let ws = new WebSocket("ws://localhost:8011"); console.log("Attempting to connect to", FAIRHOPPER_WS_SERVER);
let ws = new WebSocket(FAIRHOPPER_WS_SERVER);
ws.onopen = () => { ws.onopen = () => {
console.log("WS connected"); console.log("WS connected");
}; };
@ -151,11 +155,8 @@ function wsConnect() {
case "product_purchase_timer_tick": case "product_purchase_timer_tick":
productPurchaseTimerTick(wsMessage.data.time_left); productPurchaseTimerTick(wsMessage.data.time_left);
break; break;
case "product_purchased":
productPurchased(wsMessage.data);
break;
case "product_purchase_done": case "product_purchase_done":
productPurchaseDone(); productPurchaseDone(wsMessage.data.product);
break; break;
default: default:
console.error("Unknown message:", wsMessage); console.error("Unknown message:", wsMessage);
@ -163,7 +164,7 @@ function wsConnect() {
}; };
ws.onclose = (e) => { ws.onclose = (e) => {
setTimeout(function () { setTimeout(() => {
wsConnect(); wsConnect();
}, 1000); }, 1000);
}; };

View File

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

View File

@ -167,23 +167,22 @@ async def get_product(id: str) -> ProductDto:
) )
@router.post("/player/{id}/product/purchase") @router.post("/player/{id}/product/purchase", response_model=ProductDto)
async def purchase_product( async def purchase_product(
body: PurchaseProductDto, body: PurchaseProductDto,
engine: GameEngine = Depends(get_game_engine), engine: GameEngine = Depends(get_game_engine),
player: Player = Depends(get_player), player: Player = Depends(get_player),
): ) -> ProductDto:
for product in settings.products: for product in settings.products:
if product.id == body.product_id: if product.id == body.product_id:
try: try:
await engine.purchase_product(player=player, product=product) await engine.purchase_product(player=player, product=product)
return ProductDto.from_orm(product)
except PurchaseForbiddenForPlayer: except PurchaseForbiddenForPlayer:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Purchase forbidden for this player", detail="Purchase forbidden for this player",
) )
break
else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found" status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) )

View File

@ -212,6 +212,8 @@ class GameEngine:
) )
self._purchase_countdown_timer.start() self._purchase_countdown_timer.start()
await asyncio.sleep(settings.game.PURCHASE_START_DELAY)
async def purchase_product(self, player: Player, product: Product) -> None: async def purchase_product(self, player: Player, product: Product) -> None:
if not player.state == PlayerState.ON_DESTINATION: if not player.state == PlayerState.ON_DESTINATION:
raise PurchaseForbiddenForPlayer() raise PurchaseForbiddenForPlayer()
@ -220,6 +222,7 @@ class GameEngine:
await self.ws_server.send_product_purchase_done_message( await self.ws_server.send_product_purchase_done_message(
player=player, product=product player=player, product=product
) )
await asyncio.sleep(settings.game.PURCHASE_FINISHED_DELAY)
await self.reset_game() await self.reset_game()
def _reset_player(self, player) -> None: def _reset_player(self, player) -> None:

View File

@ -9,6 +9,8 @@ from hopper.models.product import Product
@dataclass @dataclass
class GameSettings: class GameSettings:
MOVE_DELAY: float = 0.5 # seconds MOVE_DELAY: float = 0.5 # seconds
PURCHASE_START_DELAY: float = 2 # seconds
PURCHASE_FINISHED_DELAY: float = 2 # seconds
@dataclass @dataclass
@ -27,7 +29,7 @@ class InactivityWatchdogSettings:
@dataclass @dataclass
class WSServerSettings: class WSServerSettings:
HOST: str = "localhost" HOST: str = "127.0.0.1"
PORT: int = 8011 PORT: int = 8011
@ -45,5 +47,5 @@ class Settings:
ws_server: WSServerSettings ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO log_level: int = logging.INFO
products: Optional[List[Product]] = None products: List[Product] = None
debug: Optional[DebugSettings] = None debug: Optional[DebugSettings] = None