1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct GrantedPinBonus {
24 pub attribute_id: AttributeId,
25 pub granted: i64,
27 pub total: i64,
29}
30
31pub 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
82pub 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
103pub 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
131pub 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
151pub 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 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 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 #[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 #[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 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 apply_pin_bonuses(&settings, 1, &path, &mut bonuses);
437 assert_eq!(bonuses.get(&attribute), 6 * PLINKO_MICRO);
438
439 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 #[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 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 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 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 #[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 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}