use std::collections::HashMap; use std::sync::Arc; use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD}; use logic::{ chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage, LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage, OwnHomeDataMessage, }; use service_rpc::AccountRef; use tokio::sync::{Mutex, RwLock}; use crate::config::ChestRewardConfig; use crate::home::{build_avatar, build_home}; use crate::rewards::RewardRoller; use crate::shop::{ShopCatalog, ShopOffer}; use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; #[derive(Debug, Default)] pub struct TurnResult { pub claims: Vec, pub purchases: Vec, pub out_of_sync: Option, pub changed: bool, } pub struct HomeMode { logic: LogicHomeMode, } impl HomeMode { pub fn from_profile(profile: &PlayerProfile, current_timestamp: i32) -> Self { Self { logic: LogicHomeMode::new( build_home(profile), build_avatar(profile), current_timestamp, ), } } pub fn logic(&self) -> &LogicHomeMode { &self.logic } pub fn own_home_data(&self, random_seed: i32) -> OwnHomeDataMessage { OwnHomeDataMessage::new( self.logic.home().clone(), self.logic.avatar().clone(), random_seed, ) } fn purchase( &mut self, data: &LogicDataRef, count: i32, shop: &ShopCatalog, ) -> Result { let offer = shop .offer_for(data) .filter(|offer| offer.give.count == count) .ok_or("nothing in the shop sells that")? .clone(); if !offer.cost.is_resource() { return Err("the price must be a resource"); } match offer.cost.data.name() { RESOURCE_GOLD if self.logic.spend_gold(offer.cost.count) => {} RESOURCE_DIAMONDS if self.logic.spend_diamonds(offer.cost.count) => {} RESOURCE_GOLD | RESOURCE_DIAMONDS => return Err("the player cannot afford it"), _ => return Err("only gold and diamonds are accepted"), } if offer.give.is_resource() { match offer.give.data.name() { RESOURCE_GOLD => self.logic.add_gold(offer.give.count), RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count), _ => return Err("only gold and diamonds can be sold"), } } else if offer.give.is_card() { self.logic .grant_card(offer.give.data.clone(), offer.give.count); } Ok(offer) } pub fn end_client_turn( &mut self, turn: &EndClientTurnMessage, reward: &ChestRewardConfig, roller: &RewardRoller, shop: &ShopCatalog, ) -> TurnResult { let mut result = TurnResult::default(); let ticked = self .logic .fast_forward_to_tick(turn.tick, MAX_FAST_FORWARD_TICKS); if ticked > 0 { result.changed = true; } let mut pending = Vec::new(); for command in &turn.commands { match self.logic.execute(command.as_ref()) { CommandOutcome::ClaimStarted { source, chest_id } => { pending.push((source, chest_id)); } CommandOutcome::Upgraded { spell, level, gold_spent, } => { tracing::info!(card = %spell, level, gold = gold_spent, "card upgraded"); result.changed = true; } CommandOutcome::Rejected(reason) => { tracing::warn!( command = command.command_type(), reason, "client command rejected" ); } CommandOutcome::PurchaseRequested { data, count } => { match self.purchase(&data, count, shop) { Ok(offer) => { if offer.give.is_chest() { pending.push((chest_source::PURCHASED, 0)); } result.changed = true; result.purchases.push(offer); } Err(reason) => { tracing::warn!( command = command.command_type(), item = %data, reason, "shop purchase rejected" ); } } } CommandOutcome::Applied => result.changed = true, CommandOutcome::Ignored => {} } } let server_checksum = self.logic.checksum(); if server_checksum != turn.checksum { result.out_of_sync = Some(OutOfSyncMessage { server_checksum, client_checksum: turn.checksum, tick: turn.tick, }); } for (source, chest_id) in pending { let rolled = roller.roll(reward); self.logic.apply_reward(&rolled); result.changed = true; result .claims .push(LogicClaimRewardCommand::new(rolled, chest_id, source)); } result } pub fn write_back(&self, profile: &mut PlayerProfile) { let home = self.logic.home(); let avatar = self.logic.avatar(); profile.gold = self.logic.gold(); profile.diamonds = avatar.diamonds; profile.free_diamonds = avatar.free_diamonds; profile.exp_level = avatar.exp_level; profile.exp_points = avatar.exp_points; profile.trophies = avatar.score; profile.arena = avatar.arena.clone(); profile.deck = home .decks .first() .map(|deck| { deck.slots .iter() .flatten() .map(|spell| OwnedCard { card: spell.data.clone(), level_index: spell.level_index, count: spell.count, }) .collect() }) .unwrap_or_default(); profile.collection = home .spell_collection .spells .iter() .map(|spell| OwnedCard { card: spell.data.clone(), level_index: spell.level_index, count: spell.count, }) .collect(); profile.chest_id_counter = home.chest_id_counter; profile.free_chest_collect_count = home.free_chest_collect_count; profile.crowns_towards_crown_chest = home.crowns_towards_crown_chest; profile.chest_slot_count = home.chest_slots.len(); profile.chests = home .chest_slots .iter() .flatten() .map(|chest| { let timer = chest.unlock_timer.unwrap_or(LogicTimer::idle()); StoredChest { chest: chest.data.clone(), chest_id: chest.chest_id, slot_index: chest.slot_index, source: chest.source, unlocked: chest.unlocked, claimed: chest.claimed, remaining_ticks: timer.remaining_ticks, total_ticks: timer.total_ticks, end_timestamp: timer.end_timestamp, } }) .collect(); } } #[derive(Default)] pub struct HomeModeRegistry { sessions: RwLock>>>, } impl HomeModeRegistry { pub async fn get_or_open( &self, account: AccountRef, profile: &PlayerProfile, current_timestamp: i32, ) -> Arc> { if let Some(session) = self.sessions.read().await.get(&account) { return Arc::clone(session); } let mut sessions = self.sessions.write().await; Arc::clone(sessions.entry(account).or_insert_with(|| { Arc::new(Mutex::new(HomeMode::from_profile( profile, current_timestamp, ))) })) } pub async fn close(&self, account: AccountRef) -> Option>> { self.sessions.write().await.remove(&account) } pub async fn session_count(&self) -> usize { self.sessions.read().await.len() } } pub fn available_server_command(command: LogicClaimRewardCommand) -> AvailableServerCommandMessage { AvailableServerCommandMessage::claim_reward(command) }