//! `ScoponeEngine` adapter tests: the platform contract over the pure rules. //! //! Ported from `tavolo-scopone/tests/test_plugin.py`. use serde_json::{json, Map, Value}; use tavolo_platform::engine::GameSession; use tavolo_platform::errors::Error; use tavolo_platform::{GameEngine, Seat}; use tavolo_scopone::engine as rules; use tavolo_scopone::state::{Card, ScoponeState, PHASE_FINISHED, PHASE_HAND_END, PHASE_PLAYING}; use tavolo_scopone::ScoponeEngine; fn opts(value: Value) -> Map { value.as_object().unwrap().clone() } fn empty() -> Map { Map::new() } fn capitalize(name: &str) -> String { let mut chars = name.chars(); match chars.next() { Some(first) => first.to_uppercase().collect::() + chars.as_str(), None => String::new(), } } fn base_session(engine: &ScoponeEngine) -> GameSession { let mut session = GameSession::new("s1", engine.id(), "CODE01"); session.creator_sub = "alice".to_string(); session.players = vec![Seat { user_sub: "alice".to_string(), display_name: "Alice".to_string(), team: None, }]; session } fn make_session(engine: &ScoponeEngine, options: Map) -> Result { let mut session = base_session(engine); engine.create(&mut session, &options)?; Ok(session) } fn started(engine: &ScoponeEngine, options: Map) -> GameSession { let mut session = make_session(engine, options).unwrap(); for name in ["bob", "carol", "dave"] { engine.join(&mut session, name, &capitalize(name)).unwrap(); } session } fn state(session: &GameSession) -> &ScoponeState { session .state .as_ref() .unwrap() .downcast_ref::() .unwrap() } fn state_mut(session: &mut GameSession) -> &mut ScoponeState { session .state .as_mut() .unwrap() .downcast_mut::() .unwrap() } fn err_message(error: &Error) -> String { error .as_game() .map(|game| game.message.clone()) .unwrap_or_default() } /// Extract the error from a failed `make_session` without requiring /// `GameSession: Debug` (which `unwrap_err` would). fn session_error(result: Result) -> Error { match result { Ok(_) => panic!("expected an error"), Err(error) => error, } } /// Play legal moves (and acknowledge hand ends) until the match finishes. fn drive_to_finish(engine: &ScoponeEngine, session: &mut GameSession) -> usize { let mut moves = 0; while !engine.is_finished(session) && moves < 200_000 { let (action, sub, payload): (&str, String, Map) = { let st = state(session); if st.phase == PHASE_HAND_END { let player = st .players .iter() .find(|player| !st.acked.contains(&player.seat)) .unwrap(); ("ack", player.sub.clone(), Map::new()) } else { let player = st .players .iter() .find(|player| player.seat == st.turn) .unwrap(); let played = player.hand[0].clone(); let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto); let mut payload = Map::new(); payload.insert("card".to_string(), json!(played.code())); if let Some(option) = options.first() { payload.insert( "capture".to_string(), json!(option.iter().map(Card::code).collect::>()), ); } ("play", player.sub.clone(), payload) } }; engine .handle_action(session, &sub, action, &payload) .unwrap(); moves += 1; } moves } // -- create ---------------------------------------------------------------- #[test] fn create_seats_creator_on_team_a() { let engine = ScoponeEngine::default(); let session = make_session(&engine, empty()).unwrap(); assert_eq!(Some("A".to_string()), session.players[0].team); let st = state(&session); assert_eq!(11, st.target_score); assert!(st.napola); } #[test] fn create_options() { let engine = ScoponeEngine::default(); let session = make_session(&engine, opts(json!({"target_score": 16, "napola": false}))).unwrap(); let st = state(&session); assert_eq!(16, st.target_score); assert!(!st.napola); assert!(!st.asso_pigliatutto); } #[test] fn create_asso_pigliatutto_option() { let engine = ScoponeEngine::default(); let session = make_session(&engine, opts(json!({"asso_pigliatutto": true}))).unwrap(); assert!(state(&session).asso_pigliatutto); } #[test] fn create_rejects_bad_options() { let engine = ScoponeEngine::default(); assert_eq!( "target_score must be between 1 and 100", err_message(&session_error(make_session( &engine, opts(json!({"target_score": 0})) ))) ); assert_eq!( "target_score must be an integer", err_message(&session_error(make_session( &engine, opts(json!({"target_score": "eleven"})) ))) ); assert_eq!( "napola must be a boolean", err_message(&session_error(make_session( &engine, opts(json!({"napola": "yes"})) ))) ); assert_eq!( "asso_pigliatutto must be a boolean", err_message(&session_error(make_session( &engine, opts(json!({"asso_pigliatutto": "yes"})) ))) ); } #[test] fn timeouts_come_from_the_engine() { let engine = ScoponeEngine::new(7, 9); let session = make_session(&engine, empty()).unwrap(); let st = state(&session); assert_eq!(7, st.turn_timeout); assert_eq!(9, st.hand_ack_timeout); } // -- join ------------------------------------------------------------------ #[test] fn join_assigns_teams_and_starts() { let engine = ScoponeEngine::default(); let mut session = make_session(&engine, empty()).unwrap(); assert!(engine.in_lobby(&session)); for (name, team) in [("bob", "B"), ("carol", "A"), ("dave", "B")] { engine.join(&mut session, name, &capitalize(name)).unwrap(); assert_eq!(Some(team.to_string()), session.players.last().unwrap().team); } assert!(!engine.in_lobby(&session)); assert_eq!(PHASE_PLAYING, state(&session).phase); } #[test] fn join_errors() { let engine = ScoponeEngine::default(); let mut session = make_session(&engine, empty()).unwrap(); assert_eq!( "already joined this game", err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err()) ); for name in ["bob", "carol", "dave"] { engine.join(&mut session, name, &capitalize(name)).unwrap(); } // The lobby filled up and the match started: late joins and even re-joins // are rejected as "already started". assert_eq!( "game has already started", err_message(&engine.join(&mut session, "erin", "Erin").unwrap_err()) ); assert_eq!( "game has already started", err_message(&engine.join(&mut session, "alice", "Alice").unwrap_err()) ); } // -- actions --------------------------------------------------------------- #[test] fn unknown_action_rejected() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); assert!(engine .handle_action(&mut session, "alice", "dance", &Map::new()) .is_err()); } #[test] fn play_validates_payload() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); assert!(engine .handle_action(&mut session, "bob", "play", &opts(json!({"card": 42}))) .is_err()); assert!(engine .handle_action(&mut session, "bob", "play", &Map::new()) .is_err()); assert!(engine .handle_action( &mut session, "bob", "play", &opts(json!({"card": "01D", "capture": "02C"})) ) .is_err()); } #[test] fn invalid_card_code_is_illegal_move() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); let error = engine .handle_action(&mut session, "bob", "play", &opts(json!({"card": "nope"}))) .unwrap_err(); assert_eq!("invalid card code", err_message(&error)); } #[test] fn finished_match_rejects_actions() { let engine = ScoponeEngine::default(); let mut session = started(&engine, opts(json!({"target_score": 1}))); drive_to_finish(&engine, &mut session); assert!(engine.is_finished(&session)); let error = engine .handle_action(&mut session, "alice", "play", &opts(json!({"card": "01D"}))) .unwrap_err(); assert_eq!("the match is over", err_message(&error)); } // -- views ----------------------------------------------------------------- #[test] fn view_for_hides_other_hands() { let engine = ScoponeEngine::default(); let session = started(&engine, empty()); let view = engine.view_for(&session, "alice"); let players = view["players"].as_array().unwrap(); let alice = players.iter().find(|p| p["seat"] == 0).unwrap(); let bob = players.iter().find(|p| p["seat"] == 1).unwrap(); assert!(alice.get("hand").is_some()); assert!(bob.get("hand").is_none()); // The envelope is the platform's job, not the view's. assert!(view.get("id").is_none()); assert!(view.get("join_code").is_none()); assert!(view.get("game_type").is_none()); } #[test] fn lobby_view() { let engine = ScoponeEngine::default(); let session = make_session(&engine, opts(json!({"target_score": 16}))).unwrap(); let lobby = engine.lobby_view(&session); assert_eq!(json!("lobby"), lobby["phase"]); assert_eq!(json!(16), lobby["target_score"]); assert_eq!(json!(true), lobby["napola"]); assert_eq!(json!(false), lobby["asso_pigliatutto"]); } // -- serialization --------------------------------------------------------- #[test] fn state_roundtrip() { let engine = ScoponeEngine::default(); let session = started(&engine, empty()); let state_value = engine .state_to_json(session.state.as_ref().unwrap().as_ref()) .unwrap(); let restored = engine.state_from_json(&state_value).unwrap(); let restored = restored.downcast_ref::().unwrap(); assert_eq!(state(&session).phase, restored.phase); assert_eq!(state(&session).turn, restored.turn); assert_eq!( state(&session) .players .iter() .map(|p| p.sub.clone()) .collect::>(), restored .players .iter() .map(|p| p.sub.clone()) .collect::>() ); } // -- deadlines ------------------------------------------------------------- #[test] fn no_deadline_in_lobby() { let engine = ScoponeEngine::default(); let session = make_session(&engine, empty()).unwrap(); assert!(engine.next_deadline(&session).unwrap().is_none()); } #[test] fn turn_deadline_and_revalidation() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); let deadline = engine.next_deadline(&session).unwrap().unwrap(); assert_eq!("turn", deadline.kind); // A forged token is stale. assert!(engine .fire_deadline(&mut session, "turn", "turn:1:1:0") .is_err()); let turn_before = state(&session).turn; engine .fire_deadline(&mut session, &deadline.kind, &deadline.token) .unwrap(); assert_ne!(turn_before, state(&session).turn); } #[test] fn stale_deadline_after_play() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); let deadline = engine.next_deadline(&session).unwrap().unwrap(); // A play lands in time: the armed deadline is overtaken. let (sub, payload) = { let st = state(&session); let player = st.players.iter().find(|p| p.seat == st.turn).unwrap(); let played = player.hand[0].clone(); let options = rules::legal_captures(&st.table, played.clone(), st.asso_pigliatutto); let mut payload = Map::new(); payload.insert("card".to_string(), json!(played.code())); if let Some(option) = options.first() { payload.insert( "capture".to_string(), json!(option.iter().map(Card::code).collect::>()), ); } (player.sub.clone(), payload) }; engine .handle_action(&mut session, &sub, "play", &payload) .unwrap(); assert!(engine .fire_deadline(&mut session, &deadline.kind, &deadline.token) .is_err()); } #[test] fn hand_end_deadline_advances() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); { let st = state_mut(&mut session); st.phase = PHASE_HAND_END.to_string(); st.hand_end_deadline = Some(tavolo_platform::datetime::to_iso( tavolo_platform::datetime::now() + chrono::Duration::seconds(60), )); } let deadline = engine.next_deadline(&session).unwrap().unwrap(); assert_eq!("hand_end", deadline.kind); engine .fire_deadline(&mut session, &deadline.kind, &deadline.token) .unwrap(); let st = state(&session); assert_eq!(PHASE_PLAYING, st.phase); assert_eq!(2, st.hand_number); } // -- results --------------------------------------------------------------- #[test] fn result_of_finished_match() { let engine = ScoponeEngine::default(); let mut session = started(&engine, opts(json!({"target_score": 1}))); drive_to_finish(&engine, &mut session); let result = engine.result(&session).unwrap(); assert_eq!(2, result.teams.len()); assert!(result.winner_team == 0 || result.winner_team == 1); assert_eq!(4, result.players.len()); let winner_name = if result.winner_team == 0 { "A" } else { "B" }; for player in &result.players { assert_eq!(player.won, player.team.as_deref() == Some(winner_name)); } assert!(result.summary.get("team_a_score").is_some()); assert!(result.summary.get("hands_played").is_some()); } #[test] fn result_requires_finished_match() { let engine = ScoponeEngine::default(); let session = started(&engine, empty()); assert!(engine.result(&session).is_err()); } #[test] fn game_over_view() { let engine = ScoponeEngine::default(); let mut session = started(&engine, empty()); { let st = state_mut(&mut session); st.phase = PHASE_FINISHED.to_string(); st.winner = Some(1); st.scores = vec![3, 11]; } let over = engine.game_over_view(&session, "alice"); assert_eq!(json!({"A": 3, "B": 11}), over["scores"]); assert_eq!(json!("B"), over["winner"]); } #[test] fn registry_metadata() { let engine = ScoponeEngine::default(); assert_eq!("scopone_scientifico", engine.id()); assert_eq!(4, engine.min_players()); assert_eq!(4, engine.max_players()); let schema = engine.options_schema(); assert!(schema["properties"]["target_score"].is_object()); assert!(schema["properties"]["napola"].is_object()); assert!(schema["properties"]["asso_pigliatutto"].is_object()); // Byte-compatible option schema: descriptions and defaults match Python. assert_eq!( json!("Score the napola rule; a full denari sweep wins the match"), schema["properties"]["napola"]["description"] ); assert_eq!( json!("Score the asso pigliatutto rule; an ace sweeps the whole table (no scopa)"), schema["properties"]["asso_pigliatutto"]["description"] ); }