Compare commits

...
Author SHA1 Message Date
woggioni 9eea293471 Give the page background a casino felt texture
CI / Build and push docker image (push) Successful in 59s
Replace the flat radial green with a layered body background: a soft
overhead light and an edge vignette over a fine grain tiled across the
green gradient.

The grain is a tiny seamless SVG tile (feTurbulence fractal noise mapped
to semi-transparent black speckles, stitchTiles so it repeats without
seams) committed under web/assets, so it rides the existing asset
pipeline and works offline; the palette and panel/card colors are
unchanged.
2026-10-09 09:27:05 +08:00
woggioni 4ba5ae1434 Give the match history table fixed columns
CI / Build and push docker image (push) Successful in 1m6s
Rows rendered only their own match's team labels while the header was
the union across all loaded matches. Scopone and briscola have two team
labels and briscola chiamata has three, so a mixed history produced a
five-column header above rows with two or three team cells - every cell
after them (score, winner, you, elo) landed under the wrong header, and
the columns changed with the set of game types loaded.

Replace the variable team columns with a single "Teams" column: the
table is now Finished | Game | Teams | Score | Winner | You | Elo for
every game. The cell lists each team or role on its own line, ordered
Chiamante/Socio/Difensori for chiamata and alphabetically (Team A,
Team B, ...) otherwise, with team-less players last; names read
"Bob, Carol & Dave".
2026-10-09 09:18:46 +08:00
woggioni 0f9fb9b6bb Make an absent chiamata bidder pass instead of bidding randomly
CI / Build and push docker image (push) Successful in 1m37s
The auction timeout bid a random legal rank, which could hand the
auction (and the call) to a disconnected player. An absent bidder now
always passes; only the last player standing before any bid cannot
pass, and is made to bid the ace - its suit is picked at random by the
existing call timeout. This keeps a deal from stalling without a caller
while never letting an absent player outbid someone.

The deadline log line now says which of the two happened.
2026-10-09 06:51:43 +08:00
woggioni 322ca27bf4 Stop the chiamata call picker from clipping on short windows
The call picker stacked the four 120px cards vertically, making the
dialog about 780px tall, and the overlay centered its content with no
scroll fallback. On typical laptop window heights that pushed the title
and options off-screen (the heading sat at y=-110 on a 560px viewport),
leaving what looked like a free-floating selection with no explanation
and no way to scroll.

Make dialogs scroll instead of clipping: the overlay scrolls and the
panel centers with `margin: auto`, which keeps every modal (the capture
picker and the scoring summaries too) centered when it fits and
reachable when it does not. Lay the call options out in a compact grid
(four across, 2x2 on phones) with 84px cards, name the won rank in the
title ("You won with the Re - name the card") and label each option
"Partner" or "Plays alone" so the choice reads at a glance.
2026-10-09 06:51:39 +08:00
woggioni 0674e06565 Redirect to login on 401 and return afterwards
The SPA now sends the user to a new /login page whenever an API call
answers 401, remembering the page they were on. The OIDC callback always
lands back on the configured post-login redirect, so the saved page is
restored client-side once a session exists.

- auth: save/restore the return path in sessionStorage; defer the
  navigation so the failing request can finish first
- api: centralize 401 handling (me() still treats 401 as logged out)
- login page + shared login card, extracted from the lobby
- guard async signal writes so an in-flight request cannot touch a page
  unmounted by the redirect
