scroll.server/services/gateway/tests/lobby.rs
scroll 812052bc3e initial import, client gets to the lobby
titan engine, logic model, auth/game/gateway services.
csv tables pulled out of the ipa are checked in so it runs out of the box.
2026-08-23 07:50:31 +03:00

331 lines
13 KiB
Rust

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<M: Payload + titan::MessageMeta>(&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<u8>) {
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);
}