use std::sync::Arc; use logic::table; use logic::{ AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, SectorStateMessage, StartMissionMessage, StopHomeLogicMessage, }; use service_rpc::{ AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, RpcService, WireMessage, }; use titan::{Message, MessageMeta, Payload}; use crate::battle::BattleBuilder; use crate::catalog::Catalog; use crate::config::GameConfig; use crate::bot::BotPlayer; use crate::home::{build_avatar, build_deck}; use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::rewards::RewardRoller; use crate::shop::{ShopCatalog, ShopCycle}; use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore}; use crate::time::unix_seconds; pub const ARENA_PVP_LOCATION_COLUMN: &str = "PvpLocation"; pub const NPC_LOCATION_COLUMN: &str = "Location"; fn location_of(data: &LogicDataRef, column: &str) -> LogicDataRef { data.data() .map(|row| LogicDataRef::by_name(table::LOCATIONS, row.string(column))) .unwrap_or_default() } pub struct GameService { config: GameConfig, profiles: ProfileStore, catalog: Arc, shop: Arc, battles: Arc, sessions: HomeModeRegistry, } impl GameService { pub async fn bootstrap(config: GameConfig) -> std::io::Result> { let catalog = Arc::new(Catalog::load(config.csv_root.as_deref())); let database = config.database.clone().unwrap_or_default(); let profiles = ProfileStore::open(&database) .await .map_err(connect_failed)?; let profile_count = profiles.count().await?; let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref())); let battles = Arc::new(BattleBuilder::new( config.csv_root.clone().unwrap_or_else(|| "assets".into()), )); for offer in shop.describe() { tracing::debug!(offer, "shop offer"); } tracing::info!( profiles = profile_count, offers = shop.offers.len(), catalog = catalog.is_loaded(), commands = LogicCommandManager::registry().len(), "profile store ready" ); Ok(Arc::new(Self { config, profiles, catalog, shop, battles, sessions: HomeModeRegistry::default(), })) } pub fn profiles(&self) -> &ProfileStore { &self.profiles } async fn profile(&self, account: AccountRef) -> RpcResult { if let Some(profile) = self.profiles.load(account).await.map_err(unavailable)? { return Ok(profile); } let starter = PlayerProfile::starter(account, &self.config.starter, &self.catalog); self.profiles .create_if_absent(starter) .await .map_err(unavailable) } pub fn catalog(&self) -> &Arc { &self.catalog } pub fn shop(&self) -> &Arc { &self.shop } async fn sector_state_for( &self, account: AccountRef, npc: LogicDataRef, ) -> RpcResult> { let profile = self.profile(account).await?; let avatar = build_avatar(&profile); self.sessions .get_or_open(account, &profile, unix_seconds() as i32) .await .lock() .await .stop_home_logic(); let location = if npc.is_none() { location_of(&profile.arena, ARENA_PVP_LOCATION_COLUMN) } else { location_of(&npc, NPC_LOCATION_COLUMN) }; let deck = build_deck(&profile); let (opponent, opponent_deck) = if npc.is_none() { let bot = BotPlayer::against(&avatar, &deck); (bot.avatar, bot.deck) } else { (avatar.clone(), deck.clone()) }; let opponent_name = opponent.name.clone(); let battle = self .battles .build( location.clone(), npc.clone(), profile.arena.clone(), vec![avatar, opponent], [Some(deck), Some(opponent_deck)], self.config.random_seed, ) .map_err(|error| RpcError::Rejected(error.to_string()))?; let snapshot = battle .snapshot() .map_err(|error| RpcError::Rejected(error.to_string()))?; tracing::debug!(head = %snapshot.iter().take(48).map(|b| format!("{b:02x}")).collect::>().join(" "), "snapshot head"); let report = logic::battle::verify_snapshot(&snapshot); if report.is_ok() { tracing::debug!(steps = ?report.steps, "snapshot sections"); tracing::info!( %account, opponent = %opponent_name, location = %location, objects = battle.battle.objects.objects.len(), bytes = snapshot.len(), "sending a battle sector state" ); } else { tracing::error!( %account, bytes = snapshot.len(), trailing = report.trailing, error = report.error.as_deref().unwrap_or("-"), steps = ?report.steps, "the battle snapshot does not read back, refusing to send it" ); return Err(RpcError::Rejected(report.error.unwrap_or_else(|| { format!("{} trailing byte(s)", report.trailing) }))); } Ok(vec![encode(&SectorStateMessage::new(snapshot))?]) } async fn roller(&self, account: AccountRef) -> RpcResult { let profile = self.profile(account).await?; let fallback: Vec = profile .deck .iter() .chain(profile.collection.iter()) .map(|owned| owned.card.clone()) .collect(); Ok(RewardRoller::new(&self.catalog, fallback)) } async fn persist(&self, account: AccountRef) -> RpcResult<()> { let Some(session) = self.sessions.close(account).await else { return Ok(()); }; let mut profile = self.profile(account).await?; session.lock().await.write_back(&mut profile); self.profiles.save(&profile).await.map_err(unavailable)?; Ok(()) } } fn encode(message: &M) -> RpcResult where M: MessageMeta + Message, { let payload = message .to_bytes() .map_err(|error| RpcError::Rejected(error.to_string()))?; Ok(WireMessage::new( M::MESSAGE_TYPE, M::MESSAGE_VERSION, payload, )) } #[async_trait::async_trait] impl GameApi for GameService { async fn load_home( &self, account: AccountRef, kind: HomeRequestKind, ) -> RpcResult> { let profile = self.profile(account).await?; let session = self .sessions .get_or_open(account, &profile, unix_seconds() as i32) .await; let (home_data, checksum, cycle, on_sale): ( OwnHomeDataMessage, i32, ShopCycle, Vec, ) = { let home = session.lock().await; ( home.own_home_data(self.config.random_seed), home.logic().checksum(), home.shop_cycle(), home.logic() .shop_spells() .iter() .map(|slot| slot.data.to_string()) .collect(), ) }; tracing::info!( %account, ?kind, cards = home_data.home.total_spell_count(), trophies = home_data.avatar.score, checksum, "serving own home data" ); tracing::info!( %account, seed = cycle.seed, weekday = cycle.weekday_index, cards = ?on_sale, "shop on sale" ); let seed_changed = LogicShopSeedChangedCommand::new( cycle.seed, cycle.seconds_to_cycle, cycle.weekday_index, ); Ok(vec![ encode(&home_data)?, encode(&AvailableServerCommandMessage { command: Box::new(seed_changed), })?, ]) } async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()> { tracing::debug!(%account, ping_ms, "client capabilities"); Ok(()) } async fn start_mission( &self, account: AccountRef, payload: Vec, ) -> RpcResult> { let mission = StartMissionMessage::from_bytes(&payload) .map_err(|error| RpcError::Rejected(error.to_string()))?; self.sector_state_for(account, mission.npc).await } async fn home_logic_stopped( &self, account: AccountRef, _payload: Vec, ) -> RpcResult> { tracing::info!(%account, "matchmaking against a bot player"); self.sector_state_for(account, LogicDataRef::None).await } async fn end_client_turn( &self, account: AccountRef, payload: Vec, ) -> RpcResult> { let turn = match EndClientTurnMessage::from_bytes(&payload) { Ok(turn) => turn, Err(error) => { tracing::warn!(%account, bytes = payload.len(), %error, "undecodable client turn"); return Ok(Vec::new()); } }; let profile = self.profile(account).await?; let session = self .sessions .get_or_open(account, &profile, unix_seconds() as i32) .await; let roller = self.roller(account).await?; let result = { let mut home = session.lock().await; home.end_client_turn(&turn, &roller, &self.shop) }; tracing::debug!( %account, tick = turn.tick, checksum = turn.checksum, commands = turn.commands.len(), "end client turn" ); let mut replies = Vec::new(); if result.matchmake { tracing::info!(%account, "client started matchmaking, stopping home logic"); replies.push(encode(&StopHomeLogicMessage::default())?); } if let Some(out_of_sync) = result.out_of_sync { tracing::warn!( %account, tick = out_of_sync.tick, server = out_of_sync.server_checksum, client = out_of_sync.client_checksum, "client and server home state diverged" ); replies.push(encode::(&out_of_sync)?); } for purchase in &result.purchases { tracing::info!( %account, offer = purchase.offer_id, cost = %purchase.cost, give = %purchase.give, "shop purchase" ); if let Err(error) = self.profiles.record_purchase(account, purchase).await { tracing::warn!(%account, %error, "could not record the purchase"); } } for claim in result.claims { tracing::info!( %account, source = claim.chest_source, chest_id = claim.chest_id, gold = claim.reward.as_ref().map(|reward| reward.gold).unwrap_or(0), cards = claim.reward.as_ref().map(|reward| reward.spell_count()).unwrap_or(0), "granting a chest reward" ); replies.push(encode(&available_server_command(claim))?); } if result.changed { let mut stored = self.profile(account).await?; session.lock().await.write_back(&mut stored); self.profiles.save(&stored).await.map_err(unavailable)?; } Ok(replies) } async fn disconnect(&self, account: AccountRef) -> RpcResult<()> { self.persist(account).await?; let sessions = self.sessions.session_count().await; tracing::debug!(%account, sessions, "session closed"); Ok(()) } } #[async_trait::async_trait] impl RpcService for GameService { type Request = GameRequest; type Response = GameResponse; fn service_name(&self) -> &'static str { "game" } async fn call(&self, request: GameRequest) -> RpcResult { match request { GameRequest::LoadHome { account, kind } => { Ok(GameResponse::messages(self.load_home(account, kind).await?)) } GameRequest::ClientCapabilities { account, ping_ms } => { self.client_capabilities(account, ping_ms).await?; Ok(GameResponse::Empty) } GameRequest::StartMission { account, payload } => Ok(GameResponse::messages( self.start_mission(account, payload).await?, )), GameRequest::HomeLogicStopped { account, payload } => Ok(GameResponse::messages( self.home_logic_stopped(account, payload).await?, )), GameRequest::EndClientTurn { account, payload } => Ok(GameResponse::messages( self.end_client_turn(account, payload).await?, )), GameRequest::Disconnect { account } => { self.disconnect(account).await?; Ok(GameResponse::Empty) } } } }