Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eea293471
|
||
|
|
4ba5ae1434
|
||
|
|
0f9fb9b6bb
|
||
|
|
322ca27bf4
|
||
|
|
0674e06565
|
@@ -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
|
player does not move — disconnected or fallen asleep — the server plays a
|
||||||
random legal card for them (choosing among the legal captures when the
|
random legal card for them (choosing among the legal captures when the
|
||||||
rules require a capture), so one absent player cannot stall the table.
|
rules require a capture), so one absent player cannot stall the table.
|
||||||
Briscola chiamata arms the same countdown for its auction bids and for
|
Briscola chiamata arms the same countdown for its auction turns and for
|
||||||
the auction winner's called-card choice (auto-bid or auto-pass, then a
|
the auction winner's called-card choice: an absent bidder passes, the
|
||||||
random suit of the won rank).
|
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
|
## Watching
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -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`
|
broadcast like any other. Deadlines fire from the shared `tavolo:deadlines`
|
||||||
queue (see above), not from timers tied to client connections, so the
|
queue (see above), not from timers tied to client connections, so the
|
||||||
match keeps progressing even with every player disconnected. Briscola
|
match keeps progressing even with every player disconnected. Briscola
|
||||||
chiamata uses the same timeout for its auction bids (auto-bid or
|
chiamata uses the same timeout for its auction turns (an absent bidder
|
||||||
auto-pass) and for the called-card choice (a random suit of the won
|
passes; the last player standing with no bid is made to bid the ace) and
|
||||||
rank).
|
for the called-card choice (a random suit of the won rank).
|
||||||
|
|
||||||
### Hand-end summary
|
### 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
|
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
|
and partner (or the lone caller) against the other players map onto
|
||||||
the platform's two-team result, Elo and history.
|
the platform's two-team result, Elo and history.
|
||||||
- Auction bids, the called-card choice and the plays all have their own
|
- Auction turns, the called-card choice and the plays all have their own
|
||||||
`TURN_TIMEOUT_SECONDS` deadlines (an absent player is auto-bid,
|
`TURN_TIMEOUT_SECONDS` deadlines: an absent bidder passes, the last
|
||||||
auto-called or auto-played for).
|
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
|
## Development
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ Three variants share one rules core:
|
|||||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||||
`hand_ack_timeout_seconds`), wired from the environment by the
|
`hand_ack_timeout_seconds`), wired from the environment by the
|
||||||
application composition root. Briscola chiamata uses the turn timeout
|
application composition root. Briscola chiamata uses the turn timeout
|
||||||
for its auction bids, the called-card choice and the plays; it never
|
for its auction turns, the called-card choice and the plays: an absent
|
||||||
enters the between-deals acknowledgement phase (a single deal ends the
|
bidder passes, the last player standing with no bid is made to bid the
|
||||||
match), so the hand-ack timeout is accepted but unused.
|
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
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -797,12 +797,13 @@ def _end_deal(state: ChiamataState) -> None:
|
|||||||
# -- server-side auto actions ---------------------------------------------------
|
# -- server-side auto actions ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def auto_bid(state: ChiamataState, rng: Optional[random.Random] = None) -> None:
|
def auto_bid(state: ChiamataState) -> None:
|
||||||
"""Act for the bidder on turn: bid a random legal rank, else pass.
|
"""Act for the bidder on turn: always pass, unless forced to bid.
|
||||||
|
|
||||||
The only player who cannot afford to pass — the last one standing
|
An absent player never outbids anyone: they leave the auction. Only
|
||||||
before any bid — always has every rank available, so a bid is always
|
the last player standing before any bid cannot pass — they are made
|
||||||
possible there.
|
to bid the ace, whose suit :func:`auto_call` picks at random if the
|
||||||
|
call itself times out too.
|
||||||
"""
|
"""
|
||||||
if state.phase != PHASE_AUCTION:
|
if state.phase != PHASE_AUCTION:
|
||||||
raise GameNotStarted("the auction is not open")
|
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
|
if auction is None: # pragma: no cover - guarded by phase
|
||||||
raise GameNotStarted("the auction is not open")
|
raise GameNotStarted("the auction is not open")
|
||||||
player = _player_at(state, auction.turn)
|
player = _player_at(state, auction.turn)
|
||||||
ranks = legal_bid_ranks(state)
|
if len(auction.active) == 1 and auction.current is None:
|
||||||
if ranks:
|
bid_rank(state, player.sub, 1)
|
||||||
chooser = rng or _rng
|
|
||||||
bid_rank(state, player.sub, chooser.choice(ranks))
|
|
||||||
return
|
return
|
||||||
pass_auction(state, player.sub)
|
pass_auction(state, player.sub)
|
||||||
|
|
||||||
|
|||||||
@@ -485,10 +485,18 @@ class BriscolaChiamataEngine(GameEngine):
|
|||||||
state: ChiamataState = session.state
|
state: ChiamataState = session.state
|
||||||
if kind == "auction":
|
if kind == "auction":
|
||||||
seat = state.turn
|
seat = state.turn
|
||||||
|
auction = state.auction
|
||||||
|
passed_before = len(auction.passed) if auction is not None else 0
|
||||||
chiamata.auto_bid(state)
|
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(
|
log.info(
|
||||||
"auto-bid for seat %s (auction timeout, deal %d)",
|
"seat %s %s (auction timeout, deal %d)",
|
||||||
seat,
|
seat,
|
||||||
|
action,
|
||||||
state.hand_number,
|
state.hand_number,
|
||||||
)
|
)
|
||||||
elif kind == "call":
|
elif kind == "call":
|
||||||
|
|||||||
@@ -240,18 +240,43 @@ class AuctionTest(unittest.TestCase):
|
|||||||
self.assertEqual(63, view["legal_bids"]["points_min"])
|
self.assertEqual(63, view["legal_bids"]["points_min"])
|
||||||
self.assertEqual(120, view["legal_bids"]["points_max"])
|
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()
|
state = started_state()
|
||||||
rules.auto_bid(state, random.Random(1))
|
|
||||||
assert state.auction is not None
|
assert state.auction is not None
|
||||||
self.assertIsNotNone(state.auction.current)
|
rules.auto_bid(state)
|
||||||
# The auction keeps moving and eventually concludes.
|
# Seat 1 passes: nobody has bid and the turn moves on.
|
||||||
for _ in range(50):
|
self.assertEqual([1], state.auction.passed)
|
||||||
if state.phase != PHASE_AUCTION:
|
self.assertIsNone(state.auction.current)
|
||||||
break
|
self.assertEqual(2, state.auction.turn)
|
||||||
rules.auto_bid(state, random.Random(2))
|
# 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.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):
|
class CallTest(unittest.TestCase):
|
||||||
@@ -543,7 +568,7 @@ class ScoringTest(unittest.TestCase):
|
|||||||
moves = 0
|
moves = 0
|
||||||
while state.phase != PHASE_FINISHED and moves < 5000:
|
while state.phase != PHASE_FINISHED and moves < 5000:
|
||||||
if state.phase == PHASE_AUCTION:
|
if state.phase == PHASE_AUCTION:
|
||||||
rules.auto_bid(state, rng)
|
rules.auto_bid(state)
|
||||||
elif state.phase == PHASE_CALLING:
|
elif state.phase == PHASE_CALLING:
|
||||||
rules.auto_call(state, rng)
|
rules.auto_call(state, rng)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
|||||||
return session.id
|
return session.id
|
||||||
|
|
||||||
@async_test
|
@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(
|
game_id = await self._seed(
|
||||||
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
"chiamata-auction-timeout-1", "CH0001", lambda state: None
|
||||||
)
|
)
|
||||||
@@ -263,15 +263,62 @@ class ChiamataTimeoutTest(unittest.TestCase):
|
|||||||
break
|
break
|
||||||
if (
|
if (
|
||||||
update.get("type") == "state"
|
update.get("type") == "state"
|
||||||
and update["game"]["auction"]["current"] is not None
|
and 1 in update["game"]["auction"]["passed"]
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
self.assertIsNotNone(update)
|
self.assertIsNotNone(update)
|
||||||
assert update is not None
|
assert update is not None
|
||||||
# The absent seat 1 was auto-bid for.
|
# The absent seat 1 passed: nobody bid and the turn
|
||||||
current = update["game"]["auction"]["current"]
|
# moved to seat 2.
|
||||||
self.assertEqual(1, current["seat"])
|
auction = update["game"]["auction"]
|
||||||
self.assertIn(current["rank"], (1, 3, 10, 9, 8, 7, 6, 5, 4, 2))
|
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_test
|
||||||
async def test_call_and_turn_timeouts(self) -> None:
|
async def test_call_and_turn_timeouts(self) -> None:
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ serde_json = "1"
|
|||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console"] }
|
web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage"] }
|
||||||
js-sys = "0.3"
|
js-sys = "0.3"
|
||||||
gloo-timers = "0.3"
|
gloo-timers = "0.3"
|
||||||
console_error_panic_hook = "0.1"
|
console_error_panic_hook = "0.1"
|
||||||
|
|||||||
@@ -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
@@ -1,13 +1,32 @@
|
|||||||
//! REST client for the tavolo backend. Same-origin requests carry the
|
//! REST client for the tavolo backend. Same-origin requests carry the
|
||||||
//! session cookie automatically.
|
//! session cookie automatically.
|
||||||
|
use crate::auth;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::{Request, Response};
|
||||||
|
|
||||||
fn server_error(status: u16) -> String {
|
fn server_error(status: u16) -> String {
|
||||||
format!("server returned {status}")
|
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).
|
/// 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> {
|
pub async fn me() -> Result<Option<User>, String> {
|
||||||
let resp = Request::get("/api/me")
|
let resp = Request::get("/api/me")
|
||||||
.send()
|
.send()
|
||||||
@@ -28,9 +47,7 @@ pub async fn auth_providers() -> Result<Vec<AuthProvider>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: AuthProvidersPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.providers)
|
Ok(page.providers)
|
||||||
}
|
}
|
||||||
@@ -41,9 +58,7 @@ pub async fn game_types() -> Result<Vec<GameTypeInfo>, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: GameTypesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
Ok(page.results)
|
||||||
}
|
}
|
||||||
@@ -58,9 +73,7 @@ pub async fn create_game(game_type: &str, options: serde_json::Value) -> Result<
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +84,10 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
if resp.status() == 401 {
|
||||||
|
auth::redirect_to_login();
|
||||||
|
return Err("login required".to_string());
|
||||||
|
}
|
||||||
if !resp.ok() {
|
if !resp.ok() {
|
||||||
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
let body: serde_json::Value = resp.json().await.unwrap_or_default();
|
||||||
let message = body
|
let message = body
|
||||||
@@ -94,9 +111,7 @@ pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
|
|||||||
if resp.status() == 404 {
|
if resp.status() == 404 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map(Some).map_err(|e| e.to_string())
|
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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
let page: MyGamesPage = resp.json().await.map_err(|e| e.to_string())?;
|
||||||
Ok(page.results)
|
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(),
|
None => "/api/me/matches?limit=10".to_string(),
|
||||||
};
|
};
|
||||||
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
let resp = Request::get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +171,7 @@ pub async fn my_ratings() -> Result<RatingsPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +182,6 @@ pub async fn leaderboard(game_type: &str) -> Result<LeaderboardPage, String> {
|
|||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
if !resp.ok() {
|
check(&resp)?;
|
||||||
return Err(server_error(resp.status()));
|
|
||||||
}
|
|
||||||
resp.json().await.map_err(|e| e.to_string())
|
resp.json().await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
pub mod briscola;
|
pub mod briscola;
|
||||||
pub mod card;
|
pub mod card;
|
||||||
pub mod copy;
|
pub mod copy;
|
||||||
|
pub mod login;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod summary;
|
pub mod summary;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
+33
-1
@@ -1,22 +1,27 @@
|
|||||||
//! Application entry point.
|
//! Application entry point.
|
||||||
mod api;
|
mod api;
|
||||||
|
mod auth;
|
||||||
mod components;
|
mod components;
|
||||||
mod model;
|
mod model;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod ws;
|
mod ws;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
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::game::GamePage;
|
||||||
use pages::history::HistoryPage;
|
use pages::history::HistoryPage;
|
||||||
use pages::leaderboard::LeaderboardPage;
|
use pages::leaderboard::LeaderboardPage;
|
||||||
use pages::lobby::LobbyPage;
|
use pages::lobby::LobbyPage;
|
||||||
|
use pages::login::LoginPage;
|
||||||
|
|
||||||
#[derive(Route, Clone)]
|
#[derive(Route, Clone)]
|
||||||
enum AppRoutes {
|
enum AppRoutes {
|
||||||
#[to("/")]
|
#[to("/")]
|
||||||
Lobby,
|
Lobby,
|
||||||
|
#[to("/login")]
|
||||||
|
Login,
|
||||||
#[to("/game/<id>")]
|
#[to("/game/<id>")]
|
||||||
Game { id: String },
|
Game { id: String },
|
||||||
#[to("/history")]
|
#[to("/history")]
|
||||||
@@ -27,6 +32,31 @@ enum AppRoutes {
|
|||||||
NotFound,
|
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() {
|
fn main() {
|
||||||
console_error_panic_hook::set_once();
|
console_error_panic_hook::set_once();
|
||||||
sycamore::render(|| {
|
sycamore::render(|| {
|
||||||
@@ -38,6 +68,7 @@ fn main() {
|
|||||||
div(class="app") {
|
div(class="app") {
|
||||||
(match route.get_clone() {
|
(match route.get_clone() {
|
||||||
AppRoutes::Lobby => view! { LobbyPage() },
|
AppRoutes::Lobby => view! { LobbyPage() },
|
||||||
|
AppRoutes::Login => view! { LoginPage() },
|
||||||
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
AppRoutes::Game { id } => view! { GamePage(id=id) },
|
||||||
AppRoutes::History => view! { HistoryPage() },
|
AppRoutes::History => view! { HistoryPage() },
|
||||||
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
AppRoutes::Leaderboard => view! { LeaderboardPage() },
|
||||||
@@ -60,4 +91,5 @@ fn main() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
restore_page_after_login();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,10 +464,25 @@ pub fn chiamata_table(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Overlay for the auction winner: name the called card.
|
/// 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 {
|
pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
||||||
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
let Some(cards) = game.callable_cards.clone().filter(|cards| !cards.is_empty()) else {
|
||||||
return view! {};
|
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
|
let own_hand = game
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
@@ -479,33 +494,35 @@ pub fn call_picker(game: GameView, socket: Signal<Option<GameSocket>>) -> View {
|
|||||||
.map(|code| {
|
.map(|code| {
|
||||||
let held = own_hand.contains(&code);
|
let held = own_hand.contains(&code);
|
||||||
let label = card_label(&code);
|
let label = card_label(&code);
|
||||||
let img = card_img(code.clone(), "hand-card");
|
let img = card_img(code.clone(), "call-card");
|
||||||
let solo_note = held.then(|| view! {
|
let note = if held {
|
||||||
span(class="solo-note") { "plays alone" }
|
view! { span(class="call-note solo") { "Plays alone" } }
|
||||||
});
|
} else {
|
||||||
|
view! { span(class="call-note partner") { "Partner" } }
|
||||||
|
};
|
||||||
let click_code = code.clone();
|
let click_code = code.clone();
|
||||||
view! {
|
view! {
|
||||||
button(class="capture-option call-option", on:click=move |_| {
|
button(class="call-option", on:click=move |_| {
|
||||||
if let Some(s) = socket.get_clone() {
|
if let Some(s) = socket.get_clone() {
|
||||||
s.call_card(&click_code);
|
s.call_card(&click_code);
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
(img)
|
(img)
|
||||||
span(class="call-label") { (label) }
|
span(class="call-label") { (label) }
|
||||||
(solo_note)
|
(note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
view! {
|
view! {
|
||||||
div(class="overlay") {
|
div(class="overlay") {
|
||||||
div(class="picker") {
|
div(class="picker call-picker") {
|
||||||
h3 { "Name the called card" }
|
h3 { (title) }
|
||||||
p(class="text-secondary small") {
|
p(class="text-secondary small") {
|
||||||
"Its holder becomes your secret partner; name one of your "
|
"Its holder becomes your secret partner; name one of your "
|
||||||
"own cards to play alone."
|
"own cards to play alone."
|
||||||
}
|
}
|
||||||
div(class="capture-options") { (options) }
|
div(class="call-options") { (options) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-47
@@ -12,29 +12,50 @@ use crate::api;
|
|||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
use crate::model::{GameTypeInfo, MatchSummary, MatchesPage};
|
||||||
|
|
||||||
/// Distinct team labels in seat order; players without a team render in
|
/// The match's teams, each as a `(label, names)` pair, in a readable
|
||||||
/// a shared "Players" column.
|
/// order: briscola chiamata's roles first (Chiamante, Socio, Difensori),
|
||||||
fn team_columns(m: &MatchSummary) -> Vec<Option<String>> {
|
/// then other team labels alphabetically (A, B, ...), with team-less
|
||||||
let mut teams = BTreeSet::new();
|
/// players last.
|
||||||
|
fn team_groups(m: &MatchSummary) -> Vec<(String, String)> {
|
||||||
|
let mut labels: BTreeSet<Option<String>> = BTreeSet::new();
|
||||||
for p in &m.players {
|
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();
|
let mut ordered: Vec<Option<String>> = labels.into_iter().collect();
|
||||||
// Seated teams first (in first-seat order), then the team-less column.
|
ordered.sort_by_key(team_sort_key);
|
||||||
ordered.sort_by_key(|t| match t {
|
|
||||||
Some(_) => (0, String::new()),
|
|
||||||
None => (1, String::new()),
|
|
||||||
});
|
|
||||||
ordered
|
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 {
|
fn team_names(m: &MatchSummary, team: &Option<String>) -> String {
|
||||||
m.players
|
let names = m
|
||||||
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| &p.team == team)
|
.filter(|p| &p.team == team)
|
||||||
.map(|p| p.display_name.clone())
|
.map(|p| p.display_name.clone())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>();
|
||||||
.join(" & ")
|
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 {
|
fn team_header(team: &Option<String>) -> String {
|
||||||
@@ -105,18 +126,29 @@ pub fn HistoryPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::my_matches(next.as_deref()).await {
|
match api::my_matches(next.as_deref()).await {
|
||||||
Ok(p) => {
|
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());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
rows.update(|acc| acc.extend(p.results.iter().cloned()));
|
||||||
page.set(Some(p));
|
page.set(Some(p));
|
||||||
}
|
}
|
||||||
Err(e) => error.set(Some(e)),
|
Err(e) => {
|
||||||
|
if error.is_alive() {
|
||||||
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(types) = api::game_types().await {
|
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 {
|
spawn_local(async move {
|
||||||
match api::my_matches(None).await {
|
match api::my_matches(None).await {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
|
if !page.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
cursor.set(p.next_cursor.clone());
|
cursor.set(p.next_cursor.clone());
|
||||||
rows.set(p.results.clone());
|
rows.set(p.results.clone());
|
||||||
page.set(Some(p));
|
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| {
|
.map(|m| {
|
||||||
let finished = local_finished(&m.finished_at);
|
let finished = local_finished(&m.finished_at);
|
||||||
let game = game_name(&names, &m.game_type);
|
let game = game_name(&names, &m.game_type);
|
||||||
let mut cells: Vec<View> = team_columns(&m)
|
let teams: Vec<View> = team_groups(&m)
|
||||||
.iter()
|
.into_iter()
|
||||||
.map(|t| team_names(&m, t))
|
.map(|(label, names)| {
|
||||||
.map(|c| view! { td { (c) } })
|
view! {
|
||||||
|
div(class="match-team") {
|
||||||
|
span(class="text-secondary") {
|
||||||
|
(label) ": "
|
||||||
|
}
|
||||||
|
(names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let score = score_text(&m);
|
let score = score_text(&m);
|
||||||
let winner = winner_text(&m);
|
let winner = winner_text(&m);
|
||||||
@@ -180,37 +227,18 @@ pub fn HistoryPage() -> View {
|
|||||||
} else {
|
} else {
|
||||||
"text-secondary"
|
"text-secondary"
|
||||||
};
|
};
|
||||||
let mut row_cells = vec![
|
let row_cells = vec![
|
||||||
view! { td { (finished) } },
|
view! { td { (finished) } },
|
||||||
view! { td { (game) } },
|
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) } }
|
view! { tr { (row_cells) } }
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.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! {
|
view! {
|
||||||
div(class="table-responsive") {
|
div(class="table-responsive") {
|
||||||
table(class="table table-striped table-hover align-middle") {
|
table(class="table table-striped table-hover align-middle") {
|
||||||
@@ -218,7 +246,7 @@ pub fn HistoryPage() -> View {
|
|||||||
tr {
|
tr {
|
||||||
th { "Finished" }
|
th { "Finished" }
|
||||||
th { "Game" }
|
th { "Game" }
|
||||||
(header_cells)
|
th { "Teams" }
|
||||||
th { "Score" }
|
th { "Score" }
|
||||||
th { "Winner" }
|
th { "Winner" }
|
||||||
th { "You" }
|
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
@@ -13,21 +13,13 @@ use sycamore_router::navigate;
|
|||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::components::card::card_back;
|
use crate::components::card::card_back;
|
||||||
|
use crate::components::login::login_card;
|
||||||
use crate::components::toast::toast;
|
use crate::components::toast::toast;
|
||||||
use crate::model::{
|
use crate::model::{
|
||||||
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
fallback_game_types, is_individual_game, AuthProvider, GameTypeInfo, JoinPreview,
|
||||||
MyGame, OptionField, OptionKind, PlayerRating, User,
|
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 {
|
fn json_to_string(value: &serde_json::Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
serde_json::Value::String(s) => s.clone(),
|
serde_json::Value::String(s) => s.clone(),
|
||||||
@@ -386,18 +378,30 @@ pub fn LobbyPage() -> View {
|
|||||||
if me.is_some() {
|
if me.is_some() {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(p) = api::my_ratings().await {
|
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 {
|
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) => {
|
Err(e) => {
|
||||||
error.set(Some(e));
|
if error.is_alive() {
|
||||||
user.set(Some(None));
|
error.set(Some(e));
|
||||||
|
}
|
||||||
|
if user.is_alive() {
|
||||||
|
user.set(Some(None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -405,6 +409,9 @@ pub fn LobbyPage() -> View {
|
|||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
match api::game_types().await {
|
match api::game_types().await {
|
||||||
Ok(types) if !types.is_empty() => {
|
Ok(types) if !types.is_empty() => {
|
||||||
|
if !game_types.is_alive() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
selected_game.set(types[0].id.clone());
|
selected_game.set(types[0].id.clone());
|
||||||
game_types.set(types);
|
game_types.set(types);
|
||||||
}
|
}
|
||||||
@@ -414,7 +421,9 @@ pub fn LobbyPage() -> View {
|
|||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
if let Ok(list) = api::auth_providers().await {
|
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! {
|
Some(None) => view! { (login_card(auth_providers)) },
|
||||||
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(Some(_me)) => {
|
Some(Some(_me)) => {
|
||||||
view! {
|
view! {
|
||||||
(crate::components::nav::app_nav("/", move || {
|
(crate::components::nav::app_nav("/", move || {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ pub mod game;
|
|||||||
pub mod history;
|
pub mod history;
|
||||||
pub mod leaderboard;
|
pub mod leaderboard;
|
||||||
pub mod lobby;
|
pub mod lobby;
|
||||||
|
pub mod login;
|
||||||
|
|||||||
+64
-11
@@ -292,8 +292,12 @@ body {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: rgba(0, 0, 0, 0.65);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
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;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,8 +306,11 @@ body {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-width: min(320px, calc(100vw - 1.5rem));
|
/* Centers the dialog when it fits and keeps it reachable (top
|
||||||
max-width: calc(100vw - 1.5rem);
|
* 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 {
|
.capture-options {
|
||||||
@@ -784,26 +791,64 @@ body {
|
|||||||
border-color: var(--text);
|
border-color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The auction winner's call picker: one card per suit. */
|
/* The auction winner's call picker: the four cards of the won rank,
|
||||||
.call-option {
|
* laid out in a compact grid so the dialog stays short. */
|
||||||
flex-direction: column;
|
.call-picker {
|
||||||
align-items: center;
|
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;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-label {
|
.call-label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.solo-note {
|
.call-note {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.partner {
|
||||||
|
color: var(--team-a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-note.solo {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,3 +871,11 @@ body {
|
|||||||
max-width: 100%;
|
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
@@ -40,11 +40,27 @@
|
|||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--felt-dark);
|
background-color: var(--felt-dark);
|
||||||
background-image: radial-gradient(
|
/* Casino-table felt: a fine grain texture tiled over the green
|
||||||
ellipse at 50% -10%,
|
* gradient, lit from above and darkened towards the edges. The first
|
||||||
var(--felt) 0%,
|
* layer is the topmost one. */
|
||||||
var(--felt-dark) 65%
|
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;
|
background-attachment: fixed;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user