veloren_voxygen/
discord.rs

1use std::time::{Duration, SystemTime};
2
3use common::terrain::SiteKindMeta;
4use discord_sdk::{
5    self as ds, activity,
6    activity::{ActivityArgs, ActivityBuilder},
7};
8use tokio::{
9    sync::mpsc::{UnboundedSender, unbounded_channel},
10    time::{MissedTickBehavior, interval},
11};
12use tracing::{debug, info, warn};
13
14/// Discord app id
15///
16/// **Note:** currently a private app created for testing purposes, can be
17/// shared to a team or replaced entirely later on
18const DISCORD_APP_ID: ds::AppId = 1006661232465563698;
19
20/// Discord presence update command
21#[derive(Debug, Clone)]
22pub enum ActivityUpdate {
23    /// Clear the current Discord activity and exit the activity task
24    Clear,
25    /// Set the activity to "In Main Menu"
26    MainMenu,
27    /// Set the activity to "In Character Selection"
28    CharacterSelection,
29    /// Set the activity to "Playing Singleplayer"
30    JoinSingleplayer,
31    /// Set the activity to "Playing Multiplayer"
32    JoinServer(String),
33    /// Set the large asset text to the location name
34    NewLocation {
35        chunk_name: String,
36        site: SiteKindMeta,
37    },
38}
39
40impl ActivityUpdate {
41    /// Rich Presence asset keys: the backgrounds used in the main menu and
42    /// loading screen
43    ///
44    /// TODO: randomize images? use them according to the current biome?
45    const ASSETS: [&'static str; 15] = [
46        "bg_main", "bg_1", "bg_2", "bg_3", "bg_4", "bg_5", "bg_6", "bg_7", "bg_8", "bg_9", "bg_10",
47        "bg_11", "bg_12", "bg_13", "bg_14",
48    ];
49    /// Rich Presence character screen asset key
50    const CHARACTER_SCREEN_ASSET: &'static str = "character_screen";
51    /// Rich Presence logo asset key
52    const LOGO_ASSET: &'static str = "logo";
53
54    /// Edit the current activity args according to the command in `self`.
55    ///
56    /// - For `MainMenu`, `CharacterSelection`, `JoinSingleplayer` and
57    ///   `JoinServer(name)`: create a new activity and discard the previous one
58    /// - For `NewLocation` and `LevelUp`: update the current activity
59    fn edit_activity(self, args: &mut ActivityArgs) {
60        use ActivityUpdate::*;
61
62        match self {
63            Clear => (),
64            MainMenu => {
65                *args = ActivityBuilder::default()
66                    .start_timestamp(SystemTime::now())
67                    .state("Idle")
68                    .details("In Main Menu")
69                    .assets(
70                        activity::Assets::default().large(Self::LOGO_ASSET, Option::<&str>::None),
71                    )
72                    .into();
73            },
74            CharacterSelection => {
75                *args = ActivityBuilder::default()
76                    .start_timestamp(SystemTime::now())
77                    .state("Idle")
78                    .details("In Character Selection")
79                    .assets(
80                        activity::Assets::default()
81                            .large(Self::CHARACTER_SCREEN_ASSET, Option::<&str>::None)
82                            .small(Self::LOGO_ASSET, Option::<&str>::None),
83                    )
84                    .into();
85            },
86            JoinSingleplayer => {
87                *args = ActivityBuilder::default()
88                    .start_timestamp(SystemTime::now())
89                    .details("Playing Singleplayer")
90                    .assets(
91                        activity::Assets::default()
92                            .large(Self::ASSETS[9], Option::<&str>::None)
93                            .small(Self::LOGO_ASSET, Option::<&str>::None),
94                    )
95                    .into();
96            },
97            JoinServer(server_name) => {
98                *args = ActivityBuilder::default()
99                    .start_timestamp(SystemTime::now())
100                    .state(format!("On {server_name}"))
101                    .details("Playing Multiplayer")
102                    .assets(
103                        activity::Assets::default()
104                            .large(Self::ASSETS[1], Option::<&str>::None)
105                            .small(Self::LOGO_ASSET, Option::<&str>::None),
106                    )
107                    .into();
108            },
109            NewLocation { chunk_name, site } => {
110                use common::terrain::site::{
111                    DungeonKindMeta::*, SettlementKindMeta::*, SiteKindMeta::*,
112                };
113
114                let location = match site {
115                    Dungeon(Gnarling) => format!("Hunting Gnarlings in {chunk_name}"),
116                    Dungeon(Adlet) => format!("Finding the Yeti in {chunk_name}"),
117                    Dungeon(SeaChapel) => format!("Gathering sea treasures in {chunk_name}"),
118                    Dungeon(Terracotta) => format!("Exploring ruins in {chunk_name}"),
119                    Cave => "In a Cave".to_string(),
120                    Settlement(Default) => format!("Visiting {chunk_name}"),
121                    Settlement(CliffTown) => format!("Climbing the towers of {chunk_name}"),
122                    Settlement(DesertCity) => format!("Hiding from the sun in {chunk_name}"),
123                    Settlement(SavannahTown) => format!("Shop at the market down in {chunk_name}"),
124                    Settlement(CoastalTown) => {
125                        format!("Dip your feet in the water in {chunk_name}")
126                    },
127                    _ => format!("In {chunk_name}"),
128                };
129
130                args.activity.as_mut().map(|a| {
131                    a.assets.as_mut().map(|assets| {
132                        assets.large_text = Some(location);
133                    })
134                });
135            },
136        }
137    }
138}
139
140/// A channel to the background task that updates the Discord activity.
141pub enum Discord {
142    /// Active state, receiving updates
143    Active {
144        /// The channel to communicate with the tokio task
145        channel: UnboundedSender<ActivityUpdate>,
146        /// Current chunk name, cached to check for updates
147        current_chunk_name: Option<String>,
148        /// Current site, cached to check for updates
149        current_site: SiteKindMeta,
150    },
151    /// Inactive state: either the Discord app could not be contacted, is not
152    /// installed, or was disconnected
153    Inactive,
154}
155
156impl Discord {
157    /// Start a background [tokio task](tokio::task) that will update the
158    /// Discord activity every 4 seconds (due to rate limits) if it has
159    /// changed.
160    ///
161    /// The [`update`](Discord::update) method can be used on the returned
162    /// struct to update the Discord activity via a channel command
163    pub fn start(rt: &tokio::runtime::Runtime) -> Self {
164        let (sender, mut receiver) = unbounded_channel::<ActivityUpdate>();
165
166        rt.spawn(async move {
167            let (wheel, handler) = ds::wheel::Wheel::new(Box::new(|err| {
168                warn!(error = ?err, "Encountered an error while connecting to Discord");
169            }));
170
171            let mut user = wheel.user();
172
173            let discord = match ds::Discord::new(
174                ds::DiscordApp::PlainId(DISCORD_APP_ID),
175                ds::Subscriptions::ACTIVITY,
176                Box::new(handler),
177            ) {
178                Ok(ds) => {
179                    if let Err(err) = user.0.changed().await {
180                        warn!(err = ?err, "Could not execute handshake to Discord");
181                        // If no handshake is received, exit the task immediately
182                        return;
183                    }
184                    info!("Connected to Discord");
185                    ds
186                },
187                Err(err) => {
188                    info!(err = ?err, "Could not connect to Discord app");
189                    // If no Discord app was found, exit the task immediately
190                    return;
191                },
192            };
193
194            let mut args = ActivityArgs::default();
195            let mut has_changed = false;
196            let mut interval = interval(Duration::from_secs(4));
197            interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
198
199            loop {
200                // Check every four seconds if the activity needs to change
201                tokio::select! {
202                    biased; // to save the CPU cost of selecting a random branch
203
204                    _ = interval.tick(), if has_changed => {
205                        has_changed = false;
206                        let activity = args.activity.clone();
207                        match discord.update_activity(args).await {
208                            Err(err) => {
209                                warn!(error = ?err, "Could not update Discord activity");
210                            }
211                            Ok(Some(new_activity)) => {
212                                debug!(new_activity = ?new_activity, "Updated Discord activity");
213                            },
214                            Ok(None) => ()
215                        }
216                        args = ActivityArgs::default();
217                        args.activity = activity;
218                    }
219                    update = receiver.recv() => match update {
220                        None | Some(ActivityUpdate::Clear) => {
221                            match discord.clear_activity().await {
222                                Ok(_) => {
223                                    info!("Cleared Discord activity");
224                                },
225                                Err(err) => {
226                                    warn!(error = ?err, "Failed to clear Discord activity")
227                                }
228                            }
229                            return;
230                        },
231                        Some(update) => {
232                            update.edit_activity(&mut args);
233                            has_changed = true;
234                        },
235                    }
236                }
237            }
238        });
239
240        Self::Active {
241            channel: sender,
242            current_chunk_name: None,
243            current_site: SiteKindMeta::Void,
244        }
245    }
246
247    /// Send an activity update to the background task
248    #[inline]
249    fn update(&mut self, update: ActivityUpdate) {
250        if let Self::Active { channel, .. } = self {
251            // On error, turn itself into inactive to avoid sending unecessary updates
252            if channel.send(update).is_err() {
253                *self = Self::Inactive;
254            }
255        }
256    }
257
258    /// Clear the Discord activity
259    #[inline]
260    pub fn clear_activity(&mut self) {
261        self.update(ActivityUpdate::Clear);
262        *self = Discord::Inactive;
263    }
264
265    /// Sets the current Discord activity to Main Menu
266    #[inline]
267    pub fn enter_main_menu(&mut self) { self.update(ActivityUpdate::MainMenu); }
268
269    /// Sets the current Discord activity to Character Selection
270    #[inline]
271    pub fn enter_character_selection(&mut self) { self.update(ActivityUpdate::CharacterSelection); }
272
273    /// Sets the current Discord activity to Singleplayer
274    #[inline]
275    pub fn join_singleplayer(&mut self) { self.update(ActivityUpdate::JoinSingleplayer); }
276
277    /// Sets the current Discord activity to Multiplayer with the corresponding
278    /// server name
279    #[inline]
280    pub fn join_server(&mut self, server_name: String) {
281        self.update(ActivityUpdate::JoinServer(server_name));
282    }
283
284    /// Check the current location name and update it if it has changed
285    #[inline]
286    pub fn update_location(&mut self, chunk_name: &str, site: SiteKindMeta) {
287        if let Self::Active {
288            current_chunk_name,
289            current_site,
290            ..
291        } = self
292        {
293            let different_name = current_chunk_name.as_deref() != Some(chunk_name);
294            if different_name || *current_site != site {
295                if different_name {
296                    *current_chunk_name = Some(chunk_name.to_string());
297                }
298                *current_site = site;
299                self.update(ActivityUpdate::NewLocation {
300                    chunk_name: chunk_name.to_string(),
301                    site,
302                });
303            }
304        }
305    }
306
307    /// Check wether the Discord activity is active and receiving updates
308    #[inline]
309    pub fn is_active(&self) -> bool { matches!(self, Self::Active { .. }) }
310}