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.
331 lines
13 KiB
Rust
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);
|
|
}
|