diff --git a/server/README.md b/server/README.md index 26ae19d..9f60ce1 100644 --- a/server/README.md +++ b/server/README.md @@ -285,7 +285,7 @@ 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, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` or `{"game_type": "scopa_a_quattro"}` with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. 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": 21, "napola": true, "asso_pigliatutto": false}}`; scopa games use `{"game_type": "scopa"}` (target default 11) or `{"game_type": "scopa_a_quattro"}` (target default 21) with an optional `{"target_score": 11, "asso_pigliatutto": false}` (the asso pigliatutto rule makes it scopa d'assi); briscola games use `{"game_type": "briscola"}` or `{"game_type": "briscola_a_quattro"}` with an optional `{"target_score": 61}`, and briscola chiamata uses `{"game_type": "briscola_chiamata"}` with no options. 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}` | Game snapshot. Seated players get the personalized view (own hand plus the join code); anyone else gets the read-only spectator view: table, scores, captured counts and scope, no hands and no join code | @@ -420,9 +420,10 @@ rejected with an `illegal_move` error. 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. +- The match ends when a team reaches the target score (default 21, + the traditional partnership target, 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. ### Scopa @@ -447,9 +448,11 @@ rejected with an `illegal_move` error. 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 side 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. +- The match ends when a side reaches the target score (default 11 points + for two-player scopa and 21 for scopa a quattro, the traditional + partnership target; 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. - `scopa` is the classic two-player game; `scopa_a_quattro` seats four players in two fixed partnerships (seats 0/2 vs 1/3, partners opposite). diff --git a/server/packages/tavolo-scopa/README.md b/server/packages/tavolo-scopa/README.md index c01e93a..da0f990 100644 --- a/server/packages/tavolo-scopa/README.md +++ b/server/packages/tavolo-scopa/README.md @@ -23,9 +23,11 @@ The optional *asso pigliatutto* creation option turns either variant into (four players in fixed partnerships): the platform-facing adapters. They translate create/join/websocket actions/deadlines into rules-engine calls and back, validate the - `target_score`/`asso_pigliatutto` creation options, and extract the - `MatchResult` (teams, winner, per-player scores and the match summary - persisted as the match's JSON `result`). + `target_score`/`asso_pigliatutto` creation options (the match target + defaults to the traditional 11 points for two-player scopa and 21 for + scopa a quattro), and extract 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 diff --git a/server/packages/tavolo-scopa/src/tavolo/scopa/engine.py b/server/packages/tavolo-scopa/src/tavolo/scopa/engine.py index 2d54d85..a77bcc9 100644 --- a/server/packages/tavolo-scopa/src/tavolo/scopa/engine.py +++ b/server/packages/tavolo-scopa/src/tavolo/scopa/engine.py @@ -60,7 +60,7 @@ from .errors import CardNotInHand from .state import ( DEFAULT_PLAYER_COUNT, - DEFAULT_TARGET_SCORE, + DEFAULT_TARGET_SCORES, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, @@ -153,17 +153,23 @@ def create_game( creator_sub: str, creator_name: str, player_count: int = DEFAULT_PLAYER_COUNT, - target_score: int = DEFAULT_TARGET_SCORE, + target_score: Optional[int] = None, hand_ack_timeout: int = DEFAULT_HAND_ACK_TIMEOUT_SECONDS, turn_timeout: int = DEFAULT_TURN_TIMEOUT_SECONDS, asso_pigliatutto: bool = False, ) -> ScopaState: - """Create a lobby game with the creator seated first.""" + """Create a lobby game with the creator seated first. + + ``target_score`` defaults to the variant's traditional target: 11 + points for two-player scopa, 21 for the four-player partnership game. + """ if player_count not in PLAYER_COUNTS: raise IllegalMove( "player_count must be one of: " + ", ".join(str(count) for count in PLAYER_COUNTS) ) + if target_score is None: + target_score = DEFAULT_TARGET_SCORES[player_count] if target_score < 1 or target_score > 100: raise IllegalMove("target_score must be between 1 and 100") return ScopaState( diff --git a/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py b/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py index 943b203..edd94cc 100644 --- a/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py +++ b/server/packages/tavolo-scopa/src/tavolo/scopa/plugin.py @@ -27,6 +27,7 @@ from tavolo.platform.errors import GameError, IllegalMove from . import engine from .state import ( DEFAULT_TARGET_SCORE, + DEFAULT_TARGET_SCORE_QUATTRO, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, @@ -49,18 +50,16 @@ def _deadline_ms(iso: Optional[str]) -> Optional[int]: return None -class _ScopaBase(GameEngine): - """Shared platform adapter for the two-player and four-player games.""" - - player_count: int - options_schema = { +def _options_schema(default_target: int) -> Dict[str, Any]: + """The creation options schema with this variant's target default.""" + return { "type": "object", "properties": { "target_score": { "type": "integer", "minimum": 1, "maximum": 100, - "default": DEFAULT_TARGET_SCORE, + "default": default_target, "description": "Match points the winning side must reach.", }, "asso_pigliatutto": { @@ -72,6 +71,14 @@ class _ScopaBase(GameEngine): }, } + +class _ScopaBase(GameEngine): + """Shared platform adapter for the two-player and four-player games.""" + + player_count: int + #: Traditional match target of the variant; also the schema default. + default_target_score: int + def __init__( self, turn_timeout_seconds: int = engine.DEFAULT_TURN_TIMEOUT_SECONDS, @@ -83,7 +90,7 @@ class _ScopaBase(GameEngine): # -- lobby ------------------------------------------------------------ def create(self, session: GameSession, options: Mapping[str, Any]) -> None: - target_score = options.get("target_score", DEFAULT_TARGET_SCORE) + target_score = options.get("target_score", self.default_target_score) if isinstance(target_score, bool) or not isinstance(target_score, int): raise IllegalMove("target_score must be an integer") asso_pigliatutto = options.get("asso_pigliatutto", False) @@ -277,6 +284,8 @@ class ScopaEngine(_ScopaBase): min_players = 2 max_players = 2 player_count = 2 + default_target_score = DEFAULT_TARGET_SCORE + options_schema = _options_schema(DEFAULT_TARGET_SCORE) class ScopaQuattroEngine(_ScopaBase): @@ -292,3 +301,5 @@ class ScopaQuattroEngine(_ScopaBase): min_players = 4 max_players = 4 player_count = 4 + default_target_score = DEFAULT_TARGET_SCORE_QUATTRO + options_schema = _options_schema(DEFAULT_TARGET_SCORE_QUATTRO) diff --git a/server/packages/tavolo-scopa/src/tavolo/scopa/state.py b/server/packages/tavolo-scopa/src/tavolo/scopa/state.py index 70dbb95..6027c2a 100644 --- a/server/packages/tavolo-scopa/src/tavolo/scopa/state.py +++ b/server/packages/tavolo-scopa/src/tavolo/scopa/state.py @@ -45,7 +45,11 @@ PHASE_PLAYING = "playing" PHASE_HAND_END = "hand_end" PHASE_FINISHED = "finished" +# Traditional match targets: two-player scopa is played to 11 points, +# while the four-player partnership game (like scopone) goes to 21. DEFAULT_TARGET_SCORE = 11 +DEFAULT_TARGET_SCORE_QUATTRO = 21 +DEFAULT_TARGET_SCORES = {2: DEFAULT_TARGET_SCORE, 4: DEFAULT_TARGET_SCORE_QUATTRO} DEFAULT_PLAYER_COUNT = 2 PLAYER_COUNTS = (2, 4) diff --git a/server/packages/tavolo-scopa/tests/test_engine.py b/server/packages/tavolo-scopa/tests/test_engine.py index 1361cc2..06613d7 100644 --- a/server/packages/tavolo-scopa/tests/test_engine.py +++ b/server/packages/tavolo-scopa/tests/test_engine.py @@ -172,11 +172,20 @@ class DealTest(unittest.TestCase): def test_create_game_defaults(self) -> None: state = engine.create_game("p0", "p0") self.assertEqual(2, state.player_count) + # Two-player scopa is traditionally played to 11 points. self.assertEqual(11, state.target_score) self.assertFalse(state.asso_pigliatutto) self.assertEqual("p0", state.players[0].sub) self.assertEqual("lobby", state.phase) + def test_create_game_four_player_default_target(self) -> None: + # The four-player partnership game (like scopone) goes to 21. + state = engine.create_game("p0", "p0", player_count=4) + self.assertEqual(21, state.target_score) + # An explicit target still wins over the default. + explicit = engine.create_game("p0", "p0", player_count=4, target_score=16) + self.assertEqual(16, explicit.target_score) + def test_create_game_player_count_validation(self) -> None: for bad in (1, 3, 5, 0): with self.assertRaises(IllegalMove): diff --git a/server/packages/tavolo-scopa/tests/test_plugin.py b/server/packages/tavolo-scopa/tests/test_plugin.py index 856872a..951fe02 100644 --- a/server/packages/tavolo-scopa/tests/test_plugin.py +++ b/server/packages/tavolo-scopa/tests/test_plugin.py @@ -21,8 +21,6 @@ from tavolo.scopa.state import ( ScopaState, ) -ENGINES = (ScopaEngine, ScopaQuattroEngine) - def _session(engine, **options) -> GameSession: session = GameSession( @@ -66,14 +64,26 @@ def _drive_to_end(engine, session: GameSession) -> None: class CreateTest(unittest.TestCase): def test_create_seats_creator_on_side_a(self) -> None: - for engine_type in ENGINES: + for engine_type, target in ((ScopaEngine, 11), (ScopaQuattroEngine, 21)): engine = engine_type() session = _session(engine) self.assertEqual("A", session.players[0].team) self.assertIsInstance(session.state, ScopaState) - self.assertEqual(11, session.state.target_score) + # Two-player scopa is played to 11, the partnership game to 21. + self.assertEqual(target, session.state.target_score) self.assertFalse(session.state.asso_pigliatutto) + def test_schema_defaults_follow_the_variant(self) -> None: + self.assertEqual( + 11, ScopaEngine().options_schema["properties"]["target_score"]["default"] + ) + self.assertEqual( + 21, + ScopaQuattroEngine().options_schema["properties"]["target_score"][ + "default" + ], + ) + def test_create_options(self) -> None: engine = ScopaEngine() session = _session(engine, target_score=21, asso_pigliatutto=True) diff --git a/server/packages/tavolo-scopone/README.md b/server/packages/tavolo-scopone/README.md index c491067..383c533 100644 --- a/server/packages/tavolo-scopone/README.md +++ b/server/packages/tavolo-scopone/README.md @@ -19,7 +19,8 @@ implementation. - `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`/`asso_pigliatutto` - creation options, and extracts the `MatchResult` (teams, winner, + creation options (the match target defaults to the traditional 21 + points), and extracts the `MatchResult` (teams, winner, per-player scores and the match summary persisted as the match's JSON `result`). diff --git a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py index 2971fbb..f13e0da 100644 --- a/server/packages/tavolo-scopone/src/tavolo/scopone/state.py +++ b/server/packages/tavolo-scopone/src/tavolo/scopone/state.py @@ -37,7 +37,9 @@ PHASE_PLAYING = "playing" PHASE_HAND_END = "hand_end" PHASE_FINISHED = "finished" -DEFAULT_TARGET_SCORE = 11 +# The traditional match target for scopone scientifico: the partnership +# game is played to 21 points. +DEFAULT_TARGET_SCORE = 21 def team_of(seat: int) -> int: diff --git a/server/packages/tavolo-scopone/tests/test_plugin.py b/server/packages/tavolo-scopone/tests/test_plugin.py index 9307802..2d4e304 100644 --- a/server/packages/tavolo-scopone/tests/test_plugin.py +++ b/server/packages/tavolo-scopone/tests/test_plugin.py @@ -35,7 +35,8 @@ class CreateTest(unittest.TestCase): session = _session(engine) self.assertEqual("A", session.players[0].team) self.assertIsInstance(session.state, ScoponeState) - self.assertEqual(11, session.state.target_score) + # The traditional partnership target. + self.assertEqual(21, session.state.target_score) self.assertTrue(session.state.napola) def test_create_options(self) -> None: diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index c88b31d..c401a9a 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -316,6 +316,10 @@ class GameTypesRouteTest(unittest.TestCase): self.assertEqual(4, scopone["min_players"]) self.assertEqual(4, scopone["max_players"]) self.assertIn("target_score", scopone["options_schema"]["properties"]) + self.assertEqual( + 21, + scopone["options_schema"]["properties"]["target_score"]["default"], + ) self.assertIn("napola", scopone["options_schema"]["properties"]) self.assertIn( "asso_pigliatutto", scopone["options_schema"]["properties"] @@ -326,6 +330,9 @@ class GameTypesRouteTest(unittest.TestCase): self.assertEqual(2, scopa["min_players"]) self.assertEqual(2, scopa["max_players"]) self.assertIn("target_score", scopa["options_schema"]["properties"]) + self.assertEqual( + 11, scopa["options_schema"]["properties"]["target_score"]["default"] + ) self.assertIn( "asso_pigliatutto", scopa["options_schema"]["properties"] ) @@ -337,6 +344,12 @@ class GameTypesRouteTest(unittest.TestCase): self.assertIn( "target_score", scopa_quattro["options_schema"]["properties"] ) + self.assertEqual( + 21, + scopa_quattro["options_schema"]["properties"]["target_score"][ + "default" + ], + ) self.assertIn( "asso_pigliatutto", scopa_quattro["options_schema"]["properties"] ) diff --git a/server/tests/test_scopa.py b/server/tests/test_scopa.py index 9ea7cc9..96a5fa7 100644 --- a/server/tests/test_scopa.py +++ b/server/tests/test_scopa.py @@ -99,6 +99,8 @@ class ScopaRoutesTest(unittest.TestCase): state = started.json() self.assertEqual("playing", state["phase"]) self.assertEqual(["A", "B", "A", "B"], [p["team"] for p in state["players"]]) + # Partnership scopa defaults to the traditional 21 points. + self.assertEqual(21, state["target_score"]) for participant in state["players"]: self.assertEqual(3, participant["cards_left"]) self.assertEqual(4, len(state["table"])) diff --git a/web/src/model.rs b/web/src/model.rs index deec076..4d2629b 100644 --- a/web/src/model.rs +++ b/web/src/model.rs @@ -1125,7 +1125,7 @@ mod tests { "properties": { "target_score": { "type": "integer", "minimum": 1, "maximum": 100, - "default": 11, + "default": 21, }, "napola": {"type": "boolean", "default": true}, "asso_pigliatutto": {"type": "boolean", "default": false}, @@ -1146,7 +1146,7 @@ mod tests { target.kind, OptionKind::Integer { min: Some(1), max: Some(100) } )); - assert_eq!(json!(11), target.default); + assert_eq!(json!(21), 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); @@ -1180,7 +1180,7 @@ mod tests { #[test] fn default_options_collect_schema_defaults() { let opts = scopone_game_type().default_options(); - assert_eq!(Some(&json!(11)), opts.get("target_score")); + assert_eq!(Some(&json!(21)), opts.get("target_score")); assert_eq!(Some(&json!(true)), opts.get("napola")); assert_eq!(Some(&json!(false)), opts.get("asso_pigliatutto")); }