essences/
characters.rs

1use crate::abilities::AbilityId;
2use crate::autochest::AutoChestFilterId;
3use crate::class::ClassId;
4use crate::flip::WorldSide;
5use crate::pets::PetId;
6use crate::talent_tree::TalentId;
7
8use rand::RngExt;
9
10use super::types::CustomValuesMap;
11
12use crate::prelude::*;
13use strum_macros::{Display, EnumString};
14
15#[tsify_next::declare]
16pub type CharacterId = uuid::Uuid;
17
18#[derive(
19    Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify, EnumString, Display,
20)]
21pub enum FightClass {
22    Mage,
23    Warrior,
24    Archer,
25}
26
27#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, Tsify)]
28pub struct Character {
29    pub id: CharacterId,
30    pub user_id: uuid::Uuid,
31    pub created_at: chrono::DateTime<chrono::Utc>,
32    pub class: ClassId,
33
34    /// The class this character has ASKED to switch to, applied at the start of
35    /// their next fight (BAL-035). `None` while nothing is pending.
36    ///
37    /// The switch is free and has no cooldown, but it may not land mid-fight:
38    /// specialization, passive and legal loadout all change together, and a
39    /// fight that began under one class must finish under it. Until it lands,
40    /// `class` is still the applied one — which is what keeps displayed and
41    /// matchmaking Power honest about the build the player is actually
42    /// fielding.
43    pub pending_class_id: Option<ClassId>,
44    pub character_level: i64,
45    pub character_experience: i64,
46    pub patron_level: i64,
47    pub patron_experience: i64,
48    pub cases: i64, // TODO remove
49    pub current_chapter_level: i64,
50    pub current_fight_number: i64,
51    pub max_vassal_slots_count: i64,
52    pub custom_values: CustomValuesMap,
53    pub item_case_level: i64,
54    /// Текущий уровень гачи способностей.
55    pub ability_case_level: i64,
56    /// Накопленные очки прогресса гачи способностей.
57    /// Историческое имя поля сохранено для обратной совместимости.
58    pub opened_ability_cases: i64,
59    /// Выбранные шаблоны способностей для вишлиста гачи.
60    pub ability_gacha_wishlist: Vec<AbilityId>,
61    /// Уровни слотов способностей по индексам `slot_id`.
62    pub ability_slot_levels: Vec<i64>,
63    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
64    pub item_case_upgrade_finish_at: Option<chrono::DateTime<chrono::Utc>>,
65    pub power: i64,
66    pub fight_class: Option<FightClass>,
67    pub arena_rating: i64,
68    /// Победная серия арены (атаки подряд без поражения) — питает
69    /// стрик-бонус очков асимметричного обмена.
70    #[serde(default)]
71    pub arena_win_streak: i64,
72    /// Серия поражений арены — включает матчмейкинг-защиту (гарантированная
73    /// «добыча» в списке после N поражений подряд).
74    #[serde(default)]
75    pub arena_loss_streak: i64,
76    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
77    pub arena_last_pvp_at: Option<chrono::DateTime<chrono::Utc>>,
78    pub last_boss_fight_won: bool,
79    pub auto_chest_enabled: bool,
80    pub last_filter_used_id: Option<AutoChestFilterId>,
81    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
82    pub last_item_case_speedup_at: Option<chrono::DateTime<chrono::Utc>>,
83    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
84    pub last_afk_reward_claimed_at: chrono::DateTime<chrono::Utc>,
85    pub afk_reward_seed: u64,
86    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
87    pub last_gacha_ad_roll_at: Option<chrono::DateTime<chrono::Utc>>,
88    /// Number of chest upgrade speedups used today via watching an ad.
89    pub speedup_ad_daily_count: i64,
90    pub active_loop_task_id: Option<uuid::Uuid>,
91    pub purchases_banned: bool,
92    pub completed_tutorials: Vec<i16>,
93    /// Уровни слотов петов по индексам `slot_id`.
94    pub pet_slot_levels: Vec<i64>,
95    /// Текущий уровень гачи петов.
96    pub pet_case_level: i64,
97    /// Накопленные очки прогресса гачи петов.
98    pub opened_pet_cases: i64,
99    /// Выбранные шаблоны петов для вишлиста гачи.
100    pub pet_gacha_wishlist: Vec<PetId>,
101    /// BAL-032: сколько роллов подряд не выдали первую копию нового пета.
102    /// Достигнув порога, следующий ролл гарантирует ещё не полученного пета.
103    pub pet_gacha_pity_pulls: i64,
104    /// ID персонажа, добавленного в пати.
105    pub party_character_id: Option<uuid::Uuid>,
106    // TODO: add max fight id (not double prize)
107    /// ID таланта, который сейчас изучается (если есть).
108    pub talent_upgrading_id: Option<TalentId>,
109    /// Время завершения изучения таланта.
110    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
111    pub talent_upgrade_finish_at: Option<chrono::DateTime<chrono::Utc>>,
112    /// Время последнего активного соединения игрока (обновляется каждый тик).
113    pub last_online_at: chrono::DateTime<chrono::Utc>,
114    /// Количество нажатий на мгновенную награду AFK за гемы за сегодня.
115    pub instant_reward_gems_press_count: i64,
116    /// Накопленные стаки буста AFK-награды от просмотра рекламы.
117    /// Применяются к следующему клейму AFK-награды и сбрасываются.
118    pub afk_boost_pending_stacks: i64,
119    /// Время, до которого нельзя показывать следующую птицу.
120    /// Короткий кулдаун устанавливается при эмите `ShowBird` сервером,
121    /// полный — при подтверждении показа клиентом (`BirdShown`).
122    #[cfg_attr(target_arch = "wasm32", schemars(skip))]
123    pub bird_cooldown_until: Option<chrono::DateTime<chrono::Utc>>,
124    /// Был ли уже показан игроку промпт rate-us. Сервер ставит true
125    /// при получении `RateUsShown` от клиента; пока false, сервер
126    /// продолжит эмитить `ShowRateUs` на каждой новой сессии, как только
127    /// `current_chapter_level >= game_settings.rate_us_chapter`.
128    pub rate_us_shown: bool,
129    /// Мир (Real/Fantasy), с которого этот персонаж начинает арена-бои —
130    /// и как атакующий, и как снапшот-оппонент в чужих боях. `None` = свитч
131    /// не трогали, старт со стороны durable flip_state как раньше.
132    #[serde(default)]
133    pub arena_world_side: Option<WorldSide>,
134}
135
136impl PartialEq for Character {
137    fn eq(&self, other: &Self) -> bool {
138        // Compare with microsecond precision because cockroach has microsecond precision.
139        // Helps in tests comparing, because cockroach has microsecond precision
140        fn eq_system_time(
141            a: &chrono::DateTime<chrono::Utc>,
142            b: &chrono::DateTime<chrono::Utc>,
143        ) -> bool {
144            a.signed_duration_since(*b).abs() < chrono::TimeDelta::microseconds(1)
145        }
146
147        fn eq_opt_time(
148            a: &Option<chrono::DateTime<chrono::Utc>>,
149            b: &Option<chrono::DateTime<chrono::Utc>>,
150        ) -> bool {
151            match (a, b) {
152                (Some(x), Some(y)) => eq_system_time(x, y),
153                (None, None) => true,
154                _ => false,
155            }
156        }
157
158        let Character {
159            id,
160            user_id,
161            class,
162            pending_class_id,
163            character_level,
164            character_experience,
165            patron_level,
166            patron_experience,
167            cases,
168            current_chapter_level,
169            current_fight_number,
170            max_vassal_slots_count,
171            custom_values,
172            item_case_level,
173            ability_case_level,
174            opened_ability_cases,
175            item_case_upgrade_finish_at,
176            power,
177            fight_class,
178            arena_rating,
179            arena_win_streak,
180            arena_loss_streak,
181            arena_last_pvp_at,
182            last_boss_fight_won,
183            auto_chest_enabled,
184            last_filter_used_id,
185            last_item_case_speedup_at,
186            last_afk_reward_claimed_at,
187            afk_reward_seed,
188            last_gacha_ad_roll_at,
189            speedup_ad_daily_count,
190            active_loop_task_id,
191            purchases_banned,
192            created_at,
193            ability_gacha_wishlist,
194            ability_slot_levels,
195            completed_tutorials,
196            pet_slot_levels,
197            pet_case_level,
198            opened_pet_cases,
199            pet_gacha_wishlist,
200            pet_gacha_pity_pulls,
201            party_character_id,
202            talent_upgrading_id,
203            talent_upgrade_finish_at,
204            last_online_at,
205            instant_reward_gems_press_count,
206            afk_boost_pending_stacks,
207            bird_cooldown_until,
208            rate_us_shown,
209            arena_world_side,
210        } = self;
211
212        let Character {
213            id: o_id,
214            user_id: o_user_id,
215            class: o_class,
216            pending_class_id: o_pending_class_id,
217            character_level: o_character_level,
218            character_experience: o_character_experience,
219            patron_level: o_patron_level,
220            patron_experience: o_patron_experience,
221            cases: o_cases,
222            current_chapter_level: o_current_chapter_level,
223            current_fight_number: o_current_fight_number,
224            max_vassal_slots_count: o_max_vassal_slots_count,
225            custom_values: o_custom_values,
226            item_case_level: o_item_case_level,
227            ability_case_level: o_ability_case_level,
228            opened_ability_cases: o_opened_ability_cases,
229            item_case_upgrade_finish_at: o_item_case_upgrade_finish_at,
230            power: o_power,
231            fight_class: o_fight_class,
232            arena_rating: o_arena_rating,
233            arena_win_streak: o_arena_win_streak,
234            arena_loss_streak: o_arena_loss_streak,
235            arena_last_pvp_at: o_arena_last_pvp_at,
236            last_boss_fight_won: o_last_boss_fight_won,
237            auto_chest_enabled: o_auto_chest_enabled,
238            last_filter_used_id: o_last_filter_used_id,
239            last_item_case_speedup_at: o_last_item_case_speedup_at,
240            last_afk_reward_claimed_at: o_last_afk_reward_claimed_at,
241            afk_reward_seed: o_afk_reward_seed,
242            last_gacha_ad_roll_at: o_last_gacha_ad_roll_at,
243            speedup_ad_daily_count: o_speedup_ad_daily_count,
244            active_loop_task_id: o_active_loop_task_id,
245            purchases_banned: o_purchases_banned,
246            created_at: o_created_at,
247            ability_gacha_wishlist: o_ability_gacha_wishlist,
248            ability_slot_levels: o_ability_slot_levels,
249            completed_tutorials: o_completed_tutorials,
250            pet_slot_levels: o_pet_slot_levels,
251            pet_case_level: o_pet_case_level,
252            opened_pet_cases: o_opened_pet_cases,
253            pet_gacha_wishlist: o_pet_gacha_wishlist,
254            pet_gacha_pity_pulls: o_pet_gacha_pity_pulls,
255            party_character_id: o_party_character_id,
256            talent_upgrading_id: o_talent_upgrading_id,
257            talent_upgrade_finish_at: o_talent_upgrade_finish_at,
258            last_online_at: o_last_online_at,
259            instant_reward_gems_press_count: o_instant_reward_gems_press_count,
260            afk_boost_pending_stacks: o_afk_boost_pending_stacks,
261            bird_cooldown_until: o_bird_cooldown_until,
262            rate_us_shown: o_rate_us_shown,
263            arena_world_side: o_arena_world_side,
264        } = other;
265
266        id == o_id
267            && user_id == o_user_id
268            && class == o_class
269            && pending_class_id == o_pending_class_id
270            && character_level == o_character_level
271            && character_experience == o_character_experience
272            && patron_level == o_patron_level
273            && patron_experience == o_patron_experience
274            && cases == o_cases
275            && current_chapter_level == o_current_chapter_level
276            && current_fight_number == o_current_fight_number
277            && max_vassal_slots_count == o_max_vassal_slots_count
278            && custom_values == o_custom_values
279            && item_case_level == o_item_case_level
280            && ability_case_level == o_ability_case_level
281            && opened_ability_cases == o_opened_ability_cases
282            && eq_opt_time(item_case_upgrade_finish_at, o_item_case_upgrade_finish_at)
283            && power == o_power
284            && fight_class == o_fight_class
285            && arena_rating == o_arena_rating
286            && arena_win_streak == o_arena_win_streak
287            && arena_loss_streak == o_arena_loss_streak
288            && eq_opt_time(arena_last_pvp_at, o_arena_last_pvp_at)
289            && last_boss_fight_won == o_last_boss_fight_won
290            && auto_chest_enabled == o_auto_chest_enabled
291            && last_filter_used_id == o_last_filter_used_id
292            && eq_opt_time(last_item_case_speedup_at, o_last_item_case_speedup_at)
293            && eq_system_time(last_afk_reward_claimed_at, o_last_afk_reward_claimed_at)
294            && afk_reward_seed == o_afk_reward_seed
295            && eq_opt_time(last_gacha_ad_roll_at, o_last_gacha_ad_roll_at)
296            && speedup_ad_daily_count == o_speedup_ad_daily_count
297            && active_loop_task_id == o_active_loop_task_id
298            && purchases_banned == o_purchases_banned
299            && completed_tutorials == o_completed_tutorials
300            && pet_slot_levels == o_pet_slot_levels
301            && pet_case_level == o_pet_case_level
302            && opened_pet_cases == o_opened_pet_cases
303            && pet_gacha_wishlist == o_pet_gacha_wishlist
304            && pet_gacha_pity_pulls == o_pet_gacha_pity_pulls
305            && party_character_id == o_party_character_id
306            && talent_upgrading_id == o_talent_upgrading_id
307            && eq_opt_time(talent_upgrade_finish_at, o_talent_upgrade_finish_at)
308            && eq_system_time(last_online_at, o_last_online_at)
309            && eq_system_time(created_at, o_created_at)
310            && ability_gacha_wishlist == o_ability_gacha_wishlist
311            && ability_slot_levels == o_ability_slot_levels
312            && instant_reward_gems_press_count == o_instant_reward_gems_press_count
313            && afk_boost_pending_stacks == o_afk_boost_pending_stacks
314            && eq_opt_time(bird_cooldown_until, o_bird_cooldown_until)
315            && rate_us_shown == o_rate_us_shown
316            && arena_world_side == o_arena_world_side
317    }
318}
319
320impl Eq for Character {}
321
322impl Default for Character {
323    fn default() -> Self {
324        Self {
325            id: Default::default(),
326            user_id: Default::default(),
327            created_at: ::time::utc_now(),
328            class: Default::default(),
329            pending_class_id: None,
330            character_level: 0,
331            character_experience: 0,
332            patron_level: 0,
333            patron_experience: 0,
334            cases: 0,
335            current_chapter_level: 0,
336            current_fight_number: 0,
337            max_vassal_slots_count: 0,
338            custom_values: Default::default(),
339            item_case_level: 0,
340            ability_case_level: 0,
341            opened_ability_cases: 0,
342            ability_gacha_wishlist: Vec::new(),
343            ability_slot_levels: Vec::new(),
344            item_case_upgrade_finish_at: None,
345            power: 0,
346            fight_class: None,
347            arena_rating: 0,
348            arena_win_streak: 0,
349            arena_loss_streak: 0,
350            arena_last_pvp_at: None,
351            last_boss_fight_won: false,
352            auto_chest_enabled: false,
353            last_filter_used_id: None,
354            last_item_case_speedup_at: None,
355            last_afk_reward_claimed_at: ::time::utc_now(),
356            afk_reward_seed: 0,
357            last_gacha_ad_roll_at: None,
358            speedup_ad_daily_count: 0,
359            active_loop_task_id: None,
360            purchases_banned: false,
361            completed_tutorials: Default::default(),
362            pet_slot_levels: Vec::new(),
363            pet_case_level: 1,
364            opened_pet_cases: 0,
365            pet_gacha_wishlist: Vec::new(),
366            pet_gacha_pity_pulls: 0,
367            party_character_id: None,
368            talent_upgrading_id: None,
369            talent_upgrade_finish_at: None,
370            last_online_at: ::time::utc_now(),
371            instant_reward_gems_press_count: 0,
372            afk_boost_pending_stacks: 0,
373            bird_cooldown_until: None,
374            rate_us_shown: false,
375            arena_world_side: None,
376        }
377    }
378}
379
380// Seed from 0 to i64::MAX, because cockroach can't store unsigned
381pub fn generate_new_afk_seed() -> u64 {
382    let mut rng = rand::rng();
383    rng.random_range(0..=i64::MAX as u64)
384}
385
386#[derive(Default, Serialize, Deserialize)]
387pub struct CharacterBuilder {
388    character: Character,
389}
390
391impl CharacterBuilder {
392    pub fn new() -> Self {
393        Self {
394            character: Character {
395                id: Uuid::now_v7(),
396                character_level: 1,
397                patron_level: 1,
398                item_case_level: 1,
399                ability_case_level: 1,
400                pet_case_level: 1,
401                max_vassal_slots_count: 2,
402                last_boss_fight_won: true,
403                power: 1,
404                afk_reward_seed: generate_new_afk_seed(),
405                ..Default::default()
406            },
407        }
408    }
409
410    pub fn with_id(mut self, id: CharacterId) -> Self {
411        self.character.id = id;
412        self
413    }
414
415    pub fn with_user_id(mut self, id: Uuid) -> Self {
416        self.character.user_id = id;
417        self
418    }
419
420    pub fn with_power(mut self, power: i64) -> Self {
421        self.character.power = power;
422        self
423    }
424
425    pub fn with_max_vassal_slots_count(mut self, slots_count: i64) -> Self {
426        self.character.max_vassal_slots_count = slots_count;
427        self
428    }
429
430    pub fn with_arena_rating(mut self, rating: i64) -> Self {
431        self.character.arena_rating = rating;
432        self
433    }
434
435    pub fn with_character_level(mut self, level: i64) -> Self {
436        self.character.character_level = level;
437        self
438    }
439
440    pub fn with_arena_last_pvp_at(mut self, time: Option<chrono::DateTime<chrono::Utc>>) -> Self {
441        self.character.arena_last_pvp_at = time;
442        self
443    }
444
445    pub fn with_last_boss_fight_won(mut self, won: bool) -> Self {
446        self.character.last_boss_fight_won = won;
447        self
448    }
449
450    pub fn with_cases(mut self, cases: i64) -> Self {
451        self.character.cases = cases;
452        self
453    }
454
455    pub fn with_item_case_level(mut self, level: i64) -> Self {
456        self.character.item_case_level = level;
457        self
458    }
459
460    pub fn with_class(mut self, class_id: Uuid) -> Self {
461        self.character.class = class_id;
462        self
463    }
464
465    pub fn with_custom_values(mut self, custom_values: CustomValuesMap) -> Self {
466        self.character.custom_values = custom_values;
467        self
468    }
469
470    pub fn with_ability_slot_levels(mut self, ability_slot_levels: Vec<i64>) -> Self {
471        self.character.ability_slot_levels = ability_slot_levels;
472        self
473    }
474
475    pub fn with_ability_gacha_wishlist(mut self, ability_gacha_wishlist: Vec<AbilityId>) -> Self {
476        self.character.ability_gacha_wishlist = ability_gacha_wishlist;
477        self
478    }
479
480    pub fn with_seed(mut self, seed: u64) -> Self {
481        self.character.afk_reward_seed = seed;
482        self
483    }
484
485    pub fn with_current_chapter_level(mut self, chapter_level: i64) -> Self {
486        self.character.current_chapter_level = chapter_level;
487        self
488    }
489
490    pub fn build(self) -> Character {
491        self.character
492    }
493}