nuke the tests and the dead code

data/ is runtime state, keep it out of git.
This commit is contained in:
scroll 2026-08-23 07:51:04 +03:00
parent 812052bc3e
commit 20a75dbb25
17 changed files with 3 additions and 1320 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
/target
/data
*.json.tmp
docs/protocol.md

2
Cargo.lock generated
View file

@ -81,8 +81,6 @@ name = "gateway"
version = "0.1.0"
dependencies = [
"async-trait",
"auth-service",
"game-service",
"logic",
"serde",
"serde_json",

View file

@ -1,15 +0,0 @@
{
"next_low": 2,
"accounts": [
{
"high": 0,
"low": 1,
"pass_token": "KIzXwApNr3fe6Yn0SOtv1gm9kOFIZnl3CAFT92EQ",
"created_at": 1787460399,
"last_seen_at": 1787460399,
"session_count": 1,
"play_time_seconds": 0,
"banned": false
}
]
}

View file

@ -1,182 +0,0 @@
{
"profiles": [
{
"account": {
"high": 0,
"low": 1
},
"name": "Scroll1",
"name_set_by_user": false,
"exp_level": 1,
"exp_points": 0,
"gold": 5026,
"diamonds": 1000,
"free_diamonds": 0,
"trophies": 0,
"arena": {
"table": 54,
"name": "Arena1"
},
"chest_slot_count": 4,
"deck": [
{
"card": {
"table": 26,
"name": "Knight"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Archer"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Goblins"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Giant"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Pekka"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Minions"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Balloon"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Witch"
},
"level_index": 0,
"count": 10
}
],
"collection": [
{
"card": {
"table": 26,
"name": "Barbarians"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Golem"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Skeletons"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Valkyrie"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "SkeletonArmy"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Bomber"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Musketeer"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "BabyDragon"
},
"level_index": 0,
"count": 10
},
{
"card": {
"table": 26,
"name": "Prince"
},
"level_index": 0,
"count": 3
},
{
"card": {
"table": 26,
"name": "SpearGoblins"
},
"level_index": 1,
"count": 1
}
],
"battle_count": 0,
"win_count": 0,
"lose_count": 0,
"npc_win_count": 1,
"npc_lose_count": 0,
"three_crown_wins": 0,
"tutorials_finished": true,
"gold_resource": {
"table": 5,
"name": "Gold"
}
}
]
}

View file

@ -1,194 +0,0 @@
use std::path::{Path, PathBuf};
use logic::{table, LogicDataTables};
use titan::GlobalId;
struct TempTree {
root: PathBuf,
}
impl TempTree {
fn new(label: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"scroll-data-{label}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("csv_logic")).expect("create tree");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
std::fs::write(path, contents).expect("write fixture");
}
fn path(&self) -> &Path {
&self.root
}
}
impl Drop for TempTree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn fixture(label: &str) -> TempTree {
let tree = TempTree::new(label);
tree.write(
"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"
),
);
tree.write(
"csv_logic/spells_buildings.csv",
concat!(
"Name,Rarity,ManaCost\n",
"string,string,int\n",
"Cannon,Common,3\n",
"Tesla,Common,4\n"
),
);
tree.write(
"csv_logic/arenas.csv",
concat!(
"Name,Arena,TrophyLimit\n",
"string,int,int\n",
"TrainingCamp,0,0\n",
"Arena1,1,400\n"
),
);
tree.write(
"csv_logic/resources.csv",
concat!(
"Name,Cap\n",
"string,int\n",
"Gold,1000000\n",
"Diamonds,0\n",
"FreeGold,0\n"
),
);
tree
}
#[test]
fn tables_are_indexed_by_the_client_table_ids() {
let tree = fixture("ids");
let tables = LogicDataTables::load_from_dir(tree.path()).expect("load");
assert!(tables.is_loaded());
assert_eq!(tables.loaded_files().len(), 4);
assert!(!tables.skipped_files().is_empty());
assert_eq!(
tables.table(table::SPELLS_CHARACTERS).map(|t| t.count()),
Some(3)
);
assert_eq!(tables.table(table::ARENAS).map(|t| t.count()), Some(2));
assert!(tables.table(table::ACHIEVEMENTS).is_none());
}
#[test]
fn names_resolve_to_the_global_id_that_travels_on_the_wire() {
let tree = fixture("names");
let tables = LogicDataTables::load_from_dir(tree.path()).expect("load");
assert_eq!(
tables.global_id_by_name(table::SPELLS, "Knight"),
Some(GlobalId::new(table::SPELLS_CHARACTERS, 0))
);
assert_eq!(
tables.global_id_by_name(table::SPELLS, "Giant"),
Some(GlobalId::new(table::SPELLS_CHARACTERS, 2))
);
assert_eq!(
tables.global_id_by_name(table::SPELLS, "Tesla"),
Some(GlobalId::new(table::SPELLS_BUILDINGS, 1))
);
assert_eq!(tables.global_id_by_name(table::SPELLS, "Golem"), None);
assert_eq!(
tables.global_id_by_name(table::ARENAS, "Arena1"),
Some(GlobalId::new(table::ARENAS, 1))
);
assert_eq!(
tables.global_id_by_name(table::RESOURCES, "Gold"),
Some(GlobalId::new(table::RESOURCES, 0))
);
}
#[test]
fn a_global_id_resolves_back_to_its_row() {
let tree = fixture("roundtrip");
let tables = LogicDataTables::load_from_dir(tree.path()).expect("load");
let knight = tables
.data_by_id(GlobalId::new(table::SPELLS_CHARACTERS, 0))
.expect("knight");
assert_eq!(knight.name(), "Knight");
assert_eq!(knight.int("ManaCost"), 3);
assert_eq!(knight.string("Rarity"), "Common");
assert_eq!(knight.global_id(), GlobalId::new(table::SPELLS_CHARACTERS, 0));
assert!(tables
.data_by_id(GlobalId::new(table::SPELLS_CHARACTERS, 99))
.is_none());
}
#[test]
fn an_empty_directory_is_reported_rather_than_silently_accepted() {
let tree = TempTree::new("empty");
let error = LogicDataTables::load_from_dir(tree.path()).expect_err("must fail");
assert!(error.to_string().contains("no data table"));
assert!(!LogicDataTables::empty().is_loaded());
}
fn shipped_assets() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.canonicalize()
.ok()?;
root.join("csv_logic/spells_characters.csv").is_file().then_some(root)
}
#[test]
fn data_references_round_trip_whether_or_not_the_tables_are_installed() {
use titan::Payload;
let raw = logic::LogicDataRef::of(table::SPELLS_CHARACTERS, 3);
let bytes = raw.to_bytes().expect("encode");
let decoded = logic::LogicDataRef::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, raw);
assert_eq!(decoded.global_id(), Some(GlobalId::new(table::SPELLS_CHARACTERS, 3)));
let empty = logic::LogicDataRef::None;
let bytes = empty.to_bytes().expect("encode");
assert_eq!(bytes, vec![0]);
assert!(logic::LogicDataRef::from_bytes(&bytes).expect("decode").is_none());
}
#[test]
fn installed_tables_turn_references_into_typed_data_classes() {
let Some(root) = shipped_assets() else {
eprintln!("assets/ is absent, skipping the typed data class check");
return;
};
let tables = LogicDataTables::load_from_dir(&root).expect("load");
logic::LogicDataTables::install(std::sync::Arc::new(tables));
let knight = logic::LogicDataRef::spell("Knight");
assert!(knight.is_resolved());
assert_eq!(knight.name(), "Knight");
assert_eq!(knight.to_string(), "Knight");
assert_eq!(
knight.global_id(),
Some(GlobalId::new(table::SPELLS_CHARACTERS, 0))
);
let spell = knight.as_spell().expect("spell data");
assert_eq!(spell.rarity(), "Common");
assert_eq!(spell.mana_cost(), 3);
assert!(knight.as_arena().is_none());
let arena = logic::LogicDataRef::arena("TrainingCamp");
let arena_data = arena.as_arena().expect("arena data");
assert!(arena_data.is_training_camp());
assert_eq!(arena_data.trophy_limit(), 0);
let card = logic::model::LogicSpell::card(knight.clone(), 0, 10);
assert_eq!(card.mana_cost(), 3);
assert_eq!(card.level(), 1);
let rarity = card.rarity().expect("rarity data");
assert_eq!(rarity.name(), "Common");
assert_eq!(rarity.level_count(), 12);
assert_eq!(rarity.upgrade_cost(0), 5);
let chest = logic::LogicDataRef::treasure_chest("Silver")
.as_treasure_chest()
.expect("chest data");
assert!(chest.total_time_taken_seconds() > 0);
assert!(chest.max_gold() >= chest.min_gold());
}

