CI / Build and push docker image (push) Successful in 4m20s
Replace the Python/kaya server in server/ with a Rust Cargo workspace of three crates mirroring the old layout: tavolo-platform (lobby API, live-game store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions), tavolo-scopone (pure scopone scientifico rules plus the platform adapter) and tavolo-app (composition root and the `tavolo` binary with serve/migrate/backfill-elo). The HTTP and WebSocket contracts, error messages, Redis key layout and Postgres schema are unchanged, so the existing web/ frontend and in-flight games keep working. Notable differences: sessions are JSON under tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations replace aerich, and OpenTelemetry exports Rust process metrics instead of CPython gc metrics (no Postgres/Redis client instrumentation). Docker Compose and the Kubernetes manifest now run the single Rust image (embedded migrations, /static and /assets served by the app); the release CI keeps building server/Dockerfile. The full test suite (scopone rules, platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is ported and runs with cargo test --workspace; Redis/Postgres integration tests are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
1311 lines
38 KiB
Rust
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());
|
|
}
|