From ca8e41c2c51dd182c5e4d18d1f30ff55512bd0c9 Mon Sep 17 00:00:00 2001 From: WiseDev <83840010+wisedevik@users.noreply.github.com> Date: Sun, 23 Aug 2026 09:30:38 +0300 Subject: [PATCH] roll chest loot from the csv instead of a fixed config RandomSpells / DifferentSpells / RareChance / EpicChance / MinGold / MaxGold all come off the chest row now, gold is scaled by the player's arena. magic chest is 30 cards over 8 cards with 1 epic and 6 rares, like the client says it is. arena chest rows inherit everything through BaseChest, so Free_Arena1 reads Free. --- crates/game-service/src/config.rs | 22 ------ crates/game-service/src/home_mode.rs | 37 ++++++++-- crates/game-service/src/lib.rs | 2 +- crates/game-service/src/rewards.rs | 102 ++++++++++++++++++++++----- crates/game-service/src/service.rs | 2 +- crates/logic/src/data/data_ref.rs | 12 ++++ crates/logic/src/data/mod.rs | 4 +- crates/logic/src/data/typed.rs | 93 +++++++++++++++++++----- 8 files changed, 208 insertions(+), 66 deletions(-) diff --git a/crates/game-service/src/config.rs b/crates/game-service/src/config.rs index 3b962a7..139273a 100644 --- a/crates/game-service/src/config.rs +++ b/crates/game-service/src/config.rs @@ -73,25 +73,6 @@ impl Default for StarterProfile { } } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ChestRewardConfig { - pub gold_min: i32, - pub gold_max: i32, - pub diamonds: i32, - pub different_cards: usize, - pub copies_per_card: i32, -} -impl Default for ChestRewardConfig { - fn default() -> Self { - Self { - gold_min: 15, - gold_max: 45, - diamonds: 0, - different_cards: 2, - copies_per_card: 3, - } - } -} #[derive(Debug, Clone)] pub struct GameConfig { pub database: Option, @@ -99,7 +80,6 @@ pub struct GameConfig { pub csv_root: Option, pub shop_path: Option, pub starter: StarterProfile, - pub chest_reward: ChestRewardConfig, pub random_seed: i32, } impl Default for GameConfig { @@ -110,7 +90,6 @@ impl Default for GameConfig { csv_root: Some(PathBuf::from("assets")), shop_path: Some(PathBuf::from("config/shop.json")), starter: StarterProfile::default(), - chest_reward: ChestRewardConfig::default(), random_seed: 0x5EED, } } @@ -135,7 +114,6 @@ impl GameConfig { .map(PathBuf::from) .or(defaults.shop_path), starter, - chest_reward: defaults.chest_reward, random_seed: env_string("SCROLL_RANDOM_SEED") .and_then(|value| value.parse().ok()) .unwrap_or(defaults.random_seed), diff --git a/crates/game-service/src/home_mode.rs b/crates/game-service/src/home_mode.rs index d487534..f7034ba 100644 --- a/crates/game-service/src/home_mode.rs +++ b/crates/game-service/src/home_mode.rs @@ -9,12 +9,13 @@ use logic::{ use logic::{table, LogicGlobals}; 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::{Purchase, ShopCatalog, ShopCycle, ShopEntry}; use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; +pub const CHEST_FREE: &str = "Free"; +pub const CHEST_CROWN: &str = "Star"; fn gold() -> LogicDataRef { LogicDataRef::by_name(table::RESOURCES, RESOURCE_GOLD) } @@ -65,6 +66,18 @@ impl HomeMode { random_seed, ) } + fn chest_for(&self, source: i32, chest_id: i32) -> LogicDataRef { + let arena = &self.logic.avatar().arena; + match source { + chest_source::SLOT => self + .logic + .chest_with_id(chest_id) + .map(|chest| chest.data.clone()) + .unwrap_or_default(), + chest_source::CROWN => LogicDataRef::treasure_chest_for_arena(CHEST_CROWN, arena), + _ => LogicDataRef::treasure_chest_for_arena(CHEST_FREE, arena), + } + } fn market_cost(&self, give: &ShopEntry) -> Result { if let Some(chest) = give.data.as_treasure_chest() { let price = chest.shop_price(); @@ -146,7 +159,6 @@ impl HomeMode { pub fn end_client_turn( &mut self, turn: &EndClientTurnMessage, - reward: &ChestRewardConfig, roller: &RewardRoller, shop: &ShopCatalog, ) -> TurnResult { @@ -161,7 +173,8 @@ impl HomeMode { for command in &turn.commands { match self.logic.execute(command.as_ref()) { CommandOutcome::ClaimStarted { source, chest_id } => { - pending.push((source, chest_id)); + let chest = self.chest_for(source, chest_id); + pending.push((source, chest_id, chest)); } CommandOutcome::Upgraded { spell, @@ -182,7 +195,11 @@ impl HomeMode { match self.purchase(&data, count, shop) { Ok(purchase) => { if purchase.give.is_chest() { - pending.push((chest_source::PURCHASED, 0)); + pending.push(( + chest_source::PURCHASED, + 0, + purchase.give.data.clone(), + )); } result.changed = true; result.purchases.push(purchase); @@ -209,8 +226,16 @@ impl HomeMode { tick: turn.tick, }); } - for (source, chest_id) in pending { - let rolled = roller.roll(reward); + let arena = self.logic.avatar().arena.clone(); + for (source, chest_id, chest) in pending { + let rolled = roller.roll(&chest, &arena); + tracing::info!( + chest = %chest, + gold = rolled.gold, + cards = rolled.spell_count(), + copies = rolled.card_copies(), + "chest opened" + ); self.logic.apply_reward(&rolled); result.changed = true; result diff --git a/crates/game-service/src/lib.rs b/crates/game-service/src/lib.rs index 0ec22fd..fafcfc2 100644 --- a/crates/game-service/src/lib.rs +++ b/crates/game-service/src/lib.rs @@ -8,7 +8,7 @@ pub mod shop; pub mod store; pub mod time; pub use catalog::{Catalog, ARENA_FALLBACK_INSTANCE, GOLD_RESOURCE_FALLBACK_INSTANCE}; -pub use config::{CardRef, ChestRewardConfig, DataSelector, GameConfig, StarterProfile}; +pub use config::{CardRef, DataSelector, GameConfig, StarterProfile}; pub use home::{build_avatar, build_home}; pub use home_mode::{HomeMode, HomeModeRegistry, TurnResult}; pub use rewards::RewardRoller; diff --git a/crates/game-service/src/rewards.rs b/crates/game-service/src/rewards.rs index 156b378..1b399af 100644 --- a/crates/game-service/src/rewards.rs +++ b/crates/game-service/src/rewards.rs @@ -1,9 +1,9 @@ +use logic::data::{RARITY_COMMON, RARITY_EPIC, RARITY_RARE}; use logic::model::LogicSpell; use logic::{LogicDataRef, LogicReward}; use rand::seq::SliceRandom; use rand::Rng; use crate::catalog::Catalog; -use crate::config::ChestRewardConfig; pub struct RewardRoller { pool: Vec, } @@ -18,23 +18,93 @@ impl RewardRoller { pub fn is_empty(&self) -> bool { self.pool.is_empty() } - pub fn roll(&self, config: &ChestRewardConfig) -> LogicReward { - let mut rng = rand::thread_rng(); - let gold = if config.gold_max > config.gold_min { - rng.gen_range(config.gold_min..=config.gold_max) - } else { - config.gold_min - }; - let mut cards: Vec = self - .pool - .choose_multiple(&mut rng, config.different_cards) + fn by_rarity(&self, rarity: &str) -> Vec { + self.pool + .iter() + .filter(|card| { + card.as_spell() + .map(|spell| spell.rarity() == rarity) + .unwrap_or(false) + }) .cloned() - .collect(); - cards.sort_by_key(|card| card.global_id().map(|id| (id.class_id, id.instance_id))); - let spells = cards + .collect() + } + pub fn roll(&self, chest: &LogicDataRef, arena: &LogicDataRef) -> LogicReward { + let Some(data) = chest.as_treasure_chest() else { + return LogicReward::default(); + }; + let mut rng = rand::thread_rng(); + let scale = |value: i32| match arena.as_arena() { + Some(arena) => arena.scaled_chest_reward(value), + None => value, + }; + let gold = { + let low = scale(data.min_gold()); + let high = scale(data.max_gold()); + if high > low { + rng.gen_range(low..=high) + } else { + low + } + }; + let diamonds = { + let low = data.min_diamonds(); + let high = data.max_diamonds(); + if high > low { + rng.gen_range(low..=high) + } else { + low + } + }; + let copies = data.random_spells().max(0); + let distinct = data.different_spells().max(1) as usize; + let mut picked: Vec = Vec::new(); + let guaranteed = data.guaranteed_spell(); + if !guaranteed.is_none() { + picked.push(guaranteed); + } + for (rarity, count) in [ + (RARITY_EPIC, data.guaranteed_epics()), + (RARITY_RARE, data.guaranteed_rares()), + ] { + let mut candidates = self.by_rarity(rarity); + candidates.retain(|card| !picked.contains(card)); + candidates.shuffle(&mut rng); + for card in candidates.into_iter().take(count.max(0) as usize) { + if picked.len() >= distinct { + break; + } + picked.push(card); + } + } + if picked.len() < distinct { + let mut candidates = self.by_rarity(RARITY_COMMON); + candidates.retain(|card| !picked.contains(card)); + candidates.shuffle(&mut rng); + for card in candidates { + if picked.len() >= distinct { + break; + } + picked.push(card); + } + } + if picked.is_empty() { + return LogicReward::new(Vec::new(), gold, diamonds); + } + picked.sort_by_key(|card| card.global_id().map(|id| (id.class_id, id.instance_id))); + let share = copies / picked.len() as i32; + let mut remainder = copies % picked.len() as i32; + let spells = picked .into_iter() - .map(|card| LogicSpell::card(card, 0, config.copies_per_card)) + .map(|card| { + let mut count = share; + if remainder > 0 { + count += 1; + remainder -= 1; + } + LogicSpell::card(card, 0, count.max(1)) + }) .collect(); - LogicReward::new(spells, gold, config.diamonds) + LogicReward::new(spells, gold, diamonds) } } diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index 88f8f5c..baad5b1 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -182,7 +182,7 @@ impl GameApi for GameService { let roller = self.roller(account).await?; let result = { let mut home = session.lock().await; - home.end_client_turn(&turn, &self.config.chest_reward, &roller, &self.shop) + home.end_client_turn(&turn, &roller, &self.shop) }; tracing::debug!( %account, diff --git a/crates/logic/src/data/data_ref.rs b/crates/logic/src/data/data_ref.rs index 053aa9f..dceb9bc 100644 --- a/crates/logic/src/data/data_ref.rs +++ b/crates/logic/src/data/data_ref.rs @@ -52,6 +52,18 @@ impl LogicDataRef { pub fn treasure_chest(name: &str) -> Self { Self::by_name(table::TREASURE_CHESTS, name) } + pub fn treasure_chest_for_arena(base: &str, arena: &Self) -> Self { + if let Some(data) = arena.as_arena() { + if !data.is_training_camp() { + let scoped = + Self::by_name(table::TREASURE_CHESTS, &format!("{base}_{}", data.name())); + if !scoped.is_none() { + return scoped; + } + } + } + Self::by_name(table::TREASURE_CHESTS, base) + } pub fn is_none(&self) -> bool { matches!(self, LogicDataRef::None) } diff --git a/crates/logic/src/data/mod.rs b/crates/logic/src/data/mod.rs index 266a81c..564ea57 100644 --- a/crates/logic/src/data/mod.rs +++ b/crates/logic/src/data/mod.rs @@ -14,6 +14,6 @@ pub use logic_data_tables::{ }; pub use tables::{table, DataId, RESOURCE_DIAMONDS, RESOURCE_FREE_GOLD, RESOURCE_GOLD}; pub use typed::{ - LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, - LogicTreasureChestData, + arena_by_index, LogicArenaData, LogicRarityData, LogicResourceData, LogicResourcePackData, + LogicSpellData, LogicTreasureChestData, }; diff --git a/crates/logic/src/data/typed.rs b/crates/logic/src/data/typed.rs index 47aed73..3e2fe8a 100644 --- a/crates/logic/src/data/typed.rs +++ b/crates/logic/src/data/typed.rs @@ -52,6 +52,36 @@ impl LogicArenaData { pub fn scaled_chest_price(&self, price: i32) -> i32 { (self.chest_shop_price_multiplier() as i64 * price as i64 / 100) as i32 } + pub fn chest_reward_multiplier(&self) -> i32 { + self.int("ChestRewardMultiplier") + } + pub fn previous(&self) -> Option { + let index = self.index(); + if index <= 0 { + return None; + } + arena_by_index(index - 1) + } + pub fn scaled_chest_reward(&self, value: i32) -> i32 { + let multiplier = self.chest_reward_multiplier(); + let scaled = ((multiplier as i64 * value as i64 + 50) / 100) as i32; + match self.previous() { + Some(previous) if previous.chest_reward_multiplier() < multiplier => previous + .scaled_chest_reward(value) + .saturating_add(1) + .max(scaled), + _ => scaled, + } + } +} +pub fn arena_by_index(index: i32) -> Option { + let tables = crate::data::logic_data_tables::LogicDataTables::instance(); + let rows = tables.table(crate::data::tables::table::ARENAS)?; + let found = rows + .iter() + .map(|row| LogicArenaData::new(Arc::clone(row))) + .find(|arena| arena.index() == index); + found } fn clamp_low_first(value: i64, low: i64, high: i64) -> i64 { if value > low { @@ -114,6 +144,51 @@ impl LogicTreasureChestData { let quotient = (cost * seconds_left as i64 + total - 1) / total; clamp_low_first(quotient, 1, cost) as i32 } + pub fn random_spells(&self) -> i32 { + self.root().int("RandomSpells") + } + pub fn different_spells(&self) -> i32 { + self.root().int("DifferentSpells") + } + pub fn rare_chance(&self) -> i32 { + self.root().int("RareChance") + } + pub fn epic_chance(&self) -> i32 { + self.root().int("EpicChance") + } + pub fn guaranteed_spell(&self) -> LogicDataRef { + let name = self.root().string("GuaranteedSpells").to_owned(); + if name.is_empty() { + return LogicDataRef::None; + } + LogicDataRef::by_name(crate::data::tables::table::SPELLS, &name) + } + pub fn min_gold(&self) -> i32 { + self.root().int("MinGold") + } + pub fn max_gold(&self) -> i32 { + self.root().int("MaxGold") + } + pub fn min_diamonds(&self) -> i32 { + self.root().int("MinDiamonds") + } + pub fn max_diamonds(&self) -> i32 { + self.root().int("MaxDiamonds") + } + pub fn guaranteed_rares(&self) -> i32 { + let chance = self.rare_chance(); + if chance <= 0 { + return 0; + } + self.random_spells() / chance + } + pub fn guaranteed_epics(&self) -> i32 { + let chance = self.epic_chance(); + if chance <= 0 { + return 0; + } + self.random_spells() / chance + } pub fn unlock_seconds(&self) -> i32 { let root = self.root(); root.int("TimeTakenDays").saturating_mul(86_400) @@ -207,24 +282,6 @@ impl LogicTreasureChestData { pub fn arena(&self) -> &str { self.string("Arena") } - pub fn total_time_taken_seconds(&self) -> i32 { - self.int("TimeTakenDays") * 86_400 - + self.int("TimeTakenHours") * 3_600 - + self.int("TimeTakenMinutes") * 60 - + self.int("TimeTakenSeconds") - } - pub fn random_spells(&self) -> i32 { - self.int("RandomSpells") - } - pub fn different_spells(&self) -> i32 { - self.int("DifferentSpells") - } - pub fn min_gold(&self) -> i32 { - self.int("MinGold") - } - pub fn max_gold(&self) -> i32 { - self.int("MaxGold") - } } impl LogicRarityData { pub fn level_count(&self) -> i32 {