Add briscola chiamata
Five-player briscola with a descending calling auction and a secret partner. Starting with the player after the dealer, each player bids a lower rank (A > 3 > K > H > J > 7 > 6 > 5 > 4 > 2) or passes; once the standing bid is the two the auction continues on points (62, 64, ...). The last player standing must bid, and the winner names any card of the won rank: its holder is the secret partner, or the caller plays alone when it is one of their own cards, and its suit becomes the trump. The calling side must reach 61 (or the raised target); a 60-60 deal with the plain target is a patta and is replayed. A match is a single deal, so the caller and partner (or the lone caller) against the other players map onto the platform's two-team result, Elo and match history. The rules live in tavolo.briscola.chiamata with the BriscolaChiamataEngine adapter registered alongside the other briscola variants; the partner's role and the side totals stay hidden until the called card is played. The web client gets a five-seat table, the auction panel with legal bids and point raises, the call picker, the reveal bands and a chiamata-aware final summary.
This commit is contained in:
@@ -12,8 +12,11 @@ import unittest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from tavolo.app import app, platform, tortoise_mixin
|
||||
from tavolo.briscola.chiamata import PHASE_AUCTION, PHASE_CALLING
|
||||
from tavolo.briscola.state import Card
|
||||
from tavolo.platform import GameSession, Seat
|
||||
from tavolo.platform.elo import INITIAL_RATING
|
||||
from tavolo.platform.errors import IllegalMove
|
||||
from tavolo.platform.models import Match, MatchPlayer, PlayerRating
|
||||
from tavolo.platform.stats import save_match_result
|
||||
from tavolo.scopone import engine as rules
|
||||
@@ -222,5 +225,98 @@ class BriscolaStatsTest(unittest.TestCase):
|
||||
self.assertEqual(2, len(results[0]["players"]))
|
||||
|
||||
|
||||
async def _finished_chiamata_session() -> GameSession:
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = GameSession(
|
||||
id="stats-chiamata-1",
|
||||
game_type=engine.id,
|
||||
join_code="BC0001",
|
||||
creator_sub="alice",
|
||||
players=[
|
||||
Seat(user_sub=name, display_name=name.capitalize())
|
||||
for name in ("alice", "bob", "carol", "dave", "erin")
|
||||
],
|
||||
)
|
||||
engine.create(session, {})
|
||||
for name in ("bob", "carol", "dave", "erin"):
|
||||
engine.join(session, name, name.capitalize())
|
||||
moves = 0
|
||||
while not engine.is_finished(session) and moves < 200000:
|
||||
state = session.state
|
||||
if state.phase == PHASE_AUCTION:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
try:
|
||||
engine.handle_action(session, player.sub, "pass", {})
|
||||
except IllegalMove:
|
||||
engine.handle_action(session, player.sub, "bid", {"rank": 2})
|
||||
elif state.phase == PHASE_CALLING:
|
||||
assert state.auction is not None and state.auction.current is not None
|
||||
caller = next(p for p in state.players if p.seat == state.caller)
|
||||
card = Card(rank=state.auction.current.rank, suit="D")
|
||||
engine.handle_action(session, caller.sub, "call", {"card": card.code})
|
||||
else:
|
||||
player = next(p for p in state.players if p.seat == state.turn)
|
||||
engine.handle_action(
|
||||
session, player.sub, "play", {"card": player.hand[0].code}
|
||||
)
|
||||
moves += 1
|
||||
assert engine.is_finished(session)
|
||||
return session
|
||||
|
||||
|
||||
class ChiamataStatsTest(unittest.TestCase):
|
||||
@async_test
|
||||
async def test_finished_chiamata_match_persisted_with_summary(self) -> None:
|
||||
await tortoise_mixin._bind()
|
||||
ctx = tortoise_mixin._ctx
|
||||
assert ctx is not None
|
||||
engine = platform.registry.require("briscola_chiamata")
|
||||
session = await _finished_chiamata_session()
|
||||
with ctx:
|
||||
await save_match_result(session, engine)
|
||||
self.assertEqual(1, await Match.all().count())
|
||||
self.assertEqual(5, await MatchPlayer.all().count())
|
||||
|
||||
match = await Match.all().first()
|
||||
assert match is not None
|
||||
self.assertEqual("briscola_chiamata", match.game_type)
|
||||
summary = match.result
|
||||
for key in (
|
||||
"team_a_score",
|
||||
"team_b_score",
|
||||
"winner_team",
|
||||
"declared_target",
|
||||
"called_card",
|
||||
"caller",
|
||||
"solo",
|
||||
"deals_played",
|
||||
"deal_scores",
|
||||
):
|
||||
self.assertIn(key, summary)
|
||||
self.assertIn(summary["winner_team"], ("Chiamante", "Difensori"))
|
||||
|
||||
players = await MatchPlayer.all()
|
||||
roles = {p.team for p in players}
|
||||
self.assertTrue(roles.issubset({"Chiamante", "Socio", "Difensori"}))
|
||||
# Roles match the sides: exactly one caller, the rest defenders
|
||||
# plus at most one partner.
|
||||
self.assertEqual(1, sum(1 for p in players if p.team == "Chiamante"))
|
||||
winner_team = summary["winner_team"]
|
||||
for row in players:
|
||||
on_caller_side = row.team in ("Chiamante", "Socio")
|
||||
self.assertEqual(row.won, on_caller_side == (winner_team == "Chiamante"))
|
||||
# Mean-team Elo: 2v3 or 1v4 moves every winner by +16.
|
||||
ratings = {r.user_sub: r.rating for r in await PlayerRating.all()}
|
||||
self.assertEqual(5, len(ratings))
|
||||
winners = {p.user_sub for p in players if p.won}
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING + 16}, {ratings[sub] for sub in winners}
|
||||
)
|
||||
self.assertEqual(
|
||||
{INITIAL_RATING - 16},
|
||||
{ratings[sub] for sub in ratings if sub not in winners},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user