essences/
gatings.rs

1use crate::prelude::*;
2
3// NOTE: when adding a new gated feature here (or to the nested
4// `NavBarNavigation` / `SideBarNavigation` / `AutoChestGatings` /
5// `CastleGating` structs below), decide whether it belongs in the
6// `feature_unlock_snapshot` analytics event and update
7// `Gatings::feature_unlock_thresholds` accordingly. Unlike the old
8// open-coded array, that method now destructures every field without a
9// `..` rest pattern, so adding a field here is a compile error there
10// until you handle it — the build-time signal that used to be missing.
11#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
12pub struct Gatings {
13    #[schemars(title = "Гейтинги навбара")]
14    pub navbar_navigation: NavBarNavigation,
15    #[schemars(title = "Гейтинги сайдбара")]
16    pub sidebar_navigation: SideBarNavigation,
17    #[schemars(title = "Гейтинги авточеста")]
18    pub autochest: AutoChestGatings,
19    #[schemars(title = "Чаптер, на котором открывается кнопка афк наград")]
20    pub afk_rewards_button_unlock_chapter: i64,
21    #[schemars(title = "Чаптер, на котором открывается пати")]
22    pub party_unlock_chapter: i64,
23    #[schemars(title = "Чаптер, на котором открывается кнопка магазина")]
24    pub shop_button_unlock_chapter: i64,
25    #[schemars(title = "Чаптер, на котором открывается кнопка дневного буста")]
26    pub daily_boost_button_unlock_chapter: i64,
27
28    #[schemars(title = "Чаптер, на котором открывается окно кастомизации")]
29    pub customization_screen_unlock_chapter: i64,
30}
31
32#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
33pub struct NavBarNavigation {
34    #[schemars(title = "Чаптер, на котором открывается кнопка профиля")]
35    pub hero_button_unlock_chapter: i64,
36
37    #[schemars(title = "Чаптер, на котором открывается кнопка скилов")]
38    pub skills_button_unlock_chapter: i64,
39
40    #[schemars(title = "Чаптер, на котором открывается кнопка данжа")]
41    pub dungeon_button_unlock_chapter: i64,
42
43    #[schemars(title = "Чаптер, на котором открывается кнопка ведьмы")]
44    pub summon_button_unlock_chapter: i64,
45
46    #[schemars(title = "Чаптер, на котором открывается кнопка петов")]
47    pub pets_button_unlock_chapter: i64,
48
49    // Вкладка рун — третья на экране «Билд», рядом со скилами и петами.
50    // Держите её не раньше первого сокета из `StonesSettings::socket_unlocks`:
51    // до него все 15 сокетов закрыты и открывать вкладку не на что.
52    //
53    // Обычный комментарий, а не doc: schemars отдаёт doc-строку в description, и
54    // в админке она вытесняет короткий title, которым подписаны соседние поля.
55    #[schemars(title = "Чаптер, на котором открывается кнопка рун")]
56    pub runes_button_unlock_chapter: i64,
57
58    #[schemars(title = "Гейтинги замка")]
59    pub castle: CastleGating,
60}
61
62#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
63pub struct SideBarNavigation {
64    #[schemars(title = "Чаптер, на котором открывается кнопка квестов")]
65    pub quests_button_unlock_chapter: i64,
66
67    #[schemars(title = "Чаптер, на котором открывается кнопка прогресс пасса")]
68    pub progress_pass_button_unlock_chapter: i64,
69
70    #[schemars(title = "Чаптер, на котором открывается кнопка арены")]
71    pub arena_button_unlock_chapter: i64,
72
73    #[schemars(title = "Чаптер, на котором открывается кнопка рейтингов")]
74    pub ratings_button_unlock_chapter: i64,
75
76    #[schemars(title = "Чаптер, на котором открывается кнопка почты")]
77    pub mail_button_unlock_chapter: i64,
78}
79
80#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
81pub struct AutoChestGatings {
82    #[schemars(title = "Уровень персонажа, при котором открывается авточест")]
83    pub autochest_button_unlock_character_level: i64,
84
85    #[schemars(title = "Чаптер, на котором открывается кнопка апгрейда сундука")]
86    pub chest_upgrade_button_unlock_chapter: i64,
87
88    #[schemars(title = "Показывать подсказку клика по сундуку до stage (не включительно)")]
89    pub show_chest_click_tip_until_stage: i64,
90
91    #[schemars(title = "Чаптер, на котором открывается фильтр по редкости")]
92    pub rarity_filter_unlock: i64,
93
94    #[schemars(title = "Чаптер, на котором открывается гарантированный стат")]
95    pub guaranteed_stat_unlock: i64,
96
97    #[schemars(title = "Чаптер, на котором открывается первый дополнительный стат")]
98    pub first_additional_stat_unlock: i64,
99
100    #[schemars(title = "Чаптер, на котором открывается второй дополнительный стат")]
101    pub second_additional_stat_unlock: i64,
102
103    #[schemars(title = "Чаптер, на котором открывается третий дополнительный стат")]
104    pub third_additional_stat_unlock: i64,
105}
106
107#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
108pub struct CastleGating {
109    #[schemars(title = "Чаптер, на котором открывается кнопка замка")]
110    pub castle_button_unlock_chapter: i64,
111
112    #[schemars(title = "Чаптер, на котором открывается дерево талантов")]
113    pub talent_tree_unlock_chapter: i64,
114
115    #[schemars(title = "Чаптер, на котором открывается статуя")]
116    pub statue_unlock_chapter: i64,
117}
118
119impl Gatings {
120    /// Flat `(analytics_name, unlock_chapter)` list backing the
121    /// `feature_unlock_snapshot` analytics event emitted in
122    /// `monolith::ws::connection::complete_auth`.
123    ///
124    /// The names are a curated, stable analytics contract (downstream
125    /// queries `MIN(timestamp) PER (character_id, feature)` on them), so
126    /// they intentionally differ from the raw field names.
127    ///
128    /// This destructures every field of `Gatings` and its nested structs
129    /// **without** a `..` rest pattern on purpose: adding a new gated
130    /// field anywhere is then a compile error here until the author
131    /// decides whether it belongs in the snapshot. That is the
132    /// build-time signal the previous open-coded array lacked. Fields
133    /// that are deliberately not emitted are bound to `_` with a reason.
134    pub fn feature_unlock_thresholds(&self) -> Vec<(&'static str, i64)> {
135        let Gatings {
136            navbar_navigation,
137            sidebar_navigation,
138            autochest,
139            afk_rewards_button_unlock_chapter,
140            party_unlock_chapter,
141            shop_button_unlock_chapter,
142            daily_boost_button_unlock_chapter,
143            customization_screen_unlock_chapter,
144        } = self;
145
146        let NavBarNavigation {
147            hero_button_unlock_chapter,
148            skills_button_unlock_chapter,
149            dungeon_button_unlock_chapter,
150            summon_button_unlock_chapter,
151            pets_button_unlock_chapter,
152            runes_button_unlock_chapter,
153            castle,
154        } = navbar_navigation;
155
156        let CastleGating {
157            castle_button_unlock_chapter,
158            talent_tree_unlock_chapter,
159            statue_unlock_chapter,
160        } = castle;
161
162        let SideBarNavigation {
163            quests_button_unlock_chapter,
164            // Not currently emitted to feature_unlock_snapshot. If this
165            // should be tracked, add it to the returned list below.
166            progress_pass_button_unlock_chapter: _,
167            arena_button_unlock_chapter,
168            ratings_button_unlock_chapter,
169            mail_button_unlock_chapter,
170        } = sidebar_navigation;
171
172        let AutoChestGatings {
173            // Auto-chest now unlocks by character LEVEL (enforced server-side in
174            // handle_enable_auto_chest + AutoChestFilter::validate), so it is not
175            // a chapter threshold and stays out of this chapter-keyed list.
176            autochest_button_unlock_character_level: _,
177            chest_upgrade_button_unlock_chapter,
178            // A UI hint window, not a feature unlock.
179            show_chest_click_tip_until_stage: _,
180            rarity_filter_unlock,
181            guaranteed_stat_unlock,
182            // Additional-stat unlocks are intentionally outside the snapshot.
183            first_additional_stat_unlock: _,
184            second_additional_stat_unlock: _,
185            third_additional_stat_unlock: _,
186        } = autochest;
187
188        vec![
189            ("afk_rewards", *afk_rewards_button_unlock_chapter),
190            ("party", *party_unlock_chapter),
191            ("shop", *shop_button_unlock_chapter),
192            ("daily_boost", *daily_boost_button_unlock_chapter),
193            ("hero_button", *hero_button_unlock_chapter),
194            ("skills_button", *skills_button_unlock_chapter),
195            ("dungeon_button", *dungeon_button_unlock_chapter),
196            ("summon_button", *summon_button_unlock_chapter),
197            ("pets_button", *pets_button_unlock_chapter),
198            ("runes_button", *runes_button_unlock_chapter),
199            ("castle_button", *castle_button_unlock_chapter),
200            ("talent_tree", *talent_tree_unlock_chapter),
201            ("statue", *statue_unlock_chapter),
202            ("quests_button", *quests_button_unlock_chapter),
203            ("arena_button", *arena_button_unlock_chapter),
204            ("ratings_button", *ratings_button_unlock_chapter),
205            ("mail_button", *mail_button_unlock_chapter),
206            ("chest_upgrade_button", *chest_upgrade_button_unlock_chapter),
207            ("autochest_rarity_filter", *rarity_filter_unlock),
208            ("autochest_guaranteed_stat", *guaranteed_stat_unlock),
209            ("customization_screen", *customization_screen_unlock_chapter),
210        ]
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn feature_unlock_thresholds_are_stable_and_unique() {
220        // Locks the analytics contract for `feature_unlock_snapshot`.
221        // If you intentionally add/remove a tracked feature, update this
222        // count to match.
223        let list = Gatings::default().feature_unlock_thresholds();
224        assert_eq!(list.len(), 21, "tracked feature count changed");
225
226        let mut names: Vec<&str> = list.iter().map(|(name, _)| *name).collect();
227        names.sort_unstable();
228        let mut unique = names.clone();
229        unique.dedup();
230        assert_eq!(names, unique, "feature analytics names must be unique");
231    }
232}