configs/
plinko.rs

1use essences::items::{AttributeId, ItemRarityId};
2use schema_loader::{attribute_link_id_schema, item_rarity_link_id_schema};
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use tsify_next::Tsify;
6
7use crate::validated_types::NonEmptyVec;
8
9/// Минимальная/максимальная высота доски. Совпадает с клиентскими
10/// `PlinkoBoardConfig.MinRows`/`MaxRows` — клиент валидирует то же самое.
11pub const MIN_PLINKO_ROWS: i64 = 2;
12pub const MAX_PLINKO_ROWS: i64 = 16;
13
14/// Группа физических слотов доски, подписанная одной редкостью.
15/// Физические слоты нумеруются `0..=rows`; группы одного уровня должны
16/// покрывать этот диапазон без дыр и пересечений. Если у редкости несколько
17/// физических слотов (`from_slot != to_slot`), сервер сам выбирает, в какой
18/// из них уронить шарик.
19#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
20pub struct PlinkoSlotGroup {
21    #[schemars(title = "Первый физический слот группы")]
22    pub from_slot: i64,
23
24    #[schemars(title = "Последний физический слот группы")]
25    pub to_slot: i64,
26
27    #[schemars(
28        title = "Редкость предмета для этой группы слотов",
29        schema_with = "item_rarity_link_id_schema"
30    )]
31    pub rarity_id: ItemRarityId,
32
33    #[schemars(title = "Слот показан закрытым (только визуал)")]
34    pub is_locked: bool,
35}
36
37/// Раскладка доски для одного уровня сундука.
38///
39/// Набор выпадающих редкостей зависит от уровня сундука и ползёт вверх, а
40/// слотов на доске всего `rows + 1`, поэтому одна общая раскладка неизбежно
41/// оставляла бы часть выдач без своего слота. Раскладка задаётся на КАЖДЫЙ
42/// уровень сундука и обязана покрывать ровно те редкости, которые на этом
43/// уровне выпадают с весом > 0 (`GameConfig::validate` это требует).
44#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
45pub struct PlinkoLevelSlotLayout {
46    #[schemars(title = "Уровень сундука, для которого действует раскладка")]
47    pub chest_level: i64,
48
49    #[schemars(title = "Раскладка слотов доски на этом уровне (слот → редкость)")]
50    pub slot_groups: NonEmptyVec<PlinkoSlotGroup>,
51}
52
53impl PlinkoLevelSlotLayout {
54    /// Редкость, которой подписан физический слот.
55    pub fn rarity_for_slot(&self, slot: i64) -> Option<ItemRarityId> {
56        self.slot_groups
57            .iter()
58            .find(|group| slot >= group.from_slot && slot <= group.to_slot)
59            .map(|group| group.rarity_id)
60    }
61
62    /// Все физические слоты, подписанные этой редкостью.
63    pub fn slots_for_rarity(&self, rarity_id: ItemRarityId) -> Vec<i64> {
64        self.slot_groups
65            .iter()
66            .filter(|group| group.rarity_id == rarity_id)
67            .flat_map(|group| group.from_slot..=group.to_slot)
68            .collect()
69    }
70}
71
72/// Постоянная прибавка к стату за касание конкретного колышка.
73///
74/// Один колышек доски: какой оси он принадлежит и каков его БЕЗРАЗМЕРНЫЙ вес.
75///
76/// Сам по себе вес ничего не начисляет — прибавка за касание считается как
77/// `вес × scale текущего транша` (BAL-021), поэтому один и тот же колышек
78/// стоит по-разному на разных уровнях сундука.
79#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
80pub struct PlinkoPinBonus {
81    #[schemars(title = "Ряд колышка (0 — верхний)")]
82    pub row: i64,
83
84    #[schemars(title = "Колонка колышка внутри ряда (0..=row)")]
85    pub column: i64,
86
87    #[schemars(
88        title = "Атрибут, который прибавляет колышек",
89        schema_with = "attribute_link_id_schema"
90    )]
91    pub attribute_id: AttributeId,
92
93    #[schemars(
94        title = "Базовый вес колышка",
95        description = "Безразмерное число. Реальная прибавка = вес × scale активного транша соответствующего уровня сундука."
96    )]
97    pub value: i64,
98}
99
100/// Точность fixed-point, в которой хранятся потолки, шаги и накопленный
101/// бонус игрока (BAL-021). Поздние шаги — доли единицы стата за касание, и
102/// целочисленное округление на каждом касании съело бы их целиком.
103pub const PLINKO_MICRO: i64 = 1_000_000;
104
105/// Транш одной оси на одном уровне сундука: до какого суммарного значения он
106/// доводит ось и по сколько прибавляет одно касание.
107///
108/// Потолки монотонно растут по уровню сундука, поэтому «сколько ещё можно
109/// набрать» — это `cap_micro` первого незаполненного транша, а не отдельная
110/// валюта: история касаний не нужна.
111#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
112pub struct PlinkoAxisTranche {
113    #[schemars(title = "Ось (атрибут)", schema_with = "attribute_link_id_schema")]
114    pub attribute_id: AttributeId,
115
116    #[schemars(
117        title = "Потолок оси на этом уровне, микроединицы",
118        description = "Суммарно накопленный бонус оси не может превысить это значение, пока сундук на этом уровне."
119    )]
120    pub cap_micro: i64,
121
122    #[schemars(
123        title = "Цена единицы веса на этом уровне, микроединицы",
124        description = "Прибавка за касание = базовый вес колышка × это значение."
125    )]
126    pub scale_micro: i64,
127}
128
129/// Все оси одного уровня сундука.
130#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
131pub struct PlinkoTranche {
132    #[schemars(title = "Уровень сундука")]
133    pub chest_level: i64,
134
135    #[schemars(
136        title = "Оси уровня",
137        description = "По одной строке на каждую ось таблицы колышков."
138    )]
139    pub axes: Vec<PlinkoAxisTranche>,
140}
141
142/// Настройки мини-игры Plinko.
143///
144/// Сервер — единственный источник правды: он знает высоту доски, раскладку
145/// «слот → редкость» для каждого уровня сундука и таблицу колышков, сам катит
146/// путь шарика и сам начисляет статы за задетые колышки. Клиент только
147/// проигрывает анимацию по присланному пути.
148#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, Tsify)]
149pub struct PlinkoSettings {
150    #[schemars(
151        title = "Высота доски (рядов колышков)",
152        description = "Физических слотов будет rows + 1, колышков — rows*(rows+1)/2. Путь шарика состоит ровно из rows шагов."
153    )]
154    pub rows: i64,
155
156    #[schemars(
157        title = "Раскладки слотов доски по уровням сундука",
158        description = "По одной раскладке на каждый уровень item_cases_settings. Раскладка уровня обязана покрывать все редкости, выпадающие на этом уровне."
159    )]
160    pub slot_layouts: NonEmptyVec<PlinkoLevelSlotLayout>,
161
162    #[schemars(
163        title = "Бонусы за колышки (ПЛЕЙСХОЛДЕРЫ)",
164        description = "Значения не отбалансированы: симуляция не проводилась. Колышки без записи не дают ничего."
165    )]
166    pub pin_bonuses: Vec<PlinkoPinBonus>,
167
168    #[schemars(
169        title = "Траншы потолков и шагов по уровням сундука",
170        description = "По одной строке на уровень сундука; каждая ось таблицы колышков обязана присутствовать в каждой строке. Потолки не убывают с уровнем."
171    )]
172    pub tranches: Vec<PlinkoTranche>,
173}
174
175impl PlinkoSettings {
176    /// Число физических слотов доски.
177    pub fn slot_count(&self) -> i64 {
178        self.rows + 1
179    }
180
181    /// Раскладка доски для уровня сундука. Отсутствие записи означает
182    /// сломанный конфиг: `validate` требует раскладку на каждый уровень
183    /// `item_cases_settings`.
184    pub fn layout_for_level(&self, chest_level: i64) -> Option<&PlinkoLevelSlotLayout> {
185        self.slot_layouts
186            .iter()
187            .find(|layout| layout.chest_level == chest_level)
188    }
189
190    /// Бонус конкретного колышка, если он задан в таблице.
191    pub fn pin_bonus(&self, row: i64, column: i64) -> Option<&PlinkoPinBonus> {
192        self.pin_bonuses
193            .iter()
194            .find(|bonus| bonus.row == row && bonus.column == column)
195    }
196
197    /// Транш оси `attribute_id` на уровне сундука `chest_level`.
198    pub fn tranche(
199        &self,
200        chest_level: i64,
201        attribute_id: AttributeId,
202    ) -> Option<&PlinkoAxisTranche> {
203        self.tranches
204            .iter()
205            .find(|tranche| tranche.chest_level == chest_level)
206            .and_then(|tranche| {
207                tranche
208                    .axes
209                    .iter()
210                    .find(|axis| axis.attribute_id == attribute_id)
211            })
212    }
213
214    /// Транш, который ось `attribute_id` набирает прямо сейчас: САМЫЙ РАННИЙ
215    /// уровень не выше текущего, чей потолок ещё не достигнут и который вообще
216    /// добавляет ёмкость (BAL-021).
217    ///
218    /// Это и есть догон: игрок, проскочивший несколько уровней сундука, не
219    /// теряет пропущенную ёмкость и не получает её мгновенно — он добирает её
220    /// по шагам того уровня, на котором она открылась. История касаний для
221    /// этого не нужна: потолки монотонны, поэтому накопленного значения
222    /// достаточно, чтобы понять, где игрок стоит.
223    pub fn active_tranche(
224        &self,
225        chest_level: i64,
226        attribute_id: AttributeId,
227        current_micro: i64,
228    ) -> Option<&PlinkoAxisTranche> {
229        let mut previous_cap = 0;
230        let mut fallback: Option<&PlinkoAxisTranche> = None;
231        for level in 1..=chest_level {
232            let Some(axis) = self.tranche(level, attribute_id) else {
233                continue;
234            };
235            let adds_capacity = axis.cap_micro > previous_cap;
236            previous_cap = axis.cap_micro.max(previous_cap);
237            if adds_capacity && axis.cap_micro > current_micro {
238                return Some(axis);
239            }
240            fallback = Some(axis);
241        }
242        // Every tranche up to here is full: the axis is capped, and the caller
243        // grants nothing. The last one is returned so the cap it carries is the
244        // one the clamp reads.
245        fallback
246    }
247
248    /// Потолок оси на текущем уровне сундука — верхняя граница накопления,
249    /// пока сундук не вырастет.
250    pub fn cap_micro_at(&self, chest_level: i64, attribute_id: AttributeId) -> i64 {
251        (1..=chest_level)
252            .filter_map(|level| self.tranche(level, attribute_id))
253            .map(|axis| axis.cap_micro)
254            .max()
255            .unwrap_or(0)
256    }
257
258    /// Целая часть накопленного бонуса — то, что видит бой и UI.
259    pub fn whole_from_micro(micro: i64) -> i64 {
260        micro.div_euclid(PLINKO_MICRO)
261    }
262}