Add hand-end scoring summary screen with acknowledgement
After each hand of an unfinished match the game now pauses in a new
hand_end phase instead of dealing immediately:
- engine: hand_points gains an 'award' map (which team won each category),
_end_hand stops at hand_end with a deadline, new acknowledge_hand deals
the next hand once all four players have acked; plays are rejected while
the summary is up
- state: acked seats, hand_end_deadline and hand_ack_timeout are persisted
and exposed in the personalized view (also on the finished state, so the
final hand is explained before the result)
- ws: new {"action": "ack"}; a per-hand timer force-deals the next hand
after HAND_ACK_TIMEOUT_SECONDS (new env var, default 30s) so an away
player cannot stall the match
- web: modal explaining each category in plain language with icons (card
images for denara/settebello/primiera), team-coloured rows, running
totals with progress bars, an 'Understood — next hand' button that turns
into 'Waiting for …' plus an auto-continue countdown; the final screen
shows the last hand's breakdown too
Verified in the browser against the compose stack: hand played to
completion, summary rendered (including a carte tie), ack from all four
players dealt the next hand live, and the auto-continue path fired when
nobody acked. 60 backend tests + mypy + cargo tests green.
This commit is contained in:
@@ -28,6 +28,15 @@ Because both the browser and the app talk to the OIDC issuer at
|
|||||||
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Between hands
|
||||||
|
|
||||||
|
When a hand ends but the match is not decided, the game pauses on a
|
||||||
|
**scoring summary screen**: every player sees how each category was won
|
||||||
|
(carte, denara, settebello, primiera, scope) with the running totals and
|
||||||
|
must click "Understood" before the next hand is dealt. If someone is away
|
||||||
|
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||||
|
(default 30s). The match-ending hand is explained on the final screen.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Backend (from `server/`):
|
Backend (from `server/`):
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ services:
|
|||||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-dev-secret}
|
||||||
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-http://localhost:8080/auth/callback}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
|
HAND_ACK_TIMEOUT_SECONDS: ${HAND_ACK_TIMEOUT_SECONDS:-30}
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
- "127.0.0.1:${APP_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ REDIS_URL=redis://localhost:6379/0
|
|||||||
# How long a live game survives in Redis without activity.
|
# How long a live game survives in Redis without activity.
|
||||||
GAME_TTL_SECONDS=86400
|
GAME_TTL_SECONDS=86400
|
||||||
|
|
||||||
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
|
# before dealing the next hand anyway.
|
||||||
|
HAND_ACK_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# App server
|
# App server
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
APP_PORT=8080
|
APP_PORT=8080
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ All configuration comes from environment variables (see `.env.example`):
|
|||||||
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
| `OIDC_CLIENT_SECRET` | unset | OIDC client secret |
|
||||||
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
| `OIDC_REDIRECT_URI` | `http://localhost:8080/auth/callback` | Login callback URL |
|
||||||
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
| `GAME_TTL_SECONDS` | `86400` | Sliding TTL of a live game in Redis |
|
||||||
|
| `HAND_ACK_TIMEOUT_SECONDS` | `30` | Seconds the between-hands scoring summary waits for acknowledgements |
|
||||||
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
| `APP_HOST` / `APP_PORT` | `0.0.0.0` / `8080` | Bind address |
|
||||||
|
|
||||||
## Data model
|
## Data model
|
||||||
@@ -109,6 +110,7 @@ Client → server messages:
|
|||||||
```json
|
```json
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
{"action": "play", "card": "07D"}
|
{"action": "play", "card": "07D"}
|
||||||
|
{"action": "ack"}
|
||||||
{"action": "state"}
|
{"action": "state"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -117,10 +119,23 @@ Client → server messages:
|
|||||||
settebello).
|
settebello).
|
||||||
- `capture` lists the table cards to take. When a capture is legal it is
|
- `capture` lists the table cards to take. When a capture is legal it is
|
||||||
mandatory to provide one; when no capture exists it must be omitted.
|
mandatory to provide one; when no capture exists it must be omitted.
|
||||||
|
- `ack` acknowledges the hand-end scoring summary (see below). The next
|
||||||
|
hand is dealt once all four players have acknowledged, or automatically
|
||||||
|
after `HAND_ACK_TIMEOUT_SECONDS`.
|
||||||
- `state` asks for a fresh snapshot.
|
- `state` asks for a fresh snapshot.
|
||||||
|
|
||||||
After every accepted move the new state is broadcast to all four players.
|
After every accepted move the new state is broadcast to all four players.
|
||||||
|
|
||||||
|
### Hand-end summary
|
||||||
|
|
||||||
|
When a hand finishes but the match continues, the game enters the
|
||||||
|
`hand_end` phase instead of dealing immediately: the state carries
|
||||||
|
`last_hand` (a full scoring breakdown with an `award` map naming the team
|
||||||
|
that won each category), the `acknowledged` seats and a
|
||||||
|
`hand_end_deadline`. The frontend renders this as a screen every player
|
||||||
|
must dismiss. A play attempted in this phase is rejected with an
|
||||||
|
`illegal_move` error.
|
||||||
|
|
||||||
## Rules implemented
|
## Rules implemented
|
||||||
|
|
||||||
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
- 40-card Italian deck, ten cards per player, empty table at hand start.
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class Settings:
|
|||||||
# Directory holding the compiled frontend (trunk's dist output),
|
# Directory holding the compiled frontend (trunk's dist output),
|
||||||
# served for every path that is not under /api or /auth.
|
# served for every path that is not under /api or /auth.
|
||||||
static_dir: str
|
static_dir: str
|
||||||
|
# Seconds the between-hands scoring summary waits for acknowledgements
|
||||||
|
# before dealing the next hand anyway.
|
||||||
|
hand_ack_timeout_seconds: int
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_env() -> "Settings":
|
def from_env() -> "Settings":
|
||||||
@@ -59,6 +62,7 @@ class Settings:
|
|||||||
redis_url=os.environ.get("REDIS_URL"),
|
redis_url=os.environ.get("REDIS_URL"),
|
||||||
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
game_ttl_seconds=int(_env("GAME_TTL_SECONDS", "86400")),
|
||||||
static_dir=_env("STATIC_DIR", "web/dist"),
|
static_dir=_env("STATIC_DIR", "web/dist"),
|
||||||
|
hand_ack_timeout_seconds=int(_env("HAND_ACK_TIMEOUT_SECONDS", "30")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Rules implemented
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
from typing import Dict, List, Optional, Sequence, Tuple
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
@@ -43,6 +43,7 @@ from .errors import (
|
|||||||
from .state import (
|
from .state import (
|
||||||
DEFAULT_TARGET_SCORE,
|
DEFAULT_TARGET_SCORE,
|
||||||
PHASE_FINISHED,
|
PHASE_FINISHED,
|
||||||
|
PHASE_HAND_END,
|
||||||
PHASE_LOBBY,
|
PHASE_LOBBY,
|
||||||
PHASE_PLAYING,
|
PHASE_PLAYING,
|
||||||
SUITS,
|
SUITS,
|
||||||
@@ -58,6 +59,11 @@ from .state import (
|
|||||||
HAND_SIZE = 10
|
HAND_SIZE = 10
|
||||||
PLAYERS = 4
|
PLAYERS = 4
|
||||||
|
|
||||||
|
# Default seconds the hand-end summary waits before dealing anyway. Games
|
||||||
|
# carry their own copy in ``GameState.hand_ack_timeout`` (configurable via
|
||||||
|
# the HAND_ACK_TIMEOUT_SECONDS environment variable).
|
||||||
|
DEFAULT_HAND_ACK_TIMEOUT_SECONDS = 30
|
||||||
|
|
||||||
# Primiera card values: sevens are best, then sixes, then aces, then the
|
# Primiera card values: sevens are best, then sixes, then aces, then the
|
||||||
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
# remaining ranks in descending order. All of 8/9/10 are worth 10.
|
||||||
PRIMIERA_VALUES: Dict[int, int] = {
|
PRIMIERA_VALUES: Dict[int, int] = {
|
||||||
@@ -117,6 +123,7 @@ def create_game(
|
|||||||
creator_sub: str,
|
creator_sub: str,
|
||||||
creator_name: str,
|
creator_name: str,
|
||||||
target_score: int = DEFAULT_TARGET_SCORE,
|
target_score: int = DEFAULT_TARGET_SCORE,
|
||||||
|
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||||
) -> GameState:
|
) -> GameState:
|
||||||
"""Create a lobby game with the creator seated first."""
|
"""Create a lobby game with the creator seated first."""
|
||||||
if target_score < 1 or target_score > 100:
|
if target_score < 1 or target_score > 100:
|
||||||
@@ -128,6 +135,7 @@ def create_game(
|
|||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
phase=PHASE_LOBBY,
|
phase=PHASE_LOBBY,
|
||||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||||
|
hand_ack_timeout=hand_ack_timeout,
|
||||||
created_at=datetime.now(timezone.utc).isoformat(),
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,6 +201,8 @@ def play(
|
|||||||
"""
|
"""
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
raise GameFinished("the match is over")
|
raise GameFinished("the match is over")
|
||||||
|
if state.phase == PHASE_HAND_END:
|
||||||
|
raise IllegalMove("the hand is over; acknowledge the summary to continue")
|
||||||
if state.phase != PHASE_PLAYING:
|
if state.phase != PHASE_PLAYING:
|
||||||
raise GameNotStarted("the game has not started yet")
|
raise GameNotStarted("the game has not started yet")
|
||||||
|
|
||||||
@@ -260,7 +270,14 @@ def _match_option(
|
|||||||
|
|
||||||
|
|
||||||
def _end_hand(state: GameState) -> None:
|
def _end_hand(state: GameState) -> None:
|
||||||
"""Sweep the table, score the hand and either deal again or finish."""
|
"""Sweep the table and score the hand.
|
||||||
|
|
||||||
|
If the match continues, the game pauses in the ``hand_end`` phase so
|
||||||
|
every player can read the scoring summary; the next hand is dealt by
|
||||||
|
:func:`acknowledge_hand` once all four players have acknowledged (or by
|
||||||
|
the hand-end timeout in the websocket layer). If the match is over the
|
||||||
|
game goes to ``finished`` immediately.
|
||||||
|
"""
|
||||||
if state.table and state.last_taker is not None:
|
if state.table and state.last_taker is not None:
|
||||||
taker = _player_at(state, state.last_taker)
|
taker = _player_at(state, state.last_taker)
|
||||||
taker.captured.extend(state.table)
|
taker.captured.extend(state.table)
|
||||||
@@ -282,8 +299,37 @@ def _end_hand(state: GameState) -> None:
|
|||||||
state.finished_at = datetime.now(timezone.utc).isoformat()
|
state.finished_at = datetime.now(timezone.utc).isoformat()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Pause for the scoring summary instead of dealing immediately.
|
||||||
|
state.phase = PHASE_HAND_END
|
||||||
|
state.acked = []
|
||||||
|
deadline = datetime.now(timezone.utc) + timedelta(seconds=state.hand_ack_timeout)
|
||||||
|
state.hand_end_deadline = deadline.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def acknowledge_hand(state: GameState, sub: str) -> None:
|
||||||
|
"""Record that ``sub`` has read the hand-end summary.
|
||||||
|
|
||||||
|
When all four players have acknowledged, the next hand is dealt.
|
||||||
|
Acknowledging twice is a no-op; acknowledging outside the ``hand_end``
|
||||||
|
phase raises an error.
|
||||||
|
"""
|
||||||
|
if state.phase != PHASE_HAND_END:
|
||||||
|
raise IllegalMove("no hand summary is waiting for acknowledgement")
|
||||||
|
player = state.player_for(sub)
|
||||||
|
if player is None:
|
||||||
|
raise NotYourTurn("you are not seated in this game")
|
||||||
|
if player.seat in state.acked:
|
||||||
|
return
|
||||||
|
state.acked.append(player.seat)
|
||||||
|
if len(state.acked) < PLAYERS:
|
||||||
|
return
|
||||||
|
|
||||||
state.hand_number += 1
|
state.hand_number += 1
|
||||||
state.dealer = (state.dealer + 1) % PLAYERS
|
state.dealer = (state.dealer + 1) % PLAYERS
|
||||||
|
state.acked = []
|
||||||
|
state.hand_end_deadline = None
|
||||||
|
state.last_move = None
|
||||||
|
state.phase = PHASE_PLAYING
|
||||||
_deal_hand(state)
|
_deal_hand(state)
|
||||||
|
|
||||||
|
|
||||||
@@ -308,26 +354,41 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
|||||||
scope[player.team] += player.scope
|
scope[player.team] += player.scope
|
||||||
|
|
||||||
points = [0, 0]
|
points = [0, 0]
|
||||||
|
award: Dict[str, Optional[str]] = {}
|
||||||
# Carte: most captured cards. Ties award nothing.
|
# Carte: most captured cards. Ties award nothing.
|
||||||
cards = [len(piles[0]), len(piles[1])]
|
cards = [len(piles[0]), len(piles[1])]
|
||||||
if cards[0] != cards[1]:
|
if cards[0] != cards[1]:
|
||||||
points[0 if cards[0] > cards[1] else 1] += 1
|
winner = 0 if cards[0] > cards[1] else 1
|
||||||
|
points[winner] += 1
|
||||||
|
award["carte"] = TEAM_NAMES[winner]
|
||||||
|
else:
|
||||||
|
award["carte"] = None
|
||||||
# Denara: most diamond cards. Ties award nothing.
|
# Denara: most diamond cards. Ties award nothing.
|
||||||
coins = [
|
coins = [
|
||||||
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
|
sum(1 for c in piles[t] if c.suit == "D") for t in (0, 1)
|
||||||
]
|
]
|
||||||
if coins[0] != coins[1]:
|
if coins[0] != coins[1]:
|
||||||
points[0 if coins[0] > coins[1] else 1] += 1
|
winner = 0 if coins[0] > coins[1] else 1
|
||||||
# Settebello: the 7 of diamonds.
|
points[winner] += 1
|
||||||
|
award["denara"] = TEAM_NAMES[winner]
|
||||||
|
else:
|
||||||
|
award["denara"] = None
|
||||||
|
# Settebello: the 7 of diamonds always belongs to someone.
|
||||||
settebello = [
|
settebello = [
|
||||||
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
|
any(c.rank == 7 and c.suit == "D" for c in piles[t]) for t in (0, 1)
|
||||||
]
|
]
|
||||||
if settebello[0] != settebello[1]:
|
if settebello[0] != settebello[1]:
|
||||||
points[0 if settebello[0] else 1] += 1
|
winner = 0 if settebello[0] else 1
|
||||||
|
points[winner] += 1
|
||||||
|
award["settebello"] = TEAM_NAMES[winner]
|
||||||
# Primiera: highest value, only if the team holds all four suits.
|
# Primiera: highest value, only if the team holds all four suits.
|
||||||
primiera = [primiera_score(piles[t]) for t in (0, 1)]
|
primiera = [primiera_score(piles[t]) for t in (0, 1)]
|
||||||
if primiera[0] != primiera[1]:
|
if primiera[0] != primiera[1]:
|
||||||
points[0 if primiera[0] > primiera[1] else 1] += 1
|
winner = 0 if primiera[0] > primiera[1] else 1
|
||||||
|
points[winner] += 1
|
||||||
|
award["primiera"] = TEAM_NAMES[winner]
|
||||||
|
else:
|
||||||
|
award["primiera"] = None
|
||||||
# Scope: one point each.
|
# Scope: one point each.
|
||||||
points[0] += scope[0]
|
points[0] += scope[0]
|
||||||
points[1] += scope[1]
|
points[1] += scope[1]
|
||||||
@@ -338,6 +399,7 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]:
|
|||||||
"settebello": {"A": settebello[0], "B": settebello[1]},
|
"settebello": {"A": settebello[0], "B": settebello[1]},
|
||||||
"primiera": {"A": primiera[0], "B": primiera[1]},
|
"primiera": {"A": primiera[0], "B": primiera[1]},
|
||||||
"scope": {"A": scope[0], "B": scope[1]},
|
"scope": {"A": scope[0], "B": scope[1]},
|
||||||
|
"award": award,
|
||||||
}
|
}
|
||||||
return points, details
|
return points, details
|
||||||
|
|
||||||
@@ -380,6 +442,8 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]:
|
|||||||
"players": players,
|
"players": players,
|
||||||
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
"last_hand": state.hand_scores[-1] if state.hand_scores else None,
|
||||||
"last_move": state.last_move.to_json() if state.last_move else None,
|
"last_move": state.last_move.to_json() if state.last_move else None,
|
||||||
|
"acknowledged": list(state.acked),
|
||||||
|
"hand_end_deadline": state.hand_end_deadline,
|
||||||
}
|
}
|
||||||
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
if viewer is not None and state.phase == PHASE_PLAYING and viewer.seat == state.turn:
|
||||||
payload["your_turn"] = True
|
payload["your_turn"] = True
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ TEAM_NAMES = ("A", "B")
|
|||||||
|
|
||||||
PHASE_LOBBY = "lobby"
|
PHASE_LOBBY = "lobby"
|
||||||
PHASE_PLAYING = "playing"
|
PHASE_PLAYING = "playing"
|
||||||
|
# Between hands of an unfinished match: scoring summary shown to every
|
||||||
|
# player; the next hand is dealt once all four acknowledge (or the
|
||||||
|
# hand-end timeout elapses).
|
||||||
|
PHASE_HAND_END = "hand_end"
|
||||||
PHASE_FINISHED = "finished"
|
PHASE_FINISHED = "finished"
|
||||||
|
|
||||||
DEFAULT_TARGET_SCORE = 11
|
DEFAULT_TARGET_SCORE = 11
|
||||||
@@ -162,6 +166,12 @@ class GameState:
|
|||||||
finished_at: Optional[str] = None
|
finished_at: Optional[str] = None
|
||||||
# The most recent play in the current hand, for move announcements.
|
# The most recent play in the current hand, for move announcements.
|
||||||
last_move: Optional[Move] = None
|
last_move: Optional[Move] = None
|
||||||
|
# While phase == "hand_end": seats that acknowledged the summary, and
|
||||||
|
# when the auto-continue timeout fires.
|
||||||
|
acked: List[int] = field(default_factory=list)
|
||||||
|
hand_end_deadline: Optional[str] = None
|
||||||
|
# Seconds the hand-end summary waits before dealing anyway.
|
||||||
|
hand_ack_timeout: int = 30
|
||||||
|
|
||||||
# -- serialization ----------------------------------------------------
|
# -- serialization ----------------------------------------------------
|
||||||
|
|
||||||
@@ -185,6 +195,9 @@ class GameState:
|
|||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"finished_at": self.finished_at,
|
"finished_at": self.finished_at,
|
||||||
"last_move": self.last_move.to_json() if self.last_move else None,
|
"last_move": self.last_move.to_json() if self.last_move else None,
|
||||||
|
"acked": list(self.acked),
|
||||||
|
"hand_end_deadline": self.hand_end_deadline,
|
||||||
|
"hand_ack_timeout": self.hand_ack_timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -208,6 +221,9 @@ class GameState:
|
|||||||
created_at=data.get("created_at"),
|
created_at=data.get("created_at"),
|
||||||
finished_at=data.get("finished_at"),
|
finished_at=data.get("finished_at"),
|
||||||
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
last_move=Move.from_json(data["last_move"]) if data.get("last_move") else None,
|
||||||
|
acked=[int(s) for s in data.get("acked", [])],
|
||||||
|
hand_end_deadline=data.get("hand_end_deadline"),
|
||||||
|
hand_ack_timeout=int(data.get("hand_ack_timeout", 30)),
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- helpers ----------------------------------------------------------
|
# -- helpers ----------------------------------------------------------
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from kaya.openapi import operation
|
|||||||
from .. import auth
|
from .. import auth
|
||||||
from ..app import app, game_store, oidc_mixin
|
from ..app import app, game_store, oidc_mixin
|
||||||
from ..auth import require_auth
|
from ..auth import require_auth
|
||||||
|
from ..config import settings
|
||||||
from ..game import engine
|
from ..game import engine
|
||||||
from ..game.errors import GameError
|
from ..game.errors import GameError
|
||||||
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
from ..game.state import DEFAULT_TARGET_SCORE, PHASE_LOBBY, GameState
|
||||||
@@ -103,6 +104,7 @@ async def create_game(ctx: HttpContext) -> None:
|
|||||||
creator_sub=user.sub,
|
creator_sub=user.sub,
|
||||||
creator_name=auth.display_name(user),
|
creator_name=auth.display_name(user),
|
||||||
target_score=target_score,
|
target_score=target_score,
|
||||||
|
hand_ack_timeout=settings.hand_ack_timeout_seconds,
|
||||||
)
|
)
|
||||||
except GameError as exc:
|
except GameError as exc:
|
||||||
await send_error(ctx, 400, str(exc))
|
await send_error(ctx, 400, str(exc))
|
||||||
|
|||||||
+59
-2
@@ -16,11 +16,14 @@ Client -> server messages are JSON objects::
|
|||||||
|
|
||||||
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
|
||||||
{"action": "play", "card": "07D"}
|
{"action": "play", "card": "07D"}
|
||||||
|
{"action": "ack"}
|
||||||
{"action": "state"}
|
{"action": "state"}
|
||||||
|
|
||||||
``capture`` lists the table cards to take and must be a legal capture when
|
``capture`` lists the table cards to take and must be a legal capture when
|
||||||
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
|
one exists (see :func:`scopa.game.engine.legal_captures`); it is omitted
|
||||||
when the played card cannot capture.
|
when the played card cannot capture. ``ack`` acknowledges the hand-end
|
||||||
|
scoring summary; the next hand is dealt when all four players have
|
||||||
|
acknowledged or the timeout fires.
|
||||||
|
|
||||||
Mutations run under the per-game lock; after a successful move the new
|
Mutations run under the per-game lock; after a successful move the new
|
||||||
state is saved to Redis and a change signal is published. Every connected
|
state is saved to Redis and a change signal is published. Every connected
|
||||||
@@ -40,7 +43,7 @@ from . import auth
|
|||||||
from .app import app, game_store
|
from .app import app, game_store
|
||||||
from .game import engine
|
from .game import engine
|
||||||
from .game.errors import GameError
|
from .game.errors import GameError
|
||||||
from .game.state import PHASE_FINISHED, GameState
|
from .game.state import PHASE_FINISHED, PHASE_HAND_END, GameState
|
||||||
from .stats import save_match_result
|
from .stats import save_match_result
|
||||||
|
|
||||||
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
Send = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
@@ -132,6 +135,8 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
|||||||
action = data.get("action")
|
action = data.get("action")
|
||||||
if action == "play":
|
if action == "play":
|
||||||
await _handle_play(send, game_id, sub, data)
|
await _handle_play(send, game_id, sub, data)
|
||||||
|
elif action == "ack":
|
||||||
|
await _handle_ack(send, game_id, sub)
|
||||||
elif action in ("state", "sync"):
|
elif action in ("state", "sync"):
|
||||||
state = await game_store.load(game_id)
|
state = await game_store.load(game_id)
|
||||||
if state is not None:
|
if state is not None:
|
||||||
@@ -140,6 +145,56 @@ async def _handle_message(send: Send, game_id: str, sub: str, raw: str) -> None:
|
|||||||
await send(_error(f"unknown action: {action!r}"))
|
await send(_error(f"unknown action: {action!r}"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- hand-end acknowledgement ------------------------------------------------
|
||||||
|
|
||||||
|
# Running auto-continue timers, keyed by (game_id, hand_number), so a hand's
|
||||||
|
# timeout is scheduled only once even when several clients are connected.
|
||||||
|
_hand_end_timers: Dict[tuple, asyncio.Task] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_ack(send: Send, game_id: str, sub: str) -> None:
|
||||||
|
async with game_store.lock(game_id):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if state is None:
|
||||||
|
await send(_error("game not found", code="not_found"))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
engine.acknowledge_hand(state, sub)
|
||||||
|
except GameError as exc:
|
||||||
|
await send(_error(str(exc), code="illegal_move"))
|
||||||
|
return
|
||||||
|
await game_store.save(state)
|
||||||
|
await game_store.publish(game_id)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_hand_end_timer(game_id: str, hand_number: int, timeout: int) -> None:
|
||||||
|
"""Deal the next hand after the acknowledgement timeout, even if not
|
||||||
|
everyone has clicked. Fizzles if the hand already advanced."""
|
||||||
|
key = (game_id, hand_number)
|
||||||
|
if key in _hand_end_timers:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _auto_advance() -> None:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(timeout)
|
||||||
|
async with game_store.lock(game_id):
|
||||||
|
state = await game_store.load(game_id)
|
||||||
|
if (
|
||||||
|
state is None
|
||||||
|
or state.phase != engine.PHASE_HAND_END
|
||||||
|
or state.hand_number != hand_number
|
||||||
|
):
|
||||||
|
return
|
||||||
|
for player in state.players:
|
||||||
|
engine.acknowledge_hand(state, player.sub)
|
||||||
|
await game_store.save(state)
|
||||||
|
await game_store.publish(game_id)
|
||||||
|
finally:
|
||||||
|
_hand_end_timers.pop(key, None)
|
||||||
|
|
||||||
|
_hand_end_timers[key] = asyncio.create_task(_auto_advance())
|
||||||
|
|
||||||
|
|
||||||
async def _handle_play(
|
async def _handle_play(
|
||||||
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
send: Send, game_id: str, sub: str, data: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -171,5 +226,7 @@ async def _handle_play(
|
|||||||
|
|
||||||
if state.phase == PHASE_FINISHED:
|
if state.phase == PHASE_FINISHED:
|
||||||
await save_match_result(state)
|
await save_match_result(state)
|
||||||
|
elif state.phase == PHASE_HAND_END:
|
||||||
|
schedule_hand_end_timer(game_id, state.hand_number, state.hand_ack_timeout)
|
||||||
await game_store.save(state)
|
await game_store.save(state)
|
||||||
await game_store.publish(game_id)
|
await game_store.publish(game_id)
|
||||||
|
|||||||
@@ -297,6 +297,10 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
|
|
||||||
moves = 0
|
moves = 0
|
||||||
while state.phase != PHASE_FINISHED and moves < 200000:
|
while state.phase != PHASE_FINISHED and moves < 200000:
|
||||||
|
if state.phase == "hand_end":
|
||||||
|
for p in state.players:
|
||||||
|
engine.acknowledge_hand(state, p.sub)
|
||||||
|
continue
|
||||||
player = next(p for p in state.players if p.seat == state.turn)
|
player = next(p for p in state.players if p.seat == state.turn)
|
||||||
played = player.hand[0]
|
played = player.hand[0]
|
||||||
options = engine.legal_captures(state.table, played)
|
options = engine.legal_captures(state.table, played)
|
||||||
@@ -313,5 +317,78 @@ class MatchFlowTest(unittest.TestCase):
|
|||||||
self.assertTrue(state.finished_at)
|
self.assertTrue(state.finished_at)
|
||||||
|
|
||||||
|
|
||||||
|
class HandEndAckTest(unittest.TestCase):
|
||||||
|
def _hand_end_state(self) -> GameState:
|
||||||
|
"""Drive a game into the hand_end phase with a one-card hand."""
|
||||||
|
state = make_state([["02D"], [], [], []],
|
||||||
|
table=["02C"], target=11)
|
||||||
|
engine.play(state, "p0", "02D", ["02C"])
|
||||||
|
return state
|
||||||
|
|
||||||
|
def test_end_of_hand_pauses_for_acknowledgement(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
self.assertEqual("hand_end", state.phase)
|
||||||
|
# Nobody has acknowledged yet, and no new hand was dealt.
|
||||||
|
self.assertEqual([], state.acked)
|
||||||
|
self.assertEqual(1, state.hand_number)
|
||||||
|
self.assertTrue(state.hand_end_deadline)
|
||||||
|
# Capture piles stay visible during the summary.
|
||||||
|
self.assertEqual(["02C", "02D"],
|
||||||
|
[c.code for c in state.players[0].captured])
|
||||||
|
# The summary carries the award map.
|
||||||
|
summary = state.hand_scores[-1]
|
||||||
|
self.assertEqual(1, summary["hand"])
|
||||||
|
self.assertIn("award", summary)
|
||||||
|
|
||||||
|
def test_play_during_hand_end_is_rejected(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.play(state, "p0", "02D")
|
||||||
|
|
||||||
|
def test_ack_all_four_deals_next_hand(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
dealer_before = state.dealer
|
||||||
|
for i, sub in enumerate(("p0", "p1", "p2")):
|
||||||
|
engine.acknowledge_hand(state, sub)
|
||||||
|
self.assertEqual(list(range(i + 1)), state.acked)
|
||||||
|
self.assertEqual("hand_end", state.phase)
|
||||||
|
engine.acknowledge_hand(state, "p3")
|
||||||
|
self.assertEqual("playing", state.phase)
|
||||||
|
self.assertEqual(2, state.hand_number)
|
||||||
|
self.assertEqual((dealer_before + 1) % 4, state.dealer)
|
||||||
|
self.assertEqual([], state.acked)
|
||||||
|
self.assertIsNone(state.hand_end_deadline)
|
||||||
|
self.assertIsNone(state.last_move)
|
||||||
|
for player in state.players:
|
||||||
|
self.assertEqual(10, len(player.hand))
|
||||||
|
self.assertEqual([], player.captured)
|
||||||
|
self.assertEqual((dealer_before + 2) % 4, state.turn)
|
||||||
|
|
||||||
|
def test_double_ack_is_idempotent(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
self.assertEqual([0], state.acked)
|
||||||
|
|
||||||
|
def test_ack_outside_hand_end_is_rejected(self) -> None:
|
||||||
|
state = make_state([["02D"], ["03D"], ["04D"], ["05D"]], table=[])
|
||||||
|
with self.assertRaises(IllegalMove):
|
||||||
|
engine.acknowledge_hand(state, "p0")
|
||||||
|
|
||||||
|
def test_ack_by_non_player_is_rejected(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
with self.assertRaises(NotYourTurn):
|
||||||
|
engine.acknowledge_hand(state, "mallory")
|
||||||
|
|
||||||
|
def test_state_exposes_ack_progress(self) -> None:
|
||||||
|
state = self._hand_end_state()
|
||||||
|
engine.acknowledge_hand(state, "p1")
|
||||||
|
view = engine.state_for_player(state, "p0")
|
||||||
|
self.assertEqual([1], view["acknowledged"])
|
||||||
|
self.assertTrue(view["hand_end_deadline"])
|
||||||
|
self.assertIsNotNone(view["last_hand"])
|
||||||
|
self.assertIn("award", view["last_hand"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
"""WebSocket live-play tests via kaya's ASGI websocket transport."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
@@ -8,7 +9,9 @@ from httpx_ws import WebSocketDisconnect, aconnect_ws
|
|||||||
from httpx_ws.transport import ASGIWebSocketTransport
|
from httpx_ws.transport import ASGIWebSocketTransport
|
||||||
from pwo import async_test
|
from pwo import async_test
|
||||||
|
|
||||||
from scopa.app import app
|
from scopa.app import app, game_store
|
||||||
|
from scopa.game import engine
|
||||||
|
from scopa.game.state import Card, GameState, PlayerState
|
||||||
from tests.helpers import make_user, oidc_user, ws_users
|
from tests.helpers import make_user, oidc_user, ws_users
|
||||||
|
|
||||||
PLAYERS = ("alice", "bob", "carol", "dave")
|
PLAYERS = ("alice", "bob", "carol", "dave")
|
||||||
@@ -127,5 +130,113 @@ class WebSocketTest(unittest.TestCase):
|
|||||||
self.assertEqual(4401, caught.exception.code)
|
self.assertEqual(4401, caught.exception.code)
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_last_play_state(hand_ack_timeout: int = 30) -> str:
|
||||||
|
"""Seed a game where a single play ends the hand: p0 holds the only
|
||||||
|
card left and can capture the only table card."""
|
||||||
|
state = GameState(
|
||||||
|
id="hand-end-1",
|
||||||
|
join_code="HEND01",
|
||||||
|
creator_sub="alice",
|
||||||
|
target_score=11,
|
||||||
|
phase="playing",
|
||||||
|
turn=0,
|
||||||
|
table=[Card.parse("02C")],
|
||||||
|
)
|
||||||
|
state.players = [
|
||||||
|
PlayerState(sub="alice", name="Alice", seat=0, hand=[Card.parse("02D")]),
|
||||||
|
PlayerState(sub="bob", name="Bob", seat=1),
|
||||||
|
PlayerState(sub="carol", name="Carol", seat=2),
|
||||||
|
PlayerState(sub="dave", name="Dave", seat=3),
|
||||||
|
]
|
||||||
|
state.hand_ack_timeout = hand_ack_timeout
|
||||||
|
await game_store.save(state)
|
||||||
|
return state.id
|
||||||
|
|
||||||
|
|
||||||
|
class HandEndWebSocketTest(unittest.TestCase):
|
||||||
|
@async_test
|
||||||
|
async def test_hand_end_ack_flow(self) -> None:
|
||||||
|
import contextlib
|
||||||
|
|
||||||
|
game_id = await _seed_last_play_state()
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
async with contextlib.AsyncExitStack() as stack:
|
||||||
|
with ws_users([make_user(name) for name in PLAYERS]):
|
||||||
|
sockets = [
|
||||||
|
await stack.enter_async_context(
|
||||||
|
aconnect_ws(f"/ws/games/{game_id}", ws_client)
|
||||||
|
)
|
||||||
|
for _ in PLAYERS
|
||||||
|
]
|
||||||
|
for ws in sockets:
|
||||||
|
await ws.receive_json() # initial state
|
||||||
|
|
||||||
|
# Alice plays the last card: the hand ends and the game
|
||||||
|
# pauses for acknowledgements.
|
||||||
|
await sockets[0].send_json(
|
||||||
|
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||||
|
)
|
||||||
|
summaries = [await ws.receive_json() for ws in sockets]
|
||||||
|
for summary in summaries:
|
||||||
|
self.assertEqual("state", summary["type"])
|
||||||
|
self.assertEqual("hand_end", summary["game"]["phase"])
|
||||||
|
self.assertEqual([], summary["game"]["acknowledged"])
|
||||||
|
self.assertIsNotNone(summary["game"]["hand_end_deadline"])
|
||||||
|
award = summary["game"]["last_hand"]["award"]
|
||||||
|
self.assertEqual("A", award["carte"])
|
||||||
|
self.assertEqual("A", award["denara"])
|
||||||
|
|
||||||
|
# Everyone acknowledges; the fourth ack deals the next hand.
|
||||||
|
for i, ws in enumerate(sockets):
|
||||||
|
await ws.send_json({"action": "ack"})
|
||||||
|
updates = [await other.receive_json() for other in sockets]
|
||||||
|
for update in updates:
|
||||||
|
if i < 3:
|
||||||
|
self.assertEqual("hand_end", update["game"]["phase"])
|
||||||
|
self.assertEqual(
|
||||||
|
list(range(i + 1)),
|
||||||
|
update["game"]["acknowledged"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertEqual("playing", update["game"]["phase"])
|
||||||
|
self.assertEqual(2, update["game"]["hand_number"])
|
||||||
|
self.assertEqual(
|
||||||
|
10, update["game"]["players"][i]["cards_left"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_hand_end_timeout_deals_next_hand(self) -> None:
|
||||||
|
game_id = await _seed_last_play_state(hand_ack_timeout=1)
|
||||||
|
ws_transport = ASGIWebSocketTransport(app=app)
|
||||||
|
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
|
||||||
|
with ws_users([make_user("alice")]):
|
||||||
|
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
|
||||||
|
await ws.receive_json() # initial state
|
||||||
|
await ws.send_json(
|
||||||
|
{"action": "play", "card": "02D", "capture": ["02C"]}
|
||||||
|
)
|
||||||
|
summary = await ws.receive_json()
|
||||||
|
self.assertEqual("hand_end", summary["game"]["phase"])
|
||||||
|
# Nobody acks: the timer must deal the next hand.
|
||||||
|
update = None
|
||||||
|
for _ in range(20):
|
||||||
|
try:
|
||||||
|
update = await asyncio.wait_for(
|
||||||
|
ws.receive_json(), timeout=2
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
break
|
||||||
|
if (
|
||||||
|
update.get("type") == "state"
|
||||||
|
and update["game"]["phase"] == "playing"
|
||||||
|
):
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(update)
|
||||||
|
assert update is not None
|
||||||
|
self.assertEqual("playing", update["game"]["phase"])
|
||||||
|
self.assertEqual(2, update["game"]["hand_number"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Generated
+12
@@ -174,6 +174,16 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gloo-timers"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-utils"
|
name = "gloo-utils"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -366,6 +376,8 @@ dependencies = [
|
|||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"futures",
|
"futures",
|
||||||
"gloo-net",
|
"gloo-net",
|
||||||
|
"gloo-timers",
|
||||||
|
"js-sys",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sycamore",
|
"sycamore",
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ wasm-bindgen = "0.2"
|
|||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
web-sys = { version = "0.3", features = ["Window", "Location", "console"] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
pub mod card;
|
pub mod card;
|
||||||
|
pub mod summary;
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! The hand-end scoring summary screen.
|
||||||
|
//!
|
||||||
|
//! Shown when a hand finishes but the match continues: explains, in plain
|
||||||
|
//! language, how each scoring category played out and how the running
|
||||||
|
//! totals moved toward the target. Every player must acknowledge it before
|
||||||
|
//! the next hand is dealt (or the server-side timeout deals anyway).
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
|
use crate::components::card::{card_back, card_img};
|
||||||
|
use crate::model::{GameView, HandSummary, Scores};
|
||||||
|
use crate::ws::GameSocket;
|
||||||
|
|
||||||
|
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||||
|
fn award_row(
|
||||||
|
icon: View,
|
||||||
|
title: &'static str,
|
||||||
|
text: String,
|
||||||
|
winner: Option<String>,
|
||||||
|
points: &'static str,
|
||||||
|
) -> View {
|
||||||
|
let cls = match winner.as_deref() {
|
||||||
|
Some("A") => "score-row team-a",
|
||||||
|
Some("B") => "score-row team-b",
|
||||||
|
_ => "score-row tie",
|
||||||
|
};
|
||||||
|
let chip = match &winner {
|
||||||
|
Some(t) => format!("Team {t} {points}"),
|
||||||
|
None => "tie".to_string(),
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
div(class=cls) {
|
||||||
|
div(class="score-icon") { (icon) }
|
||||||
|
div(class="score-body") {
|
||||||
|
div(class="score-title") { (title) }
|
||||||
|
div(class="score-text") { (text) }
|
||||||
|
}
|
||||||
|
div(class="score-points") { (chip) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Winner's count first, then the loser's, for a natural sentence.
|
||||||
|
fn winner_first(a: i32, b: i32, winner: Option<&String>) -> (i32, i32) {
|
||||||
|
match winner.map(String::as_str) {
|
||||||
|
Some("B") => (b, a),
|
||||||
|
_ => (a, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The five scoring rows of a completed hand.
|
||||||
|
pub fn summary_rows(summary: HandSummary) -> View {
|
||||||
|
let rows: Vec<View> = vec![
|
||||||
|
// Carte
|
||||||
|
award_row(
|
||||||
|
card_back("score-mini"),
|
||||||
|
"Carte",
|
||||||
|
match &summary.award.carte {
|
||||||
|
Some(t) => {
|
||||||
|
let (w, l) = winner_first(summary.cards.a, summary.cards.b, Some(t));
|
||||||
|
format!("Team {t} captured more cards ({w} vs {l})")
|
||||||
|
}
|
||||||
|
None => format!(
|
||||||
|
"Both teams captured {} cards — no point",
|
||||||
|
summary.cards.a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
summary.award.carte.clone(),
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
// Denara
|
||||||
|
award_row(
|
||||||
|
card_img("02D".to_string(), "score-mini"),
|
||||||
|
"Denara",
|
||||||
|
match &summary.award.denara {
|
||||||
|
Some(t) => {
|
||||||
|
let (w, l) = winner_first(summary.denara.a, summary.denara.b, Some(t));
|
||||||
|
format!("Team {t} collected more denari cards ({w} vs {l})")
|
||||||
|
}
|
||||||
|
None => format!(
|
||||||
|
"Both teams collected {} denari cards — no point",
|
||||||
|
summary.denara.a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
summary.award.denara.clone(),
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
// Settebello
|
||||||
|
award_row(
|
||||||
|
card_img("07D".to_string(), "score-mini"),
|
||||||
|
"Settebello",
|
||||||
|
match &summary.award.settebello {
|
||||||
|
Some(t) => format!("Team {t} captured the 7 of denari — the Settebello"),
|
||||||
|
None => "Nobody captured the Settebello".to_string(),
|
||||||
|
},
|
||||||
|
summary.award.settebello.clone(),
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
// Primiera
|
||||||
|
award_row(
|
||||||
|
card_img("10D".to_string(), "score-mini"),
|
||||||
|
"Primiera",
|
||||||
|
match &summary.award.primiera {
|
||||||
|
Some(t) => {
|
||||||
|
let (w, l) =
|
||||||
|
winner_first(summary.primiera.a, summary.primiera.b, Some(t));
|
||||||
|
format!("Team {t} holds the strongest primiera ({w} vs {l})")
|
||||||
|
}
|
||||||
|
None => format!(
|
||||||
|
"Both primiere are worth {} — no point",
|
||||||
|
summary.primiera.a
|
||||||
|
),
|
||||||
|
},
|
||||||
|
summary.award.primiera.clone(),
|
||||||
|
"+1",
|
||||||
|
),
|
||||||
|
// Scope
|
||||||
|
{
|
||||||
|
let a = summary.scope.a;
|
||||||
|
let b = summary.scope.b;
|
||||||
|
let text = if a == 0 && b == 0 {
|
||||||
|
"No scope this hand".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Team A made {a} scope · Team B made {b} scope")
|
||||||
|
};
|
||||||
|
let chip = format!("+{a} · +{b}");
|
||||||
|
view! {
|
||||||
|
div(class="score-row scope-row") {
|
||||||
|
div(class="score-icon") {
|
||||||
|
(card_back("score-mini"))
|
||||||
|
}
|
||||||
|
div(class="score-body") {
|
||||||
|
div(class="score-title") { "Scope" }
|
||||||
|
div(class="score-text") { (text) }
|
||||||
|
}
|
||||||
|
div(class="score-points") { (chip) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
view! {
|
||||||
|
div(class="score-rows") { (rows) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Running totals with progress toward the target score, including the
|
||||||
|
/// points gained in the hand just played.
|
||||||
|
pub fn totals(game: &GameView) -> View {
|
||||||
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
|
let target = game.target_score.max(1);
|
||||||
|
let pct_a = (100 * scores.a / target).min(100);
|
||||||
|
let pct_b = (100 * scores.b / target).min(100);
|
||||||
|
let (gained_a, gained_b) = game
|
||||||
|
.last_hand
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| (s.team_a_points, s.team_b_points))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
view! {
|
||||||
|
div(class="totals") {
|
||||||
|
div(class="total-row team-a") {
|
||||||
|
span(class="total-label") { "Team A" }
|
||||||
|
div(class="progress") {
|
||||||
|
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||||
|
}
|
||||||
|
span(class="total-value") {
|
||||||
|
(scores.a) " / " (target)
|
||||||
|
span(class="gained") { "+" (gained_a) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(class="total-row team-b") {
|
||||||
|
span(class="total-label") { "Team B" }
|
||||||
|
div(class="progress") {
|
||||||
|
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||||
|
}
|
||||||
|
span(class="total-value") {
|
||||||
|
(scores.b) " / " (target)
|
||||||
|
span(class="gained") { "+" (gained_b) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full hand-end modal: explanation + totals + acknowledgement button.
|
||||||
|
pub fn hand_summary_modal(
|
||||||
|
game: GameView,
|
||||||
|
socket: Signal<Option<GameSocket>>,
|
||||||
|
now: Signal<f64>,
|
||||||
|
) -> View {
|
||||||
|
let Some(summary) = game.last_hand.clone() else {
|
||||||
|
return view! {};
|
||||||
|
};
|
||||||
|
let viewer_seat = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.hand.is_some())
|
||||||
|
.map(|p| p.seat);
|
||||||
|
let acked = game.acknowledged.clone();
|
||||||
|
let already_acked = viewer_seat.is_some_and(|s| acked.contains(&s));
|
||||||
|
let waiting: Vec<String> = game
|
||||||
|
.players
|
||||||
|
.iter()
|
||||||
|
.filter(|p| !acked.contains(&p.seat))
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect();
|
||||||
|
let countdown = game.hand_end_deadline.as_ref().map(|deadline| {
|
||||||
|
// A dynamic closure so only the ticking number re-renders, not the
|
||||||
|
// whole modal (which would swap DOM nodes under the user's cursor).
|
||||||
|
let deadline_ms = js_sys::Date::parse(deadline);
|
||||||
|
view! {
|
||||||
|
p(class="hint") {
|
||||||
|
"Auto-continuing in "
|
||||||
|
(move || {
|
||||||
|
((deadline_ms - now.get_clone()) / 1000.0).ceil().max(0.0) as i32
|
||||||
|
})
|
||||||
|
"s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let rows = summary_rows(summary.clone());
|
||||||
|
let totals_view = totals(&game);
|
||||||
|
let title = format!("Hand {} — results", summary.hand);
|
||||||
|
|
||||||
|
let action = if already_acked {
|
||||||
|
let waiting_text = format!("Waiting for {}…", waiting.join(", "));
|
||||||
|
view! {
|
||||||
|
button(class="button primary", disabled=true) { (waiting_text) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
button(class="button primary", on:click=move |_| {
|
||||||
|
if let Some(s) = socket.get_clone() {
|
||||||
|
s.ack();
|
||||||
|
}
|
||||||
|
}) { "Understood — next hand" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
div(class="overlay") {
|
||||||
|
div(class="picker summary-panel") {
|
||||||
|
h2 { (title) }
|
||||||
|
(rows)
|
||||||
|
(totals_view)
|
||||||
|
div(class="summary-actions") { (action) }
|
||||||
|
(countdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,54 @@ pub struct MoveView {
|
|||||||
pub scopa: bool,
|
pub scopa: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
|
pub struct TeamCounts {
|
||||||
|
#[serde(rename = "A")]
|
||||||
|
pub a: i32,
|
||||||
|
#[serde(rename = "B")]
|
||||||
|
pub b: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TeamBools {
|
||||||
|
#[serde(rename = "A")]
|
||||||
|
pub a: bool,
|
||||||
|
#[serde(rename = "B")]
|
||||||
|
pub b: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which team (if any) won each scoring category of a hand.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct Award {
|
||||||
|
#[serde(default)]
|
||||||
|
pub carte: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub denara: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub settebello: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub primiera: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The scoring breakdown of one completed hand.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct HandSummary {
|
||||||
|
pub cards: TeamCounts,
|
||||||
|
pub denara: TeamCounts,
|
||||||
|
pub settebello: TeamBools,
|
||||||
|
pub primiera: TeamCounts,
|
||||||
|
pub scope: TeamCounts,
|
||||||
|
pub award: Award,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hand: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub team_a_points: i32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub team_b_points: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub struct GameView {
|
pub struct GameView {
|
||||||
@@ -74,6 +122,16 @@ pub struct GameView {
|
|||||||
pub seats_open: Option<usize>,
|
pub seats_open: Option<usize>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_move: Option<MoveView>,
|
pub last_move: Option<MoveView>,
|
||||||
|
/// Scoring breakdown of the most recent hand (present once a hand has
|
||||||
|
/// been completed).
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_hand: Option<HandSummary>,
|
||||||
|
/// Seats that acknowledged the hand-end summary.
|
||||||
|
#[serde(default)]
|
||||||
|
pub acknowledged: Vec<usize>,
|
||||||
|
/// ISO-8601 instant at which the next hand is dealt automatically.
|
||||||
|
#[serde(default)]
|
||||||
|
pub hand_end_deadline: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub your_turn: Option<bool>,
|
pub your_turn: Option<bool>,
|
||||||
/// Legal captures per hand card; present only for the player on turn.
|
/// Legal captures per hand card; present only for the player on turn.
|
||||||
|
|||||||
+22
-3
@@ -2,6 +2,7 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
|
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
use crate::model::{card_label, GameView, MoveView, PlayerView, Scores, ServerMessage};
|
||||||
use crate::ws::{self, GameSocket};
|
use crate::ws::{self, GameSocket};
|
||||||
|
|
||||||
@@ -77,6 +78,9 @@ pub fn GamePage(id: String) -> View {
|
|||||||
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
let over = create_signal(Option::<(Scores, Option<String>)>::None);
|
||||||
let closed = create_signal(false);
|
let closed = create_signal(false);
|
||||||
let socket = create_signal(Option::<GameSocket>::None);
|
let socket = create_signal(Option::<GameSocket>::None);
|
||||||
|
// Ticking clock driving the hand-end countdown display.
|
||||||
|
let now = create_signal(js_sys::Date::now());
|
||||||
|
gloo_timers::callback::Interval::new(500, move || now.set(js_sys::Date::now())).forget();
|
||||||
|
|
||||||
{
|
{
|
||||||
let on_message = move |msg: ServerMessage| match msg {
|
let on_message = move |msg: ServerMessage| match msg {
|
||||||
@@ -140,6 +144,10 @@ pub fn GamePage(id: String) -> View {
|
|||||||
(move || capture_choice.get_clone().map(|(card, options)| {
|
(move || capture_choice.get_clone().map(|(card, options)| {
|
||||||
capture_picker(card, options, socket, capture_choice)
|
capture_picker(card, options, socket, capture_choice)
|
||||||
}))
|
}))
|
||||||
|
(move || match game.get_clone() {
|
||||||
|
Some(g) if g.phase == "hand_end" => hand_summary_modal(g, socket, now),
|
||||||
|
_ => view! {},
|
||||||
|
})
|
||||||
(move || game_over_view(over.get_clone(), game.get_clone()))
|
(move || game_over_view(over.get_clone(), game.get_clone()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,6 +207,8 @@ fn table_view(
|
|||||||
let my_turn = game.your_turn == Some(true);
|
let my_turn = game.your_turn == Some(true);
|
||||||
let turn_note = if game.phase == "finished" {
|
let turn_note = if game.phase == "finished" {
|
||||||
"Match finished".to_string()
|
"Match finished".to_string()
|
||||||
|
} else if game.phase == "hand_end" {
|
||||||
|
"Hand finished".to_string()
|
||||||
} else if my_turn {
|
} else if my_turn {
|
||||||
"Your turn".to_string()
|
"Your turn".to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -321,7 +331,8 @@ fn capture_picker(
|
|||||||
/// End-of-match overlay.
|
/// End-of-match overlay.
|
||||||
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>) -> View {
|
||||||
let result = over.or_else(|| {
|
let result = over.or_else(|| {
|
||||||
game.filter(|g| g.phase == "finished")
|
game.clone()
|
||||||
|
.filter(|g| g.phase == "finished")
|
||||||
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
|
.map(|g| (g.scores.unwrap_or(Scores { a: 0, b: 0 }), g.winner))
|
||||||
});
|
});
|
||||||
match result {
|
match result {
|
||||||
@@ -329,11 +340,19 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
Some((scores, winner)) => {
|
Some((scores, winner)) => {
|
||||||
let winner = winner.unwrap_or_else(|| "?".to_string());
|
let winner = winner.unwrap_or_else(|| "?".to_string());
|
||||||
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
let line = format!("Team {winner} wins {} – {}", scores.a, scores.b);
|
||||||
|
// Explain the final hand's scoring before the result.
|
||||||
|
let final_summary = game
|
||||||
|
.and_then(|g| g.last_hand)
|
||||||
|
.map(|s| {
|
||||||
|
let rows = summary_rows(s);
|
||||||
|
view! { (rows) }
|
||||||
|
});
|
||||||
view! {
|
view! {
|
||||||
div(class="overlay") {
|
div(class="overlay") {
|
||||||
div(class="picker") {
|
div(class="picker summary-panel") {
|
||||||
h2 { "Match over" }
|
h2 { "Match over" }
|
||||||
p { (line) }
|
(final_summary)
|
||||||
|
p(class="final-score") { (line) }
|
||||||
div(class="gameover-actions") {
|
div(class="gameover-actions") {
|
||||||
a(class="button primary", href="/") { "Back to lobby" }
|
a(class="button primary", href="/") { "Back to lobby" }
|
||||||
a(class="button", href="/history") { "My matches" }
|
a(class="button", href="/history") { "My matches" }
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ impl GameSocket {
|
|||||||
self.send_json(serde_json::json!({ "action": "state" }));
|
self.send_json(serde_json::json!({ "action": "state" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acknowledge the hand-end scoring summary.
|
||||||
|
pub fn ack(&self) {
|
||||||
|
self.send_json(serde_json::json!({ "action": "ack" }));
|
||||||
|
}
|
||||||
|
|
||||||
fn send_json(&self, value: serde_json::Value) {
|
fn send_json(&self, value: serde_json::Value) {
|
||||||
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
let _ = self.sender.borrow_mut().unbounded_send(value.to_string());
|
||||||
}
|
}
|
||||||
|
|||||||
+153
@@ -9,6 +9,8 @@
|
|||||||
--muted: #a9b7ab;
|
--muted: #a9b7ab;
|
||||||
--accent: #e8c547;
|
--accent: #e8c547;
|
||||||
--danger: #d9534f;
|
--danger: #d9534f;
|
||||||
|
--team-a: #7db4e8;
|
||||||
|
--team-b: #e8967d;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -448,3 +450,154 @@ table.matches td.lost {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- hand-end scoring summary ---------- */
|
||||||
|
|
||||||
|
.summary-panel {
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 560px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel h2 {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: var(--panel-light);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row.team-a {
|
||||||
|
border-left-color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row.team-b {
|
||||||
|
border-left-color: var(--team-b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row.tie {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-icon {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-img.score-mini {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-points {
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row.team-a .score-points {
|
||||||
|
color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row.team-b .score-points {
|
||||||
|
color: var(--team-b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.totals {
|
||||||
|
margin: 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 4.5rem 1fr 4rem;
|
||||||
|
gap: 0.6rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-value {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gained {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row.team-a .gained {
|
||||||
|
color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row.team-b .gained {
|
||||||
|
color: var(--team-b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row.team-a .progress-fill {
|
||||||
|
background: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row.team-b .progress-fill {
|
||||||
|
background: var(--team-b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-actions {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-score {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user