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.
This commit is contained in:
WiseDev 2026-08-23 09:30:38 +03:00
parent 573d70ff08
commit ca8e41c2c5
8 changed files with 208 additions and 66 deletions

View file

@ -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<DatabaseConfig>,
@ -99,7 +80,6 @@ pub struct GameConfig {
pub csv_root: Option<PathBuf>,
pub shop_path: Option<PathBuf>,
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),

View file

@ -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<ShopEntry, &'static str> {
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

View file

@ -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;

View file

@ -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<LogicDataRef>,
}
@ -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<LogicDataRef> = self
.pool
.choose_multiple(&mut rng, config.different_cards)
fn by_rarity(&self, rarity: &str) -> Vec<LogicDataRef> {
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<LogicDataRef> = 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)
}
}

View file

@ -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,

View file

@ -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)
}

View file

@ -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,
};

View file

@ -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<LogicArenaData> {
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<LogicArenaData> {
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 {