Skip to main content

pumpkinplus/modules/mechanics/player/
nickname.rs

1//! Nickname module — set or remove player nicknames with JSON persistence.
2//!
3//! ## Commands
4//!
5//! | Command              | Aliases | Permission                      | Description              |
6//! |----------------------|---------|---------------------------------|--------------------------|
7//! | `/nickname [name]`   | `nick`  | `pumpkinplus:command.nickname` | Set or remove nickname   |
8//!
9//! ## Configuration
10//!
11//! | Field       | Default | Description                                         |
12//! |-------------|---------|-----------------------------------------------------|
13//! | `enabled`   | `false` | Whether this module is active                       |
14//!
15//! ## Mechanics
16//!
17//! - `/nickname` — clears the player's nickname.
18//! - `/nickname <name>` — sets the player's nickname.
19//! - Nicknames are persisted in `{data_folder}/nicknames.json`.
20//! - On join, the stored nickname is applied to the player's display name and tab list name.
21//! - A confirmation message is sent via the action bar.
22
23use crate::{PLUGIN_ID, config::ConfigManager, mechanics::mechanic::Mechanic};
24use pumpkin_plugin_api::{
25    Context, Server,
26    command::{Command, CommandError, CommandNode, CommandSender, ConsumedArgs},
27    command_wit::{ArgumentType, StringType},
28    commands::CommandHandler,
29    events::{EventData, EventHandler, EventPriority, PlayerJoinEvent},
30    player::Player,
31    text::TextComponent,
32};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::collections::HashSet;
36use std::fs;
37use std::path::PathBuf;
38use tracing::error;
39
40use std::cell::RefCell;
41
42thread_local! {
43    static DATA_FOLDER: RefCell<Option<String>> = const { RefCell::new(None) };
44}
45
46/// Stores and retrieves player nicknames from `{data_folder}/nicknames.json`.
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48struct NicknamesStore {
49    nicknames: HashMap<String, String>,
50}
51
52impl NicknamesStore {
53    /// Builds the file path for `nicknames.json` inside the given data folder.
54    fn path(data_folder: &str) -> PathBuf {
55        PathBuf::from(data_folder.trim_start_matches("./")).join("nicknames.json")
56    }
57
58    fn load(data_folder: &str) -> Self {
59        let path = Self::path(data_folder);
60        if path.exists() {
61            fs::read_to_string(&path)
62                .ok()
63                .and_then(|s| serde_json::from_str(&s).ok())
64                .unwrap_or_default()
65        } else {
66            Self::default()
67        }
68    }
69
70    fn save(&self, data_folder: &str) {
71        let path = Self::path(data_folder);
72        if let Some(parent) = path.parent() {
73            fs::create_dir_all(parent).ok();
74        }
75        fs::write(
76            &path,
77            serde_json::to_string_pretty(self).unwrap_or_default(),
78        )
79        .inspect_err(|e| error!("Failed to write nicknames.json: {}", e))
80        .ok();
81    }
82
83    fn get(&self, uuid: &str) -> Option<&String> {
84        self.nicknames.get(uuid)
85    }
86
87    fn set(&mut self, data_folder: &str, uuid: &str, nickname: String) {
88        if nickname.is_empty() {
89            self.nicknames.remove(uuid);
90        } else {
91            self.nicknames.insert(uuid.to_string(), nickname);
92        }
93        self.save(data_folder);
94    }
95}
96
97/// Handles player nicknames.
98#[derive(Default)]
99pub struct Nickname;
100
101impl Mechanic for Nickname {
102    fn enabled(&self) -> bool {
103        ConfigManager::get()
104            .map(|cm| cm.get_config::<NicknameConfig>().enabled)
105            .unwrap_or(false)
106    }
107
108    fn cmds(&self) -> Vec<Command> {
109        let command = Command::new(
110            &["nickname".to_string(), "nick".to_string()],
111            "Set or remove your nickname",
112        );
113        // /nickname <name> — sets nickname
114        command.then(
115            CommandNode::argument("name", &ArgumentType::String(StringType::Greedy))
116                .execute(NicknameExecutor),
117        );
118        // /nickname clear — clears nickname
119        command.then(CommandNode::literal("clear").execute(NicknameExecutor));
120        vec![command]
121    }
122
123    fn perms(&self) -> HashSet<String> {
124        HashSet::from([format!("{}:command.nickname", PLUGIN_ID)])
125    }
126
127    fn events(&self, context: &Context) {
128        DATA_FOLDER.with(|f| {
129            *f.borrow_mut() = Some(context.get_data_folder().to_string());
130        });
131
132        context
133            .register_event_handler::<PlayerJoinEvent, _>(Nickname, EventPriority::Normal, true)
134            .expect("failed to register nickname join event handler");
135    }
136}
137
138struct NicknameExecutor;
139
140impl CommandHandler for NicknameExecutor {
141    fn handle(
142        &self,
143        sender: CommandSender,
144        _server: Server,
145        args: ConsumedArgs,
146    ) -> Result<i32, CommandError> {
147        let player = sender.as_player().ok_or(CommandError::PermissionDenied)?;
148        let uuid = format!("{}-{}", player.get_id().high, player.get_id().low);
149
150        let data_folder = DATA_FOLDER.with(|f| f.borrow().clone().unwrap_or_default());
151        let mut store = NicknamesStore::load(&data_folder);
152
153        // Try to get the "name" argument; if missing, treat as clear
154        let arg = args.get_value("name");
155        let nickname = match arg {
156            pumpkin_plugin_api::command_wit::Arg::Simple(name) => name,
157            _ => {
158                store.set(&data_folder, &uuid, String::new());
159                update_player(&player, None);
160                sender.send_message(TextComponent::text("Nickname cleared."));
161                return Ok(0);
162            }
163        };
164
165        let trimmed = nickname.trim();
166        if trimmed.is_empty() {
167            store.set(&data_folder, &uuid, String::new());
168            update_player(&player, None);
169            sender.send_message(TextComponent::text("Nickname cleared."));
170        } else {
171            store.set(&data_folder, &uuid, trimmed.to_string());
172            update_player(&player, Some(trimmed));
173            sender.send_message(TextComponent::text(&format!(
174                "Nickname updated to: {}",
175                trimmed
176            )));
177        }
178
179        Ok(0)
180    }
181}
182
183impl EventHandler<PlayerJoinEvent> for Nickname {
184    fn handle(
185        &self,
186        _server: Server,
187        event: EventData<PlayerJoinEvent>,
188    ) -> EventData<PlayerJoinEvent> {
189        if !self.enabled() {
190            return event;
191        }
192
193        let data_folder = DATA_FOLDER.with(|f| f.borrow().clone().unwrap_or_default());
194        let store = NicknamesStore::load(&data_folder);
195        let uuid = format!(
196            "{}-{}",
197            event.player.get_id().high,
198            event.player.get_id().low
199        );
200
201        if let Some(nickname) = store.get(&uuid) {
202            update_player(&event.player, Some(nickname));
203        }
204
205        event
206    }
207}
208
209/// Applies a nickname to a player's display name and tab list name.
210fn update_player(player: &Player, nickname: Option<&str>) {
211    let display = match nickname {
212        Some(name) => TextComponent::text(name),
213        None => TextComponent::text(&player.get_name()),
214    };
215
216    let tab_list = match nickname {
217        Some(name) => TextComponent::text(name),
218        None => TextComponent::text(&player.get_name()),
219    };
220
221    player.set_display_name(display);
222    player.set_tab_list_name(Some(tab_list));
223}
224
225/// Configuration for the nickname mechanics module.
226#[derive(Debug, Clone, Default, Serialize, Deserialize)]
227pub struct NicknameConfig {
228    /// Whether this module is active.
229    pub enabled: bool,
230}