scroll.server/crates/game-service/src/home_mode.rs
WiseDev 4477b12f0a stop the home logic once the battle starts
the client sends EndClientTurnMessage during the battle too, but its
tick and checksum belong to the battle, not to the home. we kept
comparing them against the home checksum and answered with
OutOfSyncMessage, which is the "Client and server are out of sync!"
dialog on tick 60.

HomeMode carries the stopped flag now and returns an empty turn result
while it is set, and sector_state_for raises it, so both the mission and
the matchmaking entry points are covered.
2026-08-23 11:19:26 +03:00

351 lines
13 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use logic::data::{RESOURCE_DIAMONDS, RESOURCE_GOLD};
use logic::{
chest_source, AvailableServerCommandMessage, CommandOutcome, EndClientTurnMessage,
LogicClaimRewardCommand, LogicDataRef, LogicHomeMode, LogicTimer, OutOfSyncMessage,
OwnHomeDataMessage,
};
use logic::{table, LogicGlobals};
use service_rpc::AccountRef;
use tokio::sync::{Mutex, RwLock};
use crate::home::{build_avatar, build_home};
use crate::rewards::RewardRoller;
use crate::shop::{Purchase, ShopCatalog, ShopCycle, ShopEntry};
use crate::store::{OwnedCard, PlayerProfile, StoredChest};
pub const MAX_FAST_FORWARD_TICKS: i32 = 20 * 60 * 60;
pub const CHEST_FREE: &str = "Free";
pub const CHEST_CROWN: &str = "Star";
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<Purchase>,
pub matchmake: bool,
pub out_of_sync: Option<OutOfSyncMessage>,
pub changed: bool,
}
pub struct HomeMode {
logic: LogicHomeMode,
home_logic_stopped: bool,
}
impl HomeMode {
pub fn from_profile(profile: &PlayerProfile, current_timestamp: i32) -> Self {
let mut logic = LogicHomeMode::new(
build_home(profile),
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,
home_logic_stopped: false,
}
}
pub fn stop_home_logic(&mut self) {
self.home_logic_stopped = true;
}
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
}
pub fn own_home_data(&self, random_seed: i32) -> OwnHomeDataMessage {
OwnHomeDataMessage::new(
self.logic.home().clone(),
self.logic.avatar().clone(),
random_seed,
)
}
fn chest_for(&self, source: i32, chest_id: i32) -> LogicDataRef {
let arena = &self.logic.avatar().arena;
match source {
chest_source::SLOT => self
.logic
.chest_with_id(chest_id)
.map(|chest| chest.data.clone())
.unwrap_or_default(),
chest_source::CROWN => LogicDataRef::treasure_chest_for_arena(CHEST_CROWN, arena),
_ => LogicDataRef::treasure_chest_for_arena(CHEST_FREE, arena),
}
}
fn market_cost(&self, give: &ShopEntry) -> Result<ShopEntry, &'static str> {
if let Some(chest) = give.data.as_treasure_chest() {
let price = chest.shop_price();
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<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();
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"),
}
}
Ok(Purchase {
offer_id: offer.id,
give: offer.give,
cost,
})
}
pub fn end_client_turn(
&mut self,
turn: &EndClientTurnMessage,
roller: &RewardRoller,
shop: &ShopCatalog,
) -> TurnResult {
let mut result = TurnResult::default();
if self.home_logic_stopped {
return result;
}
let ticked = self
.logic
.fast_forward_to_tick(turn.tick, MAX_FAST_FORWARD_TICKS);
if ticked > 0 {
result.changed = true;
}
let mut pending = Vec::new();
for command in &turn.commands {
match self.logic.execute(command.as_ref()) {
CommandOutcome::ClaimStarted { source, chest_id } => {
let chest = self.chest_for(source, chest_id);
pending.push((source, chest_id, chest));
}
CommandOutcome::Upgraded {
spell,
level,
gold_spent,
} => {
tracing::info!(card = %spell, level, gold = gold_spent, "card upgraded");
result.changed = true;
}
CommandOutcome::Rejected(reason) => {
tracing::warn!(
command = command.command_type(),
reason,
"client command rejected"
);
}
CommandOutcome::PurchaseRequested { data, count } => {
match self.purchase(&data, count, shop) {
Ok(purchase) => {
if purchase.give.is_chest() {
pending.push((
chest_source::PURCHASED,
0,
purchase.give.data.clone(),
));
}
result.changed = true;
result.purchases.push(purchase);
}
Err(reason) => {
tracing::warn!(
command = command.command_type(),
item = %data,
reason,
"shop purchase rejected"
);
}
}
}
CommandOutcome::MatchmakeStarted => result.matchmake = true,
CommandOutcome::Applied => result.changed = true,
CommandOutcome::Ignored => {}
}
}
let server_checksum = self.logic.checksum();
if server_checksum != turn.checksum {
result.out_of_sync = Some(OutOfSyncMessage {
server_checksum,
client_checksum: turn.checksum,
tick: turn.tick,
});
}
let arena = self.logic.avatar().arena.clone();
for (source, chest_id, chest) in pending {
let rolled = roller.roll(&chest, &arena);
tracing::info!(
chest = %chest,
gold = rolled.gold,
cards = rolled.spell_count(),
copies = rolled.card_copies(),
"chest opened"
);
self.logic.apply_reward(&rolled);
result.changed = true;
result
.claims
.push(LogicClaimRewardCommand::new(rolled, chest_id, source));
}
result
}
pub fn write_back(&self, profile: &mut PlayerProfile) {
let home = self.logic.home();
let avatar = self.logic.avatar();
profile.gold = self.logic.gold();
profile.diamonds = avatar.diamonds;
profile.free_diamonds = avatar.free_diamonds;
profile.exp_level = avatar.exp_level;
profile.exp_points = avatar.exp_points;
profile.trophies = avatar.score;
profile.arena = avatar.arena.clone();
profile.deck = home
.decks
.first()
.map(|deck| {
deck.slots
.iter()
.flatten()
.map(|spell| OwnedCard {
card: spell.data.clone(),
level_index: spell.level_index,
count: spell.count,
})
.collect()
})
.unwrap_or_default();
profile.collection = home
.spell_collection
.spells
.iter()
.map(|spell| OwnedCard {
card: spell.data.clone(),
level_index: spell.level_index,
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;
profile.chest_slot_count = home.chest_slots.len();
profile.chests = home
.chest_slots
.iter()
.flatten()
.map(|chest| {
let timer = chest.unlock_timer.unwrap_or(LogicTimer::idle());
StoredChest {
chest: chest.data.clone(),
chest_id: chest.chest_id,
slot_index: chest.slot_index,
source: chest.source,
unlocked: chest.unlocked,
claimed: chest.claimed,
remaining_ticks: timer.remaining_ticks,
total_ticks: timer.total_ticks,
end_timestamp: timer.end_timestamp,
}
})
.collect();
}
}
#[derive(Default)]
pub struct HomeModeRegistry {
sessions: RwLock<HashMap<AccountRef, Arc<Mutex<HomeMode>>>>,
}
impl HomeModeRegistry {
pub async fn get_or_open(
&self,
account: AccountRef,
profile: &PlayerProfile,
current_timestamp: i32,
) -> Arc<Mutex<HomeMode>> {
if let Some(session) = self.sessions.read().await.get(&account) {
return Arc::clone(session);
}
let mut sessions = self.sessions.write().await;
Arc::clone(sessions.entry(account).or_insert_with(|| {
Arc::new(Mutex::new(HomeMode::from_profile(
profile,
current_timestamp,
)))
}))
}
pub async fn close(&self, account: AccountRef) -> Option<Arc<Mutex<HomeMode>>> {
self.sessions.write().await.remove(&account)
}
pub async fn session_count(&self) -> usize {
self.sessions.read().await.len()
}
}
pub fn available_server_command(command: LogicClaimRewardCommand) -> AvailableServerCommandMessage {
AvailableServerCommandMessage::claim_reward(command)
}