use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use auth_service::{AuthConfig, AuthService}; use game_service::{GameConfig, GameService}; use gateway::{Backends, GatewayConfig}; use game_service::{CardRef, DataSelector, StarterProfile}; use logic::{message_type, table, KeepAliveMessage, LoginMessage, LoginOkMessage, OwnHomeDataMessage}; use titan::GlobalId; use titan::crypto::SessionCipher; use titan::{FrameCodec, FrameHeader, MessageFrame, Payload, HEADER_LEN}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; struct FakeClient { stream: TcpStream, cipher: SessionCipher, } impl FakeClient { async fn connect(port: u16) -> Self { let stream = TcpStream::connect(("127.0.0.1", port)).await.expect("connect"); stream.set_nodelay(true).expect("nodelay"); Self { stream, cipher: SessionCipher::scroll_rc4(), } } async fn send(&mut self, message: &M) { let payload = message.to_bytes().expect("encode"); let cipher_text = self.cipher.encrypt_outbound(&payload).expect("encrypt"); let frame = MessageFrame::new(M::MESSAGE_TYPE, M::MESSAGE_VERSION, cipher_text); let bytes = FrameCodec::encode(&frame).expect("frame"); self.stream.write_all(&bytes).await.expect("write"); self.stream.flush().await.expect("flush"); } async fn close(self) { drop(self.stream); tokio::time::sleep(Duration::from_millis(50)).await; } async fn receive(&mut self) -> (u16, Vec) { let mut header_bytes = [0u8; HEADER_LEN]; tokio::time::timeout( Duration::from_secs(5), self.stream.read_exact(&mut header_bytes), ) .await .expect("header timeout") .expect("header read"); let header = FrameHeader::parse(&header_bytes); let mut payload = vec![0u8; header.payload_len]; tokio::time::timeout(Duration::from_secs(5), self.stream.read_exact(&mut payload)) .await .expect("payload timeout") .expect("payload read"); let plain = self.cipher.decrypt_inbound(&payload).expect("decrypt"); (header.message_type, plain) } } struct TestServer { port: u16, directory: PathBuf, } impl Drop for TestServer { fn drop(&mut self) { for attempt in 0..5 { if std::fs::remove_dir_all(&self.directory).is_ok() && !self.directory.exists() { return; } if attempt < 4 { std::thread::sleep(Duration::from_millis(20)); } } } } async fn start_server(label: &str) -> TestServer { start_server_with(label, |config| config).await } async fn start_server_with( label: &str, customise: impl FnOnce(GameConfig) -> GameConfig, ) -> TestServer { let directory = std::env::temp_dir().join(format!("scroll-test-{label}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&directory); std::fs::create_dir_all(&directory).expect("temp dir"); let auth = AuthService::bootstrap(AuthConfig { store_path: directory.join("accounts.json"), ..AuthConfig::default() }) .await .expect("auth"); let game = GameService::bootstrap(customise(GameConfig { store_path: directory.join("players.json"), ..GameConfig::default() })) .await .expect("game"); let config = GatewayConfig { listen: "127.0.0.1:0".to_owned(), ..GatewayConfig::default() }; let listener = gateway::bind(&config).await.expect("bind"); let port = listener.local_addr().expect("addr").port(); let backends = Backends::new(auth, game); tokio::spawn(async move { let _ = gateway::run_on(listener, config, Arc::clone(&backends)).await; }); TestServer { port, directory } } fn write_card_catalog(root: &Path) { std::fs::create_dir_all(root.join("csv_logic")).expect("csv dir"); std::fs::write( root.join("csv_logic/spells_characters.csv"), concat!( "Name,Rarity,ManaCost\n", "string,string,int\n", "Knight,Common,3\n", "Archers,Common,3\n", "Giant,Rare,5\n" ), ) .expect("write characters"); std::fs::write( root.join("csv_logic/spells_other.csv"), concat!( "Name,Rarity,ManaCost\n", "string,string,int\n", "Fireball,Rare,4\n", "Arrows,Common,3\n" ), ) .expect("write other"); std::fs::write( root.join("csv_logic/arenas.csv"), concat!( "Name,Arena,TrophyLimit\n", "string,int,int\n", "TrainingCamp,0,0\n", "Arena1,1,400\n" ), ) .expect("write arenas"); std::fs::write( root.join("csv_logic/resources.csv"), concat!("Name,Cap\n", "string,int\n", "Gold,1000000\n", "Diamonds,0\n"), ) .expect("write resources"); } fn fresh_login() -> LoginMessage { LoginMessage { client_major_version: 2, client_minor_version: 0, client_build: 1306, resource_sha: Some("0".repeat(40)), udid: Some("integration-test".to_owned()), device: Some("iPhone8,1".to_owned()), os_version: Some("12.5.7".to_owned()), preferred_device_language: "EN".to_owned(), ..LoginMessage::default() } } #[tokio::test] async fn a_fresh_client_reaches_the_lobby() { let server = start_server("lobby").await; let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::LOGIN_OK); let login_ok = LoginOkMessage::from_bytes(&payload).expect("decode login ok"); assert_eq!(login_ok.server_major_version, 2); assert_eq!(login_ok.server_build, 1306); assert!(!login_ok.account_id.is_zero()); assert_eq!(login_ok.pass_token.as_deref().map(str::len), Some(40)); assert_eq!(login_ok.account_id, login_ok.home_id); let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::OWN_HOME_DATA); let home_data = OwnHomeDataMessage::from_bytes(&payload).expect("decode own home data"); assert_eq!(home_data.home.home_id, login_ok.home_id); assert_eq!(home_data.avatar.account_id, login_ok.account_id); assert_eq!(home_data.home.decks.len(), 1); assert_eq!(home_data.home.decks[0].filled_slot_count(), 8); assert_eq!(home_data.home.spell_collection.spells.len(), 8); assert_eq!(home_data.home.chest_slots.len(), 4); assert_eq!(home_data.avatar.diamonds, 1_000); assert_eq!(home_data.avatar.exp_level, 1); assert_eq!(home_data.avatar.name, format!("Scroll{}", login_ok.account_id.low)); client.send(&KeepAliveMessage::default()).await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::KEEP_ALIVE_SERVER); assert!(payload.is_empty()); client.close().await; } #[tokio::test] async fn a_fresh_avatar_does_not_trigger_the_first_tutorial_battle() { let server = start_server("tutorial").await; let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let _ = client.receive().await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::OWN_HOME_DATA); let home_data = OwnHomeDataMessage::from_bytes(&payload).expect("decode"); let avatar = &home_data.avatar; assert!( avatar.npc_lose_count != 0 || avatar.npc_win_count != 0, "LogicClientAvatar::shouldGoToFirstTutorialBattle returns true when both npc counters \ are zero, which sends InitState into GameMode(1) instead of the home screen" ); client.close().await; } #[tokio::test] async fn reconnecting_with_the_issued_pass_token_keeps_the_account() { let server = start_server("reconnect").await; let (account_id, pass_token) = { let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let (_, payload) = client.receive().await; let login_ok = LoginOkMessage::from_bytes(&payload).expect("decode"); let _ = client.receive().await; let credentials = (login_ok.account_id, login_ok.pass_token.expect("token")); client.close().await; credentials }; let mut client = FakeClient::connect(server.port).await; client .send(&LoginMessage { account_id, pass_token: Some(pass_token.clone()), ..fresh_login() }) .await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::LOGIN_OK); let login_ok = LoginOkMessage::from_bytes(&payload).expect("decode"); assert_eq!(login_ok.account_id, account_id); assert_eq!(login_ok.pass_token.as_deref(), Some(pass_token.as_str())); assert!(login_ok.session_count >= 2); let (message_type, _) = client.receive().await; assert_eq!(message_type, message_type::OWN_HOME_DATA); client.close().await; } #[tokio::test] async fn a_wrong_pass_token_is_rejected_with_login_failed() { let server = start_server("rejected").await; let account_id = { let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let (_, payload) = client.receive().await; let login_ok = LoginOkMessage::from_bytes(&payload).expect("decode"); let _ = client.receive().await; let account = login_ok.account_id; client.close().await; account }; let mut client = FakeClient::connect(server.port).await; client .send(&LoginMessage { account_id, pass_token: Some("not-the-issued-token".to_owned()), ..fresh_login() }) .await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::LOGIN_FAILED); let failure = logic::LoginFailedMessage::from_bytes(&payload).expect("decode"); assert_eq!(failure.error_code, 1); client.close().await; } #[tokio::test] async fn go_home_reissues_the_lobby_snapshot() { let server = start_server("gohome").await; let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let _ = client.receive().await; let (_, first) = client.receive().await; client .send(&logic::GoHomeMessage { from_offer_wall: false, }) .await; let (message_type, second) = client.receive().await; assert_eq!(message_type, message_type::OWN_HOME_DATA); assert_eq!(first, second); client.close().await; } #[tokio::test] async fn a_starter_profile_can_name_its_cards_when_the_csv_catalog_is_present() { let catalog_root = std::env::temp_dir().join(format!("scroll-test-cards-{}", std::process::id())); let _ = std::fs::remove_dir_all(&catalog_root); write_card_catalog(&catalog_root); let root_for_config = catalog_root.clone(); let server = start_server_with("catalog", move |config| GameConfig { csv_root: Some(root_for_config), starter: StarterProfile { arena: DataSelector::named("Arena1"), gold_resource: DataSelector::named("Gold"), deck: vec![ CardRef::named("Knight"), CardRef::named("Archers"), CardRef::named("Fireball"), CardRef::named("Arrows"), ], extra_collection: vec![CardRef::named("Giant"), CardRef::named("NotACard")], ..StarterProfile::default() }, ..config }) .await; let mut client = FakeClient::connect(server.port).await; client.send(&fresh_login()).await; let _ = client.receive().await; let (message_type, payload) = client.receive().await; assert_eq!(message_type, message_type::OWN_HOME_DATA); let home_data = OwnHomeDataMessage::from_bytes(&payload).expect("decode"); let deck = &home_data.home.decks[0]; assert_eq!(deck.filled_slot_count(), 4); assert_eq!( deck.slots[0].as_ref().expect("knight").data.global_id(), Some(GlobalId::new(table::SPELLS_CHARACTERS, 0)) ); assert_eq!( deck.slots[2].as_ref().expect("fireball").data.global_id(), Some(GlobalId::new(table::SPELLS_OTHER, 0)) ); assert_eq!( home_data.home.arena.global_id(), Some(GlobalId::new(table::ARENAS, 1)) ); assert_eq!(home_data.avatar.arena, home_data.home.arena); assert_eq!(home_data.home.spell_collection.spells.len(), 1); client.close().await; let _ = std::fs::remove_dir_all(&catalog_root); }