feat: NickCore v1.0.0 - enterprise nickname plugin for Paper/Folia/Velocity
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
package com.nickcore.paper;
|
||||
|
||||
import com.nickcore.common.config.NickCoreConfig;
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import com.nickcore.common.model.SkinData;
|
||||
import com.nickcore.common.validation.NicknameValidator;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.command.NickCommand;
|
||||
import com.nickcore.paper.gui.GuiManager;
|
||||
import com.nickcore.paper.integration.NickCoreExpansion;
|
||||
import com.nickcore.paper.integration.VelocityMessaging;
|
||||
import com.nickcore.paper.listener.PlayerConnectionListener;
|
||||
import com.nickcore.paper.repository.DatabaseManager;
|
||||
import com.nickcore.paper.repository.SqlNickRepository;
|
||||
import com.nickcore.paper.scheduler.SchedulerAdapter;
|
||||
import com.nickcore.paper.service.*;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* NickCore — Enterprise Minecraft Nickname Plugin.
|
||||
* Supports Paper 1.21.11, Folia 1.21.11, and Velocity 3.5.0.
|
||||
*/
|
||||
public final class NickCorePlugin extends JavaPlugin {
|
||||
|
||||
private DatabaseManager databaseManager;
|
||||
private SqlNickRepository repository;
|
||||
private ProfileCache profileCache;
|
||||
private NickService nickService;
|
||||
private RankService rankService;
|
||||
private SkinService skinService;
|
||||
private PlaceholderService placeholderService;
|
||||
private NametagService nametagService;
|
||||
private ChatService chatService;
|
||||
private SchedulerAdapter scheduler;
|
||||
private VelocityMessaging velocityMessaging;
|
||||
private NickCoreExpansion placeholderExpansion;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
// Save default configs
|
||||
saveDefaultConfigs();
|
||||
|
||||
// Scheduler abstraction (auto-detects Folia)
|
||||
scheduler = SchedulerAdapter.create(this);
|
||||
getLogger().info("Scheduler: " + (isFolia() ? "Folia" : "Paper"));
|
||||
|
||||
// Load configuration
|
||||
NickCoreConfig config = loadConfig();
|
||||
|
||||
// Database
|
||||
databaseManager = new DatabaseManager(config.database(), getDataFolder(), getLogger());
|
||||
databaseManager.initialize();
|
||||
repository = new SqlNickRepository(databaseManager, getLogger());
|
||||
|
||||
// Cache
|
||||
profileCache = new ProfileCache();
|
||||
|
||||
// Validator
|
||||
NicknameValidator validator = NicknameValidator.builder()
|
||||
.minLength(config.general().minNickLength())
|
||||
.maxLength(config.general().maxNickLength())
|
||||
.allowedPattern(config.security().allowedCharactersRegex())
|
||||
.blockHomoglyphs(config.security().blockHomoglyphs())
|
||||
.blockInvisibleChars(config.security().blockInvisibleChars())
|
||||
.reservedNames(Set.copyOf(config.security().reservedNames()))
|
||||
.protectedNames(Set.copyOf(config.security().protectedNames()))
|
||||
.build();
|
||||
|
||||
// Services
|
||||
nickService = new NickService(repository, profileCache, validator, getLogger());
|
||||
nickService.setAllowDuplicateNicks(config.general().allowDuplicateNicks());
|
||||
nickService.setAllowCustomNames(config.general().allowCustomNames());
|
||||
|
||||
rankService = new RankService(getLogger());
|
||||
skinService = new SkinService(repository, this, getLogger());
|
||||
placeholderService = new PlaceholderService(profileCache, rankService);
|
||||
nametagService = new NametagService(profileCache, rankService, getLogger());
|
||||
chatService = new ChatService(profileCache, rankService, getLogger());
|
||||
|
||||
// Load data files
|
||||
loadNames();
|
||||
loadRanks();
|
||||
loadSkins();
|
||||
|
||||
// Chat format
|
||||
String chatFormat = getConfig().getString("chat.format",
|
||||
"<prefix><display_name><suffix><gray>: </gray><message>");
|
||||
chatService.setChatFormat(chatFormat);
|
||||
|
||||
// GUI Manager
|
||||
GuiManager guiManager = new GuiManager(this, nickService, rankService,
|
||||
skinService, placeholderService, nametagService, profileCache);
|
||||
|
||||
// Commands
|
||||
NickCommand nickCommand = new NickCommand(nickService, rankService, skinService,
|
||||
placeholderService, nametagService, profileCache, guiManager,
|
||||
getLogger(), () -> { reloadPlugin(); return null; });
|
||||
|
||||
var cmd = getCommand("nick");
|
||||
if (cmd != null) {
|
||||
cmd.setExecutor(nickCommand);
|
||||
cmd.setTabCompleter(nickCommand);
|
||||
}
|
||||
|
||||
// Listeners
|
||||
Bukkit.getPluginManager().registerEvents(
|
||||
new PlayerConnectionListener(this, nickService, skinService,
|
||||
placeholderService, nametagService, scheduler, getLogger()), this);
|
||||
Bukkit.getPluginManager().registerEvents(chatService, this);
|
||||
Bukkit.getPluginManager().registerEvents(guiManager, this);
|
||||
|
||||
// Integrations
|
||||
registerPlaceholderAPI();
|
||||
registerVelocityMessaging(config);
|
||||
|
||||
long elapsed = System.currentTimeMillis() - start;
|
||||
getLogger().info("NickCore enabled in " + elapsed + "ms");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
// Unregister PlaceholderAPI
|
||||
if (placeholderExpansion != null) {
|
||||
placeholderExpansion.unregister();
|
||||
}
|
||||
|
||||
// Unregister Velocity messaging
|
||||
if (velocityMessaging != null) {
|
||||
velocityMessaging.unregister();
|
||||
}
|
||||
|
||||
// Clean up nametags
|
||||
if (nametagService != null) {
|
||||
nametagService.clearAllNametags();
|
||||
}
|
||||
|
||||
// Clear caches
|
||||
if (profileCache != null) profileCache.clear();
|
||||
if (placeholderService != null) placeholderService.clear();
|
||||
if (skinService != null) skinService.clearCache();
|
||||
|
||||
// Close DB
|
||||
if (repository != null) repository.close();
|
||||
if (databaseManager != null) databaseManager.close();
|
||||
|
||||
getLogger().info("NickCore disabled");
|
||||
}
|
||||
|
||||
private void reloadPlugin() {
|
||||
reloadConfig();
|
||||
NickCoreConfig config = loadConfig();
|
||||
|
||||
nickService.setAllowDuplicateNicks(config.general().allowDuplicateNicks());
|
||||
nickService.setAllowCustomNames(config.general().allowCustomNames());
|
||||
|
||||
loadNames();
|
||||
loadRanks();
|
||||
loadSkins();
|
||||
|
||||
String chatFormat = getConfig().getString("chat.format",
|
||||
"<prefix><display_name><suffix><gray>: </gray><message>");
|
||||
chatService.setChatFormat(chatFormat);
|
||||
|
||||
getLogger().info("Configuration reloaded");
|
||||
}
|
||||
|
||||
private NickCoreConfig loadConfig() {
|
||||
reloadConfig();
|
||||
|
||||
// Database config
|
||||
File dbFile = new File(getDataFolder(), "database.yml");
|
||||
YamlConfiguration dbYaml = YamlConfiguration.loadConfiguration(dbFile);
|
||||
|
||||
var dbSettings = new NickCoreConfig.DatabaseSettings(
|
||||
dbYaml.getString("type", "sqlite"),
|
||||
dbYaml.getString("host", "localhost"),
|
||||
dbYaml.getInt("port", 3306),
|
||||
dbYaml.getString("database", "nickcore"),
|
||||
dbYaml.getString("username", "root"),
|
||||
dbYaml.getString("password", ""),
|
||||
dbYaml.getInt("pool.max-size", 10),
|
||||
dbYaml.getInt("pool.min-idle", 2),
|
||||
dbYaml.getLong("pool.connection-timeout", 5000),
|
||||
dbYaml.getLong("pool.idle-timeout", 300000),
|
||||
dbYaml.getLong("pool.max-lifetime", 600000),
|
||||
dbYaml.getString("table-prefix", "nickcore_")
|
||||
);
|
||||
|
||||
var generalSettings = new NickCoreConfig.GeneralSettings(
|
||||
getConfig().getString("prefix", "<gradient:#00BFFF:#1E90FF>[NickCore]</gradient>"),
|
||||
getConfig().getString("locale", "en_US"),
|
||||
getConfig().getBoolean("debug", false),
|
||||
getConfig().getBoolean("allow-custom-names", false),
|
||||
getConfig().getBoolean("allow-duplicate-nicks", false),
|
||||
getConfig().getInt("max-nick-length", 16),
|
||||
getConfig().getInt("min-nick-length", 3)
|
||||
);
|
||||
|
||||
var securitySettings = new NickCoreConfig.SecuritySettings(
|
||||
getConfig().getString("security.allowed-characters", "^[a-zA-Z0-9_]+$"),
|
||||
getConfig().getBoolean("security.block-homoglyphs", true),
|
||||
getConfig().getBoolean("security.block-invisible-chars", true),
|
||||
getConfig().getBoolean("security.prevent-impersonation", true),
|
||||
getConfig().getInt("security.impersonation-threshold", 2),
|
||||
getConfig().getStringList("security.reserved-names"),
|
||||
getConfig().getStringList("security.protected-names")
|
||||
);
|
||||
|
||||
var velocitySettings = new NickCoreConfig.VelocitySettings(
|
||||
getConfig().getBoolean("velocity.enabled", false),
|
||||
getConfig().getString("velocity.server-name", "default")
|
||||
);
|
||||
|
||||
return new NickCoreConfig(generalSettings, dbSettings, securitySettings, velocitySettings);
|
||||
}
|
||||
|
||||
private void loadNames() {
|
||||
File namesFile = new File(getDataFolder(), "names.yml");
|
||||
if (!namesFile.exists()) saveResource("names.yml", false);
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(namesFile);
|
||||
List<String> names = yaml.getStringList("names");
|
||||
nickService.setNamePool(names);
|
||||
}
|
||||
|
||||
private void loadRanks() {
|
||||
File ranksFile = new File(getDataFolder(), "ranks.yml");
|
||||
if (!ranksFile.exists()) saveResource("ranks.yml", false);
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(ranksFile);
|
||||
|
||||
List<RankDefinition> ranks = new ArrayList<>();
|
||||
ConfigurationSection section = yaml.getConfigurationSection("ranks");
|
||||
if (section != null) {
|
||||
for (String key : section.getKeys(false)) {
|
||||
ConfigurationSection rank = section.getConfigurationSection(key);
|
||||
if (rank == null) continue;
|
||||
ranks.add(new RankDefinition(
|
||||
key,
|
||||
rank.getString("display-name", key),
|
||||
rank.getString("prefix", ""),
|
||||
rank.getString("suffix", ""),
|
||||
rank.getInt("weight", 0),
|
||||
rank.getInt("priority", 0),
|
||||
rank.getString("color", "white"),
|
||||
rank.getString("permission", "nickcore.rank." + key)
|
||||
));
|
||||
}
|
||||
}
|
||||
rankService.loadRanks(ranks);
|
||||
}
|
||||
|
||||
private void loadSkins() {
|
||||
File skinsFile = new File(getDataFolder(), "skins.yml");
|
||||
if (!skinsFile.exists()) saveResource("skins.yml", false);
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(skinsFile);
|
||||
|
||||
Map<String, SkinData> skins = new LinkedHashMap<>();
|
||||
ConfigurationSection section = yaml.getConfigurationSection("skins");
|
||||
if (section != null) {
|
||||
for (String key : section.getKeys(false)) {
|
||||
ConfigurationSection skin = section.getConfigurationSection(key);
|
||||
if (skin == null) continue;
|
||||
String value = skin.getString("value", "");
|
||||
String sig = skin.getString("signature", "");
|
||||
if (!value.isEmpty() && !sig.isEmpty()) {
|
||||
skins.put(key, new SkinData(key, value, sig, Instant.now(),
|
||||
Instant.now().plusSeconds(86400)));
|
||||
}
|
||||
}
|
||||
}
|
||||
skinService.loadConfiguredSkins(skins);
|
||||
}
|
||||
|
||||
private void registerPlaceholderAPI() {
|
||||
if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
|
||||
placeholderExpansion = new NickCoreExpansion(this, placeholderService);
|
||||
placeholderExpansion.register();
|
||||
getLogger().info("PlaceholderAPI integration enabled");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerVelocityMessaging(NickCoreConfig config) {
|
||||
if (config.velocity().enabled()) {
|
||||
velocityMessaging = new VelocityMessaging(this, profileCache,
|
||||
placeholderService, nametagService, scheduler,
|
||||
getLogger(), config.velocity().serverName());
|
||||
velocityMessaging.register();
|
||||
getLogger().info("Velocity messaging enabled (server: " + config.velocity().serverName() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
private void saveDefaultConfigs() {
|
||||
saveDefaultConfig();
|
||||
String[] files = {"database.yml", "messages.yml", "ranks.yml", "skins.yml", "names.yml"};
|
||||
for (String file : files) {
|
||||
if (!new File(getDataFolder(), file).exists()) {
|
||||
saveResource(file, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isFolia() {
|
||||
try {
|
||||
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
97
nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java
vendored
Normal file
97
nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java
vendored
Normal file
@@ -0,0 +1,97 @@
|
||||
package com.nickcore.paper.cache;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe in-memory cache for active player nick profiles.
|
||||
*
|
||||
* <p>Profiles are loaded on player join and evicted on player quit.
|
||||
* All placeholder resolution and display name lookups read from this cache
|
||||
* to avoid database roundtrips during hot paths.</p>
|
||||
*
|
||||
* <p>Designed for 500+ concurrent players. Uses {@link ConcurrentHashMap}
|
||||
* for lock-free concurrent reads with minimal contention on writes.</p>
|
||||
*/
|
||||
public final class ProfileCache {
|
||||
|
||||
private final ConcurrentHashMap<UUID, NickProfile> profiles = new ConcurrentHashMap<>(512, 0.75f, 4);
|
||||
|
||||
/**
|
||||
* Stores or updates a profile in the cache.
|
||||
*
|
||||
* @param profile the profile to cache (must not be null)
|
||||
*/
|
||||
public void put(NickProfile profile) {
|
||||
Objects.requireNonNull(profile, "profile must not be null");
|
||||
profiles.put(profile.uuid(), profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a cached profile.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
* @return the cached profile, or empty if not cached
|
||||
*/
|
||||
public Optional<NickProfile> get(UUID uuid) {
|
||||
return Optional.ofNullable(profiles.get(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a profile from the cache.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
* @return the removed profile, or null if not cached
|
||||
*/
|
||||
public NickProfile remove(UUID uuid) {
|
||||
return profiles.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a nickname is currently in use in the cache.
|
||||
*
|
||||
* @param nickname the nickname to check
|
||||
* @param excludeUuid UUID to exclude from the check
|
||||
* @return true if the nickname is in use
|
||||
*/
|
||||
public boolean isNicknameInUse(String nickname, UUID excludeUuid) {
|
||||
if (nickname == null) return false;
|
||||
|
||||
for (Map.Entry<UUID, NickProfile> entry : profiles.entrySet()) {
|
||||
if (entry.getKey().equals(excludeUuid)) continue;
|
||||
NickProfile profile = entry.getValue();
|
||||
if (profile.nicked() && nickname.equalsIgnoreCase(profile.nickname())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a snapshot of all cached profiles.
|
||||
*
|
||||
* @return unmodifiable map of all cached profiles
|
||||
*/
|
||||
public Map<UUID, NickProfile> getAll() {
|
||||
return Map.copyOf(profiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of cached profiles.
|
||||
*/
|
||||
public int size() {
|
||||
return profiles.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all cached profiles. Called during plugin shutdown.
|
||||
*/
|
||||
public void clear() {
|
||||
profiles.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package com.nickcore.paper.command;
|
||||
|
||||
import com.nickcore.common.model.NickHistory;
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.validation.InputSanitizer;
|
||||
import com.nickcore.paper.gui.GuiManager;
|
||||
import com.nickcore.paper.service.NickService;
|
||||
import com.nickcore.paper.service.PlaceholderService;
|
||||
import com.nickcore.paper.service.NametagService;
|
||||
import com.nickcore.paper.service.RankService;
|
||||
import com.nickcore.paper.service.SkinService;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Main /nick command handler with subcommands.
|
||||
* Async-safe: all DB-touching operations return futures.
|
||||
*/
|
||||
public final class NickCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static final String PERM_USE = "nickcore.use";
|
||||
private static final String PERM_RANDOM = "nickcore.name.random";
|
||||
private static final String PERM_CUSTOM = "nickcore.name.custom";
|
||||
private static final String PERM_RESET = "nickcore.reset";
|
||||
private static final String PERM_RANK = "nickcore.rank.select";
|
||||
private static final String PERM_SKIN = "nickcore.skin.select";
|
||||
private static final String PERM_ADMIN = "nickcore.admin";
|
||||
private static final String PERM_RELOAD = "nickcore.reload";
|
||||
private static final String PERM_FORCE = "nickcore.force";
|
||||
private static final String PERM_INFO = "nickcore.info";
|
||||
private static final String PERM_CLEAR = "nickcore.clear";
|
||||
|
||||
private final NickService nickService;
|
||||
private final RankService rankService;
|
||||
private final SkinService skinService;
|
||||
private final PlaceholderService placeholderService;
|
||||
private final NametagService nametagService;
|
||||
private final ProfileCache profileCache;
|
||||
private final GuiManager guiManager;
|
||||
private final Logger logger;
|
||||
private final MiniMessage mm;
|
||||
private final Supplier<Void> reloadAction;
|
||||
|
||||
public NickCommand(NickService nickService, RankService rankService,
|
||||
SkinService skinService, PlaceholderService placeholderService,
|
||||
NametagService nametagService, ProfileCache profileCache,
|
||||
GuiManager guiManager, Logger logger, Supplier<Void> reloadAction) {
|
||||
this.nickService = nickService;
|
||||
this.rankService = rankService;
|
||||
this.skinService = skinService;
|
||||
this.placeholderService = placeholderService;
|
||||
this.nametagService = nametagService;
|
||||
this.profileCache = profileCache;
|
||||
this.guiManager = guiManager;
|
||||
this.logger = logger;
|
||||
this.mm = MiniMessage.miniMessage();
|
||||
this.reloadAction = reloadAction;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage(Component.text("Only players can use this command.", NamedTextColor.RED));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!player.hasPermission(PERM_USE)) {
|
||||
sendMessage(player, "<red>You don't have permission to use this command.");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.length == 0 || args[0].equalsIgnoreCase("gui")) {
|
||||
guiManager.openMainMenu(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (args[0].toLowerCase()) {
|
||||
case "random" -> handleRandom(player);
|
||||
case "reset" -> handleReset(player);
|
||||
case "rank" -> handleRank(player, args);
|
||||
case "skin" -> handleSkin(player, args);
|
||||
case "reload" -> handleReload(player);
|
||||
case "info" -> handleInfo(player, args);
|
||||
case "force" -> handleForce(player, args);
|
||||
case "clear" -> handleClear(player, args);
|
||||
default -> {
|
||||
// Treat as custom nickname attempt
|
||||
if (player.hasPermission(PERM_CUSTOM)) {
|
||||
handleCustomNick(player, String.join(" ", args));
|
||||
} else {
|
||||
sendMessage(player, "<red>Unknown subcommand. Use /nick for the GUI.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void handleCustomNick(Player player, String nickname) {
|
||||
sendMessage(player, "<gray>Applying nickname...");
|
||||
nickService.applyNickname(player, nickname, null).thenAccept(result -> {
|
||||
if (result.success()) {
|
||||
sendMessage(player, "<green>Nickname set to <white>" +
|
||||
InputSanitizer.escapeMiniMessage(result.profile().nickname()));
|
||||
applyDisplay(player);
|
||||
} else {
|
||||
sendMessage(player, "<red>" + result.message());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRandom(Player player) {
|
||||
if (!player.hasPermission(PERM_RANDOM)) {
|
||||
sendMessage(player, "<red>You don't have permission to use random nicknames.");
|
||||
return;
|
||||
}
|
||||
sendMessage(player, "<gray>Generating random nickname...");
|
||||
nickService.applyRandomNickname(player).thenAccept(result -> {
|
||||
if (result.success()) {
|
||||
sendMessage(player, "<green>Random nickname set to <white>" +
|
||||
InputSanitizer.escapeMiniMessage(result.profile().nickname()));
|
||||
applyDisplay(player);
|
||||
} else {
|
||||
sendMessage(player, "<red>" + result.message());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleReset(Player player) {
|
||||
if (!player.hasPermission(PERM_RESET)) {
|
||||
sendMessage(player, "<red>You don't have permission to reset.");
|
||||
return;
|
||||
}
|
||||
nickService.resetNickname(player, null).thenAccept(result -> {
|
||||
if (result.success()) {
|
||||
sendMessage(player, "<green>Identity restored.");
|
||||
applyDisplay(player);
|
||||
} else {
|
||||
sendMessage(player, "<red>" + result.message());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRank(Player player, String[] args) {
|
||||
if (!player.hasPermission(PERM_RANK)) {
|
||||
sendMessage(player, "<red>You don't have permission to select ranks.");
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
guiManager.openRankMenu(player);
|
||||
return;
|
||||
}
|
||||
String rankId = args[1];
|
||||
if (!rankService.hasRankPermission(player, rankId)) {
|
||||
sendMessage(player, "<red>You don't have permission for that rank.");
|
||||
return;
|
||||
}
|
||||
NickProfile profile = profileCache.get(player.getUniqueId())
|
||||
.orElse(NickProfile.empty(player.getUniqueId()));
|
||||
NickProfile updated = profile.withRank(rankId);
|
||||
profileCache.put(updated);
|
||||
sendMessage(player, "<green>Rank set to <white>" +
|
||||
rankService.getRank(rankId).map(r -> r.displayName()).orElse(rankId));
|
||||
applyDisplay(player);
|
||||
}
|
||||
|
||||
private void handleSkin(Player player, String[] args) {
|
||||
if (!player.hasPermission(PERM_SKIN)) {
|
||||
sendMessage(player, "<red>You don't have permission to change skins.");
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
guiManager.openSkinMenu(player);
|
||||
return;
|
||||
}
|
||||
sendMessage(player, "<gray>Fetching skin...");
|
||||
skinService.fetchSkin(args[1]).thenAccept(skinOpt -> {
|
||||
if (skinOpt.isPresent()) {
|
||||
var skin = skinOpt.get();
|
||||
NickProfile profile = profileCache.get(player.getUniqueId())
|
||||
.orElse(NickProfile.empty(player.getUniqueId()));
|
||||
profileCache.put(profile.withSkin(skin.name(), skin.textureValue(), skin.textureSignature()));
|
||||
Bukkit.getScheduler().runTask(
|
||||
Bukkit.getPluginManager().getPlugin("NickCore"),
|
||||
() -> skinService.applySkin(player, skin));
|
||||
sendMessage(player, "<green>Skin applied!");
|
||||
} else {
|
||||
sendMessage(player, "<red>Could not find that skin.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleReload(Player player) {
|
||||
if (!player.hasPermission(PERM_RELOAD)) {
|
||||
sendMessage(player, "<red>You don't have permission to reload.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
reloadAction.get();
|
||||
sendMessage(player, "<green>Configuration reloaded.");
|
||||
} catch (Exception e) {
|
||||
sendMessage(player, "<red>Reload failed: " + e.getMessage());
|
||||
logger.severe("Reload failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInfo(Player player, String[] args) {
|
||||
if (!player.hasPermission(PERM_INFO)) {
|
||||
sendMessage(player, "<red>You don't have permission to view player info.");
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
sendMessage(player, "<red>Usage: /nick info <player>");
|
||||
return;
|
||||
}
|
||||
Player target = Bukkit.getPlayer(args[1]);
|
||||
if (target == null) {
|
||||
sendMessage(player, "<red>Player not found.");
|
||||
return;
|
||||
}
|
||||
NickProfile profile = profileCache.get(target.getUniqueId()).orElse(null);
|
||||
sendMessage(player, "<gold>===== Player Info =====");
|
||||
sendMessage(player, "<yellow>Real Name: <white>" + target.getName());
|
||||
if (profile != null && profile.nicked()) {
|
||||
sendMessage(player, "<yellow>Nickname: <white>" + profile.nickname());
|
||||
sendMessage(player, "<yellow>Rank: <white>" + (profile.rankId() != null ? profile.rankId() : "None"));
|
||||
sendMessage(player, "<yellow>Skin: <white>" + (profile.skinId() != null ? profile.skinId() : "Default"));
|
||||
sendMessage(player, "<yellow>Nicked: <white>" + profile.nicked());
|
||||
} else {
|
||||
sendMessage(player, "<gray>No active nickname.");
|
||||
}
|
||||
}
|
||||
|
||||
private void handleForce(Player player, String[] args) {
|
||||
if (!player.hasPermission(PERM_FORCE)) {
|
||||
sendMessage(player, "<red>You don't have permission to force nicknames.");
|
||||
return;
|
||||
}
|
||||
if (args.length < 3) {
|
||||
sendMessage(player, "<red>Usage: /nick force <player> <nickname>");
|
||||
return;
|
||||
}
|
||||
Player target = Bukkit.getPlayer(args[1]);
|
||||
if (target == null) {
|
||||
sendMessage(player, "<red>Player not found.");
|
||||
return;
|
||||
}
|
||||
String nickname = args[2];
|
||||
nickService.applyNickname(target, nickname, player.getUniqueId()).thenAccept(result -> {
|
||||
if (result.success()) {
|
||||
sendMessage(player, "<green>Forced nickname on " + target.getName());
|
||||
sendMessage(target, "<yellow>An admin set your nickname to <white>" +
|
||||
InputSanitizer.escapeMiniMessage(nickname));
|
||||
applyDisplay(target);
|
||||
} else {
|
||||
sendMessage(player, "<red>" + result.message());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleClear(Player player, String[] args) {
|
||||
if (!player.hasPermission(PERM_CLEAR)) {
|
||||
sendMessage(player, "<red>You don't have permission to clear nicknames.");
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
sendMessage(player, "<red>Usage: /nick clear <player>");
|
||||
return;
|
||||
}
|
||||
Player target = Bukkit.getPlayer(args[1]);
|
||||
if (target == null) {
|
||||
sendMessage(player, "<red>Player not found.");
|
||||
return;
|
||||
}
|
||||
nickService.resetNickname(target, player.getUniqueId()).thenAccept(result -> {
|
||||
if (result.success()) {
|
||||
sendMessage(player, "<green>Cleared nickname for " + target.getName());
|
||||
sendMessage(target, "<yellow>Your nickname has been cleared by an admin.");
|
||||
applyDisplay(target);
|
||||
} else {
|
||||
sendMessage(player, "<red>" + result.message());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyDisplay(Player player) {
|
||||
placeholderService.invalidate(player.getUniqueId(), player.getName());
|
||||
Bukkit.getScheduler().runTask(
|
||||
Bukkit.getPluginManager().getPlugin("NickCore"),
|
||||
() -> {
|
||||
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||
if (profile != null && profile.effectiveDisplayName() != null) {
|
||||
player.displayName(mm.deserialize(
|
||||
InputSanitizer.escapeMiniMessage(profile.effectiveDisplayName())));
|
||||
} else {
|
||||
player.displayName(Component.text(player.getName()));
|
||||
}
|
||||
nametagService.updateNametag(player);
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMessage(Player player, String miniMessageText) {
|
||||
player.sendMessage(mm.deserialize(miniMessageText));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (!(sender instanceof Player player)) return List.of();
|
||||
|
||||
if (args.length == 1) {
|
||||
List<String> subs = new ArrayList<>();
|
||||
subs.add("gui");
|
||||
if (player.hasPermission(PERM_RANDOM)) subs.add("random");
|
||||
if (player.hasPermission(PERM_RESET)) subs.add("reset");
|
||||
if (player.hasPermission(PERM_RANK)) subs.add("rank");
|
||||
if (player.hasPermission(PERM_SKIN)) subs.add("skin");
|
||||
if (player.hasPermission(PERM_RELOAD)) subs.add("reload");
|
||||
if (player.hasPermission(PERM_INFO)) subs.add("info");
|
||||
if (player.hasPermission(PERM_FORCE)) subs.add("force");
|
||||
if (player.hasPermission(PERM_CLEAR)) subs.add("clear");
|
||||
return filterCompletions(subs, args[0]);
|
||||
}
|
||||
|
||||
if (args.length == 2) {
|
||||
return switch (args[0].toLowerCase()) {
|
||||
case "rank" -> filterCompletions(
|
||||
rankService.getAvailableRanks(player).stream()
|
||||
.map(r -> r.id()).toList(), args[1]);
|
||||
case "skin" -> filterCompletions(skinService.getConfiguredSkinNames(), args[1]);
|
||||
case "info", "force", "clear" -> filterCompletions(
|
||||
Bukkit.getOnlinePlayers().stream()
|
||||
.map(Player::getName).toList(), args[1]);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> filterCompletions(List<String> options, String input) {
|
||||
String lower = input.toLowerCase();
|
||||
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.inventory.Inventory;
|
||||
import org.bukkit.inventory.InventoryHolder;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Base class for all NickCore GUI menus.
|
||||
* Handles item placement, click routing, and pagination.
|
||||
*/
|
||||
public abstract class AbstractGui implements InventoryHolder {
|
||||
|
||||
protected final Player viewer;
|
||||
protected final int size;
|
||||
protected final Component title;
|
||||
protected Inventory inventory;
|
||||
|
||||
private final Map<Integer, Consumer<InventoryClickEvent>> clickHandlers = new HashMap<>();
|
||||
private long lastClickTime = 0;
|
||||
private static final long CLICK_COOLDOWN_MS = 200;
|
||||
|
||||
protected AbstractGui(Player viewer, int size, Component title) {
|
||||
this.viewer = viewer;
|
||||
this.size = size;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public void open() {
|
||||
this.inventory = Bukkit.createInventory(this, size, title);
|
||||
build();
|
||||
viewer.openInventory(inventory);
|
||||
}
|
||||
|
||||
protected abstract void build();
|
||||
|
||||
@Override
|
||||
public Inventory getInventory() { return inventory; }
|
||||
|
||||
public void handleClick(InventoryClickEvent event) {
|
||||
event.setCancelled(true);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastClickTime < CLICK_COOLDOWN_MS) return;
|
||||
lastClickTime = now;
|
||||
|
||||
Consumer<InventoryClickEvent> handler = clickHandlers.get(event.getRawSlot());
|
||||
if (handler != null) handler.accept(event);
|
||||
}
|
||||
|
||||
protected void setItem(int slot, ItemStack item, Consumer<InventoryClickEvent> onClick) {
|
||||
inventory.setItem(slot, item);
|
||||
if (onClick != null) clickHandlers.put(slot, onClick);
|
||||
}
|
||||
|
||||
protected void setItem(int slot, ItemStack item) {
|
||||
setItem(slot, item, null);
|
||||
}
|
||||
|
||||
protected ItemStack createItem(Material material, Component name, List<Component> lore) {
|
||||
ItemStack item = new ItemStack(material);
|
||||
ItemMeta meta = item.getItemMeta();
|
||||
meta.displayName(name);
|
||||
if (lore != null) meta.lore(lore);
|
||||
meta.setMaxStackSize(99);
|
||||
item.setItemMeta(meta);
|
||||
return item;
|
||||
}
|
||||
|
||||
protected ItemStack createItem(Material material, Component name) {
|
||||
return createItem(material, name, null);
|
||||
}
|
||||
|
||||
protected void fillBorder(Material material) {
|
||||
ItemStack filler = createItem(material, Component.empty());
|
||||
for (int i = 0; i < size; i++) {
|
||||
if (i < 9 || i >= size - 9 || i % 9 == 0 || i % 9 == 8) {
|
||||
if (inventory.getItem(i) == null) setItem(i, filler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Player getViewer() { return viewer; }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.service.*;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Manages all GUI instances and routes inventory events.
|
||||
* Uses ConcurrentHashMap to prevent player reference leaks.
|
||||
*/
|
||||
public final class GuiManager implements Listener {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final NickService nickService;
|
||||
private final RankService rankService;
|
||||
private final SkinService skinService;
|
||||
private final PlaceholderService placeholderService;
|
||||
private final NametagService nametagService;
|
||||
private final ProfileCache profileCache;
|
||||
private final ConcurrentHashMap<UUID, AbstractGui> openGuis = new ConcurrentHashMap<>();
|
||||
|
||||
public GuiManager(Plugin plugin, NickService nickService, RankService rankService,
|
||||
SkinService skinService, PlaceholderService placeholderService,
|
||||
NametagService nametagService, ProfileCache profileCache) {
|
||||
this.plugin = plugin;
|
||||
this.nickService = nickService;
|
||||
this.rankService = rankService;
|
||||
this.skinService = skinService;
|
||||
this.placeholderService = placeholderService;
|
||||
this.nametagService = nametagService;
|
||||
this.profileCache = profileCache;
|
||||
}
|
||||
|
||||
public void openMainMenu(Player player) {
|
||||
MainMenuGui gui = new MainMenuGui(player, this);
|
||||
openGuis.put(player.getUniqueId(), gui);
|
||||
gui.open();
|
||||
}
|
||||
|
||||
public void openNicknameMenu(Player player) {
|
||||
NicknameMenuGui gui = new NicknameMenuGui(player, this, nickService);
|
||||
openGuis.put(player.getUniqueId(), gui);
|
||||
gui.open();
|
||||
}
|
||||
|
||||
public void openRankMenu(Player player) {
|
||||
RankMenuGui gui = new RankMenuGui(player, this, rankService, profileCache,
|
||||
placeholderService, nametagService);
|
||||
openGuis.put(player.getUniqueId(), gui);
|
||||
gui.open();
|
||||
}
|
||||
|
||||
public void openSkinMenu(Player player) {
|
||||
SkinMenuGui gui = new SkinMenuGui(player, this, skinService, profileCache);
|
||||
openGuis.put(player.getUniqueId(), gui);
|
||||
gui.open();
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClick(InventoryClickEvent event) {
|
||||
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||
if (!(event.getInventory().getHolder() instanceof AbstractGui gui)) return;
|
||||
gui.handleClick(event);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onInventoryClose(InventoryCloseEvent event) {
|
||||
if (event.getPlayer() instanceof Player player) {
|
||||
openGuis.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||
openGuis.remove(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
public Plugin getPlugin() { return plugin; }
|
||||
public NickService getNickService() { return nickService; }
|
||||
public ProfileCache getProfileCache() { return profileCache; }
|
||||
public PlaceholderService getPlaceholderService() { return placeholderService; }
|
||||
public NametagService getNametagService() { return nametagService; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Main menu GUI — entry point for all nick operations.
|
||||
*/
|
||||
public final class MainMenuGui extends AbstractGui {
|
||||
|
||||
private final GuiManager guiManager;
|
||||
|
||||
public MainMenuGui(Player viewer, GuiManager guiManager) {
|
||||
super(viewer, 27, Component.text("NickCore", NamedTextColor.DARK_PURPLE, TextDecoration.BOLD));
|
||||
this.guiManager = guiManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void build() {
|
||||
fillBorder(Material.BLACK_STAINED_GLASS_PANE);
|
||||
|
||||
// Nickname
|
||||
if (viewer.hasPermission("nickcore.use")) {
|
||||
setItem(10, createItem(Material.NAME_TAG,
|
||||
Component.text("Change Nickname", NamedTextColor.GREEN, TextDecoration.BOLD),
|
||||
List.of(Component.text("Select or generate a nickname", NamedTextColor.GRAY))),
|
||||
e -> guiManager.openNicknameMenu(viewer));
|
||||
}
|
||||
|
||||
// Rank
|
||||
if (viewer.hasPermission("nickcore.rank.select")) {
|
||||
setItem(12, createItem(Material.GOLDEN_HELMET,
|
||||
Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD),
|
||||
List.of(Component.text("Choose a visual rank", NamedTextColor.GRAY))),
|
||||
e -> guiManager.openRankMenu(viewer));
|
||||
}
|
||||
|
||||
// Skin
|
||||
if (viewer.hasPermission("nickcore.skin.select")) {
|
||||
setItem(14, createItem(Material.PLAYER_HEAD,
|
||||
Component.text("Change Skin", NamedTextColor.AQUA, TextDecoration.BOLD),
|
||||
List.of(Component.text("Apply a different skin", NamedTextColor.GRAY))),
|
||||
e -> guiManager.openSkinMenu(viewer));
|
||||
}
|
||||
|
||||
// Reset
|
||||
if (viewer.hasPermission("nickcore.reset")) {
|
||||
setItem(16, createItem(Material.BARRIER,
|
||||
Component.text("Reset Identity", NamedTextColor.RED, TextDecoration.BOLD),
|
||||
List.of(Component.text("Return to your real identity", NamedTextColor.GRAY))),
|
||||
e -> {
|
||||
viewer.closeInventory();
|
||||
guiManager.getNickService().resetNickname(viewer, null).thenAccept(r -> {
|
||||
if (r.success()) {
|
||||
viewer.sendMessage(Component.text("Identity restored.", NamedTextColor.GREEN));
|
||||
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||
guiManager.getNametagService().updateNametag(viewer);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import com.nickcore.common.validation.InputSanitizer;
|
||||
import com.nickcore.paper.service.NickService;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Nickname selection GUI with paginated name pool and random option.
|
||||
*/
|
||||
public final class NicknameMenuGui extends AbstractGui {
|
||||
|
||||
private final GuiManager guiManager;
|
||||
private final NickService nickService;
|
||||
private int page = 0;
|
||||
|
||||
public NicknameMenuGui(Player viewer, GuiManager guiManager, NickService nickService) {
|
||||
super(viewer, 54, Component.text("Select Nickname", NamedTextColor.GREEN, TextDecoration.BOLD));
|
||||
this.guiManager = guiManager;
|
||||
this.nickService = nickService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void build() {
|
||||
fillBorder(Material.GREEN_STAINED_GLASS_PANE);
|
||||
populateNames();
|
||||
|
||||
// Random button
|
||||
if (viewer.hasPermission("nickcore.name.random")) {
|
||||
setItem(49, createItem(Material.ENDER_PEARL,
|
||||
Component.text("Random Nickname", NamedTextColor.LIGHT_PURPLE, TextDecoration.BOLD),
|
||||
List.of(Component.text("Get a random nickname", NamedTextColor.GRAY))),
|
||||
e -> {
|
||||
viewer.closeInventory();
|
||||
nickService.applyRandomNickname(viewer).thenAccept(r -> {
|
||||
if (r.success()) {
|
||||
viewer.sendMessage(Component.text("Nickname: " + r.profile().nickname(), NamedTextColor.GREEN));
|
||||
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||
guiManager.getNametagService().updateNametag(viewer);
|
||||
} else {
|
||||
viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Back button
|
||||
setItem(45, createItem(Material.ARROW,
|
||||
Component.text("Back", NamedTextColor.RED)),
|
||||
e -> guiManager.openMainMenu(viewer));
|
||||
|
||||
// Pagination
|
||||
if (page > 0) {
|
||||
setItem(48, createItem(Material.ARROW, Component.text("Previous Page", NamedTextColor.YELLOW)),
|
||||
e -> { page--; inventory.clear(); build(); });
|
||||
}
|
||||
|
||||
List<String> pool = nickService.getNamePool();
|
||||
int totalPages = (int) Math.ceil(pool.size() / 28.0);
|
||||
if (page < totalPages - 1) {
|
||||
setItem(50, createItem(Material.ARROW, Component.text("Next Page", NamedTextColor.YELLOW)),
|
||||
e -> { page++; inventory.clear(); build(); });
|
||||
}
|
||||
}
|
||||
|
||||
private void populateNames() {
|
||||
List<String> pool = nickService.getNamePool();
|
||||
int start = page * 28;
|
||||
int[] contentSlots = getContentSlots();
|
||||
|
||||
for (int i = 0; i < contentSlots.length && (start + i) < pool.size(); i++) {
|
||||
String name = pool.get(start + i);
|
||||
setItem(contentSlots[i], createItem(Material.PAPER,
|
||||
Component.text(name, NamedTextColor.WHITE)),
|
||||
e -> {
|
||||
viewer.closeInventory();
|
||||
nickService.applyNickname(viewer, name, null).thenAccept(r -> {
|
||||
if (r.success()) {
|
||||
viewer.sendMessage(Component.text("Nickname set to " + name, NamedTextColor.GREEN));
|
||||
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||
guiManager.getNametagService().updateNametag(viewer);
|
||||
} else {
|
||||
viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private int[] getContentSlots() {
|
||||
int[] slots = new int[28];
|
||||
int idx = 0;
|
||||
for (int row = 1; row <= 4; row++) {
|
||||
for (int col = 1; col <= 7; col++) {
|
||||
slots[idx++] = row * 9 + col;
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.service.NametagService;
|
||||
import com.nickcore.paper.service.PlaceholderService;
|
||||
import com.nickcore.paper.service.RankService;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Rank selection GUI with permission filtering and pagination.
|
||||
*/
|
||||
public final class RankMenuGui extends AbstractGui {
|
||||
|
||||
private final GuiManager guiManager;
|
||||
private final RankService rankService;
|
||||
private final ProfileCache profileCache;
|
||||
private final PlaceholderService placeholderService;
|
||||
private final NametagService nametagService;
|
||||
private final MiniMessage mm = MiniMessage.miniMessage();
|
||||
private int page = 0;
|
||||
|
||||
public RankMenuGui(Player viewer, GuiManager guiManager, RankService rankService,
|
||||
ProfileCache profileCache, PlaceholderService placeholderService,
|
||||
NametagService nametagService) {
|
||||
super(viewer, 54, Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD));
|
||||
this.guiManager = guiManager;
|
||||
this.rankService = rankService;
|
||||
this.profileCache = profileCache;
|
||||
this.placeholderService = placeholderService;
|
||||
this.nametagService = nametagService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void build() {
|
||||
fillBorder(Material.ORANGE_STAINED_GLASS_PANE);
|
||||
|
||||
List<RankDefinition> available = rankService.getAvailableRanks(viewer);
|
||||
int start = page * 28;
|
||||
int[] slots = getContentSlots();
|
||||
|
||||
for (int i = 0; i < slots.length && (start + i) < available.size(); i++) {
|
||||
RankDefinition rank = available.get(start + i);
|
||||
Component itemName = mm.deserialize(rank.prefix() + rank.displayName() + rank.suffix());
|
||||
setItem(slots[i], createItem(Material.LEATHER_CHESTPLATE, itemName,
|
||||
List.of(
|
||||
Component.text("Weight: " + rank.weight(), NamedTextColor.GRAY),
|
||||
Component.empty(),
|
||||
Component.text("Click to select", NamedTextColor.YELLOW)
|
||||
)),
|
||||
e -> {
|
||||
NickProfile profile = profileCache.get(viewer.getUniqueId())
|
||||
.orElse(NickProfile.empty(viewer.getUniqueId()));
|
||||
profileCache.put(profile.withRank(rank.id()));
|
||||
viewer.closeInventory();
|
||||
viewer.sendMessage(Component.text("Rank set to ", NamedTextColor.GREEN)
|
||||
.append(mm.deserialize(rank.displayName())));
|
||||
placeholderService.invalidate(viewer.getUniqueId(), viewer.getName());
|
||||
nametagService.updateNametag(viewer);
|
||||
});
|
||||
}
|
||||
|
||||
// Back button
|
||||
setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)),
|
||||
e -> guiManager.openMainMenu(viewer));
|
||||
|
||||
// Pagination
|
||||
int totalPages = (int) Math.ceil(available.size() / 28.0);
|
||||
if (page > 0) {
|
||||
setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)),
|
||||
e -> { page--; inventory.clear(); build(); });
|
||||
}
|
||||
if (page < totalPages - 1) {
|
||||
setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)),
|
||||
e -> { page++; inventory.clear(); build(); });
|
||||
}
|
||||
}
|
||||
|
||||
private int[] getContentSlots() {
|
||||
int[] slots = new int[28];
|
||||
int idx = 0;
|
||||
for (int row = 1; row <= 4; row++)
|
||||
for (int col = 1; col <= 7; col++)
|
||||
slots[idx++] = row * 9 + col;
|
||||
return slots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.nickcore.paper.gui;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.SkinData;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.service.SkinService;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Skin selection GUI with configured skins and pagination.
|
||||
*/
|
||||
public final class SkinMenuGui extends AbstractGui {
|
||||
|
||||
private final GuiManager guiManager;
|
||||
private final SkinService skinService;
|
||||
private final ProfileCache profileCache;
|
||||
private int page = 0;
|
||||
|
||||
public SkinMenuGui(Player viewer, GuiManager guiManager,
|
||||
SkinService skinService, ProfileCache profileCache) {
|
||||
super(viewer, 54, Component.text("Select Skin", NamedTextColor.AQUA, TextDecoration.BOLD));
|
||||
this.guiManager = guiManager;
|
||||
this.skinService = skinService;
|
||||
this.profileCache = profileCache;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void build() {
|
||||
fillBorder(Material.LIGHT_BLUE_STAINED_GLASS_PANE);
|
||||
|
||||
List<String> skinNames = skinService.getConfiguredSkinNames();
|
||||
int start = page * 28;
|
||||
int[] slots = getContentSlots();
|
||||
|
||||
for (int i = 0; i < slots.length && (start + i) < skinNames.size(); i++) {
|
||||
String skinName = skinNames.get(start + i);
|
||||
setItem(slots[i], createItem(Material.PLAYER_HEAD,
|
||||
Component.text(skinName, NamedTextColor.WHITE),
|
||||
List.of(Component.text("Click to apply", NamedTextColor.YELLOW))),
|
||||
e -> {
|
||||
viewer.closeInventory();
|
||||
viewer.sendMessage(Component.text("Applying skin...", NamedTextColor.GRAY));
|
||||
|
||||
skinService.getConfiguredSkin(skinName).or(() -> {
|
||||
// Fetch from Mojang if not configured
|
||||
return java.util.Optional.empty();
|
||||
}).ifPresentOrElse(
|
||||
skin -> applySkin(skin),
|
||||
() -> skinService.fetchSkin(skinName).thenAccept(opt ->
|
||||
opt.ifPresentOrElse(
|
||||
this::applySkin,
|
||||
() -> viewer.sendMessage(Component.text("Skin not found.", NamedTextColor.RED))
|
||||
))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Keep current skin button
|
||||
if (viewer.hasPermission("nickcore.skin.keep")) {
|
||||
setItem(49, createItem(Material.ARMOR_STAND,
|
||||
Component.text("Keep Current Skin", NamedTextColor.GREEN),
|
||||
List.of(Component.text("Don't change your skin", NamedTextColor.GRAY))),
|
||||
e -> viewer.closeInventory());
|
||||
}
|
||||
|
||||
// Back button
|
||||
setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)),
|
||||
e -> guiManager.openMainMenu(viewer));
|
||||
|
||||
// Pagination
|
||||
int totalPages = Math.max(1, (int) Math.ceil(skinNames.size() / 28.0));
|
||||
if (page > 0) {
|
||||
setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)),
|
||||
e -> { page--; inventory.clear(); build(); });
|
||||
}
|
||||
if (page < totalPages - 1) {
|
||||
setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)),
|
||||
e -> { page++; inventory.clear(); build(); });
|
||||
}
|
||||
}
|
||||
|
||||
private void applySkin(SkinData skin) {
|
||||
NickProfile profile = profileCache.get(viewer.getUniqueId())
|
||||
.orElse(NickProfile.empty(viewer.getUniqueId()));
|
||||
profileCache.put(profile.withSkin(skin.name(), skin.textureValue(), skin.textureSignature()));
|
||||
|
||||
Bukkit.getScheduler().runTask(guiManager.getPlugin(), () -> {
|
||||
skinService.applySkin(viewer, skin);
|
||||
viewer.sendMessage(Component.text("Skin applied!", NamedTextColor.GREEN));
|
||||
});
|
||||
}
|
||||
|
||||
private int[] getContentSlots() {
|
||||
int[] slots = new int[28];
|
||||
int idx = 0;
|
||||
for (int row = 1; row <= 4; row++)
|
||||
for (int col = 1; col <= 7; col++)
|
||||
slots[idx++] = row * 9 + col;
|
||||
return slots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.nickcore.paper.integration;
|
||||
|
||||
import com.nickcore.paper.service.PlaceholderService;
|
||||
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
/**
|
||||
* PlaceholderAPI expansion for NickCore.
|
||||
* All placeholder resolution reads from the in-memory PlaceholderService cache (zero DB hits).
|
||||
*/
|
||||
public final class NickCoreExpansion extends PlaceholderExpansion {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final PlaceholderService placeholderService;
|
||||
|
||||
public NickCoreExpansion(Plugin plugin, PlaceholderService placeholderService) {
|
||||
this.plugin = plugin;
|
||||
this.placeholderService = placeholderService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull String getIdentifier() { return "nickcore"; }
|
||||
|
||||
@Override
|
||||
public @NotNull String getAuthor() { return "NickCore Team"; }
|
||||
|
||||
@Override
|
||||
public @NotNull String getVersion() { return plugin.getPluginMeta().getVersion(); }
|
||||
|
||||
@Override
|
||||
public boolean persist() { return true; }
|
||||
|
||||
@Override
|
||||
public boolean canRegister() { return true; }
|
||||
|
||||
@Override
|
||||
public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) {
|
||||
if (player == null || player.getUniqueId() == null) return "";
|
||||
|
||||
return switch (params.toLowerCase()) {
|
||||
case "nick" -> placeholderService.resolve(player.getUniqueId(), "nick");
|
||||
case "real_name" -> placeholderService.resolve(player.getUniqueId(), "real_name");
|
||||
case "rank" -> placeholderService.resolve(player.getUniqueId(), "rank");
|
||||
case "rank_prefix" -> placeholderService.resolve(player.getUniqueId(), "rank_prefix");
|
||||
case "rank_suffix" -> placeholderService.resolve(player.getUniqueId(), "rank_suffix");
|
||||
case "skin" -> placeholderService.resolve(player.getUniqueId(), "skin");
|
||||
case "is_nicked" -> placeholderService.resolve(player.getUniqueId(), "is_nicked");
|
||||
case "display_name" -> placeholderService.resolve(player.getUniqueId(), "display_name");
|
||||
case "original_name" -> placeholderService.resolve(player.getUniqueId(), "original_name");
|
||||
case "nick_history_count" -> placeholderService.resolve(player.getUniqueId(), "nick_history_count");
|
||||
case "server" -> placeholderService.resolve(player.getUniqueId(), "server");
|
||||
case "network_rank" -> placeholderService.resolve(player.getUniqueId(), "network_rank");
|
||||
case "network_nick" -> placeholderService.resolve(player.getUniqueId(), "network_nick");
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.nickcore.paper.integration;
|
||||
|
||||
import com.nickcore.common.dto.NickSyncMessage;
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.service.PlaceholderService;
|
||||
import com.nickcore.paper.service.NametagService;
|
||||
import com.nickcore.paper.scheduler.SchedulerAdapter;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.messaging.PluginMessageListener;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Velocity plugin messaging integration.
|
||||
* Sends/receives NickSyncMessages for cross-server nick synchronization.
|
||||
*/
|
||||
public final class VelocityMessaging implements PluginMessageListener {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final ProfileCache profileCache;
|
||||
private final PlaceholderService placeholderService;
|
||||
private final NametagService nametagService;
|
||||
private final SchedulerAdapter scheduler;
|
||||
private final Logger logger;
|
||||
private final String serverName;
|
||||
|
||||
public VelocityMessaging(Plugin plugin, ProfileCache profileCache,
|
||||
PlaceholderService placeholderService, NametagService nametagService,
|
||||
SchedulerAdapter scheduler, Logger logger, String serverName) {
|
||||
this.plugin = plugin;
|
||||
this.profileCache = profileCache;
|
||||
this.placeholderService = placeholderService;
|
||||
this.nametagService = nametagService;
|
||||
this.scheduler = scheduler;
|
||||
this.logger = logger;
|
||||
this.serverName = serverName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the plugin messaging channel.
|
||||
*/
|
||||
public void register() {
|
||||
Bukkit.getMessenger().registerOutgoingPluginChannel(plugin, NickSyncMessage.CHANNEL);
|
||||
Bukkit.getMessenger().registerIncomingPluginChannel(plugin, NickSyncMessage.CHANNEL, this);
|
||||
logger.info("Velocity messaging channel registered: " + NickSyncMessage.CHANNEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the plugin messaging channel.
|
||||
*/
|
||||
public void unregister() {
|
||||
Bukkit.getMessenger().unregisterOutgoingPluginChannel(plugin, NickSyncMessage.CHANNEL);
|
||||
Bukkit.getMessenger().unregisterIncomingPluginChannel(plugin, NickSyncMessage.CHANNEL, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a sync message through any online player's connection.
|
||||
*/
|
||||
public void sendSync(NickSyncMessage message) {
|
||||
var players = Bukkit.getOnlinePlayers();
|
||||
if (players.isEmpty()) return;
|
||||
|
||||
Player carrier = players.iterator().next();
|
||||
carrier.sendPluginMessage(plugin, NickSyncMessage.CHANNEL, message.encode());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a full profile sync for a player.
|
||||
*/
|
||||
public void syncProfile(NickProfile profile) {
|
||||
sendSync(NickSyncMessage.fullSync(profile, serverName));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPluginMessageReceived(@NotNull String channel, @NotNull Player player, byte @NotNull [] data) {
|
||||
if (!NickSyncMessage.CHANNEL.equals(channel)) return;
|
||||
|
||||
try {
|
||||
NickSyncMessage message = NickSyncMessage.decode(data);
|
||||
|
||||
// Don't process our own messages
|
||||
if (serverName.equals(message.sourceServer())) return;
|
||||
|
||||
switch (message.action()) {
|
||||
case FULL_SYNC -> handleFullSync(message);
|
||||
case NICK_UPDATE -> handleNickUpdate(message);
|
||||
case RANK_UPDATE -> handleRankUpdate(message);
|
||||
case RESET -> handleReset(message);
|
||||
case CACHE_INVALIDATE -> handleCacheInvalidate(message);
|
||||
default -> logger.fine("Unhandled sync action: " + message.action());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.log(Level.WARNING, "Failed to process sync message", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFullSync(NickSyncMessage msg) {
|
||||
NickProfile profile = new NickProfile(msg.playerUuid(), msg.nickname(), msg.rankId(),
|
||||
msg.skinId(), msg.skinValue(), msg.skinSignature(),
|
||||
msg.nickname() != null, null, null);
|
||||
profileCache.put(profile);
|
||||
|
||||
Player player = Bukkit.getPlayer(msg.playerUuid());
|
||||
if (player != null) {
|
||||
placeholderService.invalidate(msg.playerUuid(), player.getName());
|
||||
scheduler.runAtEntity(plugin, player, () -> nametagService.updateNametag(player));
|
||||
}
|
||||
}
|
||||
|
||||
private void handleNickUpdate(NickSyncMessage msg) {
|
||||
profileCache.get(msg.playerUuid()).ifPresent(profile -> {
|
||||
profileCache.put(profile.withNickname(msg.nickname()));
|
||||
refreshPlayer(msg.playerUuid());
|
||||
});
|
||||
}
|
||||
|
||||
private void handleRankUpdate(NickSyncMessage msg) {
|
||||
profileCache.get(msg.playerUuid()).ifPresent(profile -> {
|
||||
profileCache.put(profile.withRank(msg.rankId()));
|
||||
refreshPlayer(msg.playerUuid());
|
||||
});
|
||||
}
|
||||
|
||||
private void handleReset(NickSyncMessage msg) {
|
||||
profileCache.get(msg.playerUuid()).ifPresent(profile -> {
|
||||
profileCache.put(profile.reset());
|
||||
refreshPlayer(msg.playerUuid());
|
||||
});
|
||||
}
|
||||
|
||||
private void handleCacheInvalidate(NickSyncMessage msg) {
|
||||
profileCache.remove(msg.playerUuid());
|
||||
placeholderService.remove(msg.playerUuid());
|
||||
}
|
||||
|
||||
private void refreshPlayer(java.util.UUID uuid) {
|
||||
Player player = Bukkit.getPlayer(uuid);
|
||||
if (player != null) {
|
||||
placeholderService.invalidate(uuid, player.getName());
|
||||
scheduler.runAtEntity(plugin, player, () -> nametagService.updateNametag(player));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.nickcore.paper.listener;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.validation.InputSanitizer;
|
||||
import com.nickcore.paper.service.*;
|
||||
import com.nickcore.paper.scheduler.SchedulerAdapter;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Handles player join/quit for profile loading, display application, and cleanup.
|
||||
*/
|
||||
public final class PlayerConnectionListener implements Listener {
|
||||
|
||||
private final Plugin plugin;
|
||||
private final NickService nickService;
|
||||
private final SkinService skinService;
|
||||
private final PlaceholderService placeholderService;
|
||||
private final NametagService nametagService;
|
||||
private final SchedulerAdapter scheduler;
|
||||
private final Logger logger;
|
||||
private final MiniMessage mm = MiniMessage.miniMessage();
|
||||
|
||||
public PlayerConnectionListener(Plugin plugin, NickService nickService,
|
||||
SkinService skinService, PlaceholderService placeholderService,
|
||||
NametagService nametagService, SchedulerAdapter scheduler,
|
||||
Logger logger) {
|
||||
this.plugin = plugin;
|
||||
this.nickService = nickService;
|
||||
this.skinService = skinService;
|
||||
this.placeholderService = placeholderService;
|
||||
this.nametagService = nametagService;
|
||||
this.scheduler = scheduler;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.LOWEST)
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
|
||||
// Async: load profile from DB
|
||||
nickService.loadProfile(player.getUniqueId()).thenAccept(profile -> {
|
||||
// Update placeholder cache
|
||||
placeholderService.invalidate(player.getUniqueId(), player.getName());
|
||||
|
||||
// Apply display on the appropriate thread
|
||||
scheduler.runAtEntity(plugin, player, () -> {
|
||||
if (!player.isOnline()) return;
|
||||
|
||||
// Apply display name
|
||||
if (profile.effectiveDisplayName() != null) {
|
||||
player.displayName(mm.deserialize(
|
||||
InputSanitizer.escapeMiniMessage(profile.effectiveDisplayName())));
|
||||
}
|
||||
|
||||
// Apply nametag
|
||||
nametagService.updateNametag(player);
|
||||
|
||||
// Apply skin if overridden
|
||||
if (profile.hasSkinOverride()) {
|
||||
var skinData = new com.nickcore.common.model.SkinData(
|
||||
profile.skinId(), profile.skinValue(), profile.skinSignature(),
|
||||
null, null);
|
||||
skinService.applySkin(player, skinData);
|
||||
}
|
||||
});
|
||||
}).exceptionally(ex -> {
|
||||
logger.severe("Failed to load profile for " + player.getName() + ": " + ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
|
||||
// Save current profile state
|
||||
nickService.getProfile(player.getUniqueId()).ifPresent(profile -> {
|
||||
// Profile is already saved on changes; this is just cleanup
|
||||
});
|
||||
|
||||
// Cleanup
|
||||
nickService.unloadProfile(player.getUniqueId());
|
||||
placeholderService.remove(player.getUniqueId());
|
||||
nametagService.removeNametag(player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.nickcore.paper.repository;
|
||||
|
||||
import com.nickcore.common.config.NickCoreConfig;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Database connection pool manager using HikariCP.
|
||||
*
|
||||
* <p>Supports SQLite, MySQL, and MariaDB. Handles schema creation and
|
||||
* migration on startup. All connections are pooled for optimal performance.</p>
|
||||
*
|
||||
* <p>Thread-safe: HikariDataSource is inherently thread-safe.</p>
|
||||
*/
|
||||
public final class DatabaseManager implements AutoCloseable {
|
||||
|
||||
private static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
private final Logger logger;
|
||||
private final HikariDataSource dataSource;
|
||||
private final boolean isSqlite;
|
||||
private final String tablePrefix;
|
||||
|
||||
public DatabaseManager(NickCoreConfig.DatabaseSettings config, File dataFolder, Logger logger) {
|
||||
this.logger = logger;
|
||||
this.isSqlite = "sqlite".equalsIgnoreCase(config.type());
|
||||
this.tablePrefix = config.tablePrefix();
|
||||
|
||||
HikariConfig hikariConfig = new HikariConfig();
|
||||
|
||||
if (isSqlite) {
|
||||
File dbFile = new File(dataFolder, config.database() + ".db");
|
||||
hikariConfig.setJdbcUrl("jdbc:sqlite:" + dbFile.getAbsolutePath());
|
||||
hikariConfig.setDriverClassName("org.sqlite.JDBC");
|
||||
// SQLite only supports single writer
|
||||
hikariConfig.setMaximumPoolSize(1);
|
||||
hikariConfig.setMinimumIdle(1);
|
||||
} else {
|
||||
String jdbcUrl = String.format("jdbc:%s://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&autoReconnect=true",
|
||||
"mariadb".equalsIgnoreCase(config.type()) ? "mariadb" : "mysql",
|
||||
config.host(), config.port(), config.database());
|
||||
hikariConfig.setJdbcUrl(jdbcUrl);
|
||||
hikariConfig.setUsername(config.username());
|
||||
hikariConfig.setPassword(config.password());
|
||||
hikariConfig.setMaximumPoolSize(config.maxPoolSize());
|
||||
hikariConfig.setMinimumIdle(config.minIdle());
|
||||
}
|
||||
|
||||
hikariConfig.setConnectionTimeout(config.connectionTimeoutMs());
|
||||
hikariConfig.setIdleTimeout(config.idleTimeoutMs());
|
||||
hikariConfig.setMaxLifetime(config.maxLifetimeMs());
|
||||
hikariConfig.setPoolName("NickCore-HikariPool");
|
||||
|
||||
// Performance optimizations
|
||||
hikariConfig.addDataSourceProperty("cachePrepStmts", "true");
|
||||
hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250");
|
||||
hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
|
||||
hikariConfig.addDataSourceProperty("useServerPrepStmts", "true");
|
||||
|
||||
this.dataSource = new HikariDataSource(hikariConfig);
|
||||
logger.info("Database connection pool initialized (" + config.type() + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a connection from the pool.
|
||||
*
|
||||
* <p>Callers MUST close the connection in a try-with-resources block.</p>
|
||||
*/
|
||||
public Connection getConnection() throws SQLException {
|
||||
return dataSource.getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this is a SQLite database.
|
||||
*/
|
||||
public boolean isSqlite() {
|
||||
return isSqlite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the configured table prefix.
|
||||
*/
|
||||
public String getTablePrefix() {
|
||||
return tablePrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the database schema, creating tables and running migrations.
|
||||
*/
|
||||
public void initialize() {
|
||||
try (Connection conn = getConnection()) {
|
||||
createSchemaTable(conn);
|
||||
int currentVersion = getSchemaVersion(conn);
|
||||
|
||||
if (currentVersion < CURRENT_SCHEMA_VERSION) {
|
||||
runMigrations(conn, currentVersion);
|
||||
}
|
||||
|
||||
logger.info("Database schema is up to date (version " + CURRENT_SCHEMA_VERSION + ")");
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to initialize database schema", e);
|
||||
throw new RuntimeException("Database initialization failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void createSchemaTable(Connection conn) throws SQLException {
|
||||
String sql = "CREATE TABLE IF NOT EXISTS " + tablePrefix + "schema_version (" +
|
||||
"version INTEGER PRIMARY KEY, " +
|
||||
"applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)";
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute(sql);
|
||||
}
|
||||
}
|
||||
|
||||
private int getSchemaVersion(Connection conn) throws SQLException {
|
||||
String sql = "SELECT COALESCE(MAX(version), 0) FROM " + tablePrefix + "schema_version";
|
||||
try (Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery(sql)) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt(1);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void runMigrations(Connection conn, int fromVersion) throws SQLException {
|
||||
logger.info("Running database migrations from version " + fromVersion + " to " + CURRENT_SCHEMA_VERSION);
|
||||
|
||||
boolean autoCommit = conn.getAutoCommit();
|
||||
conn.setAutoCommit(false);
|
||||
|
||||
try {
|
||||
for (int version = fromVersion + 1; version <= CURRENT_SCHEMA_VERSION; version++) {
|
||||
runMigration(conn, version);
|
||||
recordSchemaVersion(conn, version);
|
||||
logger.info("Applied migration v" + version);
|
||||
}
|
||||
conn.commit();
|
||||
} catch (SQLException e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.setAutoCommit(autoCommit);
|
||||
}
|
||||
}
|
||||
|
||||
private void runMigration(Connection conn, int version) throws SQLException {
|
||||
switch (version) {
|
||||
case 1 -> applyMigrationV1(conn);
|
||||
default -> throw new SQLException("Unknown migration version: " + version);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMigrationV1(Connection conn) throws SQLException {
|
||||
String autoInc = isSqlite ? "AUTOINCREMENT" : "AUTO_INCREMENT";
|
||||
|
||||
// Profiles table
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "profiles (" +
|
||||
"uuid CHAR(36) PRIMARY KEY, " +
|
||||
"nickname VARCHAR(16) NULL, " +
|
||||
"rank_id VARCHAR(32) NULL, " +
|
||||
"skin_id VARCHAR(64) NULL, " +
|
||||
"skin_value TEXT NULL, " +
|
||||
"skin_signature TEXT NULL, " +
|
||||
"is_nicked BOOLEAN NOT NULL DEFAULT 0, " +
|
||||
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
|
||||
"updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
|
||||
}
|
||||
|
||||
// History table
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "history (" +
|
||||
"id INTEGER PRIMARY KEY " + autoInc + ", " +
|
||||
"uuid CHAR(36) NOT NULL, " +
|
||||
"old_nickname VARCHAR(16) NULL, " +
|
||||
"new_nickname VARCHAR(16) NULL, " +
|
||||
"old_rank VARCHAR(32) NULL, " +
|
||||
"new_rank VARCHAR(32) NULL, " +
|
||||
"changed_by CHAR(36) NULL, " +
|
||||
"reason VARCHAR(255) NULL, " +
|
||||
"timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
|
||||
}
|
||||
|
||||
// History indexes
|
||||
if (!isSqlite) {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_uuid ON " +
|
||||
tablePrefix + "history (uuid)");
|
||||
}
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_timestamp ON " +
|
||||
tablePrefix + "history (timestamp)");
|
||||
}
|
||||
}
|
||||
|
||||
// Skin cache table
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "skin_cache (" +
|
||||
"skin_name VARCHAR(64) PRIMARY KEY, " +
|
||||
"texture_value TEXT NOT NULL, " +
|
||||
"texture_signature TEXT NOT NULL, " +
|
||||
"fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
|
||||
"expires_at TIMESTAMP NOT NULL)");
|
||||
}
|
||||
}
|
||||
|
||||
private void recordSchemaVersion(Connection conn, int version) throws SQLException {
|
||||
String sql = "INSERT INTO " + tablePrefix + "schema_version (version) VALUES (?)";
|
||||
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
ps.setInt(1, version);
|
||||
ps.executeUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
logger.info("Database connection pool closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.nickcore.paper.repository;
|
||||
|
||||
import com.nickcore.common.model.NickHistory;
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.SkinData;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Repository interface for nick profile persistence.
|
||||
*
|
||||
* <p>All operations return {@link CompletableFuture} to enforce async execution.
|
||||
* Implementations MUST NOT perform any blocking I/O on the caller's thread.</p>
|
||||
*/
|
||||
public interface NickRepository {
|
||||
|
||||
// ── Profile Operations ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Finds a player's nick profile by UUID.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
* @return the profile, or empty if no profile exists
|
||||
*/
|
||||
CompletableFuture<Optional<NickProfile>> findByUuid(UUID uuid);
|
||||
|
||||
/**
|
||||
* Saves or updates a player's nick profile.
|
||||
*
|
||||
* @param profile the profile to save (upsert)
|
||||
*/
|
||||
CompletableFuture<Void> save(NickProfile profile);
|
||||
|
||||
/**
|
||||
* Deletes a player's nick profile.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
*/
|
||||
CompletableFuture<Void> delete(UUID uuid);
|
||||
|
||||
/**
|
||||
* Checks whether a nickname is currently in use by another player.
|
||||
*
|
||||
* @param nickname the nickname to check
|
||||
* @param excludeUuid UUID to exclude from the check (the requesting player)
|
||||
* @return true if the nickname is taken
|
||||
*/
|
||||
CompletableFuture<Boolean> isNicknameTaken(String nickname, UUID excludeUuid);
|
||||
|
||||
// ── History Operations ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records a change in the history audit log.
|
||||
*
|
||||
* @param history the history entry to record
|
||||
*/
|
||||
CompletableFuture<Void> recordHistory(NickHistory history);
|
||||
|
||||
/**
|
||||
* Retrieves the nick history for a player.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
* @param limit maximum number of entries to return
|
||||
* @return list of history entries, most recent first
|
||||
*/
|
||||
CompletableFuture<List<NickHistory>> getHistory(UUID uuid, int limit);
|
||||
|
||||
/**
|
||||
* Counts the total number of nick changes for a player.
|
||||
*
|
||||
* @param uuid the player's UUID
|
||||
* @return the count
|
||||
*/
|
||||
CompletableFuture<Integer> getHistoryCount(UUID uuid);
|
||||
|
||||
// ── Skin Cache Operations ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retrieves cached skin data by name.
|
||||
*
|
||||
* @param skinName the skin identifier
|
||||
* @return the cached skin data, or empty if not cached or expired
|
||||
*/
|
||||
CompletableFuture<Optional<SkinData>> getCachedSkin(String skinName);
|
||||
|
||||
/**
|
||||
* Caches skin data for future use.
|
||||
*
|
||||
* @param skin the skin data to cache
|
||||
*/
|
||||
CompletableFuture<Void> cacheSkin(SkinData skin);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.nickcore.paper.repository;
|
||||
|
||||
import com.nickcore.common.model.NickHistory;
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.SkinData;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* SQL-based implementation of {@link NickRepository}.
|
||||
*
|
||||
* <p>All operations execute asynchronously on a dedicated thread pool to prevent
|
||||
* blocking server threads. Uses prepared statements exclusively to prevent
|
||||
* SQL injection.</p>
|
||||
*
|
||||
* <p>Thread-safe: all state is accessed through the thread-safe HikariCP pool
|
||||
* and the dedicated executor service.</p>
|
||||
*/
|
||||
public final class SqlNickRepository implements NickRepository, AutoCloseable {
|
||||
|
||||
private final DatabaseManager databaseManager;
|
||||
private final Logger logger;
|
||||
private final String prefix;
|
||||
private final ExecutorService executor;
|
||||
|
||||
public SqlNickRepository(DatabaseManager databaseManager, Logger logger) {
|
||||
this.databaseManager = databaseManager;
|
||||
this.logger = logger;
|
||||
this.prefix = databaseManager.getTablePrefix();
|
||||
// Dedicated thread pool for DB operations — sized to match connection pool
|
||||
this.executor = Executors.newFixedThreadPool(
|
||||
Math.max(2, Runtime.getRuntime().availableProcessors()),
|
||||
Thread.ofVirtual().name("nickcore-db-", 0).factory()
|
||||
);
|
||||
}
|
||||
|
||||
// ── Profile Operations ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<NickProfile>> findByUuid(UUID uuid) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
String sql = "SELECT * FROM " + prefix + "profiles WHERE uuid = ?";
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, uuid.toString());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return Optional.of(mapProfile(rs));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to find profile for " + uuid, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> save(NickProfile profile) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
String sql = databaseManager.isSqlite()
|
||||
? "INSERT OR REPLACE INTO " + prefix + "profiles " +
|
||||
"(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
: "INSERT INTO " + prefix + "profiles " +
|
||||
"(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE nickname=?, rank_id=?, skin_id=?, skin_value=?, skin_signature=?, " +
|
||||
"is_nicked=?, updated_at=?";
|
||||
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
// INSERT values
|
||||
ps.setString(1, profile.uuid().toString());
|
||||
ps.setString(2, profile.nickname());
|
||||
ps.setString(3, profile.rankId());
|
||||
ps.setString(4, profile.skinId());
|
||||
ps.setString(5, profile.skinValue());
|
||||
ps.setString(6, profile.skinSignature());
|
||||
ps.setBoolean(7, profile.nicked());
|
||||
ps.setTimestamp(8, Timestamp.from(profile.createdAt()));
|
||||
ps.setTimestamp(9, Timestamp.from(profile.updatedAt()));
|
||||
|
||||
if (!databaseManager.isSqlite()) {
|
||||
// ON DUPLICATE KEY UPDATE values
|
||||
ps.setString(10, profile.nickname());
|
||||
ps.setString(11, profile.rankId());
|
||||
ps.setString(12, profile.skinId());
|
||||
ps.setString(13, profile.skinValue());
|
||||
ps.setString(14, profile.skinSignature());
|
||||
ps.setBoolean(15, profile.nicked());
|
||||
ps.setTimestamp(16, Timestamp.from(profile.updatedAt()));
|
||||
}
|
||||
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to save profile for " + profile.uuid(), e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> delete(UUID uuid) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
String sql = "DELETE FROM " + prefix + "profiles WHERE uuid = ?";
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, uuid.toString());
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to delete profile for " + uuid, e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Boolean> isNicknameTaken(String nickname, UUID excludeUuid) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
String sql = "SELECT COUNT(*) FROM " + prefix + "profiles WHERE LOWER(nickname) = LOWER(?) AND uuid != ?";
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, nickname);
|
||||
ps.setString(2, excludeUuid.toString());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return rs.getInt(1) > 0;
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to check nickname availability: " + nickname, e);
|
||||
}
|
||||
return false;
|
||||
}, executor);
|
||||
}
|
||||
|
||||
// ── History Operations ──────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> recordHistory(NickHistory history) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
String sql = "INSERT INTO " + prefix + "history " +
|
||||
"(uuid, old_nickname, new_nickname, old_rank, new_rank, changed_by, reason, timestamp) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, history.uuid().toString());
|
||||
ps.setString(2, history.oldNickname());
|
||||
ps.setString(3, history.newNickname());
|
||||
ps.setString(4, history.oldRank());
|
||||
ps.setString(5, history.newRank());
|
||||
ps.setString(6, history.changedBy() != null ? history.changedBy().toString() : null);
|
||||
ps.setString(7, history.reason());
|
||||
ps.setTimestamp(8, Timestamp.from(history.timestamp()));
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to record history for " + history.uuid(), e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<List<NickHistory>> getHistory(UUID uuid, int limit) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
String sql = "SELECT * FROM " + prefix + "history WHERE uuid = ? ORDER BY timestamp DESC LIMIT ?";
|
||||
List<NickHistory> history = new ArrayList<>();
|
||||
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, uuid.toString());
|
||||
ps.setInt(2, limit);
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
while (rs.next()) {
|
||||
history.add(mapHistory(rs));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to get history for " + uuid, e);
|
||||
}
|
||||
return List.copyOf(history);
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Integer> getHistoryCount(UUID uuid) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
String sql = "SELECT COUNT(*) FROM " + prefix + "history WHERE uuid = ?";
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, uuid.toString());
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) return rs.getInt(1);
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to count history for " + uuid, e);
|
||||
}
|
||||
return 0;
|
||||
}, executor);
|
||||
}
|
||||
|
||||
// ── Skin Cache Operations ───────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Optional<SkinData>> getCachedSkin(String skinName) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
String sql = "SELECT * FROM " + prefix + "skin_cache WHERE skin_name = ? AND expires_at > ?";
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, skinName);
|
||||
ps.setTimestamp(2, Timestamp.from(Instant.now()));
|
||||
try (ResultSet rs = ps.executeQuery()) {
|
||||
if (rs.next()) {
|
||||
return Optional.of(new SkinData(
|
||||
rs.getString("skin_name"),
|
||||
rs.getString("texture_value"),
|
||||
rs.getString("texture_signature"),
|
||||
rs.getTimestamp("fetched_at").toInstant(),
|
||||
rs.getTimestamp("expires_at").toInstant()
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to get cached skin: " + skinName, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}, executor);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> cacheSkin(SkinData skin) {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
String sql = databaseManager.isSqlite()
|
||||
? "INSERT OR REPLACE INTO " + prefix + "skin_cache " +
|
||||
"(skin_name, texture_value, texture_signature, fetched_at, expires_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?)"
|
||||
: "INSERT INTO " + prefix + "skin_cache " +
|
||||
"(skin_name, texture_value, texture_signature, fetched_at, expires_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?) " +
|
||||
"ON DUPLICATE KEY UPDATE texture_value=?, texture_signature=?, fetched_at=?, expires_at=?";
|
||||
|
||||
try (Connection conn = databaseManager.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||
|
||||
ps.setString(1, skin.name());
|
||||
ps.setString(2, skin.textureValue());
|
||||
ps.setString(3, skin.textureSignature());
|
||||
ps.setTimestamp(4, Timestamp.from(skin.fetchedAt()));
|
||||
ps.setTimestamp(5, Timestamp.from(skin.expiresAt()));
|
||||
|
||||
if (!databaseManager.isSqlite()) {
|
||||
ps.setString(6, skin.textureValue());
|
||||
ps.setString(7, skin.textureSignature());
|
||||
ps.setTimestamp(8, Timestamp.from(skin.fetchedAt()));
|
||||
ps.setTimestamp(9, Timestamp.from(skin.expiresAt()));
|
||||
}
|
||||
|
||||
ps.executeUpdate();
|
||||
} catch (SQLException e) {
|
||||
logger.log(Level.SEVERE, "Failed to cache skin: " + skin.name(), e);
|
||||
}
|
||||
}, executor);
|
||||
}
|
||||
|
||||
// ── Mapping Helpers ─────────────────────────────────────────────────
|
||||
|
||||
private NickProfile mapProfile(ResultSet rs) throws SQLException {
|
||||
return new NickProfile(
|
||||
UUID.fromString(rs.getString("uuid")),
|
||||
rs.getString("nickname"),
|
||||
rs.getString("rank_id"),
|
||||
rs.getString("skin_id"),
|
||||
rs.getString("skin_value"),
|
||||
rs.getString("skin_signature"),
|
||||
rs.getBoolean("is_nicked"),
|
||||
rs.getTimestamp("created_at").toInstant(),
|
||||
rs.getTimestamp("updated_at").toInstant()
|
||||
);
|
||||
}
|
||||
|
||||
private NickHistory mapHistory(ResultSet rs) throws SQLException {
|
||||
String changedByStr = rs.getString("changed_by");
|
||||
UUID changedBy = changedByStr != null ? UUID.fromString(changedByStr) : null;
|
||||
|
||||
return new NickHistory(
|
||||
rs.getLong("id"),
|
||||
UUID.fromString(rs.getString("uuid")),
|
||||
rs.getString("old_nickname"),
|
||||
rs.getString("new_nickname"),
|
||||
rs.getString("old_rank"),
|
||||
rs.getString("new_rank"),
|
||||
changedBy,
|
||||
rs.getString("reason"),
|
||||
rs.getTimestamp("timestamp").toInstant()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
executor.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.nickcore.paper.scheduler;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Folia-compliant scheduler using RegionScheduler and EntityScheduler.
|
||||
* All player interactions are dispatched through the entity's owning region.
|
||||
*/
|
||||
public final class FoliaSchedulerAdapter implements SchedulerAdapter {
|
||||
|
||||
@Override
|
||||
public void runSync(Plugin plugin, Runnable task) {
|
||||
Bukkit.getGlobalRegionScheduler().run(plugin, scheduledTask -> task.run());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAsync(Plugin plugin, Runnable task) {
|
||||
Bukkit.getAsyncScheduler().runNow(plugin, scheduledTask -> task.run());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runLater(Plugin plugin, Runnable task, long delayTicks) {
|
||||
Bukkit.getGlobalRegionScheduler().runDelayed(plugin, scheduledTask -> task.run(), delayTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) {
|
||||
Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin,
|
||||
scheduledTask -> task.run(), delayTicks, periodTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAtEntity(Plugin plugin, Entity entity, Runnable task) {
|
||||
entity.getScheduler().run(plugin, scheduledTask -> task.run(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAtLocation(Plugin plugin, Location location, Runnable task) {
|
||||
Bukkit.getRegionScheduler().run(plugin, location, scheduledTask -> task.run());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.nickcore.paper.scheduler;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Standard Paper scheduler implementation using BukkitScheduler.
|
||||
*/
|
||||
public final class PaperSchedulerAdapter implements SchedulerAdapter {
|
||||
|
||||
@Override
|
||||
public void runSync(Plugin plugin, Runnable task) {
|
||||
if (Bukkit.isPrimaryThread()) {
|
||||
task.run();
|
||||
} else {
|
||||
Bukkit.getScheduler().runTask(plugin, task);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAsync(Plugin plugin, Runnable task) {
|
||||
Bukkit.getScheduler().runTaskAsynchronously(plugin, task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runLater(Plugin plugin, Runnable task, long delayTicks) {
|
||||
Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) {
|
||||
Bukkit.getScheduler().runTaskTimer(plugin, task, delayTicks, periodTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAtEntity(Plugin plugin, Entity entity, Runnable task) {
|
||||
runSync(plugin, task);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void runAtLocation(Plugin plugin, Location location, Runnable task) {
|
||||
runSync(plugin, task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.nickcore.paper.scheduler;
|
||||
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
/**
|
||||
* Abstraction over Paper/Folia scheduler APIs.
|
||||
* Implementations are selected at startup based on Folia detection.
|
||||
*/
|
||||
public interface SchedulerAdapter {
|
||||
|
||||
void runSync(Plugin plugin, Runnable task);
|
||||
void runAsync(Plugin plugin, Runnable task);
|
||||
void runLater(Plugin plugin, Runnable task, long delayTicks);
|
||||
void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks);
|
||||
void runAtEntity(Plugin plugin, Entity entity, Runnable task);
|
||||
void runAtLocation(Plugin plugin, Location location, Runnable task);
|
||||
|
||||
static SchedulerAdapter create(Plugin plugin) {
|
||||
if (isFolia()) return new FoliaSchedulerAdapter();
|
||||
return new PaperSchedulerAdapter();
|
||||
}
|
||||
|
||||
private static boolean isFolia() {
|
||||
try {
|
||||
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||
return true;
|
||||
} catch (ClassNotFoundException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import com.nickcore.common.validation.InputSanitizer;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Chat formatting service using AsyncChatEvent and Adventure API.
|
||||
* Uses ViewerUnaware renderer for performance (single render per message).
|
||||
*/
|
||||
public final class ChatService implements Listener {
|
||||
|
||||
private final ProfileCache profileCache;
|
||||
private final RankService rankService;
|
||||
private final Logger logger;
|
||||
private final MiniMessage miniMessage;
|
||||
|
||||
/** Chat format template — configurable, uses MiniMessage. */
|
||||
private volatile String chatFormat = "<prefix><display_name><suffix><gray>: </gray><message>";
|
||||
|
||||
public ChatService(ProfileCache profileCache, RankService rankService, Logger logger) {
|
||||
this.profileCache = Objects.requireNonNull(profileCache);
|
||||
this.rankService = Objects.requireNonNull(rankService);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.miniMessage = MiniMessage.miniMessage();
|
||||
}
|
||||
|
||||
public void setChatFormat(String format) {
|
||||
this.chatFormat = format;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||
public void onChat(AsyncChatEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||
|
||||
String displayName = (profile != null && profile.effectiveDisplayName() != null)
|
||||
? profile.effectiveDisplayName() : player.getName();
|
||||
|
||||
RankDefinition rank = (profile != null && profile.rankId() != null)
|
||||
? rankService.getRank(profile.rankId()).orElse(null) : null;
|
||||
|
||||
String prefix = rank != null ? rank.formattedPrefix() : "";
|
||||
String suffix = rank != null ? rank.formattedSuffix() : "";
|
||||
|
||||
// Escape display name to prevent MiniMessage injection
|
||||
String safeDisplayName = InputSanitizer.escapeMiniMessage(displayName);
|
||||
|
||||
event.renderer((source, sourceDisplayName, message, viewer) -> {
|
||||
String formatted = chatFormat
|
||||
.replace("<prefix>", prefix)
|
||||
.replace("<display_name>", safeDisplayName)
|
||||
.replace("<suffix>", suffix)
|
||||
.replace("<message>", miniMessage.serialize(message));
|
||||
|
||||
return miniMessage.deserialize(formatted);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scoreboard.Scoreboard;
|
||||
import org.bukkit.scoreboard.Team;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Nametag management via scoreboard teams.
|
||||
* Efficient updates with caching to prevent scoreboard spam.
|
||||
*/
|
||||
public final class NametagService {
|
||||
|
||||
private static final String TEAM_PREFIX = "nc_";
|
||||
|
||||
private final ProfileCache profileCache;
|
||||
private final RankService rankService;
|
||||
private final Logger logger;
|
||||
private final MiniMessage miniMessage;
|
||||
|
||||
/** Tracks current team assignments to avoid redundant updates. */
|
||||
private final ConcurrentHashMap<UUID, String> teamAssignments = new ConcurrentHashMap<>();
|
||||
|
||||
public NametagService(ProfileCache profileCache, RankService rankService, Logger logger) {
|
||||
this.profileCache = Objects.requireNonNull(profileCache);
|
||||
this.rankService = Objects.requireNonNull(rankService);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.miniMessage = MiniMessage.miniMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the nametag for a player based on their current nick profile.
|
||||
*/
|
||||
public void updateNametag(Player player) {
|
||||
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||
|
||||
String rankId = profile != null ? profile.rankId() : null;
|
||||
RankDefinition rank = rankId != null ? rankService.getRank(rankId).orElse(null) : null;
|
||||
|
||||
// Determine team name based on rank weight for sorting
|
||||
int weight = rank != null ? 999 - rank.weight() : 999;
|
||||
String teamName = TEAM_PREFIX + String.format("%03d", weight) + "_" + player.getName();
|
||||
if (teamName.length() > 16) teamName = teamName.substring(0, 16);
|
||||
|
||||
// Check if update is needed
|
||||
String currentTeam = teamAssignments.get(player.getUniqueId());
|
||||
if (teamName.equals(currentTeam)) return;
|
||||
|
||||
// Remove from old team
|
||||
if (currentTeam != null) {
|
||||
Team oldTeam = scoreboard.getTeam(currentTeam);
|
||||
if (oldTeam != null) {
|
||||
oldTeam.removeEntry(player.getName());
|
||||
if (oldTeam.getEntries().isEmpty()) oldTeam.unregister();
|
||||
}
|
||||
}
|
||||
|
||||
// Create/update team
|
||||
Team team = scoreboard.getTeam(teamName);
|
||||
if (team == null) team = scoreboard.registerNewTeam(teamName);
|
||||
|
||||
if (rank != null) {
|
||||
if (!rank.prefix().isEmpty()) {
|
||||
team.prefix(miniMessage.deserialize(rank.formattedPrefix()));
|
||||
}
|
||||
if (!rank.suffix().isEmpty()) {
|
||||
team.suffix(miniMessage.deserialize(rank.formattedSuffix()));
|
||||
}
|
||||
try {
|
||||
team.color(NamedTextColor.NAMES.value(rank.color()));
|
||||
} catch (Exception ignored) { /* invalid color name */ }
|
||||
}
|
||||
|
||||
team.addEntry(player.getName());
|
||||
teamAssignments.put(player.getUniqueId(), teamName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a player's nametag team. Called on quit.
|
||||
*/
|
||||
public void removeNametag(Player player) {
|
||||
String teamName = teamAssignments.remove(player.getUniqueId());
|
||||
if (teamName != null) {
|
||||
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||
Team team = scoreboard.getTeam(teamName);
|
||||
if (team != null) {
|
||||
team.removeEntry(player.getName());
|
||||
if (team.getEntries().isEmpty()) team.unregister();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all NickCore-managed teams from the scoreboard.
|
||||
*/
|
||||
public void clearAllNametags() {
|
||||
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||
for (Team team : scoreboard.getTeams()) {
|
||||
if (team.getName().startsWith(TEAM_PREFIX)) {
|
||||
team.unregister();
|
||||
}
|
||||
}
|
||||
teamAssignments.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.NickHistory;
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.validation.InputSanitizer;
|
||||
import com.nickcore.common.validation.NicknameValidator;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import com.nickcore.paper.repository.NickRepository;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.logging.Logger;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Core service for managing player nicknames.
|
||||
*
|
||||
* <p>Coordinates between the cache, database, validator, and display systems.
|
||||
* All public methods that touch the database return {@link CompletableFuture}
|
||||
* and MUST NOT be called from async contexts that expect synchronous results.</p>
|
||||
*/
|
||||
public final class NickService {
|
||||
|
||||
private final NickRepository repository;
|
||||
private final ProfileCache cache;
|
||||
private final NicknameValidator validator;
|
||||
private final Logger logger;
|
||||
private final MiniMessage miniMessage;
|
||||
|
||||
// Configurable name pool — loaded from names.yml, swapped atomically on reload
|
||||
private volatile List<String> namePool = List.of();
|
||||
|
||||
// Configurable settings
|
||||
private volatile boolean allowDuplicateNicks = false;
|
||||
private volatile boolean allowCustomNames = false;
|
||||
|
||||
public NickService(NickRepository repository, ProfileCache cache,
|
||||
NicknameValidator validator, Logger logger) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.cache = Objects.requireNonNull(cache);
|
||||
this.validator = Objects.requireNonNull(validator);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.miniMessage = MiniMessage.miniMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a player's profile from the database into cache. Called on join.
|
||||
*/
|
||||
public CompletableFuture<NickProfile> loadProfile(UUID uuid) {
|
||||
return repository.findByUuid(uuid).thenApply(opt -> {
|
||||
NickProfile profile = opt.orElse(NickProfile.empty(uuid));
|
||||
cache.put(profile);
|
||||
return profile;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unloads a player's profile from cache. Called on quit.
|
||||
*/
|
||||
public void unloadProfile(UUID uuid) {
|
||||
cache.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the cached profile for an online player.
|
||||
*/
|
||||
public Optional<NickProfile> getProfile(UUID uuid) {
|
||||
return cache.get(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a nickname to a player.
|
||||
*
|
||||
* @param player the player to nick
|
||||
* @param nickname the desired nickname
|
||||
* @param changedBy UUID of the player making the change (null for self)
|
||||
* @return future with the result
|
||||
*/
|
||||
public CompletableFuture<NickResult> applyNickname(Player player, String nickname, UUID changedBy) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
|
||||
// Sanitize input
|
||||
String sanitized = InputSanitizer.sanitize(nickname);
|
||||
|
||||
// Validate
|
||||
NicknameValidator.ValidationResult validation = validator.validate(sanitized);
|
||||
if (!validation.valid()) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure(String.join(", ", validation.violations()))
|
||||
);
|
||||
}
|
||||
|
||||
// Check impersonation (if not bypassed)
|
||||
if (changedBy == null || changedBy.equals(uuid)) {
|
||||
Set<String> onlineNames = Bukkit.getOnlinePlayers().stream()
|
||||
.filter(p -> !p.getUniqueId().equals(uuid))
|
||||
.map(Player::getName)
|
||||
.collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
List<String> similar = validator.findSimilarNames(sanitized, onlineNames, 1);
|
||||
if (!similar.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("Nickname too similar to online player: " + String.join(", ", similar))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check duplicates
|
||||
if (!allowDuplicateNicks && cache.isNicknameInUse(sanitized, uuid)) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("That nickname is already in use")
|
||||
);
|
||||
}
|
||||
|
||||
// Also check database for cross-server duplicates
|
||||
return repository.isNicknameTaken(sanitized, uuid).thenCompose(taken -> {
|
||||
if (taken && !allowDuplicateNicks) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("That nickname is already in use")
|
||||
);
|
||||
}
|
||||
|
||||
NickProfile oldProfile = cache.get(uuid).orElse(NickProfile.empty(uuid));
|
||||
NickProfile newProfile = oldProfile.withNickname(sanitized);
|
||||
|
||||
// Save to DB and cache
|
||||
return repository.save(newProfile).thenCompose(v -> {
|
||||
cache.put(newProfile);
|
||||
|
||||
// Record history
|
||||
NickHistory history = NickHistory.nickChange(
|
||||
uuid, oldProfile.nickname(), sanitized,
|
||||
changedBy, null
|
||||
);
|
||||
return repository.recordHistory(history);
|
||||
}).thenApply(v -> NickResult.success(newProfile));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates and applies a random nickname from the name pool.
|
||||
*/
|
||||
public CompletableFuture<NickResult> applyRandomNickname(Player player) {
|
||||
List<String> pool = this.namePool;
|
||||
if (pool.isEmpty()) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("No names available in the name pool")
|
||||
);
|
||||
}
|
||||
|
||||
// Try up to 10 times to find an unused name
|
||||
for (int attempts = 0; attempts < 10; attempts++) {
|
||||
String randomName = pool.get(ThreadLocalRandom.current().nextInt(pool.size()));
|
||||
if (!cache.isNicknameInUse(randomName, player.getUniqueId())) {
|
||||
return applyNickname(player, randomName, null);
|
||||
}
|
||||
}
|
||||
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("Could not find an available random nickname")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a player's nickname, restoring their real identity.
|
||||
*/
|
||||
public CompletableFuture<NickResult> resetNickname(Player player, UUID changedBy) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
NickProfile oldProfile = cache.get(uuid).orElse(NickProfile.empty(uuid));
|
||||
|
||||
if (!oldProfile.nicked() && oldProfile.rankId() == null) {
|
||||
return CompletableFuture.completedFuture(
|
||||
NickResult.failure("You don't have an active nickname")
|
||||
);
|
||||
}
|
||||
|
||||
NickProfile resetProfile = oldProfile.reset();
|
||||
return repository.save(resetProfile).thenCompose(v -> {
|
||||
cache.put(resetProfile);
|
||||
|
||||
NickHistory history = NickHistory.reset(
|
||||
uuid, oldProfile.nickname(), oldProfile.rankId(), changedBy
|
||||
);
|
||||
return repository.recordHistory(history);
|
||||
}).thenApply(v -> NickResult.success(resetProfile));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the nickname history for a player.
|
||||
*/
|
||||
public CompletableFuture<List<NickHistory>> getHistory(UUID uuid, int limit) {
|
||||
return repository.getHistory(uuid, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the history count for a player.
|
||||
*/
|
||||
public CompletableFuture<Integer> getHistoryCount(UUID uuid) {
|
||||
return repository.getHistoryCount(uuid);
|
||||
}
|
||||
|
||||
// ── Configuration Methods ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reloads the name pool. Thread-safe via volatile reference swap.
|
||||
*/
|
||||
public void setNamePool(List<String> names) {
|
||||
this.namePool = List.copyOf(names);
|
||||
logger.info("Loaded " + names.size() + " names into the name pool");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current name pool (immutable snapshot).
|
||||
*/
|
||||
public List<String> getNamePool() {
|
||||
return namePool;
|
||||
}
|
||||
|
||||
public void setAllowDuplicateNicks(boolean allow) {
|
||||
this.allowDuplicateNicks = allow;
|
||||
}
|
||||
|
||||
public void setAllowCustomNames(boolean allow) {
|
||||
this.allowCustomNames = allow;
|
||||
}
|
||||
|
||||
public boolean isAllowCustomNames() {
|
||||
return allowCustomNames;
|
||||
}
|
||||
|
||||
// ── Result Container ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of a nick operation.
|
||||
*/
|
||||
public record NickResult(boolean success, String message, NickProfile profile) {
|
||||
public static NickResult success(NickProfile profile) {
|
||||
return new NickResult(true, null, profile);
|
||||
}
|
||||
|
||||
public static NickResult failure(String message) {
|
||||
return new NickResult(false, message, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.NickProfile;
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import com.nickcore.paper.cache.ProfileCache;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Fast placeholder resolution service.
|
||||
* Pre-computes placeholder values on state changes, serves from memory cache.
|
||||
* Zero DB hits during placeholder resolution.
|
||||
*/
|
||||
public final class PlaceholderService {
|
||||
|
||||
private final ProfileCache profileCache;
|
||||
private final RankService rankService;
|
||||
private final ConcurrentHashMap<UUID, Map<String, String>> placeholderCache = new ConcurrentHashMap<>(512);
|
||||
|
||||
public PlaceholderService(ProfileCache profileCache, RankService rankService) {
|
||||
this.profileCache = Objects.requireNonNull(profileCache);
|
||||
this.rankService = Objects.requireNonNull(rankService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds all placeholder values for a player. Called on nick/rank/skin change.
|
||||
*/
|
||||
public void invalidate(UUID uuid, String realName) {
|
||||
NickProfile profile = profileCache.get(uuid).orElse(null);
|
||||
if (profile == null) {
|
||||
placeholderCache.remove(uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
RankDefinition rank = profile.rankId() != null
|
||||
? rankService.getRank(profile.rankId()).orElse(null) : null;
|
||||
|
||||
Map<String, String> values = Map.ofEntries(
|
||||
Map.entry("nick", profile.nickname() != null ? profile.nickname() : realName),
|
||||
Map.entry("real_name", realName),
|
||||
Map.entry("rank", rank != null ? rank.displayName() : ""),
|
||||
Map.entry("rank_prefix", rank != null ? rank.prefix() : ""),
|
||||
Map.entry("rank_suffix", rank != null ? rank.suffix() : ""),
|
||||
Map.entry("skin", profile.skinId() != null ? profile.skinId() : ""),
|
||||
Map.entry("is_nicked", String.valueOf(profile.nicked())),
|
||||
Map.entry("display_name", profile.effectiveDisplayName() != null
|
||||
? profile.effectiveDisplayName() : realName),
|
||||
Map.entry("original_name", realName)
|
||||
);
|
||||
|
||||
placeholderCache.put(uuid, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a placeholder for a player. Returns from cache (zero DB hits).
|
||||
*/
|
||||
public String resolve(UUID uuid, String placeholder) {
|
||||
Map<String, String> values = placeholderCache.get(uuid);
|
||||
if (values == null) return "";
|
||||
return values.getOrDefault(placeholder, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all cached placeholders for a player.
|
||||
*/
|
||||
public void remove(UUID uuid) {
|
||||
placeholderCache.remove(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all placeholder caches.
|
||||
*/
|
||||
public void clear() {
|
||||
placeholderCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.RankDefinition;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Service for managing visual rank definitions and player rank assignments.
|
||||
*/
|
||||
public final class RankService {
|
||||
|
||||
private final Logger logger;
|
||||
private final MiniMessage miniMessage;
|
||||
private volatile Map<String, RankDefinition> ranks = Map.of();
|
||||
private volatile List<RankDefinition> sortedRanks = List.of();
|
||||
|
||||
public RankService(Logger logger) {
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.miniMessage = MiniMessage.miniMessage();
|
||||
}
|
||||
|
||||
public void loadRanks(Collection<RankDefinition> definitions) {
|
||||
Map<String, RankDefinition> newRanks = new LinkedHashMap<>();
|
||||
for (RankDefinition rank : definitions) newRanks.put(rank.id(), rank);
|
||||
this.ranks = Map.copyOf(newRanks);
|
||||
this.sortedRanks = definitions.stream()
|
||||
.sorted(Comparator.comparingInt(RankDefinition::weight).reversed()).toList();
|
||||
logger.info("Loaded " + definitions.size() + " rank definitions");
|
||||
}
|
||||
|
||||
public Optional<RankDefinition> getRank(String rankId) {
|
||||
return rankId == null ? Optional.empty() : Optional.ofNullable(ranks.get(rankId));
|
||||
}
|
||||
|
||||
public List<RankDefinition> getSortedRanks() { return sortedRanks; }
|
||||
|
||||
public List<RankDefinition> getAvailableRanks(Player player) {
|
||||
return sortedRanks.stream().filter(r -> player.hasPermission(r.permission())).toList();
|
||||
}
|
||||
|
||||
public boolean hasRankPermission(Player player, String rankId) {
|
||||
return getRank(rankId).map(r -> player.hasPermission(r.permission())).orElse(false);
|
||||
}
|
||||
|
||||
public Component renderPrefix(RankDefinition rank) {
|
||||
if (rank == null || rank.prefix().isEmpty()) return Component.empty();
|
||||
return miniMessage.deserialize(rank.formattedPrefix());
|
||||
}
|
||||
|
||||
public Component renderSuffix(RankDefinition rank) {
|
||||
if (rank == null || rank.suffix().isEmpty()) return Component.empty();
|
||||
return miniMessage.deserialize(rank.formattedSuffix());
|
||||
}
|
||||
|
||||
public Component renderDisplayName(String displayName, RankDefinition rank) {
|
||||
return Component.empty().append(renderPrefix(rank))
|
||||
.append(miniMessage.deserialize(displayName)).append(renderSuffix(rank));
|
||||
}
|
||||
|
||||
public int getRankCount() { return ranks.size(); }
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.nickcore.paper.service;
|
||||
|
||||
import com.nickcore.common.model.SkinData;
|
||||
import com.nickcore.paper.repository.NickRepository;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Skin fetching and caching service.
|
||||
* Multi-tier cache: memory -> database -> Mojang API.
|
||||
* Rate-limited, deduplicating, fully async.
|
||||
*/
|
||||
public final class SkinService {
|
||||
|
||||
private static final String PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=false";
|
||||
private static final String UUID_URL = "https://api.mojang.com/users/profiles/minecraft/%s";
|
||||
private static final Duration CACHE_DURATION = Duration.ofHours(6);
|
||||
|
||||
private final NickRepository repository;
|
||||
private final Plugin plugin;
|
||||
private final Logger logger;
|
||||
private final HttpClient httpClient;
|
||||
|
||||
/** Memory cache for skin data. */
|
||||
private final ConcurrentHashMap<String, SkinData> memoryCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** Deduplication map: prevents duplicate concurrent requests for the same skin. */
|
||||
private final ConcurrentHashMap<String, CompletableFuture<Optional<SkinData>>> inflightRequests = new ConcurrentHashMap<>();
|
||||
|
||||
/** Rate limiter: semaphore limiting concurrent Mojang API requests. */
|
||||
private final Semaphore rateLimiter = new Semaphore(2);
|
||||
|
||||
/** Configured skins loaded from skins.yml. */
|
||||
private volatile Map<String, SkinData> configuredSkins = Map.of();
|
||||
|
||||
public SkinService(NickRepository repository, Plugin plugin, Logger logger) {
|
||||
this.repository = Objects.requireNonNull(repository);
|
||||
this.plugin = Objects.requireNonNull(plugin);
|
||||
this.logger = Objects.requireNonNull(logger);
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configured skins from config. Atomic swap.
|
||||
*/
|
||||
public void loadConfiguredSkins(Map<String, SkinData> skins) {
|
||||
this.configuredSkins = Map.copyOf(skins);
|
||||
logger.info("Loaded " + skins.size() + " configured skins");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all configured skin names.
|
||||
*/
|
||||
public List<String> getConfiguredSkinNames() {
|
||||
return List.copyOf(configuredSkins.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a configured skin by name.
|
||||
*/
|
||||
public Optional<SkinData> getConfiguredSkin(String name) {
|
||||
return Optional.ofNullable(configuredSkins.get(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches skin data with multi-tier caching and deduplication.
|
||||
* Memory -> DB cache -> Mojang API.
|
||||
*/
|
||||
public CompletableFuture<Optional<SkinData>> fetchSkin(String playerName) {
|
||||
String key = playerName.toLowerCase();
|
||||
|
||||
// Tier 1: Memory cache
|
||||
SkinData cached = memoryCache.get(key);
|
||||
if (cached != null && !cached.isExpired()) {
|
||||
return CompletableFuture.completedFuture(Optional.of(cached));
|
||||
}
|
||||
|
||||
// Deduplicate: if there's already a request in flight, return same future
|
||||
return inflightRequests.computeIfAbsent(key, k -> fetchSkinInternal(k, playerName)
|
||||
.whenComplete((result, ex) -> inflightRequests.remove(k)));
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<SkinData>> fetchSkinInternal(String key, String playerName) {
|
||||
// Tier 2: DB cache
|
||||
return repository.getCachedSkin(key).thenCompose(dbCached -> {
|
||||
if (dbCached.isPresent() && !dbCached.get().isExpired()) {
|
||||
memoryCache.put(key, dbCached.get());
|
||||
return CompletableFuture.completedFuture(dbCached);
|
||||
}
|
||||
|
||||
// Tier 3: Mojang API
|
||||
return fetchFromMojang(playerName).thenApply(skinOpt -> {
|
||||
skinOpt.ifPresent(skin -> {
|
||||
memoryCache.put(key, skin);
|
||||
repository.cacheSkin(skin); // fire-and-forget
|
||||
});
|
||||
return skinOpt;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<SkinData>> fetchFromMojang(String playerName) {
|
||||
return CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
rateLimiter.acquire();
|
||||
try {
|
||||
// Step 1: Get UUID
|
||||
String uuidUrl = String.format(UUID_URL, playerName);
|
||||
HttpResponse<String> uuidResp = httpClient.send(
|
||||
HttpRequest.newBuilder(URI.create(uuidUrl)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (uuidResp.statusCode() != 200) return Optional.<SkinData>empty();
|
||||
|
||||
String uuid = parseJsonField(uuidResp.body(), "id");
|
||||
if (uuid == null) return Optional.<SkinData>empty();
|
||||
|
||||
// Step 2: Get profile with textures
|
||||
String profileUrl = String.format(PROFILE_URL, uuid);
|
||||
HttpResponse<String> profileResp = httpClient.send(
|
||||
HttpRequest.newBuilder(URI.create(profileUrl)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (profileResp.statusCode() != 200) return Optional.<SkinData>empty();
|
||||
|
||||
String value = parseTextureValue(profileResp.body());
|
||||
String signature = parseTextureSignature(profileResp.body());
|
||||
|
||||
if (value == null || signature == null) return Optional.<SkinData>empty();
|
||||
|
||||
Instant now = Instant.now();
|
||||
return Optional.of(new SkinData(
|
||||
playerName.toLowerCase(), value, signature,
|
||||
now, now.plus(CACHE_DURATION)));
|
||||
} finally {
|
||||
rateLimiter.release();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.log(Level.WARNING, "Failed to fetch skin for " + playerName, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a skin to a player by cycling visibility.
|
||||
* Must be called from the appropriate scheduler context.
|
||||
*/
|
||||
public void applySkin(Player player, SkinData skin) {
|
||||
if (skin == null || !player.isOnline()) return;
|
||||
|
||||
// Apply skin via player profile
|
||||
var profile = player.getPlayerProfile();
|
||||
var textures = profile.getProperties();
|
||||
textures.removeIf(p -> p.getName().equals("textures"));
|
||||
textures.add(new com.destroystokyo.paper.profile.ProfileProperty(
|
||||
"textures", skin.textureValue(), skin.textureSignature()));
|
||||
player.setPlayerProfile(profile);
|
||||
|
||||
// Refresh visibility for all online players
|
||||
for (Player other : Bukkit.getOnlinePlayers()) {
|
||||
if (other.equals(player)) continue;
|
||||
if (other.canSee(player)) {
|
||||
other.hidePlayer(plugin, player);
|
||||
other.showPlayer(plugin, player);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the memory cache for a specific skin.
|
||||
*/
|
||||
public void invalidateCache(String skinName) {
|
||||
memoryCache.remove(skinName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all memory caches.
|
||||
*/
|
||||
public void clearCache() {
|
||||
memoryCache.clear();
|
||||
}
|
||||
|
||||
// Simple JSON field parsers (avoids GSON dependency for minimal fields)
|
||||
private static String parseJsonField(String json, String field) {
|
||||
String key = "\"" + field + "\"";
|
||||
int idx = json.indexOf(key);
|
||||
if (idx < 0) return null;
|
||||
int valStart = json.indexOf("\"", idx + key.length() + 1);
|
||||
if (valStart < 0) return null;
|
||||
int valEnd = json.indexOf("\"", valStart + 1);
|
||||
if (valEnd < 0) return null;
|
||||
return json.substring(valStart + 1, valEnd);
|
||||
}
|
||||
|
||||
private static String parseTextureValue(String json) {
|
||||
int propIdx = json.indexOf("\"properties\"");
|
||||
if (propIdx < 0) return null;
|
||||
String sub = json.substring(propIdx);
|
||||
return parseJsonField(sub, "value");
|
||||
}
|
||||
|
||||
private static String parseTextureSignature(String json) {
|
||||
int propIdx = json.indexOf("\"properties\"");
|
||||
if (propIdx < 0) return null;
|
||||
String sub = json.substring(propIdx);
|
||||
return parseJsonField(sub, "signature");
|
||||
}
|
||||
}
|
||||
61
nickcore-paper/src/main/resources/config.yml
Normal file
61
nickcore-paper/src/main/resources/config.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Configuration ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
# Plugin prefix (MiniMessage format)
|
||||
prefix: "<gradient:#00BFFF:#1E90FF>[NickCore]</gradient> "
|
||||
|
||||
# Language/locale
|
||||
locale: en_US
|
||||
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
# Allow players with nickcore.name.custom to enter any nickname
|
||||
allow-custom-names: false
|
||||
|
||||
# Allow multiple players to use the same nickname
|
||||
allow-duplicate-nicks: false
|
||||
|
||||
# Nickname length limits
|
||||
min-nick-length: 3
|
||||
max-nick-length: 16
|
||||
|
||||
# ── Chat Configuration ───────────────────────────────────────
|
||||
chat:
|
||||
# Enable chat formatting (disable if using another chat plugin)
|
||||
enabled: true
|
||||
# Chat format (MiniMessage)
|
||||
# Placeholders: <prefix>, <display_name>, <suffix>, <message>
|
||||
format: "<prefix><display_name><suffix><gray>: </gray><message>"
|
||||
|
||||
# ── Security Configuration ───────────────────────────────────
|
||||
security:
|
||||
# Regex for allowed nickname characters
|
||||
allowed-characters: "^[a-zA-Z0-9_]+$"
|
||||
# Block Unicode homoglyphs (Cyrillic/Greek lookalikes)
|
||||
block-homoglyphs: true
|
||||
# Block invisible/zero-width characters
|
||||
block-invisible-chars: true
|
||||
# Prevent names similar to online players
|
||||
prevent-impersonation: true
|
||||
# Levenshtein distance threshold for impersonation detection
|
||||
impersonation-threshold: 2
|
||||
# Names that cannot be used as nicknames
|
||||
reserved-names:
|
||||
- Server
|
||||
- Console
|
||||
- Admin
|
||||
- Moderator
|
||||
- Owner
|
||||
- Staff
|
||||
- Helper
|
||||
# Real player names that are protected from impersonation
|
||||
protected-names: []
|
||||
|
||||
# ── Velocity Configuration ───────────────────────────────────
|
||||
velocity:
|
||||
# Enable Velocity plugin messaging
|
||||
enabled: false
|
||||
# This server's name (must match Velocity config)
|
||||
server-name: "lobby"
|
||||
24
nickcore-paper/src/main/resources/database.yml
Normal file
24
nickcore-paper/src/main/resources/database.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Database Configuration ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
|
||||
# Database type: sqlite, mysql, or mariadb
|
||||
type: sqlite
|
||||
|
||||
# MySQL/MariaDB connection settings (ignored for SQLite)
|
||||
host: localhost
|
||||
port: 3306
|
||||
database: nickcore
|
||||
username: root
|
||||
password: ""
|
||||
|
||||
# Table prefix
|
||||
table-prefix: "nickcore_"
|
||||
|
||||
# Connection pool settings
|
||||
pool:
|
||||
max-size: 10
|
||||
min-idle: 2
|
||||
connection-timeout: 5000 # ms
|
||||
idle-timeout: 300000 # ms (5 minutes)
|
||||
max-lifetime: 600000 # ms (10 minutes)
|
||||
53
nickcore-paper/src/main/resources/messages.yml
Normal file
53
nickcore-paper/src/main/resources/messages.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Messages ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
# All messages use MiniMessage format.
|
||||
# See: https://docs.advntr.dev/minimessage/format.html
|
||||
|
||||
messages:
|
||||
# General
|
||||
no-permission: "<red>You don't have permission to do that."
|
||||
player-only: "<red>Only players can use this command."
|
||||
player-not-found: "<red>Player not found."
|
||||
reload-success: "<green>Configuration reloaded successfully."
|
||||
reload-failed: "<red>Failed to reload configuration: <message>"
|
||||
|
||||
# Nickname
|
||||
nick-set: "<green>Nickname set to <white><nickname>"
|
||||
nick-reset: "<green>Your identity has been restored."
|
||||
nick-already-active: "<red>You already have that nickname."
|
||||
nick-taken: "<red>That nickname is already in use."
|
||||
nick-invalid: "<red>Invalid nickname: <reason>"
|
||||
nick-similar: "<red>Nickname too similar to online player: <names>"
|
||||
nick-applying: "<gray>Applying nickname..."
|
||||
|
||||
# Random
|
||||
random-success: "<green>Random nickname: <white><nickname>"
|
||||
random-failed: "<red>Could not find an available random nickname."
|
||||
random-no-pool: "<red>No names available in the name pool."
|
||||
|
||||
# Rank
|
||||
rank-set: "<green>Rank set to <rank>"
|
||||
rank-no-permission: "<red>You don't have permission for that rank."
|
||||
rank-not-found: "<red>Rank not found."
|
||||
|
||||
# Skin
|
||||
skin-applying: "<gray>Applying skin..."
|
||||
skin-applied: "<green>Skin applied!"
|
||||
skin-not-found: "<red>Skin not found."
|
||||
skin-failed: "<red>Failed to apply skin."
|
||||
|
||||
# Admin
|
||||
admin-force-success: "<green>Forced nickname on <player>."
|
||||
admin-force-target: "<yellow>An admin set your nickname to <white><nickname>"
|
||||
admin-clear-success: "<green>Cleared nickname for <player>."
|
||||
admin-clear-target: "<yellow>Your nickname has been cleared by an admin."
|
||||
|
||||
# Info
|
||||
info-header: "<gold>===== Player Info ====="
|
||||
info-real-name: "<yellow>Real Name: <white><name>"
|
||||
info-nickname: "<yellow>Nickname: <white><nickname>"
|
||||
info-rank: "<yellow>Rank: <white><rank>"
|
||||
info-skin: "<yellow>Skin: <white><skin>"
|
||||
info-nicked: "<yellow>Nicked: <white><status>"
|
||||
info-no-nick: "<gray>No active nickname."
|
||||
57
nickcore-paper/src/main/resources/names.yml
Normal file
57
nickcore-paper/src/main/resources/names.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Name Pool ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
# Approved nicknames for random selection and the nickname menu.
|
||||
# Players without nickcore.name.custom must choose from this list.
|
||||
|
||||
names:
|
||||
- ShadowWolf
|
||||
- FrostByte
|
||||
- CrimsonKnight
|
||||
- Eclipse
|
||||
- Phantom
|
||||
- NightShade
|
||||
- StormBringer
|
||||
- IronVeil
|
||||
- LunarFox
|
||||
- EmberStrike
|
||||
- ArcticBlaze
|
||||
- SilverHawk
|
||||
- ThunderVolt
|
||||
- MysticRune
|
||||
- ObsidianEdge
|
||||
- CelestialWing
|
||||
- VenomFang
|
||||
- GhostReaper
|
||||
- BlazingComet
|
||||
- DarkMatter
|
||||
- AuroraHunter
|
||||
- CrystalForge
|
||||
- SteelNova
|
||||
- VoidWalker
|
||||
- TitanFist
|
||||
- PhoenixRise
|
||||
- NeonDrift
|
||||
- ShadowMerge
|
||||
- FrostFire
|
||||
- StormClaw
|
||||
- IronPulse
|
||||
- MoonStriker
|
||||
- ThunderWing
|
||||
- CrimsonVeil
|
||||
- ArcticHunter
|
||||
- EmberShade
|
||||
- NightProwler
|
||||
- SilverStorm
|
||||
- VenomTide
|
||||
- BlazeShard
|
||||
- DarkVoyager
|
||||
- CelestialDawn
|
||||
- GhostFlame
|
||||
- CrystalBlade
|
||||
- SteelWraith
|
||||
- VoidForge
|
||||
- TitanEdge
|
||||
- PhoenixStar
|
||||
- NeonBlaze
|
||||
- ShadowBorne
|
||||
64
nickcore-paper/src/main/resources/plugin.yml
Normal file
64
nickcore-paper/src/main/resources/plugin.yml
Normal file
@@ -0,0 +1,64 @@
|
||||
name: NickCore
|
||||
version: '1.0.0'
|
||||
main: com.nickcore.paper.NickCorePlugin
|
||||
api-version: '1.21'
|
||||
description: Enterprise Minecraft Nickname Plugin
|
||||
author: NickCore Team
|
||||
website: https://nickcore.com
|
||||
folia-supported: true
|
||||
|
||||
softdepend:
|
||||
- PlaceholderAPI
|
||||
- LuckPerms
|
||||
- TAB
|
||||
- Essentials
|
||||
|
||||
commands:
|
||||
nick:
|
||||
description: NickCore main command
|
||||
usage: /nick [subcommand]
|
||||
aliases: [nickname, disguise]
|
||||
|
||||
permissions:
|
||||
nickcore.use:
|
||||
description: Access /nick command and GUI
|
||||
default: true
|
||||
nickcore.name.custom:
|
||||
description: Enter custom nicknames
|
||||
default: op
|
||||
nickcore.name.select:
|
||||
description: Select from approved name list
|
||||
default: true
|
||||
nickcore.name.random:
|
||||
description: Generate random nicknames
|
||||
default: true
|
||||
nickcore.rank.select:
|
||||
description: Access rank selection
|
||||
default: false
|
||||
nickcore.skin.select:
|
||||
description: Access skin selection
|
||||
default: false
|
||||
nickcore.skin.keep:
|
||||
description: Keep current skin option
|
||||
default: true
|
||||
nickcore.reset:
|
||||
description: Reset identity
|
||||
default: true
|
||||
nickcore.admin:
|
||||
description: Admin features
|
||||
default: op
|
||||
nickcore.reload:
|
||||
description: Reload configuration
|
||||
default: op
|
||||
nickcore.bypass:
|
||||
description: Bypass restrictions
|
||||
default: op
|
||||
nickcore.force:
|
||||
description: Force nicknames on players
|
||||
default: op
|
||||
nickcore.info:
|
||||
description: View player nick info
|
||||
default: op
|
||||
nickcore.clear:
|
||||
description: Clear player nicknames
|
||||
default: op
|
||||
78
nickcore-paper/src/main/resources/ranks.yml
Normal file
78
nickcore-paper/src/main/resources/ranks.yml
Normal file
@@ -0,0 +1,78 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Ranks ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
# Visual ranks for the nickname system.
|
||||
# MiniMessage formatting is supported for prefix/suffix.
|
||||
|
||||
ranks:
|
||||
vip:
|
||||
display-name: "VIP"
|
||||
prefix: "<gold>[VIP]</gold>"
|
||||
suffix: ""
|
||||
weight: 10
|
||||
priority: 10
|
||||
color: "gold"
|
||||
permission: "nickcore.rank.vip"
|
||||
|
||||
mvp:
|
||||
display-name: "MVP"
|
||||
prefix: "<aqua>[MVP]</aqua>"
|
||||
suffix: ""
|
||||
weight: 20
|
||||
priority: 20
|
||||
color: "aqua"
|
||||
permission: "nickcore.rank.mvp"
|
||||
|
||||
mvpplus:
|
||||
display-name: "MVP+"
|
||||
prefix: "<light_purple>[MVP+]</light_purple>"
|
||||
suffix: ""
|
||||
weight: 30
|
||||
priority: 30
|
||||
color: "light_purple"
|
||||
permission: "nickcore.rank.mvpplus"
|
||||
|
||||
elite:
|
||||
display-name: "Elite"
|
||||
prefix: "<gradient:#FFD700:#FFA500>[Elite]</gradient>"
|
||||
suffix: ""
|
||||
weight: 40
|
||||
priority: 40
|
||||
color: "yellow"
|
||||
permission: "nickcore.rank.elite"
|
||||
|
||||
legend:
|
||||
display-name: "Legend"
|
||||
prefix: "<gradient:#FF6B6B:#EE5A24>[Legend]</gradient>"
|
||||
suffix: ""
|
||||
weight: 50
|
||||
priority: 50
|
||||
color: "red"
|
||||
permission: "nickcore.rank.legend"
|
||||
|
||||
builder:
|
||||
display-name: "Builder"
|
||||
prefix: "<green>[Builder]</green>"
|
||||
suffix: ""
|
||||
weight: 60
|
||||
priority: 60
|
||||
color: "green"
|
||||
permission: "nickcore.rank.builder"
|
||||
|
||||
moderator:
|
||||
display-name: "Moderator"
|
||||
prefix: "<dark_green>[Mod]</dark_green>"
|
||||
suffix: ""
|
||||
weight: 70
|
||||
priority: 70
|
||||
color: "dark_green"
|
||||
permission: "nickcore.rank.moderator"
|
||||
|
||||
admin:
|
||||
display-name: "Admin"
|
||||
prefix: "<dark_red>[Admin]</dark_red>"
|
||||
suffix: ""
|
||||
weight: 80
|
||||
priority: 80
|
||||
color: "dark_red"
|
||||
permission: "nickcore.rank.admin"
|
||||
14
nickcore-paper/src/main/resources/skins.yml
Normal file
14
nickcore-paper/src/main/resources/skins.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
# ╔═══════════════════════════════════════════════════════════╗
|
||||
# ║ NickCore Skins ║
|
||||
# ╚═══════════════════════════════════════════════════════════╝
|
||||
# Pre-configured skins. Provide Mojang texture value and signature.
|
||||
# You can get these from https://mineskin.org
|
||||
|
||||
skins:
|
||||
# Example skin — replace with real texture data
|
||||
# steve:
|
||||
# value: "eyJ0aW1lc3RhbXAiOjE2..."
|
||||
# signature: "dGVzdHNpZ25hdHVyZQ..."
|
||||
# alex:
|
||||
# value: "eyJ0aW1lc3RhbXAiOjE2..."
|
||||
# signature: "dGVzdHNpZ25hdHVyZQ..."
|
||||
Reference in New Issue
Block a user