fix chest shop prices and gate the free chest

rounding is banded and the 5s band biases +3, so 22 goes to 25 not 20. numbers match the csv for arena 1 and 2.
free chest end timestamp needs migration 0002.
This commit is contained in:
WiseDev 2026-08-23 09:02:49 +03:00
parent a2bf58a627
commit f218a45ed2
10 changed files with 129 additions and 11 deletions

View file

@ -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;

View file

@ -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(

View file

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

View file

@ -63,6 +63,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
}
}
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),
})],

View file

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

View file

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

View file

@ -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<LogicTreasureChestData> {
@ -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();

View file

@ -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;

3
crates/storage/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
println!("cargo:rerun-if-changed=migrations");
}

View file

@ -0,0 +1,2 @@
alter table players
add column if not exists free_chest_end_timestamp integer not null default 0;