View file

@ -1,163 +0,0 @@
use logic::model::{
ChestSource, LogicAllianceInfo, LogicChest, LogicClientAvatar, LogicClientHome, LogicDataSlot,
LogicSpell, LogicSpellCollection, LogicSpellDeck, LogicTimer,
};
use logic::{DataId, LogicScrollMessageFactory, OwnHomeDataMessage};
use titan::{ByteStreamReader, ByteStreamWriter, LogicLong, MessageFactory, MessageMeta, Payload};
fn sample_message() -> OwnHomeDataMessage {
let deck = LogicSpellDeck::from_cards((0..8).map(|index| {
LogicSpell::card(DataId::character_card(index), index % 3, 10 + index)
}));
let collection = LogicSpellCollection::from_spells(
(0..12)
.map(|index| LogicSpell::card(DataId::character_card(index), 1, index))
.collect(),
);
let mut home = LogicClientHome {
home_id: LogicLong::new(0, 42),
chest_id_counter: 7,
free_chest_collect_count: 3,
donation_capacity_limit_timer: LogicTimer::started(86_400, 1_000),
donated_capacity: 12,
decks: vec![deck, LogicSpellDeck::default()],
spell_collection: collection,
selected_deck_index: 0,
chest_slots: vec![
Some(LogicChest::locked(
DataId::treasure_chest(0),
1,
0,
ChestSource::Battle,
)),
None,
Some(LogicChest {
unlock_timer: Some(LogicTimer::started(3_600, 500)),
..LogicChest::locked(DataId::treasure_chest(1), 2, 2, ChestSource::Crown)
}),
],
free_chest_timer: LogicTimer::started(14_400, 100),
donation_cooldown_timer: LogicTimer::idle(),
free_chest: Some(LogicChest::locked(
DataId::treasure_chest(2),
3,
-1,
ChestSource::Free,
)),
purchased_chest: None,
crowns_towards_crown_chest: 4,
star_chest_timer_pending: true,
star_chest_timer: LogicTimer::idle(),
crown_chest: None,
request_spells_cooldown_timer: LogicTimer::idle(),
finished_tutorials: [0; 8],
opened_pages: 0b1011,
last_shown_level_up: 2,
arena: DataId::arena(1),
shop_seed: -1,
shop_weekday_index: 3,
shop_weekday_index_seen: 3,
shop_timer: LogicTimer::started(600, 900),
shop_spells: vec![
LogicDataSlot::new(DataId::character_card(4), 1),
LogicDataSlot::new(DataId::other_card(2), 0),
],
share_replay_cooldown_timer: LogicTimer::idle(),
send_team_mail_cooldown_timer: LogicTimer::idle(),
free_chest_diamond_index: 1,
crown_chest_diamond_index: 2,
card_usage_stats_battle_count: 25,
royal_tv_replay_version_major: 2,
royal_tv_replay_version_build: 1306,
royal_tv_replay_version_content: 8,
seen_replay_ids: vec![5, -9, 1_000_000],
card_request_message: Some("need arrows".to_owned()),
requested_cards: vec![DataId::other_card(1), DataId::character_card(3)],
};
home.set_tutorial_finished(0);
home.set_tutorial_finished(37);
let mut avatar = LogicClientAvatar {
avatar_id: LogicLong::new(0, 42),
account_id: LogicLong::new(0, 42),
home_id: LogicLong::new(0, 42),
name: "BladeWise".to_owned(),
name_set_by_user: true,
name_change_state: -1,
arena: DataId::arena(1),
score: 1_337,
legacy_flag: false,
commodities: Default::default(),
diamonds: 500,
free_diamonds: 25,
exp_points: 90,
exp_level: 6,
cumulative_purchased_diamonds: 0,
alliance: Some(LogicAllianceInfo {
alliance_id: LogicLong::new(0, 9),
name: "Scroll Devs".to_owned(),
badge: DataId::alliance_badge(16_000_000),
role: 2,
}),
battle_count: 40,
win_count: 22,
lose_count: 18,
win_loose_streak: 3,
npc_win_count: 4,
npc_lose_count: 0,
arena_before_last_npc_match: true,
};
avatar
.commodities
.set(0, vec![LogicDataSlot::new(DataId::resource(0), 12_500)]);
avatar.commodities.set(
1,
vec![LogicDataSlot::new(DataId::character_card(0), 3)],
);
OwnHomeDataMessage::new(home, avatar, 0x5EED)
}
#[test]
fn own_home_data_roundtrips_byte_for_byte() {
let message = sample_message();
let bytes = message.to_bytes().unwrap();
let decoded = OwnHomeDataMessage::from_bytes(&bytes).unwrap();
assert_eq!(decoded, message);
assert_eq!(decoded.to_bytes().unwrap(), bytes);
let mut reader = ByteStreamReader::new(&bytes);
OwnHomeDataMessage::decode(&mut reader).unwrap();
assert_eq!(reader.remaining(), 0);
}
#[test]
fn own_home_data_starts_with_the_raw_big_endian_home_id() {
let message = sample_message();
let bytes = message.to_bytes().unwrap();
assert_eq!(&bytes[0..8], &[0, 0, 0, 0, 0, 0, 0, 42]);
assert_eq!(bytes[8], 7);
}
#[test]
fn message_metadata_matches_the_client_definition() {
assert_eq!(OwnHomeDataMessage::MESSAGE_TYPE, 24101);
assert_eq!(OwnHomeDataMessage::MESSAGE_VERSION, 0);
assert_eq!(OwnHomeDataMessage::NAME, "OwnHomeDataMessage");
}
#[test]
fn the_factory_decodes_registered_messages() {
let registry = LogicScrollMessageFactory::registry();
let message = sample_message();
let bytes = message.to_bytes().unwrap();
let decoded = registry.create(24101, 0, &bytes).unwrap();
assert_eq!(decoded.message_type(), 24101);
assert_eq!(decoded.name(), "OwnHomeDataMessage");
let concrete = decoded
.as_any()
.downcast_ref::<OwnHomeDataMessage>()
.expect("downcast");
assert_eq!(concrete, &message);
}
#[test]
fn empty_optionals_only_cost_a_single_packed_bit() {
let mut writer = ByteStreamWriter::new();
let home = LogicClientHome::default();
home.encode(&mut writer).unwrap();
let bytes = writer.into_inner();
let decoded = LogicClientHome::from_bytes(&bytes).unwrap();
assert_eq!(decoded, home);
}

