generate the daily card shop server side

prices come from the csv globals now, a json cost is just an override.
DATABASE_URL defaults to postgres:///scroll when unset.
This commit is contained in:
WiseDev 2026-08-23 08:47:55 +03:00
parent ad5a1613e3
commit 8a745d634c
19 changed files with 449 additions and 80 deletions

View file

@ -1,12 +1,11 @@
{ {
"offers": [ "offers": [
{ "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, { "id": 1, "give": "Gold#1000" },
{ "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, { "id": 2, "give": "Gold#10000" },
{ "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, { "id": 3, "give": "Gold#100000" },
{ "id": 4, "give": "Knight#10", "cost": "Gold#500" }, { "id": 4, "give": "chest:Silver#1" },
{ "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, { "id": 5, "give": "chest:Gold#1" },
{ "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, { "id": 6, "give": "chest:Magic#1" },
{ "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, { "id": 7, "give": "Diamonds#500", "cost": "Gold#20000" }
{ "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" }
] ]
} }

View file

@ -6,10 +6,10 @@ use service_rpc::{
use crate::config::AuthConfig; use crate::config::AuthConfig;
use crate::store::{unavailable, AccountStore}; use crate::store::{unavailable, AccountStore};
use crate::time::{days_between, format_utc, unix_millis, unix_seconds}; use crate::time::{days_between, format_utc, unix_millis, unix_seconds};
pub fn missing_database() -> std::io::Error { fn connect_failed(error: storage::StorageError) -> std::io::Error {
std::io::Error::other( std::io::Error::other(format!(
"DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", "{error}\ncreate the database with `createdb scroll`, or point DATABASE_URL somewhere else"
) ))
} }
pub const ERROR_CODE_GENERIC: i32 = 1; pub const ERROR_CODE_GENERIC: i32 = 1;
pub const ERROR_CODE_PATCH: i32 = 7; pub const ERROR_CODE_PATCH: i32 = 7;
@ -21,10 +21,12 @@ pub struct AuthService {
} }
impl AuthService { impl AuthService {
pub async fn bootstrap(config: AuthConfig) -> std::io::Result<Arc<Self>> { pub async fn bootstrap(config: AuthConfig) -> std::io::Result<Arc<Self>> {
let database = config.database.as_ref().ok_or_else(missing_database)?; let database = config.database.clone().unwrap_or_default();
let store = AccountStore::open(database).await?; let store = AccountStore::open(&database)
.await
.map_err(connect_failed)?;
let accounts = store.count().await?; 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 })) Ok(Arc::new(Self { config, store }))
} }
pub fn store(&self) -> &AccountStore { pub fn store(&self) -> &AccountStore {

View file

@ -6,18 +6,25 @@ use logic::{
LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage, LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage,
OwnHomeDataMessage, OwnHomeDataMessage,
}; };
use logic::{table, LogicGlobals};
use service_rpc::AccountRef; use service_rpc::AccountRef;
use tokio::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use crate::config::ChestRewardConfig; use crate::config::ChestRewardConfig;
use crate::home::{build_avatar, build_home}; use crate::home::{build_avatar, build_home};
use crate::rewards::RewardRoller; use crate::rewards::RewardRoller;
use crate::shop::{ShopCatalog, ShopOffer}; use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry};
use crate::store::{OwnedCard, PlayerProfile, StoredChest}; use crate::store::{OwnedCard, PlayerProfile, StoredChest};
pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60; 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)] #[derive(Debug, Default)]
pub struct TurnResult { pub struct TurnResult {
pub claims: Vec<LogicClaimRewardCommand>, pub claims: Vec<LogicClaimRewardCommand>,
pub purchases: Vec<ShopOffer>, pub purchases: Vec<Purchase>,
pub out_of_sync: Option<OutOfSyncMessage>, pub out_of_sync: Option<OutOfSyncMessage>,
pub changed: bool, pub changed: bool,
} }
@ -26,13 +33,26 @@ pub struct HomeMode {
} }
impl HomeMode { impl HomeMode {
pub fn from_profile(profile: &PlayerProfile, current_timestamp: i32) -> Self { pub fn from_profile(profile: &PlayerProfile, current_timestamp: i32) -> Self {
Self { let mut logic = LogicHomeMode::new(
logic: LogicHomeMode::new(
build_home(profile), build_home(profile),
build_avatar(profile), build_avatar(profile),
current_timestamp, 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<ShopCycle> {
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 { pub fn logic(&self) -> &LogicHomeMode {
&self.logic &self.logic
@ -44,37 +64,83 @@ impl HomeMode {
random_seed, random_seed,
) )
} }
fn market_cost(&self, give: &ShopEntry) -> Result<ShopEntry, &'static str> {
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<Purchase, &'static str> {
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( fn purchase(
&mut self, &mut self,
data: &LogicDataRef, data: &LogicDataRef,
count: i32, count: i32,
shop: &ShopCatalog, shop: &ShopCatalog,
) -> Result<ShopOffer, &'static str> { ) -> Result<Purchase, &'static str> {
if data.as_spell().is_some() {
return self.buy_card(data);
}
let offer = shop let offer = shop
.offer_for(data) .offer_for(data)
.filter(|offer| offer.give.count == count) .filter(|offer| offer.give.count == count)
.ok_or("nothing in the shop sells that")? .ok_or("nothing in the shop sells that")?
.clone(); .clone();
if !offer.cost.is_resource() { let cost = match &offer.cost {
return Err("the price must be a resource"); Some(cost) => cost.clone(),
} None => self.market_cost(&offer.give)?,
match offer.cost.data.name() { };
RESOURCE_GOLD if self.logic.spend_gold(offer.cost.count) => {} self.pay(&cost)?;
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() { if offer.give.is_resource() {
match offer.give.data.name() { match offer.give.data.name() {
RESOURCE_GOLD => self.logic.add_gold(offer.give.count), RESOURCE_GOLD => self.logic.add_gold(offer.give.count),
RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count), RESOURCE_DIAMONDS => self.logic.add_diamonds(offer.give.count),
_ => return Err("only gold and diamonds can be sold"), _ => 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( pub fn end_client_turn(
&mut self, &mut self,
@ -113,12 +179,12 @@ impl HomeMode {
} }
CommandOutcome::PurchaseRequested { data, count } => { CommandOutcome::PurchaseRequested { data, count } => {
match self.purchase(&data, count, shop) { match self.purchase(&data, count, shop) {
Ok(offer) => { Ok(purchase) => {
if offer.give.is_chest() { if purchase.give.is_chest() {
pending.push((chest_source::PURCHASED, 0)); pending.push((chest_source::PURCHASED, 0));
} }
result.changed = true; result.changed = true;
result.purchases.push(offer); result.purchases.push(purchase);
} }
Err(reason) => { Err(reason) => {
tracing::warn!( tracing::warn!(

View file

@ -1,6 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use logic::{ use logic::{
EndClientTurnMessage, LogicCommandManager, LogicDataRef, OutOfSyncMessage, OwnHomeDataMessage, AvailableServerCommandMessage, EndClientTurnMessage, LogicCommandManager, LogicDataRef,
LogicShopSeedChangedCommand, OutOfSyncMessage, OwnHomeDataMessage,
}; };
use service_rpc::{ use service_rpc::{
AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult, AccountRef, GameApi, GameRequest, GameResponse, HomeRequestKind, RpcError, RpcResult,
@ -11,8 +12,8 @@ use crate::catalog::Catalog;
use crate::config::GameConfig; use crate::config::GameConfig;
use crate::home_mode::{available_server_command, HomeModeRegistry}; use crate::home_mode::{available_server_command, HomeModeRegistry};
use crate::rewards::RewardRoller; use crate::rewards::RewardRoller;
use crate::shop::ShopCatalog; use crate::shop::{ShopCatalog, ShopCycle};
use crate::store::{missing_database, unavailable, PlayerProfile, ProfileStore}; use crate::store::{connect_failed, unavailable, PlayerProfile, ProfileStore};
use crate::time::unix_seconds; use crate::time::unix_seconds;
pub struct GameService { pub struct GameService {
config: GameConfig, config: GameConfig,
@ -24,8 +25,10 @@ pub struct GameService {
impl GameService { impl GameService {
pub async fn bootstrap(config: GameConfig) -> std::io::Result<Arc<Self>> { pub async fn bootstrap(config: GameConfig) -> std::io::Result<Arc<Self>> {
let catalog = Arc::new(Catalog::load(config.csv_root.as_deref())); let catalog = Arc::new(Catalog::load(config.csv_root.as_deref()));
let database = config.database.as_ref().ok_or_else(missing_database)?; let database = config.database.clone().unwrap_or_default();
let profiles = ProfileStore::open(database).await?; let profiles = ProfileStore::open(&database)
.await
.map_err(connect_failed)?;
let profile_count = profiles.count().await?; let profile_count = profiles.count().await?;
let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref())); let shop = Arc::new(ShopCatalog::load(config.shop_path.as_deref()));
for offer in shop.describe() { for offer in shop.describe() {
@ -110,11 +113,22 @@ impl GameApi for GameService {
.sessions .sessions
.get_or_open(account, &profile, unix_seconds() as i32) .get_or_open(account, &profile, unix_seconds() as i32)
.await; .await;
let (home_data, checksum): (OwnHomeDataMessage, i32) = { let (home_data, checksum, cycle, on_sale): (
OwnHomeDataMessage,
i32,
ShopCycle,
Vec<String>,
) = {
let home = session.lock().await; let home = session.lock().await;
( (
home.own_home_data(self.config.random_seed), home.own_home_data(self.config.random_seed),
home.logic().checksum(), home.logic().checksum(),
home.shop_cycle(),
home.logic()
.shop_spells()
.iter()
.map(|slot| slot.data.to_string())
.collect(),
) )
}; };
tracing::info!( tracing::info!(
@ -125,7 +139,24 @@ impl GameApi for GameService {
checksum, checksum,
"serving own home data" "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<()> { async fn client_capabilities(&self, account: AccountRef, ping_ms: i32) -> RpcResult<()> {
tracing::debug!(%account, ping_ms, "client capabilities"); tracing::debug!(%account, ping_ms, "client capabilities");
@ -171,16 +202,16 @@ impl GameApi for GameService {
); );
replies.push(encode::<OutOfSyncMessage>(&out_of_sync)?); replies.push(encode::<OutOfSyncMessage>(&out_of_sync)?);
} }
for offer in &result.purchases { for purchase in &result.purchases {
tracing::info!( tracing::info!(
%account, %account,
offer = offer.id, offer = purchase.offer_id,
cost = %offer.cost, cost = %purchase.cost,
give = %offer.give, give = %purchase.give,
"shop purchase" "shop purchase"
); );
if let Err(error) = self.profiles.record_purchase(account, offer).await { if let Err(error) = self.profiles.record_purchase(account, purchase).await {
tracing::warn!(%account, offer = offer.id, %error, "could not record the purchase"); tracing::warn!(%account, %error, "could not record the purchase");
} }
} }
for claim in result.claims { for claim in result.claims {

View file

@ -6,7 +6,8 @@ pub const DEFAULT_SHOP: &str = include_str!("default_shop.json");
pub struct ShopOffer { pub struct ShopOffer {
pub id: i32, pub id: i32,
pub give: ShopEntry, pub give: ShopEntry,
pub cost: ShopEntry, #[serde(default)]
pub cost: Option<ShopEntry>,
} }
impl ShopOffer { impl ShopOffer {
pub fn sells(&self, data: &logic::LogicDataRef) -> bool { pub fn sells(&self, data: &logic::LogicDataRef) -> bool {
@ -67,7 +68,10 @@ impl ShopCatalog {
pub fn describe(&self) -> Vec<String> { pub fn describe(&self) -> Vec<String> {
self.offers self.offers
.iter() .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() .collect()
} }
} }

View file

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

View file

@ -1,12 +1,11 @@
{ {
"offers": [ "offers": [
{ "id": 1, "give": "Gold#1000", "cost": "Diamonds#10" }, { "id": 1, "give": "Gold#1000" },
{ "id": 2, "give": "Gold#10000", "cost": "Diamonds#80" }, { "id": 2, "give": "Gold#10000" },
{ "id": 3, "give": "Diamonds#500", "cost": "Gold#20000" }, { "id": 3, "give": "Gold#100000" },
{ "id": 4, "give": "Knight#10", "cost": "Gold#500" }, { "id": 4, "give": "chest:Silver#1" },
{ "id": 5, "give": "Witch#4", "cost": "Gold#2000" }, { "id": 5, "give": "chest:Gold#1" },
{ "id": 6, "give": "chest:Silver#1", "cost": "Diamonds#5" }, { "id": 6, "give": "chest:Magic#1" },
{ "id": 7, "give": "chest:Gold#1", "cost": "Diamonds#20" }, { "id": 7, "give": "Diamonds#500", "cost": "Gold#20000" }
{ "id": 8, "give": "chest:Magic#1", "cost": "Diamonds#230" }
] ]
} }

View file

@ -1,4 +1,12 @@
mod catalog; mod catalog;
mod cycle;
mod entry; mod entry;
pub use catalog::{ShopCatalog, ShopOffer, DEFAULT_SHOP}; 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}; pub use entry::{EntryError, ShopEntry, LOOKUP, QUALIFIER, SEPARATOR};

View file

@ -9,8 +9,8 @@ pub use profile::{
pub fn unavailable(error: StorageError) -> RpcError { pub fn unavailable(error: StorageError) -> RpcError {
RpcError::Unavailable(error.to_string()) RpcError::Unavailable(error.to_string())
} }
pub fn missing_database() -> std::io::Error { pub fn connect_failed(error: StorageError) -> std::io::Error {
std::io::Error::other( std::io::Error::other(format!(
"DATABASE_URL is not set, point it at a postgres database (postgres:///scroll)", "{error}\ncreate the database with `createdb scroll`, or point DATABASE_URL somewhere else"
) ))
} }

View file

@ -2,7 +2,7 @@ use logic::LogicDataRef;
use service_rpc::AccountRef; use service_rpc::AccountRef;
use storage::sqlx::{Postgres, QueryBuilder}; use storage::sqlx::{Postgres, QueryBuilder};
use storage::{Database, DatabaseConfig, PgRow, Result, Row}; use storage::{Database, DatabaseConfig, PgRow, Result, Row};
use crate::shop::ShopOffer; use crate::shop::Purchase;
use crate::store::{ use crate::store::{
OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK, OwnedCard, PlayerProfile, StoredChest, CARD_HOLDER_COLLECTION, CARD_HOLDER_DECK,
}; };
@ -341,9 +341,9 @@ impl ProfileStore {
.await?; .await?;
Ok(total.max(0) as usize) Ok(total.max(0) as usize)
} }
pub async fn record_purchase(&self, account: AccountRef, offer: &ShopOffer) -> Result<()> { pub async fn record_purchase(&self, account: AccountRef, purchase: &Purchase) -> Result<()> {
let (cost_table, cost_instance, _) = parts(&offer.cost.data); let (cost_table, cost_instance, _) = parts(&purchase.cost.data);
let (give_table, give_instance, _) = parts(&offer.give.data); let (give_table, give_instance, _) = parts(&purchase.give.data);
storage::sqlx::query( storage::sqlx::query(
"insert into shop_purchases ( "insert into shop_purchases (
account_high, account_low, offer_id, account_high, account_low, offer_id,
@ -354,13 +354,13 @@ impl ProfileStore {
) )
.bind(account.high) .bind(account.high)
.bind(account.low) .bind(account.low)
.bind(offer.id) .bind(purchase.offer_id)
.bind(cost_table) .bind(cost_table)
.bind(cost_instance) .bind(cost_instance)
.bind(offer.cost.count) .bind(purchase.cost.count)
.bind(give_table) .bind(give_table)
.bind(give_instance) .bind(give_instance)
.bind(offer.give.count) .bind(purchase.give.count)
.bind(unix_seconds()) .bind(unix_seconds())
.execute(self.database.pool()) .execute(self.database.pool())
.await?; .await?;

View file

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

View file

@ -1,10 +1,12 @@
mod data_ref; mod data_ref;
mod globals;
mod logic_data; mod logic_data;
mod logic_data_table; mod logic_data_table;
mod logic_data_tables; mod logic_data_tables;
mod tables; mod tables;
mod typed; mod typed;
pub use data_ref::LogicDataRef; pub use data_ref::LogicDataRef;
pub use globals::{LogicGlobals, RARITY_COMMON, RARITY_EPIC, RARITY_RARE};
pub use logic_data::LogicData; pub use logic_data::LogicData;
pub use logic_data_table::LogicDataTable; pub use logic_data_table::LogicDataTable;
pub use logic_data_tables::{ pub use logic_data_tables::{

View file

@ -42,6 +42,11 @@ typed_data!(LogicResourceData);
typed_data!(LogicTreasureChestData); typed_data!(LogicTreasureChestData);
typed_data!(LogicRarityData); typed_data!(LogicRarityData);
typed_data!(LogicResourcePackData); typed_data!(LogicResourcePackData);
impl LogicArenaData {
pub fn index(&self) -> i32 {
self.int("Arena")
}
}
impl LogicResourcePackData { impl LogicResourcePackData {
pub fn resource(&self) -> LogicDataRef { pub fn resource(&self) -> LogicDataRef {
LogicDataRef::by_name( LogicDataRef::by_name(
@ -57,6 +62,34 @@ impl LogicSpellData {
pub fn rarity(&self) -> &str { pub fn rarity(&self) -> &str {
self.string("Rarity") 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 { pub fn mana_cost(&self) -> i32 {
self.int("ManaCost") self.int("ManaCost")
} }

View file

@ -1,8 +1,8 @@
use crate::commands::{CommandOutcome, LogicCommand, LogicReward}; use crate::commands::{CommandOutcome, LogicCommand, LogicReward};
use crate::data::{LogicDataRef, LogicRarityData}; use crate::data::{LogicDataRef, LogicRarityData};
use crate::model::{ use crate::model::{
ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicSpell, LogicTimer, ChestSource, LogicChest, LogicClientAvatar, LogicClientHome, LogicDataSlot, LogicSpell,
TICKS_PER_SECOND, LogicTimer, TICKS_PER_SECOND,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogicHomeMode { pub struct LogicHomeMode {
@ -108,6 +108,33 @@ impl LogicHomeMode {
pub fn begin_claim(&mut self) { pub fn begin_claim(&mut self) {
self.claiming_reward = true; 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<i32> {
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 { pub fn diamonds(&self) -> i32 {
self.avatar.diamonds self.avatar.diamonds
} }

View file

@ -1,2 +1,7 @@
mod logic_home_mode; mod logic_home_mode;
mod shop;
pub use logic_home_mode::LogicHomeMode; pub use logic_home_mode::LogicHomeMode;
pub use shop::{
cards_on_sale, randomize_shop_items, LogicRandom, CARD_TABLES, EPIC_SUNDAY_WEEKDAY,
RARITIES_ON_SALE,
};

View file

@ -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<LogicDataRef> {
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<LogicDataSlot> {
let picks = if weekday_index == EPIC_SUNDAY_WEEKDAY {
2
} else {
1
};
let mut random = LogicRandom::new(seed);
let mut chosen: Vec<LogicDataRef> = 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()
}

View file

@ -15,12 +15,12 @@ pub use commands::{
}; };
pub use data::{ pub use data::{
table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable, table, DataError, LogicArenaData, LogicData, LogicDataRef, LogicDataTable,
LogicDataTableResource, LogicDataTables, LogicRarityData, LogicResourceData, LogicDataTableResource, LogicDataTables, LogicGlobals, LogicRarityData, LogicResourceData,
LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES, LogicResourcePackData, LogicSpellData, LogicTreasureChestData, DATA_TABLE_RESOURCES,
TABLE_COUNT, TABLE_COUNT,
}; };
pub use factory::{scroll_message_registry, LogicScrollMessageFactory}; pub use factory::{scroll_message_registry, LogicScrollMessageFactory};
pub use home::LogicHomeMode; pub use home::{randomize_shop_items, LogicHomeMode, LogicRandom};
pub use messages::*; pub use messages::*;
pub use model::*; pub use model::*;
pub use titan::{GlobalId, LogicLong}; pub use titan::{GlobalId, LogicLong};

View file

@ -1,7 +1,7 @@
mod error; mod error;
mod pool; mod pool;
pub use error::{Result, StorageError}; pub use error::{Result, StorageError};
pub use pool::{Database, DatabaseConfig}; pub use pool::{Database, DatabaseConfig, DEFAULT_URL};
pub use sqlx; pub use sqlx;
pub use sqlx::postgres::PgRow; pub use sqlx::postgres::PgRow;
pub use sqlx::{PgPool, Row}; pub use sqlx::{PgPool, Row};

View file

@ -2,6 +2,7 @@ use std::time::Duration;
use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
use sqlx::{ConnectOptions, PgPool}; use sqlx::{ConnectOptions, PgPool};
use crate::error::Result; use crate::error::Result;
pub const DEFAULT_URL: &str = "postgres:///scroll";
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DatabaseConfig { pub struct DatabaseConfig {
pub url: String, pub url: String,
@ -9,6 +10,11 @@ pub struct DatabaseConfig {
pub acquire_timeout: Duration, pub acquire_timeout: Duration,
pub statement_log_threshold: Duration, pub statement_log_threshold: Duration,
} }
impl Default for DatabaseConfig {
fn default() -> Self {
Self::new(DEFAULT_URL)
}
}
impl DatabaseConfig { impl DatabaseConfig {
pub fn new(url: impl Into<String>) -> Self { pub fn new(url: impl Into<String>) -> Self {
Self { Self {
@ -21,7 +27,8 @@ impl DatabaseConfig {
pub fn from_env() -> Option<Self> { pub fn from_env() -> Option<Self> {
let url = std::env::var("DATABASE_URL") let url = std::env::var("DATABASE_URL")
.ok() .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); let mut config = Self::new(url);
if let Some(max) = std::env::var("DATABASE_MAX_CONNECTIONS") if let Some(max) = std::env::var("DATABASE_MAX_CONNECTIONS")
.ok() .ok()