Compare commits

..
Author SHA1 Message Date
woggioni d2dae476f0 Prefer preferred_username for the displayed player name
CI / Build and push docker image (push) Successful in 1m8s
The free-form "name" claim can change and two accounts can share it;
preferred_username is the account's stable handle. display_name() now
falls back preferred_username -> name -> email -> sub, with tests
pinning the claim priority.
2026-10-06 17:53:27 +08:00
woggioni 18877b8109 Offer a Continue link for ongoing games in the lobby
A player who accidentally closes the game page can now get back to it
from the lobby.

Backend:
- GameStore.list_ongoing() returns live unfinished sessions (newest
  first), skipping expired keys and finished matches;
- GET /api/me/games lists the games the caller is seated in, with the
  game name/description, players, open seats and your seat (no join
  code);
- store, platform route and app integration tests.

Frontend:
- MyGame/MyGamesPage model plus api::my_games();
- the lobby shows a "Your games" card with phase, seats, players and a
  Continue button linking to /game/<id>; hidden when empty or logged
  out.
2026-10-06 17:41:44 +08:00
woggioni b821274f0b Use player names in two-player briscola
Classic briscola has no partnerships: its "teams" are just the two
players, but the UI labelled them Team A/Team B.

- add is_individual_game() and GameView::player_name_for_team() to the
  model;
- the briscola table HUD reads "Alice 0 — 0 Bob (target 61)" and the
  seat panels drop the Team badge for two-player games;
- the deal summary and game-over screen name the sides and the winner;
- the waiting room and join preview hide the Team badge.

