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:
parent
573d70ff08
commit
ca8e41c2c5
8 changed files with 208 additions and 66 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue