9 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
15 changed files with 91 additions and 236 deletions

View File

@ -9,7 +9,6 @@ from hopper.models.config import (
Settings,
WSServerSettings,
)
from hopper.models.product import Product
settings = Settings(
game=GameSettings(),
@ -21,14 +20,6 @@ settings = Settings(
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)),

View File

@ -5,9 +5,11 @@ 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 \
@ -15,7 +17,8 @@ run:
--workers=1
run-dev:
@poetry run \
@ \
poetry run \
uvicorn \
main:app \
--host 0.0.0.0 \
@ -24,7 +27,8 @@ run-dev:
--reload
create-requirements:
@poetry export \
@ \
poetry export \
--without-hashes \
--format=requirements.txt \
> requirements.txt
@ -37,15 +41,17 @@ docker-clean:
docker-build:
@docker \
@ \
docker \
buildx build \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
--tag $(CONTAINER_NAME) \
--tag $(CONTAINER_NAME):$(timestamp) \
.
docker-run:
@docker \
@ \
docker \
run \
--publish $(EXTERNAL_API_PORT):$(INTERNAL_API_PORT) \
--publish $(EXTERNAL_WS_PORT):$(INTERNAL_WS_PORT) \

140
README.md
View File

@ -44,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
@ -55,9 +54,9 @@ 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
@ -222,7 +221,7 @@ end
== Player reached destination ==
Game -> Game: Lock game for other players
activate Game
activate Game #skyblue
Game -> WS: Player reached destination
activate WS #coral
WS o-> Client1: Select product
@ -232,25 +231,30 @@ deactivate Game
loop #lightyellow Product select countdown timer (60s)
Game ->o WS: Timer timeout
activate Game
activate Game #skyblue
activate WS #coral
WS o-> Client1: Cancel selection
WS o-> Client2: Cancel selection
WS o-> Client1: Selection timeout
WS o-> Client2: Selection timeout
deactivate WS
Game -> Game: Unlock game
deactivate Game
end
Client1 -> WS: Product selected
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
activate Game #skyblue
WS o-> Client2: Product selected
deactivate WS
Game -> Game: Unlock game
Game -> WS: Game state
Game ->o WS: Game state
activate WS #coral
WS o-> Client1: Game state
WS o-> Client2: Game state
@ -323,6 +327,7 @@ Response code:
- 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
@ -400,6 +405,8 @@ Response body:
### Game state structure
Direction: Game server -> Clients
Message: `game_dump`
Data:
@ -503,109 +510,42 @@ Data:
}
```
### Product purchase start
### Player reached destination
Message: `product_purchase_start`
Direction: Game server -> Clients
Message: `player_reached_destination`
Data:
```json
{
"player": {
"id": "test-player-pero",
"name": "Pero",
"id": "2e0f1a50-eaa6-4efd-b0c3-adbf7000eec2",
"name": "Joso",
"active": true,
"position": {
"x": 10,
"y": 10
"x": 5,
"y": 5
},
"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,
"move_count": 6,
"move_attempt_count": 6,
"state": "ON_DESTINATION"
}
}
```
### Product purchase timer done
### Product selection timeout
Message: `product_purchase_done`
Direction: Game server -> Clients
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
}
}
```
Message: `product_selection_timeout`
If product selection timeout occured, product will be null.
Data: `null`
### Product selection done
Message: `product_selection_done`
Direction: Client -> Game server, Game server -> Clients
Data: `null`

View File

@ -34,13 +34,6 @@ POST http://localhost:8010/player/test-player-pero/move/up
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

View File

@ -22,18 +22,6 @@
<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="col-10">
<div class="board-container">
@ -68,18 +56,6 @@
</div>
</div>
</div>
<script>
document.querySelector(".test-button").onclick = () => {
playerReachedDestination({
player: {
name: "Pero Perić",
move_count: 123,
}
});
}
</script>
</body>
</html>

View File

@ -104,6 +104,10 @@ function playerReachedDestination(data) {
playerOnDestinationModal.show();
}
function productSelectionTimeout() {
playerOnDestinationModal.hide();
}
function productSelectionDone() {
playerOnDestinationModal.hide();
}
@ -127,6 +131,9 @@ function wsConnect() {
case "player_reached_destination":
playerReachedDestination(wsMessage.data);
break;
case "product_selection_timeout":
productSelectionTimeout();
break;
case "product_selection_done":
productSelectionDone();
break;

View File

@ -18,7 +18,7 @@ main.main-container {
padding-bottom: 2px;
}
.flex-grid:last-of-type {
padding-bottom: 0px;
padding-bottom: 0;
}
.cell {
@ -56,44 +56,3 @@ ul.players {
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

@ -179,14 +179,14 @@ class GameEngine:
)
def on_purchase_timer_tick(time_left) -> None:
logging.info(f"Product selection countdown timer tick, time left: {time_left}")
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_done_message()
)
asyncio.run(self.ws_server.send_product_selection_timeout_message())
self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump())

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,7 +3,6 @@ from dataclasses import dataclass
from typing import List, Optional
from hopper.models.player import Player
from hopper.models.product import Product
@dataclass
@ -46,5 +45,4 @@ class Settings:
ws_server: WSServerSettings
purchase_timeout: int = 10 # seconds
log_level: int = logging.INFO
products: List[Product] = None
debug: Optional[DebugSettings] = None

View File

@ -1,10 +0,0 @@
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

@ -6,13 +6,7 @@ from typing import Optional, TypeVar
from pydantic import Field
from pydantic.generics import GenericModel
from hopper.api.dto import (
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
)
from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
from hopper.enums import ObjectType
@ -65,6 +59,10 @@ class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_selection_done"
class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_selection_timeout"
class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination"
data: PlayerReachedDestinationDto

View File

@ -2,7 +2,7 @@ import asyncio
import datetime
import logging
import time
from threading import Thread
from threading import Thread, Event
from hopper.models.player import PlayerList
from hopper.ws_server import WSServer
@ -10,19 +10,18 @@ from settings import settings
class InactivityWatchdog(Thread):
def __init__(
self, players: PlayerList, ws_server: WSServer = 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()
@ -64,4 +63,4 @@ class InactivityWatchdog(Thread):
asyncio.run(self.ws_server.send_game_dump())
def stop(self) -> None:
self.stopped = True
self.stop_event.set()

View File

@ -15,6 +15,7 @@ from hopper.models.ws_dto import (
WSMessage,
WSPlayerReachedDestinationMessage,
WSProductSelectionDoneMessage,
WSProductSelectionTimeoutMessage,
)
@ -63,7 +64,9 @@ class WSServer(Thread):
try:
# we're expecting nothing from client, but read if client sends a message
rcv_data = await websocket.recv()
await self.handle_rcv_message(client=websocket, raw_message=rcv_data)
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
@ -126,6 +129,10 @@ class WSServer(Thread):
message = WSProductSelectionDoneMessage()
await self.send_message_to_clients(message)
async def send_product_selection_timeout_message(self) -> None:
message = WSProductSelectionTimeoutMessage()
await self.send_message_to_clients(message)
async def run_async(self) -> None:
logging.info(
f"Starting FairHopper Websockets Server on {self.host}:{self.port}"

View File

@ -9,7 +9,6 @@ from hopper.models.config import (
WSServerSettings,
)
from hopper.models.player import Player, Position
from hopper.models.product import Product
settings = Settings(
game=GameSettings(),
@ -21,14 +20,6 @@ settings = Settings(
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(),
debug=DebugSettings(
PRINT_BOARD=True,