Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be4926885
|
||
|
|
a3566f2083
|
||
|
|
cc0b31ce6b
|
||
|
|
8f0e974842
|
@@ -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
|
||||
**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
|
||||
must click "Understood" before the next hand is dealt. If someone is away
|
||||
the next hand is dealt automatically after `HAND_ACK_TIMEOUT_SECONDS`
|
||||
|
||||
@@ -72,7 +72,7 @@ data:
|
||||
# CORS_EXPOSE_HEADERS: ""
|
||||
# CORS_MAX_AGE: "600"
|
||||
# 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_SERVICE_NAME: "tavolo"
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.observability:4318"
|
||||
|
||||
+2
-1
@@ -123,7 +123,8 @@ services:
|
||||
CORS_EXPOSE_HEADERS: ${CORS_EXPOSE_HEADERS:-}
|
||||
CORS_MAX_AGE: ${CORS_MAX_AGE:-}
|
||||
# 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_SERVICE_NAME: ${OTEL_SERVICE_NAME:-}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
|
||||
+2
-1
@@ -60,7 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
|
||||
# The three sibling distributions, installed from the local tree (PEP 420
|
||||
# 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/
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
/opt/venv/bin/pip install --no-deps ./packages/tavolo-platform ./packages/tavolo-scopone ./packages/tavolo-app
|
||||
|
||||
+9
-2
@@ -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_EXPOSE_HEADERS` | unset | Comma-separated response headers exposed to the browser |
|
||||
| `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_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) |
|
||||
@@ -189,8 +189,9 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`,
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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/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 |
|
||||
@@ -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, …).
|
||||
A team that captures the whole denari suit (ace to king) wins the match
|
||||
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,
|
||||
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.
|
||||
|
||||
@@ -201,7 +201,13 @@ class GameEngine(ABC):
|
||||
|
||||
@abstractmethod
|
||||
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
|
||||
def in_lobby(self, session: GameSession) -> bool:
|
||||
|
||||
@@ -24,7 +24,14 @@ from kaya.openapi import operation
|
||||
from ..auth import display_name, require_auth
|
||||
from ..engine import GameSession, Seat
|
||||
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:
|
||||
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:
|
||||
@app.GET("/api/game-types")
|
||||
@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)
|
||||
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}")
|
||||
@operation(summary="Get a game snapshot",
|
||||
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.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 def test_create_rejects_bad_options(self) -> None:
|
||||
app, platform, _ = make_platform()
|
||||
|
||||
@@ -11,15 +11,17 @@ implementation.
|
||||
(de)serialization. No session envelope, no transport, no I/O.
|
||||
- `engine.py` — the pure rules engine: deck, legal captures, plays,
|
||||
auto-play, hand scoring (carte, denara, settebello, primiera, scope,
|
||||
napola), hand-end acknowledgements. Deterministic and I/O-free apart
|
||||
from logging, so the whole rule set is unit-testable.
|
||||
napola), the optional asso pigliatutto capture, hand-end
|
||||
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
|
||||
failure mode is a shared `tavolo.platform.errors` subclass.
|
||||
- `plugin.py` — `ScoponeEngine(GameEngine)`: the platform-facing adapter.
|
||||
It translates create/join/websocket actions/deadlines into rules-engine
|
||||
calls and back, validates the `target_score`/`napola` creation options,
|
||||
and extracts the `MatchResult` (teams, winner, per-player scores and the
|
||||
match summary persisted as the match's JSON `result`).
|
||||
calls and back, validates the `target_score`/`napola`/`asso_pigliatutto`
|
||||
creation options, and extracts the `MatchResult` (teams, winner,
|
||||
per-player scores and the match summary persisted as the match's JSON
|
||||
`result`).
|
||||
|
||||
Timeouts are constructor arguments (`turn_timeout_seconds`,
|
||||
`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
|
||||
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.
|
||||
* 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
|
||||
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
|
||||
|
||||
|
||||
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``.
|
||||
|
||||
If an equal-ranked card is on the table, only those single-card
|
||||
captures are returned (the rule forbids taking a combination instead).
|
||||
Otherwise every subset of the table whose ranks sum to ``card.rank`` is
|
||||
returned.
|
||||
Otherwise, when ``asso_pigliatutto`` is enabled and ``card`` is an ace,
|
||||
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]
|
||||
if 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]
|
||||
captures: List[List[Card]] = []
|
||||
@@ -140,6 +152,7 @@ def create_game(
|
||||
hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
|
||||
turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS,
|
||||
napola: bool = True,
|
||||
asso_pigliatutto: bool = False,
|
||||
) -> ScoponeState:
|
||||
"""Create a lobby game with the creator seated first."""
|
||||
if target_score < 1 or target_score > 100:
|
||||
@@ -147,6 +160,7 @@ def create_game(
|
||||
return ScoponeState(
|
||||
target_score=target_score,
|
||||
napola=napola,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
phase=PHASE_LOBBY,
|
||||
players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)],
|
||||
hand_ack_timeout=hand_ack_timeout,
|
||||
@@ -240,7 +254,18 @@ def play(
|
||||
player.hand.remove(card)
|
||||
|
||||
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] = []
|
||||
scopa = False
|
||||
@@ -260,7 +285,7 @@ def play(
|
||||
state.last_taker = player.seat
|
||||
# A scopa scores only if cards remain to be played this hand.
|
||||
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
|
||||
scopa = True
|
||||
|
||||
@@ -270,6 +295,7 @@ def play(
|
||||
card=card.code,
|
||||
captured=[c.code for c in taken],
|
||||
scopa=scopa,
|
||||
pigliatutto=pigliatutto,
|
||||
)
|
||||
|
||||
if all(not p.hand for p in state.players):
|
||||
@@ -282,10 +308,12 @@ def play(
|
||||
def _match_option(
|
||||
options: Sequence[Sequence[Card]], requested: Sequence[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)
|
||||
if not wanted:
|
||||
return None
|
||||
for option in options:
|
||||
if sorted(c.code for c in option) == wanted:
|
||||
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")
|
||||
chooser = rng or _rng
|
||||
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
|
||||
play(
|
||||
state,
|
||||
player.sub,
|
||||
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,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
"hand_number": state.hand_number,
|
||||
"dealer": state.dealer,
|
||||
"turn": state.turn,
|
||||
@@ -545,7 +576,9 @@ def state_for_player(state: ScoponeState, sub: str) -> Dict[str, object]:
|
||||
# rule logic stays server-side.
|
||||
legal_moves: Dict[str, List[List[str]]] = {}
|
||||
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:
|
||||
legal_moves[hand_card.code] = [
|
||||
[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 "
|
||||
"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)
|
||||
if not isinstance(napola, bool):
|
||||
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]
|
||||
session.state = engine.create_game(
|
||||
creator_sub=creator.user_sub,
|
||||
@@ -103,6 +112,7 @@ class ScoponeEngine(GameEngine):
|
||||
hand_ack_timeout=self._hand_ack_timeout,
|
||||
turn_timeout=self._turn_timeout,
|
||||
napola=napola,
|
||||
asso_pigliatutto=asso_pigliatutto,
|
||||
)
|
||||
# The creator takes seat 0, i.e. team A.
|
||||
session.players[0] = Seat(
|
||||
@@ -133,6 +143,7 @@ class ScoponeEngine(GameEngine):
|
||||
"phase": state.phase,
|
||||
"target_score": state.target_score,
|
||||
"napola": state.napola,
|
||||
"asso_pigliatutto": state.asso_pigliatutto,
|
||||
}
|
||||
|
||||
# -- play -------------------------------------------------------------
|
||||
|
||||
@@ -92,6 +92,9 @@ class Move:
|
||||
card: str
|
||||
captured: List[str] = field(default_factory=list)
|
||||
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]:
|
||||
return {
|
||||
@@ -100,6 +103,7 @@ class Move:
|
||||
"card": self.card,
|
||||
"captured": list(self.captured),
|
||||
"scopa": self.scopa,
|
||||
"pigliatutto": self.pigliatutto,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -110,6 +114,7 @@ class Move:
|
||||
card=str(data["card"]),
|
||||
captured=[str(c) for c in data.get("captured", [])],
|
||||
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
|
||||
# suit wins the match instantly). Default on.
|
||||
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
|
||||
players: List[PlayerState] = field(default_factory=list)
|
||||
table: List[Card] = field(default_factory=list)
|
||||
@@ -184,6 +194,7 @@ class ScoponeState:
|
||||
return {
|
||||
"target_score": self.target_score,
|
||||
"napola": self.napola,
|
||||
"asso_pigliatutto": self.asso_pigliatutto,
|
||||
"phase": self.phase,
|
||||
"players": [p.to_json() for p in self.players],
|
||||
"table": [c.to_json() for c in self.table],
|
||||
@@ -207,6 +218,7 @@ class ScoponeState:
|
||||
return ScoponeState(
|
||||
target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)),
|
||||
napola=bool(data.get("napola", True)),
|
||||
asso_pigliatutto=bool(data.get("asso_pigliatutto", False)),
|
||||
phase=str(data.get("phase", PHASE_LOBBY)),
|
||||
players=[PlayerState.from_json(p) for p in data.get("players", [])],
|
||||
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):
|
||||
def test_join_starts_when_full(self) -> None:
|
||||
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)
|
||||
self.assertEqual(16, session.state.target_score)
|
||||
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:
|
||||
engine = ScoponeEngine()
|
||||
@@ -52,6 +58,8 @@ class CreateTest(unittest.TestCase):
|
||||
_session(engine, target_score="eleven")
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, napola="yes")
|
||||
with self.assertRaises(IllegalMove):
|
||||
_session(engine, asso_pigliatutto="yes")
|
||||
|
||||
def test_timeouts_come_from_the_engine(self) -> None:
|
||||
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(16, lobby["target_score"])
|
||||
self.assertTrue(lobby["napola"])
|
||||
self.assertFalse(lobby["asso_pigliatutto"])
|
||||
|
||||
|
||||
class SerializationTest(unittest.TestCase):
|
||||
@@ -283,6 +292,7 @@ class ResultTest(unittest.TestCase):
|
||||
self.assertEqual(4, engine.max_players)
|
||||
self.assertIn("target_score", engine.options_schema["properties"])
|
||||
self.assertIn("napola", engine.options_schema["properties"])
|
||||
self.assertIn("asso_pigliatutto", engine.options_schema["properties"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+47
-3
@@ -1,15 +1,19 @@
|
||||
#
|
||||
# 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
|
||||
# are NOT pinned here: they are installed from ./packages in the Dockerfile
|
||||
# and in development (pip install -e ./packages/tavolo-platform
|
||||
# -e ./packages/tavolo-scopone -e ./packages/tavolo-app). All of their
|
||||
# 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
|
||||
--extra-index-url https://pypi.org/simple
|
||||
@@ -39,6 +43,8 @@ cryptography==50.0.1
|
||||
# via pyjwt
|
||||
dictdiffer==0.10.0
|
||||
# via aerich
|
||||
googleapis-common-protos==1.75.4
|
||||
# via opentelemetry-exporter-otlp-proto-http
|
||||
granian==2.8.3
|
||||
# via
|
||||
# kaya-rsgi
|
||||
@@ -62,6 +68,7 @@ kaya-core==0.0.4
|
||||
# kaya-cors
|
||||
# kaya-oidc
|
||||
# kaya-openapi
|
||||
# kaya-otel
|
||||
# kaya-rsgi
|
||||
# kaya-session
|
||||
# tavolo-app (pyproject.toml)
|
||||
@@ -71,6 +78,8 @@ kaya-oidc==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-openapi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-otel==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-rsgi==0.0.4
|
||||
# via tavolo-app (pyproject.toml)
|
||||
kaya-session==0.0.4
|
||||
@@ -80,6 +89,35 @@ kaya-session==0.0.4
|
||||
# tavolo-app (pyproject.toml)
|
||||
kaya-session-redis==0.0.4
|
||||
# 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
|
||||
# via
|
||||
# kaya-core
|
||||
@@ -106,4 +144,10 @@ typing-extensions==4.16.0
|
||||
# via
|
||||
# anyio
|
||||
# kaya-core
|
||||
# opentelemetry-api
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
# pwo
|
||||
urllib3==2.8.0
|
||||
# via opentelemetry-exporter-http-transport
|
||||
|
||||
@@ -94,6 +94,77 @@ class GamesRouteTest(unittest.TestCase):
|
||||
)
|
||||
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 def test_join_errors(self) -> None:
|
||||
transport = ASGITransport(app=app)
|
||||
@@ -163,6 +234,9 @@ class GameTypesRouteTest(unittest.TestCase):
|
||||
self.assertEqual(4, results[0]["max_players"])
|
||||
self.assertIn("target_score", 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 def test_create_defaults_game_type(self) -> None:
|
||||
|
||||
@@ -70,6 +70,23 @@ pub async fn join_game(code: &str) -> Result<GameView, 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)]
|
||||
pub async fn game_state(game_id: &str) -> Result<GameView, String> {
|
||||
let resp = Request::get(&format!("/api/games/{game_id}"))
|
||||
|
||||
+261
-4
@@ -49,6 +49,9 @@ pub struct MoveView {
|
||||
pub captured: Vec<String>,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -117,6 +120,9 @@ pub struct GameView {
|
||||
/// Whether the napola rule is scored in this match.
|
||||
#[serde(default = "default_true")]
|
||||
pub napola: bool,
|
||||
/// Whether the asso pigliatutto rule is scored in this match.
|
||||
#[serde(default)]
|
||||
pub asso_pigliatutto: bool,
|
||||
pub phase: String,
|
||||
#[serde(default)]
|
||||
pub target_score: i32,
|
||||
@@ -331,6 +337,83 @@ pub struct GameTypesPage {
|
||||
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`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
@@ -344,14 +427,52 @@ pub enum OptionKind {
|
||||
pub struct OptionField {
|
||||
pub key: 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 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> {
|
||||
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> {
|
||||
schema.get(key).and_then(|v| {
|
||||
v.as_i64().or_else(|| {
|
||||
@@ -372,9 +493,12 @@ impl GameTypeInfo {
|
||||
properties
|
||||
.iter()
|
||||
.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"))
|
||||
.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 kind = match schema.get("type").and_then(|v| v.as_str()) {
|
||||
Some("integer") => OptionKind::Integer {
|
||||
@@ -397,7 +521,7 @@ impl GameTypeInfo {
|
||||
Some("string") => OptionKind::Text,
|
||||
_ => return None,
|
||||
};
|
||||
Some(OptionField { key: key.clone(), title, kind, default })
|
||||
Some(OptionField { key: key.clone(), title, label, kind, default })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -563,6 +687,7 @@ mod tests {
|
||||
"default": 11,
|
||||
},
|
||||
"napola": {"type": "boolean", "default": true},
|
||||
"asso_pigliatutto": {"type": "boolean", "default": false},
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -572,7 +697,7 @@ mod tests {
|
||||
#[test]
|
||||
fn option_fields_parse_scopone_schema() {
|
||||
let fields = scopone_game_type().option_fields();
|
||||
assert_eq!(2, fields.len());
|
||||
assert_eq!(3, fields.len());
|
||||
let by_key: std::collections::HashMap<_, _> =
|
||||
fields.into_iter().map(|f| (f.key.clone(), f)).collect();
|
||||
let target = &by_key["target_score"];
|
||||
@@ -583,6 +708,8 @@ mod tests {
|
||||
assert_eq!(json!(11), target.default);
|
||||
assert_eq!(OptionKind::Boolean, by_key["napola"].kind);
|
||||
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]
|
||||
@@ -614,13 +741,14 @@ mod tests {
|
||||
let opts = scopone_game_type().default_options();
|
||||
assert_eq!(Some(&json!(11)), opts.get("target_score"));
|
||||
assert_eq!(Some(&json!(true)), opts.get("napola"));
|
||||
assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_view_parses_new_lobby_payload() {
|
||||
// Exact shape of the platform's lobby payload: envelope fields,
|
||||
// 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!({
|
||||
"id": "11111111-2222-3333-4444-555555555555",
|
||||
"join_code": "ABC123",
|
||||
@@ -632,6 +760,7 @@ mod tests {
|
||||
"phase": "lobby",
|
||||
"target_score": 16,
|
||||
"napola": false,
|
||||
"asso_pigliatutto": true,
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("ABC123", g.join_code);
|
||||
@@ -639,6 +768,7 @@ mod tests {
|
||||
assert_eq!("lobby", g.phase);
|
||||
assert_eq!(16, g.target_score);
|
||||
assert!(!g.napola);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!("A", g.players[0].team);
|
||||
}
|
||||
|
||||
@@ -652,6 +782,7 @@ mod tests {
|
||||
"phase": "playing",
|
||||
"target_score": 11,
|
||||
"napola": true,
|
||||
"asso_pigliatutto": true,
|
||||
"hand_number": 1,
|
||||
"dealer": 0,
|
||||
"turn": 1,
|
||||
@@ -675,11 +806,137 @@ mod tests {
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!("playing", g.phase);
|
||||
assert!(g.asso_pigliatutto);
|
||||
assert_eq!(None, g.players[0].hand);
|
||||
assert_eq!(Some(vec!["01D".to_string()]), g.players[1].hand);
|
||||
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]
|
||||
fn game_type_without_schema_has_no_fields() {
|
||||
let g: GameTypeInfo = serde_json::from_value(json!({
|
||||
|
||||
@@ -63,6 +63,9 @@ fn move_banner(mv: MoveView) -> View {
|
||||
.map(|c| card_img(c.clone(), "move-card"))
|
||||
.collect::<Vec<_>>();
|
||||
let scopa = mv.scopa.then(|| view! { span(class="scopa-badge") { "Scopa!" } });
|
||||
let pigliatutto = mv
|
||||
.pigliatutto
|
||||
.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto!" } });
|
||||
view! {
|
||||
div(class="move-banner") {
|
||||
strong { (name) }
|
||||
@@ -70,6 +73,7 @@ fn move_banner(mv: MoveView) -> View {
|
||||
(played)
|
||||
(captured)
|
||||
(scopa)
|
||||
(pigliatutto)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,6 +569,9 @@ fn table_view(
|
||||
let banner = game.last_move.clone().map(move_banner);
|
||||
let hand_number = game.hand_number;
|
||||
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 my_captured = viewer.as_ref().map(|p| p.captured_count).unwrap_or(0);
|
||||
@@ -602,6 +609,7 @@ fn table_view(
|
||||
div(class="hud") {
|
||||
a(href="/") { "← Lobby" }
|
||||
span { "Hand " (hand_number) }
|
||||
(rule_badge)
|
||||
span(class="hud-scores") {
|
||||
"Team A " (scores.a) " — " (scores.b) " Team B (target "
|
||||
(target_score) ")"
|
||||
|
||||
+184
-7
@@ -3,7 +3,9 @@
|
||||
//! Match creation is game-generic: the form is rendered from the selected
|
||||
//! game's `options_schema` (see [`crate::model::GameTypeInfo`]), so new
|
||||
//! games get a working creation UI without frontend changes.
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use sycamore::prelude::*;
|
||||
@@ -12,7 +14,7 @@ use sycamore_router::navigate;
|
||||
use crate::api;
|
||||
use crate::components::card::card_back;
|
||||
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.
|
||||
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]
|
||||
pub fn LobbyPage() -> View {
|
||||
// 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 {
|
||||
match api::me().await {
|
||||
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! {
|
||||
div {
|
||||
(toast(error))
|
||||
@@ -427,13 +603,14 @@ pub fn LobbyPage() -> View {
|
||||
maxlength="6",
|
||||
bind:value=code,
|
||||
)
|
||||
button(
|
||||
class="btn btn-warning btn-lg",
|
||||
on:click=on_join,
|
||||
) {
|
||||
"Join"
|
||||
}
|
||||
(join_button)
|
||||
}
|
||||
(move || join_preview_panel(
|
||||
preview.get_clone(),
|
||||
game_types.get_clone(),
|
||||
checking_code.get(),
|
||||
preview_not_found.get(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,15 @@ body {
|
||||
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 ---------- */
|
||||
|
||||
.overlay {
|
||||
|
||||
Reference in New Issue
Block a user