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:
parent
ad5a1613e3
commit
8a745d634c
19 changed files with 449 additions and 80 deletions
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Arc<Self>> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<LogicClaimRewardCommand>,
|
||||
pub purchases: Vec<ShopOffer>,
|
||||
pub purchases: Vec<Purchase>,
|
||||
pub out_of_sync: Option<OutOfSyncMessage>,
|
||||
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<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 {
|
||||
&self.logic
|
||||
|
|
@ -44,37 +64,83 @@ impl HomeMode {
|
|||
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(
|
||||
&mut self,
|
||||
data: &LogicDataRef,
|
||||
count: i32,
|
||||
shop: &ShopCatalog,
|
||||
) -> Result<ShopOffer, &'static str> {
|
||||
) -> Result<Purchase, &'static str> {
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -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<Arc<Self>> {
|
||||
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<String>,
|
||||
) = {
|
||||
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::<OutOfSyncMessage>(&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 {
|
||||
|
|
|
|||
|
|
@ -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<ShopEntry>,
|
||||
}
|
||||
impl ShopOffer {
|
||||
pub fn sells(&self, data: &logic::LogicDataRef) -> bool {
|
||||
|
|
@ -67,7 +68,10 @@ impl ShopCatalog {
|
|||
pub fn describe(&self) -> Vec<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
crates/game-service/src/shop/cycle.rs
Normal file
19
crates/game-service/src/shop/cycle.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
75
crates/logic/src/data/globals.rs
Normal file
75
crates/logic/src/data/globals.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
self.avatar.diamonds
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
92
crates/logic/src/home/shop.rs
Normal file
92
crates/logic/src/home/shop.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<String>) -> Self {
|
||||
Self {
|
||||
|
|
@ -21,7 +27,8 @@ impl DatabaseConfig {
|
|||
pub fn from_env() -> Option<Self> {
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue