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:
1 parent
adc3d340b1
commit
7b3b38ccbc
136 files changed
+16821
-11777
No files matched your search
@@ -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 }
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 ¤t {
|
||||
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)
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user