Files
tavolo/server/crates/tavolo-scopone/tests/engine.rs
T
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
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.
2026-10-02 13:54:55 +00:00

1311 lines
38 KiB
Rust

//! Rule-engine tests: captures, scope, scoring and full-match simulation.
//!
//! Ported from `tavolo-scopone/tests/test_engine.py`.
use std::collections::HashSet;
use rand::rngs::StdRng;
use rand::SeedableRng;
use serde_json::{json, Value};
use tavolo_scopone::engine::{self, HAND_SIZE, PLAYERS};
use tavolo_scopone::state::{
Card, Move, PlayerState, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING,
};
fn card(code: &str) -> Card {
Card::parse(code).unwrap()
}
fn codes(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
fn view_codes(options: &[Vec<Card>]) -> Vec<Vec<String>> {
options
.iter()
.map(|option| option.iter().map(Card::code).collect())
.collect()
}
fn make_state(
hands: &[&[&str]],
table: &[&str],
turn: i64,
captured: Option<&[&[&str]]>,
scope: Option<&[i64]>,
target: i64,
last_taker: Option<i64>,
) -> ScoponeState {
let mut state = ScoponeState {
target_score: target,
phase: PHASE_PLAYING.to_string(),
turn,
last_taker,
..Default::default()
};
for (seat, hand) in hands.iter().enumerate() {
state.players.push(PlayerState {
sub: format!("p{seat}"),
name: format!("p{seat}"),
seat: seat as i64,
hand: hand.iter().map(|c| card(c)).collect(),
..Default::default()
});
}
if let Some(captured) = captured {
for (player, pile) in state.players.iter_mut().zip(captured.iter()) {
player.captured = pile.iter().map(|c| card(c)).collect();
}
}
if let Some(scope) = scope {
for (player, value) in state.players.iter_mut().zip(scope.iter()) {
player.scope = *value;
}
}
state.table = table.iter().map(|c| card(c)).collect();
state
}
// -- deck ------------------------------------------------------------------
#[test]
fn full_deck_has_40_unique_cards() {
let deck = engine::full_deck();
assert_eq!(40, deck.len());
let unique: HashSet<String> = deck.iter().map(Card::code).collect();
assert_eq!(40, unique.len());
let suits: HashSet<char> = deck.iter().map(|c| c.suit).collect();
assert_eq!(4, suits.len());
assert_eq!(4, deck.iter().filter(|c| c.rank == 7).count());
}
#[test]
fn shuffled_deck_is_permutation() {
let mut rng = StdRng::seed_from_u64(7);
let deck = engine::shuffled_deck(&mut rng);
let mut expected: Vec<String> = engine::full_deck().iter().map(Card::code).collect();
let mut actual: Vec<String> = deck.iter().map(Card::code).collect();
expected.sort();
actual.sort();
assert_eq!(expected, actual);
}
// -- captures --------------------------------------------------------------
#[test]
fn equal_card_is_mandatory() {
let table = [card("05C"), card("02D"), card("03S")];
let options = engine::legal_captures(&table, card("05D"), false);
assert_eq!(vec![vec!["05C".to_string()]], view_codes(&options));
}
#[test]
fn sum_combination() {
let table = [card("01C"), card("03C"), card("02S")];
let options = engine::legal_captures(&table, card("04D"), false);
assert_eq!(
vec![vec!["01C".to_string(), "03C".to_string()]],
view_codes(&options)
);
}
#[test]
fn multiple_equal_cards_each_a_separate_option() {
let table = [card("05C"), card("05S")];
let mut options = view_codes(&engine::legal_captures(&table, card("05D"), false));
options.sort();
assert_eq!(
vec![vec!["05C".to_string()], vec!["05S".to_string()]],
options
);
}
#[test]
fn no_capture() {
let table = [card("09C"), card("08S")];
assert!(engine::legal_captures(&table, card("02D"), false).is_empty());
}
#[test]
fn play_without_capture_places_card_on_table() {
let mut state = make_state(
&[&["02D", "03D"], &["01C"], &["01S"], &["01B"]],
&["09C"],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "02D", None).unwrap();
let table: Vec<String> = state.table.iter().map(Card::code).collect();
assert!(table.contains(&"02D".to_string()));
let hand: Vec<String> = state.players[0].hand.iter().map(Card::code).collect();
assert!(!hand.contains(&"02D".to_string()));
assert_eq!(1, state.turn);
}
#[test]
fn play_capture_and_scopa() {
let mut state = make_state(
&[&["02D", "09C"], &["01C"], &["01S"], &["01B"]],
&["02C"],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap();
assert_eq!(1, state.players[0].scope);
assert!(state.table.is_empty());
let captured: Vec<String> = state.players[0].captured.iter().map(Card::code).collect();
assert_eq!(vec!["02C".to_string(), "02D".to_string()], captured);
let last_move = state.last_move.as_ref().expect("move recorded");
assert_eq!(0, last_move.seat);
assert_eq!("p0", last_move.name);
assert_eq!("02D", last_move.card);
assert_eq!(vec!["02C".to_string()], last_move.captured);
assert!(last_move.scopa);
}
#[test]
fn play_without_capture_records_move() {
let mut state = make_state(
&[&["02D", "03D"], &["01C"], &["01S"], &["01B"]],
&["09C"],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "02D", None).unwrap();
let last_move = state.last_move.as_ref().expect("move recorded");
assert_eq!("02D", last_move.card);
assert!(last_move.captured.is_empty());
assert!(!last_move.scopa);
}
#[test]
fn illegal_combination_when_equal_card_present() {
let mut state = make_state(
&[&["05D"], &["01C"], &["01S"], &["01B"]],
&["05C", "02D", "03S"],
0,
None,
None,
11,
None,
);
assert!(engine::play(&mut state, "p0", "05D", Some(&codes(&["02D", "03S"]))).is_err());
}
#[test]
fn illegal_capture_rejected() {
let mut state = make_state(
&[&["04D"], &["01C"], &["01S"], &["01B"]],
&["02C", "03S"],
0,
None,
None,
11,
None,
);
assert!(engine::play(&mut state, "p0", "04D", Some(&codes(&["02C"]))).is_err());
}
#[test]
fn no_capture_requested_when_capture_possible() {
let mut state = make_state(
&[&["05D"], &["01C"], &["01S"], &["01B"]],
&["05C"],
0,
None,
None,
11,
None,
);
assert!(engine::play(&mut state, "p0", "05D", None).is_err());
}
#[test]
fn not_your_turn() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&[],
1,
None,
None,
11,
None,
);
assert!(engine::play(&mut state, "p0", "02D", None).is_err());
}
#[test]
fn card_not_in_hand() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&[],
0,
None,
None,
11,
None,
);
let error = engine::play(&mut state, "p0", "07D", None).unwrap_err();
assert_eq!(
"card 07D is not in your hand",
error.as_game().unwrap().message
);
}
#[test]
fn finished_game_rejects_moves() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&[],
0,
None,
None,
11,
None,
);
state.phase = PHASE_FINISHED.to_string();
assert!(engine::play(&mut state, "p0", "02D", None).is_err());
}
// -- last play / sweeping --------------------------------------------------
#[test]
fn no_scopa_on_last_play_of_hand() {
let mut state = make_state(&[&["02D"], &[], &[], &[]], &["02C"], 0, None, None, 2, None);
engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap();
assert_eq!(PHASE_FINISHED, state.phase);
assert_eq!(Some(0), state.winner);
let summary = state.hand_scores.last().unwrap();
assert_eq!(0, summary["scope"]["A"]);
}
#[test]
fn table_swept_to_last_taker() {
let mut state = make_state(
&[&["02D"], &[], &[], &[]],
&["05C", "04D"],
0,
None,
None,
2,
Some(1),
);
engine::play(&mut state, "p0", "02D", None).unwrap();
let captured: HashSet<String> = state.players[1].captured.iter().map(Card::code).collect();
let expected: HashSet<String> = ["05C", "04D", "02D"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(expected, captured);
assert_eq!(PHASE_FINISHED, state.phase);
assert_eq!(Some(1), state.winner);
}
// -- scoring ---------------------------------------------------------------
#[test]
fn primiera_values_and_all_suits_requirement() {
assert_eq!(
70,
engine::primiera_score(&["07D", "06C", "01S", "05B"].map(card))
);
assert_eq!(0, engine::primiera_score(&["07D", "06C", "01S"].map(card)));
assert_eq!(
40,
engine::primiera_score(&["08D", "09C", "10S", "10B"].map(card))
);
}
#[test]
fn hand_points_carte_denara_settebello_primiera_scope() {
let state = make_state(
&[&[], &[], &[], &[]],
&[],
0,
Some(&[
&["07D", "06C", "01S", "05B"],
&["03D", "04C", "07S", "02B"],
&["02D"],
&["10D", "10C", "10S", "10B"],
]),
Some(&[1, 0, 0, 2]),
11,
None,
);
let (points, details) = engine::hand_points(&state);
assert_eq!(vec![3, 3], points);
assert_eq!(json!({"A": 5, "B": 8}), details["cards"]);
assert_eq!(json!({"A": 2, "B": 2}), details["denara"]);
assert_eq!(json!({"A": true, "B": false}), details["settebello"]);
assert_eq!(json!({"A": 70, "B": 60}), details["primiera"]);
assert_eq!(json!({"A": 1, "B": 2}), details["scope"]);
}
#[test]
fn ties_award_nothing() {
let state = make_state(
&[&[], &[], &[], &[]],
&[],
0,
Some(&[
&["06C", "01S", "05B", "02D"],
&["06S", "01B", "05D", "02C"],
&[],
&[],
]),
Some(&[0, 0, 0, 0]),
11,
None,
);
let (points, _) = engine::hand_points(&state);
assert_eq!(vec![0, 0], points);
}
// -- napola ----------------------------------------------------------------
#[test]
fn napola_score_runs() {
assert_eq!(0, engine::napola_score(&["02D", "03D", "04D"].map(card)));
assert_eq!(0, engine::napola_score(&["01D", "02D"].map(card)));
assert_eq!(3, engine::napola_score(&["03D", "01D", "02D"].map(card)));
assert_eq!(
4,
engine::napola_score(&["01D", "02D", "03D", "04D", "07C"].map(card))
);
assert_eq!(
3,
engine::napola_score(&["01D", "02D", "03D", "05D"].map(card))
);
let full: Vec<Card> = (1..=10).map(|rank| card(&format!("{rank:02}D"))).collect();
assert_eq!(10, engine::napola_score(&full));
}
#[test]
fn hand_points_napola() {
let state = make_state(
&[&[], &[], &[], &[]],
&[],
0,
Some(&[
&["01D", "02D", "03D", "04C"],
&["05D", "06D", "07D", "08D"],
&["09D", "10D", "01C", "02C"],
&["03C", "05C", "06C", "07C"],
]),
None,
11,
None,
);
let (points, details) = engine::hand_points(&state);
assert_eq!(json!({"A": 3, "B": 0}), details["napola"]);
assert_eq!(json!("A"), details["award"]["napola"]);
assert_eq!(vec![4, 1], points);
}
#[test]
fn napola_disabled() {
let mut state = make_state(
&[&[], &[], &[], &[]],
&[],
0,
Some(&[
&["01D", "02D", "03D", "04C"],
&["05D", "06D", "07D", "08D"],
&["09D", "10D", "01C", "02C"],
&["03C", "05C", "06C", "07C"],
]),
None,
11,
None,
);
state.napola = false;
let (points, details) = engine::hand_points(&state);
assert!(details.get("napola").is_none());
assert_eq!(vec![1, 1], points);
}
#[test]
fn full_denari_sweep_wins_match_instantly() {
let mut state = make_state(
&[&["02C"], &[], &[], &[]],
&[],
0,
Some(&[
&[
"01D", "02D", "03D", "04D", "05D", "06D", "07D", "08D", "09D", "10D",
],
&[],
&[],
&[],
]),
None,
100,
None,
);
state.scores = vec![0, 50];
engine::play(&mut state, "p0", "02C", None).unwrap();
assert_eq!(PHASE_FINISHED, state.phase);
assert_eq!(Some(0), state.winner);
assert!(state.scores[0] < 100);
assert_eq!(10, state.hand_scores.last().unwrap()["napola"]["A"]);
}
#[test]
fn napola_serialization_roundtrip() {
let mut state = make_state(&[&["02D"], &[], &[], &[]], &[], 0, None, None, 11, None);
assert!(state.napola);
state.napola = false;
assert!(!ScoponeState::from_json(&state.to_json()).unwrap().napola);
// States serialized before the option existed default to enabled.
let mut data = state.to_json();
data.as_object_mut().unwrap().remove("napola");
assert!(ScoponeState::from_json(&data).unwrap().napola);
}
#[test]
fn create_game_napola_default_and_override() {
assert!(
engine::create_game("p0", "p0", 11, 30, 30, true, false)
.unwrap()
.napola
);
assert!(
!engine::create_game("p0", "p0", 11, 30, 30, false, false)
.unwrap()
.napola
);
}
// -- asso pigliatutto ------------------------------------------------------
#[test]
fn ace_sweeps_the_whole_table_without_scopa() {
let mut state = make_state(
&[&["01D", "09C"], &["04D"], &["05D"], &["06D"]],
&["05C", "02D", "03S"],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
engine::play(
&mut state,
"p0",
"01D",
Some(&codes(&["05C", "02D", "03S"])),
)
.unwrap();
assert!(state.table.is_empty());
let captured: HashSet<String> = state.players[0].captured.iter().map(Card::code).collect();
let expected: HashSet<String> = ["05C", "02D", "03S", "01D"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(expected, captured);
assert_eq!(0, state.players[0].scope);
let last_move = state.last_move.as_ref().unwrap();
assert!(last_move.pigliatutto);
assert!(!last_move.scopa);
}
#[test]
fn legal_captures_offer_only_the_whole_table() {
let table = [card("05C"), card("02D"), card("03S")];
assert_eq!(
vec![vec![
"05C".to_string(),
"02D".to_string(),
"03S".to_string()
]],
view_codes(&engine::legal_captures(&table, card("01D"), true))
);
assert_eq!(
vec![Vec::<String>::new()],
view_codes(&engine::legal_captures(&[], card("01D"), true))
);
assert!(engine::legal_captures(&[], card("01D"), false).is_empty());
assert!(engine::legal_captures(&table, card("04B"), true).is_empty());
}
#[test]
fn equal_rank_ace_blocks_the_sweep() {
let table = ["01C", "02D"];
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&table,
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
assert_eq!(
vec![vec!["01C".to_string()]],
view_codes(&engine::legal_captures(&state.table, card("01D"), true))
);
assert!(engine::play(&mut state, "p0", "01D", Some(&codes(&["01C", "02D"]))).is_err());
// A rejected play consumes the card, so retry on a fresh state.
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&table,
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
engine::play(&mut state, "p0", "01D", Some(&codes(&["01C"]))).unwrap();
let table: Vec<String> = state.table.iter().map(Card::code).collect();
assert_eq!(vec!["02D".to_string()], table);
assert!(!state.last_move.as_ref().unwrap().pigliatutto);
}
#[test]
fn disabled_by_default() {
let mut state = make_state(
&[&["01D", "09C"], &["04D"], &["05D"], &["06D"]],
&["05C", "02D"],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "01D", None).unwrap();
let table: HashSet<String> = state.table.iter().map(Card::code).collect();
let expected: HashSet<String> = ["05C", "02D", "01D"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(expected, table);
assert!(!state.last_move.as_ref().unwrap().pigliatutto);
// ... and on a bare table it stays there too.
let mut state = make_state(
&[&["01D", "09C"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "01D", None).unwrap();
let table: Vec<String> = state.table.iter().map(Card::code).collect();
assert_eq!(vec!["01D".to_string()], table);
assert!(state.players[0].captured.is_empty());
assert!(engine::legal_captures(&[card("05C")], card("01D"), false).is_empty());
}
#[test]
fn ace_on_empty_table_captures_itself() {
let mut state = make_state(
&[&["01D", "09C"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
Some(1),
);
state.asso_pigliatutto = true;
engine::play(&mut state, "p0", "01D", None).unwrap();
assert!(state.table.is_empty());
let captured: Vec<String> = state.players[0].captured.iter().map(Card::code).collect();
assert_eq!(vec!["01D".to_string()], captured);
assert_eq!(0, state.players[0].scope);
assert_eq!(Some(0), state.last_taker);
let last_move = state.last_move.as_ref().unwrap();
assert!(last_move.pigliatutto);
assert!(last_move.captured.is_empty());
assert!(!last_move.scopa);
}
#[test]
fn non_ace_on_empty_table_stays() {
let mut state = make_state(
&[&["02D", "09C"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
engine::play(&mut state, "p0", "02D", None).unwrap();
let table: Vec<String> = state.table.iter().map(Card::code).collect();
assert_eq!(vec!["02D".to_string()], table);
assert!(state.players[0].captured.is_empty());
assert!(!state.last_move.as_ref().unwrap().pigliatutto);
}
#[test]
fn move_pigliatutto_serialization() {
let move_ = Move {
seat: 0,
name: "p0".to_string(),
card: "01D".to_string(),
captured: vec!["05C".to_string()],
pigliatutto: true,
..Default::default()
};
let restored = Move::from_json(&move_.to_json());
assert!(restored.pigliatutto);
let mut legacy = move_.to_json();
legacy.as_object_mut().unwrap().remove("pigliatutto");
assert!(!Move::from_json(&legacy).pigliatutto);
}
#[test]
fn state_serialization_roundtrip() {
let mut state = make_state(&[&["02D"], &[], &[], &[]], &[], 0, None, None, 11, None);
assert!(!state.asso_pigliatutto);
state.asso_pigliatutto = true;
assert!(
ScoponeState::from_json(&state.to_json())
.unwrap()
.asso_pigliatutto
);
let mut data = state.to_json();
data.as_object_mut().unwrap().remove("asso_pigliatutto");
assert!(!ScoponeState::from_json(&data).unwrap().asso_pigliatutto);
}
#[test]
fn create_game_default_and_override() {
assert!(
!engine::create_game("p0", "p0", 11, 30, 30, true, false)
.unwrap()
.asso_pigliatutto
);
assert!(
engine::create_game("p0", "p0", 11, 30, 30, true, true)
.unwrap()
.asso_pigliatutto
);
}
#[test]
fn state_for_player_exposes_rule_and_legal_moves() {
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&["05C", "02D", "03S"],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
let view = engine::state_for_player(&state, "p0");
assert_eq!(json!(true), view["asso_pigliatutto"]);
assert_eq!(json!({"01D": [["05C", "02D", "03S"]]}), view["legal_moves"]);
// On a bare table the ace advertises the self-capture.
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
let view = engine::state_for_player(&state, "p0");
assert_eq!(json!({"01D": [[]]}), view["legal_moves"]);
}
#[test]
fn auto_play_sweeps_with_the_rule() {
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&["05C", "02D"],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
let mut rng = StdRng::seed_from_u64(1);
engine::auto_play(&mut state, &mut rng).unwrap();
let last_move = state.last_move.as_ref().unwrap();
assert!(last_move.pigliatutto);
assert_eq!(
vec!["05C".to_string(), "02D".to_string()],
last_move.captured
);
}
#[test]
fn auto_play_self_captures_on_bare_table() {
let mut state = make_state(
&[&["01D"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
None,
);
state.asso_pigliatutto = true;
let mut rng = StdRng::seed_from_u64(2);
engine::auto_play(&mut state, &mut rng).unwrap();
let last_move = state.last_move.as_ref().unwrap();
assert!(last_move.pigliatutto);
let captured: Vec<String> = state.players[0].captured.iter().map(Card::code).collect();
assert_eq!(vec!["01D".to_string()], captured);
assert!(state.table.is_empty());
}
#[test]
fn full_random_match_with_rule_keeps_all_cards() {
let mut rng = StdRng::seed_from_u64(2024);
let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, true).unwrap();
for i in 1..4 {
let name = format!("p{i}");
engine::join_game(&mut state, &name, &name, &mut rng).unwrap();
}
let mut moves = 0;
while state.phase != PHASE_FINISHED && moves < 200_000 {
if state.phase == PHASE_HAND_END {
let subs: Vec<String> = state.players.iter().map(|p| p.sub.clone()).collect();
for sub in subs {
engine::acknowledge_hand(&mut state, &sub, &mut rng).unwrap();
}
continue;
}
let index = state
.players
.iter()
.position(|p| p.seat == state.turn)
.unwrap();
let played = state.players[index].hand[0].clone();
let options = engine::legal_captures(&state.table, played.clone(), state.asso_pigliatutto);
let capture: Option<Vec<String>> = if options.is_empty() {
None
} else {
Some(options[0].iter().map(Card::code).collect())
};
let sub = state.players[index].sub.clone();
engine::play(&mut state, &sub, &played.code(), capture.as_deref()).unwrap();
moves += 1;
}
assert_eq!(PHASE_FINISHED, state.phase);
assert!(state.winner == Some(0) || state.winner == Some(1));
// Self-captures included, all 40 cards end up in capture piles.
assert!(state.table.is_empty());
assert!(state.players.iter().all(|p| p.hand.is_empty()));
assert_eq!(
40,
state
.players
.iter()
.map(|p| p.captured.len())
.sum::<usize>()
);
}
// -- match flow ------------------------------------------------------------
#[test]
fn join_starts_when_full() {
let mut rng = StdRng::seed_from_u64(3);
let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, false).unwrap();
assert_eq!(1, state.players.len());
engine::join_game(&mut state, "p1", "p1", &mut rng).unwrap();
engine::join_game(&mut state, "p2", "p2", &mut rng).unwrap();
assert_eq!("lobby", state.phase);
engine::join_game(&mut state, "p3", "p3", &mut rng).unwrap();
assert_eq!(PHASE_PLAYING, state.phase);
assert_eq!(PLAYERS, state.players.len());
for player in &state.players {
assert_eq!(HAND_SIZE, player.hand.len());
}
assert!(state.table.is_empty());
assert_eq!(1, state.turn); // dealer is seat 0
}
#[test]
fn match_ends_when_target_reached() {
let mut state = make_state(&[&["02D"], &[], &[], &[]], &["02C"], 0, None, None, 1, None);
engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap();
assert_eq!(PHASE_FINISHED, state.phase);
assert_eq!(Some(0), state.winner);
assert!(state.scores[0] >= 1);
}
#[test]
fn state_for_player_hides_other_hands() {
let state = make_state(
&[&["02D", "03C"], &["04D"], &["05D"], &["06D"]],
&["07C"],
0,
None,
None,
11,
None,
);
let view = engine::state_for_player(&state, "p0");
let players = view["players"].as_array().unwrap();
let by_seat = |seat: i64| players.iter().find(|p| p["seat"] == seat).unwrap();
assert_eq!(json!(["02D", "03C"]), by_seat(0)["hand"]);
assert!(by_seat(1).get("hand").is_none());
assert_eq!(json!(1), by_seat(1)["cards_left"]);
assert_eq!(json!(["07C"]), view["table"]);
assert_eq!(json!(true), view["your_turn"]);
}
#[test]
fn legal_moves_only_for_player_on_turn() {
let state = make_state(
&[&["02D", "03C"], &["04D"], &["05D"], &["06D"]],
&["07C"],
0,
None,
None,
11,
None,
);
let view = engine::state_for_player(&state, "p0");
assert_eq!(json!({}), view["legal_moves"]);
let state = make_state(
&[&["09D"], &["04D"], &["05D"], &["06D"]],
&["07C", "02S"],
0,
None,
None,
11,
None,
);
let view = engine::state_for_player(&state, "p0");
assert_eq!(json!({"09D": [["07C", "02S"]]}), view["legal_moves"]);
// A player who is not on turn gets no legal_moves key.
let other = engine::state_for_player(&state, "p1");
assert!(other.get("legal_moves").is_none());
assert!(other.get("your_turn").is_none());
}
#[test]
fn full_random_match_reaches_completion() {
let mut rng = StdRng::seed_from_u64(99);
let mut state = engine::create_game("p0", "p0", 11, 30, 30, true, false).unwrap();
for i in 1..4 {
let name = format!("p{i}");
engine::join_game(&mut state, &name, &name, &mut rng).unwrap();
}
let mut moves = 0;
while state.phase != PHASE_FINISHED && moves < 200_000 {
if state.phase == PHASE_HAND_END {
let subs: Vec<String> = state.players.iter().map(|p| p.sub.clone()).collect();
for sub in subs {
engine::acknowledge_hand(&mut state, &sub, &mut rng).unwrap();
}
continue;
}
let index = state
.players
.iter()
.position(|p| p.seat == state.turn)
.unwrap();
let played = state.players[index].hand[0].clone();
let options = engine::legal_captures(&state.table, played.clone(), false);
let capture: Option<Vec<String>> = if options.is_empty() {
None
} else {
Some(options[0].iter().map(Card::code).collect())
};
let sub = state.players[index].sub.clone();
engine::play(&mut state, &sub, &played.code(), capture.as_deref()).unwrap();
moves += 1;
}
assert_eq!(PHASE_FINISHED, state.phase);
assert!(state.winner == Some(0) || state.winner == Some(1));
assert!(state.table.is_empty());
assert!(state.players.iter().all(|p| p.hand.is_empty()));
assert_eq!(
40,
state
.players
.iter()
.map(|p| p.captured.len())
.sum::<usize>()
);
}
// -- hand-end acknowledgement ---------------------------------------------
fn hand_end_state() -> ScoponeState {
let mut state = make_state(
&[&["02D"], &[], &[], &[]],
&["02C"],
0,
None,
None,
11,
None,
);
engine::play(&mut state, "p0", "02D", Some(&codes(&["02C"]))).unwrap();
state
}
#[test]
fn end_of_hand_pauses_for_acknowledgement() {
let state = hand_end_state();
assert_eq!("hand_end", state.phase);
assert!(state.acked.is_empty());
assert_eq!(1, state.hand_number);
assert!(state.hand_end_deadline.is_some());
let captured: Vec<String> = state.players[0].captured.iter().map(Card::code).collect();
assert_eq!(vec!["02C".to_string(), "02D".to_string()], captured);
let summary = state.hand_scores.last().unwrap();
assert_eq!(1, summary["hand"]);
assert!(summary.get("award").is_some());
}
#[test]
fn play_during_hand_end_is_rejected() {
let mut state = hand_end_state();
assert!(engine::play(&mut state, "p0", "02D", None).is_err());
}
#[test]
fn ack_all_four_deals_next_hand() {
let mut rng = StdRng::seed_from_u64(5);
let mut state = hand_end_state();
let dealer_before = state.dealer;
for (i, sub) in ["p0", "p1", "p2"].iter().enumerate() {
engine::acknowledge_hand(&mut state, sub, &mut rng).unwrap();
assert_eq!((0..=i as i64).collect::<Vec<_>>(), state.acked);
assert_eq!("hand_end", state.phase);
}
engine::acknowledge_hand(&mut state, "p3", &mut rng).unwrap();
assert_eq!("playing", state.phase);
assert_eq!(2, state.hand_number);
assert_eq!((dealer_before + 1) % 4, state.dealer);
assert!(state.acked.is_empty());
assert!(state.hand_end_deadline.is_none());
assert!(state.last_move.is_none());
for player in &state.players {
assert_eq!(HAND_SIZE, player.hand.len());
assert!(player.captured.is_empty());
}
assert_eq!((dealer_before + 2) % 4, state.turn);
}
#[test]
fn double_ack_is_idempotent() {
let mut rng = StdRng::seed_from_u64(6);
let mut state = hand_end_state();
engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap();
engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap();
assert_eq!(vec![0], state.acked);
}
#[test]
fn ack_outside_hand_end_is_rejected() {
let mut rng = StdRng::seed_from_u64(7);
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&[],
0,
None,
None,
11,
None,
);
let error = engine::acknowledge_hand(&mut state, "p0", &mut rng).unwrap_err();
assert_eq!(
"no hand summary is waiting for acknowledgement",
error.as_game().unwrap().message
);
}
#[test]
fn ack_by_non_player_is_rejected() {
let mut rng = StdRng::seed_from_u64(8);
let mut state = hand_end_state();
let error = engine::acknowledge_hand(&mut state, "mallory", &mut rng).unwrap_err();
assert_eq!(
"you are not seated in this game",
error.as_game().unwrap().message
);
}
#[test]
fn state_exposes_ack_progress() {
let mut rng = StdRng::seed_from_u64(9);
let mut state = hand_end_state();
engine::acknowledge_hand(&mut state, "p1", &mut rng).unwrap();
let view = engine::state_for_player(&state, "p0");
assert_eq!(json!([1]), view["acknowledged"]);
assert!(view["hand_end_deadline"].is_string());
assert!(view["last_hand"].is_object());
assert!(view["last_hand"].get("award").is_some());
}
// -- auto play -------------------------------------------------------------
#[test]
fn auto_play_plays_a_card_and_advances_turn() {
let mut rng = StdRng::seed_from_u64(11);
let mut state = make_state(
&[&["02D", "03C"], &["04D"], &["05D"], &["06D"]],
&["09B"],
0,
None,
None,
11,
None,
);
state.turn_deadline = Some("2000-01-01T00:00:00+00:00".to_string());
engine::auto_play(&mut state, &mut rng).unwrap();
assert_eq!(1, state.turn);
assert_eq!(1, state.players[0].hand.len());
// The played card could not capture the nine, so the table grew.
assert_eq!(2, state.table.len());
let last_move = state.last_move.as_ref().unwrap();
assert_eq!(0, last_move.seat);
assert_ne!(
"2000-01-01T00:00:00+00:00",
state.turn_deadline.as_ref().unwrap()
);
}
#[test]
fn auto_play_takes_a_mandatory_capture() {
let mut rng = StdRng::seed_from_u64(12);
let mut state = make_state(
&[&["05D"], &["04D"], &["05D"], &["06D"]],
&["05C", "09B"],
0,
None,
None,
11,
None,
);
engine::auto_play(&mut state, &mut rng).unwrap();
let last_move = state.last_move.as_ref().unwrap();
assert_eq!("05D", last_move.card);
assert_eq!(vec!["05C".to_string()], last_move.captured);
let table: Vec<String> = state.table.iter().map(Card::code).collect();
assert_eq!(vec!["09B".to_string()], table);
let captured: Vec<String> = state.players[0].captured.iter().map(Card::code).collect();
assert_eq!(vec!["05C".to_string(), "05D".to_string()], captured);
}
#[test]
fn auto_play_can_end_the_hand_and_clears_deadline() {
let mut rng = StdRng::seed_from_u64(13);
let mut state = make_state(
&[&["02D"], &[], &[], &[]],
&["02C"],
0,
None,
None,
11,
None,
);
state.turn_deadline = Some("2000-01-01T00:00:00+00:00".to_string());
engine::auto_play(&mut state, &mut rng).unwrap();
assert_eq!("hand_end", state.phase);
assert!(state.turn_deadline.is_none());
assert!(state.hand_end_deadline.is_some());
}
#[test]
fn auto_play_requires_playing_phase() {
let mut rng = StdRng::seed_from_u64(14);
let mut state = make_state(
&[&["02D"], &["04D"], &["05D"], &["06D"]],
&[],
0,
None,
None,
11,
None,
);
state.phase = "hand_end".to_string();
assert!(engine::auto_play(&mut state, &mut rng).is_err());
}
#[test]
fn create_game_copies_turn_timeout_and_arms_deadline() {
let mut rng = StdRng::seed_from_u64(15);
let mut state = engine::create_game("p0", "p0", 11, 30, 7, true, false).unwrap();
assert_eq!(7, state.turn_timeout);
for i in 1..4 {
let name = format!("p{i}");
engine::join_game(&mut state, &name, &name, &mut rng).unwrap();
}
assert_eq!(PHASE_PLAYING, state.phase);
assert!(state.turn_deadline.is_some());
let view = engine::state_for_player(&state, "p0");
assert!(view["turn_deadline"].is_string());
}
// -- JSON shape ------------------------------------------------------------
#[test]
fn state_json_matches_python_keys() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&["07C"],
0,
Some(&[&["07D"], &[], &[], &[]]),
Some(&[1, 0, 0, 0]),
11,
Some(1),
);
state.last_move = Some(Move {
seat: 0,
name: "p0".to_string(),
card: "02D".to_string(),
captured: vec!["02C".to_string()],
scopa: false,
pigliatutto: false,
});
state.last_taker = Some(0);
state.hand_number = 2;
state.winner = Some(1);
state.acked = vec![0, 2];
state.hand_end_deadline = Some("2026-01-01T00:00:30+00:00".to_string());
state.turn_deadline = Some("2026-01-01T00:00:00+00:00".to_string());
let value = state.to_json();
let obj = value.as_object().unwrap();
let keys: Vec<&str> = obj.keys().map(String::as_str).collect();
assert_eq!(
vec![
"target_score",
"napola",
"asso_pigliatutto",
"phase",
"players",
"table",
"dealer",
"turn",
"hand_number",
"scores",
"winner",
"last_taker",
"hand_scores",
"last_move",
"acked",
"hand_end_deadline",
"hand_ack_timeout",
"turn_deadline",
"turn_timeout",
],
keys
);
let player = obj["players"][0].as_object().unwrap();
let player_keys: Vec<&str> = player.keys().map(String::as_str).collect();
assert_eq!(
vec!["sub", "name", "seat", "hand", "captured", "scope"],
player_keys
);
assert_eq!(json!("07D"), player["captured"][0]);
let move_keys: Vec<&str> = obj["last_move"]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
vec!["seat", "name", "card", "captured", "scopa", "pigliatutto"],
move_keys
);
assert_eq!(json!([]), obj["hand_scores"]);
}
#[test]
fn state_json_roundtrip_preserves() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&["07C"],
0,
Some(&[&["07D"], &[], &[], &[]]),
Some(&[1, 0, 0, 0]),
11,
Some(1),
);
state.last_move = Some(Move {
seat: 0,
name: "p0".to_string(),
card: "02D".to_string(),
captured: vec!["02C".to_string()],
scopa: true,
pigliatutto: false,
});
state.winner = Some(1);
state.acked = vec![0, 2];
state
.hand_scores
.push(json!({"hand": 1, "team_a_points": 2}));
let restored = ScoponeState::from_json(&state.to_json()).unwrap();
assert_eq!(state.to_json(), restored.to_json());
}
#[test]
fn invalid_card_code_error_message() {
let mut state = make_state(
&[&["02D"], &["03D"], &["04D"], &["05D"]],
&[],
0,
None,
None,
11,
None,
);
let error = engine::play(&mut state, "p0", "nope", None).unwrap_err();
assert_eq!("invalid card code", error.as_game().unwrap().message);
}
#[test]
fn target_score_range_error_message() {
let error = engine::create_game("p0", "p0", 0, 30, 30, true, false).unwrap_err();
assert_eq!(
"target_score must be between 1 and 100",
error.as_game().unwrap().message
);
}
#[test]
fn legal_moves_value_is_json() {
// Sanity: the legal-move map serializes as expected.
let state = make_state(
&[&["02D", "03C"], &["04D"], &["05D"], &["06D"]],
&["07C"],
0,
None,
None,
11,
None,
);
let view: Value = engine::state_for_player(&state, "p0");
assert!(view["legal_moves"].is_object());
}