2026-10-09 05:53:21 +08:00
21 changed files with 689 additions and 199 deletions
+4 -3
View File
@@ -56,9 +56,10 @@ Every turn shows a countdown (`TURN_TIMEOUT_SECONDS`, default 30s). If a
player does not move — disconnected or fallen asleep — the server plays a
random legal card for them (choosing among the legal captures when the
rules require a capture), so one absent player cannot stall the table.
Briscola chiamata arms the same countdown for its auction bids and for
the auction winner's called-card choice (auto-bid or auto-pass, then a
random suit of the won rank).
Briscola chiamata arms the same countdown for its auction turns and for
the auction winner's called-card choice: an absent bidder passes, the
last player standing with no bid is made to bid the ace, and a timed-out
call names a random suit of the won rank.
## Watching
+7 -6
View File
@@ -375,9 +375,9 @@ timeout is `TURN_TIMEOUT_SECONDS` (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
queue (see above), not from timers tied to client connections, so the
match keeps progressing even with every player disconnected. Briscola
chiamata uses the same timeout for its auction bids (auto-bid or
auto-pass) and for the called-card choice (a random suit of the won
rank).
chiamata uses the same timeout for its auction turns (an absent bidder
passes; the last player standing with no bid is made to bid the ace) and
for the called-card choice (a random suit of the won rank).
### Hand-end summary
@@ -460,9 +460,10 @@ player must dismiss. A play attempted in this phase is rejected with an
replayed with the next dealer. A match is a single deal, so the caller
and partner (or the lone caller) against the other players map onto
the platform's two-team result, Elo and history.
- Auction bids, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
auto-called or auto-played for).
- Auction turns, the called-card choice and the plays all have their own
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
player standing with no bid is made to bid the ace, an absent winner's
call names a random suit of the won rank, and plays are auto-played.
## Development
+5 -3
View File
@@ -41,9 +41,11 @@ Three variants share one rules core:
Timeouts are constructor arguments (`turn_timeout_seconds`,
`hand_ack_timeout_seconds`), wired from the environment by the
application composition root. Briscola chiamata uses the turn timeout
for its auction bids, the called-card choice and the plays; it never
enters the between-deals acknowledgement phase (a single deal ends the
match), so the hand-ack timeout is accepted but unused.
for its auction turns, the called-card choice and the plays: an absent
bidder passes, the last player standing with no bid is made to bid the
ace, and an absent winner's call names a random suit of the won rank. It
never enters the between-deals acknowledgement phase (a single deal ends
the match), so the hand-ack timeout is accepted but unused.
## Rules
@@ -797,12 +797,13 @@ def _end_deal(state: ChiamataState) -> None:
# -- server-side auto actions ---------------------------------------------------
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
"""Act for the bidder on turn: bid a random legal rank, else pass.
def auto_bid(state: ChiamataState) -> None:
"""Act for the bidder on turn: always pass, unless forced to bid.
The only player who cannot afford to pass — the last one standing
before any bid — always has every rank available, so a bid is always
possible there.
An absent player never outbids anyone: they leave the auction. Only
the last player standing before any bid cannot pass — they are made
to bid the ace, whose suit :func:`auto_call` picks at random if the
call itself times out too.
"""
if state.phase != PHASE_AUCTION:
raise GameNotStarted("the auction is not open")
@@ -810,10 +811,8 @@ def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
if auction is None: # pragma: no cover - guarded by phase
raise GameNotStarted("the auction is not open")
player = _player_at(state, auction.turn)
ranks = legal_bid_ranks(state)
if ranks:
chooser = rng or _rng
bid_rank(state, player.sub, chooser.choice(ranks))
if len(auction.active) == 1 and auction.current is None:
bid_rank(state, player.sub, 1)
return
pass_auction(state, player.sub)
@@ -485,10 +485,18 @@ class BriscolaChiamataEngine(GameEngine):
state: ChiamataState = session.state
if kind == "auction":
seat = state.turn
auction = state.auction
passed_before = len(auction.passed) if auction is not None else 0
chiamata.auto_bid(state)
action = (
"auto-passed"
if auction is not None and len(auction.passed) > passed_before
else "forced to bid the ace"
)
log.info(
"auto-bid for seat %s (auction timeout, deal %d)",
"seat %s %s (auction timeout, deal %d)",
seat,
action,
state.hand_number,
)
elif kind == "call":
@@ -240,18 +240,43 @@ class AuctionTest(unittest.TestCase):
self.assertEqual(63, view["legal_bids"]["points_min"])
self.assertEqual(120, view["legal_bids"]["points_max"])
def test_auto_bid_bids_or_passes(self) -> None:
def test_auto_bid_always_passes_when_legal(self) -> None:
state = started_state()
rules.auto_bid(state, random.Random(1))
assert state.auction is not None
self.assertIsNotNone(state.auction.current)
# The auction keeps moving and eventually concludes.
for _ in range(50):
if state.phase != PHASE_AUCTION:
break
rules.auto_bid(state, random.Random(2))
rules.auto_bid(state)
# Seat 1 passes: nobody has bid and the turn moves on.
self.assertEqual([1], state.auction.passed)
self.assertIsNone(state.auction.current)
self.assertEqual(2, state.auction.turn)
# An absent bidder still passes when there is a standing bid.
rules.bid_rank(state, "p2", 10)
rules.auto_bid(state)
self.assertEqual([1, 3], state.auction.passed)
assert state.auction.current is not None
self.assertEqual(10, state.auction.current.rank)
def test_auto_bid_passes_at_the_point_stage(self) -> None:
state = started_state()
rules.bid_rank(state, "p1", 2)
rules.bid_points(state, "p2", 62)
rules.auto_bid(state) # seat 3
assert state.auction is not None
self.assertIn(3, state.auction.passed)
assert state.auction.current is not None
self.assertEqual(62, state.auction.current.points)
def test_auto_bid_forced_to_call_the_ace(self) -> None:
state = started_state()
for seat in (1, 2, 3, 4):
rules.pass_auction(state, f"p{seat}")
# Seat 0 is the last one standing and nobody has bid yet: the
# forced auto-bid calls the ace.
rules.auto_bid(state)
self.assertEqual(PHASE_CALLING, state.phase)
self.assertIsNotNone(state.caller)
self.assertEqual(0, state.caller)
assert state.auction is not None and state.auction.current is not None
self.assertEqual(1, state.auction.current.rank)
self.assertEqual(61, state.declared_target)
class CallTest(unittest.TestCase):
@@ -543,7 +568,7 @@ class ScoringTest(unittest.TestCase):
moves = 0
while state.phase != PHASE_FINISHED and moves < 5000:
if state.phase == PHASE_AUCTION:
rules.auto_bid(state, rng)
rules.auto_bid(state)
elif state.phase == PHASE_CALLING:
rules.auto_call(state, rng)
else:
+53 -6
View File
@@ -241,7 +241,7 @@ class ChiamataTimeoutTest(unittest.TestCase):
return session.id
@async_test
async def test_auction_timeout_auto_bids(self) -> None:
async def test_auction_timeout_auto_passes(self) -> None:
game_id = await self._seed(
"chiamata-auction-timeout-1", "CH0001", lambda state: None
)
@@ -263,15 +263,62 @@ class ChiamataTimeoutTest(unittest.TestCase):
break
if (
update.get("type") == "state"
and update["game"]["auction"]["current"] is not None
and 1 in update["game"]["auction"]["passed"]
):
break
self.assertIsNotNone(update)
assert update is not None
# The absent seat 1 was auto-bid for.
current = update["game"]["auction"]["current"]
self.assertEqual(1, current["seat"])
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2))
# The absent seat 1 passed: nobody bid and the turn
# moved to seat 2.
auction = update["game"]["auction"]
self.assertIn(1, auction["passed"])
self.assertIsNone(auction["current"])
self.assertEqual(2, auction["turn"])
@async_test
async def test_forced_last_bidder_auto_bids_the_ace(self) -> None:
def seed(state) -> None:
state.phase = PHASE_AUCTION
state.auction = Auction(
turn=0, active=[0], passed=[1, 2, 3, 4], current=None
)
state.turn = 0
state.auction_deadline = (
datetime.now(timezone.utc) + timedelta(seconds=1)
).isoformat()
state.call_deadline = None
game_id = await self._seed("chiamata-forced-ace-1", "CH0003", seed)
ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]):
async with aconnect_ws(f"/ws/games/{game_id}", ws_client) as ws:
first = await ws.receive_json()
self.assertEqual(PHASE_AUCTION, first["game"]["phase"])
# The auction times out: seat 0 is forced to bid the
# ace; its call then times out too and a random ace
# suit is named.
played = None
for _ in range(20):
try:
played = await asyncio.wait_for(
ws.receive_json(), timeout=2
)
except asyncio.TimeoutError:
break
if (
played.get("type") == "state"
and played["game"]["phase"] == PHASE_PLAYING
):
break
self.assertIsNotNone(played)
assert played is not None
self.assertEqual(PHASE_PLAYING, played["game"]["phase"])
self.assertEqual(0, played["game"]["caller"])
card = played["game"]["called_card"]
self.assertIsNotNone(card)
self.assertTrue(card.startswith("01"))
@async_test
async def test_call_and_turn_timeouts(self) -> None:
+1 -1
View File
@@ -13,7 +13,7 @@ serde_json = "1"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
futures = "0.3"
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
js-sys = "0.3"
gloo-timers = "0.3"
console_error_panic_hook = "0.1"
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">
<!-- Seamless felt grain: fractal noise mapped to semi-transparent black
speckles, tiled as a CSS background over the green table gradient.
stitchTiles + a filter region equal to the tile keep the seams
invisible. -->
<filter id="felt" x="0" y="0" width="100%" height="100%" color-interpolation-filters="sRGB">
<feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="4" stitchTiles="stitch" result="noise"/>
<feColorMatrix in="noise" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.3 0.3 0.3 0 -0.3"/>
</filter>
<rect width="240" height="240" filter="url(#felt)"/>
</svg>

After

Width:  |  Height:  |  Size: 716 B

+32 -25
View File
@@ -1,13 +1,32 @@
//! REST client for the tavolo backend. Same-origin requests carry the
//! session cookie automatically.
use crate::auth;
use crate::model::*;
use gloo_net::http::Request;
use gloo_net::http::{Request, Response};
fn server_error(status: u16) -> String {
format!("server returned {status}")
}
/// Map a failed response to an error. A `401` additionally starts the
/// login flow: the session is gone (or the caller was never logged in),
/// and the user is returned to this page after logging in.
fn check(resp: &Response) -> Result<(), String> {
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
Ok(())
}
/// Fetch the current user; `None` when unauthenticated (401).
///
/// Deliberately bypasses [`check`]: an unauthenticated answer is how the
/// app renders its logged-out state, so it must not trigger the login
/// redirect.
pub async fn me() -> Result<Option<User>, String> {
let resp = Request::get("/api/me")
.send()
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.providers)
}
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 401 {
auth::redirect_to_login();
return Err("login required".to_string());
}
if !resp.ok() {
let body: serde_json::Value = resp.json().await.unwrap_or_default();
let message = body
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
if resp.status() == 404 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map(Some).map_err(|e| e.to_string())
}
@@ -135,9 +150,7 @@ pub async fn my_games() -> Result<Vec<MyGame>, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
Ok(page.results)
}
@@ -148,9 +161,7 @@ pub async fn my_matches(cursor: Option<&str>) -> Result<MatchesPage, String> {
None => "/api/me/matches?limit=10".to_string(),
};
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.ok() {
return Err(server_error(resp.status()));
}
check(&resp)?;
resp.json().await.map_err(|e| e.to_string())
}
+74
View File
@@ -0,0 +1,74 @@
//! Client-side login redirect handling.
//!
//! The OIDC callback always returns the browser to the configured
//! post-login redirect (usually `/`), and the backend's login flow has no
//! per-request `next` support. So when the API answers `401`, the page the
//! user is on is saved in `sessionStorage` and restored once a session
//! exists again.
use sycamore_router::navigate_replace;
/// `sessionStorage` key holding the page to return to after login.
const RETURN_TO_KEY: &str = "tavolo.login.return_to";
fn session_storage() -> Option<web_sys::Storage> {
web_sys::window()?.session_storage().ok().flatten()
}
/// The current page (path plus query), the URL to come back to after
/// login.
pub fn current_path() -> String {
let Some(location) = web_sys::window().map(|w| w.location()) else {
return String::new();
};
format!(
"{}{}",
location.pathname().unwrap_or_default(),
location.search().unwrap_or_default()
)
}
/// Whether a path is a page worth returning to: not the login flow
/// itself, and not an absolute/protocol-relative URL.
fn is_restorable(path: &str) -> bool {
path.starts_with('/')
&& !path.starts_with("//")
&& !path.starts_with("/auth/")
&& !path.starts_with("/api/")
&& path != "/login"
}
/// Handle an expired session: remember the current page and go to the
/// login page.
///
/// The navigation is deferred to a timer so the caller can finish handling
/// the failed response (e.g. set an error) before its page is unmounted.
pub fn redirect_to_login() {
let path = current_path();
if is_restorable(&path) {
if let Some(storage) = session_storage() {
let _ = storage.set_item(RETURN_TO_KEY, &path);
}
}
gloo_timers::callback::Timeout::new(0, move || {
// Several in-flight requests may fail at once; only the first
// takes us to the login page.
if current_path() != "/login" {
navigate_replace("/login");
}
})
.forget();
}
/// The page saved by [`redirect_to_login`], if any.
pub fn saved_return_to() -> Option<String> {
let storage = session_storage()?;
let path = storage.get_item(RETURN_TO_KEY).ok().flatten()?;
is_restorable(&path).then_some(path)
}
/// Drop the saved page, e.g. after it has been restored.
pub fn clear_return_to() {
if let Some(storage) = session_storage() {
let _ = storage.remove_item(RETURN_TO_KEY);
}
}
+70
View File
@@ -0,0 +1,70 @@
//! Login card, shared by the lobby's logged-out state and the `/login`
//! page.
use sycamore::prelude::*;
use crate::model::AuthProvider;
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
/// Card offering one login button per configured provider.
///
/// `providers` is filled by the caller from `GET /api/auth/providers`;
/// while it is empty (or the fetch failed) the card falls back to the
/// generic `/auth/login` route, which redirects to the first configured
/// provider.
pub fn login_card(providers: Signal<Vec<AuthProvider>>) -> View {
view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the generic
// route redirects to the first configured
// provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod briscola;
pub mod card;
pub mod copy;
pub mod login;
pub mod nav;
pub mod summary;
pub mod toast;
+33 -1
View File
@@ -1,22 +1,27 @@
//! Application entry point.
mod api;
mod auth;
mod components;
mod model;
mod pages;
mod ws;
use sycamore::prelude::*;
use sycamore_router::{HistoryIntegration, Route, Router};
use sycamore_router::{navigate, HistoryIntegration, Route, Router};
use wasm_bindgen_futures::spawn_local;
use pages::game::GamePage;
use pages::history::HistoryPage;
use pages::leaderboard::LeaderboardPage;
use pages::lobby::LobbyPage;
use pages::login::LoginPage;
#[derive(Route, Clone)]
enum AppRoutes {
#[to("/")]
Lobby,
#[to("/login")]
Login,
#[to("/game/<id>")]
Game { id: String },
#[to("/history")]
@@ -27,6 +32,31 @@ enum AppRoutes {
NotFound,
}
/// After the OIDC callback lands the SPA back on the configured
/// post-login redirect, forward the user to the page they were on before
/// being sent to the login page (see [`auth`]) — but only once a session
/// exists, so a cancelled login never bounces into a stale page.
fn restore_page_after_login() {
let Some(path) = auth::saved_return_to() else {
return;
};
spawn_local(async move {
match api::me().await {
Ok(Some(_)) => {
auth::clear_return_to();
if auth::current_path() != path {
navigate(&path);
}
}
// Not logged in: keep the target for the next successful
// login (a reload of the login page must not lose it).
Ok(None) => {}
// The probe failed (offline?): keep the target, too.
Err(_) => {}
}
});
}
fn main() {
console_error_panic_hook::set_once();
sycamore::render(|| {
@@ -38,6 +68,7 @@ fn main() {
div(class="app") {
(match route.get_clone() {
AppRoutes::Lobby => view! { LobbyPage() },
AppRoutes::Login => view! { LoginPage() },
AppRoutes::Game { id } => view! { GamePage(id=id) },
AppRoutes::History => view! { HistoryPage() },
AppRoutes::Leaderboard => view! { LeaderboardPage() },
@@ -60,4 +91,5 @@ fn main() {
)
}
});
restore_page_after_login();
}
+26 -9
View File
@@ -464,10 +464,25 @@ pub fn chiamata_table(
}
/// Overlay for the auction winner: name the called card.
///
/// The winning bid fixes the rank, so the choice is which of the four
/// suits to call: its holder becomes the secret partner, unless the
/// caller holds it too — then that option means playing alone.
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
return view! {};
};
let rank = cards
.first()
.and_then(|code| code.get(..2))
.and_then(|rank| rank.parse::<i32>().ok())
.map(rank_name)
.unwrap_or_default();
let title = if rank.is_empty() {
"Name the called card".to_string()
} else {
format!("You won with the {rank} — name the card")
};
let own_hand = game
.players
.iter()
@@ -479,33 +494,35 @@ pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
.map(|code| {
let held = own_hand.contains(&code);
let label = card_label(&code);
let img = card_img(code.clone(), "hand-card");
let solo_note = held.then(|| view! {
span(class="solo-note") { "plays alone" }
});
let img = card_img(code.clone(), "call-card");
let note = if held {
view! { span(class="call-note solo") { "Plays alone" } }
} else {
view! { span(class="call-note partner") { "Partner" } }
};
let click_code = code.clone();
view! {
button(class="capture-option call-option", on:click=move |_| {
button(class="call-option", on:click=move |_| {
if let Some(s) = socket.get_clone() {
s.call_card(&click_code);
}
}) {
(img)
span(class="call-label") { (label) }
(solo_note)
(note)
}
}
})
.collect::<Vec<_>>();
view! {
div(class="overlay") {
div(class="picker") {
h3 { "Name the called card" }
div(class="picker call-picker") {
h3 { (title) }
p(class="text-secondary small") {
"Its holder becomes your secret partner; name one of your "
"own cards to play alone."
}
div(class="capture-options") { (options) }
div(class="call-options") { (options) }
}
}
}
+168 -47
View File
@@ -12,29 +12,50 @@ use crate::api;
use crate::components::toast::toast;
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
/// Distinct team labels in seat order; players without a team render in
/// a shared "Players" column.
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
let mut teams = BTreeSet::new();
/// The match's teams, each as a `(label, names)` pair, in a readable
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
/// then other team labels alphabetically (A, B, ...), with team-less
/// players last.
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
for p in &m.players {
teams.insert(p.team.clone());
labels.insert(p.team.clone());
}
let mut ordered: Vec<Option<String>> = teams.into_iter().collect();
// Seated teams first (in first-seat order), then the team-less column.
ordered.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
ordered.sort_by_key(team_sort_key);
ordered
.into_iter()
.map(|label| (team_header(&label), team_names(m, &label)))
.collect()
}
/// Sort key for team labels: chiamata roles in play order, other labels
/// alphabetically, team-less players last.
fn team_sort_key(label: &Option<String>) -> (u8, String) {
match label.as_deref() {
Some("Chiamante") => (0, String::new()),
Some("Socio") => (1, String::new()),
Some("Difensori") => (2, String::new()),
Some(other) => (0, other.to_string()),
None => (9, String::new()),
}
}
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
m.players
let names = m
.players
.iter()
.filter(|p| &p.team == team)
.map(|p| p.display_name.clone())
.collect::<Vec<_>>()
.join(" & ")
.collect::<Vec<_>>();
match names.len() {
0 => String::new(),
1 => names[0].clone(),
_ => {
let (last, rest) = names.split_last().expect("non-empty");
format!("{} & {}", rest.join(", "), last)
}
}
}
fn team_header(team: &Option<String>) -> String {
@@ -105,18 +126,29 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(next.as_deref()).await {
Ok(p) => {
// The page can unmount (e.g. the login redirect) while
// the request is in flight.
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.update(|acc| acc.extend(p.results.iter().cloned()));
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
};
spawn_local(async move {
if let Ok(types) = api::game_types().await {
game_types.set(types);
if game_types.is_alive() {
game_types.set(types);
}
}
});
@@ -124,11 +156,18 @@ pub fn HistoryPage() -> View {
spawn_local(async move {
match api::my_matches(None).await {
Ok(p) => {
if !page.is_alive() {
return;
}
cursor.set(p.next_cursor.clone());
rows.set(p.results.clone());
page.set(Some(p));
}
Err(e) => error.set(Some(e)),
Err(e) => {
if error.is_alive() {
error.set(Some(e));
}
}
}
});
@@ -157,10 +196,18 @@ pub fn HistoryPage() -> View {
.map(|m| {
let finished = local_finished(&m.finished_at);
let game = game_name(&names, &m.game_type);
let mut cells: Vec<View> = team_columns(&m)
.iter()
.map(|t| team_names(&m, t))
.map(|c| view! { td { (c) } })
let teams: Vec<View> = team_groups(&m)
.into_iter()
.map(|(label, names)| {
view! {
div(class="match-team") {
span(class="text-secondary") {
(label) ": "
}
(names)
}
}
})
.collect();
let score = score_text(&m);
let winner = winner_text(&m);
@@ -180,37 +227,18 @@ pub fn HistoryPage() -> View {
} else {
"text-secondary"
};
let mut row_cells = vec![
let row_cells = vec![
view! { td { (finished) } },
view! { td { (game) } },
view! { td { (teams) } },
view! { td { (score) } },
view! { td { (winner) } },
view! { td(class=cls) { (outcome) } },
view! { td(class=cls) { (elo_delta) } },
];
row_cells.append(&mut cells);
row_cells.push(view! { td { (score) } });
row_cells.push(view! { td { (winner) } });
row_cells.push(view! { td(class=cls) { (outcome) } });
row_cells.push(view! { td(class=cls) { (elo_delta) } });
view! { tr { (row_cells) } }
})
.collect::<Vec<_>>();
// Team columns are per-row (matches may mix games), so
// the header shows the union across the loaded rows.
let mut header_teams = BTreeSet::new();
for m in rows.get_clone().iter() {
for t in team_columns(m) {
header_teams.insert(t);
}
}
let mut header_teams: Vec<Option<String>> =
header_teams.into_iter().collect();
header_teams.sort_by_key(|t| match t {
Some(_) => (0, String::new()),
None => (1, String::new()),
});
let header_cells: Vec<View> = header_teams
.iter()
.map(team_header)
.map(|h| view! { th { (h) } })
.collect();
view! {
div(class="table-responsive") {
table(class="table table-striped table-hover align-middle") {
@@ -218,7 +246,7 @@ pub fn HistoryPage() -> View {
tr {
th { "Finished" }
th { "Game" }
(header_cells)
th { "Teams" }
th { "Score" }
th { "Winner" }
th { "You" }
@@ -243,3 +271,96 @@ pub fn HistoryPage() -> View {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn match_summary(players: serde_json::Value) -> MatchSummary {
serde_json::from_value(json!({
"id": "m1",
"started_at": "2026-01-01T00:00:00+00:00",
"finished_at": "2026-01-01T01:00:00+00:00",
"players": players,
}))
.unwrap()
}
#[test]
fn team_groups_order_chiamata_roles() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "Difensori", "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "Chiamante", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "Difensori", "won": false},
{"user_sub": "d", "display_name": "Dave", "seat": 3,
"team": "Socio", "won": true},
{"user_sub": "e", "display_name": "Erin", "seat": 4,
"team": "Difensori", "won": false},
]));
assert_eq!(
vec![
("Chiamante".to_string(), "Bob".to_string()),
("Socio".to_string(), "Dave".to_string()),
(
"Difensori".to_string(),
"Alice, Carol & Erin".to_string()
),
],
team_groups(&m)
);
}
#[test]
fn team_groups_keep_a_and_b_in_order() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "B", "won": false},
]));
assert_eq!(
vec![
("Team A".to_string(), "Alice".to_string()),
("Team B".to_string(), "Bob".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_groups_put_teamless_players_last() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": null, "won": false},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
]));
assert_eq!(
vec![
("Team A".to_string(), "Bob".to_string()),
("Players".to_string(), "Alice".to_string()),
],
team_groups(&m)
);
}
#[test]
fn team_names_join_three_names_readably() {
let m = match_summary(json!([
{"user_sub": "a", "display_name": "Alice", "seat": 0,
"team": "A", "won": true},
{"user_sub": "b", "display_name": "Bob", "seat": 1,
"team": "A", "won": true},
{"user_sub": "c", "display_name": "Carol", "seat": 2,
"team": "A", "won": true},
]));
assert_eq!(
"Alice, Bob & Carol",
team_names(&m, &Some("A".to_string()))
);
}
}
+25 -62
View File
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
use crate::api;
use crate::components::card::card_back;
use crate::components::login::login_card;
use crate::components::toast::toast;
use crate::model::{
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
MyGame, OptionField, OptionKind, PlayerRating, User,
};
/// Human-readable label for a provider name ("google" -> "Google").
fn provider_label(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn json_to_string(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
if me.is_some() {
spawn_local(async move {
if let Ok(p) = api::my_ratings().await {
ratings.set(p.results);
if ratings.is_alive() {
ratings.set(p.results);
}
}
if let Ok(games) = api::my_games().await {
my_games.set(games);
if my_games.is_alive() {
my_games.set(games);
}
}
});
}
user.set(Some(me));
// The page may have been unmounted (e.g. by the login
// redirect triggered while this request was in flight).
if user.is_alive() {
user.set(Some(me));
}
}
Err(e) => {
error.set(Some(e));
user.set(Some(None));
if error.is_alive() {
error.set(Some(e));
}
if user.is_alive() {
user.set(Some(None));
}
}
}
});
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
match api::game_types().await {
Ok(types) if !types.is_empty() => {
if !game_types.is_alive() {
return;
}
selected_game.set(types[0].id.clone());
game_types.set(types);
}
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
spawn_local(async move {
if let Ok(list) = api::auth_providers().await {
auth_providers.set(list);
if auth_providers.is_alive() {
auth_providers.set(list);
}
}
});
@@ -470,53 +479,7 @@ pub fn LobbyPage() -> View {
}
}
},
Some(None) => view! {
div(class="status-card") {
div(class="card") {
div(class="card-body p-4") {
h1(class="h3 mb-3") {
span(class="text-warning") { "♠" }
" tavolo"
}
p(class="text-secondary") { "Log in with your account to play." }
// rel="external" opts out of the SPA router's click
// interception: these must be full page navigations
// to the backend's OIDC login endpoints.
(move || {
let providers = auth_providers.get_clone();
if providers.is_empty() {
// Providers could not be fetched: the
// generic route redirects to the first
// configured provider.
view! {
a(
class="btn btn-warning btn-lg w-100",
href="/auth/login",
rel="external",
) {
"Log in"
}
}
} else {
view! {
div(class="d-grid gap-2") {
(providers.iter().cloned().map(|p| view! {
a(
class="btn btn-warning btn-lg w-100",
href=p.login_url,
rel="external",
) {
"Log in with " (provider_label(&p.name))
}
}).collect::<Vec<_>>())
}
}
}
})
}
}
}
},
Some(None) => view! { (login_card(auth_providers)) },
Some(Some(_me)) => {
view! {
(crate::components::nav::app_nav("/", move || {
+41
View File
@@ -0,0 +1,41 @@
//! Login page, shown after the API answered `401` (see [`crate::auth`]):
//! the session expired, or the visitor tried to open a page that needs an
//! account.
//!
//! The backend's OIDC callback lands the SPA back on the configured
//! post-login redirect; [`crate::auth`] then returns the user to the page
//! they were on before this one.
use sycamore::prelude::*;
use sycamore_router::navigate_replace;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::auth;
use crate::components::login::login_card;
use crate::model::AuthProvider;
#[component]
pub fn LoginPage() -> View {
let providers = create_signal(Vec::<AuthProvider>::new());
spawn_local(async move {
// Already signed in: no business here — return to where the user
// was headed, or the lobby.
if matches!(api::me().await, Ok(Some(_))) {
let target = auth::saved_return_to().unwrap_or_else(|| "/".to_string());
auth::clear_return_to();
navigate_replace(&target);
return;
}
if let Ok(list) = api::auth_providers().await {
// The page can unmount while the fetch is in flight.
if providers.is_alive() {
providers.set(list);
}
}
});
view! {
(login_card(providers))
}
}
+1
View File
@@ -4,3 +4,4 @@ pub mod game;
pub mod history;
pub mod leaderboard;
pub mod lobby;
pub mod login;
+64 -11
View File
@@ -292,8 +292,12 @@ body {
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
/* Tall dialogs (the chiamata call picker, the scoring summaries) can
* exceed short windows: allow scrolling instead of clipping them. */
overflow-y: auto;
padding: 1rem;
z-index: 20;
}
@@ -302,8 +306,11 @@ body {
border-radius: 14px;
padding: 1.5rem 2rem;
text-align: center;
min-width: min(320px, calc(100vw - 1.5rem));
max-width: calc(100vw - 1.5rem);
/* Centers the dialog when it fits and keeps it reachable (top
* aligned, scrollable via the overlay) when it does not. */
margin: auto;
min-width: min(320px, calc(100vw - 3rem));
max-width: calc(100vw - 3rem);
}
.capture-options {
@@ -784,26 +791,64 @@ body {
border-color: var(--text);
}
/* The auction winner's call picker: one card per suit. */
.call-option {
flex-direction: column;
align-items: center;
/* The auction winner's call picker: the four cards of the won rank,
* laid out in a compact grid so the dialog stays short. */
.call-picker {
max-width: min(34rem, calc(100vw - 3rem));
}
.call-option .playing-card {
.call-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin: 1rem 0 0;
}
@media (min-width: 576px) {
.call-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.call-option {
background: var(--panel-light);
border: 1px solid #4c6a54;
border-radius: 10px;
padding: 0.4rem 0.3rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.call-option:hover {
border-color: var(--accent);
}
/* Smaller than a hand card: the picker must fit short laptop windows. */
.playing-card.call-card {
height: 84px;
margin: 0 auto;
}
.call-label {
display: block;
font-size: 0.85rem;
font-size: 0.8rem;
color: var(--text);
margin-top: 0.25rem;
margin-top: 0.15rem;
}
.solo-note {
.call-note {
display: block;
font-size: 0.7rem;
}
.call-note.partner {
color: var(--team-a);
}
.call-note.solo {
color: var(--accent);
}
@@ -826,3 +871,11 @@ body {
max-width: 100%;
}
}
/* ---------- match history ---------- */
/* One line per team inside the "Teams" cell of the history table. */
.match-team {
font-size: 0.85rem;
line-height: 1.4;
}
+21 -5
View File
@@ -40,11 +40,27 @@
body {
min-height: 100vh;
background-color: var(--felt-dark);
background-image: radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
/* Casino-table felt: a fine grain texture tiled over the green
* gradient, lit from above and darkened towards the edges. The first
* layer is the topmost one. */
background-image:
radial-gradient(
ellipse at 50% 45%,
transparent 55%,
rgba(0, 0, 0, 0.38) 100%
),
radial-gradient(
ellipse at 50% -10%,
rgba(255, 255, 255, 0.07),
transparent 60%
),
url("/assets/felt.svg"),
radial-gradient(
ellipse at 50% -10%,
var(--felt) 0%,
var(--felt-dark) 65%
);
background-repeat: no-repeat, no-repeat, repeat, no-repeat;
background-attachment: fixed;
color: var(--text);
}