//! Phase H gates: disconnect/reconnect and timer edges. A dropped connection //! must not corrupt a match, and a late or wrong-turn submission must be //! rejected deterministically. use protocol::{Action, ClientMessage, ServerMessage}; use web_tests::{spawn_test_server, WsClient}; #[test] fn wrong_turn_submission_is_rejected_deterministically() { let addr = spawn_test_server(2000); // long turn so we control timing let mut c = WsClient::connect(&addr).unwrap(); c.send(&ClientMessage::JoinMatch { name: "timer".into(), match_id: None }).unwrap(); c.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); // Submit for a turn that is not live. c.send(&ClientMessage::SubmitTurn { turn: 999, action: Action::Wait }).unwrap(); let report = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap(); match report { ServerMessage::ValidationReport { accepted, detail, .. } => { assert!(!accepted, "wrong-turn submission should be rejected"); assert!(detail.contains("wrong turn"), "reason: {detail}"); } _ => unreachable!(), } // A correct, in-time submission is accepted. c.send(&ClientMessage::SubmitTurn { turn: 0, action: Action::Wait }).unwrap(); let ok = c.recv_until(|m| matches!(m, ServerMessage::ValidationReport { .. })).unwrap(); match ok { ServerMessage::ValidationReport { accepted, .. } => assert!(accepted), _ => unreachable!(), } } #[test] fn disconnect_does_not_corrupt_an_ongoing_duel() { let addr = spawn_test_server(10); // Two players share a duel match by id. let mut a = WsClient::connect(&addr).unwrap(); a.send(&ClientMessage::JoinMatch { name: "a".into(), match_id: Some(protocol::MatchId(7)) }) .unwrap(); let _ = a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); let mut b = WsClient::connect(&addr).unwrap(); b.send(&ClientMessage::JoinMatch { name: "b".into(), match_id: Some(protocol::MatchId(7)) }) .unwrap(); let _ = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); // Player A plays a couple of turns. let mut live = 0u64; for _ in 0..2 { a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap(); let r = a .recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1)) .unwrap(); if let ServerMessage::TurnResolved { turn, .. } = r { live = turn; } } // Player B disconnects abruptly. drop(b); // The match continues for A without corruption: more turns still resolve. for _ in 0..2 { a.send(&ClientMessage::SubmitTurn { turn: live, action: Action::Cast }).unwrap(); let r = a .recv_until(|m| matches!(m, ServerMessage::TurnResolved { turn, .. } if *turn == live + 1)) .unwrap(); if let ServerMessage::TurnResolved { turn, runtime_hash, .. } = r { assert!(!runtime_hash.is_empty()); live = turn; } } assert!(live >= 4, "match advanced past a mid-match disconnect"); } #[test] fn reconnect_into_open_duel_slot_succeeds() { let addr = spawn_test_server(50); let mid = protocol::MatchId(21); let mut a = WsClient::connect(&addr).unwrap(); a.send(&ClientMessage::JoinMatch { name: "host".into(), match_id: Some(mid) }).unwrap(); a.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); // A second player takes the open slot. let mut b = WsClient::connect(&addr).unwrap(); b.send(&ClientMessage::JoinMatch { name: "guest".into(), match_id: Some(mid) }).unwrap(); let ms = b.recv_until(|m| matches!(m, ServerMessage::MatchState { .. })).unwrap(); if let ServerMessage::MatchState { player_id, .. } = ms { assert_eq!(player_id.0, 2, "second human should take slot 2"); } }