mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
306 lines
12 KiB
Rust
306 lines
12 KiB
Rust
use std::time::{Duration, SystemTime};
|
|
|
|
use common::terrain::SiteKindMeta;
|
|
use discord_sdk::{
|
|
self as ds, activity,
|
|
activity::{ActivityArgs, ActivityBuilder},
|
|
};
|
|
use tokio::{
|
|
sync::mpsc::{unbounded_channel, UnboundedSender},
|
|
time::{interval, MissedTickBehavior},
|
|
};
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Discord app id
|
|
///
|
|
/// **Note:** currently a private app created for testing purposes, can be
|
|
/// shared to a team or replaced entirely later on
|
|
const DISCORD_APP_ID: ds::AppId = 1006661232465563698;
|
|
|
|
/// Discord presence update command
|
|
#[derive(Debug, Clone)]
|
|
pub enum ActivityUpdate {
|
|
/// Clear the current Discord activity and exit the activity task
|
|
Clear,
|
|
/// Set the activity to "In Main Menu"
|
|
MainMenu,
|
|
/// Set the activity to "In Character Selection"
|
|
CharacterSelection,
|
|
/// Set the activity to "Playing Singleplayer"
|
|
JoinSingleplayer,
|
|
/// Set the activity to "Playing Multiplayer"
|
|
JoinServer(String),
|
|
/// Set the large asset text to the location name
|
|
NewLocation {
|
|
chunk_name: String,
|
|
site: SiteKindMeta,
|
|
},
|
|
}
|
|
|
|
impl ActivityUpdate {
|
|
/// Rich Presence asset keys: the backgrounds used in the main menu and
|
|
/// loading screen
|
|
///
|
|
/// TODO: randomize images? use them according to the current biome?
|
|
const ASSETS: [&'static str; 15] = [
|
|
"bg_main", "bg_1", "bg_2", "bg_3", "bg_4", "bg_5", "bg_6", "bg_7", "bg_8", "bg_9", "bg_10",
|
|
"bg_11", "bg_12", "bg_13", "bg_14",
|
|
];
|
|
/// Rich Presence character screen asset key
|
|
const CHARACTER_SCREEN_ASSET: &'static str = "character_screen";
|
|
/// Rich Presence logo asset key
|
|
const LOGO_ASSET: &'static str = "logo";
|
|
|
|
/// Edit the current activity args according to the command in `self`.
|
|
///
|
|
/// - For `MainMenu`, `CharacterSelection`, `JoinSingleplayer` and
|
|
/// `JoinServer(name)`: create a new activity and discard the previous one
|
|
/// - For `NewLocation` and `LevelUp`: update the current activity
|
|
fn edit_activity(self, args: &mut ActivityArgs) {
|
|
use ActivityUpdate::*;
|
|
|
|
match self {
|
|
Clear => (),
|
|
MainMenu => {
|
|
*args = ActivityBuilder::default()
|
|
.start_timestamp(SystemTime::now())
|
|
.state("Idle")
|
|
.details("In Main Menu")
|
|
.assets(
|
|
activity::Assets::default().large(Self::LOGO_ASSET, Option::<&str>::None),
|
|
)
|
|
.into();
|
|
},
|
|
CharacterSelection => {
|
|
*args = ActivityBuilder::default()
|
|
.start_timestamp(SystemTime::now())
|
|
.state("Idle")
|
|
.details("In Character Selection")
|
|
.assets(
|
|
activity::Assets::default()
|
|
.large(Self::CHARACTER_SCREEN_ASSET, Option::<&str>::None)
|
|
.small(Self::LOGO_ASSET, Option::<&str>::None),
|
|
)
|
|
.into();
|
|
},
|
|
JoinSingleplayer => {
|
|
*args = ActivityBuilder::default()
|
|
.start_timestamp(SystemTime::now())
|
|
.details("Playing Singleplayer")
|
|
.assets(
|
|
activity::Assets::default()
|
|
.large(Self::ASSETS[9], Option::<&str>::None)
|
|
.small(Self::LOGO_ASSET, Option::<&str>::None),
|
|
)
|
|
.into();
|
|
},
|
|
JoinServer(server_name) => {
|
|
*args = ActivityBuilder::default()
|
|
.start_timestamp(SystemTime::now())
|
|
.state(format!("On {server_name}"))
|
|
.details("Playing Multiplayer")
|
|
.assets(
|
|
activity::Assets::default()
|
|
.large(Self::ASSETS[1], Option::<&str>::None)
|
|
.small(Self::LOGO_ASSET, Option::<&str>::None),
|
|
)
|
|
.into();
|
|
},
|
|
NewLocation { chunk_name, site } => {
|
|
use common::terrain::site::{
|
|
DungeonKindMeta::*, SettlementKindMeta::*, SiteKindMeta::*,
|
|
};
|
|
|
|
let location = match site {
|
|
Dungeon(Old) => format!("Battling evil in {chunk_name}"),
|
|
Dungeon(Gnarling) => format!("Hunting Gnarlings in {chunk_name}"),
|
|
Cave => "In a Cave".to_string(),
|
|
Settlement(Default) => format!("Visiting {chunk_name}"),
|
|
Settlement(CliffTown) => format!("Climbing the towers of {chunk_name}"),
|
|
Settlement(DesertCity) => format!("Hiding from the sun in {chunk_name}"),
|
|
Settlement(SavannahPit) => format!("Shop at the market down in {chunk_name}"),
|
|
_ => format!("In {chunk_name}"),
|
|
};
|
|
|
|
args.activity.as_mut().map(|a| {
|
|
a.assets.as_mut().map(|assets| {
|
|
assets.large_text = Some(location);
|
|
})
|
|
});
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A channel to the background task that updates the Discord activity.
|
|
pub enum Discord {
|
|
/// Active state, receiving updates
|
|
Active {
|
|
/// The channel to communicate with the tokio task
|
|
channel: UnboundedSender<ActivityUpdate>,
|
|
/// Current chunk name, cached to check for updates
|
|
current_chunk_name: Option<String>,
|
|
/// Current site, cached to check for updates
|
|
current_site: SiteKindMeta,
|
|
},
|
|
/// Inactive state: either the Discord app could not be contacted, is not
|
|
/// installed, or was disconnected
|
|
Inactive,
|
|
}
|
|
|
|
impl Discord {
|
|
/// Start a background [tokio task](tokio::task) that will update the
|
|
/// Discord activity every 4 seconds (due to rate limits) if it has
|
|
/// changed.
|
|
///
|
|
/// The [`update`](Discord::update) method can be used on the returned
|
|
/// struct to update the Discord activity via a channel command
|
|
pub fn start(rt: &tokio::runtime::Runtime) -> Self {
|
|
let (sender, mut receiver) = unbounded_channel::<ActivityUpdate>();
|
|
|
|
rt.spawn(async move {
|
|
let (wheel, handler) = ds::wheel::Wheel::new(Box::new(|err| {
|
|
warn!(error = ?err, "Encountered an error while connecting to Discord");
|
|
}));
|
|
|
|
let mut user = wheel.user();
|
|
|
|
let discord = match ds::Discord::new(
|
|
ds::DiscordApp::PlainId(DISCORD_APP_ID),
|
|
ds::Subscriptions::ACTIVITY,
|
|
Box::new(handler),
|
|
) {
|
|
Ok(ds) => {
|
|
if let Err(err) = user.0.changed().await {
|
|
warn!(err = ?err, "Could not execute handshake to Discord");
|
|
// If no handshake is received, exit the task immediately
|
|
return;
|
|
}
|
|
info!("Connected to Discord");
|
|
ds
|
|
},
|
|
Err(err) => {
|
|
info!(err = ?err, "Could not connect to Discord app");
|
|
// If no Discord app was found, exit the task immediately
|
|
return;
|
|
},
|
|
};
|
|
|
|
let mut args = ActivityArgs::default();
|
|
let mut has_changed = false;
|
|
let mut interval = interval(Duration::from_secs(4));
|
|
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
|
|
|
loop {
|
|
// Check every four seconds if the activity needs to change
|
|
tokio::select! {
|
|
biased; // to save the CPU cost of selecting a random branch
|
|
|
|
_ = interval.tick(), if has_changed => {
|
|
has_changed = false;
|
|
let activity = args.activity.clone();
|
|
match discord.update_activity(args).await {
|
|
Err(err) => {
|
|
warn!(error = ?err, "Could not update Discord activity");
|
|
}
|
|
Ok(Some(new_activity)) => {
|
|
debug!(new_activity = ?new_activity, "Updated Discord activity");
|
|
},
|
|
Ok(None) => ()
|
|
}
|
|
args = ActivityArgs::default();
|
|
args.activity = activity;
|
|
}
|
|
update = receiver.recv() => match update {
|
|
None | Some(ActivityUpdate::Clear) => {
|
|
match discord.clear_activity().await {
|
|
Ok(_) => {
|
|
info!("Cleared Discord activity");
|
|
},
|
|
Err(err) => {
|
|
warn!(error = ?err, "Failed to clear Discord activity")
|
|
}
|
|
}
|
|
return;
|
|
},
|
|
Some(update) => {
|
|
update.edit_activity(&mut args);
|
|
has_changed = true;
|
|
},
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Self::Active {
|
|
channel: sender,
|
|
current_chunk_name: None,
|
|
current_site: SiteKindMeta::Void,
|
|
}
|
|
}
|
|
|
|
/// Send an activity update to the background task
|
|
#[inline]
|
|
fn update(&mut self, update: ActivityUpdate) {
|
|
if let Self::Active { channel, .. } = self {
|
|
// On error, turn itself into inactive to avoid sending unecessary updates
|
|
if channel.send(update).is_err() {
|
|
*self = Self::Inactive;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clear the Discord activity
|
|
#[inline]
|
|
pub fn clear_activity(&mut self) {
|
|
self.update(ActivityUpdate::Clear);
|
|
*self = Discord::Inactive;
|
|
}
|
|
|
|
/// Sets the current Discord activity to Main Menu
|
|
#[inline]
|
|
pub fn enter_main_menu(&mut self) { self.update(ActivityUpdate::MainMenu); }
|
|
|
|
/// Sets the current Discord activity to Character Selection
|
|
#[inline]
|
|
pub fn enter_character_selection(&mut self) { self.update(ActivityUpdate::CharacterSelection); }
|
|
|
|
/// Sets the current Discord activity to Singleplayer
|
|
#[inline]
|
|
pub fn join_singleplayer(&mut self) { self.update(ActivityUpdate::JoinSingleplayer); }
|
|
|
|
/// Sets the current Discord activity to Multiplayer with the corresponding
|
|
/// server name
|
|
#[inline]
|
|
pub fn join_server(&mut self, server_name: String) {
|
|
self.update(ActivityUpdate::JoinServer(server_name));
|
|
}
|
|
|
|
/// Check the current location name and update it if it has changed
|
|
#[inline]
|
|
pub fn update_location(&mut self, chunk_name: &str, site: SiteKindMeta) {
|
|
if let Self::Active {
|
|
current_chunk_name,
|
|
current_site,
|
|
..
|
|
} = self
|
|
{
|
|
let different_name = current_chunk_name.as_deref() != Some(chunk_name);
|
|
if different_name || *current_site != site {
|
|
if different_name {
|
|
*current_chunk_name = Some(chunk_name.to_string());
|
|
}
|
|
*current_site = site;
|
|
self.update(ActivityUpdate::NewLocation {
|
|
chunk_name: chunk_name.to_string(),
|
|
site,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Check wether the Discord activity is active and receiving updates
|
|
#[inline]
|
|
pub fn is_active(&self) -> bool { matches!(self, Self::Active { .. }) }
|
|
}
|