feat: NickCore v1.0.0 - enterprise nickname plugin for Paper/Folia/Velocity

This commit is contained in:
2026-05-30 21:05:02 -05:00
parent 8a42740875
commit f0b35e89ac
54 changed files with 5393 additions and 0 deletions

View File

@@ -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;
}
}
}

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}
});
});
}
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
};
}
}

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
});
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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(); }
}

View File

@@ -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");
}
}

View 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"

View 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)

View 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."

View 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

View 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

View 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"

View 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..."