overlord_event_system/mechanics/
plinko.rs

1//! Серверная механика доски Plinko.
2//!
3//! Путь шарика решает сервер: он выбирает физический слот под редкость
4//! выданного предмета, катит `rows` шагов Left/Right своим сидом и отдаёт
5//! путь клиенту событием `PlinkoBallDropped`. Колышки, задетые по дороге,
6//! выводятся из пути детерминированно и начисляют постоянные прибавки к
7//! статам — тоже на сервере.
8//!
9//! Клиент не участвует ни в одном из этих решений: путь определяет слот, а
10//! слот определяет редкость награды, поэтому клиентский путь означал бы
11//! клиента, выбирающего себе награду.
12
13use configs::plinko::PlinkoSettings;
14use essences::item_case::ItemCaseRarityWeight;
15use essences::items::{AttributeId, ItemRarityId};
16use essences::plinko::{PlinkoDirection, PlinkoPinBonusesMap, pins_touched_by_path};
17use rand::RngExt;
18use rand::rngs::StdRng;
19use rand::seq::SliceRandom;
20
21/// Одна начисленная прибавка — для логов и тестов.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct GrantedPinBonus {
24    pub attribute_id: AttributeId,
25    /// Сколько реально начислено (0, если упёрлись в потолок).
26    pub granted: i64,
27    /// Значение накопленного бонуса после начисления.
28    pub total: i64,
29}
30
31/// Runtime-раскладка физических слотов для отображения и серверного пути.
32///
33/// Авторская раскладка задаёт только количество физических слотов каждой
34/// редкости. В runtime эти вхождения сортируются по фактическим весам уровня:
35/// самое частое оказывается ближе всего к центру, самое редкое — у краёв.
36/// При равных весах исходная близость к центру и номер слота сохраняют
37/// раскладку стабильной. Клиент воспроизводит тот же простой порядок.
38pub fn runtime_slot_layout(
39    settings: &PlinkoSettings,
40    chest_level: i64,
41    rarity_weights: &[ItemCaseRarityWeight],
42) -> Vec<(i64, ItemRarityId)> {
43    let Some(layout) = settings.layout_for_level(chest_level) else {
44        return Vec::new();
45    };
46
47    let doubled_distance = |slot: i64| (2 * slot - settings.rows).abs();
48    let weight_of = |rarity_id: ItemRarityId| {
49        rarity_weights
50            .iter()
51            .find(|weight| weight.rarity_id == rarity_id)
52            .map(|weight| weight.weight)
53            .unwrap_or(0.0)
54    };
55
56    let mut occurrences: Vec<_> = (0..settings.slot_count())
57        .filter_map(|slot| {
58            layout
59                .rarity_for_slot(slot)
60                .map(|rarity_id| (rarity_id, slot))
61        })
62        .collect();
63    occurrences.sort_by(|(rarity_a, slot_a), (rarity_b, slot_b)| {
64        weight_of(*rarity_b)
65            .total_cmp(&weight_of(*rarity_a))
66            .then_with(|| doubled_distance(*slot_a).cmp(&doubled_distance(*slot_b)))
67            .then_with(|| slot_a.cmp(slot_b))
68    });
69
70    let mut physical_slots: Vec<_> = (0..settings.slot_count()).collect();
71    physical_slots.sort_by_key(|slot| (doubled_distance(*slot), *slot));
72
73    let mut assigned: Vec<_> = occurrences
74        .into_iter()
75        .zip(physical_slots)
76        .map(|((rarity_id, _), physical_slot)| (physical_slot, rarity_id))
77        .collect();
78    assigned.sort_by_key(|(slot, _)| *slot);
79    assigned
80}
81
82/// Все runtime-слоты уровня `chest_level`, несущие редкость выданного
83/// предмета.
84///
85/// Пустой результат означает сломанный конфиг, а не штатный случай:
86/// раскладка каждого уровня обязана покрывать все редкости, выпадающие на
87/// этом уровне (`GameConfig::validate`). Подстановки чужой редкости здесь
88/// больше нет: пока раскладка была одна на всю игру, непокрытая редкость
89/// роняла шарик в слот с ближайшей меньшей подписью, и с 33-го уровня
90/// сундука подпись под шариком не совпадала с наградой ни разу.
91pub fn slots_for_rarity(
92    settings: &PlinkoSettings,
93    chest_level: i64,
94    rarity_weights: &[ItemCaseRarityWeight],
95    rarity_id: ItemRarityId,
96) -> Vec<i64> {
97    runtime_slot_layout(settings, chest_level, rarity_weights)
98        .into_iter()
99        .filter_map(|(slot, slot_rarity)| (slot_rarity == rarity_id).then_some(slot))
100        .collect()
101}
102
103/// Катит путь шарика под редкость выданного предмета.
104///
105/// Ровно `rows` шагов; число шагов `Right` равно индексу целевого слота,
106/// поэтому шарик гарантированно приземляется в слот, подписанный редкостью
107/// этого предмета на доске уровня `chest_level`. Порядок шагов перемешивается
108/// тем же `rng`, что и остальная серверная случайность события, — путь
109/// воспроизводим по сиду сессии.
110///
111/// Ошибка означает сломанный конфиг (нет раскладки на уровень или редкость не
112/// покрыта), а не штатную ситуацию.
113pub fn roll_path_for_rarity(
114    settings: &PlinkoSettings,
115    chest_level: i64,
116    rarity_weights: &[ItemCaseRarityWeight],
117    rarity_id: ItemRarityId,
118    rng: &mut StdRng,
119) -> anyhow::Result<Vec<PlinkoDirection>> {
120    let slots = slots_for_rarity(settings, chest_level, rarity_weights, rarity_id);
121    if slots.is_empty() {
122        anyhow::bail!(
123            "No plinko slot on the chest level {chest_level} board matches item rarity {rarity_id}"
124        );
125    }
126
127    let target_slot = slots[rng.random_range(0..slots.len())];
128    path_to_slot(settings.rows, target_slot, rng)
129}
130
131/// Путь ровно из `rows` шагов, приводящий в слот `target_slot`.
132pub fn path_to_slot(
133    rows: i64,
134    target_slot: i64,
135    rng: &mut StdRng,
136) -> anyhow::Result<Vec<PlinkoDirection>> {
137    if rows <= 0 {
138        anyhow::bail!("Plinko rows must be positive, got {rows}");
139    }
140    if target_slot < 0 || target_slot > rows {
141        anyhow::bail!("Plinko slot {target_slot} is outside 0..={rows}");
142    }
143
144    let mut path = Vec::with_capacity(rows as usize);
145    path.resize(target_slot as usize, PlinkoDirection::Right);
146    path.resize(rows as usize, PlinkoDirection::Left);
147    path.shuffle(rng);
148    Ok(path)
149}
150
151/// Начисляет постоянные прибавки за колышки, задетые путём (BAL-021).
152///
153/// Прибавка считается в микроединицах: `вес колышка × scale активного
154/// транша`, где активный транш — самый ранний уровень сундука, чей потолок
155/// по этой оси ещё не набран. Накопленное значение не превышает потолок
156/// этого транша; упёрлись — колышек не даёт ничего, частичное добирание
157/// остатка допускается.
158pub fn apply_pin_bonuses(
159    settings: &PlinkoSettings,
160    chest_level: i64,
161    path: &[PlinkoDirection],
162    bonuses: &mut PlinkoPinBonusesMap,
163) -> Vec<GrantedPinBonus> {
164    let mut granted = Vec::new();
165    for pin in pins_touched_by_path(path) {
166        let Some(bonus) = settings.pin_bonus(pin.row, pin.column) else {
167            continue;
168        };
169
170        let current = bonuses.get(&bonus.attribute_id);
171        // BAL-021: the pin's weight is priced by the tranche the axis is
172        // currently filling — the oldest one still short of its cap, so a
173        // player who outran the board catches the skipped capacity up at the
174        // step of the level that opened it.
175        let Some(tranche) = settings.active_tranche(chest_level, bonus.attribute_id, current)
176        else {
177            tracing::error!(
178                "Plinko pin ({}, {}) pays attribute {} but chest level {chest_level} has no                  tranche for it; skipping",
179                pin.row,
180                pin.column,
181                bonus.attribute_id
182            );
183            continue;
184        };
185
186        let gain_micro = bonus.value.saturating_mul(tranche.scale_micro);
187        let total = current.saturating_add(gain_micro).min(tranche.cap_micro);
188        let delta = (total - current).max(0);
189        if delta > 0 {
190            bonuses.set(bonus.attribute_id, total);
191        }
192        granted.push(GrantedPinBonus {
193            attribute_id: bonus.attribute_id,
194            granted: delta,
195            total: current.max(total),
196        });
197    }
198    granted
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use configs::plinko::{
205        PLINKO_MICRO, PlinkoAxisTranche, PlinkoLevelSlotLayout, PlinkoPinBonus, PlinkoSlotGroup,
206        PlinkoTranche,
207    };
208    use configs::validated_types::NonEmptyVec;
209    use essences::plinko::slot_index_for_path;
210    use rand::SeedableRng;
211    use uuid::Uuid;
212
213    fn settings_with_rows(rows: i64) -> PlinkoSettings {
214        PlinkoSettings {
215            rows,
216            slot_layouts: NonEmptyVec::new(vec![PlinkoLevelSlotLayout {
217                chest_level: 1,
218                slot_groups: NonEmptyVec::new(vec![PlinkoSlotGroup {
219                    from_slot: 0,
220                    to_slot: rows,
221                    rarity_id: Uuid::nil(),
222                    is_locked: false,
223                }]),
224            }]),
225            pin_bonuses: vec![],
226            tranches: vec![],
227        }
228    }
229
230    fn settings_with_slots(rarities: &[Uuid]) -> PlinkoSettings {
231        let rows = rarities.len() as i64 - 1;
232        PlinkoSettings {
233            rows,
234            slot_layouts: NonEmptyVec::new(vec![PlinkoLevelSlotLayout {
235                chest_level: 1,
236                slot_groups: NonEmptyVec::new(
237                    rarities
238                        .iter()
239                        .enumerate()
240                        .map(|(slot, rarity_id)| PlinkoSlotGroup {
241                            from_slot: slot as i64,
242                            to_slot: slot as i64,
243                            rarity_id: *rarity_id,
244                            is_locked: false,
245                        })
246                        .collect(),
247                ),
248            }]),
249            pin_bonuses: vec![],
250            tranches: vec![],
251        }
252    }
253
254    fn rarity_weights(entries: &[(Uuid, f64)]) -> Vec<ItemCaseRarityWeight> {
255        entries
256            .iter()
257            .map(|(rarity_id, weight)| ItemCaseRarityWeight {
258                rarity_id: *rarity_id,
259                weight: *weight,
260            })
261            .collect()
262    }
263
264    /// One axis, one tranche per chest level: `caps` is the cap of each level
265    /// in whole stat points, `scale` the price of one unit of pin weight.
266    fn tranches(attribute: Uuid, caps: &[i64], scale: i64) -> Vec<PlinkoTranche> {
267        caps.iter()
268            .enumerate()
269            .map(|(index, cap)| PlinkoTranche {
270                chest_level: index as i64 + 1,
271                axes: vec![PlinkoAxisTranche {
272                    attribute_id: attribute,
273                    cap_micro: cap * PLINKO_MICRO,
274                    scale_micro: scale,
275                }],
276            })
277            .collect()
278    }
279
280    #[test]
281    fn path_has_one_step_per_row_and_lands_in_the_requested_slot() {
282        let mut rng = StdRng::seed_from_u64(7);
283        for target in 0..=7 {
284            let path = path_to_slot(7, target, &mut rng).unwrap();
285            assert_eq!(path.len(), 7, "one step per row");
286            assert_eq!(slot_index_for_path(&path), target);
287        }
288    }
289
290    #[test]
291    fn same_seed_gives_the_same_path() {
292        let first = path_to_slot(7, 3, &mut StdRng::seed_from_u64(1234)).unwrap();
293        let second = path_to_slot(7, 3, &mut StdRng::seed_from_u64(1234)).unwrap();
294        assert_eq!(first, second);
295    }
296
297    #[test]
298    fn runtime_layout_puts_heavier_occurrences_closer_to_the_centre() {
299        let high = Uuid::from_u128(1);
300        let medium = Uuid::from_u128(2);
301        let low = Uuid::from_u128(3);
302        let settings = settings_with_slots(&[high, medium, medium, low, low, low, low, low]);
303        let weights = rarity_weights(&[(high, 10.0), (medium, 5.0), (low, 1.0)]);
304
305        let runtime: Vec<_> = runtime_slot_layout(&settings, 1, &weights)
306            .into_iter()
307            .map(|(_, rarity)| rarity)
308            .collect();
309
310        assert_eq!(runtime, vec![low, low, medium, high, medium, low, low, low]);
311        assert_eq!(runtime.iter().filter(|&&rarity| rarity == high).count(), 1);
312        assert_eq!(
313            runtime.iter().filter(|&&rarity| rarity == medium).count(),
314            2
315        );
316        assert_eq!(runtime.iter().filter(|&&rarity| rarity == low).count(), 5);
317    }
318
319    #[test]
320    fn equal_weights_preserve_the_authored_slot_order() {
321        let first = Uuid::from_u128(1);
322        let second = Uuid::from_u128(2);
323        let authored = vec![first, second, first, second];
324        let settings = settings_with_slots(&authored);
325        let weights = rarity_weights(&[(first, 5.0), (second, 5.0)]);
326
327        let runtime: Vec<_> = runtime_slot_layout(&settings, 1, &weights)
328            .into_iter()
329            .map(|(_, rarity)| rarity)
330            .collect();
331
332        assert_eq!(runtime, authored);
333    }
334
335    /// Раскладка берётся под уровень сундука: одна и та же редкость на разных
336    /// уровнях падает в разные слоты.
337    #[test]
338    fn the_board_of_the_players_chest_level_decides_the_slot() {
339        let rarity = Uuid::from_u128(7);
340        let other = Uuid::from_u128(8);
341        let weights = rarity_weights(&[(rarity, 10.0), (other, 1.0)]);
342        let mut settings = settings_with_rows(3);
343        settings.slot_layouts = NonEmptyVec::new(vec![
344            PlinkoLevelSlotLayout {
345                chest_level: 1,
346                slot_groups: NonEmptyVec::new(vec![PlinkoSlotGroup {
347                    from_slot: 0,
348                    to_slot: 3,
349                    rarity_id: rarity,
350                    is_locked: false,
351                }]),
352            },
353            PlinkoLevelSlotLayout {
354                chest_level: 2,
355                slot_groups: NonEmptyVec::new(vec![
356                    PlinkoSlotGroup {
357                        from_slot: 0,
358                        to_slot: 2,
359                        rarity_id: other,
360                        is_locked: false,
361                    },
362                    PlinkoSlotGroup {
363                        from_slot: 3,
364                        to_slot: 3,
365                        rarity_id: rarity,
366                        is_locked: false,
367                    },
368                ]),
369            },
370        ]);
371
372        let mut rng = StdRng::seed_from_u64(11);
373        for _ in 0..20 {
374            let level_two = roll_path_for_rarity(&settings, 2, &weights, rarity, &mut rng).unwrap();
375            assert_eq!(
376                slot_index_for_path(&level_two),
377                1,
378                "the frequent rarity moves from authored slot 3 to the runtime centre"
379            );
380            let level_one = roll_path_for_rarity(&settings, 1, &weights, rarity, &mut rng).unwrap();
381            assert!((0..=3).contains(&slot_index_for_path(&level_one)));
382        }
383    }
384
385    /// Непокрытая редкость и уровень без раскладки — сломанный конфиг, а не
386    /// штатный путь: подстановки чужой редкости больше нет.
387    #[test]
388    fn an_uncovered_rarity_is_an_error_not_a_fallback() {
389        let settings = settings_with_rows(3);
390        let weights = rarity_weights(&[(Uuid::nil(), 1.0)]);
391
392        assert!(
393            roll_path_for_rarity(
394                &settings,
395                1,
396                &weights,
397                Uuid::from_u128(42),
398                &mut StdRng::seed_from_u64(1)
399            )
400            .is_err(),
401            "a rarity with no slot on that level must fail loudly"
402        );
403        assert!(
404            roll_path_for_rarity(
405                &settings,
406                2,
407                &weights,
408                Uuid::nil(),
409                &mut StdRng::seed_from_u64(1)
410            )
411            .is_err(),
412            "a chest level with no layout must fail loudly"
413        );
414    }
415
416    #[test]
417    fn cap_stops_further_grants() {
418        let attribute = Uuid::from_u128(1);
419        let mut settings = settings_with_rows(2);
420        settings.pin_bonuses = vec![PlinkoPinBonus {
421            row: 0,
422            column: 0,
423            attribute_id: attribute,
424            value: 4,
425        }];
426        // One chest level, cap 6 whole points, one point per unit of weight.
427        settings.tranches = tranches(attribute, &[6], PLINKO_MICRO);
428
429        let mut bonuses = PlinkoPinBonusesMap::default();
430        let path = vec![PlinkoDirection::Left, PlinkoDirection::Left];
431
432        apply_pin_bonuses(&settings, 1, &path, &mut bonuses);
433        assert_eq!(bonuses.get(&attribute), 4 * PLINKO_MICRO);
434
435        // Второе касание добирает только остаток до потолка.
436        apply_pin_bonuses(&settings, 1, &path, &mut bonuses);
437        assert_eq!(bonuses.get(&attribute), 6 * PLINKO_MICRO);
438
439        // Третье не даёт ничего.
440        let granted = apply_pin_bonuses(&settings, 1, &path, &mut bonuses);
441        assert_eq!(bonuses.get(&attribute), 6 * PLINKO_MICRO);
442        assert_eq!(granted.first().map(|g| g.granted), Some(0));
443    }
444
445    /// BAL-021: a chest upgrade opens the NEXT tranche, and the axis keeps
446    /// filling at the price of the tranche it is on — not at the newest one.
447    #[test]
448    fn a_new_chest_level_opens_the_next_tranche_at_its_own_price() {
449        let attribute = Uuid::from_u128(1);
450        let mut settings = settings_with_rows(2);
451        settings.pin_bonuses = vec![PlinkoPinBonus {
452            row: 0,
453            column: 0,
454            attribute_id: attribute,
455            value: 1,
456        }];
457        // L1 pays a whole point per touch up to 2; L2 adds one more point of
458        // room but pays a tenth of a point per touch.
459        settings.tranches = vec![
460            PlinkoTranche {
461                chest_level: 1,
462                axes: vec![PlinkoAxisTranche {
463                    attribute_id: attribute,
464                    cap_micro: 2 * PLINKO_MICRO,
465                    scale_micro: PLINKO_MICRO,
466                }],
467            },
468            PlinkoTranche {
469                chest_level: 2,
470                axes: vec![PlinkoAxisTranche {
471                    attribute_id: attribute,
472                    cap_micro: 3 * PLINKO_MICRO,
473                    scale_micro: PLINKO_MICRO / 10,
474                }],
475            },
476        ];
477
478        let mut bonuses = PlinkoPinBonusesMap::default();
479        let path = vec![PlinkoDirection::Left, PlinkoDirection::Left];
480
481        // Two touches fill L1 exactly.
482        apply_pin_bonuses(&settings, 2, &path, &mut bonuses);
483        apply_pin_bonuses(&settings, 2, &path, &mut bonuses);
484        assert_eq!(bonuses.get(&attribute), 2 * PLINKO_MICRO);
485
486        // The third touch is priced by L2, so it moves a tenth of a point —
487        // and stays visible only because the aggregate is fixed-point.
488        apply_pin_bonuses(&settings, 2, &path, &mut bonuses);
489        assert_eq!(
490            bonuses.get(&attribute),
491            2 * PLINKO_MICRO + PLINKO_MICRO / 10
492        );
493        assert_eq!(
494            configs::plinko::PlinkoSettings::whole_from_micro(bonuses.get(&attribute)),
495            2,
496            "combat reads whole points; the tail accumulates"
497        );
498    }
499
500    /// The catch-up rule: a player who upgraded past a tranche without filling
501    /// it keeps filling THAT tranche first, at its price.
502    #[test]
503    fn a_skipped_tranche_is_caught_up_before_the_current_one() {
504        let attribute = Uuid::from_u128(1);
505        let mut settings = settings_with_rows(2);
506        settings.pin_bonuses = vec![PlinkoPinBonus {
507            row: 0,
508            column: 0,
509            attribute_id: attribute,
510            value: 1,
511        }];
512        settings.tranches = vec![
513            PlinkoTranche {
514                chest_level: 1,
515                axes: vec![PlinkoAxisTranche {
516                    attribute_id: attribute,
517                    cap_micro: 5 * PLINKO_MICRO,
518                    scale_micro: PLINKO_MICRO,
519                }],
520            },
521            PlinkoTranche {
522                chest_level: 2,
523                axes: vec![PlinkoAxisTranche {
524                    attribute_id: attribute,
525                    cap_micro: 6 * PLINKO_MICRO,
526                    scale_micro: PLINKO_MICRO / 100,
527                }],
528            },
529        ];
530
531        // The player is on chest level 2 having banked nothing at all.
532        let mut bonuses = PlinkoPinBonusesMap::default();
533        let path = vec![PlinkoDirection::Left, PlinkoDirection::Left];
534        apply_pin_bonuses(&settings, 2, &path, &mut bonuses);
535
536        assert_eq!(
537            bonuses.get(&attribute),
538            PLINKO_MICRO,
539            "the unfilled L1 tranche pays first, at L1's price"
540        );
541    }
542
543    #[test]
544    fn pins_without_a_configured_bonus_grant_nothing() {
545        let settings = settings_with_rows(3);
546        let mut bonuses = PlinkoPinBonusesMap::default();
547        let path = vec![
548            PlinkoDirection::Right,
549            PlinkoDirection::Left,
550            PlinkoDirection::Right,
551        ];
552
553        assert!(apply_pin_bonuses(&settings, 1, &path, &mut bonuses).is_empty());
554        assert!(bonuses.is_empty());
555    }
556}