View file

@ -21,5 +21,3 @@ async-trait = { workspace = true }
[[bin]]
name = "game-service"
path = "src/main.rs"
[dev-dependencies]

View file

@ -159,7 +159,7 @@ impl HomeModeRegistry {
pub async fn close(&self, account: AccountRef) -> Option<Arc<Mutex<HomeMode>>> {
self.sessions.write().await.remove(&account)
}
pub async fn len(&self) -> usize {
pub async fn session_count(&self) -> usize {
self.sessions.read().await.len()
}
}

View file

@ -181,7 +181,7 @@ impl GameApi for GameService {
}
async fn disconnect(&self, account: AccountRef) -> RpcResult<()> {
self.persist(account).await?;
let sessions = self.sessions.len().await;
let sessions = self.sessions.session_count().await;
tracing::debug!(%account, sessions, "session closed");
Ok(())
}

View file

@ -1,99 +0,0 @@
use std::path::{Path, PathBuf};
use game_service::{CardRef, Catalog, DataSelector};
use logic::table;
fn assets_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../assets")
.canonicalize()
.ok()?;
root.join("csv_logic/spells_characters.csv").is_file().then_some(root)
}
#[test]
fn the_shipped_client_tables_load_and_resolve_by_name() {
let Some(root) = assets_root() else {
eprintln!("assets/ is absent, skipping the shipped catalog check");
return;
};
let catalog = Catalog::load(Some(&root));
assert!(catalog.is_loaded(), "catalog failed to load from {root:?}");
let tables = catalog.tables();
assert_eq!(tables.loaded_files().len(), 44);
assert!(tables.skipped_files().is_empty(), "{:?}", tables.skipped_files());
assert_eq!(
tables.global_id_by_name(table::ARENAS, "TrainingCamp"),
Some(titan::GlobalId::new(table::ARENAS, 0))
);
assert_eq!(
tables.global_id_by_name(table::RESOURCES, "Gold"),
Some(titan::GlobalId::new(table::RESOURCES, 1))
);
assert_eq!(
tables.global_id_by_name(table::RESOURCES, "Diamonds"),
Some(titan::GlobalId::new(table::RESOURCES, 0))
);
let knight = tables.spell_by_name("Knight").expect("Knight");
assert_eq!(knight.global_id().class_id, table::SPELLS_CHARACTERS);
assert_eq!(knight.global_id().instance_id, 0);
let fireball = tables.spell_by_name("Fireball").expect("Fireball");
assert_eq!(fireball.global_id().class_id, table::SPELLS_OTHER);
let cannon = tables.spell_by_name("Cannon").expect("Cannon");
assert_eq!(cannon.global_id().class_id, table::SPELLS_BUILDINGS);
}
#[test]
fn continuation_rows_build_the_per_level_arrays() {
let Some(root) = assets_root() else {
return;
};
let catalog = Catalog::load(Some(&root));
let tables = catalog.tables();
let common = tables
.data_by_name(table::RARITIES, "Common")
.expect("Common rarity");
assert!(
common.array_size() > 1,
"rarities.csv uses continuation rows for the upgrade curve"
);
assert_eq!(common.int_at("UpgradeCost", 0), 5);
assert_eq!(common.int_at("UpgradeCost", 1), 20);
assert_eq!(common.int("LevelCount"), 12);
assert_eq!(
common.int_at("UpgradeCost", 999),
common.int_at("UpgradeCost", common.array_size() - 1),
"reading past the end clamps to the last level"
);
let newbie = tables
.treasure_chest_by_name("Newbie1")
.expect("Newbie1 chest");
assert_eq!(newbie.string("Arena"), "TrainingCamp");
let row = newbie.row().expect("row");
let guaranteed = newbie
.csv_table()
.column_index("GuaranteedSpells")
.expect("column");
assert_eq!(row.value_count(guaranteed), 2);
assert_eq!(row.string_at(guaranteed, 0), "Knight");
assert_eq!(row.string_at(guaranteed, 1), "Arrows");
}
#[test]
fn the_default_starter_profile_resolves_against_the_real_tables() {
let Some(root) = assets_root() else {
return;
};
let catalog = Catalog::load(Some(&root));
let starter = game_service::StarterProfile::default();
for card in starter.deck.iter().chain(starter.extra_collection.iter()) {
assert!(
catalog.resolve_card(card).is_some(),
"starter card {card:?} does not resolve"
);
}
let gold = catalog.resolve_resource(&DataSelector::named("Gold"));
assert_eq!(gold.name(), "Gold");
assert_eq!(
gold.global_id(),
Some(titan::GlobalId::new(table::RESOURCES, 1))
);
let arena = catalog.resolve_arena(&starter.arena);
assert_eq!(arena.table_index(), Some(table::ARENAS));
assert!(catalog.resolve_card(&CardRef::named("NotACard")).is_none());
}

