diff --git a/config/shop.example.json b/config/shop.example.json index c86b213..e515a18 100644 --- a/config/shop.example.json +++ b/config/shop.example.json @@ -1,12 +1,11 @@ { "offers": [ - { "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, - { "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, - { "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, - { "id": 4, "give": "Knight#10", "cost": "Gold#500" }, - { "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, - { "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, - { "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, - { "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" } + { "id": 1, "give": "Gold#1000" }, + { "id": 2, "give": "Gold#10000" }, + { "id": 3, "give": "Gold#100000" }, + { "id": 4, "give": "chest:Silver#1" }, + { "id": 5, "give": "chest:Gold#1" }, + { "id": 6, "give": "chest:Magic#1" }, + { "id": 7, "give": "Diamonds#500", "cost": "Gold#20000" } ] } diff --git a/crates/auth-service/src/service.rs b/crates/auth-service/src/service.rs index c016f12..bc90f31 100644 --- a/crates/auth-service/src/service.rs +++ b/crates/auth-service/src/service.rs @@ -6,10 +6,10 @@ use service_rpc::{ use crate::config::AuthConfig; use crate::store::{unavailable, AccountStore}; use crate::time::{days_between, format_utc, unix_millis, unix_seconds}; -pub fn missing_database() -> std::io::Error { - std::io::Error::other( - "DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", - ) +fn connect_failed(error: storage::StorageError) -> std::io::Error { + std::io::Error::other(format!( + "{error}\ncreate the database with `createdb scroll`, or point DATABASE_URL somewhere else" + )) } pub const ERROR_CODE_GENERIC: i32 = 1; pub const ERROR_CODE_PATCH: i32 = 7; @@ -21,10 +21,12 @@ pub struct AuthService { } impl AuthService { pub async fn bootstrap(config: AuthConfig) -> std::io::Result> { - let database = config.database.as_ref().ok_or_else(missing_database)?; - let store = AccountStore::open(database).await?; + let database = config.database.clone().unwrap_or_default(); + let store = AccountStore::open(&database) + .await + .map_err(connect_failed)?; let accounts = store.count().await?; - tracing::info!(accounts, "account store ready"); + tracing::info!(accounts, url = %database.url, "account store ready"); Ok(Arc::new(Self { config, store })) } pub fn store(&self) -> &AccountStore { diff --git a/crates/game-service/src/home_mode.rs b/crates/game-service/src/home_mode.rs index 67132f3..adf589d 100644 --- a/crates/game-service/src/home_mode.rs +++ b/crates/game-service/src/home_mode.rs @@ -6,18 +6,25 @@ use logic::{ LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage, OwnHomeDataMessage, }; +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::{ShopCatalog, ShopOffer}; +use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry}; use crate::store::{OwnedCard, PlayerProfile, StoredChest}; pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; +fn gold() -> LogicDataRef { + LogicDataRef::by_name(table::RESOURCES, RESOURCE_GOLD) +} +fn diamonds() -> LogicDataRef { + LogicDataRef::by_name(table::RESOURCES, RESOURCE_DIAMONDS) +} #[derive(Debug, Default)] pub struct TurnResult { pub claims: Vec, - pub purchases: Vec, + pub purchases: Vec, pub out_of_sync: Option, pub changed: bool, } @@ -26,13 +33,26 @@ pub struct HomeMode { } impl HomeMode { pub fn from_profile(profile: &PlayerProfile, current_timestamp: i32) -> Self { - Self { - logic: LogicHomeMode::new( - build_home(profile), - build_avatar(profile), - current_timestamp, - ), + let mut logic = LogicHomeMode::new( + build_home(profile), + build_avatar(profile), + current_timestamp, + ); + let cycle = ShopCycle::at(current_timestamp); + logic.set_shop(cycle.seed, cycle.seconds_to_cycle, cycle.weekday_index); + Self { logic } + } + pub fn shop_cycle(&self) -> ShopCycle { + ShopCycle::at(self.logic.current_timestamp()) + } + pub fn refresh_shop(&mut self) -> Option { + let cycle = self.shop_cycle(); + if self.logic.home().shop_seed == cycle.seed { + return None; } + self.logic + .set_shop(cycle.seed, cycle.seconds_to_cycle, cycle.weekday_index); + Some(cycle) } pub fn logic(&self) -> &LogicHomeMode { &self.logic @@ -44,37 +64,83 @@ impl HomeMode { random_seed, ) } + fn market_cost(&self, give: &ShopEntry) -> Result { + if let Some(chest) = give.data.as_treasure_chest() { + let price = chest.int("ShopPriceWithoutSpeedUp"); + if price < 1 { + return Err("that chest is not sold in the shop"); + } + return Ok(ShopEntry::new(diamonds(), price)); + } + if give.is_resource() { + return Ok(ShopEntry::new( + diamonds(), + LogicGlobals::resource_diamond_cost(give.count), + )); + } + Err("that item has no market price") + } + fn pay(&mut self, cost: &ShopEntry) -> Result<(), &'static str> { + if !cost.is_resource() { + return Err("the price must be a resource"); + } + match cost.data.name() { + RESOURCE_GOLD if self.logic.spend_gold(cost.count) => Ok(()), + RESOURCE_DIAMONDS if self.logic.spend_diamonds(cost.count) => Ok(()), + RESOURCE_GOLD | RESOURCE_DIAMONDS => Err("the player cannot afford it"), + _ => Err("only gold and diamonds are accepted"), + } + } + fn buy_card(&mut self, card: &LogicDataRef) -> Result { + let spell = card.as_spell().ok_or("that is not a card")?; + let buy_times = self + .logic + .shop_buy_times(card) + .ok_or("that card is not on sale right now")?; + if buy_times >= spell.shop_buy_limit() { + return Err("the buy limit for that card is reached"); + } + let cost = ShopEntry::new(gold(), spell.cost_in_shop(buy_times)); + self.pay(&cost)?; + self.logic.increase_sold_spells(card); + self.logic.grant_card(card.clone(), 1); + Ok(Purchase { + offer_id: 0, + give: ShopEntry::new(card.clone(), 1), + cost, + }) + } fn purchase( &mut self, data: &LogicDataRef, count: i32, shop: &ShopCatalog, - ) -> Result { + ) -> Result { + if data.as_spell().is_some() { + return self.buy_card(data); + } 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"), - } + let cost = match &offer.cost { + Some(cost) => cost.clone(), + None => self.market_cost(&offer.give)?, + }; + self.pay(&cost)?; 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) + Ok(Purchase { + offer_id: offer.id, + give: offer.give, + cost, + }) } pub fn end_client_turn( &mut self, @@ -113,12 +179,12 @@ impl HomeMode { } CommandOutcome::PurchaseRequested { data, count } => { match self.purchase(&data, count, shop) { - Ok(offer) => { - if offer.give.is_chest() { + Ok(purchase) => { + if purchase.give.is_chest() { pending.push((chest_source::PURCHASED, 0)); } result.changed = true; - result.purchases.push(offer); + result.purchases.push(purchase); } Err(reason) => { tracing::warn!( diff --git a/crates/game-service/src/service.rs b/crates/game-service/src/service.rs index f5eb00f..88f8f5c 100644 --- a/crates/game-service/src/service.rs +++ b/crates/game-service/src/service.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use logic::{ - EndClientTurnMessage, LogicCommandManager, LogicDataRef, OutOfSyncMessage, OwnHomeDataMessage, + AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef, + LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage, }; use service_rpc::{ AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, @@ -11,8 +12,8 @@ use crate::catalog::Catalog; use crate::config::GameConfig; use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::rewards::RewardRoller; -use crate::shop::ShopCatalog; -use crate::store::{missing_database, unavailable, PlayerProfile, ProfileStore}; +use crate::shop::{ShopCatalog, ShopCycle}; +use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore}; use crate::time::unix_seconds; pub struct GameService { config: GameConfig, @@ -24,8 +25,10 @@ pub struct GameService { 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.as_ref().ok_or_else(missing_database)?; - let profiles = ProfileStore::open(database).await?; + 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())); for offer in shop.describe() { @@ -110,11 +113,22 @@ impl GameApi for GameService { .sessions .get_or_open(account, &profile, unix_seconds() as i32) .await; - let (home_data, checksum): (OwnHomeDataMessage, i32) = { + 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!( @@ -125,7 +139,24 @@ impl GameApi for GameService { checksum, "serving own home data" ); - Ok(vec![encode(&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"); @@ -171,16 +202,16 @@ impl GameApi for GameService { ); replies.push(encode::(&out_of_sync)?); } - for offer in &result.purchases { + for purchase in &result.purchases { tracing::info!( %account, - offer = offer.id, - cost = %offer.cost, - give = %offer.give, + offer = purchase.offer_id, + cost = %purchase.cost, + give = %purchase.give, "shop purchase" ); - if let Err(error) = self.profiles.record_purchase(account, offer).await { - tracing::warn!(%account, offer = offer.id, %error, "could not record the 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 { diff --git a/crates/game-service/src/shop/catalog.rs b/crates/game-service/src/shop/catalog.rs index dd7e43e..8fa9377 100644 --- a/crates/game-service/src/shop/catalog.rs +++ b/crates/game-service/src/shop/catalog.rs @@ -6,7 +6,8 @@ pub const DEFAULT_SHOP: &str = include_str!("default_shop.json"); pub struct ShopOffer { pub id: i32, pub give: ShopEntry, - pub cost: ShopEntry, + #[serde(default)] + pub cost: Option, } impl ShopOffer { pub fn sells(&self, data: &logic::LogicDataRef) -> bool { @@ -67,7 +68,10 @@ impl ShopCatalog { pub fn describe(&self) -> Vec { self.offers .iter() - .map(|offer| format!("{} = {} -> {}", offer.id, offer.cost, offer.give)) + .map(|offer| match &offer.cost { + Some(cost) => format!("{} = {} -> {}", offer.id, cost, offer.give), + None => format!("{} = market price -> {}", offer.id, offer.give), + }) .collect() } } diff --git a/crates/game-service/src/shop/cycle.rs b/crates/game-service/src/shop/cycle.rs new file mode 100644 index 0000000..9cddde2 --- /dev/null +++ b/crates/game-service/src/shop/cycle.rs @@ -0,0 +1,19 @@ +pub const SECONDS_PER_DAY: i32 = 86_400; +pub const WEEKDAYS: i32 = 7; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ShopCycle { + pub seed: i32, + pub weekday_index: i32, + pub seconds_to_cycle: i32, +} +impl ShopCycle { + pub fn at(timestamp: i32) -> Self { + let day = timestamp.div_euclid(SECONDS_PER_DAY); + let seconds_into_day = timestamp.rem_euclid(SECONDS_PER_DAY); + Self { + seed: day.wrapping_mul(2_654_435_761u32 as i32) | 1, + weekday_index: day.rem_euclid(WEEKDAYS) + 1, + seconds_to_cycle: SECONDS_PER_DAY - seconds_into_day, + } + } +} diff --git a/crates/game-service/src/shop/default_shop.json b/crates/game-service/src/shop/default_shop.json index c86b213..e515a18 100644 --- a/crates/game-service/src/shop/default_shop.json +++ b/crates/game-service/src/shop/default_shop.json @@ -1,12 +1,11 @@ { "offers": [ - { "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, - { "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, - { "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, - { "id": 4, "give": "Knight#10", "cost": "Gold#500" }, - { "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, - { "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, - { "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, - { "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" } + { "id": 1, "give": "Gold#1000" }, + { "id": 2, "give": "Gold#10000" }, + { "id": 3, "give": "Gold#100000" }, + { "id": 4, "give": "chest:Silver#1" }, + { "id": 5, "give": "chest:Gold#1" }, + { "id": 6, "give": "chest:Magic#1" }, + { "id": 7, "give": "Diamonds#500", "cost": "Gold#20000" } ] } diff --git a/crates/game-service/src/shop/mod.rs b/crates/game-service/src/shop/mod.rs index 7bed8ef..7a279a5 100644 --- a/crates/game-service/src/shop/mod.rs +++ b/crates/game-service/src/shop/mod.rs @@ -1,4 +1,12 @@ mod catalog; +mod cycle; mod entry; pub use catalog::{ShopCatalog, ShopOffer, DEFAULT_SHOP}; +pub use cycle::{ShopCycle, SECONDS_PER_DAY, WEEKDAYS}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Purchase { + pub offer_id: i32, + pub give: ShopEntry, + pub cost: ShopEntry, +} pub use entry::{EntryError, ShopEntry, LOOKUP, QUALIFIER, SEPARATOR}; diff --git a/crates/game-service/src/store/mod.rs b/crates/game-service/src/store/mod.rs index 22d6f0c..37563a1 100644 --- a/crates/game-service/src/store/mod.rs +++ b/crates/game-service/src/store/mod.rs @@ -9,8 +9,8 @@ pub use profile::{ pub fn unavailable(error: StorageError) -> RpcError { RpcError::Unavailable(error.to_string()) } -pub fn missing_database() -> std::io::Error { - std::io::Error::other( - "DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", - ) +pub fn connect_failed(error: StorageError) -> std::io::Error { + std::io::Error::other(format!( + "{error}\ncreate the database with `createdb scroll`, or point DATABASE_URL somewhere else" + )) } diff --git a/crates/game-service/src/store/postgres.rs b/crates/game-service/src/store/postgres.rs index 50063d9..fe0a315 100644 --- a/crates/game-service/src/store/postgres.rs +++ b/crates/game-service/src/store/postgres.rs @@ -2,7 +2,7 @@ use logic::LogicDataRef; use service_rpc::AccountRef; use storage::sqlx::{Postgres, QueryBuilder}; use storage::{Database, DatabaseConfig, PgRow, Result, Row}; -use crate::shop::ShopOffer; +use crate::shop::Purchase; use crate::store::{ OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, }; @@ -341,9 +341,9 @@ impl ProfileStore { .await?; Ok(total.max(0) as usize) } - pub async fn record_purchase(&self, account: AccountRef, offer: &ShopOffer) -> Result<()> { - let (cost_table, cost_instance, _) = parts(&offer.cost.data); - let (give_table, give_instance, _) = parts(&offer.give.data); + pub async fn record_purchase(&self, account: AccountRef, purchase: &Purchase) -> Result<()> { + let (cost_table, cost_instance, _) = parts(&purchase.cost.data); + let (give_table, give_instance, _) = parts(&purchase.give.data); storage::sqlx::query( "insert into shop_purchases ( account_high, account_low, offer_id, @@ -354,13 +354,13 @@ impl ProfileStore { ) .bind(account.high) .bind(account.low) - .bind(offer.id) + .bind(purchase.offer_id) .bind(cost_table) .bind(cost_instance) - .bind(offer.cost.count) + .bind(purchase.cost.count) .bind(give_table) .bind(give_instance) - .bind(offer.give.count) + .bind(purchase.give.count) .bind(unix_seconds()) .execute(self.database.pool()) .await?; diff --git a/crates/logic/src/data/globals.rs b/crates/logic/src/data/globals.rs new file mode 100644 index 0000000..ae2f5c2 --- /dev/null +++ b/crates/logic/src/data/globals.rs @@ -0,0 +1,75 @@ +use crate::data::data_ref::LogicDataRef; +use crate::data::tables::table; +pub const PRICE_COMMON: &str = "PRICE_COMMON"; +pub const PRICE_RARE: &str = "PRICE_RARE"; +pub const PRICE_EPIC: &str = "PRICE_EPIC"; +pub const PRICE_COMMON_INCREASE: &str = "PRICE_COMMON_INCREASE_PER_ONE_BOUGHT_PERCENT"; +pub const PRICE_RARE_INCREASE: &str = "PRICE_RARE_INCREASE_PER_ONE_BOUGHT_PERCENT"; +pub const PRICE_EPIC_INCREASE: &str = "PRICE_EPIC_INCREASE_PER_ONE_BOUGHT_PERCENT"; +pub const BUY_LIMIT_COMMON: &str = "BUY_LIMIT_COMMON"; +pub const BUY_LIMIT_RARE: &str = "BUY_LIMIT_RARE"; +pub const BUY_LIMIT_EPIC: &str = "BUY_LIMIT_EPIC"; +pub const RARITY_COMMON: &str = "Common"; +pub const RARITY_RARE: &str = "Rare"; +pub const RARITY_EPIC: &str = "Epic"; +pub struct LogicGlobals; +impl LogicGlobals { + pub fn number(name: &str) -> i32 { + LogicDataRef::by_name(table::GLOBALS, name) + .data() + .map(|data| data.int("NumberValue")) + .unwrap_or(0) + } + pub fn base_price(rarity: &str) -> i32 { + Self::number(match rarity { + RARITY_EPIC => PRICE_EPIC, + RARITY_RARE => PRICE_RARE, + _ => PRICE_COMMON, + }) + } + pub fn price_increase_percent(rarity: &str) -> i32 { + Self::number(match rarity { + RARITY_EPIC => PRICE_EPIC_INCREASE, + RARITY_RARE => PRICE_RARE_INCREASE, + _ => PRICE_COMMON_INCREASE, + }) + } + pub fn resource_diamond_cost(amount: i32) -> i32 { + const STEPS: [(i32, &str); 7] = [ + (1, "RESOURCE_DIAMOND_COST_1"), + (10, "RESOURCE_DIAMOND_COST_10"), + (100, "RESOURCE_DIAMOND_COST_100"), + (1_000, "RESOURCE_DIAMOND_COST_1000"), + (10_000, "RESOURCE_DIAMOND_COST_10000"), + (100_000, "RESOURCE_DIAMOND_COST_100000"), + (1_000_000, "RESOURCE_DIAMOND_COST_1000000"), + ]; + if amount <= STEPS[0].0 { + return Self::number(STEPS[0].1); + } + for window in STEPS.windows(2) { + let (low_amount, low_key) = window[0]; + let (high_amount, high_key) = window[1]; + if amount > high_amount { + continue; + } + let low = Self::number(low_key); + let high = Self::number(high_key); + let span = (high_amount - low_amount) as i64; + if span <= 0 { + return high; + } + let travelled = (amount - low_amount) as i64; + let interpolated = low as i64 + (high - low) as i64 * travelled / span; + return interpolated as i32; + } + Self::number(STEPS[STEPS.len() - 1].1) + } + pub fn buy_limit(rarity: &str) -> i32 { + Self::number(match rarity { + RARITY_EPIC => BUY_LIMIT_EPIC, + RARITY_RARE => BUY_LIMIT_RARE, + _ => BUY_LIMIT_COMMON, + }) + } +} diff --git a/crates/logic/src/data/mod.rs b/crates/logic/src/data/mod.rs index de29cbc..266a81c 100644 --- a/crates/logic/src/data/mod.rs +++ b/crates/logic/src/data/mod.rs @@ -1,10 +1,12 @@ mod data_ref; +mod globals; mod logic_data; mod logic_data_table; mod logic_data_tables; mod tables; mod typed; pub use data_ref::LogicDataRef; +pub use globals::{LogicGlobals, RARITY_COMMON, RARITY_EPIC, RARITY_RARE}; pub use logic_data::LogicData; pub use logic_data_table::LogicDataTable; pub use logic_data_tables::{ diff --git a/crates/logic/src/data/typed.rs b/crates/logic/src/data/typed.rs index 78d9a84..f7659c2 100644 --- a/crates/logic/src/data/typed.rs +++ b/crates/logic/src/data/typed.rs @@ -42,6 +42,11 @@ typed_data!(LogicResourceData); typed_data!(LogicTreasureChestData); typed_data!(LogicRarityData); typed_data!(LogicResourcePackData); +impl LogicArenaData { + pub fn index(&self) -> i32 { + self.int("Arena") + } +} impl LogicResourcePackData { pub fn resource(&self) -> LogicDataRef { LogicDataRef::by_name( @@ -57,6 +62,34 @@ impl LogicSpellData { pub fn rarity(&self) -> &str { self.string("Rarity") } + pub fn unlock_arena_data(&self) -> LogicDataRef { + LogicDataRef::by_name( + crate::data::tables::table::ARENAS, + self.string("UnlockArena"), + ) + } + pub fn is_unlocked_in_arena(&self, arena: &LogicDataRef) -> bool { + let required = self + .unlock_arena_data() + .as_arena() + .map(|a| a.index()) + .unwrap_or(0); + let reached = arena.as_arena().map(|a| a.index()).unwrap_or(0); + reached >= required + } + pub fn cost_in_shop(&self, buy_times: i32) -> i32 { + use crate::data::globals::LogicGlobals; + let rarity = self.rarity(); + let percent = LogicGlobals::price_increase_percent(rarity); + let mut cost = LogicGlobals::base_price(rarity); + for _ in 0..buy_times.max(0) { + cost = cost.saturating_mul(percent) / 100; + } + cost + } + pub fn shop_buy_limit(&self) -> i32 { + crate::data::globals::LogicGlobals::buy_limit(self.rarity()) + } pub fn mana_cost(&self) -> i32 { self.int("ManaCost") } diff --git a/crates/logic/src/home/logic_home_mode.rs b/crates/logic/src/home/logic_home_mode.rs index 0bec567..29e5bbd 100644 --- a/crates/logic/src/home/logic_home_mode.rs +++ b/crates/logic/src/home/logic_home_mode.rs @@ -1,8 +1,8 @@ use crate::commands::{CommandOutcome, LogicCommand, LogicReward}; use crate::data::{LogicDataRef, LogicRarityData}; use crate::model::{ - ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer, - TICKS_PER_SECOND, + ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicDataSlot, LogicSpell, + LogicTimer, TICKS_PER_SECOND, }; #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogicHomeMode { @@ -108,6 +108,33 @@ impl LogicHomeMode { pub fn begin_claim(&mut self) { self.claiming_reward = true; } + pub fn set_shop(&mut self, seed: i32, seconds_to_cycle: i32, weekday_index: i32) { + let arena = self.avatar.arena.clone(); + self.home.shop_seed = seed; + self.home.shop_weekday_index = weekday_index; + self.home.shop_timer = LogicTimer::started(seconds_to_cycle, self.current_timestamp); + self.home.shop_spells = crate::home::randomize_shop_items(seed, weekday_index, &arena); + } + pub fn shop_spells(&self) -> &[LogicDataSlot] { + &self.home.shop_spells + } + pub fn shop_buy_times(&self, card: &LogicDataRef) -> Option { + self.home + .shop_spells + .iter() + .find(|slot| &slot.data == card) + .map(|slot| slot.count) + } + pub fn increase_sold_spells(&mut self, card: &LogicDataRef) { + if let Some(slot) = self + .home + .shop_spells + .iter_mut() + .find(|slot| &slot.data == card) + { + slot.count = slot.count.saturating_add(1); + } + } pub fn diamonds(&self) -> i32 { self.avatar.diamonds } diff --git a/crates/logic/src/home/mod.rs b/crates/logic/src/home/mod.rs index 6180112..2396c9e 100644 --- a/crates/logic/src/home/mod.rs +++ b/crates/logic/src/home/mod.rs @@ -1,2 +1,7 @@ mod logic_home_mode; +mod shop; pub use logic_home_mode::LogicHomeMode; +pub use shop::{ + cards_on_sale, randomize_shop_items, LogicRandom, CARD_TABLES, EPIC_SUNDAY_WEEKDAY, + RARITIES_ON_SALE, +}; diff --git a/crates/logic/src/home/shop.rs b/crates/logic/src/home/shop.rs new file mode 100644 index 0000000..fe49094 --- /dev/null +++ b/crates/logic/src/home/shop.rs @@ -0,0 +1,92 @@ +use crate::data::{table, LogicDataRef, LogicDataTables, RARITY_COMMON, RARITY_EPIC, RARITY_RARE}; +use crate::model::LogicDataSlot; +pub const EPIC_SUNDAY_WEEKDAY: i32 = 1; +pub const RARITIES_ON_SALE: [&str; 3] = [RARITY_COMMON, RARITY_RARE, RARITY_EPIC]; +pub const CARD_TABLES: [i32; 3] = [ + table::SPELLS_CHARACTERS, + table::SPELLS_BUILDINGS, + table::SPELLS_OTHER, +]; +pub struct LogicRandom { + seed: i32, +} +impl LogicRandom { + pub fn new(seed: i32) -> Self { + Self { seed } + } + pub fn seed(&self) -> i32 { + self.seed + } + pub fn next(&mut self, bound: i32) -> i32 { + if bound <= 0 { + return 0; + } + if self.seed == 0 { + self.seed = -1; + } + let mut state = self.seed; + state ^= state.wrapping_shl(13); + state ^= state >> 17; + state ^= state.wrapping_shl(5); + self.seed = state; + state.checked_abs().unwrap_or(0) % bound + } +} +pub fn cards_on_sale(rarity: &str, arena: &LogicDataRef) -> Vec { + let tables = LogicDataTables::instance(); + let mut pool = Vec::new(); + for table_index in CARD_TABLES { + let Some(rows) = tables.table(table_index) else { + continue; + }; + for row in rows.iter() { + let candidate = LogicDataRef::from(std::sync::Arc::clone(row)); + let Some(spell) = candidate.as_spell() else { + continue; + }; + if spell.not_in_use() || spell.rarity() != rarity || !spell.is_unlocked_in_arena(arena) + { + continue; + } + pool.push(candidate); + } + } + pool +} +pub fn randomize_shop_items( + seed: i32, + weekday_index: i32, + arena: &LogicDataRef, +) -> Vec { + let picks = if weekday_index == EPIC_SUNDAY_WEEKDAY { + 2 + } else { + 1 + }; + let mut random = LogicRandom::new(seed); + let mut chosen: Vec = Vec::new(); + for rarity in RARITIES_ON_SALE { + let pool = cards_on_sale(rarity, arena); + if pool.is_empty() { + continue; + } + for _ in 0..picks { + let mut attempts = 0; + loop { + let candidate = pool[random.next(pool.len() as i32) as usize].clone(); + if !chosen.contains(&candidate) { + chosen.push(candidate); + break; + } + attempts += 1; + if attempts >= pool.len() { + break; + } + } + } + } + chosen + .into_iter() + .map(|card| LogicDataSlot::new(card, 0)) + .collect() +} diff --git a/crates/logic/src/lib.rs b/crates/logic/src/lib.rs index 4dcc703..8712947 100644 --- a/crates/logic/src/lib.rs +++ b/crates/logic/src/lib.rs @@ -15,12 +15,12 @@ pub use commands::{ }; pub use data::{ table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable, - LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData, + LogicDataTableResource, LogicDataTables, LogicGlobals, LogicRarityData, LogicResourceData, LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES, TABLE_COUNT, }; pub use factory::{scroll_message_registry, LogicScrollMessageFactory}; -pub use home::LogicHomeMode; +pub use home::{randomize_shop_items, LogicHomeMode, LogicRandom}; pub use messages::*; pub use model::*; pub use titan::{GlobalId, LogicLong}; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 49a3591..2ff43c4 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,7 +1,7 @@ mod error; mod pool; pub use error::{Result, StorageError}; -pub use pool::{Database, DatabaseConfig}; +pub use pool::{Database, DatabaseConfig, DEFAULT_URL}; pub use sqlx; pub use sqlx::postgres::PgRow; pub use sqlx::{PgPool, Row}; diff --git a/crates/storage/src/pool.rs b/crates/storage/src/pool.rs index d38bd0d..d9e98a0 100644 --- a/crates/storage/src/pool.rs +++ b/crates/storage/src/pool.rs @@ -2,6 +2,7 @@ use std::time::Duration; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use sqlx::{ConnectOptions, PgPool}; use crate::error::Result; +pub const DEFAULT_URL: &str = "postgres:///scroll"; #[derive(Debug, Clone)] pub struct DatabaseConfig { pub url: String, @@ -9,6 +10,11 @@ pub struct DatabaseConfig { pub acquire_timeout: Duration, pub statement_log_threshold: Duration, } +impl Default for DatabaseConfig { + fn default() -> Self { + Self::new(DEFAULT_URL) + } +} impl DatabaseConfig { pub fn new(url: impl Into) -> Self { Self { @@ -21,7 +27,8 @@ impl DatabaseConfig { pub fn from_env() -> Option { let url = std::env::var("DATABASE_URL") .ok() - .filter(|value| !value.trim().is_empty())?; + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_URL.to_owned()); let mut config = Self::new(url); if let Some(max) = std::env::var("DATABASE_MAX_CONNECTIONS") .ok()