Rewrite the backend in Rust with Axum
CI / Build and push docker image (push) Successful in 4m20s

Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
This commit is contained in:
woggioni committed 2026-10-02 13:54:55 +00:00
1 parent adc3d340b1
commit 7b3b38ccbc
136 files changed
+16821 -11777

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
[package]
name = "tavolo-scopone"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Scopone scientifico rules engine and platform adapter"
[dependencies]
chrono.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
tavolo-platform = { path = "../tavolo-platform" }
thiserror.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true }
+791
View File
@@ -0,0 +1,791 @@
//! Pure rules engine for scopone scientifico.
//!
//! Every function here is deterministic and I/O-free (the only side effect is
//! debug logging): it mutates (or reads) [`ScoponeState`] and returns a
//! [`GameError`] on rule violations. This makes the whole rule set
//! unit-testable without a store or transport. The platform-facing adapter is
//! [`crate::plugin::ScoponeEngine`].
//!
//! Rules implemented
//! -----------------
//! * 40-card Italian deck (4 suits x ranks 1-10), ten cards per player, empty
//! table at the start of every hand.
//! * A card captures either a **single card of equal rank** or a
//! **combination of cards whose ranks sum to its own**. When an equal-ranked
//! card is on the table that capture is mandatory; the player may not take
//! an alternative combination instead.
//! * Emptying the table with a capture is a **scopa** (+1), except on the very
//! last play of a hand.
//! * At the end of a hand the remaining table cards go to the player who made
//! the last capture.
//! * Hand points: `carte` (most captured cards), `denara` (most diamond
//! 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.
//! * 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.
use std::collections::HashSet;
use rand::seq::SliceRandom;
use rand::Rng;
use serde_json::{json, Map, Value};
use tavolo_platform::datetime::{now, to_iso};
use tavolo_platform::errors::{GameError, Result};
use crate::errors::card_not_in_hand;
use crate::state::{
parse_card, Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY,
PHASE_PLAYING, SUITS, TEAM_NAMES,
};
/// Number of cards dealt to each player at the start of a hand.
pub const HAND_SIZE: usize = 10;
pub const PLAYERS: usize = 4;
/// Default seconds the hand-end summary waits before dealing anyway. Games
/// carry their own copy in [`ScoponeState::hand_ack_timeout`].
pub const DEFAULT_HAND_ACK_TIMEOUT_SECONDS: i64 = 30;
/// Default seconds a player has to play before the server plays a random
/// legal card for them. Games carry their own copy in
/// [`ScoponeState::turn_timeout`].
pub const DEFAULT_TURN_TIMEOUT_SECONDS: i64 = 30;
/// Primiera card values: sevens are best, then sixes, then aces, then the
/// remaining ranks in descending order. All of 8/9/10 are worth 10.
fn primiera_value(rank: u32) -> i64 {
match rank {
7 => 21,
6 => 18,
1 => 16,
5 => 15,
4 => 14,
3 => 13,
2 => 12,
8..=10 => 10,
_ => 0,
}
}
/// Return the 40 cards of the Italian deck in canonical order.
pub fn full_deck() -> Vec<Card> {
let mut deck = Vec::with_capacity(40);
for &suit in SUITS.iter() {
for rank in 1..=10u32 {
deck.push(Card { rank, suit });
}
}
deck
}
/// Return a shuffled deck. Pass `rng` for deterministic tests.
pub fn shuffled_deck<R: Rng + ?Sized>(rng: &mut R) -> Vec<Card> {
let mut deck = full_deck();
deck.shuffle(rng);
deck
}
/// Every `size`-element subset of `items`, in the same lexicographic order
/// `itertools.combinations` produces.
fn combinations(items: &[Card], size: usize) -> Vec<Vec<Card>> {
let mut result = Vec::new();
if size == 0 {
return vec![Vec::new()];
}
if size > items.len() {
return result;
}
let mut indices: Vec<usize> = (0..size).collect();
loop {
result.push(indices.iter().map(|&i| items[i].clone()).collect());
// Advance to the next combination; stop once the last one is reached.
let mut i = size;
loop {
if i == 0 {
return result;
}
i -= 1;
if indices[i] != i + items.len() - size {
break;
}
}
indices[i] += 1;
for j in (i + 1)..size {
indices[j] = indices[j - 1] + 1;
}
}
}
/// 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,
/// 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.
pub fn legal_captures(table: &[Card], card: Card, asso_pigliatutto: bool) -> Vec<Vec<Card>> {
let equal: Vec<Card> = table
.iter()
.filter(|c| c.rank == card.rank)
.cloned()
.collect();
if !equal.is_empty() {
return equal.into_iter().map(|c| vec![c]).collect();
}
if asso_pigliatutto && card.rank == 1 {
return vec![table.to_vec()];
}
let candidates: Vec<Card> = table
.iter()
.filter(|c| c.rank <= card.rank)
.cloned()
.collect();
let mut captures: Vec<Vec<Card>> = Vec::new();
// A sum-equal capture needs at least two cards (single non-equal cards
// cannot sum to the played card).
for size in 2..=candidates.len() {
for combo in combinations(&candidates, size) {
if combo.iter().map(|c| c.rank).sum::<u32>() == card.rank {
captures.push(combo);
}
}
}
captures
}
/// Create a lobby game with the creator seated first.
pub fn create_game(
creator_sub: &str,
creator_name: &str,
target_score: i64,
hand_ack_timeout: i64,
turn_timeout: i64,
napola: bool,
asso_pigliatutto: bool,
) -> Result<ScoponeState> {
if !(1..=100).contains(&target_score) {
return Err(GameError::illegal_move("target_score must be between 1 and 100").into());
}
Ok(ScoponeState {
target_score,
napola,
asso_pigliatutto,
phase: PHASE_LOBBY.to_string(),
players: vec![PlayerState {
sub: creator_sub.to_string(),
name: creator_name.to_string(),
seat: 0,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
}],
hand_ack_timeout,
turn_timeout,
..Default::default()
})
}
/// Seat `sub` in the next free chair, starting the match when full.
pub fn join_game<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
name: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_LOBBY {
return Err(GameError::game_not_started("game has already started").into());
}
if state.seated(sub) {
return Err(GameError::already_joined("already joined this game").into());
}
if state.players.len() >= PLAYERS {
return Err(GameError::lobby_full("game is full").into());
}
let seat = state.players.len() as i64;
state.players.push(PlayerState {
sub: sub.to_string(),
name: name.to_string(),
seat,
hand: Vec::new(),
captured: Vec::new(),
scope: 0,
});
if state.players.len() == PLAYERS {
start_game(state, rng)?;
}
Ok(())
}
/// Deal the first hand and switch the game to playing.
pub fn start_game<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.players.len() != PLAYERS {
return Err(GameError::game_not_started("need exactly four players to start").into());
}
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Arm the auto-play deadline for whoever is on turn.
fn set_turn_deadline(state: &mut ScoponeState) {
let deadline = now() + chrono::Duration::seconds(state.turn_timeout);
state.turn_deadline = Some(to_iso(deadline));
}
fn deal_hand<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) {
let mut deck = shuffled_deck(rng);
for player in state.players.iter_mut() {
player.hand = Vec::new();
player.captured = Vec::new();
player.scope = 0;
}
state.table = Vec::new();
state.last_taker = None;
// Dealer rotates each hand; the first card is played by the player to the
// dealer's left.
state.turn = (state.dealer + 1) % PLAYERS as i64;
set_turn_deadline(state);
for _offset in 0..HAND_SIZE {
for seat_offset in 0..PLAYERS {
let seat = (state.dealer + 1 + seat_offset as i64) % PLAYERS as i64;
let index = player_index_at(state, seat).expect("seated player");
state.players[index]
.hand
.push(deck.pop().expect("deck has enough cards"));
}
}
}
/// The index in `state.players` of the player in `seat`.
fn player_index_at(state: &ScoponeState, seat: i64) -> Result<usize> {
state
.players
.iter()
.position(|player| player.seat == seat)
.ok_or_else(|| GameError::illegal_move(format!("no player in seat {seat}")).into())
}
/// 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.
fn match_option(options: &[Vec<Card>], requested: &[Card]) -> Option<Vec<Card>> {
let mut wanted: Vec<String> = requested.iter().map(Card::code).collect();
wanted.sort();
for option in options {
let mut have: Vec<String> = option.iter().map(Card::code).collect();
have.sort();
if have == wanted {
return Some(option.clone());
}
}
None
}
/// Apply one move by the player identified by `sub`.
///
/// `capture_codes` selects which table cards to capture; it must be a legal
/// capture (see [`legal_captures`]) when one exists and empty otherwise.
/// Returns a [`GameError`] on any violation.
pub fn play(
state: &mut ScoponeState,
sub: &str,
card_code: &str,
capture_codes: Option<&[String]>,
) -> Result<()> {
if state.phase == PHASE_FINISHED {
return Err(GameError::game_finished("the match is over").into());
}
if state.phase == PHASE_HAND_END {
return Err(GameError::illegal_move(
"the hand is over; acknowledge the summary to continue",
)
.into());
}
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = state
.players
.iter()
.position(|player| player.sub == sub)
.filter(|&i| state.players[i].seat == state.turn);
let index = match index {
Some(index) => index,
None => return Err(GameError::not_your_turn("it is not your turn").into()),
};
let card = parse_card(card_code)?;
let hand_position = state.players[index].hand.iter().position(|c| c == &card);
let hand_position = match hand_position {
Some(position) => position,
None => return Err(card_not_in_hand(&card.code()).into()),
};
// Remove the card now so the scopa check below can tell whether this was
// the last play of the hand.
state.players[index].hand.remove(hand_position);
let mut requested: Vec<Card> = Vec::new();
if let Some(codes) = capture_codes {
for code in codes {
requested.push(parse_card(code)?);
}
}
let table_before = state.table.clone();
let options = legal_captures(&table_before, card.clone(), 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.
let pigliatutto =
state.asso_pigliatutto && card.rank == 1 && table_before.iter().all(|c| c.rank != 1);
let mut taken: Vec<Card> = Vec::new();
let mut scopa = false;
if options.is_empty() {
if !requested.is_empty() {
return Err(GameError::illegal_move("no capture is possible with that card").into());
}
state.table.push(card.clone());
} else {
let chosen = match match_option(&options, &requested) {
Some(chosen) => chosen,
None => {
return Err(GameError::illegal_move("the requested capture is not legal").into())
}
};
for captured in &chosen {
if let Some(position) = state.table.iter().position(|c| c == captured) {
state.table.remove(position);
}
state.players[index].captured.push(captured.clone());
}
state.players[index].captured.push(card.clone());
state.last_taker = Some(state.players[index].seat);
// A scopa scores only if cards remain to be played this hand.
let hands_empty = state.players.iter().all(|player| player.hand.is_empty());
if state.table.is_empty() && !hands_empty && !pigliatutto {
state.players[index].scope += 1;
scopa = true;
}
taken = chosen;
}
state.last_move = Some(Move {
seat: state.players[index].seat,
name: state.players[index].name.clone(),
card: card.code(),
captured: taken.iter().map(Card::code).collect(),
scopa,
pigliatutto,
});
if state.players.iter().all(|player| player.hand.is_empty()) {
end_hand(state)?;
} else {
state.turn = (state.turn + 1) % PLAYERS as i64;
set_turn_deadline(state);
}
Ok(())
}
/// Play a random legal move for the player currently on turn.
///
/// A card is drawn at random from that player's hand; if it can capture, one
/// of the legal captures is chosen at random (the rules require a capture when
/// one exists). Delegates to [`play`], so the move is fully validated and can
/// end the hand or the match. Pass `rng` for deterministic tests.
pub fn auto_play<R: Rng + ?Sized>(state: &mut ScoponeState, rng: &mut R) -> Result<()> {
if state.phase != PHASE_PLAYING {
return Err(GameError::game_not_started("the game has not started yet").into());
}
let index = player_index_at(state, state.turn)?;
if state.players[index].hand.is_empty() {
return Err(GameError::illegal_move("the player on turn has no cards").into());
}
let card = state.players[index]
.hand
.choose(rng)
.cloned()
.expect("hand is not empty");
let options = legal_captures(&state.table, card.clone(), state.asso_pigliatutto);
let capture: Option<Vec<Card>> = if options.is_empty() {
None
} else {
options.choose(rng).cloned()
};
let sub = state.players[index].sub.clone();
let capture_codes: Option<Vec<String>> = capture
.as_ref()
.map(|cards| cards.iter().map(Card::code).collect());
play(state, &sub, &card.code(), capture_codes.as_deref())
}
/// Sweep the table and score the hand.
///
/// If the match continues, the game pauses in the `hand_end` phase so every
/// player can read the scoring summary; the next hand is dealt by
/// [`acknowledge_hand`] once all four players have acknowledged (or by the
/// hand-end timeout fired by the platform's deadline scheduler). If the match
/// is over the game goes to `finished` immediately.
fn end_hand(state: &mut ScoponeState) -> Result<()> {
state.turn_deadline = None;
if !state.table.is_empty() {
if let Some(taker) = state.last_taker {
let index = player_index_at(state, taker)?;
let swept: Vec<Card> = std::mem::take(&mut state.table);
state.players[index].captured.extend(swept);
}
}
let (points, mut details) = hand_points(state);
for (score, awarded) in state.scores.iter_mut().zip(points.iter()) {
*score += awarded;
}
details.insert("hand".to_string(), json!(state.hand_number));
details.insert("team_a_points".to_string(), json!(points[0]));
details.insert("team_b_points".to_string(), json!(points[1]));
state.hand_scores.push(Value::Object(details.clone()));
// A full napola (the whole denari suit) wins the match outright,
// regardless of the score.
if let Some(Value::Object(napola)) = details.get("napola") {
for (team, name) in TEAM_NAMES.iter().enumerate() {
if napola.get(*name).and_then(Value::as_i64) == Some(10) {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(team as i64);
return Ok(());
}
}
}
let a = state.scores[0];
let b = state.scores[1];
let reached = a.max(b) >= state.target_score;
if reached && a != b {
state.phase = PHASE_FINISHED.to_string();
state.winner = Some(if a > b { 0 } else { 1 });
return Ok(());
}
// Pause for the scoring summary instead of dealing immediately.
state.phase = PHASE_HAND_END.to_string();
state.acked = Vec::new();
let deadline = now() + chrono::Duration::seconds(state.hand_ack_timeout);
state.hand_end_deadline = Some(to_iso(deadline));
Ok(())
}
/// Record that `sub` has read the hand-end summary.
///
/// When all four players have acknowledged, the next hand is dealt.
/// Acknowledging twice is a no-op; acknowledging outside the `hand_end` phase
/// returns an error.
pub fn acknowledge_hand<R: Rng + ?Sized>(
state: &mut ScoponeState,
sub: &str,
rng: &mut R,
) -> Result<()> {
if state.phase != PHASE_HAND_END {
return Err(
GameError::illegal_move("no hand summary is waiting for acknowledgement").into(),
);
}
let seat = match state.players.iter().find(|player| player.sub == sub) {
Some(player) => player.seat,
None => return Err(GameError::not_your_turn("you are not seated in this game").into()),
};
if state.acked.contains(&seat) {
return Ok(());
}
state.acked.push(seat);
if state.acked.len() < PLAYERS {
return Ok(());
}
state.hand_number += 1;
state.dealer = (state.dealer + 1) % PLAYERS as i64;
state.acked = Vec::new();
state.hand_end_deadline = None;
state.last_move = None;
state.phase = PHASE_PLAYING.to_string();
deal_hand(state, rng);
Ok(())
}
/// Return the primiera value of a capture pile (0 if a suit is absent).
pub fn primiera_score(captured: &[Card]) -> i64 {
let mut best: [Option<i64>; 4] = [None; 4];
for card in captured {
let value = primiera_value(card.rank);
if let Some(index) = SUITS.iter().position(|&suit| suit == card.suit) {
if best[index].is_none_or(|current| value > current) {
best[index] = Some(value);
}
}
}
if best.iter().any(Option::is_none) {
return 0;
}
best.iter().map(|value| value.unwrap_or(0)).sum()
}
/// 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.
pub fn napola_score(captured: &[Card]) -> i64 {
let ranks: HashSet<u32> = captured
.iter()
.filter(|card| card.suit == 'D')
.map(|card| card.rank)
.collect();
let mut run = 0u32;
while ranks.contains(&(run + 1)) {
run += 1;
}
if run >= 3 {
run as i64
} else {
0
}
}
/// Compute the hand points for both teams (index 0 = team A).
pub fn hand_points(state: &ScoponeState) -> (Vec<i64>, Map<String, Value>) {
let mut piles: [Vec<Card>; 2] = [Vec::new(), Vec::new()];
let mut scope: [i64; 2] = [0, 0];
for player in &state.players {
let team = player.team() as usize;
piles[team].extend(player.captured.iter().cloned());
scope[team] += player.scope;
}
let mut points = [0i64, 0i64];
let mut award: Map<String, Value> = Map::new();
// Carte: most captured cards. Ties award nothing.
let cards = [piles[0].len() as i64, piles[1].len() as i64];
if cards[0] != cards[1] {
let winner = if cards[0] > cards[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"carte".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("carte".to_string(), Value::Null);
}
// Denara: most diamond cards. Ties award nothing.
let coins = [
piles[0].iter().filter(|c| c.suit == 'D').count() as i64,
piles[1].iter().filter(|c| c.suit == 'D').count() as i64,
];
if coins[0] != coins[1] {
let winner = if coins[0] > coins[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"denara".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("denara".to_string(), Value::Null);
}
// Settebello: the 7 of diamonds always belongs to someone.
let settebello = [
piles[0].iter().any(|c| c.rank == 7 && c.suit == 'D'),
piles[1].iter().any(|c| c.rank == 7 && c.suit == 'D'),
];
if settebello[0] != settebello[1] {
let winner = if settebello[0] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"settebello".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
}
// Primiera: highest value, only if the team holds all four suits.
let primiera = [primiera_score(&piles[0]), primiera_score(&piles[1])];
if primiera[0] != primiera[1] {
let winner = if primiera[0] > primiera[1] { 0 } else { 1 };
points[winner] += 1;
award.insert(
"primiera".to_string(),
Value::String(TEAM_NAMES[winner].to_string()),
);
} else {
award.insert("primiera".to_string(), Value::Null);
}
// Scope: one point each.
points[0] += scope[0];
points[1] += scope[1];
// Napola (optional rule): consecutive denari from the ace. A run of 10
// means the team swept the whole suit and wins the match instantly.
let mut napola_details: Option<Value> = None;
if state.napola {
let napola = [napola_score(&piles[0]), napola_score(&piles[1])];
for team in 0..2 {
points[team] += napola[team];
}
let name = (0..2usize)
.find(|&team| napola[team] > 0)
.map(|team| Value::String(TEAM_NAMES[team].to_string()))
.unwrap_or(Value::Null);
award.insert("napola".to_string(), name);
napola_details = Some(json!({"A": napola[0], "B": napola[1]}));
}
let mut details: Map<String, Value> = Map::new();
details.insert("cards".to_string(), json!({"A": cards[0], "B": cards[1]}));
details.insert("denara".to_string(), json!({"A": coins[0], "B": coins[1]}));
details.insert(
"settebello".to_string(),
json!({"A": settebello[0], "B": settebello[1]}),
);
details.insert(
"primiera".to_string(),
json!({"A": primiera[0], "B": primiera[1]}),
);
details.insert("scope".to_string(), json!({"A": scope[0], "B": scope[1]}));
details.insert("award".to_string(), Value::Object(award));
if let Some(napola) = napola_details {
details.insert("napola".to_string(), napola);
}
(points.to_vec(), details)
}
fn optional_string(value: &Option<String>) -> Value {
value
.as_ref()
.map(|text| Value::String(text.clone()))
.unwrap_or(Value::Null)
}
/// Serialize `state` hiding other players' hands.
///
/// Hands are reduced to a count, except for the requesting player's own hand.
/// Non-seated viewers simply see no hand at all.
pub fn state_for_player(state: &ScoponeState, sub: &str) -> Value {
let viewer = state.player_for(sub);
let viewer_seat = viewer.map(|player| player.seat);
let mut players: Vec<Value> = Vec::new();
for player in &state.players {
let mut view: Map<String, Value> = Map::new();
view.insert("sub".to_string(), json!(player.sub));
view.insert("name".to_string(), json!(player.name));
view.insert("seat".to_string(), json!(player.seat));
view.insert(
"team".to_string(),
json!(TEAM_NAMES[player.team() as usize]),
);
view.insert("cards_left".to_string(), json!(player.hand.len()));
view.insert("captured_count".to_string(), json!(player.captured.len()));
view.insert("scope".to_string(), json!(player.scope));
if viewer_seat == Some(player.seat) {
view.insert(
"hand".to_string(),
Value::Array(player.hand.iter().map(Card::to_json).collect()),
);
}
players.push(Value::Object(view));
}
let mut payload: Map<String, Value> = Map::new();
payload.insert("phase".to_string(), json!(state.phase));
payload.insert("target_score".to_string(), json!(state.target_score));
payload.insert("napola".to_string(), json!(state.napola));
payload.insert(
"asso_pigliatutto".to_string(),
json!(state.asso_pigliatutto),
);
payload.insert("hand_number".to_string(), json!(state.hand_number));
payload.insert("dealer".to_string(), json!(state.dealer));
payload.insert("turn".to_string(), json!(state.turn));
payload.insert(
"scores".to_string(),
json!({"A": state.scores[0], "B": state.scores[1]}),
);
payload.insert(
"winner".to_string(),
match state.winner {
Some(winner) => json!(TEAM_NAMES[winner as usize]),
None => Value::Null,
},
);
payload.insert(
"table".to_string(),
Value::Array(state.table.iter().map(Card::to_json).collect()),
);
payload.insert("players".to_string(), Value::Array(players));
payload.insert(
"last_hand".to_string(),
state.hand_scores.last().cloned().unwrap_or(Value::Null),
);
payload.insert(
"last_move".to_string(),
state
.last_move
.as_ref()
.map(Move::to_json)
.unwrap_or(Value::Null),
);
payload.insert("acknowledged".to_string(), json!(state.acked));
payload.insert(
"hand_end_deadline".to_string(),
optional_string(&state.hand_end_deadline),
);
payload.insert(
"turn_deadline".to_string(),
optional_string(&state.turn_deadline),
);
if let Some(seat) = viewer_seat {
if state.phase == PHASE_PLAYING && seat == state.turn {
payload.insert("your_turn".to_string(), Value::Bool(true));
// Only the player on turn receives their legal captures, so all
// the rule logic stays server-side.
let mut legal_moves: Map<String, Value> = Map::new();
if let Some(viewer) = viewer {
for hand_card in &viewer.hand {
let options =
legal_captures(&state.table, hand_card.clone(), state.asso_pigliatutto);
if !options.is_empty() {
let list: Vec<Value> = options
.iter()
.map(|option| {
Value::Array(option.iter().map(|c| json!(c.code())).collect())
})
.collect();
legal_moves.insert(hand_card.code(), Value::Array(list));
}
}
}
payload.insert("legal_moves".to_string(), Value::Object(legal_moves));
}
}
Value::Object(payload)
}
@@ -0,0 +1,15 @@
//! Scopone scientifico errors.
//!
//! Most failure modes are game-independent and live in
//! [`tavolo_platform::errors`]; only genuinely scopone-specific errors are
//! defined here. The Rust platform folds every game error into a single
//! [`GameError`] value carrying an `"illegal_move"` code, so a Python-style
//! exception hierarchy is unnecessary; this module keeps named constructors so
//! call sites (and tests) stay readable.
use tavolo_platform::errors::GameError;
/// The played card is not held by the player.
pub fn card_not_in_hand(code: &str) -> GameError {
GameError::illegal_move(format!("card {code} is not in your hand"))
}
+19
View File
@@ -0,0 +1,19 @@
//! Scopone scientifico rules engine and platform adapter.
//!
//! [`engine`] holds the pure, deterministic rules; [`state`] the JSON-native
//! game representation; [`plugin`] the [`tavolo_platform::engine::GameEngine`]
//! implementation that plugs the game into the platform.
pub mod engine;
pub mod errors;
pub mod plugin;
pub mod state;
pub use engine::{
DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS, HAND_SIZE, PLAYERS,
};
pub use plugin::ScoponeEngine;
pub use state::{
Card, Move, PlayerState, ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END,
PHASE_LOBBY, PHASE_PLAYING, TEAM_NAMES,
};
+428
View File
@@ -0,0 +1,428 @@
//! The platform-facing adapter for scopone scientifico.
//!
//! [`ScoponeEngine`] implements [`GameEngine`] on top of the pure rules in
//! [`crate::engine`]: it translates platform calls (create, join, websocket
//! actions, deadlines) into rules-engine calls and back, keeping all scopone
//! knowledge inside this package. Nothing outside `tavolo_scopone` needs to
//! know about phases, turns or hand scoring.
use std::any::Any;
use rand::thread_rng;
use serde_json::{json, Map, Value};
use tavolo_platform::engine::{Deadline, GameEngine, GameSession, MatchResult, PlayerResult, Seat};
use tavolo_platform::errors::{GameError, Result};
use crate::engine::{self, DEFAULT_HAND_ACK_TIMEOUT_SECONDS, DEFAULT_TURN_TIMEOUT_SECONDS};
use crate::state::{
ScoponeState, DEFAULT_TARGET_SCORE, PHASE_FINISHED, PHASE_HAND_END, PHASE_LOBBY, PHASE_PLAYING,
TEAM_NAMES,
};
/// Downcast a session's opaque state to [`ScoponeState`].
fn state_ref(session: &GameSession) -> Result<&ScoponeState> {
session
.state
.as_ref()
.and_then(|state| state.downcast_ref::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Downcast a session's opaque state to [`ScoponeState`], mutably.
fn state_mut(session: &mut GameSession) -> Result<&mut ScoponeState> {
session
.state
.as_mut()
.and_then(|state| state.downcast_mut::<ScoponeState>())
.ok_or_else(|| GameError::illegal_move("missing scopone state").into())
}
/// Epoch milliseconds for an ISO-8601 deadline, `None` when absent or
/// unparseable.
fn deadline_ms(iso: Option<&str>) -> Option<i64> {
iso.and_then(tavolo_platform::datetime::from_iso)
.map(|dt| dt.timestamp_millis())
}
/// Scopone scientifico as a platform game engine.
pub struct ScoponeEngine {
turn_timeout_seconds: i64,
hand_ack_timeout_seconds: i64,
}
impl ScoponeEngine {
pub fn new(turn_timeout_seconds: i64, hand_ack_timeout_seconds: i64) -> Self {
Self {
turn_timeout_seconds,
hand_ack_timeout_seconds,
}
}
}
impl Default for ScoponeEngine {
fn default() -> Self {
Self::new(
DEFAULT_TURN_TIMEOUT_SECONDS,
DEFAULT_HAND_ACK_TIMEOUT_SECONDS,
)
}
}
impl GameEngine for ScoponeEngine {
fn id(&self) -> &'static str {
"scopone_scientifico"
}
fn name(&self) -> &'static str {
"Scopone scientifico"
}
fn description(&self) -> &'static str {
"Four players in fixed partnerships, ten cards each and an empty table. \
First team to the target score wins."
}
fn min_players(&self) -> usize {
4
}
fn max_players(&self) -> usize {
4
}
fn options_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"target_score": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": DEFAULT_TARGET_SCORE,
"description": "Match points the winning team must reach.",
},
"napola": {
"type": "boolean",
"default": true,
"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)",
},
},
})
}
// -- lobby ------------------------------------------------------------
fn create(&self, session: &mut GameSession, options: &Map<String, Value>) -> Result<()> {
let target_score = match options.get("target_score") {
None => DEFAULT_TARGET_SCORE,
Some(Value::Bool(_)) => {
return Err(GameError::illegal_move("target_score must be an integer").into())
}
Some(value) => value
.as_i64()
.ok_or_else(|| GameError::illegal_move("target_score must be an integer"))?,
};
let napola = match options.get("napola") {
None => true,
Some(Value::Bool(value)) => *value,
Some(_) => return Err(GameError::illegal_move("napola must be a boolean").into()),
};
let asso_pigliatutto = match options.get("asso_pigliatutto") {
None => false,
Some(Value::Bool(value)) => *value,
Some(_) => {
return Err(GameError::illegal_move("asso_pigliatutto must be a boolean").into())
}
};
let creator = session
.players
.first()
.ok_or_else(|| GameError::illegal_move("session has no creator seat"))?;
let creator_sub = creator.user_sub.clone();
let creator_name = creator.display_name.clone();
let state = engine::create_game(
&creator_sub,
&creator_name,
target_score,
self.hand_ack_timeout_seconds,
self.turn_timeout_seconds,
napola,
asso_pigliatutto,
)?;
session.state = Some(Box::new(state));
// The creator takes seat 0, i.e. team A.
if let Some(seat) = session.players.get_mut(0) {
seat.team = Some(TEAM_NAMES[0].to_string());
}
Ok(())
}
fn join(&self, session: &mut GameSession, user_sub: &str, display_name: &str) -> Result<()> {
let seat = session.players.len();
{
let state = state_mut(session)?;
engine::join_game(state, user_sub, display_name, &mut thread_rng())?;
}
// join_game raises before appending on any violation, so the seat list
// stays in sync with the engine's players.
session.players.push(Seat {
user_sub: user_sub.to_string(),
display_name: display_name.to_string(),
team: Some(TEAM_NAMES[seat % 2].to_string()),
});
Ok(())
}
fn in_lobby(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_LOBBY)
.unwrap_or(false)
}
fn lobby_view(&self, session: &GameSession) -> Value {
match state_ref(session) {
Ok(state) => json!({
"phase": state.phase,
"target_score": state.target_score,
"napola": state.napola,
"asso_pigliatutto": state.asso_pigliatutto,
}),
Err(_) => json!({}),
}
}
// -- play -------------------------------------------------------------
fn handle_action(
&self,
session: &mut GameSession,
user_sub: &str,
action: &str,
payload: &Map<String, Value>,
) -> Result<()> {
match action {
"play" => {
let card = payload
.get("card")
.and_then(Value::as_str)
.ok_or_else(|| GameError::illegal_move("'card' must be a card code string"))?;
let capture: Option<Vec<String>> = match payload.get("capture") {
None | Some(Value::Null) => None,
Some(Value::Array(items)) => {
let mut codes = Vec::with_capacity(items.len());
for item in items {
match item.as_str() {
Some(code) => codes.push(code.to_string()),
None => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
}
}
Some(codes)
}
Some(_) => {
return Err(GameError::illegal_move(
"'capture' must be a list of card codes",
)
.into())
}
};
let state = state_mut(session)?;
engine::play(state, user_sub, card, capture.as_deref())
}
"ack" => {
let state = state_mut(session)?;
engine::acknowledge_hand(state, user_sub, &mut thread_rng())
}
_ => Err(GameError::illegal_move(format!(
"unknown action: {}",
tavolo_platform::http::py_repr(&serde_json::Value::String(action.to_string()))
))
.into()),
}
}
fn view_for(&self, session: &GameSession, user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => engine::state_for_player(state, user_sub),
Err(_) => Value::Null,
}
}
fn is_finished(&self, session: &GameSession) -> bool {
state_ref(session)
.map(|state| state.phase == PHASE_FINISHED)
.unwrap_or(false)
}
fn game_over_view(&self, session: &GameSession, _user_sub: &str) -> Value {
match state_ref(session) {
Ok(state) => json!({
"scores": {"A": state.scores[0], "B": state.scores[1]},
"winner": state.winner.map(|winner| TEAM_NAMES[winner as usize]),
}),
Err(_) => json!({}),
}
}
// -- (de)serialization -------------------------------------------------
fn state_to_json(&self, state: &(dyn Any + Send + Sync)) -> Result<Value> {
state
.downcast_ref::<ScoponeState>()
.map(ScoponeState::to_json)
.ok_or_else(|| GameError::illegal_move("state is not a ScoponeState").into())
}
fn state_from_json(&self, data: &Value) -> Result<Box<dyn Any + Send + Sync>> {
Ok(Box::new(ScoponeState::from_json(data)?))
}
// -- deadlines ----------------------------------------------------------
fn next_deadline(&self, session: &GameSession) -> Result<Option<Deadline>> {
let state = state_ref(session)?;
if state.phase == PHASE_PLAYING {
if let Some(iso) = state.turn_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "turn".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("turn:{}:{}:{}", state.hand_number, state.turn, due_ms),
}));
}
}
if state.phase == PHASE_HAND_END {
if let Some(iso) = state.hand_end_deadline.as_deref() {
let due_ms = match deadline_ms(Some(iso)) {
Some(due_ms) => due_ms,
None => return Ok(None),
};
return Ok(Some(Deadline {
kind: "hand_end".to_string(),
due_at: timestamp_millis(due_ms),
token: format!("hand_end:{}:{}", state.hand_number, due_ms),
}));
}
}
Ok(None)
}
fn fire_deadline(&self, session: &mut GameSession, kind: &str, token: &str) -> Result<()> {
let current = self.next_deadline(session)?;
let stale = match &current {
Some(deadline) => deadline.kind != kind || deadline.token != token,
None => true,
};
if stale {
// Overtaken by events (a play landed in time, the hand was
// acknowledged, the deadline moved): nothing to do.
return Err(GameError::illegal_move("stale deadline").into());
}
match kind {
"turn" => {
let state = state_mut(session)?;
engine::auto_play(state, &mut thread_rng())?;
let actor = state.last_move.as_ref().map(|move_| move_.seat);
tracing::info!(
actor,
hand = state.hand_number,
"auto-played for the turn timeout"
);
Ok(())
}
"hand_end" => {
let state = state_mut(session)?;
let subs: Vec<String> = state
.players
.iter()
.map(|player| player.sub.clone())
.collect();
let mut rng = thread_rng();
for sub in subs {
engine::acknowledge_hand(state, &sub, &mut rng)?;
}
tracing::info!(
hand = state.hand_number,
"hand auto-advanced after the acknowledgement timeout"
);
Ok(())
}
// next_deadline never emits other kinds.
_ => Err(GameError::illegal_move(format!("unknown deadline kind: {kind:?}")).into()),
}
}
// -- results -------------------------------------------------------------
fn result(&self, session: &GameSession) -> Result<MatchResult> {
let state = state_ref(session)?;
let winner = state
.winner
.ok_or_else(|| GameError::illegal_move("no result: the match is not finished"))?
as usize;
let teams = vec![
state
.players
.iter()
.filter(|player| player.team() == 0)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
state
.players
.iter()
.filter(|player| player.team() == 1)
.map(|player| player.sub.clone())
.collect::<Vec<_>>(),
];
let players = state
.players
.iter()
.map(|player| {
let team = player.team() as usize;
PlayerResult {
user_sub: player.sub.clone(),
seat: player.seat,
won: team == winner,
team: Some(TEAM_NAMES[team].to_string()),
score: state.scores[team] as f64,
details: json!({"scope": player.scope}),
}
})
.collect();
let summary = json!({
"team_a_score": state.scores[0],
"team_b_score": state.scores[1],
"winner_team": TEAM_NAMES[winner],
"target_score": state.target_score,
"hands_played": state.hand_number,
"hand_scores": state.hand_scores,
});
Ok(MatchResult::new(teams, winner, players, summary))
}
}
/// Convert epoch milliseconds back to a UTC timestamp, falling back to now
/// when the value is out of range (it never is for parsed deadlines).
fn timestamp_millis(millis: i64) -> chrono::DateTime<chrono::Utc> {
chrono::DateTime::<chrono::Utc>::from_timestamp_millis(millis)
.unwrap_or_else(tavolo_platform::datetime::now)
}
+388
View File
@@ -0,0 +1,388 @@
//! In-memory representation of a scopone scientifico game.
//!
//! The whole mutable game lives in [`ScoponeState`], which is serialized to
//! and from plain JSON for storage inside the platform's session envelope.
//! Keeping the representation JSON-native means the store needs no custom
//! codecs and the state is inspectable.
//!
//! The state contains only game data: the platform owns the session envelope
//! (id, join code, seats, timestamps, stats persistence).
//!
//! Deck convention: a 40-card Italian deck. Suits are `D` (denari), `C`
//! (coppe), `S` (spade) and `B` (bastoni); ranks are `1`..`10`. A card is
//! rendered as `RRSUIT` (e.g. `07D` is the settebello).
use serde_json::{json, Value};
use tavolo_platform::errors::{GameError, Result};
/// The four suits, in canonical order.
pub const SUITS: [char; 4] = ['D', 'C', 'S', 'B'];
/// Teams are derived from the seat: seats 0 and 2 form team A (index 0),
/// seats 1 and 3 form team B (index 1). Team pairs always sit opposite each
/// other, as in real scopone scientifico.
pub const TEAM_NAMES: [&str; 2] = ["A", "B"];
pub const PHASE_LOBBY: &str = "lobby";
pub const PHASE_PLAYING: &str = "playing";
/// Between hands of an unfinished match: scoring summary shown to every
/// player; the next hand is dealt once all four acknowledge (or the hand-end
/// timeout elapses).
pub const PHASE_HAND_END: &str = "hand_end";
pub const PHASE_FINISHED: &str = "finished";
pub const DEFAULT_TARGET_SCORE: i64 = 11;
/// The team index for a seat (0 or 1).
pub fn team_of(seat: i64) -> i64 {
seat.rem_euclid(2)
}
/// A single playing card.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Card {
pub rank: u32,
pub suit: char,
}
impl Card {
/// Build a validated card, or `None` when the suit/rank is out of range.
pub fn new(rank: u32, suit: char) -> Option<Card> {
if SUITS.contains(&suit) && (1..=10).contains(&rank) {
Some(Card { rank, suit })
} else {
None
}
}
/// The `RRSUIT` code (e.g. `07D`).
pub fn code(&self) -> String {
format!("{:02}{}", self.rank, self.suit)
}
/// Parse a card code, uppercasing the input like Python's `Card.parse`.
pub fn parse(code: &str) -> Option<Card> {
let upper = code.to_ascii_uppercase();
let chars: Vec<char> = upper.chars().collect();
if chars.len() != 3 || !chars[0].is_ascii_digit() || !chars[1].is_ascii_digit() {
return None;
}
let rank: u32 = format!("{}{}", chars[0], chars[1]).parse().ok()?;
Card::new(rank, chars[2])
}
pub fn to_json(&self) -> Value {
Value::String(self.code())
}
pub fn from_json(value: &Value) -> Result<Card> {
value
.as_str()
.and_then(Card::parse)
.ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
}
/// Parse a card code, raising an `"invalid card code"` error on malformed
/// input.
pub fn parse_card(code: &str) -> Result<Card> {
Card::parse(code).ok_or_else(|| GameError::illegal_move("invalid card code").into())
}
/// Record of a single play, broadcast so every client can show who played
/// which card and what it captured.
#[derive(Debug, Clone, Default)]
pub struct Move {
pub seat: i64,
pub name: String,
pub card: String,
pub captured: Vec<String>,
pub scopa: bool,
/// The play was an asso pigliatutto capture (the whole table, or the ace
/// itself when the table was empty).
pub pigliatutto: bool,
}
impl Move {
pub fn to_json(&self) -> Value {
json!({
"seat": self.seat,
"name": self.name,
"card": self.card,
"captured": self.captured,
"scopa": self.scopa,
"pigliatutto": self.pigliatutto,
})
}
pub fn from_json(data: &Value) -> Move {
Move {
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
card: data
.get("card")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
captured: data
.get("captured")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|c| c.as_str().unwrap_or_default().to_string())
.collect()
})
.unwrap_or_default(),
scopa: data.get("scopa").and_then(Value::as_bool).unwrap_or(false),
pigliatutto: data
.get("pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
}
}
}
/// One player's private state within a game.
#[derive(Debug, Clone, Default)]
pub struct PlayerState {
pub sub: String,
pub name: String,
pub seat: i64,
pub hand: Vec<Card>,
pub captured: Vec<Card>,
pub scope: i64,
}
impl PlayerState {
pub fn team(&self) -> i64 {
team_of(self.seat)
}
pub fn to_json(&self) -> Value {
json!({
"sub": self.sub,
"name": self.name,
"seat": self.seat,
"hand": self.hand.iter().map(Card::to_json).collect::<Vec<_>>(),
"captured": self.captured.iter().map(Card::to_json).collect::<Vec<_>>(),
"scope": self.scope,
})
}
pub fn from_json(data: &Value) -> Result<PlayerState> {
let parse_cards = |value: Option<&Value>| -> Result<Vec<Card>> {
let items = value.and_then(Value::as_array).cloned().unwrap_or_default();
items.iter().map(Card::from_json).collect()
};
Ok(PlayerState {
sub: data
.get("sub")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: data
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
seat: data.get("seat").and_then(Value::as_i64).unwrap_or(0),
hand: parse_cards(data.get("hand"))?,
captured: parse_cards(data.get("captured"))?,
scope: data.get("scope").and_then(Value::as_i64).unwrap_or(0),
})
}
}
/// The whole mutable state of one scopone scientifico game.
#[derive(Debug, Clone)]
pub struct ScoponeState {
pub target_score: i64,
/// Whether the napola rule is scored (denari run from the ace; a full suit
/// wins the match instantly). Default on.
pub napola: bool,
/// 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.
pub asso_pigliatutto: bool,
pub phase: String,
pub players: Vec<PlayerState>,
pub table: Vec<Card>,
pub dealer: i64,
pub turn: i64,
pub hand_number: i64,
pub scores: Vec<i64>,
pub winner: Option<i64>,
pub last_taker: Option<i64>,
/// Per-hand points awarded, for a compact audit trail in the API.
pub hand_scores: Vec<Value>,
/// The most recent play in the current hand, for move announcements.
pub last_move: Option<Move>,
/// While `phase == "hand_end"`: seats that acknowledged the summary, and
/// when the auto-continue timeout fires.
pub acked: Vec<i64>,
pub hand_end_deadline: Option<String>,
/// Seconds the hand-end summary waits before dealing anyway.
pub hand_ack_timeout: i64,
/// While `phase == "playing"`: when the server plays a random legal card
/// for the player on turn.
pub turn_deadline: Option<String>,
pub turn_timeout: i64,
}
impl Default for ScoponeState {
fn default() -> Self {
ScoponeState {
target_score: DEFAULT_TARGET_SCORE,
napola: true,
asso_pigliatutto: false,
phase: PHASE_LOBBY.to_string(),
players: Vec::new(),
table: Vec::new(),
dealer: 0,
turn: 0,
hand_number: 1,
scores: vec![0, 0],
winner: None,
last_taker: None,
hand_scores: Vec::new(),
last_move: None,
acked: Vec::new(),
hand_end_deadline: None,
hand_ack_timeout: 30,
turn_deadline: None,
turn_timeout: 30,
}
}
}
impl ScoponeState {
pub fn to_json(&self) -> Value {
json!({
"target_score": self.target_score,
"napola": self.napola,
"asso_pigliatutto": self.asso_pigliatutto,
"phase": self.phase,
"players": self.players.iter().map(PlayerState::to_json).collect::<Vec<_>>(),
"table": self.table.iter().map(Card::to_json).collect::<Vec<_>>(),
"dealer": self.dealer,
"turn": self.turn,
"hand_number": self.hand_number,
"scores": self.scores,
"winner": self.winner,
"last_taker": self.last_taker,
"hand_scores": self.hand_scores,
"last_move": self.last_move.as_ref().map(Move::to_json),
"acked": self.acked,
"hand_end_deadline": self.hand_end_deadline,
"hand_ack_timeout": self.hand_ack_timeout,
"turn_deadline": self.turn_deadline,
"turn_timeout": self.turn_timeout,
})
}
pub fn from_json(data: &Value) -> Result<ScoponeState> {
let obj = data
.as_object()
.ok_or_else(|| GameError::illegal_move("state must be a JSON object"))?;
let parse_array = |value: Option<&Value>| -> Vec<Value> {
value.and_then(Value::as_array).cloned().unwrap_or_default()
};
let players = parse_array(obj.get("players"))
.iter()
.map(PlayerState::from_json)
.collect::<Result<Vec<_>>>()?;
let table = parse_array(obj.get("table"))
.iter()
.map(Card::from_json)
.collect::<Result<Vec<_>>>()?;
let scores = obj
.get("scores")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_else(|| vec![0, 0]);
let last_move = match obj.get("last_move") {
Some(Value::Object(map)) if !map.is_empty() => {
Some(Move::from_json(obj.get("last_move").unwrap()))
}
_ => None,
};
Ok(ScoponeState {
target_score: obj
.get("target_score")
.and_then(Value::as_i64)
.unwrap_or(DEFAULT_TARGET_SCORE),
napola: obj.get("napola").and_then(Value::as_bool).unwrap_or(true),
asso_pigliatutto: obj
.get("asso_pigliatutto")
.and_then(Value::as_bool)
.unwrap_or(false),
phase: obj
.get("phase")
.and_then(Value::as_str)
.unwrap_or(PHASE_LOBBY)
.to_string(),
players,
table,
dealer: obj.get("dealer").and_then(Value::as_i64).unwrap_or(0),
turn: obj.get("turn").and_then(Value::as_i64).unwrap_or(0),
hand_number: obj.get("hand_number").and_then(Value::as_i64).unwrap_or(1),
scores,
winner: obj.get("winner").and_then(Value::as_i64),
last_taker: obj.get("last_taker").and_then(Value::as_i64),
hand_scores: parse_array(obj.get("hand_scores")),
last_move,
acked: obj
.get("acked")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.map(|x| x.as_i64().unwrap_or(0))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
hand_end_deadline: obj
.get("hand_end_deadline")
.and_then(Value::as_str)
.map(str::to_string),
hand_ack_timeout: obj
.get("hand_ack_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
turn_deadline: obj
.get("turn_deadline")
.and_then(Value::as_str)
.map(str::to_string),
turn_timeout: obj
.get("turn_timeout")
.and_then(Value::as_i64)
.unwrap_or(30),
})
}
// -- helpers ----------------------------------------------------------
pub fn player_for(&self, sub: &str) -> Option<&PlayerState> {
self.players.iter().find(|player| player.sub == sub)
}
pub fn seated(&self, sub: &str) -> bool {
self.player_for(sub).is_some()
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,483 @@
//! `ScoponeEngine` adapter tests: the platform contract over the pure rules.
//!
//! Ported from `tavolo-scopone/tests/test_plugin.py`.
use serde_json::{json, Map, Value};
use tavolo_platform::engine::GameSession;
use tavolo_platform::errors::Error;
use tavolo_platform::{GameEngine, Seat};
use tavolo_scopone::engine as rules;
use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING};
use tavolo_scopone::ScoponeEngine;
fn opts(value: Value) -> Map<String, Value> {
value.as_object().unwrap().clone()
}
fn empty() -> Map<String, Value> {
Map::new()
}
fn capitalize(name: &str) -> String {
let mut chars = name.chars();
match chars.next() {
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
fn base_session(engine: &ScoponeEngine) -> GameSession {
let mut session = GameSession::new("s1", engine.id(), "CODE01");
session.creator_sub = "alice".to_string();
session.players = vec![Seat {
user_sub: "alice".to_string(),
display_name: "Alice".to_string(),
team: None,
}];
session
}
fn make_session(engine: &ScoponeEngine, options: Map<String, Value>) -> Result<GameSession, Error> {
let mut session = base_session(engine);
engine.create(&mut session, &options)?;
Ok(session)
}
fn started(engine: &ScoponeEngine, options: Map<String, Value>) -> GameSession {
let mut session = make_session(engine, options).unwrap();
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
session
}
fn state(session: &GameSession) -> &ScoponeState {
session
.state
.as_ref()
.unwrap()
.downcast_ref::<ScoponeState>()
.unwrap()
}
fn state_mut(session: &mut GameSession) -> &mut ScoponeState {
session
.state
.as_mut()
.unwrap()
.downcast_mut::<ScoponeState>()
.unwrap()
}
fn err_message(error: &Error) -> String {
error
.as_game()
.map(|game| game.message.clone())
.unwrap_or_default()
}
/// Extract the error from a failed `make_session` without requiring
/// `GameSession: Debug` (which `unwrap_err` would).
fn session_error(result: Result<GameSession, Error>) -> Error {
match result {
Ok(_) => panic!("expected an error"),
Err(error) => error,
}
}
/// Play legal moves (and acknowledge hand ends) until the match finishes.
fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize {
let mut moves = 0;
while !engine.is_finished(session) && moves < 200_000 {
let (action, sub, payload): (&str, String, Map<String, Value>) = {
let st = state(session);
if st.phase == PHASE_HAND_END {
let player = st
.players
.iter()
.find(|player| !st.acked.contains(&player.seat))
.unwrap();
("ack", player.sub.clone(), Map::new())
} else {
let player = st
.players
.iter()
.find(|player| player.seat == st.turn)
.unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
("play", player.sub.clone(), payload)
}
};
engine
.handle_action(session, &sub, action, &payload)
.unwrap();
moves += 1;
}
moves
}
// -- create ----------------------------------------------------------------
#[test]
fn create_seats_creator_on_team_a() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert_eq!(Some("A".to_string()), session.players[0].team);
let st = state(&session);
assert_eq!(11, st.target_score);
assert!(st.napola);
}
#[test]
fn create_options() {
let engine = ScoponeEngine::default();
let session =
make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap();
let st = state(&session);
assert_eq!(16, st.target_score);
assert!(!st.napola);
assert!(!st.asso_pigliatutto);
}
#[test]
fn create_asso_pigliatutto_option() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap();
assert!(state(&session).asso_pigliatutto);
}
#[test]
fn create_rejects_bad_options() {
let engine = ScoponeEngine::default();
assert_eq!(
"target_score must be between 1 and 100",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": 0}))
)))
);
assert_eq!(
"target_score must be an integer",
err_message(&session_error(make_session(
&engine,
opts(json!({"target_score": "eleven"}))
)))
);
assert_eq!(
"napola must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"napola": "yes"}))
)))
);
assert_eq!(
"asso_pigliatutto must be a boolean",
err_message(&session_error(make_session(
&engine,
opts(json!({"asso_pigliatutto": "yes"}))
)))
);
}
#[test]
fn timeouts_come_from_the_engine() {
let engine = ScoponeEngine::new(7, 9);
let session = make_session(&engine, empty()).unwrap();
let st = state(&session);
assert_eq!(7, st.turn_timeout);
assert_eq!(9, st.hand_ack_timeout);
}
// -- join ------------------------------------------------------------------
#[test]
fn join_assigns_teams_and_starts() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert!(engine.in_lobby(&session));
for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
assert_eq!(Some(team.to_string()), session.players.last().unwrap().team);
}
assert!(!engine.in_lobby(&session));
assert_eq!(PHASE_PLAYING, state(&session).phase);
}
#[test]
fn join_errors() {
let engine = ScoponeEngine::default();
let mut session = make_session(&engine, empty()).unwrap();
assert_eq!(
"already joined this game",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
for name in ["bob", "carol", "dave"] {
engine.join(&mut session, name, &capitalize(name)).unwrap();
}
// The lobby filled up and the match started: late joins and even re-joins
// are rejected as "already started".
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err())
);
assert_eq!(
"game has already started",
err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err())
);
}
// -- actions ---------------------------------------------------------------
#[test]
fn unknown_action_rejected() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "alice", "dance", &Map::new())
.is_err());
}
#[test]
fn play_validates_payload() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
assert!(engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": 42})))
.is_err());
assert!(engine
.handle_action(&mut session, "bob", "play", &Map::new())
.is_err());
assert!(engine
.handle_action(
&mut session,
"bob",
"play",
&opts(json!({"card": "01D", "capture": "02C"}))
)
.is_err());
}
#[test]
fn invalid_card_code_is_illegal_move() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let error = engine
.handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"})))
.unwrap_err();
assert_eq!("invalid card code", err_message(&error));
}
#[test]
fn finished_match_rejects_actions() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
assert!(engine.is_finished(&session));
let error = engine
.handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"})))
.unwrap_err();
assert_eq!("the match is over", err_message(&error));
}
// -- views -----------------------------------------------------------------
#[test]
fn view_for_hides_other_hands() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let view = engine.view_for(&session, "alice");
let players = view["players"].as_array().unwrap();
let alice = players.iter().find(|p| p["seat"] == 0).unwrap();
let bob = players.iter().find(|p| p["seat"] == 1).unwrap();
assert!(alice.get("hand").is_some());
assert!(bob.get("hand").is_none());
// The envelope is the platform's job, not the view's.
assert!(view.get("id").is_none());
assert!(view.get("join_code").is_none());
assert!(view.get("game_type").is_none());
}
#[test]
fn lobby_view() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap();
let lobby = engine.lobby_view(&session);
assert_eq!(json!("lobby"), lobby["phase"]);
assert_eq!(json!(16), lobby["target_score"]);
assert_eq!(json!(true), lobby["napola"]);
assert_eq!(json!(false), lobby["asso_pigliatutto"]);
}
// -- serialization ---------------------------------------------------------
#[test]
fn state_roundtrip() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
let state_value = engine
.state_to_json(session.state.as_ref().unwrap().as_ref())
.unwrap();
let restored = engine.state_from_json(&state_value).unwrap();
let restored = restored.downcast_ref::<ScoponeState>().unwrap();
assert_eq!(state(&session).phase, restored.phase);
assert_eq!(state(&session).turn, restored.turn);
assert_eq!(
state(&session)
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>(),
restored
.players
.iter()
.map(|p| p.sub.clone())
.collect::<Vec<_>>()
);
}
// -- deadlines -------------------------------------------------------------
#[test]
fn no_deadline_in_lobby() {
let engine = ScoponeEngine::default();
let session = make_session(&engine, empty()).unwrap();
assert!(engine.next_deadline(&session).unwrap().is_none());
}
#[test]
fn turn_deadline_and_revalidation() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("turn", deadline.kind);
// A forged token is stale.
assert!(engine
.fire_deadline(&mut session, "turn", "turn:1:1:0")
.is_err());
let turn_before = state(&session).turn;
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
assert_ne!(turn_before, state(&session).turn);
}
#[test]
fn stale_deadline_after_play() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
let deadline = engine.next_deadline(&session).unwrap().unwrap();
// A play lands in time: the armed deadline is overtaken.
let (sub, payload) = {
let st = state(&session);
let player = st.players.iter().find(|p| p.seat == st.turn).unwrap();
let played = player.hand[0].clone();
let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto);
let mut payload = Map::new();
payload.insert("card".to_string(), json!(played.code()));
if let Some(option) = options.first() {
payload.insert(
"capture".to_string(),
json!(option.iter().map(Card::code).collect::<Vec<_>>()),
);
}
(player.sub.clone(), payload)
};
engine
.handle_action(&mut session, &sub, "play", &payload)
.unwrap();
assert!(engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.is_err());
}
#[test]
fn hand_end_deadline_advances() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_HAND_END.to_string();
st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso(
tavolo_platform::datetime::now() + chrono::Duration::seconds(60),
));
}
let deadline = engine.next_deadline(&session).unwrap().unwrap();
assert_eq!("hand_end", deadline.kind);
engine
.fire_deadline(&mut session, &deadline.kind, &deadline.token)
.unwrap();
let st = state(&session);
assert_eq!(PHASE_PLAYING, st.phase);
assert_eq!(2, st.hand_number);
}
// -- results ---------------------------------------------------------------
#[test]
fn result_of_finished_match() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, opts(json!({"target_score": 1})));
drive_to_finish(&engine, &mut session);
let result = engine.result(&session).unwrap();
assert_eq!(2, result.teams.len());
assert!(result.winner_team == 0 || result.winner_team == 1);
assert_eq!(4, result.players.len());
let winner_name = if result.winner_team == 0 { "A" } else { "B" };
for player in &result.players {
assert_eq!(player.won, player.team.as_deref() == Some(winner_name));
}
assert!(result.summary.get("team_a_score").is_some());
assert!(result.summary.get("hands_played").is_some());
}
#[test]
fn result_requires_finished_match() {
let engine = ScoponeEngine::default();
let session = started(&engine, empty());
assert!(engine.result(&session).is_err());
}
#[test]
fn game_over_view() {
let engine = ScoponeEngine::default();
let mut session = started(&engine, empty());
{
let st = state_mut(&mut session);
st.phase = PHASE_FINISHED.to_string();
st.winner = Some(1);
st.scores = vec![3, 11];
}
let over = engine.game_over_view(&session, "alice");
assert_eq!(json!({"A": 3, "B": 11}), over["scores"]);
assert_eq!(json!("B"), over["winner"]);
}
#[test]
fn registry_metadata() {
let engine = ScoponeEngine::default();
assert_eq!("scopone_scientifico", engine.id());
assert_eq!(4, engine.min_players());
assert_eq!(4, engine.max_players());
let schema = engine.options_schema();
assert!(schema["properties"]["target_score"].is_object());
assert!(schema["properties"]["napola"].is_object());
assert!(schema["properties"]["asso_pigliatutto"].is_object());
// Byte-compatible option schema: descriptions and defaults match Python.
assert_eq!(
json!("Score the napola rule; a full denari sweep wins the match"),
schema["properties"]["napola"]["description"]
);
assert_eq!(
json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"),
schema["properties"]["asso_pigliatutto"]["description"]
);
}