Compare commits

...
Author SHA1 Message Date
woggioni 5be4926885 Bundle kaya-otel in the Docker image
CI / Build and push docker image (push) Successful in 1m57s
The tavolo-app otel extra was never installed: requirements.txt was
locked without it and the Dockerfile installs the local distributions
with --no-deps, so any container with OTEL_ENABLED set died at import
with "kaya-otel is not installed".

Add the extra's full closure (kaya-otel plus its OpenTelemetry/protobuf
dependencies) to the pinned lock and refresh the comments that claimed
the extra had to be added separately.
2026-09-26 15:47:16 +08:00
woggioni a3566f2083 Preview a match by join code before joining
CI / Build and push docker image (push) Successful in 1m22s
GET /api/games/preview returns the lobby behind a code plus the game's
name and engine-echoed options. The lobby debounces the lookup as a
complete code is typed and shows the game, seated players, open seats
and option badges rendered from the game's schema, disabling Join when
the lobby is full or the match has already started.
2026-09-24 09:03:31 +08:00
woggioni cc0b31ce6b Let the asso pigliatutto ace capture itself on a bare table
With the rule enabled, an ace played on an empty table goes straight to
the player's capture pile instead of staying on the table: it counts as
the last capture but never scores a scopa. An empty capture request now
matches only that self-capture option, so requesting nothing still can't
satisfy a real capture.
2026-09-23 23:53:40 +08:00
woggioni 8f0e974842 Add optional asso pigliatutto rule with a lobby toggle
CI / Build and push docker image (push) Successful in 3m16s
An ace played when no equal rank is on the table sweeps the whole
table; the sweep never scores a scopa, and an ace already on the table
still forces the mandatory equal-rank capture. The rule is off by
default, selectable per match via the schema-driven creation form, and
announced in the game view.
2026-09-23 23:22:37 +08:00
21 changed files with 1019 additions and 38 deletions
+2 -1
View File
@@ -38,7 +38,8 @@ echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
When a hand ends but the match is not decided, the game pauses on a When a hand ends but the match is not decided, the game pauses on a
**scoring summary screen**: every player sees how each category was won **scoring summary screen**: every player sees how each category was won
(carte, denara, settebello, primiera, scope — plus napola when enabled) (carte, denara, settebello, primiera, scope — plus napola and asso
pigliatutto when enabled)
with the running totals and with the running totals and
must click "Understood" before the next hand is dealt. If someone is away must click "Understood" before the next hand is dealt. If someone is away
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS` the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
+1 -1
View File
@@ -72,7 +72,7 @@ data:
# CORS_EXPOSE_HEADERS: "" # CORS_EXPOSE_HEADERS: ""
# CORS_MAX_AGE: "600" # CORS_MAX_AGE: "600"
# OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled # OpenTelemetry (kaya-otel): traces + metrics via OTLP/HTTP, disabled
# unless OTEL_ENABLED is set. Requires the otel extra in the image. # unless OTEL_ENABLED is set. The image bundles the otel extra.
# OTEL_ENABLED: "true" # OTEL_ENABLED: "true"
# OTEL_SERVICE_NAME: "tavolo" # OTEL_SERVICE_NAME: "tavolo"
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318" # OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
+2 -1
View File
@@ -123,7 +123,8 @@ services:
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-} CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
CORS_MAX_AGE: ${CORS_MAX_AGE:-} CORS_MAX_AGE: ${CORS_MAX_AGE:-}
# OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set. # OpenTelemetry (kaya-otel): disabled unless OTEL_ENABLED is set.
# Requires the otel extra in the image (see server/pyproject.toml). # The image bundles the otel extra (see packages/tavolo-app/pyproject.toml
# and server/requirements.txt).
OTEL_ENABLED: ${OTEL_ENABLED:-} OTEL_ENABLED: ${OTEL_ENABLED:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-} OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-} OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
+2 -1
View File
@@ -60,7 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# The three sibling distributions, installed from the local tree (PEP 420 # The three sibling distributions, installed from the local tree (PEP 420
# namespace packages sharing the `tavolo` namespace). --no-deps: every # namespace packages sharing the `tavolo` namespace). --no-deps: every
# third-party dependency is already pinned in requirements.txt. # third-party dependency — including the tavolo-app `otel` extra — is
# already pinned in requirements.txt.
COPY server/packages/ ./packages/ COPY server/packages/ ./packages/
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app /opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app
+9 -2
View File
@@ -81,7 +81,7 @@ All configuration comes from environment variables (see `.env.example`):
| `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests | | `CORS_ALLOW_CREDENTIALS` | `false` | `1`/`true`/`yes`/`on` allow cookies/credentials on cross-origin requests |
| `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser | | `CORS_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
| `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response | | `CORS_MAX_AGE` | `600` | Seconds browsers may cache the preflight response |
| `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (requires the `otel` extra, i.e. `pip install tavolo-app[otel]`) | | `OTEL_ENABLED` | `false` | `1`/`true`/`yes`/`on` enable OpenTelemetry traces and metrics (the Docker image bundles the `otel` extra; non-image installs need `pip install tavolo-app[otel]`) |
| `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry | | `OTEL_SERVICE_NAME` | `tavolo` | `service.name` resource attribute of the exported telemetry |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default | | `OTEL_EXPORTER_OTLP_ENDPOINT` | unset | Base URL of an OTLP/HTTP collector (e.g. `http://localhost:4318`); unset uses the exporter default |
| `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) | | `OTEL_EXPORTER_OTLP_HEADERS` | unset | Comma-separated `key=value` headers sent to the collector (e.g. authentication) |
@@ -189,8 +189,9 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` | | `GET` | `/api/game-types` | The games the platform can host (for the creation dropdown), each with its `options_schema` |
| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) | | `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}`. Returns the lobby (`{id, join_code, players, seats_open, ...}`) |
| `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The last player triggers the start and gets the game view back |
| `GET` | `/api/games/preview?code=ABC123` | Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. `404` when the code is unknown |
| `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) |
| `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) | | `GET` | `/api/me/matches` | Cursor-paginated match history with the engine's `result` and per-player Elo deltas (`?limit=&cursor=&game_type=`) |
| `GET` | `/api/me/ratings` | The caller's Elo rating per game type | | `GET` | `/api/me/ratings` | The caller's Elo rating per game type |
@@ -270,6 +271,12 @@ must dismiss. A play attempted in this phase is rejected with an
point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …). point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …).
A team that captures the whole denari suit (ace to king) wins the match A team that captures the whole denari suit (ace to king) wins the match
instantly, regardless of the score. instantly, regardless of the score.
- Optional *asso pigliatutto* rule (per-game `asso_pigliatutto` creation
option, default off): an ace played when no equal rank is on the table
sweeps every card on the table — or captures itself when the table is
empty. The capture never scores a scopa, but it counts as the last
capture, and the mandatory equal-rank capture still applies when another
ace is on the table.
- The match ends when a team reaches the target score (default 11, - The match ends when a team reaches the target score (default 11,
configurable per game via the `target_score` creation option) with a configurable per game via the `target_score` creation option) with a
clear lead; a tie at or above the target is broken by another hand. clear lead; a tie at or above the target is broken by another hand.
@@ -201,7 +201,13 @@ class GameEngine(ABC):
@abstractmethod @abstractmethod
def lobby_view(self, session: GameSession) -> Dict[str, Any]: def lobby_view(self, session: GameSession) -> Dict[str, Any]:
"""Game-specific fields of the lobby payload (options echo, phase).""" """Game-specific fields of the lobby payload (options echo, phase).
Echo the creation options under the same keys as
:attr:`options_schema`, plus ``phase``, so clients can render a
generic pre-join preview (``GET /api/games/preview``) from the
schema without game-specific code.
"""
@abstractmethod @abstractmethod
def in_lobby(self, session: GameSession) -> bool: def in_lobby(self, session: GameSession) -> bool:
@@ -24,7 +24,14 @@ from kaya.openapi import operation
from ..auth import display_name, require_auth from ..auth import display_name, require_auth
from ..engine import GameSession, Seat from ..engine import GameSession, Seat
from ..errors import GameError from ..errors import GameError
from ..http import JsonRequestError, read_json, read_json_optional, send_error, send_json from ..http import (
JsonRequestError,
extract_query_params,
read_json,
read_json_optional,
send_error,
send_json,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from ..mixin import Platform from ..mixin import Platform
@@ -79,6 +86,18 @@ def _view_payload(platform: "Platform", session: GameSession, sub: str) -> Dict[
} }
def _preview_payload(platform: "Platform", session: GameSession) -> Dict[str, Any]:
"""The join-code preview: the lobby payload plus the game's name and
description, so a client can render a pre-join summary without a
second lookup."""
engine = platform.registry.require(session.game_type)
return {
**_lobby_payload(platform, session),
"game_name": engine.name,
"game_description": engine.description,
}
def register(app: KayaApp, platform: "Platform") -> None: def register(app: KayaApp, platform: "Platform") -> None:
@app.GET("/api/game-types") @app.GET("/api/game-types")
@operation(summary="List available games", @operation(summary="List available games",
@@ -262,6 +281,33 @@ def register(app: KayaApp, platform: "Platform") -> None:
log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat) log.info("%s joined game %s (seat %d); match started", user.sub, session.id, seat)
await send_json(ctx, 200, _view_payload(platform, session, user.sub)) await send_json(ctx, 200, _view_payload(platform, session, user.sub))
@app.GET("/api/games/preview")
@operation(summary="Preview a game by join code",
description="Looks up a game by its join code without "
"joining, for the lobby's pre-join summary: game "
"name and description, seated players, open "
"seats and the game-specific options echoed by "
"its engine.",
tags=["games"],
responses={
200: {"description": "The lobby preview"},
400: {"description": "Missing code"},
401: {"description": "Authentication required"},
404: {"description": "Unknown join code"},
})
@require_auth(platform.oidc)
async def preview_game(ctx: HttpContext) -> None:
values = extract_query_params(ctx.query_string).get("code")
code = values[0].strip() if values else ""
if not code:
await send_error(ctx, 400, "code is required")
return
session = await platform.game_store.find_by_code(code)
if session is None:
await send_error(ctx, 404, "unknown join code")
return
await send_json(ctx, 200, _preview_payload(platform, session))
@app.GET("/api/games/${game_id}") @app.GET("/api/games/${game_id}")
@operation(summary="Get a game snapshot", @operation(summary="Get a game snapshot",
description="Only seated players may read a game; the view is " description="Only seated players may read a game; the view is "
@@ -69,6 +69,56 @@ class GamesRouteTest(unittest.TestCase):
self.assertEqual(0, state["plays"]) self.assertEqual(0, state["plays"])
self.assertTrue(state["viewer_seated"]) self.assertTrue(state["viewer_seated"])
@async_test
async def test_preview_requires_auth(self) -> None:
app, _, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
response = await client.get("/api/games/preview?code=ABC123")
self.assertEqual(401, response.status_code)
@async_test
async def test_preview_lobby_and_started_game(self) -> None:
app, platform, _ = make_platform()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user(platform.oidc, "alice"):
created = await client.post(
"/api/games", json={"options": {"target": 5}}
)
code = created.json()["join_code"]
with oidc_user(platform.oidc, "mallory"):
missing = await client.get("/api/games/preview")
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
self.assertEqual(400, missing.status_code)
self.assertEqual(404, unknown.status_code)
with oidc_user(platform.oidc, "mallory"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("dummy", body["game_type"])
self.assertEqual("Dummy game", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(5, body["target"])
self.assertEqual(1, body["seats_open"])
self.assertEqual("alice", body["players"][0]["sub"])
# Join codes are case-insensitive.
with oidc_user(platform.oidc, "mallory"):
lower = await client.get(f"/api/games/preview?code={code.lower()}")
self.assertEqual(200, lower.status_code)
with oidc_user(platform.oidc, "bob"):
await client.post("/api/games/join", json={"code": code})
with oidc_user(platform.oidc, "mallory"):
started = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, started.status_code)
self.assertEqual("playing", started.json()["phase"])
self.assertEqual(0, started.json()["seats_open"])
@async_test @async_test
async def test_create_rejects_bad_options(self) -> None: async def test_create_rejects_bad_options(self) -> None:
app, platform, _ = make_platform() app, platform, _ = make_platform()
+7 -5
View File
@@ -11,15 +11,17 @@ implementation.
(de)serialization. No session envelope, no transport, no I/O. (de)serialization. No session envelope, no transport, no I/O.
- `engine.py` — the pure rules engine: deck, legal captures, plays, - `engine.py` — the pure rules engine: deck, legal captures, plays,
auto-play, hand scoring (carte, denara, settebello, primiera, scope, auto-play, hand scoring (carte, denara, settebello, primiera, scope,
napola), hand-end acknowledgements. Deterministic and I/O-free apart napola), the optional asso pigliatutto capture, hand-end
from logging, so the whole rule set is unit-testable. acknowledgements. Deterministic and I/O-free apart from logging, so the
whole rule set is unit-testable.
- `errors.py` — scopone-specific errors (`CardNotInHand`); every other - `errors.py` — scopone-specific errors (`CardNotInHand`); every other
failure mode is a shared `tavolo.platform.errors` subclass. failure mode is a shared `tavolo.platform.errors` subclass.
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter. - `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
It translates create/join/websocket actions/deadlines into rules-engine It translates create/join/websocket actions/deadlines into rules-engine
calls and back, validates the `target_score`/`napola` creation options, calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
and extracts the `MatchResult` (teams, winner, per-player scores and the creation options, and extracts the `MatchResult` (teams, winner,
match summary persisted as the match's JSON `result`). per-player scores and the match summary persisted as the match's JSON
`result`).
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
@@ -28,6 +28,12 @@ Rules implemented
consecutive denari starting from the ace scores one point per card when consecutive denari starting from the ace scores one point per card when
it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team it reaches at least three cards (A-2-3 = 3, A-2-3-4 = 4, ...). A team
capturing the whole denari suit (ace to king) wins the match instantly. capturing the whole denari suit (ace to king) wins the match instantly.
* Optional ``asso_pigliatutto`` rule (disabled by default): an ace played
when no equal rank is on the table sweeps the whole table — or captures
itself, leaving the table empty, when the table is bare. The sweep never
scores a scopa, even when cards remain in hand, but it counts as a
capture for the last-taker rule. When another ace is on the table the
mandatory equal-rank capture still applies.
* The match ends when a team reaches the target score with a clear lead; a * The match ends when a team reaches the target score with a clear lead; a
tie at or above the target is broken by playing another hand. tie at or above the target is broken by playing another hand.
""" """
@@ -110,17 +116,23 @@ def shuffled_deck(rng: Optional[random.Random] = None) -> List[Card]:
return deck return deck
def legal_captures(table: Sequence[Card], card: Card) -> List[List[Card]]: def legal_captures(
table: Sequence[Card], card: Card, *, asso_pigliatutto: bool = False
) -> List[List[Card]]:
"""Return every legal capture (a list of card sets) for ``card``. """Return every legal capture (a list of card sets) for ``card``.
If an equal-ranked card is on the table, only those single-card If an equal-ranked card is on the table, only those single-card
captures are returned (the rule forbids taking a combination instead). captures are returned (the rule forbids taking a combination instead).
Otherwise every subset of the table whose ranks sum to ``card.rank`` is Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
returned. the only capture is the whole table — or an empty capture when the
table is bare, the ace taking itself. Without the option every subset
of the table whose ranks sum to ``card.rank`` is returned.
""" """
equal = [c for c in table if c.rank == card.rank] equal = [c for c in table if c.rank == card.rank]
if equal: if equal:
return [[c] for c in equal] return [[c] for c in equal]
if asso_pigliatutto and card.rank == 1:
return [list(table)]
candidates = [c for c in table if c.rank <= card.rank] candidates = [c for c in table if c.rank <= card.rank]
captures: List[List[Card]] = [] captures: List[List[Card]] = []
@@ -140,6 +152,7 @@ def create_game(
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
napola: bool = True, napola: bool = True,
asso_pigliatutto: bool = False,
) -> ScoponeState: ) -> ScoponeState:
"""Create a lobby game with the creator seated first.""" """Create a lobby game with the creator seated first."""
if target_score < 1 or target_score > 100: if target_score < 1 or target_score > 100:
@@ -147,6 +160,7 @@ def create_game(
return ScoponeState( return ScoponeState(
target_score=target_score, target_score=target_score,
napola=napola, napola=napola,
asso_pigliatutto=asso_pigliatutto,
phase=PHASE_LOBBY, phase=PHASE_LOBBY,
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
hand_ack_timeout=hand_ack_timeout, hand_ack_timeout=hand_ack_timeout,
@@ -240,7 +254,18 @@ def play(
player.hand.remove(card) player.hand.remove(card)
requested = [parse_card(c) for c in (capture_codes or [])] requested = [parse_card(c) for c in (capture_codes or [])]
options = legal_captures(state.table, card) table_before = list(state.table)
options = legal_captures(
table_before, card, asso_pigliatutto=state.asso_pigliatutto
)
# An asso pigliatutto capture: the ace takes the whole table because no
# equal rank was on it, or takes itself when the table is bare. Such a
# capture never scores a scopa.
pigliatutto = (
state.asso_pigliatutto
and card.rank == 1
and all(c.rank != card.rank for c in table_before)
)
taken: List[Card] = [] taken: List[Card] = []
scopa = False scopa = False
@@ -260,7 +285,7 @@ def play(
state.last_taker = player.seat state.last_taker = player.seat
# A scopa scores only if cards remain to be played this hand. # A scopa scores only if cards remain to be played this hand.
hands_empty = all(not p.hand for p in state.players) hands_empty = all(not p.hand for p in state.players)
if not state.table and not hands_empty: if not state.table and not hands_empty and not pigliatutto:
player.scope += 1 player.scope += 1
scopa = True scopa = True
@@ -270,6 +295,7 @@ def play(
card=card.code, card=card.code,
captured=[c.code for c in taken], captured=[c.code for c in taken],
scopa=scopa, scopa=scopa,
pigliatutto=pigliatutto,
) )
if all(not p.hand for p in state.players): if all(not p.hand for p in state.players):
@@ -282,10 +308,12 @@ def play(
def _match_option( def _match_option(
options: Sequence[Sequence[Card]], requested: Sequence[Card] options: Sequence[Sequence[Card]], requested: Sequence[Card]
) -> Optional[List[Card]]: ) -> Optional[List[Card]]:
"""Return the option matching ``requested`` exactly, if any.""" """Return the option matching ``requested`` exactly, if any.
An empty request matches only an empty option — the asso pigliatutto
self-capture — so requesting nothing never satisfies a real capture.
"""
wanted = sorted(c.code for c in requested) wanted = sorted(c.code for c in requested)
if not wanted:
return None
for option in options: for option in options:
if sorted(c.code for c in option) == wanted: if sorted(c.code for c in option) == wanted:
return list(option) return list(option)
@@ -308,13 +336,15 @@ def auto_play(state: ScoponeState, rng: Optional[random.Random] = None) -> None:
raise IllegalMove("the player on turn has no cards") raise IllegalMove("the player on turn has no cards")
chooser = rng or _rng chooser = rng or _rng
card = chooser.choice(player.hand) card = chooser.choice(player.hand)
options = legal_captures(state.table, card) options = legal_captures(
state.table, card, asso_pigliatutto=state.asso_pigliatutto
)
capture = chooser.choice(options) if options else None capture = chooser.choice(options) if options else None
play( play(
state, state,
player.sub, player.sub,
card.code, card.code,
[c.code for c in capture] if capture else None, [c.code for c in capture] if capture is not None else None,
) )
@@ -526,6 +556,7 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola, "napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
"hand_number": state.hand_number, "hand_number": state.hand_number,
"dealer": state.dealer, "dealer": state.dealer,
"turn": state.turn, "turn": state.turn,
@@ -545,7 +576,9 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
# rule logic stays server-side. # rule logic stays server-side.
legal_moves: Dict[str, List[List[str]]] = {} legal_moves: Dict[str, List[List[str]]] = {}
for hand_card in viewer.hand: for hand_card in viewer.hand:
options = legal_captures(state.table, hand_card) options = legal_captures(
state.table, hand_card, asso_pigliatutto=state.asso_pigliatutto
)
if options: if options:
legal_moves[hand_card.code] = [ legal_moves[hand_card.code] = [
[c.code for c in option] for option in options [c.code for c in option] for option in options
@@ -75,6 +75,12 @@ class ScoponeEngine(GameEngine):
"description": "Score the napola rule; a full denari " "description": "Score the napola rule; a full denari "
"sweep wins the match", "sweep wins the match",
}, },
"asso_pigliatutto": {
"type": "boolean",
"default": False,
"description": "Score the asso pigliatutto rule; an ace "
"sweeps the whole table (no scopa)",
},
}, },
} }
@@ -95,6 +101,9 @@ class ScoponeEngine(GameEngine):
napola = options.get("napola", True) napola = options.get("napola", True)
if not isinstance(napola, bool): if not isinstance(napola, bool):
raise IllegalMove("napola must be a boolean") raise IllegalMove("napola must be a boolean")
asso_pigliatutto = options.get("asso_pigliatutto", False)
if not isinstance(asso_pigliatutto, bool):
raise IllegalMove("asso_pigliatutto must be a boolean")
creator = session.players[0] creator = session.players[0]
session.state = engine.create_game( session.state = engine.create_game(
creator_sub=creator.user_sub, creator_sub=creator.user_sub,
@@ -103,6 +112,7 @@ class ScoponeEngine(GameEngine):
hand_ack_timeout=self._hand_ack_timeout, hand_ack_timeout=self._hand_ack_timeout,
turn_timeout=self._turn_timeout, turn_timeout=self._turn_timeout,
napola=napola, napola=napola,
asso_pigliatutto=asso_pigliatutto,
) )
# The creator takes seat 0, i.e. team A. # The creator takes seat 0, i.e. team A.
session.players[0] = Seat( session.players[0] = Seat(
@@ -133,6 +143,7 @@ class ScoponeEngine(GameEngine):
"phase": state.phase, "phase": state.phase,
"target_score": state.target_score, "target_score": state.target_score,
"napola": state.napola, "napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
} }
# -- play ------------------------------------------------------------- # -- play -------------------------------------------------------------
@@ -92,6 +92,9 @@ class Move:
card: str card: str
captured: List[str] = field(default_factory=list) captured: List[str] = field(default_factory=list)
scopa: bool = False scopa: bool = False
# The play was an asso pigliatutto capture (the whole table, or the
# ace itself when the table was empty).
pigliatutto: bool = False
def to_json(self) -> Dict[str, Any]: def to_json(self) -> Dict[str, Any]:
return { return {
@@ -100,6 +103,7 @@ class Move:
"card": self.card, "card": self.card,
"captured": list(self.captured), "captured": list(self.captured),
"scopa": self.scopa, "scopa": self.scopa,
"pigliatutto": self.pigliatutto,
} }
@staticmethod @staticmethod
@@ -110,6 +114,7 @@ class Move:
card=str(data["card"]), card=str(data["card"]),
captured=[str(c) for c in data.get("captured", [])], captured=[str(c) for c in data.get("captured", [])],
scopa=bool(data.get("scopa", False)), scopa=bool(data.get("scopa", False)),
pigliatutto=bool(data.get("pigliatutto", False)),
) )
@@ -154,6 +159,11 @@ class ScoponeState:
# Whether the napola rule is scored (denari run from the ace; a full # Whether the napola rule is scored (denari run from the ace; a full
# suit wins the match instantly). Default on. # suit wins the match instantly). Default on.
napola: bool = True napola: bool = True
# Whether the asso pigliatutto rule is scored (an ace sweeps the whole
# table when no equal rank is on it, or captures itself when the table
# is empty; the capture never scores a scopa). Default off, also for
# states serialized before the option existed.
asso_pigliatutto: bool = False
phase: str = PHASE_LOBBY phase: str = PHASE_LOBBY
players: List[PlayerState] = field(default_factory=list) players: List[PlayerState] = field(default_factory=list)
table: List[Card] = field(default_factory=list) table: List[Card] = field(default_factory=list)
@@ -184,6 +194,7 @@ class ScoponeState:
return { return {
"target_score": self.target_score, "target_score": self.target_score,
"napola": self.napola, "napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase, "phase": self.phase,
"players": [p.to_json() for p in self.players], "players": [p.to_json() for p in self.players],
"table": [c.to_json() for c in self.table], "table": [c.to_json() for c in self.table],
@@ -207,6 +218,7 @@ class ScoponeState:
return ScoponeState( return ScoponeState(
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
napola=bool(data.get("napola", True)), napola=bool(data.get("napola", True)),
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
phase=str(data.get("phase", PHASE_LOBBY)), phase=str(data.get("phase", PHASE_LOBBY)),
players=[PlayerState.from_json(p) for p in data.get("players", [])], players=[PlayerState.from_json(p) for p in data.get("players", [])],
table=[Card.from_json(c) for c in data.get("table", [])], table=[Card.from_json(c) for c in data.get("table", [])],
@@ -325,6 +325,221 @@ class NapolaTest(unittest.TestCase):
) )
class AssoPigliatuttoTest(unittest.TestCase):
def test_ace_sweeps_the_whole_table_without_scopa(self) -> None:
# The other players still hold cards, so emptying the table would
# normally score a scopa: the sweep must suppress it.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["05C", "02D", "03S"])
self.assertEqual([], state.table)
self.assertEqual(
{"05C", "02D", "03S", "01D"},
{c.code for c in state.players[0].captured},
)
self.assertEqual(0, state.players[0].scope)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertFalse(state.last_move.scopa)
def test_legal_captures_offer_only_the_whole_table(self) -> None:
table = [card("05C"), card("02D"), card("03S")]
options = engine.legal_captures(
table, card("01D"), asso_pigliatutto=True
)
self.assertEqual(
[["05C", "02D", "03S"]], [[c.code for c in o] for o in options]
)
# A bare table leaves only the self-capture option; without the
# rule the ace stays on the table.
self.assertEqual(
[[]], engine.legal_captures([], card("01D"), asso_pigliatutto=True)
)
self.assertEqual(
[], engine.legal_captures([], card("01D"), asso_pigliatutto=False)
)
# Non-ace cards keep the normal rules.
self.assertEqual(
[],
engine.legal_captures(table, card("04B"), asso_pigliatutto=True),
)
def test_equal_rank_ace_blocks_the_sweep(self) -> None:
table = ["01C", "02D"]
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
options = engine.legal_captures(
state.table, card("01D"), asso_pigliatutto=True
)
self.assertEqual([["01C"]], [[c.code for c in o] for o in options])
with self.assertRaises(IllegalMove):
engine.play(state, "p0", "01D", ["01C", "02D"])
# A rejected play consumes the card, so retry on a fresh state.
state = make_state([["01D"], ["04D"], ["05D"], ["06D"]], table=table)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D", ["01C"])
self.assertEqual(["02D"], [c.code for c in state.table])
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_disabled_by_default(self) -> None:
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
engine.play(state, "p0", "01D")
# Without the rule an ace captures nothing here and stays on the
# table.
self.assertEqual({"05C", "02D", "01D"}, {c.code for c in state.table})
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
# ... and on a bare table it stays there too.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
)
engine.play(state, "p0", "01D")
self.assertEqual(["01D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
self.assertEqual(
[],
engine.legal_captures(
[card("05C")], card("01D"), asso_pigliatutto=False
),
)
def test_ace_on_empty_table_captures_itself(self) -> None:
# Other players still hold cards, so the bare table would trigger a
# (nonsensical) scopa without the pigliatutto suppression. The
# self-capture makes p0 the last taker, replacing seat 1.
state = make_state(
[["01D", "09C"], ["04D"], ["05D"], ["06D"]],
table=[],
last_taker=1,
)
state.asso_pigliatutto = True
engine.play(state, "p0", "01D")
self.assertEqual([], state.table)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual(0, state.players[0].scope)
self.assertEqual(0, state.last_taker)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual([], state.last_move.captured)
self.assertFalse(state.last_move.scopa)
def test_non_ace_on_empty_table_stays(self) -> None:
state = make_state(
[["02D", "09C"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
engine.play(state, "p0", "02D")
self.assertEqual(["02D"], [c.code for c in state.table])
self.assertEqual([], state.players[0].captured)
assert state.last_move is not None
self.assertFalse(state.last_move.pigliatutto)
def test_move_pigliatutto_serialization(self) -> None:
move = engine.Move(
seat=0, name="p0", card="01D", captured=["05C"], pigliatutto=True
)
restored = engine.Move.from_json(move.to_json())
self.assertTrue(restored.pigliatutto)
legacy = move.to_json()
del legacy["pigliatutto"]
self.assertFalse(engine.Move.from_json(legacy).pigliatutto)
def test_state_serialization_roundtrip(self) -> None:
state = make_state([["02D"], [], [], []], table=[])
self.assertFalse(state.asso_pigliatutto)
state.asso_pigliatutto = True
self.assertTrue(
ScoponeState.from_json(state.to_json()).asso_pigliatutto
)
# States serialized before the option existed default to disabled.
data = state.to_json()
del data["asso_pigliatutto"]
self.assertFalse(ScoponeState.from_json(data).asso_pigliatutto)
def test_create_game_default_and_override(self) -> None:
self.assertFalse(engine.create_game("p0", "p0").asso_pigliatutto)
self.assertTrue(
engine.create_game("p0", "p0", asso_pigliatutto=True).asso_pigliatutto
)
def test_state_for_player_exposes_rule_and_legal_moves(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D", "03S"],
)
state.asso_pigliatutto = True
view = engine.state_for_player(state, "p0")
self.assertTrue(view["asso_pigliatutto"])
self.assertEqual(
{"01D": [["05C", "02D", "03S"]]}, view["legal_moves"]
)
# On a bare table the ace advertises the self-capture.
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
view = engine.state_for_player(state, "p0")
self.assertEqual({"01D": [[]]}, view["legal_moves"])
def test_auto_play_sweeps_with_the_rule(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]],
table=["05C", "02D"],
)
state.asso_pigliatutto = True
engine.auto_play(state)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual(["05C", "02D"], state.last_move.captured)
def test_auto_play_self_captures_on_bare_table(self) -> None:
state = make_state(
[["01D"], ["04D"], ["05D"], ["06D"]], table=[]
)
state.asso_pigliatutto = True
engine.auto_play(state)
assert state.last_move is not None
self.assertTrue(state.last_move.pigliatutto)
self.assertEqual(["01D"], [c.code for c in state.players[0].captured])
self.assertEqual([], state.table)
def test_full_random_match_with_rule_keeps_all_cards(self) -> None:
state = engine.create_game(
"p0", "p0", target_score=11, asso_pigliatutto=True
)
for i in range(1, 4):
engine.join_game(state, f"p{i}", f"p{i}")
moves = 0
while state.phase != PHASE_FINISHED and moves < 200000:
if state.phase == "hand_end":
for p in state.players:
engine.acknowledge_hand(state, p.sub)
continue
player = next(p for p in state.players if p.seat == state.turn)
played = player.hand[0]
options = engine.legal_captures(
state.table, played, asso_pigliatutto=state.asso_pigliatutto
)
capture = [c.code for c in options[0]] if options else None
engine.play(state, player.sub, played.code, capture)
moves += 1
self.assertEqual(PHASE_FINISHED, state.phase)
self.assertIn(state.winner, (0, 1))
# Self-captures included, all 40 cards end up in capture piles.
self.assertEqual([], state.table)
self.assertTrue(all(not p.hand for p in state.players))
self.assertEqual(40, sum(len(p.captured) for p in state.players))
class MatchFlowTest(unittest.TestCase): class MatchFlowTest(unittest.TestCase):
def test_join_starts_when_full(self) -> None: def test_join_starts_when_full(self) -> None:
state = engine.create_game("p0", "p0", target_score=11) state = engine.create_game("p0", "p0", target_score=11)
@@ -43,6 +43,12 @@ class CreateTest(unittest.TestCase):
session = _session(engine, target_score=16, napola=False) session = _session(engine, target_score=16, napola=False)
self.assertEqual(16, session.state.target_score) self.assertEqual(16, session.state.target_score)
self.assertFalse(session.state.napola) self.assertFalse(session.state.napola)
self.assertFalse(session.state.asso_pigliatutto)
def test_create_asso_pigliatutto_option(self) -> None:
engine = ScoponeEngine()
session = _session(engine, asso_pigliatutto=True)
self.assertTrue(session.state.asso_pigliatutto)
def test_create_rejects_bad_options(self) -> None: def test_create_rejects_bad_options(self) -> None:
engine = ScoponeEngine() engine = ScoponeEngine()
@@ -52,6 +58,8 @@ class CreateTest(unittest.TestCase):
_session(engine, target_score="eleven") _session(engine, target_score="eleven")
with self.assertRaises(IllegalMove): with self.assertRaises(IllegalMove):
_session(engine, napola="yes") _session(engine, napola="yes")
with self.assertRaises(IllegalMove):
_session(engine, asso_pigliatutto="yes")
def test_timeouts_come_from_the_engine(self) -> None: def test_timeouts_come_from_the_engine(self) -> None:
engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9) engine = ScoponeEngine(turn_timeout_seconds=7, hand_ack_timeout_seconds=9)
@@ -159,6 +167,7 @@ class ViewTest(unittest.TestCase):
self.assertEqual("lobby", lobby["phase"]) self.assertEqual("lobby", lobby["phase"])
self.assertEqual(16, lobby["target_score"]) self.assertEqual(16, lobby["target_score"])
self.assertTrue(lobby["napola"]) self.assertTrue(lobby["napola"])
self.assertFalse(lobby["asso_pigliatutto"])
class SerializationTest(unittest.TestCase): class SerializationTest(unittest.TestCase):
@@ -283,6 +292,7 @@ class ResultTest(unittest.TestCase):
self.assertEqual(4, engine.max_players) self.assertEqual(4, engine.max_players)
self.assertIn("target_score", engine.options_schema["properties"]) self.assertIn("target_score", engine.options_schema["properties"])
self.assertIn("napola", engine.options_schema["properties"]) self.assertIn("napola", engine.options_schema["properties"])
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
if __name__ == "__main__": if __name__ == "__main__":
+47 -3
View File
@@ -1,15 +1,19 @@
# #
# This file is autogenerated by pip-compile with Python 3.14 # This file is autogenerated by pip-compile with Python 3.14
# by the following command: # by the following command (run from server/packages/tavolo-app/):
# #
# pip-compile --allow-unsafe --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=requirements.txt pyproject.toml # pip-compile --allow-unsafe --extra otel --extra-index-url=https://pypi.org/simple --index-url=https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --output-file=../../requirements.txt pyproject.toml
# #
# NOTE: the tavolo-platform, tavolo-scopone and tavolo-app sibling packages # NOTE: the tavolo-platform, tavolo-scopone and tavolo-app sibling packages
# are NOT pinned here: they are installed from ./packages in the Dockerfile # are NOT pinned here: they are installed from ./packages in the Dockerfile
# and in development (pip install -e ./packages/tavolo-platform # and in development (pip install -e ./packages/tavolo-platform
# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their # -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their
# third-party dependencies are direct dependencies of tavolo-app, so this # third-party dependencies are direct dependencies of tavolo-app, so this
# lock covers the full closure. # lock covers the full closure. The otel extra (kaya-otel and its
# OpenTelemetry dependencies) IS included, so OTEL_ENABLED works in the
# Docker image; pip-compile cannot resolve the unpublishable sibling
# packages, so regeneration drops them from a copy of the input file (the
# existing pins constrain the rest).
# #
--index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple --index-url https://gitea.woggioni.net/api/packages/woggioni/pypi/simple
--extra-index-url https://pypi.org/simple --extra-index-url https://pypi.org/simple
@@ -39,6 +43,8 @@ cryptography==50.0.1
# via pyjwt # via pyjwt
dictdiffer==0.10.0 dictdiffer==0.10.0
# via aerich # via aerich
googleapis-common-protos==1.75.4
# via opentelemetry-exporter-otlp-proto-http
granian==2.8.3 granian==2.8.3
# via # via
# kaya-rsgi # kaya-rsgi
@@ -62,6 +68,7 @@ kaya-core==0.0.4
# kaya-cors # kaya-cors
# kaya-oidc # kaya-oidc
# kaya-openapi # kaya-openapi
# kaya-otel
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
@@ -71,6 +78,8 @@ kaya-oidc==0.0.4
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-openapi==0.0.4 kaya-openapi==0.0.4
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-otel==0.0.4
# via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.4 kaya-rsgi==0.0.4
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-session==0.0.4 kaya-session==0.0.4
@@ -80,6 +89,35 @@ kaya-session==0.0.4
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-session-redis==0.0.4 kaya-session-redis==0.0.4
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0
# via
# opentelemetry-exporter-http-transport
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
opentelemetry-exporter-http-transport[urllib3]==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-common==0.66b0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-common==1.45.0
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.45.0
# via kaya-otel
opentelemetry-proto==1.45.0
# via
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.45.0
# via
# kaya-otel
# opentelemetry-exporter-otlp-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-semantic-conventions==0.66b0
# via opentelemetry-sdk
protobuf==7.36.2
# via
# googleapis-common-protos
# opentelemetry-proto
pwo==0.1.2 pwo==0.1.2
# via # via
# kaya-core # kaya-core
@@ -106,4 +144,10 @@ typing-extensions==4.16.0
# via # via
# anyio # anyio
# kaya-core # kaya-core
# opentelemetry-api
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pwo # pwo
urllib3==2.8.0
# via opentelemetry-exporter-http-transport
+74
View File
@@ -94,6 +94,77 @@ class GamesRouteTest(unittest.TestCase):
) )
self.assertEqual(400, invalid.status_code) self.assertEqual(400, invalid.status_code)
@async_test
async def test_create_asso_pigliatutto_option(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
default = await client.post("/api/games", json={})
self.assertEqual(201, default.status_code)
self.assertFalse(default.json()["asso_pigliatutto"])
with oidc_user("alice"):
enabled = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": True}}
)
self.assertEqual(201, enabled.status_code)
self.assertTrue(enabled.json()["asso_pigliatutto"])
with oidc_user("alice"):
invalid = await client.post(
"/api/games", json={"options": {"asso_pigliatutto": "yes"}}
)
self.assertEqual(400, invalid.status_code)
@async_test
async def test_preview_join_code(self) -> None:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client:
with oidc_user("alice"):
created = await client.post(
"/api/games",
json={
"options": {
"target_score": 16,
"napola": False,
"asso_pigliatutto": True,
}
},
)
code = created.json()["join_code"]
response = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(401, response.status_code)
with oidc_user("bob"):
missing = await client.get("/api/games/preview")
unknown = await client.get("/api/games/preview?code=ZZZZZZ")
self.assertEqual(400, missing.status_code)
self.assertEqual(404, unknown.status_code)
with oidc_user("bob"):
preview = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, preview.status_code)
body = preview.json()
self.assertEqual("scopone_scientifico", body["game_type"])
self.assertEqual("Scopone scientifico", body["game_name"])
self.assertTrue(body["game_description"])
self.assertEqual("lobby", body["phase"])
self.assertEqual(16, body["target_score"])
self.assertFalse(body["napola"])
self.assertTrue(body["asso_pigliatutto"])
self.assertEqual(3, body["seats_open"])
self.assertEqual(["alice"], [p["sub"] for p in body["players"]])
for player in ("bob", "carol", "dave"):
with oidc_user(player):
await client.post("/api/games/join", json={"code": code})
with oidc_user("bob"):
started = await client.get(f"/api/games/preview?code={code}")
self.assertEqual(200, started.status_code)
self.assertEqual("playing", started.json()["phase"])
self.assertEqual(0, started.json()["seats_open"])
@async_test @async_test
async def test_join_errors(self) -> None: async def test_join_errors(self) -> None:
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
@@ -163,6 +234,9 @@ class GameTypesRouteTest(unittest.TestCase):
self.assertEqual(4, results[0]["max_players"]) self.assertEqual(4, results[0]["max_players"])
self.assertIn("target_score", results[0]["options_schema"]["properties"]) self.assertIn("target_score", results[0]["options_schema"]["properties"])
self.assertIn("napola", results[0]["options_schema"]["properties"]) self.assertIn("napola", results[0]["options_schema"]["properties"])
self.assertIn(
"asso_pigliatutto", results[0]["options_schema"]["properties"]
)
@async_test @async_test
async def test_create_defaults_game_type(self) -> None: async def test_create_defaults_game_type(self) -> None:
+17
View File
@@ -70,6 +70,23 @@ pub async fn join_game(code: &str) -> Result<GameView, String> {
resp.json().await.map_err(|e| e.to_string()) resp.json().await.map_err(|e| e.to_string())
} }
/// Look up a game by join code without joining, for the pre-join summary.
/// `Ok(None)` means the code names no live game (404); other failures are
/// `Err`.
pub async fn join_preview(code: &str) -> Result<Option<JoinPreview>, String> {
let resp = Request::get(&format!("/api/games/preview?code={code}"))
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == 404 {
return Ok(None);
}
if !resp.ok() {
return Err(server_error(resp.status()));
}
resp.json().await.map(Some).map_err(|e| e.to_string())
}
#[allow(dead_code)] #[allow(dead_code)]
pub async fn game_state(game_id: &str) -> Result<GameView, String> { pub async fn game_state(game_id: &str) -> Result<GameView, String> {
let resp = Request::get(&format!("/api/games/{game_id}")) let resp = Request::get(&format!("/api/games/{game_id}"))
+261 -4
View File
@@ -49,6 +49,9 @@ pub struct MoveView {
pub captured: Vec<String>, pub captured: Vec<String>,
#[serde(default)] #[serde(default)]
pub scopa: bool, pub scopa: bool,
/// The play was an asso pigliatutto sweep (it took the whole table).
#[serde(default)]
pub pigliatutto: bool,
} }
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
@@ -117,6 +120,9 @@ pub struct GameView {
/// Whether the napola rule is scored in this match. /// Whether the napola rule is scored in this match.
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub napola: bool, pub napola: bool,
/// Whether the asso pigliatutto rule is scored in this match.
#[serde(default)]
pub asso_pigliatutto: bool,
pub phase: String, pub phase: String,
#[serde(default)] #[serde(default)]
pub target_score: i32, pub target_score: i32,
@@ -331,6 +337,83 @@ pub struct GameTypesPage {
pub results: Vec<GameTypeInfo>, pub results: Vec<GameTypeInfo>,
} }
/// A join-code lookup result (GET /api/games/preview): the game's lobby
/// payload plus its display name, for the pre-join summary. The options
/// echoed by the engine land in `details` under their `options_schema`
/// keys.
#[derive(Debug, Clone, Deserialize)]
pub struct JoinPreview {
#[serde(default)]
pub game_type: String,
#[serde(default)]
pub game_name: String,
#[serde(default)]
pub game_description: String,
#[serde(default)]
pub players: Vec<PreviewPlayer>,
#[serde(default)]
pub seats_open: Option<usize>,
#[serde(default)]
pub phase: String,
#[serde(flatten)]
pub details: HashMap<String, serde_json::Value>,
}
/// One seated player in a join preview.
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
pub struct PreviewPlayer {
pub name: String,
#[serde(default)]
pub seat: usize,
#[serde(default)]
pub team: Option<String>,
}
/// One option to show in the join preview, rendered from the game's
/// schema and the engine-echoed value.
#[derive(Debug, Clone, PartialEq)]
pub struct PreviewOption {
pub label: String,
pub value: String,
/// Present for booleans, for on/off styling.
pub on: Option<bool>,
}
impl JoinPreview {
/// Whether the lobby is still waiting for players with a free seat.
pub fn joinable(&self) -> bool {
self.phase == "lobby" && self.seats_open.map(|n| n > 0).unwrap_or(true)
}
/// The open-seat note for the preview, e.g. `" · 2 seats open"` (empty
/// when the engine does not report a seat count).
pub fn seats_text(&self) -> String {
match self.seats_open {
Some(1) => " · 1 seat open".to_string(),
Some(n) => format!(" · {n} seats open"),
None => String::new(),
}
}
/// The options to display: every schema field the engine echoed, in
/// schema order. Fields the engine does not echo are skipped.
pub fn options(&self, fields: &[OptionField]) -> Vec<PreviewOption> {
fields
.iter()
.filter_map(|field| {
let raw = self.details.get(&field.key)?;
let value = field.format_value(raw)?;
let on = match &field.kind {
OptionKind::Boolean => raw.as_bool(),
_ => None,
};
Some(PreviewOption { label: field.label.clone(), value, on })
})
.collect()
}
}
/// One creation option rendered from a game's `options_schema`. /// One creation option rendered from a game's `options_schema`.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum OptionKind { pub enum OptionKind {
@@ -344,14 +427,52 @@ pub enum OptionKind {
pub struct OptionField { pub struct OptionField {
pub key: String, pub key: String,
pub title: String, pub title: String,
/// Compact label for badges: the schema `title` when present, else the
/// key with underscores turned into spaces.
pub label: String,
pub kind: OptionKind, pub kind: OptionKind,
pub default: serde_json::Value, pub default: serde_json::Value,
} }
impl OptionField {
/// Human-readable rendering of one engine-echoed option value.
pub fn format_value(&self, value: &serde_json::Value) -> Option<String> {
match &self.kind {
OptionKind::Boolean => value
.as_bool()
.map(|b| if b { "On" } else { "Off" }.to_string()),
OptionKind::Integer { .. } => value
.as_i64()
.map(|n| n.to_string())
.or_else(|| value.as_f64().map(fmt_points)),
OptionKind::Text | OptionKind::Enum { .. } => {
value.as_str().map(str::to_string)
}
}
}
}
fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> { fn schema_string(schema: &serde_json::Value, key: &str) -> Option<String> {
schema.get(key).and_then(|v| v.as_str()).map(str::to_string) schema.get(key).and_then(|v| v.as_str()).map(str::to_string)
} }
/// Turn a schema key into a readable label: `asso_pigliatutto` ->
/// "Asso pigliatutto".
fn humanize_key(key: &str) -> String {
let spaced = key.replace('_', " ");
let spaced = spaced.trim();
if spaced.is_empty() {
return key.to_string();
}
let mut chars = spaced.chars();
let mut out = String::new();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
out
}
fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> { fn schema_i64(schema: &serde_json::Value, key: &str) -> Option<i64> {
schema.get(key).and_then(|v| { schema.get(key).and_then(|v| {
v.as_i64().or_else(|| { v.as_i64().or_else(|| {
@@ -372,9 +493,12 @@ impl GameTypeInfo {
properties properties
.iter() .iter()
.filter_map(|(key, schema)| { .filter_map(|(key, schema)| {
let title = schema_string(schema, "title") let explicit_title = schema_string(schema, "title");
let title = explicit_title
.clone()
.or_else(|| schema_string(schema, "description")) .or_else(|| schema_string(schema, "description"))
.unwrap_or_else(|| key.clone()); .unwrap_or_else(|| key.clone());
let label = explicit_title.unwrap_or_else(|| humanize_key(key));
let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null); let default = schema.get("default").cloned().unwrap_or(serde_json::Value::Null);
let kind = match schema.get("type").and_then(|v| v.as_str()) { let kind = match schema.get("type").and_then(|v| v.as_str()) {
Some("integer") => OptionKind::Integer { Some("integer") => OptionKind::Integer {
@@ -397,7 +521,7 @@ impl GameTypeInfo {
Some("string") => OptionKind::Text, Some("string") => OptionKind::Text,
_ => return None, _ => return None,
}; };
Some(OptionField { key: key.clone(), title, kind, default }) Some(OptionField { key: key.clone(), title, label, kind, default })
}) })
.collect() .collect()
} }
@@ -563,6 +687,7 @@ mod tests {
"default": 11, "default": 11,
}, },
"napola": {"type": "boolean", "default": true}, "napola": {"type": "boolean", "default": true},
"asso_pigliatutto": {"type": "boolean", "default": false},
}, },
}, },
})) }))
@@ -572,7 +697,7 @@ mod tests {
#[test] #[test]
fn option_fields_parse_scopone_schema() { fn option_fields_parse_scopone_schema() {
let fields = scopone_game_type().option_fields(); let fields = scopone_game_type().option_fields();
assert_eq!(2, fields.len()); assert_eq!(3, fields.len());
let by_key: std::collections::HashMap<_, _> = let by_key: std::collections::HashMap<_, _> =
fields.into_iter().map(|f| (f.key.clone(), f)).collect(); fields.into_iter().map(|f| (f.key.clone(), f)).collect();
let target = &by_key["target_score"]; let target = &by_key["target_score"];
@@ -583,6 +708,8 @@ mod tests {
assert_eq!(json!(11), target.default); assert_eq!(json!(11), target.default);
assert_eq!(OptionKind::Boolean, by_key["napola"].kind); assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
assert_eq!(json!(true), by_key["napola"].default); assert_eq!(json!(true), by_key["napola"].default);
assert_eq!(OptionKind::Boolean, by_key["asso_pigliatutto"].kind);
assert_eq!(json!(false), by_key["asso_pigliatutto"].default);
} }
#[test] #[test]
@@ -614,13 +741,14 @@ mod tests {
let opts = scopone_game_type().default_options(); let opts = scopone_game_type().default_options();
assert_eq!(Some(&json!(11)), opts.get("target_score")); assert_eq!(Some(&json!(11)), opts.get("target_score"));
assert_eq!(Some(&json!(true)), opts.get("napola")); assert_eq!(Some(&json!(true)), opts.get("napola"));
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
} }
#[test] #[test]
fn game_view_parses_new_lobby_payload() { fn game_view_parses_new_lobby_payload() {
// Exact shape of the platform's lobby payload: envelope fields, // Exact shape of the platform's lobby payload: envelope fields,
// seats with team labels, seats_open, plus the engine's // seats with team labels, seats_open, plus the engine's
// lobby_view (phase/target_score/napola for scopone). // lobby_view (phase/target_score/napola/asso_pigliatutto).
let g: GameView = serde_json::from_value(json!({ let g: GameView = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555", "id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123", "join_code": "ABC123",
@@ -632,6 +760,7 @@ mod tests {
"phase": "lobby", "phase": "lobby",
"target_score": 16, "target_score": 16,
"napola": false, "napola": false,
"asso_pigliatutto": true,
})) }))
.unwrap(); .unwrap();
assert_eq!("ABC123", g.join_code); assert_eq!("ABC123", g.join_code);
@@ -639,6 +768,7 @@ mod tests {
assert_eq!("lobby", g.phase); assert_eq!("lobby", g.phase);
assert_eq!(16, g.target_score); assert_eq!(16, g.target_score);
assert!(!g.napola); assert!(!g.napola);
assert!(g.asso_pigliatutto);
assert_eq!("A", g.players[0].team); assert_eq!("A", g.players[0].team);
} }
@@ -652,6 +782,7 @@ mod tests {
"phase": "playing", "phase": "playing",
"target_score": 11, "target_score": 11,
"napola": true, "napola": true,
"asso_pigliatutto": true,
"hand_number": 1, "hand_number": 1,
"dealer": 0, "dealer": 0,
"turn": 1, "turn": 1,
@@ -675,11 +806,137 @@ mod tests {
})) }))
.unwrap(); .unwrap();
assert_eq!("playing", g.phase); assert_eq!("playing", g.phase);
assert!(g.asso_pigliatutto);
assert_eq!(None, g.players[0].hand); assert_eq!(None, g.players[0].hand);
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand); assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
assert_eq!(Some(true), g.your_turn); assert_eq!(Some(true), g.your_turn);
} }
#[test]
fn move_view_parses_pigliatutto() {
let mv: MoveView = serde_json::from_value(json!({
"seat": 0,
"name": "Alice",
"card": "01D",
"captured": ["05C", "02D"],
"scopa": false,
"pigliatutto": true,
}))
.unwrap();
assert!(mv.pigliatutto);
// Older payloads without the flag default to a normal capture.
let legacy: MoveView = serde_json::from_value(json!({
"seat": 0, "name": "Alice", "card": "01D",
}))
.unwrap();
assert!(!legacy.pigliatutto);
}
#[test]
fn join_preview_parses_scopone_payload() {
let p: JoinPreview = serde_json::from_value(json!({
"id": "11111111-2222-3333-4444-555555555555",
"join_code": "ABC123",
"game_type": "scopone_scientifico",
"game_name": "Scopone scientifico",
"game_description": "Four players in fixed partnerships.",
"players": [
{"sub": "alice", "name": "Alice", "seat": 0, "team": "A"},
],
"seats_open": 3,
"phase": "lobby",
"target_score": 16,
"napola": false,
"asso_pigliatutto": true,
}))
.unwrap();
assert_eq!("Scopone scientifico", p.game_name);
assert_eq!("lobby", p.phase);
assert!(p.joinable());
assert_eq!(Some(3), p.seats_open);
assert_eq!("Alice", p.players[0].name);
assert_eq!(Some("A".to_string()), p.players[0].team);
// Options render from the schema, in schema order; keys the
// engine did not echo are skipped.
let opts = p.options(&scopone_game_type().option_fields());
assert_eq!(3, opts.len());
let by_label: HashMap<_, _> =
opts.into_iter().map(|o| (o.label.clone(), o)).collect();
assert_eq!("Target score", by_label["Target score"].label);
assert_eq!("16", by_label["Target score"].value);
assert_eq!(None, by_label["Target score"].on);
assert_eq!("Napola", by_label["Napola"].label);
assert_eq!("Off", by_label["Napola"].value);
assert_eq!(Some(false), by_label["Napola"].on);
assert_eq!("Asso pigliatutto", by_label["Asso pigliatutto"].label);
assert_eq!("On", by_label["Asso pigliatutto"].value);
assert_eq!(Some(true), by_label["Asso pigliatutto"].on);
}
#[test]
fn join_preview_joinable_states() {
let started: JoinPreview = serde_json::from_value(json!({
"game_type": "x", "phase": "playing", "seats_open": 0,
}))
.unwrap();
assert!(!started.joinable());
let full: JoinPreview = serde_json::from_value(json!({
"game_type": "x", "phase": "lobby", "seats_open": 0,
}))
.unwrap();
assert!(!full.joinable());
let open: JoinPreview = serde_json::from_value(json!({
"game_type": "x", "phase": "lobby", "seats_open": 1,
}))
.unwrap();
assert!(open.joinable());
assert!(open.options(&scopone_game_type().option_fields()).is_empty());
// Engines that don't report seats_open are assumed joinable.
let unknown: JoinPreview = serde_json::from_value(json!({
"game_type": "x", "phase": "lobby",
}))
.unwrap();
assert!(unknown.joinable());
assert_eq!("", unknown.seats_text());
assert_eq!(" · 1 seat open", open.seats_text());
assert_eq!(" · 0 seats open", full.seats_text());
let two: JoinPreview = serde_json::from_value(json!({
"game_type": "x", "phase": "lobby", "seats_open": 2,
}))
.unwrap();
assert_eq!(" · 2 seats open", two.seats_text());
}
#[test]
fn option_field_formats_values() {
let g: GameTypeInfo = serde_json::from_value(json!({
"id": "x",
"name": "x",
"options_schema": {
"type": "object",
"properties": {
"flag": {"type": "boolean", "title": "Flag", "default": false},
"count": {"type": "integer", "default": 2},
"mode": {"type": "string", "enum": ["a", "b"], "default": "a"},
"note": {"type": "string"},
},
},
}))
.unwrap();
let fields: HashMap<_, _> =
g.option_fields().into_iter().map(|f| (f.key.clone(), f)).collect();
assert_eq!(Some("On".to_string()), fields["flag"].format_value(&json!(true)));
assert_eq!(Some("Off".to_string()), fields["flag"].format_value(&json!(false)));
assert_eq!(None, fields["flag"].format_value(&json!("yes")));
assert_eq!(Some("16".to_string()), fields["count"].format_value(&json!(16)));
assert_eq!(Some("2.5".to_string()), fields["count"].format_value(&json!(2.5)));
assert_eq!(Some("b".to_string()), fields["mode"].format_value(&json!("b")));
assert_eq!(Some("hi".to_string()), fields["note"].format_value(&json!("hi")));
// Badges use the schema title, or the humanized key without one.
assert_eq!("Flag", fields["flag"].label);
assert_eq!("Count", fields["count"].label);
}
#[test] #[test]
fn game_type_without_schema_has_no_fields() { fn game_type_without_schema_has_no_fields() {
let g: GameTypeInfo = serde_json::from_value(json!({ let g: GameTypeInfo = serde_json::from_value(json!({
+8
View File
@@ -63,6 +63,9 @@ fn move_banner(mv: MoveView) -> View {
.map(|c| card_img(c.clone(), "move-card")) .map(|c| card_img(c.clone(), "move-card"))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } }); let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
let pigliatutto = mv
.pigliatutto
.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto!" } });
view! { view! {
div(class="move-banner") { div(class="move-banner") {
strong { (name) } strong { (name) }
@@ -70,6 +73,7 @@ fn move_banner(mv: MoveView) -> View {
(played) (played)
(captured) (captured)
(scopa) (scopa)
(pigliatutto)
} }
} }
} }
@@ -565,6 +569,9 @@ fn table_view(
let banner = game.last_move.clone().map(move_banner); let banner = game.last_move.clone().map(move_banner);
let hand_number = game.hand_number; let hand_number = game.hand_number;
let target_score = game.target_score; let target_score = game.target_score;
let rule_badge = game.asso_pigliatutto.then(|| view! {
span(class="pigliatutto-badge") { "Asso pigliatutto" }
});
let viewer = player_for_seat(&game, viewer_seat); let viewer = player_for_seat(&game, viewer_seat);
let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0); let my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
@@ -602,6 +609,7 @@ fn table_view(
div(class="hud") { div(class="hud") {
a(href="/") { "← Lobby" } a(href="/") { "← Lobby" }
span { "Hand " (hand_number) } span { "Hand " (hand_number) }
(rule_badge)
span(class="hud-scores") { span(class="hud-scores") {
"Team A " (scores.a) " — " (scores.b) " Team B (target " "Team A " (scores.a) " — " (scores.b) " Team B (target "
(target_score) ")" (target_score) ")"
+184 -7
View File
@@ -3,7 +3,9 @@
//! Match creation is game-generic: the form is rendered from the selected //! Match creation is game-generic: the form is rendered from the selected
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new //! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
//! games get a working creation UI without frontend changes. //! games get a working creation UI without frontend changes.
use std::cell::{Cell, RefCell};
use std::collections::HashMap; use std::collections::HashMap;
use std::rc::Rc;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use sycamore::prelude::*; use sycamore::prelude::*;
@@ -12,7 +14,7 @@ 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::toast::toast; use crate::components::toast::toast;
use crate::model::{GameTypeInfo, OptionField, OptionKind, PlayerRating, User}; use crate::model::{GameTypeInfo, JoinPreview, OptionField, OptionKind, PlayerRating, User};
/// Used when the game-types fetch fails: match creation must still work. /// Used when the game-types fetch fails: match creation must still work.
fn fallback_game_types() -> Vec<GameTypeInfo> { fn fallback_game_types() -> Vec<GameTypeInfo> {
@@ -181,6 +183,102 @@ fn OptionInput(props: OptionInputProps) -> View {
} }
} }
/// Pre-join summary of the match behind the entered code: game name,
/// seated players and the engine-echoed options, rendered from the game's
/// `options_schema` so every game gets a preview without custom code.
fn join_preview_panel(
preview: Option<JoinPreview>,
game_types: Vec<GameTypeInfo>,
checking: bool,
not_found: bool,
) -> View {
let Some(p) = preview else {
if checking {
return view! {
p(class="text-secondary small mb-0") { "Checking code…" }
};
}
if not_found {
return view! {
p(class="text-secondary small mb-0") { "No match with this code." }
};
}
return view! {};
};
let game = game_types.into_iter().find(|g| g.id == p.game_type);
let name = if p.game_name.is_empty() {
game.as_ref()
.map(|g| g.name.clone())
.unwrap_or_else(|| p.game_type.clone())
} else {
p.game_name.clone()
};
let description = if p.game_description.is_empty() {
game.as_ref().map(|g| g.description.clone()).unwrap_or_default()
} else {
p.game_description.clone()
};
let description_view = (!description.is_empty()).then(|| view! {
p(class="text-secondary small mb-2") { (description) }
});
let option_badges = game
.as_ref()
.map(|g| p.options(&g.option_fields()))
.unwrap_or_default()
.into_iter()
.map(|o| {
let cls = if o.on == Some(true) {
"badge text-bg-success me-1"
} else {
"badge text-bg-secondary me-1"
};
view! { span(class=cls) { (o.label) ": " (o.value) } }
})
.collect::<Vec<_>>();
let seated = p.players.len();
let players = p
.players
.iter()
.map(|player| {
let name = player.name.clone();
let team = player.team.as_ref().map(|t| {
let label = t.clone();
view! { span(class="badge text-bg-info ms-2") { "Team " (label) } }
});
view! {
li(
class="list-group-item d-flex justify-content-between \
align-items-center py-1 px-0"
) {
span { (name) }
(team)
}
}
})
.collect::<Vec<_>>();
let seats_text = p.seats_text();
let status_view = match p.phase.as_str() {
"lobby" if p.seats_open == Some(0) => Some("Lobby full"),
"lobby" => None,
_ => Some("Match in progress"),
}
.map(|text| view! { p(class="text-warning small mb-0") { (text) } });
view! {
div(class="join-preview border-top mt-3 pt-3") {
h3(class="h6 mb-1") { (name) }
(description_view)
p(class="text-secondary small mb-2") {
(seated) " seated" (seats_text)
}
ul(class="list-group list-group-flush mb-2") { (players) }
div(class="d-flex flex-wrap gap-1") { (option_badges) }
(status_view)
}
}
}
#[component] #[component]
pub fn LobbyPage() -> View { pub fn LobbyPage() -> View {
// Outer None = still loading; Some(None) = logged out. // Outer None = still loading; Some(None) = logged out.
@@ -211,6 +309,68 @@ pub fn LobbyPage() -> View {
} }
}); });
// Pre-join preview: once a complete code is typed, look it up
// (debounced) and show the match's details before joining.
let preview = create_signal(Option::<JoinPreview>::None);
let preview_not_found = create_signal(false);
let checking_code = create_signal(false);
let check_seq = Rc::new(Cell::new(0u64));
let check_timer: Rc<RefCell<Option<gloo_timers::callback::Timeout>>> =
Rc::new(RefCell::new(None));
let alive = Rc::new(Cell::new(true));
on_cleanup({
let check_timer = check_timer.clone();
let alive = alive.clone();
move || {
alive.set(false);
check_timer.borrow_mut().take();
}
});
create_effect({
let check_seq = check_seq.clone();
let check_timer = check_timer.clone();
let alive = alive.clone();
move || {
let value = code.get_clone().trim().to_uppercase();
// Any edit invalidates an in-flight lookup.
check_seq.set(check_seq.get() + 1);
check_timer.borrow_mut().take();
if value.len() != 6 {
preview.set(None);
preview_not_found.set(false);
checking_code.set(false);
return;
}
checking_code.set(true);
preview_not_found.set(false);
let seq = check_seq.get();
let seq_cell = check_seq.clone();
let alive_async = alive.clone();
*check_timer.borrow_mut() = Some(gloo_timers::callback::Timeout::new(
400,
move || {
let seq_cell = seq_cell.clone();
let alive = alive_async.clone();
spawn_local(async move {
let result = api::join_preview(&value).await;
if !alive.get() || seq_cell.get() != seq {
return;
}
match result {
Ok(Some(found)) => preview.set(Some(found)),
Ok(None) => {
preview.set(None);
preview_not_found.set(true);
}
Err(_) => preview.set(None),
}
checking_code.set(false);
});
},
));
}
});
spawn_local(async move { spawn_local(async move {
match api::me().await { match api::me().await {
Ok(me) => { Ok(me) => {
@@ -265,6 +425,22 @@ pub fn LobbyPage() -> View {
}); });
}; };
// Join is disabled only when the previewed lobby is full or already
// started; without a preview it stays enabled (the server still
// validates).
let join_button = move || {
let disabled = preview.get_clone().map(|p| !p.joinable()).unwrap_or(false);
view! {
button(
class="btn btn-warning btn-lg",
disabled=disabled,
on:click=on_join,
) {
"Join"
}
}
};
view! { view! {
div { div {
(toast(error)) (toast(error))
@@ -427,13 +603,14 @@ pub fn LobbyPage() -> View {
maxlength="6", maxlength="6",
bind:value=code, bind:value=code,
) )
button( (join_button)
class="btn btn-warning btn-lg",
on:click=on_join,
) {
"Join"
}
} }
(move || join_preview_panel(
preview.get_clone(),
game_types.get_clone(),
checking_code.get(),
preview_not_found.get(),
))
} }
} }
} }
+9
View File
@@ -257,6 +257,15 @@ body {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.pigliatutto-badge {
background: var(--danger, #d9534f);
color: #fff;
font-weight: 700;
border-radius: 999px;
padding: 0.15rem 0.6rem;
margin-left: 0.25rem;
}
/* ---------- overlays ---------- */ /* ---------- overlays ---------- */
.overlay { .overlay {