//! 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 { items.iter().map(|s| s.to_string()).collect() } fn view_codes(options: &[Vec]) -> Vec> { 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, ) -> 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 = deck.iter().map(Card::code).collect(); assert_eq!(40, unique.len()); let suits: HashSet = 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 = engine::full_deck().iter().map(Card::code).collect(); let mut actual: Vec = 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 = state.table.iter().map(Card::code).collect(); assert!(table.contains(&"02D".to_string())); let hand: Vec = 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 = 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 = state.players[1].captured.iter().map(Card::code).collect(); let expected: HashSet = ["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 = (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 = state.players[0].captured.iter().map(Card::code).collect(); let expected: HashSet = ["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::::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 = 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 = state.table.iter().map(Card::code).collect(); let expected: HashSet = ["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 = 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 = 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 = 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 = 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 = 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> = 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::() ); } // -- 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 = 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> = 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::() ); } // -- 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 = 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::>(), 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 = state.table.iter().map(Card::code).collect(); assert_eq!(vec!["09B".to_string()], table); let captured: Vec = 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()); }