Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2dae476f0
|
||
|
|
18877b8109
|
||
|
|
b821274f0b
|
||
|
|
6222e38da3
|
||
|
|
97d4b3f217
|
@@ -279,6 +279,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
|||||||
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
|
||||||
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
|
||||||
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
| `GET` | `/api/games/{id}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code |
|
||||||
|
| `GET` | `/api/me/games` | Live games the caller is seated in (waiting lobbies, matches in progress or between hands), newest first, for the lobby's "Continue" list. Finished matches are not included |
|
||||||
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
|
||||||
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
|
||||||
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
| `GET` | `/api/leaderboard` | Elo rating, aggregated wins / matches / points per player, sorted by Elo (`?game_type=`) |
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
|
|||||||
|
|
||||||
|
|
||||||
def display_name(user: OIDCUser) -> str:
|
def display_name(user: OIDCUser) -> str:
|
||||||
"""Best-effort human-readable name for a user."""
|
"""Best-effort human-readable name for a user.
|
||||||
for key in ("name", "preferred_username", "email"):
|
|
||||||
|
``preferred_username`` is preferred: it is the account's stable handle,
|
||||||
|
while ``name`` is a free-form display name a user may change (and two
|
||||||
|
accounts can share). ``email`` is a last resort before the ``sub``.
|
||||||
|
"""
|
||||||
|
for key in ("preferred_username", "name", "email"):
|
||||||
value = user.get(key)
|
value = user.get(key)
|
||||||
if isinstance(value, str) and value:
|
if isinstance(value, str) and value:
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -108,6 +108,34 @@ def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, An
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ongoing_game_payload(
|
||||||
|
platform: "Platform", session: GameSession, sub: str
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""One entry of the caller's ongoing-games list.
|
||||||
|
|
||||||
|
Reuses the lobby payload (players, open seats, option echo) but drops
|
||||||
|
the join code — the Continue link only needs the id, and the game page
|
||||||
|
shows the code to its players anyway.
|
||||||
|
"""
|
||||||
|
engine = platform.registry.require(session.game_type)
|
||||||
|
payload = _lobby_payload(platform, session)
|
||||||
|
payload.pop("join_code", None)
|
||||||
|
payload["game_name"] = engine.name
|
||||||
|
payload["game_description"] = engine.description
|
||||||
|
payload["your_seat"] = next(
|
||||||
|
(
|
||||||
|
index
|
||||||
|
for index, seat in enumerate(session.players)
|
||||||
|
if seat.user_sub == sub
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
payload["created_at"] = (
|
||||||
|
session.created_at.isoformat() if session.created_at else None
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def register(app: KayaApp, platform: "Platform") -> None:
|
def register(app: KayaApp, platform: "Platform") -> None:
|
||||||
@app.GET("/api/game-types")
|
@app.GET("/api/game-types")
|
||||||
@operation(summary="List available games",
|
@operation(summary="List available games",
|
||||||
@@ -341,3 +369,27 @@ def register(app: KayaApp, platform: "Platform") -> None:
|
|||||||
await send_json(
|
await send_json(
|
||||||
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
ctx, 200, _view_payload(platform, session, user.sub if user else None)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.GET("/api/me/games")
|
||||||
|
@operation(summary="List my ongoing games",
|
||||||
|
description="Live games the caller is seated in — waiting "
|
||||||
|
"lobbies and matches in progress or between "
|
||||||
|
"hands — newest first, for the lobby's Continue "
|
||||||
|
"list. Finished matches are not included.",
|
||||||
|
tags=["games"],
|
||||||
|
responses={
|
||||||
|
200: {"description": "The caller's ongoing games"},
|
||||||
|
401: {"description": "Authentication required"},
|
||||||
|
})
|
||||||
|
@require_auth(platform.oidc)
|
||||||
|
async def my_games(ctx: HttpContext) -> None:
|
||||||
|
user = current_user(platform.oidc, ctx)
|
||||||
|
assert user is not None # enforced by @require_auth
|
||||||
|
sessions = await platform.game_store.list_ongoing()
|
||||||
|
await send_json(ctx, 200, {
|
||||||
|
"results": [
|
||||||
|
_ongoing_game_payload(platform, session, user.sub)
|
||||||
|
for session in sessions
|
||||||
|
if session.seated(user.sub)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
from typing import Any, AsyncContextManager, AsyncIterator, Dict, List, Mapping, Optional, Set, cast
|
||||||
|
|
||||||
@@ -72,6 +72,15 @@ def _dt_to_json(value: Optional[datetime]) -> Optional[str]:
|
|||||||
return value.isoformat() if value is not None else None
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _created_at_key(session: GameSession) -> datetime:
|
||||||
|
"""Sort key for :meth:`GameStore.list_ongoing` (newest first).
|
||||||
|
|
||||||
|
Sessions without a creation timestamp sort last; the epoch sentinel is
|
||||||
|
timezone-aware so it never mixes with the stored UTC datetimes.
|
||||||
|
"""
|
||||||
|
return session.created_at or datetime.min.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def _dt_from_json(value: Any) -> Optional[datetime]:
|
def _dt_from_json(value: Any) -> Optional[datetime]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -137,6 +146,15 @@ class GameStore(ABC):
|
|||||||
still retained for the final-board view do not.
|
still retained for the final-board view do not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
"""Return up to ``limit`` live games that have not finished yet.
|
||||||
|
|
||||||
|
Newest ``created_at`` first. Sessions whose key has expired are
|
||||||
|
skipped, and so are matches that are over but still retained for
|
||||||
|
the final-board view.
|
||||||
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
def lock(self, game_id: str) -> AsyncContextManager[None]:
|
||||||
"""Async context manager serializing mutations of one game."""
|
"""Async context manager serializing mutations of one game."""
|
||||||
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
|
|||||||
_, count = await pipe.execute()
|
_, count = await pipe.execute()
|
||||||
return int(count)
|
return int(count)
|
||||||
|
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
await self._redis.zremrangebyscore(
|
||||||
|
ONGOING_GAMES_KEY, "-inf", time.time()
|
||||||
|
)
|
||||||
|
members = await self._redis.zrange(ONGOING_GAMES_KEY, 0, -1)
|
||||||
|
sessions: List[GameSession] = []
|
||||||
|
for member in members:
|
||||||
|
game_id = (
|
||||||
|
member.decode("utf-8") if isinstance(member, bytes) else str(member)
|
||||||
|
)
|
||||||
|
session = await self.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
# The game key expired but the ongoing entry had not been
|
||||||
|
# pruned yet: skip it.
|
||||||
|
continue
|
||||||
|
if self._registry.require(session.game_type).is_finished(session):
|
||||||
|
continue
|
||||||
|
sessions.append(session)
|
||||||
|
sessions.sort(key=_created_at_key, reverse=True)
|
||||||
|
return sessions[:limit]
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
pubsub = self._redis.pubsub()
|
pubsub = self._redis.pubsub()
|
||||||
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
|
|||||||
async def count_ongoing(self) -> int:
|
async def count_ongoing(self) -> int:
|
||||||
return len(self._ongoing)
|
return len(self._ongoing)
|
||||||
|
|
||||||
|
async def list_ongoing(self, limit: int = 20) -> List[GameSession]:
|
||||||
|
sessions: List[GameSession] = []
|
||||||
|
for game_id in list(self._ongoing):
|
||||||
|
session = await self.load(game_id)
|
||||||
|
if session is None:
|
||||||
|
continue
|
||||||
|
if self._registry.require(session.game_type).is_finished(session):
|
||||||
|
continue
|
||||||
|
sessions.append(session)
|
||||||
|
sessions.sort(key=_created_at_key, reverse=True)
|
||||||
|
return sessions[:limit]
|
||||||
|
|
||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
|
||||||
queue: asyncio.Queue = asyncio.Queue()
|
queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
|||||||
@@ -11,11 +11,17 @@ import unittest
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from kaya.oidc import OIDCConfig, OIDCProviders
|
from kaya.oidc import OIDCConfig, OIDCProviders, OIDCUser
|
||||||
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
from kaya.session import InMemorySessionStore, Session, SessionMixin
|
||||||
|
|
||||||
from helpers import async_test
|
from helpers import async_test
|
||||||
from tavolo.platform.auth import current_user, get_ws_user, require_auth, session_user
|
from tavolo.platform.auth import (
|
||||||
|
current_user,
|
||||||
|
display_name,
|
||||||
|
get_ws_user,
|
||||||
|
require_auth,
|
||||||
|
session_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _providers(*names: str) -> OIDCProviders:
|
def _providers(*names: str) -> OIDCProviders:
|
||||||
@@ -87,6 +93,32 @@ class GetWsUserTest(unittest.TestCase):
|
|||||||
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
self.assertIsNone(get_ws_user(SimpleNamespace()))
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayNameTest(unittest.TestCase):
|
||||||
|
"""The claim priority used for the name shown at the table."""
|
||||||
|
|
||||||
|
def test_prefers_preferred_username_over_name(self) -> None:
|
||||||
|
user = OIDCUser(
|
||||||
|
{"sub": "alice", "preferred_username": "alice", "name": "Alice"}
|
||||||
|
)
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_name(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "name": "Alice"})
|
||||||
|
self.assertEqual("Alice", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_email(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "email": "alice@example.com"})
|
||||||
|
self.assertEqual("alice@example.com", display_name(user))
|
||||||
|
|
||||||
|
def test_falls_back_to_sub(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice"})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
def test_ignores_empty_and_non_string_claims(self) -> None:
|
||||||
|
user = OIDCUser({"sub": "alice", "preferred_username": "", "name": 42})
|
||||||
|
self.assertEqual("alice", display_name(user))
|
||||||
|
|
||||||
|
|
||||||
class CurrentUserTest(unittest.TestCase):
|
class CurrentUserTest(unittest.TestCase):
|
||||||
def test_anonymous_session_has_no_user(self) -> None:
|
def test_anonymous_session_has_no_user(self) -> None:
|
||||||
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
self.assertIsNone(current_user(_providers("default"), _ctx({})))
|
||||||
|
|||||||
@@ -188,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
response = await client.get("/api/games/does-not-exist")
|
response = await client.get("/api/games/does-not-exist")
|
||||||
self.assertEqual(404, response.status_code)
|
self.assertEqual(404, response.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_games_lists_only_my_ongoing_games(self) -> None:
|
||||||
|
app, platform, _ = make_platform()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
anonymous = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(401, anonymous.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
mine = await client.post(
|
||||||
|
"/api/games", json={"options": {"target": 5}}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
theirs = await client.post("/api/games", json={})
|
||||||
|
self.assertEqual(201, mine.status_code)
|
||||||
|
self.assertEqual(201, theirs.status_code)
|
||||||
|
|
||||||
|
with oidc_user(platform.oidc, "alice"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(200, listed.status_code)
|
||||||
|
results = listed.json()["results"]
|
||||||
|
self.assertEqual([mine.json()["id"]], [g["id"] for g in results])
|
||||||
|
entry = results[0]
|
||||||
|
self.assertEqual("dummy", entry["game_type"])
|
||||||
|
self.assertEqual("Dummy game", entry["game_name"])
|
||||||
|
self.assertTrue(entry["game_description"])
|
||||||
|
self.assertEqual("lobby", entry["phase"])
|
||||||
|
self.assertEqual(5, entry["target"])
|
||||||
|
self.assertEqual(1, entry["seats_open"])
|
||||||
|
self.assertEqual(0, entry["your_seat"])
|
||||||
|
self.assertNotIn("join_code", entry)
|
||||||
|
self.assertEqual("alice", entry["players"][0]["sub"])
|
||||||
|
|
||||||
|
# Bob's own game is the only one he sees; Alice's is not his.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(
|
||||||
|
[theirs.json()["id"]],
|
||||||
|
[g["id"] for g in bobs.json()["results"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once Bob joins Alice's game it appears for both, now in the
|
||||||
|
# playing phase.
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
await client.post(
|
||||||
|
"/api/games/join", json={"code": mine.json()["join_code"]}
|
||||||
|
)
|
||||||
|
with oidc_user(platform.oidc, "bob"):
|
||||||
|
bobs = await client.get("/api/me/games")
|
||||||
|
by_id = {g["id"]: g for g in bobs.json()["results"]}
|
||||||
|
self.assertEqual(
|
||||||
|
{mine.json()["id"], theirs.json()["id"]}, set(by_id)
|
||||||
|
)
|
||||||
|
self.assertEqual("playing", by_id[mine.json()["id"]]["phase"])
|
||||||
|
self.assertEqual(1, by_id[mine.json()["id"]]["your_seat"])
|
||||||
|
self.assertEqual(0, by_id[mine.json()["id"]]["seats_open"])
|
||||||
|
|
||||||
|
|
||||||
class GameTypesRouteTest(unittest.TestCase):
|
class GameTypesRouteTest(unittest.TestCase):
|
||||||
@async_test
|
@async_test
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional, cast
|
from typing import Any, Optional, cast
|
||||||
|
|
||||||
from tavolo.platform import GameSession, Seat
|
from tavolo.platform import GameSession, Seat
|
||||||
@@ -48,7 +49,7 @@ class _FakePipeline:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def set(self, key: str, value: str, ex: object = None) -> None:
|
def set(self, key: str, value: str, ex: object = None) -> None:
|
||||||
self._ops.append(("set", key))
|
self._ops.append(("set", key, value))
|
||||||
|
|
||||||
def zadd(self, key: str, mapping: dict) -> None:
|
def zadd(self, key: str, mapping: dict) -> None:
|
||||||
self._ops.append(("zadd", key, mapping))
|
self._ops.append(("zadd", key, mapping))
|
||||||
@@ -88,6 +89,14 @@ class _FakeRedis:
|
|||||||
async def zrem(self, key: str, member: str) -> int:
|
async def zrem(self, key: str, member: str) -> int:
|
||||||
return cast(int, self.apply(("zrem", key, member)))
|
return cast(int, self.apply(("zrem", key, member)))
|
||||||
|
|
||||||
|
async def get(self, key: str) -> object:
|
||||||
|
return self.keys.get(key)
|
||||||
|
|
||||||
|
async def zremrangebyscore(
|
||||||
|
self, key: str, minimum: object, maximum: object
|
||||||
|
) -> int:
|
||||||
|
return cast(int, self.apply(("zremrangebyscore", key)))
|
||||||
|
|
||||||
async def zrange(
|
async def zrange(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
@@ -113,7 +122,7 @@ class _FakeRedis:
|
|||||||
def apply(self, op: tuple) -> object:
|
def apply(self, op: tuple) -> object:
|
||||||
name, *args = op
|
name, *args = op
|
||||||
if name == "set":
|
if name == "set":
|
||||||
self.keys[args[0]] = "payload"
|
self.keys[args[0]] = args[1]
|
||||||
return True
|
return True
|
||||||
if name == "zadd":
|
if name == "zadd":
|
||||||
self.zsets.setdefault(args[0], {}).update(args[1])
|
self.zsets.setdefault(args[0], {}).update(args[1])
|
||||||
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
|
|||||||
await store.save(other)
|
await store.save(other)
|
||||||
self.assertEqual(0, await store.count_ongoing())
|
self.assertEqual(0, await store.count_ongoing())
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_list_ongoing_newest_first_and_unfinished_only(self) -> None:
|
||||||
|
store = InMemoryGameStore(_registry())
|
||||||
|
old = _session("old", "CODE01")
|
||||||
|
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
new = _session("new", "CODE02")
|
||||||
|
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||||
|
await store.save(old)
|
||||||
|
await store.save(new)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# A finished match retained for the final board is not listed.
|
||||||
|
new.state["finished"] = True
|
||||||
|
await store.save(new)
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_deadline_queue(self) -> None:
|
async def test_deadline_queue(self) -> None:
|
||||||
store = InMemoryGameStore(_registry())
|
store = InMemoryGameStore(_registry())
|
||||||
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
|
|||||||
self.assertEqual(1, await store.count_ongoing())
|
self.assertEqual(1, await store.count_ongoing())
|
||||||
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
self.assertNotIn("stale", redis.zsets[ONGOING_GAMES_KEY])
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_list_ongoing_loads_unfinished_sessions(self) -> None:
|
||||||
|
redis = _FakeRedis()
|
||||||
|
store = RedisGameStore(redis, _registry(), ttl_seconds=3600) # type: ignore[arg-type]
|
||||||
|
old = _session("old", "CODE01")
|
||||||
|
old.created_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||||
|
new = _session("new", "CODE02")
|
||||||
|
new.created_at = datetime(2026, 1, 2, tzinfo=timezone.utc)
|
||||||
|
await store.save(old)
|
||||||
|
await store.save(new)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["new", "old"], [s.id for s in await store.list_ongoing()]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["new"], [s.id for s in await store.list_ongoing(limit=1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
# A finished match retained for the final board is not listed.
|
||||||
|
new.state["finished"] = True
|
||||||
|
await store.save(new)
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
|
# An ongoing entry whose game key expired is skipped.
|
||||||
|
redis.zsets[ONGOING_GAMES_KEY]["gone"] = time.time() + 100
|
||||||
|
self.assertEqual(["old"], [s.id for s in await store.list_ongoing()])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
async def test_due_deadlines_uses_zrange_byscore(self) -> None:
|
||||||
redis = _FakeRedis()
|
redis = _FakeRedis()
|
||||||
|
|||||||
@@ -236,6 +236,52 @@ class GamesRouteTest(unittest.TestCase):
|
|||||||
self.assertEqual(400, text.status_code)
|
self.assertEqual(400, text.status_code)
|
||||||
self.assertEqual(400, huge.status_code)
|
self.assertEqual(400, huge.status_code)
|
||||||
|
|
||||||
|
@async_test
|
||||||
|
async def test_my_games_lists_my_ongoing_games(self) -> None:
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
|
||||||
|
with oidc_user("alice"):
|
||||||
|
created = await client.post(
|
||||||
|
"/api/games", json={"game_type": "briscola"}
|
||||||
|
)
|
||||||
|
game_id = created.json()["id"]
|
||||||
|
|
||||||
|
with oidc_user("alice"):
|
||||||
|
listed = await client.get("/api/me/games")
|
||||||
|
self.assertEqual(200, listed.status_code)
|
||||||
|
# The app suite shares one in-memory store, so other tests'
|
||||||
|
# games may be listed too: pick ours by id.
|
||||||
|
by_id = {g["id"]: g for g in listed.json()["results"]}
|
||||||
|
self.assertIn(game_id, by_id)
|
||||||
|
entry = by_id[game_id]
|
||||||
|
self.assertEqual("briscola", entry["game_type"])
|
||||||
|
self.assertEqual("Briscola", entry["game_name"])
|
||||||
|
self.assertTrue(entry["game_description"])
|
||||||
|
self.assertEqual("lobby", entry["phase"])
|
||||||
|
self.assertEqual(1, entry["seats_open"])
|
||||||
|
self.assertEqual(0, entry["your_seat"])
|
||||||
|
self.assertNotIn("join_code", entry)
|
||||||
|
|
||||||
|
# A logged-in non-player does not see the game.
|
||||||
|
with oidc_user("mallory"):
|
||||||
|
others = await client.get("/api/me/games")
|
||||||
|
self.assertNotIn(
|
||||||
|
game_id, [g["id"] for g in others.json()["results"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Once the match starts it stays listed, for both players.
|
||||||
|
code = created.json()["join_code"]
|
||||||
|
with oidc_user("bob"):
|
||||||
|
await client.post("/api/games/join", json={"code": code})
|
||||||
|
with oidc_user("bob"):
|
||||||
|
started = await client.get("/api/me/games")
|
||||||
|
bob_entry = next(
|
||||||
|
g for g in started.json()["results"] if g["id"] == game_id
|
||||||
|
)
|
||||||
|
self.assertEqual("playing", bob_entry["phase"])
|
||||||
|
self.assertEqual(1, bob_entry["your_seat"])
|
||||||
|
self.assertEqual(0, bob_entry["seats_open"])
|
||||||
|
|
||||||
@async_test
|
@async_test
|
||||||
async def test_get_unknown_game(self) -> None:
|
async def test_get_unknown_game(self) -> None:
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
|
|||||||
+18
-2
@@ -128,6 +128,20 @@ pub async fn probe_game(game_id: &str) -> GameEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the live games the caller is seated in, for the lobby's
|
||||||
|
/// "Continue" list.
|
||||||
|
pub async fn my_games() -> Result<Vec<MyGame>, String> {
|
||||||
|
let resp = Request::get("/api/me/games")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if !resp.ok() {
|
||||||
|
return Err(server_error(resp.status()));
|
||||||
|
}
|
||||||
|
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(page.results)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
|
||||||
let url = match cursor {
|
let url = match cursor {
|
||||||
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
|
||||||
@@ -152,8 +166,10 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn leaderboard() -> Result<LeaderboardPage, String> {
|
/// Fetch the leaderboard for one game type: Elo, wins, matches and points
|
||||||
let resp = Request::get("/api/leaderboard")
|
/// are all scoped to that game.
|
||||||
|
pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
||||||
|
let resp = Request::get(&format!("/api/leaderboard?game_type={game_type}"))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -7,9 +7,23 @@
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
use crate::model::{DealSummary, GameView};
|
use crate::model::{is_individual_game, DealSummary, GameView};
|
||||||
use crate::ws::GameSocket;
|
use crate::ws::GameSocket;
|
||||||
|
|
||||||
|
/// Names of the two sides: the players' names for games without
|
||||||
|
/// partnerships (classic two-player briscola), "Team A"/"Team B"
|
||||||
|
/// otherwise.
|
||||||
|
fn side_names(game: &GameView) -> (String, String) {
|
||||||
|
if is_individual_game(&game.game_type) {
|
||||||
|
(
|
||||||
|
game.player_name_for_team("A"),
|
||||||
|
game.player_name_for_team("B"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
("Team A".to_string(), "Team B".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
/// One explanatory row: icon, title, plain-language sentence, points chip.
|
||||||
fn summary_row(
|
fn summary_row(
|
||||||
icon: View,
|
icon: View,
|
||||||
@@ -31,9 +45,10 @@ fn summary_row(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The scoring rows of one completed deal (also used by the final screen).
|
/// The scoring rows of one completed deal (also used by the final screen).
|
||||||
pub fn deal_rows(summary: &DealSummary) -> View {
|
pub fn deal_rows(game: &GameView, summary: &DealSummary) -> View {
|
||||||
let a = summary.points.a;
|
let a = summary.points.a;
|
||||||
let b = summary.points.b;
|
let b = summary.points.b;
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
let cls = if summary.patta {
|
let cls = if summary.patta {
|
||||||
"score-row tie"
|
"score-row tie"
|
||||||
} else if a > b {
|
} else if a > b {
|
||||||
@@ -54,7 +69,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
|||||||
let points_row = summary_row(
|
let points_row = summary_row(
|
||||||
card_img("01D".to_string(), "score-mini"),
|
card_img("01D".to_string(), "score-mini"),
|
||||||
"Deal points",
|
"Deal points",
|
||||||
format!("Team A {a} · Team B {b}"),
|
format!("{name_a} {a} · {name_b} {b}"),
|
||||||
cls,
|
cls,
|
||||||
chip,
|
chip,
|
||||||
);
|
);
|
||||||
@@ -62,7 +77,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
|
|||||||
summary_row(
|
summary_row(
|
||||||
card_back("score-mini"),
|
card_back("score-mini"),
|
||||||
"Cards captured",
|
"Cards captured",
|
||||||
format!("Team A {} · Team B {}", c.a, c.b),
|
format!("{name_a} {} · {name_b} {}", c.a, c.b),
|
||||||
"score-row",
|
"score-row",
|
||||||
format!("{} · {}", c.a, c.b),
|
format!("{} · {}", c.a, c.b),
|
||||||
)
|
)
|
||||||
@@ -87,10 +102,11 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
} else {
|
} else {
|
||||||
(summary.points.a, summary.points.b)
|
(summary.points.a, summary.points.b)
|
||||||
};
|
};
|
||||||
|
let (name_a, name_b) = side_names(game);
|
||||||
view! {
|
view! {
|
||||||
div(class="totals") {
|
div(class="totals") {
|
||||||
div(class="total-row team-a") {
|
div(class="total-row team-a") {
|
||||||
span(class="total-label") { "Team A" }
|
span(class="total-label") { (name_a) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_a}%")) {}
|
||||||
}
|
}
|
||||||
@@ -100,7 +116,7 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(class="total-row team-b") {
|
div(class="total-row team-b") {
|
||||||
span(class="total-label") { "Team B" }
|
span(class="total-label") { (name_b) }
|
||||||
div(class="progress") {
|
div(class="progress") {
|
||||||
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
div(class="progress-fill", style=format!("width: {pct_b}%")) {}
|
||||||
}
|
}
|
||||||
@@ -151,7 +167,7 @@ pub fn deal_summary_modal(
|
|||||||
});
|
});
|
||||||
|
|
||||||
let title = format!("Deal {} — results", summary.deal);
|
let title = format!("Deal {} — results", summary.deal);
|
||||||
let rows = deal_rows(&summary);
|
let rows = deal_rows(&game, &summary);
|
||||||
let totals_view = totals(&game, &summary);
|
let totals_view = totals(&game, &summary);
|
||||||
|
|
||||||
let action = if game.spectator {
|
let action = if game.spectator {
|
||||||
|
|||||||
@@ -229,6 +229,18 @@ pub struct GameView {
|
|||||||
pub last_deal: Option<DealSummary>,
|
pub last_deal: Option<DealSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl GameView {
|
||||||
|
/// Display name of the player on team `team` ("A"/"B"), for games
|
||||||
|
/// without partnerships; falls back to the team label itself.
|
||||||
|
pub fn player_name_for_team(&self, team: &str) -> String {
|
||||||
|
self.players
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.team == team)
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.unwrap_or_else(|| team.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -408,6 +420,38 @@ pub struct GameTypesPage {
|
|||||||
pub results: Vec<GameTypeInfo>,
|
pub results: Vec<GameTypeInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The games to show in the lobby's and leaderboard's game selectors when
|
||||||
|
/// the `/api/game-types` fetch fails: match creation and per-game views
|
||||||
|
/// must still work.
|
||||||
|
pub fn fallback_game_types() -> Vec<GameTypeInfo> {
|
||||||
|
vec![
|
||||||
|
GameTypeInfo {
|
||||||
|
id: "scopone_scientifico".to_string(),
|
||||||
|
name: "Scopone scientifico".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
min_players: 4,
|
||||||
|
max_players: 4,
|
||||||
|
options_schema: serde_json::Value::Null,
|
||||||
|
},
|
||||||
|
GameTypeInfo {
|
||||||
|
id: "briscola".to_string(),
|
||||||
|
name: "Briscola".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
min_players: 2,
|
||||||
|
max_players: 2,
|
||||||
|
options_schema: serde_json::Value::Null,
|
||||||
|
},
|
||||||
|
GameTypeInfo {
|
||||||
|
id: "briscola_a_quattro".to_string(),
|
||||||
|
name: "Briscola a quattro".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
min_players: 4,
|
||||||
|
max_players: 4,
|
||||||
|
options_schema: serde_json::Value::Null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
/// One configured OIDC login provider (GET /api/auth/providers).
|
/// One configured OIDC login provider (GET /api/auth/providers).
|
||||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||||
pub struct AuthProvider {
|
pub struct AuthProvider {
|
||||||
@@ -454,6 +498,66 @@ pub struct PreviewPlayer {
|
|||||||
pub team: Option<String>,
|
pub team: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One ongoing game the caller is seated in (GET /api/me/games): the
|
||||||
|
/// lobby payload plus the game's display name, for the "Continue" list.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct MyGame {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_description: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub phase: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub players: Vec<PreviewPlayer>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub seats_open: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub your_seat: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MyGame {
|
||||||
|
/// Human-readable phase for the lobby list.
|
||||||
|
pub fn phase_label(&self) -> &'static str {
|
||||||
|
match self.phase.as_str() {
|
||||||
|
"lobby" => "Waiting for players",
|
||||||
|
"hand_end" => "Between hands",
|
||||||
|
_ => "In progress",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seated/total note, e.g. `"2/4 seated"`.
|
||||||
|
pub fn seats_text(&self) -> String {
|
||||||
|
match self.seats_open {
|
||||||
|
Some(open) => {
|
||||||
|
format!("{}/{} seated", self.players.len(), self.players.len() + open)
|
||||||
|
}
|
||||||
|
None => format!("{} seated", self.players.len()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The seated players' names, for the list subtitle.
|
||||||
|
pub fn players_text(&self) -> String {
|
||||||
|
self.players
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct MyGamesPage {
|
||||||
|
#[serde(default)]
|
||||||
|
pub results: Vec<MyGame>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One option to show in the join preview, rendered from the game's
|
/// One option to show in the join preview, rendered from the game's
|
||||||
/// schema and the engine-echoed value.
|
/// schema and the engine-echoed value.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
@@ -631,6 +735,13 @@ pub fn is_briscola(game_type: &str) -> bool {
|
|||||||
game_type == "briscola" || game_type == "briscola_a_quattro"
|
game_type == "briscola" || game_type == "briscola_a_quattro"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a game is played by two individuals rather than partnerships
|
||||||
|
/// (classic two-player briscola): team labels and team messages are
|
||||||
|
/// replaced by the players' names.
|
||||||
|
pub fn is_individual_game(game_type: &str) -> bool {
|
||||||
|
game_type == "briscola"
|
||||||
|
}
|
||||||
|
|
||||||
/// Human-readable suit name for a one-letter code (`D` -> "denari").
|
/// Human-readable suit name for a one-letter code (`D` -> "denari").
|
||||||
pub fn suit_name(suit: &str) -> String {
|
pub fn suit_name(suit: &str) -> String {
|
||||||
match suit {
|
match suit {
|
||||||
@@ -1142,6 +1253,78 @@ mod tests {
|
|||||||
assert_eq!("?", suit_name("?"));
|
assert_eq!("?", suit_name("?"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_individual_game_only_matches_two_player_briscola() {
|
||||||
|
assert!(is_individual_game("briscola"));
|
||||||
|
assert!(!is_individual_game("briscola_a_quattro"));
|
||||||
|
assert!(!is_individual_game("scopone_scientifico"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn player_name_for_team_resolves_names() {
|
||||||
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
"id": "b1",
|
||||||
|
"game_type": "briscola",
|
||||||
|
"phase": "playing",
|
||||||
|
"players": [
|
||||||
|
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
|
||||||
|
{"sub": "bob", "name": "Bob", "seat": 1, "team": "B"},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Alice", g.player_name_for_team("A"));
|
||||||
|
assert_eq!("Bob", g.player_name_for_team("B"));
|
||||||
|
// Unknown labels fall back to the label itself.
|
||||||
|
assert_eq!("C", g.player_name_for_team("C"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn my_game_phase_and_seats_text() {
|
||||||
|
let g: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g1",
|
||||||
|
"game_type": "briscola_a_quattro",
|
||||||
|
"game_name": "Briscola a quattro",
|
||||||
|
"phase": "lobby",
|
||||||
|
"players": [
|
||||||
|
{"name": "Alice", "seat": 0, "team": "A"},
|
||||||
|
{"name": "Bob", "seat": 1, "team": "B"},
|
||||||
|
],
|
||||||
|
"seats_open": 2,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Waiting for players", g.phase_label());
|
||||||
|
assert_eq!("2/4 seated", g.seats_text());
|
||||||
|
assert_eq!("Alice, Bob", g.players_text());
|
||||||
|
|
||||||
|
let playing: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g2",
|
||||||
|
"game_type": "briscola",
|
||||||
|
"phase": "playing",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("In progress", playing.phase_label());
|
||||||
|
assert_eq!("0 seated", playing.seats_text());
|
||||||
|
|
||||||
|
let between: MyGame = serde_json::from_value(json!({
|
||||||
|
"id": "g3",
|
||||||
|
"phase": "hand_end",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!("Between hands", between.phase_label());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fallback_game_types_cover_the_registered_games() {
|
||||||
|
let types = fallback_game_types();
|
||||||
|
let ids: Vec<&str> = types.iter().map(|g| g.id.as_str()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
vec!["scopone_scientifico", "briscola", "briscola_a_quattro"],
|
||||||
|
ids
|
||||||
|
);
|
||||||
|
// The lobby's first game (the creation default) must exist.
|
||||||
|
assert_eq!("scopone_scientifico", types[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn game_view_parses_briscola_payload() {
|
fn game_view_parses_briscola_payload() {
|
||||||
let g: GameView = serde_json::from_value(json!({
|
let g: GameView = serde_json::from_value(json!({
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ use sycamore::prelude::*;
|
|||||||
|
|
||||||
use crate::components::card::{card_back, card_img};
|
use crate::components::card::{card_back, card_img};
|
||||||
use crate::components::copy::CopyButton;
|
use crate::components::copy::CopyButton;
|
||||||
use crate::model::{hand_sort_key, suit_name, GameView, PlayerView, Scores};
|
use crate::model::{
|
||||||
|
hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores,
|
||||||
|
};
|
||||||
use crate::pages::game::watch_link;
|
use crate::pages::game::watch_link;
|
||||||
|
|
||||||
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
fn player_for_seat(game: &GameView, seat: usize) -> Option<PlayerView> {
|
||||||
@@ -25,7 +27,10 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
|||||||
if active { " active" } else { "" }
|
if active { " active" } else { "" }
|
||||||
);
|
);
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
|
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
|
||||||
let team = player.team.clone();
|
let team = player.team.clone();
|
||||||
|
view! { span(class="team-badge") { "Team " (team) } }
|
||||||
|
});
|
||||||
let points = player.points;
|
let points = player.points;
|
||||||
let captured = player.captured_count;
|
let captured = player.captured_count;
|
||||||
let backs = (0..player.cards_left)
|
let backs = (0..player.cards_left)
|
||||||
@@ -35,7 +40,7 @@ fn seat_panel(game: GameView, seat: usize, position: &'static str) -> View {
|
|||||||
div(class=cls) {
|
div(class=cls) {
|
||||||
div(class="seat-name") {
|
div(class="seat-name") {
|
||||||
(name)
|
(name)
|
||||||
span(class="team-badge") { "Team " (team) }
|
(team_badge)
|
||||||
}
|
}
|
||||||
div(class="seat-cards") { (backs) }
|
div(class="seat-cards") { (backs) }
|
||||||
div(class="seat-stats") {
|
div(class="seat-stats") {
|
||||||
@@ -160,6 +165,19 @@ pub fn briscola_table(
|
|||||||
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
|
||||||
let target = game.target_score;
|
let target = game.target_score;
|
||||||
let deal = game.hand_number;
|
let deal = game.hand_number;
|
||||||
|
// Two-player briscola has no partnerships: show the players' names.
|
||||||
|
let score_line = if is_individual_game(&game.game_type) {
|
||||||
|
format!(
|
||||||
|
"{} {} — {} {} (target {})",
|
||||||
|
game.player_name_for_team("A"),
|
||||||
|
scores.a,
|
||||||
|
scores.b,
|
||||||
|
game.player_name_for_team("B"),
|
||||||
|
target
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("Team A {} — {} Team B (target {})", scores.a, scores.b, target)
|
||||||
|
};
|
||||||
|
|
||||||
let (grid_cls, top, left, right) = if player_count == 2 {
|
let (grid_cls, top, left, right) = if player_count == 2 {
|
||||||
(
|
(
|
||||||
@@ -234,10 +252,7 @@ pub fn briscola_table(
|
|||||||
span { "Deal " (deal) }
|
span { "Deal " (deal) }
|
||||||
(trump_badge)
|
(trump_badge)
|
||||||
(spectator_badge)
|
(spectator_badge)
|
||||||
span(class="hud-scores") {
|
span(class="hud-scores") { (score_line) }
|
||||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
|
||||||
(target) ")"
|
|
||||||
}
|
|
||||||
span(class=turn_cls) { (turn_note) }
|
span(class=turn_cls) { (turn_note) }
|
||||||
(countdown)
|
(countdown)
|
||||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||||
|
|||||||
+20
-6
@@ -12,8 +12,8 @@ use crate::components::copy::CopyButton;
|
|||||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
|
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
|
||||||
ServerMessage,
|
PlayerView, Scores, ServerMessage,
|
||||||
};
|
};
|
||||||
use crate::pages::briscola::briscola_table;
|
use crate::pages::briscola::briscola_table;
|
||||||
use crate::ws::{self, GameSocket};
|
use crate::ws::{self, GameSocket};
|
||||||
@@ -433,19 +433,23 @@ fn LobbyView(game: GameView) -> View {
|
|||||||
let join_code = game.join_code.clone();
|
let join_code = game.join_code.clone();
|
||||||
let watch_link = watch_link(&game.id);
|
let watch_link = watch_link(&game.id);
|
||||||
|
|
||||||
|
let show_teams = !is_individual_game(&game.game_type);
|
||||||
let players = game
|
let players = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| {
|
.map(|p| {
|
||||||
let name = p.name.clone();
|
let name = p.name.clone();
|
||||||
let seat = p.seat;
|
let seat = p.seat;
|
||||||
|
let team_badge = show_teams.then(|| {
|
||||||
let team = p.team.clone();
|
let team = p.team.clone();
|
||||||
|
view! { span(class="badge text-bg-info") { "Team " (team) } }
|
||||||
|
});
|
||||||
view! {
|
view! {
|
||||||
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
li(class="list-group-item d-flex justify-content-between align-items-center") {
|
||||||
strong { (name) }
|
strong { (name) }
|
||||||
span {
|
span {
|
||||||
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
span(class="badge text-bg-secondary me-2") { "Seat " (seat) }
|
||||||
span(class="badge text-bg-info") { "Team " (team) }
|
(team_badge)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -744,14 +748,24 @@ fn game_over_view(over: Option<(Scores, Option<String>)>, game: Option<GameView>
|
|||||||
None => view! {},
|
None => view! {},
|
||||||
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);
|
// Two-player briscola has no teams: name the winner instead.
|
||||||
|
let winner_label = match game.as_ref() {
|
||||||
|
Some(g) if is_individual_game(&g.game_type) => {
|
||||||
|
g.player_name_for_team(&winner)
|
||||||
|
}
|
||||||
|
_ => format!("Team {winner}"),
|
||||||
|
};
|
||||||
|
let line = format!("{winner_label} wins {} – {}", scores.a, scores.b);
|
||||||
// Spectators have no match history to look at.
|
// Spectators have no match history to look at.
|
||||||
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
let spectator = game.as_ref().is_some_and(|g| g.spectator);
|
||||||
// Explain the final hand's (scopone) or deal's (briscola)
|
// Explain the final hand's (scopone) or deal's (briscola)
|
||||||
// scoring before the result.
|
// scoring before the result.
|
||||||
let final_summary: View = if briscola {
|
let final_summary: View = if briscola {
|
||||||
match game.as_ref().and_then(|g| g.last_deal.clone()) {
|
match game.as_ref() {
|
||||||
Some(deal) => deal_rows(&deal),
|
Some(g) => match g.last_deal.clone() {
|
||||||
|
Some(deal) => deal_rows(g, &deal),
|
||||||
|
None => view! {},
|
||||||
|
},
|
||||||
None => view! {},
|
None => view! {},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,20 +1,53 @@
|
|||||||
//! Global leaderboard page.
|
//! Per-game leaderboard page.
|
||||||
|
//!
|
||||||
|
//! Elo ratings are stored per game type, so the board is scoped to the
|
||||||
|
//! game chosen in the dropdown: Elo, wins, matches and points all come
|
||||||
|
//! from that game's matches only.
|
||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{fmt_points, LeaderboardPage};
|
use crate::model::{fallback_game_types, fmt_points, LeaderboardPage};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LeaderboardPage() -> View {
|
pub fn LeaderboardPage() -> View {
|
||||||
let page = create_signal(Option::<LeaderboardPage>::None);
|
let page = create_signal(Option::<LeaderboardPage>::None);
|
||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
|
let game_types = create_signal(fallback_game_types());
|
||||||
|
let selected_game = create_signal("scopone_scientifico".to_string());
|
||||||
|
|
||||||
|
// Reload whenever the selection changes; a response for a game the
|
||||||
|
// user has already switched away from is dropped.
|
||||||
|
create_effect(move || {
|
||||||
|
let game_type = selected_game.get_clone();
|
||||||
|
page.set(None);
|
||||||
|
spawn_local(async move {
|
||||||
|
match api::leaderboard(&game_type).await {
|
||||||
|
Ok(p) => {
|
||||||
|
if selected_game.get_clone() == game_type {
|
||||||
|
page.set(Some(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
if selected_game.get_clone() == game_type {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::leaderboard().await {
|
match api::game_types().await {
|
||||||
Ok(p) => page.set(Some(p)),
|
Ok(types) if !types.is_empty() => {
|
||||||
Err(e) => error.set(Some(e)),
|
let current = selected_game.get_clone();
|
||||||
|
if !types.iter().any(|g| g.id == current) {
|
||||||
|
selected_game.set(types[0].id.clone());
|
||||||
|
}
|
||||||
|
game_types.set(types);
|
||||||
|
}
|
||||||
|
_ => {} // keep the fallback list
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -22,7 +55,30 @@ pub fn LeaderboardPage() -> View {
|
|||||||
div {
|
div {
|
||||||
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
(crate::components::nav::app_nav("/leaderboard", || view! {}))
|
||||||
div(class="container page-shell py-4") {
|
div(class="container page-shell py-4") {
|
||||||
h1(class="h3 mb-4") { "Leaderboard" }
|
div(
|
||||||
|
class="d-flex flex-wrap gap-3 align-items-center \
|
||||||
|
justify-content-between mb-4"
|
||||||
|
) {
|
||||||
|
h1(class="h3 mb-0") { "Leaderboard" }
|
||||||
|
div(class="d-flex align-items-center gap-2") {
|
||||||
|
label(class="form-label mb-0", r#for="leaderboard-game") {
|
||||||
|
"Game"
|
||||||
|
}
|
||||||
|
select(
|
||||||
|
class="form-select w-auto",
|
||||||
|
id="leaderboard-game",
|
||||||
|
bind:value=selected_game,
|
||||||
|
) {
|
||||||
|
Keyed(
|
||||||
|
list=game_types,
|
||||||
|
view=|g| view! {
|
||||||
|
option(value=g.id.clone()) { (g.name) }
|
||||||
|
},
|
||||||
|
key=|g| g.id.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
(toast(error))
|
(toast(error))
|
||||||
(move || match page.get_clone() {
|
(move || match page.get_clone() {
|
||||||
None => view! {
|
None => view! {
|
||||||
|
|||||||
+77
-33
@@ -15,39 +15,10 @@ use crate::api;
|
|||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
|
MyGame, OptionField, OptionKind, PlayerRating, User,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Used when the game-types fetch fails: match creation must still work.
|
|
||||||
fn fallback_game_types() -> Vec<GameTypeInfo> {
|
|
||||||
vec![
|
|
||||||
GameTypeInfo {
|
|
||||||
id: "scopone_scientifico".to_string(),
|
|
||||||
name: "Scopone scientifico".to_string(),
|
|
||||||
description: String::new(),
|
|
||||||
min_players: 4,
|
|
||||||
max_players: 4,
|
|
||||||
options_schema: serde_json::Value::Null,
|
|
||||||
},
|
|
||||||
GameTypeInfo {
|
|
||||||
id: "briscola".to_string(),
|
|
||||||
name: "Briscola".to_string(),
|
|
||||||
description: String::new(),
|
|
||||||
min_players: 2,
|
|
||||||
max_players: 2,
|
|
||||||
options_schema: serde_json::Value::Null,
|
|
||||||
},
|
|
||||||
GameTypeInfo {
|
|
||||||
id: "briscola_a_quattro".to_string(),
|
|
||||||
name: "Briscola a quattro".to_string(),
|
|
||||||
description: String::new(),
|
|
||||||
min_players: 4,
|
|
||||||
max_players: 4,
|
|
||||||
options_schema: serde_json::Value::Null,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Human-readable label for a provider name ("google" -> "Google").
|
/// Human-readable label for a provider name ("google" -> "Google").
|
||||||
fn provider_label(name: &str) -> String {
|
fn provider_label(name: &str) -> String {
|
||||||
let mut chars = name.chars();
|
let mut chars = name.chars();
|
||||||
@@ -266,15 +237,20 @@ fn join_preview_panel(
|
|||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let seated = p.players.len();
|
let seated = p.players.len();
|
||||||
|
let show_teams = !is_individual_game(&p.game_type);
|
||||||
let players = p
|
let players = p
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.map(|player| {
|
.map(|player| {
|
||||||
let name = player.name.clone();
|
let name = player.name.clone();
|
||||||
let team = player.team.as_ref().map(|t| {
|
let team = if show_teams {
|
||||||
|
player.team.as_ref().map(|t| {
|
||||||
let label = t.clone();
|
let label = t.clone();
|
||||||
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
|
||||||
});
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
view! {
|
view! {
|
||||||
li(
|
li(
|
||||||
class="list-group-item d-flex justify-content-between \
|
class="list-group-item d-flex justify-content-between \
|
||||||
@@ -316,6 +292,8 @@ pub fn LobbyPage() -> View {
|
|||||||
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
let auth_providers = create_signal(Vec::<AuthProvider>::new());
|
||||||
// The player's Elo ratings, one row per game type played.
|
// The player's Elo ratings, one row per game type played.
|
||||||
let ratings = create_signal(Vec::<PlayerRating>::new());
|
let ratings = create_signal(Vec::<PlayerRating>::new());
|
||||||
|
// Live games the player is seated in, for the "Continue" list.
|
||||||
|
let my_games = create_signal(Vec::<MyGame>::new());
|
||||||
let error = create_signal(Option::<String>::None);
|
let error = create_signal(Option::<String>::None);
|
||||||
let code = create_signal(String::new());
|
let code = create_signal(String::new());
|
||||||
let game_types = create_signal(fallback_game_types());
|
let game_types = create_signal(fallback_game_types());
|
||||||
@@ -410,6 +388,9 @@ pub fn LobbyPage() -> View {
|
|||||||
if let Ok(p) = api::my_ratings().await {
|
if let Ok(p) = api::my_ratings().await {
|
||||||
ratings.set(p.results);
|
ratings.set(p.results);
|
||||||
}
|
}
|
||||||
|
if let Ok(games) = api::my_games().await {
|
||||||
|
my_games.set(games);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
user.set(Some(me));
|
user.set(Some(me));
|
||||||
@@ -573,6 +554,69 @@ pub fn LobbyPage() -> View {
|
|||||||
"Play scopone scientifico and briscola with friends."
|
"Play scopone scientifico and briscola with friends."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ongoing games: a "Continue" link back into a
|
||||||
|
// match whose page was accidentally closed.
|
||||||
|
(move || {
|
||||||
|
let games = my_games.get_clone();
|
||||||
|
if games.is_empty() {
|
||||||
|
return view! {};
|
||||||
|
}
|
||||||
|
let types = game_types.get_clone();
|
||||||
|
let rows = games
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| {
|
||||||
|
let name = if g.game_name.is_empty() {
|
||||||
|
types
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.id == g.game_type)
|
||||||
|
.map(|t| t.name.clone())
|
||||||
|
.unwrap_or_else(|| g.game_type.clone())
|
||||||
|
} else {
|
||||||
|
g.game_name.clone()
|
||||||
|
};
|
||||||
|
let phase = g.phase_label();
|
||||||
|
let seats = g.seats_text();
|
||||||
|
let players = g.players_text();
|
||||||
|
let players_note = if players.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" · {players}")
|
||||||
|
};
|
||||||
|
let href = format!("/game/{}", g.id);
|
||||||
|
view! {
|
||||||
|
li(
|
||||||
|
class="list-group-item d-flex flex-wrap \
|
||||||
|
justify-content-between \
|
||||||
|
align-items-center gap-2"
|
||||||
|
) {
|
||||||
|
div {
|
||||||
|
div(class="fw-semibold") { (name) }
|
||||||
|
div(class="text-secondary small") {
|
||||||
|
(phase) " · " (seats)
|
||||||
|
(players_note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a(
|
||||||
|
class="btn btn-sm btn-warning",
|
||||||
|
href=href,
|
||||||
|
) {
|
||||||
|
"Continue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
view! {
|
||||||
|
div(class="card mb-4") {
|
||||||
|
div(class="card-header") { "Your games" }
|
||||||
|
div(class="card-body p-0") {
|
||||||
|
ul(class="list-group list-group-flush") {
|
||||||
|
(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
div(class="row g-4") {
|
div(class="row g-4") {
|
||||||
div(class="col-lg-7") {
|
div(class="col-lg-7") {
|
||||||
div(class="card h-100") {
|
div(class="card h-100") {
|
||||||
|
|||||||
+134
-7
@@ -37,7 +37,8 @@ body {
|
|||||||
|
|
||||||
.hud {
|
.hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem 1.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
@@ -76,7 +77,10 @@ body {
|
|||||||
.table-grid {
|
.table-grid {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 2.4fr 1fr;
|
/* minmax(0, …) lets the tracks shrink below their content's
|
||||||
|
* min-content width; without it the center's move banner and the
|
||||||
|
* seat panels push the grid wider than the screen on phones. */
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 2.4fr) minmax(0, 1fr);
|
||||||
grid-template-rows: auto 1fr auto;
|
grid-template-rows: auto 1fr auto;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
". top ."
|
". top ."
|
||||||
@@ -114,6 +118,7 @@ body {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
min-height: 116px;
|
min-height: 116px;
|
||||||
|
min-width: 0;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@@ -126,7 +131,8 @@ body {
|
|||||||
.seat-name {
|
.seat-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.15rem 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -167,6 +173,7 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 230px;
|
min-height: 230px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-cards {
|
.table-cards {
|
||||||
@@ -240,8 +247,11 @@ body {
|
|||||||
|
|
||||||
.move-banner {
|
.move-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
max-width: 100%;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.35rem 1rem;
|
padding: 0.35rem 1rem;
|
||||||
@@ -292,7 +302,8 @@ body {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: 320px;
|
min-width: min(320px, calc(100vw - 1.5rem));
|
||||||
|
max-width: calc(100vw - 1.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-options {
|
.capture-options {
|
||||||
@@ -327,8 +338,8 @@ body {
|
|||||||
/* ---------- hand-end scoring summary ---------- */
|
/* ---------- hand-end scoring summary ---------- */
|
||||||
|
|
||||||
.summary-panel {
|
.summary-panel {
|
||||||
min-width: 420px;
|
min-width: min(420px, calc(100vw - 1.5rem));
|
||||||
max-width: 560px;
|
max-width: min(560px, calc(100vw - 1.5rem));
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +510,7 @@ body {
|
|||||||
|
|
||||||
.playing-card.stock-card {
|
.playing-card.stock-card {
|
||||||
height: 48px;
|
height: 48px;
|
||||||
margin-left: -34px;
|
margin-left: -18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playing-card.stock-card:first-child {
|
.playing-card.stock-card:first-child {
|
||||||
@@ -540,3 +551,119 @@ body {
|
|||||||
"center"
|
"center"
|
||||||
"bottom";
|
"bottom";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- phones ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.hud {
|
||||||
|
gap: 0.3rem 0.75rem;
|
||||||
|
padding: 0.45rem 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-grid {
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat {
|
||||||
|
min-height: 92px;
|
||||||
|
padding: 0.35rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat-stats {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex-wrap lets the hidden-hand backs drop to the next row instead
|
||||||
|
* of forcing the seat wider than its track. */
|
||||||
|
.seat-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
min-height: 180px;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move-banner {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.mini {
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.table-card {
|
||||||
|
height: 62px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.move-card {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.hand-card {
|
||||||
|
height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand {
|
||||||
|
min-height: 100px;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock {
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.stock-card {
|
||||||
|
height: 38px;
|
||||||
|
margin-left: -12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.briscola-card {
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-row {
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-icon {
|
||||||
|
width: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-card.score-mini {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-row {
|
||||||
|
grid-template-columns: 3.5rem 1fr 3.2rem;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user