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
14const DISCORD_APP_ID: ds::AppId = 1006661232465563698;
19
20#[derive(Debug, Clone)]
22pub enum ActivityUpdate {
23 Clear,
25 MainMenu,
27 CharacterSelection,
29 JoinSingleplayer,
31 JoinServer(String),
33 NewLocation {
35 chunk_name: String,
36 site: SiteKindMeta,
37 },
38}
39
40impl ActivityUpdate {
41 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 const CHARACTER_SCREEN_ASSET: &'static str = "character_screen";
51 const LOGO_ASSET: &'static str = "logo";
53
54 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
140pub enum Discord {
142 Active {
144 channel: UnboundedSender<ActivityUpdate>,
146 current_chunk_name: Option<String>,
148 current_site: SiteKindMeta,
150 },
151 Inactive,
154}
155
156impl Discord {
157 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 return;
183 }
184 info!("Connected to Discord");
185 ds
186 },
187 Err(err) => {
188 info!(err = ?err, "Could not connect to Discord app");
189 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 tokio::select! {
202 biased; _ = 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 #[inline]
249 fn update(&mut self, update: ActivityUpdate) {
250 if let Self::Active { channel, .. } = self {
251 if channel.send(update).is_err() {
253 *self = Self::Inactive;
254 }
255 }
256 }
257
258 #[inline]
260 pub fn clear_activity(&mut self) {
261 self.update(ActivityUpdate::Clear);
262 *self = Discord::Inactive;
263 }
264
265 #[inline]
267 pub fn enter_main_menu(&mut self) { self.update(ActivityUpdate::MainMenu); }
268
269 #[inline]
271 pub fn enter_character_selection(&mut self) { self.update(ActivityUpdate::CharacterSelection); }
272
273 #[inline]
275 pub fn join_singleplayer(&mut self) { self.update(ActivityUpdate::JoinSingleplayer); }
276
277 #[inline]
280 pub fn join_server(&mut self, server_name: String) {
281 self.update(ActivityUpdate::JoinServer(server_name));
282 }
283
284 #[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 #[inline]
309 pub fn is_active(&self) -> bool { matches!(self, Self::Active { .. }) }
310}