Skip to content

Commit ddef4c0

Browse files
authored
feat: add support for mods, rates and custom attributes for /map (MaxOhn#1088)
* feat: add rate support and modals to /map * fix: clean-up debug logging * feat: add clock_rate as a parameter for /map * chore: fix-up logs - remove unnecessary debug logs from parsing - change log for modal failures to use Debug over Display
1 parent fb4c95b commit ddef4c0

2 files changed

Lines changed: 321 additions & 12 deletions

File tree

bathbot/src/active/impls/map.rs

Lines changed: 294 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,36 @@ use std::fmt::Write;
22

33
use bathbot_macros::PaginationBuilder;
44
use bathbot_util::{
5-
AuthorBuilder, CowUtils, EmbedBuilder, FooterBuilder, MessageOrigin, attachment,
5+
AuthorBuilder, Authored, CowUtils, EmbedBuilder, FooterBuilder, MessageOrigin, attachment,
66
constants::{AVATAR_URL, OSU_BASE},
77
datetime::SecToMinSec,
88
fields,
9+
modal::{ModalBuilder, TextInputBuilder},
910
numbers::{WithComma, round},
1011
};
11-
use eyre::{Result, WrapErr};
12+
use eyre::{ContextCompat, Result, WrapErr};
1213
use rosu_pp::{Difficulty, any::HitResultPriority};
1314
use rosu_v2::prelude::{
1415
BeatmapExtended, BeatmapsetExtended, GameMode, GameModsIntermode, Username,
1516
};
1617
use twilight_model::{
17-
channel::message::Component,
18+
channel::message::{
19+
Component,
20+
component::{ActionRow, Button, ButtonStyle},
21+
},
1822
id::{Id, marker::UserMarker},
1923
};
2024

2125
use crate::{
2226
active::{
2327
BuildPage, ComponentResult, IActiveMessage,
24-
pagination::{Pages, handle_pagination_component, handle_pagination_modal},
28+
pagination::{Pages, handle_pagination_component},
2529
},
2630
commands::osu::CustomAttrs,
2731
core::Context,
2832
manager::redis::osu::UserArgs,
2933
util::{
30-
Emote,
34+
Emote, ModalExt,
3135
interaction::{InteractionComponent, InteractionModal},
3236
},
3337
};
@@ -80,7 +84,15 @@ impl IActiveMessage for MapPagination {
8084
let mut seconds_drain = map.seconds_drain;
8185
let mut bpm = map.bpm as f64;
8286

83-
let clock_rate = self.mods.legacy_clock_rate();
87+
let clock_rate = match (self.attrs.bpm, self.attrs.clock_rate) {
88+
(None, None) => self.mods.legacy_clock_rate(),
89+
(_, Some(clock_rate)) => clock_rate,
90+
(Some(new_bpm), None) => {
91+
let old_bpm = map.bpm;
92+
(new_bpm / old_bpm) as f64
93+
}
94+
};
95+
8496
seconds_total = (seconds_total as f64 / clock_rate) as u32;
8597
seconds_drain = (seconds_drain as f64 / clock_rate) as u32;
8698
bpm *= clock_rate;
@@ -321,15 +333,264 @@ impl IActiveMessage for MapPagination {
321333
}
322334

323335
fn build_components(&self) -> Vec<Component> {
324-
self.pages.components()
336+
let mods = Button {
337+
custom_id: Some("map_mods".to_owned()),
338+
disabled: false,
339+
emoji: None,
340+
label: Some("Mods".to_owned()),
341+
style: ButtonStyle::Primary,
342+
url: None,
343+
sku_id: None,
344+
};
345+
let clock_rate = Button {
346+
custom_id: Some("map_clock_rate".to_owned()),
347+
disabled: false,
348+
emoji: None,
349+
label: Some("Clock rate".to_owned()),
350+
style: ButtonStyle::Primary,
351+
url: None,
352+
sku_id: None,
353+
};
354+
let attrs = Button {
355+
custom_id: Some("map_attrs".to_owned()),
356+
disabled: false,
357+
emoji: None,
358+
label: Some("Attributes".to_owned()),
359+
style: ButtonStyle::Primary,
360+
url: None,
361+
sku_id: None,
362+
};
363+
let top: Component = Component::ActionRow(ActionRow {
364+
components: vec![mods, clock_rate, attrs]
365+
.into_iter()
366+
.map(Component::Button)
367+
.collect(),
368+
});
369+
let pages = if self.pages.last_index() == 0 {
370+
None
371+
} else {
372+
let jump_start = Button {
373+
custom_id: Some("pagination_start".to_owned()),
374+
disabled: self.pages.index() == 0,
375+
emoji: Some(Emote::JumpStart.reaction_type()),
376+
label: None,
377+
style: ButtonStyle::Secondary,
378+
url: None,
379+
sku_id: None,
380+
};
381+
382+
let single_step_back = Button {
383+
custom_id: Some("pagination_back".to_owned()),
384+
disabled: self.pages.index() == 0,
385+
emoji: Some(Emote::SingleStepBack.reaction_type()),
386+
label: None,
387+
style: ButtonStyle::Secondary,
388+
url: None,
389+
sku_id: None,
390+
};
391+
392+
let jump_custom = Button {
393+
custom_id: Some("pagination_custom".to_owned()),
394+
disabled: false,
395+
emoji: Some(Emote::MyPosition.reaction_type()),
396+
label: None,
397+
style: ButtonStyle::Secondary,
398+
url: None,
399+
sku_id: None,
400+
};
401+
402+
let single_step = Button {
403+
custom_id: Some("pagination_step".to_owned()),
404+
disabled: self.pages.index() == self.pages.last_index(),
405+
emoji: Some(Emote::SingleStep.reaction_type()),
406+
label: None,
407+
style: ButtonStyle::Secondary,
408+
url: None,
409+
sku_id: None,
410+
};
411+
412+
let jump_end = Button {
413+
custom_id: Some("pagination_end".to_owned()),
414+
disabled: self.pages.index() == self.pages.last_index(),
415+
emoji: Some(Emote::JumpEnd.reaction_type()),
416+
label: None,
417+
style: ButtonStyle::Secondary,
418+
url: None,
419+
sku_id: None,
420+
};
421+
422+
Some(Component::ActionRow(ActionRow {
423+
components: vec![
424+
Component::Button(jump_start),
425+
Component::Button(single_step_back),
426+
Component::Button(jump_custom),
427+
Component::Button(single_step),
428+
Component::Button(jump_end),
429+
],
430+
}))
431+
};
432+
433+
let mut components = vec![top];
434+
if let Some(pages) = pages {
435+
components.push(pages);
436+
}
437+
components
325438
}
326439

327440
async fn handle_component(&mut self, component: &mut InteractionComponent) -> ComponentResult {
328-
handle_pagination_component(component, self.msg_owner, true, &mut self.pages).await
441+
let user_id = match component.user_id() {
442+
Ok(user_id) => user_id,
443+
Err(err) => return ComponentResult::Err(err),
444+
};
445+
446+
if user_id != self.msg_owner {
447+
return ComponentResult::Ignore;
448+
}
449+
450+
if component.data.custom_id.starts_with("pagination") {
451+
return handle_pagination_component(component, self.msg_owner, true, &mut self.pages)
452+
.await;
453+
}
454+
let modal = match component.data.custom_id.as_str() {
455+
"map_mods" => {
456+
let input = TextInputBuilder::new("map_mods", "Mods")
457+
.placeholder("E.g. hd or HdHRdteZ")
458+
.required(false);
459+
460+
ModalBuilder::new("map_mods", "Specify mods").input(input)
461+
}
462+
"map_clock_rate" => {
463+
let clock_rate = TextInputBuilder::new("map_clock_rate", "Clock rate")
464+
.placeholder("Specify a clock rate")
465+
.required(false);
466+
467+
let bpm = TextInputBuilder::new(
468+
"map_bpm",
469+
"BPM (overwritten if clock rate is specified)",
470+
)
471+
.placeholder("Specify a BPM")
472+
.required(false);
473+
474+
ModalBuilder::new("map_speed_adjustments", "Speed adjustments")
475+
.input(clock_rate)
476+
.input(bpm)
477+
}
478+
"map_attrs" => {
479+
let ar = TextInputBuilder::new("map_ar", "AR")
480+
.placeholder("Specify an approach rate")
481+
.required(false);
482+
483+
let cs = TextInputBuilder::new("map_cs", "CS")
484+
.placeholder("Specify a circle size")
485+
.required(false);
486+
487+
let hp = TextInputBuilder::new("map_hp", "HP")
488+
.placeholder("Specify a drain rate")
489+
.required(false);
490+
491+
let od = TextInputBuilder::new("map_od", "OD")
492+
.placeholder("Specify an overall difficulty")
493+
.required(false);
494+
495+
ModalBuilder::new("map_attrs", "Attributes")
496+
.input(ar)
497+
.input(cs)
498+
.input(hp)
499+
.input(od)
500+
}
501+
other => {
502+
warn!(name = %other, ?component, "Unknown map component");
503+
504+
return ComponentResult::Ignore;
505+
}
506+
};
507+
ComponentResult::CreateModal(modal)
329508
}
330509

331510
async fn handle_modal(&mut self, modal: &mut InteractionModal) -> Result<()> {
332-
handle_pagination_modal(modal, self.msg_owner, true, &mut self.pages).await
511+
if modal.user_id()? != self.msg_owner {
512+
return Ok(());
513+
}
514+
515+
let input = modal
516+
.data
517+
.components
518+
.first()
519+
.and_then(|row| row.components.first())
520+
.wrap_err("Missing map modal input")?
521+
.value
522+
.as_deref()
523+
.filter(|val| !val.is_empty());
524+
let map = &self.maps[self.pages.index()];
525+
526+
match modal.data.custom_id.as_str() {
527+
"pagination_custom" => {
528+
let Some(Ok(page)) = input.as_deref().map(str::parse) else {
529+
debug!(input = input, "Failed to parse page input as usize");
530+
531+
return Ok(());
532+
};
533+
534+
let max_page = self.pages.last_page();
535+
536+
if !(1..=max_page).contains(&page) {
537+
debug!("Page {page} is not between 1 and {max_page}");
538+
539+
return Ok(());
540+
}
541+
542+
self.pages.set_index((page - 1) * self.pages.per_page());
543+
}
544+
"map_mods" => {
545+
let mods_res = input.map(|s| {
546+
s.trim_start_matches('+')
547+
.trim_end_matches('!')
548+
.parse::<GameModsIntermode>()
549+
});
550+
551+
let mods = match mods_res {
552+
Some(Ok(value)) => Some(value),
553+
Some(Err(_)) => {
554+
debug!(input, "Failed to parse simulate mods");
555+
556+
return Ok(());
557+
}
558+
None => None,
559+
};
560+
debug!(?mods, "Matched mods");
561+
562+
match mods.map(|mods| mods.try_with_mode(map.mode)) {
563+
Some(Some(mods)) if mods.is_valid() => self.mods = mods.into(),
564+
None => self.mods = GameModsIntermode::new(),
565+
Some(Some(mods)) => {
566+
debug!("Incompatible mods {mods}");
567+
568+
return Ok(());
569+
}
570+
Some(None) => {
571+
debug!(input, "Invalid mods for mode");
572+
573+
return Ok(());
574+
}
575+
}
576+
}
577+
"map_attrs" => {
578+
self.attrs.ar = parse_attr(&*modal, "map_ar");
579+
self.attrs.cs = parse_attr(&*modal, "map_cs");
580+
self.attrs.hp = parse_attr(&*modal, "map_hp");
581+
self.attrs.od = parse_attr(&*modal, "map_od");
582+
}
583+
"map_speed_adjustments" => {
584+
self.attrs.clock_rate = parse_attr(&*modal, "map_clock_rate");
585+
self.attrs.bpm = parse_attr(modal, "map_bpm");
586+
}
587+
other => warn!(name = %other, ?modal, "Unknown map modal"),
588+
}
589+
if let Err(err) = modal.defer().await {
590+
warn!(?err, "Failed to defer modal");
591+
}
592+
593+
Ok(())
333594
}
334595
}
335596

@@ -361,3 +622,27 @@ async fn creator_name(map: &BeatmapExtended, mapset: &BeatmapsetExtended) -> Opt
361622
}
362623
}
363624
}
625+
626+
fn parse_attr<T: std::str::FromStr + std::fmt::Debug>(
627+
modal: &InteractionModal,
628+
component_id: &str,
629+
) -> Option<T> {
630+
let result = modal
631+
.data
632+
.components
633+
.iter()
634+
.find_map(|row| {
635+
row.components.first().and_then(|component| {
636+
(component.custom_id == component_id).then(|| {
637+
component
638+
.value
639+
.as_deref()
640+
.filter(|value| !value.is_empty())
641+
.map(str::parse)
642+
.and_then(Result::ok)
643+
})
644+
})
645+
})
646+
.flatten();
647+
result
648+
}

0 commit comments

Comments
 (0)