briscola a quattro and scopone keep their team labels, and the match
history table is unchanged.
2026-10-06 17:37:55 +08:00
14 changed files with 607 additions and 34 deletions
+1
View File
@@ -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 |
| `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/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/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=`) |
@@ -47,8 +47,13 @@ def get_ws_user(ws: WebSocket) -> Optional[OIDCUser]:
def display_name(user: OIDCUser) -> str:
"""Best-effort human-readable name for a user."""
for key in ("name", "preferred_username", "email"):
"""Best-effort human-readable name for a user.
``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)
if isinstance(value, str) and 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:
@app.GET("/api/game-types")
@operation(summary="List available games",
@@ -341,3 +369,27 @@ def register(app: KayaApp, platform: "Platform") -> None:
await send_json(
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 time
from abc import ABC, abstractmethod
from datetime import datetime
from datetime import datetime, timezone
from logging import getLogger
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
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]:
if not value:
return None
@@ -137,6 +146,15 @@ class GameStore(ABC):
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
def lock(self, game_id: str) -> AsyncContextManager[None]:
"""Async context manager serializing mutations of one game."""
@@ -241,6 +259,27 @@ class RedisGameStore(GameStore):
_, count = await pipe.execute()
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
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
pubsub = self._redis.pubsub()
@@ -352,6 +391,18 @@ class InMemoryGameStore(GameStore):
async def count_ongoing(self) -> int:
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
async def subscribe(self, game_id: str) -> AsyncIterator[AsyncIterator[None]]:
queue: asyncio.Queue = asyncio.Queue()
@@ -11,11 +11,17 @@ import unittest
from types import SimpleNamespace
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 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:
@@ -87,6 +93,32 @@ class GetWsUserTest(unittest.TestCase):
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):
def test_anonymous_session_has_no_user(self) -> None:
self.assertIsNone(current_user(_providers("default"), _ctx({})))
@@ -188,6 +188,63 @@ class GamesRouteTest(unittest.TestCase):
response = await client.get("/api/games/does-not-exist")
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):
@async_test
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import time
import unittest
from datetime import datetime, timezone
from typing import Any, Optional, cast
from tavolo.platform import GameSession, Seat
@@ -48,7 +49,7 @@ class _FakePipeline:
return False
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:
self._ops.append(("zadd", key, mapping))
@@ -88,6 +89,14 @@ class _FakeRedis:
async def zrem(self, key: str, member: str) -> int:
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(
self,
name: str,
@@ -113,7 +122,7 @@ class _FakeRedis:
def apply(self, op: tuple) -> object:
name, *args = op
if name == "set":
self.keys[args[0]] = "payload"
self.keys[args[0]] = args[1]
return True
if name == "zadd":
self.zsets.setdefault(args[0], {}).update(args[1])
@@ -247,6 +256,28 @@ class InMemoryGameStoreTest(unittest.TestCase):
await store.save(other)
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 def test_deadline_queue(self) -> None:
store = InMemoryGameStore(_registry())
@@ -311,6 +342,33 @@ class RedisGameStoreTest(unittest.TestCase):
self.assertEqual(1, await store.count_ongoing())
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 def test_due_deadlines_uses_zrange_byscore(self) -> None:
redis = _FakeRedis()
+46
View File
@@ -236,6 +236,52 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(400, text.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 def test_get_unknown_game(self) -> None:
transport = ASGITransport(app=app)
+14
View File
@@ -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> {
let url = match cursor {
Some(c) => format!("/api/me/matches?limit=10&cursor={c}"),
+23 -7
View File
@@ -7,9 +7,23 @@
use sycamore::prelude::*;
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;
/// 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.
fn summary_row(
icon: View,
@@ -31,9 +45,10 @@ fn summary_row(
}
/// 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 b = summary.points.b;
let (name_a, name_b) = side_names(game);
let cls = if summary.patta {
"score-row tie"
} else if a > b {
@@ -54,7 +69,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
let points_row = summary_row(
card_img("01D".to_string(), "score-mini"),
"Deal points",
format!("Team A {a} · Team B {b}"),
format!("{name_a} {a} · {name_b} {b}"),
cls,
chip,
);
@@ -62,7 +77,7 @@ pub fn deal_rows(summary: &DealSummary) -> View {
summary_row(
card_back("score-mini"),
"Cards captured",
format!("Team A {} · Team B {}", c.a, c.b),
format!("{name_a} {} · {name_b} {}", c.a, c.b),
"score-row",
format!("{} · {}", c.a, c.b),
)
@@ -87,10 +102,11 @@ fn totals(game: &GameView, summary: &DealSummary) -> View {
} else {
(summary.points.a, summary.points.b)
};
let (name_a, name_b) = side_names(game);
view! {
div(class="totals") {
div(class="total-row team-a") {
span(class="total-label") { "Team A" }
span(class="total-label") { (name_a) }
div(class="progress") {
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") {
span(class="total-label") { "Team B" }
span(class="total-label") { (name_b) }
div(class="progress") {
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 rows = deal_rows(&summary);
let rows = deal_rows(&game, &summary);
let totals_view = totals(&game, &summary);
let action = if game.spectator {
+139
View File
@@ -229,6 +229,18 @@ pub struct GameView {
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)]
#[serde(tag = "type", rename_all = "snake_case")]
#[allow(dead_code)]
@@ -486,6 +498,66 @@ pub struct PreviewPlayer {
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
/// schema and the engine-echoed value.
#[derive(Debug, Clone, PartialEq)]
@@ -663,6 +735,13 @@ pub fn is_briscola(game_type: &str) -> bool {
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").
pub fn suit_name(suit: &str) -> String {
match suit {
@@ -1174,6 +1253,66 @@ mod tests {
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();
+22 -7
View File
@@ -7,7 +7,9 @@ use sycamore::prelude::*;
use crate::components::card::{card_back, card_img};
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;
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 { "" }
);
let name = player.name.clone();
let team = player.team.clone();
let team_badge = (!is_individual_game(&game.game_type)).then(|| {
let team = player.team.clone();
view! { span(class="team-badge") { "Team " (team) } }
});
let points = player.points;
let captured = player.captured_count;
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="seat-name") {
(name)
span(class="team-badge") { "Team " (team) }
(team_badge)
}
div(class="seat-cards") { (backs) }
div(class="seat-stats") {
@@ -160,6 +165,19 @@ pub fn briscola_table(
let scores = game.scores.unwrap_or(Scores { a: 0, b: 0 });
let target = game.target_score;
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 {
(
@@ -234,10 +252,7 @@ pub fn briscola_table(
span { "Deal " (deal) }
(trump_badge)
(spectator_badge)
span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target "
(target) ")"
}
span(class="hud-scores") { (score_line) }
span(class=turn_cls) { (turn_note) }
(countdown)
CopyButton(text=watch_link, label="Copy watch link".to_string())
+21 -7
View File
@@ -12,8 +12,8 @@ use crate::components::copy::CopyButton;
use crate::components::summary::{hand_summary_modal, summary_rows};
use crate::components::toast::toast;
use crate::model::{
card_label, hand_sort_key, is_briscola, GameView, MoveView, PlayerView, Scores,
ServerMessage,
card_label, hand_sort_key, is_briscola, is_individual_game, GameView, MoveView,
PlayerView, Scores, ServerMessage,
};
use crate::pages::briscola::briscola_table;
use crate::ws::{self, GameSocket};
@@ -433,19 +433,23 @@ fn LobbyView(game: GameView) -> View {
let join_code = game.join_code.clone();
let watch_link = watch_link(&game.id);
let show_teams = !is_individual_game(&game.game_type);
let players = game
.players
.iter()
.map(|p| {
let name = p.name.clone();
let seat = p.seat;
let team = p.team.clone();
let team_badge = show_teams.then(|| {
let team = p.team.clone();
view! { span(class="badge text-bg-info") { "Team " (team) } }
});
view! {
li(class="list-group-item d-flex justify-content-between align-items-center") {
strong { (name) }
span {
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! {},
Some((scores, winner)) => {
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.
let spectator = game.as_ref().is_some_and(|g| g.spectator);
// Explain the final hand's (scopone) or deal's (briscola)
// scoring before the result.
let final_summary: View = if briscola {
match game.as_ref().and_then(|g| g.last_deal.clone()) {
Some(deal) => deal_rows(&deal),
match game.as_ref() {
Some(g) => match g.last_deal.clone() {
Some(deal) => deal_rows(g, &deal),
None => view! {},
},
None => view! {},
}
} else {
+79 -6
View File
@@ -15,8 +15,8 @@ use crate::api;
use crate::components::card::card_back;
use crate::components::toast::toast;
use crate::model::{
fallback_game_types, AuthProvider, GameTypeInfo, JoinPreview, OptionField, OptionKind,
PlayerRating, User,
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
MyGame, OptionField, OptionKind, PlayerRating, User,
};
/// Human-readable label for a provider name ("google" -> "Google").
@@ -237,15 +237,20 @@ fn join_preview_panel(
})
.collect::<Vec<_>>();
let seated = p.players.len();
let show_teams = !is_individual_game(&p.game_type);
let players = p
.players
.iter()
.map(|player| {
let name = player.name.clone();
let team = player.team.as_ref().map(|t| {
let label = t.clone();
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
});
let team = if show_teams {
player.team.as_ref().map(|t| {
let label = t.clone();
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
})
} else {
None
};
view! {
li(
class="list-group-item d-flex justify-content-between \
@@ -287,6 +292,8 @@ pub fn LobbyPage() -> View {
let auth_providers = create_signal(Vec::<AuthProvider>::new());
// The player's Elo ratings, one row per game type played.
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 code = create_signal(String::new());
let game_types = create_signal(fallback_game_types());
@@ -381,6 +388,9 @@ pub fn LobbyPage() -> View {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
}
});
}
user.set(Some(me));
@@ -544,6 +554,69 @@ pub fn LobbyPage() -> View {
"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="col-lg-7") {
div(class="card h-100") {