diff --git a/crates/game-service/src/home_mode.rs b/crates/game-service/src/home_mode.rs index 99c9f05..d487534 100644 --- a/crates/game-service/src/home_mode.rs +++ b/crates/game-service/src/home_mode.rs @@ -38,6 +38,7 @@ impl HomeMode { build_avatar(profile), current_timestamp, ); + logic.restore_free_chest_timer(profile.free_chest_end_timestamp); let cycle = ShopCycle::at(current_timestamp); logic.set_shop(cycle.seed, cycle.seconds_to_cycle, cycle.weekday_index); Self { logic } @@ -253,6 +254,7 @@ impl HomeMode { count: spell.count, }) .collect(); + profile.free_chest_end_timestamp = self.logic.free_chest_end_timestamp(); profile.chest_id_counter = home.chest_id_counter; profile.free_chest_collect_count = home.free_chest_collect_count; profile.crowns_towards_crown_chest = home.crowns_towards_crown_chest; diff --git a/crates/game-service/src/store/postgres.rs b/crates/game-service/src/store/postgres.rs index fe0a315..b33cb6d 100644 --- a/crates/game-service/src/store/postgres.rs +++ b/crates/game-service/src/store/postgres.rs @@ -132,6 +132,7 @@ fn profile_from_row( chest_id_counter: row.get("chest_id_counter"), free_chest_collect_count: row.get("free_chest_collect_count"), crowns_towards_crown_chest: row.get("crowns_towards_crown_chest"), + free_chest_end_timestamp: row.get("free_chest_end_timestamp"), chests, } } @@ -215,10 +216,11 @@ impl ProfileStore { gold_resource_table, gold_resource_instance, gold_resource_name, chest_slot_count, battle_count, win_count, lose_count, npc_win_count, npc_lose_count, three_crown_wins, tutorials_finished, updated_at, - chest_id_counter, free_chest_collect_count, crowns_towards_crown_chest + chest_id_counter, free_chest_collect_count, crowns_towards_crown_chest, + free_chest_end_timestamp ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, - $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) + $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29) on conflict (account_high, account_low) do update set name = excluded.name, name_set_by_user = excluded.name_set_by_user, @@ -245,7 +247,8 @@ impl ProfileStore { updated_at = excluded.updated_at, chest_id_counter = excluded.chest_id_counter, free_chest_collect_count = excluded.free_chest_collect_count, - crowns_towards_crown_chest = excluded.crowns_towards_crown_chest", + crowns_towards_crown_chest = excluded.crowns_towards_crown_chest, + free_chest_end_timestamp = excluded.free_chest_end_timestamp", ) .bind(account.high) .bind(account.low) @@ -275,6 +278,7 @@ impl ProfileStore { .bind(profile.chest_id_counter) .bind(profile.free_chest_collect_count) .bind(profile.crowns_towards_crown_chest) + .bind(profile.free_chest_end_timestamp) .execute(&mut *transaction) .await?; storage::sqlx::query( diff --git a/crates/game-service/src/store/profile.rs b/crates/game-service/src/store/profile.rs index 805d7d7..93402b4 100644 --- a/crates/game-service/src/store/profile.rs +++ b/crates/game-service/src/store/profile.rs @@ -48,6 +48,7 @@ pub struct PlayerProfile { pub chest_id_counter: i32, pub free_chest_collect_count: i32, pub crowns_towards_crown_chest: i32, + pub free_chest_end_timestamp: i32, pub chests: Vec, } impl PlayerProfile { @@ -88,6 +89,7 @@ impl PlayerProfile { chest_id_counter: 0, free_chest_collect_count: 1, crowns_towards_crown_chest: 0, + free_chest_end_timestamp: 0, chests: Vec::new(), } } diff --git a/crates/gateway/src/bin/scroll-probe.rs b/crates/gateway/src/bin/scroll-probe.rs index 87f232c..e143ae5 100644 --- a/crates/gateway/src/bin/scroll-probe.rs +++ b/crates/gateway/src/bin/scroll-probe.rs @@ -63,6 +63,9 @@ async fn main() -> Result<(), Box> { let pass_token = std::env::args().nth(4).filter(|token| token != "-"); let claim_free_chest = std::env::args().any(|arg| arg == "--claim-free-chest"); let desync = std::env::args().any(|arg| arg == "--desync"); + let buy_chest = std::env::args() + .skip_while(|arg| arg != "--buy-chest") + .nth(1); let csv_root = std::env::var("SCROLL_CSV_ROOT").unwrap_or_else(|_| "assets".to_owned()); match LogicDataTables::load_from_dir(&csv_root) { Ok(tables) => { @@ -175,12 +178,36 @@ async fn main() -> Result<(), Box> { } } println!(" random seed {}", home.random_seed); - if claim_free_chest { + let checksum = home.home.chest_id_counter + + ((home.home.spell_collection.spells.len() as i32) << 16) + + i32::from(desync); + if let Some(name) = buy_chest.as_deref() { + let chest = logic::LogicDataRef::by_name(logic::table::TREASURE_CHESTS, name); + match chest.as_treasure_chest() { + None => println!("!! no treasure chest named {name}"), + Some(data) => { + println!( + " buying {name} for {} diamonds", + data.shop_price() + ); + let turn = EndClientTurnMessage { + tick: 0, + checksum, + commands: vec![Box::new(logic::LogicBuyChestCommand { + header: LogicCommandHeader::for_account(home.avatar.account_id), + unused: 1, + chest, + })], + trailing: None, + }; + probe.send(&turn).await?; + println!("-> 14102 EndClientTurnMessage [LogicBuyChestCommand]"); + } + } + } else if claim_free_chest { let turn = EndClientTurnMessage { tick: 0, - checksum: home.home.chest_id_counter - + ((home.home.spell_collection.spells.len() as i32) << 16) - + i32::from(desync), + checksum, commands: vec![Box::new(LogicCollectFreeChestCommand { header: LogicCommandHeader::for_account(home.avatar.account_id), })], diff --git a/crates/logic/src/commands/chest.rs b/crates/logic/src/commands/chest.rs index 50f704b..1048a34 100644 --- a/crates/logic/src/commands/chest.rs +++ b/crates/logic/src/commands/chest.rs @@ -78,6 +78,10 @@ impl Execute for LogicCollectFreeChestCommand { if mode.avatar().arena.is_none() { return CommandOutcome::Rejected("the avatar has no arena"); } + if !mode.free_chest_ready() { + return CommandOutcome::Rejected("the free chest is still on cooldown"); + } + mode.start_free_chest_timer(); mode.begin_claim(); CommandOutcome::ClaimStarted { source: chest_source::FREE, diff --git a/crates/logic/src/data/globals.rs b/crates/logic/src/data/globals.rs index ae2f5c2..05e4bcf 100644 --- a/crates/logic/src/data/globals.rs +++ b/crates/logic/src/data/globals.rs @@ -9,6 +9,7 @@ pub const PRICE_EPIC_INCREASE: &str = "PRICE_EPIC_INCREASE_PER_ONE_BOUGHT_PERCEN 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 FREE_CHEST_INTERVAL_HOURS: &str = "FREE_CHEST_INTERVAL_HOURS"; pub const RARITY_COMMON: &str = "Common"; pub const RARITY_RARE: &str = "Rare"; pub const RARITY_EPIC: &str = "Epic"; @@ -34,6 +35,11 @@ impl LogicGlobals { _ => PRICE_COMMON_INCREASE, }) } + pub fn free_chest_interval_seconds() -> i32 { + Self::number(FREE_CHEST_INTERVAL_HOURS) + .max(0) + .saturating_mul(3_600) + } pub fn resource_diamond_cost(amount: i32) -> i32 { const STEPS: [(i32, &str); 7] = [ (1, "RESOURCE_DIAMOND_COST_1"), diff --git a/crates/logic/src/data/typed.rs b/crates/logic/src/data/typed.rs index 6c03be1..47aed73 100644 --- a/crates/logic/src/data/typed.rs +++ b/crates/logic/src/data/typed.rs @@ -46,6 +46,34 @@ impl LogicArenaData { pub fn index(&self) -> i32 { self.int("Arena") } + pub fn chest_shop_price_multiplier(&self) -> i32 { + self.int("ChestShopPriceMultiplier") + } + pub fn scaled_chest_price(&self, price: i32) -> i32 { + (self.chest_shop_price_multiplier() as i64 * price as i64 / 100) as i32 + } +} +fn clamp_low_first(value: i64, low: i64, high: i64) -> i64 { + if value > low { + if value < high { + value + } else { + high + } + } else { + low + } +} +fn round_shop_price(total: i32) -> i32 { + if total > 500 { + (total + 50) / 100 * 100 + } else if total >= 101 { + (total + 5) / 10 * 10 + } else if total >= 21 { + (total + 3) / 5 * 5 + } else { + total + } } impl LogicTreasureChestData { pub fn base_chest(&self) -> Option { @@ -64,11 +92,27 @@ impl LogicTreasureChestData { pub fn arena_data(&self) -> LogicDataRef { LogicDataRef::by_name(crate::data::tables::table::ARENAS, self.string("Arena")) } - pub fn shop_price(&self) -> i32 { - self.root().int("ShopPriceWithoutSpeedUp") + fn price_row(&self) -> LogicTreasureChestData { + self.base_chest().unwrap_or_else(|| self.clone()) } - pub fn speed_up_cost(&self) -> i32 { - self.root().int("SpeedUpCost") + pub fn shop_price(&self) -> i32 { + let scaled = match self.arena_data().as_arena() { + Some(arena) => { + arena.scaled_chest_price(self.price_row().int("ShopPriceWithoutSpeedUp")) + } + None => self.price_row().int("ShopPriceWithoutSpeedUp"), + }; + round_shop_price(scaled.saturating_add(self.speed_up_cost_at(self.unlock_seconds()))) + } + pub fn speed_up_cost_at(&self, seconds_left: i32) -> i32 { + let total = self.unlock_seconds(); + if seconds_left < 1 || total < 1 { + return 0; + } + let cost = self.price_row().int("SpeedUpCost") as i64; + let total = total as i64; + let quotient = (cost * seconds_left as i64 + total - 1) / total; + clamp_low_first(quotient, 1, cost) as i32 } pub fn unlock_seconds(&self) -> i32 { let root = self.root(); diff --git a/crates/logic/src/home/logic_home_mode.rs b/crates/logic/src/home/logic_home_mode.rs index 29e5bbd..f8f1abf 100644 --- a/crates/logic/src/home/logic_home_mode.rs +++ b/crates/logic/src/home/logic_home_mode.rs @@ -108,6 +108,30 @@ impl LogicHomeMode { pub fn begin_claim(&mut self) { self.claiming_reward = true; } + pub fn free_chest_ready(&self) -> bool { + self.home.free_chest_timer.remaining_ticks <= 0 + } + pub fn free_chest_end_timestamp(&self) -> i32 { + self.home.free_chest_timer.end_timestamp + } + pub fn start_free_chest_timer(&mut self) { + let seconds = crate::data::LogicGlobals::free_chest_interval_seconds(); + self.home.free_chest_timer = LogicTimer::started(seconds, self.current_timestamp); + self.home.free_chest_collect_count = self.home.free_chest_collect_count.saturating_add(1); + } + pub fn restore_free_chest_timer(&mut self, end_timestamp: i32) { + let remaining = end_timestamp.saturating_sub(self.current_timestamp).max(0); + self.home.free_chest_timer = if remaining > 0 { + LogicTimer { + remaining_ticks: remaining.saturating_mul(TICKS_PER_SECOND), + total_ticks: crate::data::LogicGlobals::free_chest_interval_seconds() + .saturating_mul(TICKS_PER_SECOND), + end_timestamp, + } + } else { + LogicTimer::idle() + }; + } 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; diff --git a/crates/storage/build.rs b/crates/storage/build.rs new file mode 100644 index 0000000..3a8149e --- /dev/null +++ b/crates/storage/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/crates/storage/migrations/0002_free_chest_timer.sql b/crates/storage/migrations/0002_free_chest_timer.sql new file mode 100644 index 0000000..2401259 --- /dev/null +++ b/crates/storage/migrations/0002_free_chest_timer.sql @@ -0,0 +1,2 @@ +alter table players + add column if not exists free_chest_end_timestamp integer not null default 0;