View file

@ -20,8 +20,3 @@ async-trait = { workspace = true }
[[bin]]
name = "gateway"
path = "src/main.rs"
[dev-dependencies]
auth-service = { workspace = true }
game-service = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "fs"] }

View file

@ -1,331 +0,0 @@
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);
}

View file

@ -11,7 +11,3 @@ tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
async-trait = { workspace = true }

View file

@ -1,61 +0,0 @@
use std::sync::Arc;
use service_rpc::base64;
use service_rpc::{
serve, AccountRef, GameRequest, GameResponse, HomeRequestKind, RpcClient, RpcResult, RpcService,
WireMessage,
};
use tokio::net::TcpListener;
struct EchoGame;
#[async_trait::async_trait]
impl RpcService for EchoGame {
type Request = GameRequest;
type Response = GameResponse;
fn service_name(&self) -> &'static str {
"echo-game"
}
async fn call(&self, request: GameRequest) -> RpcResult<GameResponse> {
match request {
GameRequest::LoadHome { account, .. } => Ok(GameResponse::messages(vec![
WireMessage::new(24101, 0, vec![account.low as u8, 0xFF, 0x00, 0x7F]),
])),
_ => Ok(GameResponse::Empty),
}
}
}
#[tokio::test]
async fn requests_round_trip_over_the_length_prefixed_transport() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let endpoint = listener.local_addr().expect("addr").to_string();
tokio::spawn(async move {
let _ = serve(listener, Arc::new(EchoGame)).await;
});
let client: RpcClient<GameRequest, GameResponse> = RpcClient::new(endpoint);
let response = client
.call(&GameRequest::LoadHome {
account: AccountRef::new(0, 9),
kind: HomeRequestKind::Login,
})
.await
.expect("call");
let messages = response.into_messages();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].message_type, 24101);
assert_eq!(messages[0].payload, vec![9, 0xFF, 0x00, 0x7F]);
let empty = client
.call(&GameRequest::Disconnect {
account: AccountRef::new(0, 9),
})
.await
.expect("call");
assert_eq!(empty, GameResponse::Empty);
}
#[tokio::test]
async fn payloads_survive_base64_encoding() {
for length in 0..64usize {
let payload: Vec<u8> = (0..length).map(|index| (index * 7 % 251) as u8).collect();
let encoded = base64::encode(&payload);
assert_eq!(base64::decode(&encoded).expect("decode"), payload);
}
assert_eq!(base64::encode(b"scroll"), "c2Nyb2xs");
assert_eq!(base64::decode("c2Nyb2xs").unwrap(), b"scroll");
}

View file

@ -11,5 +11,3 @@ titan-derive = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]

View file

@ -1,90 +0,0 @@
use titan::csv::{ColumnType, CsvNode, CsvReader};
const SPELLS: &str = concat!(
"Name,Rarity,Mana,SummonCharacter,Damage,Enabled\n",
"string,string,int,string,int,boolean\n",
"Knight,Common,3,Knight,75,true\n",
",,,,82,\n",
",,,,90,\n",
"Archers,Common,3,Archer,42,true\n",
",,,,46,\n",
"\"Mini P.E.K.K.A\",Rare,4,MiniPekka,325,false\n"
);
#[test]
fn the_first_two_lines_declare_names_and_types() {
let table = CsvReader::parse("spells_characters.csv", SPELLS).expect("parse");
assert_eq!(table.column_count(), 6);
assert_eq!(table.column_name(0), "Name");
assert_eq!(table.column_type(0), Some(ColumnType::String));
assert_eq!(table.column_type(2), Some(ColumnType::Int));
assert_eq!(table.column_type(5), Some(ColumnType::Boolean));
assert_eq!(table.column_index("Damage"), Some(4));
}
#[test]
fn a_row_opens_only_when_the_first_column_is_populated() {
let table = CsvReader::parse("spells_characters.csv", SPELLS).expect("parse");
assert_eq!(table.row_count(), 3);
assert_eq!(table.row_at(0).unwrap().string(0), "Knight");
assert_eq!(table.row_at(1).unwrap().string(0), "Archers");
assert_eq!(table.row_at(2).unwrap().string(0), "Mini P.E.K.K.A");
}
#[test]
fn continuation_lines_extend_the_per_level_arrays() {
let table = CsvReader::parse("spells_characters.csv", SPELLS).expect("parse");
let knight = table.row_at(0).expect("knight");
assert_eq!(knight.array_size(), 3);
assert_eq!(knight.value_count(4), 3);
assert_eq!(knight.int_at(4, 0), 75);
assert_eq!(knight.int_at(4, 1), 82);
assert_eq!(knight.int_at(4, 2), 90);
assert_eq!(knight.scaled_int(4, 9), 90);
assert_eq!(knight.int(2), 3);
assert!(knight.boolean(5));
let archers = table.row_at(1).expect("archers");
assert_eq!(archers.value_count(4), 2);
assert!(!table.row_at(2).unwrap().boolean(5));
}
#[test]
fn rows_are_addressable_by_their_first_column() {
let table = CsvReader::parse("spells_characters.csv", SPELLS).expect("parse");
assert_eq!(table.row_index_of("Archers"), Some(1));
assert_eq!(
table.row_by_name("Mini P.E.K.K.A").map(|row| row.int(2)),
Some(4)
);
assert!(table.row_by_name("Golem").is_none());
}
#[test]
fn quoted_cells_keep_their_commas() {
let source = concat!(
"Name,Text\n",
"string,string\n",
"Greeting,\"hello, world\"\n",
"Quote,\"say \"\"hi\"\"\"\n"
);
let table = CsvReader::parse("hints.csv", source).expect("parse");
assert_eq!(table.row_at(0).unwrap().string(1), "hello, world");
assert_eq!(table.row_at(1).unwrap().string(1), "say \"hi\"");
}
#[test]
fn missing_trailing_cells_decode_as_empty() {
let source = concat!("Name,A,B\n", "string,int,int\n", "Row,5\n");
let table = CsvReader::parse("t.csv", source).expect("parse");
let row = table.row_at(0).expect("row");
assert_eq!(row.int(1), 5);
assert_eq!(row.int(2), 0);
assert!(row.value(2).expect("value").is_empty());
}
#[test]
fn an_unknown_column_type_is_rejected() {
let source = concat!("Name,A\n", "string,float\n", "Row,1\n");
let error = CsvReader::parse("bad.csv", source).expect_err("must fail");
assert!(error.to_string().contains("float"));
}
#[test]
fn a_node_wraps_the_table_with_its_file_identity() {
let node = CsvNode::load("csv_logic/spells_characters.csv", SPELLS).expect("load");
assert_eq!(node.name(), "spells_characters");
assert_eq!(node.file_name(), "csv_logic/spells_characters.csv");
assert_eq!(node.table().row_count(), 3);
assert_eq!(node.node_count(), 0);
}

