11 Commits

Author SHA1 Message Date
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
10 changed files with 330 additions and 203 deletions

2
.gitignore vendored
View File

@ -4,3 +4,5 @@ __pycache__
/env /env
/.venv /.venv
/settings.py /settings.py
/requirements.txt
/frontend/js/config.js

View File

@ -14,3 +14,9 @@ run-dev:
--port 8010 \ --port 8010 \
--workers=1 \ --workers=1 \
--reload --reload
create-requirements:
@poetry export \
--without-hashes \
--format=requirements.txt \
> requirements.txt

444
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
@ -8,10 +17,10 @@
- Destination: center of a board (W / 2, H / 2) - Destination: center of a board (W / 2, H / 2)
- Initial player position: Random on board border - Initial player position: Random on board border
- Available moves: - Available moves:
- left - left
- right - right
- up - up
- down - down
- Optional on-board obstacles - Optional on-board obstacles
### Rules ### Rules
@ -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
@ -202,33 +212,33 @@ Check REST API interface on [FastAPI docs](http://localhost:8010/docs).
Request body: Request body:
```json ```json
{ {
"player_name": "Pero" "player_name": "Pero"
} }
``` ```
Response body: Response body:
```json ```json
{ {
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destination": { "destination": {
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
} }
}, },
"player": { "player": {
"id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "id": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 0, "x": 0,
"y": 10 "y": 10
}, },
"move_count": 0, "move_count": 0,
"move_attempt_count": 0 "move_attempt_count": 0
} }
} }
``` ```
@ -251,16 +261,16 @@ Response code:
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"id": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
@ -273,16 +283,16 @@ Request body: None
Response body: Response body:
```json ```json
{ {
"player": { "player": {
"id": "string", "id": "string",
"name": "Pero", "name": "Pero",
"position": { "position": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"move_count": 10, "move_count": 10,
"move_attempt_count": 12 "move_attempt_count": 12
} }
} }
``` ```
@ -293,19 +303,19 @@ GET `/game`
Response body: Response body:
```json ```json
{ {
"playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25", "playerId": "75bba7cd-a4c1-4b50-b0b5-6382c2822a25",
"board": { "board": {
"width": 101, "width": 101,
"height": 101 "height": 101
}, },
"destinationPosition": { "destinationPosition": {
"x": 50, "x": 50,
"y": 50 "y": 50
}, },
"playerPosition": { "playerPosition": {
"x": 0, "x": 0,
"y": 10 "y": 10
} }
} }
``` ```
@ -314,120 +324,222 @@ 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", {
"name": "Mirko", "id": "test-player-mirko",
"active": true, "name": "Mirko",
"position": { "active": true,
"x": 0, "position": {
"y": 0 "x": 4,
}, "y": 4
"move_count": 15, },
"move_attempt_count": 20 "move_count": 0,
} "move_attempt_count": 0,
], "state": "CREATED"
"layers": [ }
{ ],
"name": "obstacles", "layers": [
"objects": [ {
{ "name": "obstacles",
"type": "OBSTACLE", "objects": [
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 2 "position": {
} "x": 0,
}, "y": 6
{ }
"type": "OBSTACLE", },
"position": { {
"x": 4, "type": "OBSTACLE",
"y": 13 "position": {
} "x": 5,
}, "y": 1
{ }
"type": "OBSTACLE", },
"position": { {
"x": 18, "type": "OBSTACLE",
"y": 18 "position": {
} "x": 1,
}, "y": 6
{ }
"type": "OBSTACLE", }
"position": { ]
"x": 5, },
"y": 4 {
} "name": "destination",
}, "objects": [
{ {
"type": "OBSTACLE", "type": "DESTINATION",
"position": { "position": {
"x": 7, "x": 5,
"y": 10 "y": 5
} }
} }
] ]
}, },
{ {
"name": "destination", "name": "players",
"objects": [ "objects": [
{ {
"type": "DESTINATION", "type": "PLAYER",
"position": { "position": {
"x": 10, "x": 3,
"y": 10 "y": 3
} }
} },
] {
}, "type": "PLAYER",
{ "position": {
"name": "players", "x": 4,
"objects": [ "y": 4
{ }
"type": "PLAYER", }
"position": { ]
"x": 2, }
"y": 2 ]
}
},
{
"type": "PLAYER",
"position": {
"x": 0,
"y": 0
}
}
]
}
]
} }
``` ```
### 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

@ -13,10 +13,10 @@ function createBoard(board) {
colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`; colHtml += `<div class="cell" id="cell-${x}-${y}">&nbsp;</div>`;
} }
html += ` html += `
<div class="flex-grid"> <div class="flex-grid">
${colHtml} ${colHtml}
</div> </div>
`; `;
} }
document.getElementById("board-content").innerHTML = html; document.getElementById("board-content").innerHTML = html;
} }
@ -33,10 +33,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 +45,6 @@ function renderPlayerList(players) {
`; `;
}) })
.join(""); .join("");
document.getElementById("players-content").innerHTML = html;
} }
function renderPlayers(players) { function renderPlayers(players) {
@ -53,14 +52,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 +83,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 +98,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 +110,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 +119,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 +151,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 +160,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 raise HTTPException(
else: status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
raise HTTPException( )
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
@ -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