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, Settings,
WSServerSettings, WSServerSettings,
) )
from hopper.models.product import Product
settings = Settings( settings = Settings(
game=GameSettings(), game=GameSettings(),
@ -21,14 +20,6 @@ settings = Settings(
inacivity_watchdog=InactivityWatchdogSettings(), inacivity_watchdog=InactivityWatchdogSettings(),
purchase_timeout=5, 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(
HOST="0.0.0.0", HOST="0.0.0.0",
PORT=int(os.environ.get("FAIRHOPPER_WS_PORT", 8011)), PORT=int(os.environ.get("FAIRHOPPER_WS_PORT", 8011)),

View File

@ -5,9 +5,11 @@ INTERNAL_WS_PORT=8011
EXTERNAL_API_PORT=8010 EXTERNAL_API_PORT=8010
EXTERNAL_WS_PORT=8011 EXTERNAL_WS_PORT=8011
timestamp := `/bin/date "+%Y-%m-%d-%H-%M-%S"`
run: run:
@poetry run \ @ \
poetry run \
uvicorn \ uvicorn \
main:app \ main:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
@ -15,7 +17,8 @@ run:
--workers=1 --workers=1
run-dev: run-dev:
@poetry run \ @ \
poetry run \
uvicorn \ uvicorn \
main:app \ main:app \
--host 0.0.0.0 \ --host 0.0.0.0 \
@ -24,7 +27,8 @@ run-dev:
--reload --reload
create-requirements: create-requirements:
@poetry export \ @ \
poetry export \
--without-hashes \ --without-hashes \
--format=requirements.txt \ --format=requirements.txt \
> requirements.txt > requirements.txt
@ -37,15 +41,17 @@ docker-clean:
docker-build: docker-build:
@docker \ @ \
docker \
buildx build \ buildx build \
--build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \ --build-arg INTERNAL_API_PORT=$(INTERNAL_API_PORT) \
--build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \ --build-arg INTERNAL_WS_PORT=$(INTERNAL_WS_PORT) \
--tag $(CONTAINER_NAME) \ --tag $(CONTAINER_NAME):$(timestamp) \
. .
docker-run: docker-run:
@docker \ @ \
docker \
run \ run \
--publish $(EXTERNAL_API_PORT):$(INTERNAL_API_PORT) \ --publish $(EXTERNAL_API_PORT):$(INTERNAL_API_PORT) \
--publish $(EXTERNAL_WS_PORT):$(INTERNAL_WS_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 "Selection Timeout" as SelectionTimeout
state "End Player's Game" as EndPlayer state "End Player's Game" as EndPlayer
state "Lock Game" as LockGame <<end>> state "Lock Game" as LockGame <<end>>
state "End Game" as EndGame <<end>> state "Unlock game and restart" as UnlockGame <<end>>
state "Unlock game" as UnlockGame <<end>>
[*] -> StartGame [*] -> StartGame
StartGame -> MovePlayer StartGame -> MovePlayer
@ -55,9 +54,9 @@ DestinationReached --> ProductSelection
DestinationReached -> LockGame: Lock game for all other players DestinationReached -> LockGame: Lock game for all other players
ProductSelection --> ProductSelected ProductSelection --> ProductSelected
ProductSelection --> SelectionTimeout ProductSelection --> SelectionTimeout
ProductSelected --> EndGame: End game\nfor all players ProductSelected --> UnlockGame: Unlock game\nand restart
SelectionTimeout -> EndPlayer SelectionTimeout -> EndPlayer
EndPlayer --> UnlockGame: Unlock game\n for all players EndPlayer --> UnlockGame: Unlock game\nand restart
``` ```
## FairHopper Game Server ## FairHopper Game Server
@ -222,7 +221,7 @@ end
== Player reached destination == == Player reached destination ==
Game -> Game: Lock game for other players Game -> Game: Lock game for other players
activate Game activate Game #skyblue
Game -> WS: Player reached destination Game -> WS: Player reached destination
activate WS #coral activate WS #coral
WS o-> Client1: Select product WS o-> Client1: Select product
@ -232,25 +231,30 @@ deactivate Game
loop #lightyellow Product select countdown timer (60s) loop #lightyellow Product select countdown timer (60s)
Game ->o WS: Timer timeout Game ->o WS: Timer timeout
activate Game activate Game #skyblue
activate WS #coral activate WS #coral
WS o-> Client1: Cancel selection WS o-> Client1: Selection timeout
WS o-> Client2: Cancel selection WS o-> Client2: Selection timeout
deactivate WS deactivate WS
Game -> Game: Unlock game Game -> Game: Unlock game
deactivate Game deactivate Game
end 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 activate WS #coral
WS o-> Game: Product selected WS o-> Game: Product selected
activate Game activate Game #skyblue
WS o-> Client2: Product selected WS o-> Client2: Product selected
deactivate WS deactivate WS
Game -> Game: Unlock game Game -> Game: Unlock game
Game -> 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
@ -323,6 +327,7 @@ Response code:
- 403 Forbidden: Player id 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
- 423 Locked: Game locked, product selection in progress
Response body: Response body:
```json ```json
@ -400,6 +405,8 @@ Response body:
### Game state structure ### Game state structure
Direction: Game server -> Clients
Message: `game_dump` Message: `game_dump`
Data: 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: Data:
```json ```json
{ {
"player": { "player": {
"id": "test-player-pero", "id": "2e0f1a50-eaa6-4efd-b0c3-adbf7000eec2",
"name": "Pero", "name": "Joso",
"active": true, "active": true,
"position": { "position": {
"x": 10, "x": 5,
"y": 10 "y": 5
}, },
"move_count": 1, "move_count": 6,
"move_attempt_count": 1, "move_attempt_count": 6,
"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" "state": "ON_DESTINATION"
} }
} }
``` ```
### Product purchase timer done ### Product selection timeout
Message: `product_purchase_done` Direction: Game server -> Clients
Data: Message: `product_selection_timeout`
```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. 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 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 # move Mirko left

View File

@ -22,18 +22,6 @@
<h1 class="mt-1 mb-2"> <h1 class="mt-1 mb-2">
FairHopper Visualisation Client FairHopper Visualisation Client
</h1> </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">
@ -68,18 +56,6 @@
</div> </div>
</div> </div>
</div> </div>
<script>
document.querySelector(".test-button").onclick = () => {
playerReachedDestination({
player: {
name: "Pero Perić",
move_count: 123,
}
});
}
</script>
</body> </body>
</html> </html>

View File

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

View File

@ -18,7 +18,7 @@ main.main-container {
padding-bottom: 2px; padding-bottom: 2px;
} }
.flex-grid:last-of-type { .flex-grid:last-of-type {
padding-bottom: 0px; padding-bottom: 0;
} }
.cell { .cell {
@ -56,44 +56,3 @@ ul.players {
border-style: solid; border-style: solid;
border-color: darkred transparent transparent transparent; 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: 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: def on_purchase_timer_done() -> None:
logging.info("Ding ding! Product selection countdown timer timeout") logging.info("Ding ding! Product selection countdown timer timeout")
self._purchase_countdown_timer = None self._purchase_countdown_timer = None
asyncio.run( asyncio.run(self.ws_server.send_product_selection_timeout_message())
self.ws_server.send_product_selection_done_message()
)
self.game_state = GameState.RUNNING self.game_state = GameState.RUNNING
asyncio.run(self.send_game_dump()) 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 @dataclass
class LayerObject: class LayerObject:
type_: ObjectType type_: ObjectType
@ -102,10 +109,3 @@ class BoardLayout:
) )
) )
return layers 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 typing import List, Optional
from hopper.models.player import Player from hopper.models.player import Player
from hopper.models.product import Product
@dataclass @dataclass
@ -46,5 +45,4 @@ 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: List[Product] = None
debug: Optional[DebugSettings] = 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 import Field
from pydantic.generics import GenericModel from pydantic.generics import GenericModel
from hopper.api.dto import ( from hopper.api.dto import BaseModel, BoardDto, DestinationDto, PlayerDto, PositionDto
BaseModel,
BoardDto,
DestinationDto,
PlayerDto,
PositionDto,
)
from hopper.enums import ObjectType from hopper.enums import ObjectType
@ -65,6 +59,10 @@ class WSProductSelectionDoneMessage(WSMessage):
message: str = "product_selection_done" message: str = "product_selection_done"
class WSProductSelectionTimeoutMessage(WSMessage):
message: str = "product_selection_timeout"
class WSPlayerReachedDestinationMessage(WSMessage): class WSPlayerReachedDestinationMessage(WSMessage):
message: str = "player_reached_destination" message: str = "player_reached_destination"
data: PlayerReachedDestinationDto data: PlayerReachedDestinationDto

View File

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

View File

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

View File

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