View file

@ -1,168 +0,0 @@
use titan::crypto::{Rc4Encrypter, StreamEncrypter};
use titan::{ByteStreamReader, ByteStreamWriter, FrameCodec, FrameHeader, MessageFrame, HEADER_LEN};
fn roundtrip_vint(value: i32) {
let mut writer = ByteStreamWriter::new();
writer.write_vint(value);
let bytes = writer.into_inner();
let mut reader = ByteStreamReader::new(&bytes);
assert_eq!(reader.read_vint().unwrap(), value, "vint {value} roundtrip");
assert_eq!(reader.remaining(), 0, "vint {value} length");
}
#[test]
fn vint_roundtrips_across_every_width() {
let boundaries = [
0,
1,
63,
64,
0x1FFF,
0x2000,
0x000F_FFFF,
0x0010_0000,
0x07FF_FFFF,
0x0800_0000,
i32::MAX,
-1,
-63,
-64,
-8191,
-8192,
-1_048_575,
-1_048_576,
-134_217_727,
-134_217_728,
i32::MIN,
];
for value in boundaries {
roundtrip_vint(value);
}
let mut value: i64 = -2_000_000;
while value < 2_000_000 {
roundtrip_vint(value as i32);
value += 9_973;
}
}
#[test]
fn vint_encodes_to_the_expected_byte_widths() {
let cases = [
(0i32, 1usize),
(63, 1),
(64, 2),
(0x1FFF, 2),
(0x2000, 3),
(0x000F_FFFF, 3),
(0x0010_0000, 4),
(0x07FF_FFFF, 4),
(0x0800_0000, 5),
(-63, 1),
(-64, 2),
(-8191, 2),
(-8192, 3),
(-1_048_575, 3),
(-1_048_576, 4),
(-134_217_727, 4),
(-134_217_728, 5),
];
for (value, width) in cases {
let mut writer = ByteStreamWriter::new();
writer.write_vint(value);
assert_eq!(writer.len(), width, "width of {value}");
}
}
#[test]
fn booleans_are_bit_packed_until_another_primitive_is_written() {
let mut writer = ByteStreamWriter::new();
for bit in 0..8 {
writer.write_boolean(bit % 2 == 0);
}
writer.write_boolean(true);
writer.write_vint(7);
writer.write_boolean(true);
let bytes = writer.into_inner();
assert_eq!(bytes[0], 0b0101_0101);
assert_eq!(bytes[1], 0b0000_0001);
assert_eq!(bytes[2], 7);
assert_eq!(bytes[3], 0b0000_0001);
let mut reader = ByteStreamReader::new(&bytes);
for bit in 0..8 {
assert_eq!(reader.read_boolean().unwrap(), bit % 2 == 0);
}
assert!(reader.read_boolean().unwrap());
assert_eq!(reader.read_vint().unwrap(), 7);
assert!(reader.read_boolean().unwrap());
assert_eq!(reader.remaining(), 0);
}
#[test]
fn strings_carry_a_big_endian_length_prefix() {
let mut writer = ByteStreamWriter::new();
writer.write_string(Some("scroll")).unwrap();
writer.write_string(None).unwrap();
writer.write_string_reference("").unwrap();
let bytes = writer.into_inner();
assert_eq!(&bytes[0..4], &[0, 0, 0, 6]);
assert_eq!(&bytes[4..10], b"scroll");
assert_eq!(&bytes[10..14], &[0xFF, 0xFF, 0xFF, 0xFF]);
assert_eq!(&bytes[14..18], &[0, 0, 0, 0]);
let mut reader = ByteStreamReader::new(&bytes);
assert_eq!(reader.read_string().unwrap().as_deref(), Some("scroll"));
assert_eq!(reader.read_string().unwrap(), None);
assert_eq!(reader.read_string_reference().unwrap(), "");
}
#[test]
fn frame_header_is_seven_bytes_big_endian() {
let frame = MessageFrame::new(24101, 0, vec![1, 2, 3]);
let encoded = FrameCodec::encode(&frame).unwrap();
assert_eq!(encoded.len(), HEADER_LEN + 3);
assert_eq!(&encoded[0..HEADER_LEN], &[0x5E, 0x25, 0, 0, 3, 0, 0]);
let mut header_bytes = [0u8; HEADER_LEN];
header_bytes.copy_from_slice(&encoded[0..HEADER_LEN]);
let header = FrameHeader::parse(&header_bytes);
assert_eq!(header.message_type, 24101);
assert_eq!(header.payload_len, 3);
assert_eq!(header.message_version, 0);
}
#[test]
fn rc4_streams_are_symmetric_and_stateful() {
let mut client_to_server = Rc4Encrypter::scroll_default();
let mut server_side = Rc4Encrypter::scroll_default();
let first = client_to_server.encrypt(b"LoginMessage payload").unwrap();
let second = client_to_server.encrypt(b"KeepAliveMessage").unwrap();
assert_eq!(server_side.decrypt(&first).unwrap(), b"LoginMessage payload");
assert_eq!(server_side.decrypt(&second).unwrap(), b"KeepAliveMessage");
}
#[test]
fn rc4_matches_a_reference_implementation_with_the_supercell_keystream_discard() {
fn reference(key: &[u8], nonce: &[u8], length: usize) -> Vec<u8> {
let mut seed = key.to_vec();
seed.extend_from_slice(nonce);
let mut state: Vec<u8> = (0..=255u8).collect();
let mut j: u8 = 0;
for i in 0..256usize {
j = j.wrapping_add(state[i]).wrapping_add(seed[i % seed.len()]);
state.swap(i, j as usize);
}
let mut i: u8 = 0;
let mut j: u8 = 0;
let next = |state: &mut Vec<u8>, i: &mut u8, j: &mut u8| {
*i = i.wrapping_add(1);
*j = j.wrapping_add(state[*i as usize]);
state.swap(*i as usize, *j as usize);
let index = state[*i as usize].wrapping_add(state[*j as usize]);
state[index as usize]
};
for _ in 0..seed.len() {
next(&mut state, &mut i, &mut j);
}
(0..length)
.map(|_| next(&mut state, &mut i, &mut j))
.collect()
}
let expected = reference(
titan::crypto::SCROLL_RC4_KEY.as_bytes(),
titan::crypto::SCROLL_RC4_NONCE.as_bytes(),
32,
);
let mut cipher = Rc4Encrypter::scroll_default();
let produced = cipher.encrypt(&[0u8; 32]).unwrap();
assert_eq!(produced, expected);
}