From db7ba30d13d667f3cabc8d27639ac27f46aac1a0 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 18 Sep 2026 09:18:17 +0800 Subject: [PATCH] Add configurable napola rule with instant win on a full denari sweep --- README.md | 3 +- server/README.md | 7 ++- server/src/tavolo/game/engine.py | 44 +++++++++++++++ server/src/tavolo/game/state.py | 6 +++ server/src/tavolo/routes/games.py | 13 +++++ server/tests/test_engine.py | 89 +++++++++++++++++++++++++++++++ server/tests/test_routes_games.py | 18 +++++++ web/src/api.rs | 4 +- web/src/components/summary.rs | 39 ++++++++++++++ web/src/model.rs | 13 +++++ web/src/pages/lobby.rs | 13 ++++- web/style.css | 6 +++ 12 files changed, 250 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e6d6ca0..d413448 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,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) with the running totals and +(carte, denara, settebello, primiera, scope — plus napola 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` (default 30s). The match-ending hand is explained on the final screen. diff --git a/server/README.md b/server/README.md index 3664ead..434922f 100644 --- a/server/README.md +++ b/server/README.md @@ -133,7 +133,7 @@ All endpoints except `/api/health`, `/api/docs`, `/api/openapi.json`, | Method | Path | Description | |---|---|---| | `GET` | `/api/game-types` | The card games the platform can host (for the creation dropdown) | -| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11}`. Returns `{id, join_code}` | +| `POST` | `/api/games` | Create a lobby game. Optional body `{"game_type": "scopone_scientifico", "target_score": 11, "napola": true}`. Returns `{id, join_code}` | | `POST` | `/api/games/join` | Join with `{"code": "ABC123"}`. The fourth player triggers the deal | | `GET` | `/api/games/{id}` | Personalized snapshot (only your own hand is visible) | | `GET` | `/api/me/matches` | Cursor-paginated match history with final scores (`?limit=&cursor=&game_type=`) | @@ -203,6 +203,11 @@ must dismiss. A play attempted in this phase is rejected with an - Hand points: `carte` (most captured cards), `denara` (most diamonds), `settebello` (7♦), `primiera` (best 7/6/5/4 per suit, all four suits required), plus one point per scopa. Ties award nothing. +- Optional *napola* rule (per-game `napola` flag on `POST /api/games`, + default on): the longest run of consecutive denari starting from the + ace scores one 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. - The match ends when a team reaches the target score (default 11, configurable per game) with a clear lead; a tie at or above the target is broken by another hand. diff --git a/server/src/tavolo/game/engine.py b/server/src/tavolo/game/engine.py index 16de8a6..a47c6c1 100644 --- a/server/src/tavolo/game/engine.py +++ b/server/src/tavolo/game/engine.py @@ -22,6 +22,10 @@ Rules implemented cards), ``settebello`` (the 7 of diamonds), ``primiera`` (best seven/five/four/three card of each suit, all four suits required), plus one point per ``scopa``. Ties on carte/denara/primiera award nothing. +* Optional ``napola`` rule (enabled by default): the longest run of + 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. * 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. """ @@ -135,6 +139,7 @@ def create_game( hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, game_type: str = "scopone_scientifico", + napola: bool = True, ) -> GameState: """Create a lobby game with the creator seated first.""" if target_score < 1 or target_score > 100: @@ -145,6 +150,7 @@ def create_game( creator_sub=creator_sub, game_type=game_type, target_score=target_score, + napola=napola, phase=PHASE_LOBBY, players=[PlayerState(sub=creator_sub, name=creator_name, seat=0)], hand_ack_timeout=hand_ack_timeout, @@ -350,6 +356,17 @@ def _end_hand(state: GameState) -> None: a, b, ) + # A full napola (the whole denari suit) wins the match outright, + # regardless of the score. + napola = details.get("napola") + if isinstance(napola, dict): + for team, name in enumerate(TEAM_NAMES): + if napola.get(name) == 10: + state.phase = PHASE_FINISHED + state.winner = team + state.finished_at = datetime.now(timezone.utc).isoformat() + log.info("game %s: team %s swept the denari (napola) and wins", state.id, name) + return reached = max(a, b) >= state.target_score if reached and a != b: state.phase = PHASE_FINISHED @@ -404,6 +421,22 @@ def primiera_score(captured: Sequence[Card]) -> int: return sum(best.values()) +def napola_score(captured: Sequence[Card]) -> int: + """Return the napola value of a capture pile. + + The longest run of consecutive denari starting from the ace scores one + point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, + ...), so the whole suit (ace to king) is worth 10. Shorter runs score + nothing. Only one team can score a napola: the ace of denari belongs + to exactly one capture pile. + """ + ranks = {card.rank for card in captured if card.suit == "D"} + run = 0 + while run + 1 in ranks: + run += 1 + return run if run >= 3 else 0 + + def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: """Compute the hand points for both teams (index 0 = team A).""" piles: List[List[Card]] = [[], []] @@ -460,6 +493,16 @@ def hand_points(state: GameState) -> Tuple[List[int], Dict[str, object]]: "scope": {"A": scope[0], "B": scope[1]}, "award": award, } + # Napola (optional rule): consecutive denari from the ace. A run of 10 + # means the team swept the whole suit and wins the match instantly. + if state.napola: + napola = [napola_score(piles[t]) for t in (0, 1)] + for team in (0, 1): + points[team] += napola[team] + award["napola"] = next( + (TEAM_NAMES[t] for t in (0, 1) if napola[t] > 0), None + ) + details["napola"] = {"A": napola[0], "B": napola[1]} return points, details @@ -493,6 +536,7 @@ def state_for_player(state: GameState, sub: str) -> Dict[str, object]: "game_type": state.game_type, "phase": state.phase, "target_score": state.target_score, + "napola": state.napola, "hand_number": state.hand_number, "dealer": state.dealer, "turn": state.turn, diff --git a/server/src/tavolo/game/state.py b/server/src/tavolo/game/state.py index cd38fc9..7656550 100644 --- a/server/src/tavolo/game/state.py +++ b/server/src/tavolo/game/state.py @@ -152,6 +152,10 @@ class GameState: # Defaults so states serialized before game types existed still load. game_type: str = "scopone_scientifico" target_score: int = DEFAULT_TARGET_SCORE + # Whether the napola rule is scored (denari run from the ace; a full + # suit wins the match instantly). Default on, also for states + # serialized before the option existed. + napola: bool = True phase: str = PHASE_LOBBY players: List[PlayerState] = field(default_factory=list) table: List[Card] = field(default_factory=list) @@ -189,6 +193,7 @@ class GameState: "creator_sub": self.creator_sub, "game_type": self.game_type, "target_score": self.target_score, + "napola": self.napola, "phase": self.phase, "players": [p.to_json() for p in self.players], "table": [c.to_json() for c in self.table], @@ -218,6 +223,7 @@ class GameState: creator_sub=str(data.get("creator_sub", "")), game_type=str(data.get("game_type", "scopone_scientifico")), target_score=int(data.get("target_score", DEFAULT_TARGET_SCORE)), + napola=bool(data.get("napola", True)), 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", [])], diff --git a/server/src/tavolo/routes/games.py b/server/src/tavolo/routes/games.py index a9a41fa..b67f05e 100644 --- a/server/src/tavolo/routes/games.py +++ b/server/src/tavolo/routes/games.py @@ -52,6 +52,7 @@ def _lobby_payload(state: GameState) -> Dict[str, Any]: "join_code": state.join_code, "game_type": state.game_type, "target_score": state.target_score, + "napola": state.napola, "phase": state.phase, "players": [ {"sub": p.sub, "name": p.name, "seat": p.seat, "team": "A" if p.seat % 2 == 0 else "B"} @@ -94,6 +95,12 @@ async def list_game_types(ctx: HttpContext) -> None: "description": "One of the ids from GET /api/game-types", }, "target_score": {"type": "integer", "minimum": 1, "maximum": 100}, + "napola": { + "type": "boolean", + "default": True, + "description": "Score the napola rule; a full " + "denari sweep wins the match", + }, }, } } @@ -123,6 +130,11 @@ async def create_game(ctx: HttpContext) -> None: await send_error(ctx, 400, f"unknown game_type: {game_type!r}") return + napola: Any = body.get("napola", True) + if not isinstance(napola, bool): + await send_error(ctx, 400, "napola must be a boolean") + return + user = oidc_mixin.get_user(ctx) assert user is not None # enforced by @require_auth game_id = str(uuid.uuid4()) @@ -137,6 +149,7 @@ async def create_game(ctx: HttpContext) -> None: hand_ack_timeout=settings.hand_ack_timeout_seconds, turn_timeout=settings.turn_timeout_seconds, game_type=game_type, + napola=napola, ) except GameError as exc: await send_error(ctx, 400, str(exc)) diff --git a/server/tests/test_engine.py b/server/tests/test_engine.py index 85c5c27..b7be55a 100644 --- a/server/tests/test_engine.py +++ b/server/tests/test_engine.py @@ -239,6 +239,95 @@ class ScoringTest(unittest.TestCase): self.assertEqual([0, 0], points) +class NapolaTest(unittest.TestCase): + def test_napola_score_runs(self) -> None: + self.assertEqual(0, engine.napola_score( + [card(c) for c in ["02D", "03D", "04D"]])) # no ace + self.assertEqual(0, engine.napola_score( + [card(c) for c in ["01D", "02D"]])) # too short + self.assertEqual(3, engine.napola_score( + [card(c) for c in ["03D", "01D", "02D"]])) # order-independent + self.assertEqual(4, engine.napola_score( + [card(c) for c in ["01D", "02D", "03D", "04D", "07C"]])) + self.assertEqual(3, engine.napola_score( + [card(c) for c in ["01D", "02D", "03D", "05D"]])) # broken run + self.assertEqual(10, engine.napola_score( + [card(f"{rank:02d}D") for rank in range(1, 11)])) + + def test_hand_points_napola(self) -> None: + state = make_state( + [[], [], [], []], + table=[], + captured=[ + ["01D", "02D", "03D", "04C"], # seat 0, team A + ["05D", "06D", "07D", "08D"], # seat 1, team B + ["09D", "10D", "01C", "02C"], # seat 2, team A + ["03C", "05C", "06C", "07C"], # seat 3, team B + ], + ) + points, details = engine.hand_points(state) + # Team A has the ace-led run 01D-03D (3 points); team B's denari + # start at the 5, so no napola. Carte tie (8 each), denara to A + # (5 vs 4), settebello to B, primiere tied at 0 (missing suits). + self.assertEqual({"A": 3, "B": 0}, details["napola"]) + self.assertEqual("A", details["award"]["napola"]) + self.assertEqual([4, 1], points) + + def test_napola_disabled(self) -> None: + state = make_state( + [[], [], [], []], + table=[], + captured=[ + ["01D", "02D", "03D", "04C"], + ["05D", "06D", "07D", "08D"], + ["09D", "10D", "01C", "02C"], + ["03C", "05C", "06C", "07C"], + ], + ) + state.napola = False + points, details = engine.hand_points(state) + self.assertNotIn("napola", details) + self.assertEqual([1, 1], points) + + def test_full_denari_sweep_wins_match_instantly(self) -> None: + # Team A already captured the whole denari suit; the last play of + # the hand cannot capture. Team B leads 50-0, yet the napola ends + # the match in team A's favour, well below the target of 100. + state = make_state( + [["02C"], [], [], []], + table=[], + target=100, + captured=[ + [f"{rank:02d}D" for rank in range(1, 11)], + [], + [], + [], + ], + ) + state.scores = [0, 50] + engine.play(state, "p0", "02C") + self.assertEqual(PHASE_FINISHED, state.phase) + self.assertEqual(0, state.winner) + self.assertLess(state.scores[0], 100) + self.assertEqual(10, state.hand_scores[-1]["napola"]["A"]) + + def test_napola_serialization_roundtrip(self) -> None: + state = make_state([["02D"], [], [], []], table=[]) + self.assertTrue(state.napola) + state.napola = False + self.assertFalse(GameState.from_json(state.to_json()).napola) + # States serialized before the option existed default to enabled. + data = state.to_json() + del data["napola"] + self.assertTrue(GameState.from_json(data).napola) + + def test_create_game_napola_default_and_override(self) -> None: + self.assertTrue(engine.create_game("g", "CODE42", "p0", "p0").napola) + self.assertFalse( + engine.create_game("g", "CODE42", "p0", "p0", napola=False).napola + ) + + class MatchFlowTest(unittest.TestCase): def test_join_starts_when_full(self) -> None: state = engine.create_game("g", "CODE42", "p0", "p0", target_score=11) diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index c0d91d3..4e33724 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -71,6 +71,24 @@ class GamesRouteTest(unittest.TestCase): self.assertNotIn("hand", state["players"][0]) self.assertEqual(1, state["turn"]) + @async_test + async def test_create_napola_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.assertTrue(default.json()["napola"]) + + with oidc_user("alice"): + disabled = await client.post("/api/games", json={"napola": False}) + self.assertEqual(201, disabled.status_code) + self.assertFalse(disabled.json()["napola"]) + + with oidc_user("alice"): + invalid = await client.post("/api/games", json={"napola": "yes"}) + self.assertEqual(400, invalid.status_code) + @async_test async def test_join_errors(self) -> None: transport = ASGITransport(app=app) diff --git a/web/src/api.rs b/web/src/api.rs index b675243..93c1f54 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -35,9 +35,9 @@ pub async fn game_types() -> Result, String> { Ok(page.results) } -pub async fn create_game(game_type: &str, target_score: i32) -> Result { +pub async fn create_game(game_type: &str, target_score: i32, napola: bool) -> Result { let resp = Request::post("/api/games") - .json(&serde_json::json!({ "game_type": game_type, "target_score": target_score })) + .json(&serde_json::json!({ "game_type": game_type, "target_score": target_score, "napola": napola })) .map_err(|e| e.to_string())? .send() .await diff --git a/web/src/components/summary.rs b/web/src/components/summary.rs index cc7c6c8..0a65182 100644 --- a/web/src/components/summary.rs +++ b/web/src/components/summary.rs @@ -113,6 +113,45 @@ pub fn summary_rows(summary: HandSummary) -> View { summary.award.primiera.clone(), "+1", ), + // Napola (only when the rule is enabled for this match) + match summary.napola { + None => view! {}, + Some(napola) => { + let n = match summary.award.napola.as_deref() { + Some("A") => napola.a, + Some("B") => napola.b, + _ => 0, + }; + let text = match (&summary.award.napola, n) { + (Some(t), 10) => format!( + "Team {t} swept the whole denari suit — napola! Instant match win" + ), + (Some(t), n) => format!( + "Team {t} captured {n} consecutive denari from the ace" + ), + (None, _) => "No napola this hand".to_string(), + }; + let chip = match &summary.award.napola { + Some(t) => format!("Team {t} +{n}"), + None => "tie".to_string(), + }; + let cls = match summary.award.napola.as_deref() { + Some("A") => "score-row team-a", + Some("B") => "score-row team-b", + _ => "score-row tie", + }; + view! { + div(class=cls) { + div(class="score-icon") { (card_img("01D".to_string(), "score-mini")) } + div(class="score-body") { + div(class="score-title") { "Napola" } + div(class="score-text") { (text) } + } + div(class="score-points") { (chip) } + } + } + } + }, // Scope { let a = summary.scope.a; diff --git a/web/src/model.rs b/web/src/model.rs index 105d9af..949004e 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -2,6 +2,10 @@ use serde::Deserialize; use std::collections::HashMap; +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] pub struct User { @@ -75,6 +79,8 @@ pub struct Award { pub settebello: Option, #[serde(default)] pub primiera: Option, + #[serde(default)] + pub napola: Option, } /// The scoring breakdown of one completed hand. @@ -86,6 +92,10 @@ pub struct HandSummary { pub settebello: TeamBools, pub primiera: TeamCounts, pub scope: TeamCounts, + /// Napola run lengths per team; absent when the rule is disabled (or + /// the summary predates the option). + #[serde(default)] + pub napola: Option, pub award: Award, #[serde(default)] pub hand: i32, @@ -104,6 +114,9 @@ pub struct GameView { /// Which card game this match is (id from /api/game-types). #[serde(default)] pub game_type: String, + /// Whether the napola rule is scored in this match. + #[serde(default = "default_true")] + pub napola: bool, pub phase: String, #[serde(default)] pub target_score: i32, diff --git a/web/src/pages/lobby.rs b/web/src/pages/lobby.rs index 2e224b2..4849704 100644 --- a/web/src/pages/lobby.rs +++ b/web/src/pages/lobby.rs @@ -24,6 +24,7 @@ pub fn LobbyPage() -> View { let code = create_signal(String::new()); let game_types = create_signal(fallback_game_types()); let selected_game = create_signal("scopone_scientifico".to_string()); + let napola = create_signal(true); spawn_local(async move { match api::me().await { @@ -47,8 +48,9 @@ pub fn LobbyPage() -> View { let on_create = move |target: i32| { let game_type = selected_game.get_clone(); + let napola = napola.get(); spawn_local(async move { - match api::create_game(&game_type, target).await { + match api::create_game(&game_type, target, napola).await { Ok(game) => navigate(&format!("/game/{}", game.id)), Err(e) => error.set(Some(e)), } @@ -101,6 +103,15 @@ pub fn LobbyPage() -> View { key=|g| g.id.clone(), ) } + label(class="check", r#for="napola") { + input(id="napola", r#type="checkbox", bind:checked=napola) + " Napola" + } + p(class="hint") { + "A-2-3 of denari scores 3 points, plus 1 per extra " + "consecutive denari card; sweeping the whole suit " + "wins the match instantly." + } p { "First team to reach the target score wins." } div(class="target-buttons") { button(class="button", on:click=move |_| on_create(11)) { "Target 11" } diff --git a/web/style.css b/web/style.css index a71b72f..b81de66 100644 --- a/web/style.css +++ b/web/style.css @@ -123,6 +123,12 @@ body { gap: 0.5rem; } +.check { + display: flex; + align-items: center; + gap: 0.4rem; +} + .join-form { display: flex; gap: 0.5rem;