Set scopa targets to their traditional defaults
CI / Build and push docker image (push) Successful in 2m6s

Partnership games go to 21: scopone scientifico (the tournament target)
and scopa a quattro, while two-player scopa keeps its usual 11. The scopa
package resolves the default from the player count and exposes a per
variant options_schema default, so the lobby creation form shows the
right value without frontend changes.
This commit is contained in:
2026-10-09 14:45:30 +08:00
parent 68200bbe62
commit ae7ab5ce60
13 changed files with 94 additions and 30 deletions
+5 -3
View File
@@ -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
@@ -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(
@@ -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)
@@ -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)
@@ -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):
@@ -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)
+2 -1
View File
@@ -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`).
@@ -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:
@@ -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: