Compare commits
3 Commits
8a42740875
...
v1.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 062d398d71 | |||
| 0521ee688d | |||
| f0b35e89ac |
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ── Build outputs ────────────────────────────────────────────
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# ── Gradle ───────────────────────────────────────────────────
|
||||||
|
.gradle/
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!gradle/wrapper/gradle-wrapper.properties
|
||||||
|
|
||||||
|
# ── IDE ──────────────────────────────────────────────────────
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ── OS ───────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ── Java ─────────────────────────────────────────────────────
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
|
||||||
|
# ── Database (runtime-generated) ─────────────────────────────
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
||||||
|
# ── Logs ─────────────────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
149
README.MD
149
README.MD
@@ -0,0 +1,149 @@
|
|||||||
|
# NickCore
|
||||||
|
|
||||||
|
**Enterprise Minecraft Nickname Plugin** for Paper 1.21.11, Folia 1.21.11, and Velocity 3.5.0.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Nickname System** — Approved names, random names, custom names (permission-gated)
|
||||||
|
- **Rank System** — Configurable visual ranks with MiniMessage prefixes/suffixes
|
||||||
|
- **Skin System** — Multi-tier cache (memory → DB → Mojang API), rate-limited
|
||||||
|
- **GUI System** — Paginated inventory menus with click cooldowns
|
||||||
|
- **PlaceholderAPI** — 14 placeholders served from in-memory cache
|
||||||
|
- **Velocity Sync** — Cross-server nickname/rank/skin synchronization
|
||||||
|
- **Folia Support** — Region/Entity/Global scheduler compliance
|
||||||
|
- **Security** — Homoglyph detection, impersonation prevention, input sanitization
|
||||||
|
- **Database** — SQLite/MySQL/MariaDB with HikariCP pooling and schema migration
|
||||||
|
- **Audit Logging** — Full history of all nickname changes
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
nickcore/
|
||||||
|
├── nickcore-common/ # Shared models, DTOs, validation
|
||||||
|
├── nickcore-paper/ # Paper + Folia plugin (single JAR)
|
||||||
|
└── nickcore-velocity/ # Velocity proxy plugin
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts:
|
||||||
|
- `nickcore-paper/build/libs/NickCore-Paper-1.0.0.jar`
|
||||||
|
- `nickcore-velocity/build/libs/NickCore-Velocity-1.0.0.jar`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Java 25+
|
||||||
|
- Paper 1.21.11 or Folia 1.21.11
|
||||||
|
- Velocity 3.5.0 (optional, for multi-server)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Place `NickCore-Paper-1.0.0.jar` in your server's `plugins/` folder
|
||||||
|
2. (Optional) Place `NickCore-Velocity-1.0.0.jar` in your Velocity proxy's `plugins/` folder
|
||||||
|
3. Start the server to generate configuration files
|
||||||
|
4. Edit configuration files in `plugins/NickCore/`
|
||||||
|
5. Run `/nick reload` to apply changes
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Permission | Description |
|
||||||
|
|---------|-----------|-------------|
|
||||||
|
| `/nick` | `nickcore.use` | Open the main GUI |
|
||||||
|
| `/nick <name>` | `nickcore.name.custom` | Set custom nickname |
|
||||||
|
| `/nick random` | `nickcore.name.random` | Generate random nickname |
|
||||||
|
| `/nick reset` | `nickcore.reset` | Reset to real identity |
|
||||||
|
| `/nick rank [id]` | `nickcore.rank.select` | Select visual rank |
|
||||||
|
| `/nick skin [name]` | `nickcore.skin.select` | Change skin |
|
||||||
|
| `/nick gui` | `nickcore.use` | Open GUI explicitly |
|
||||||
|
| `/nick reload` | `nickcore.reload` | Reload configuration |
|
||||||
|
| `/nick info <player>` | `nickcore.info` | View player nick info |
|
||||||
|
| `/nick force <player> <nick>` | `nickcore.force` | Force nickname on player |
|
||||||
|
| `/nick clear <player>` | `nickcore.clear` | Clear player's nickname |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Default | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `nickcore.use` | `true` | Access `/nick` |
|
||||||
|
| `nickcore.name.custom` | `op` | Enter custom nicknames |
|
||||||
|
| `nickcore.name.select` | `true` | Select from approved list |
|
||||||
|
| `nickcore.name.random` | `true` | Generate random nicknames |
|
||||||
|
| `nickcore.rank.select` | `false` | Access rank selection |
|
||||||
|
| `nickcore.rank.<id>` | `false` | Per-rank permissions |
|
||||||
|
| `nickcore.skin.select` | `false` | Access skin selection |
|
||||||
|
| `nickcore.skin.keep` | `true` | Keep current skin option |
|
||||||
|
| `nickcore.reset` | `true` | Reset identity |
|
||||||
|
| `nickcore.admin` | `op` | Admin features |
|
||||||
|
| `nickcore.reload` | `op` | Reload configuration |
|
||||||
|
| `nickcore.bypass` | `op` | Bypass restrictions |
|
||||||
|
| `nickcore.force` | `op` | Force nicknames |
|
||||||
|
| `nickcore.info` | `op` | View player info |
|
||||||
|
| `nickcore.clear` | `op` | Clear nicknames |
|
||||||
|
|
||||||
|
## PlaceholderAPI
|
||||||
|
|
||||||
|
| Placeholder | Description |
|
||||||
|
|------------|-------------|
|
||||||
|
| `%nickcore_nick%` | Current nickname |
|
||||||
|
| `%nickcore_real_name%` | Real player name |
|
||||||
|
| `%nickcore_rank%` | Visual rank name |
|
||||||
|
| `%nickcore_rank_prefix%` | Rank prefix |
|
||||||
|
| `%nickcore_rank_suffix%` | Rank suffix |
|
||||||
|
| `%nickcore_skin%` | Applied skin name |
|
||||||
|
| `%nickcore_is_nicked%` | Whether player is nicked |
|
||||||
|
| `%nickcore_display_name%` | Effective display name |
|
||||||
|
| `%nickcore_original_name%` | Original player name |
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `config.yml` | General settings, chat format, security |
|
||||||
|
| `database.yml` | Database connection and pooling |
|
||||||
|
| `messages.yml` | Player-facing messages (MiniMessage) |
|
||||||
|
| `ranks.yml` | Rank definitions |
|
||||||
|
| `skins.yml` | Pre-configured skins |
|
||||||
|
| `names.yml` | Approved nickname pool |
|
||||||
|
|
||||||
|
## Integrations
|
||||||
|
|
||||||
|
- **PlaceholderAPI** — Soft dependency, 14 placeholders
|
||||||
|
- **LuckPerms** — Permission-based rank filtering
|
||||||
|
- **TAB** — Tablist prefix/suffix updates
|
||||||
|
- **Velocity** — Cross-server synchronization
|
||||||
|
- **EssentialsX** — Compatible display names
|
||||||
|
- **HuskChat** — Compatible via PlaceholderAPI
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Unicode homoglyph detection
|
||||||
|
- Invisible/zero-width character blocking
|
||||||
|
- Levenshtein-based impersonation prevention
|
||||||
|
- MiniMessage injection sanitization
|
||||||
|
- SQL injection prevention (prepared statements)
|
||||||
|
- Velocity packet validation with protocol versioning
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- ConcurrentHashMap-based caches (lock-free reads)
|
||||||
|
- Pre-computed placeholder values (zero DB hits during resolution)
|
||||||
|
- Deduplicating skin fetcher (one request per skin)
|
||||||
|
- Rate-limited Mojang API access
|
||||||
|
- HikariCP connection pooling
|
||||||
|
- Virtual threads for database operations
|
||||||
|
- ViewerUnaware chat renderer (single render per message)
|
||||||
|
- Scoreboard team caching (no redundant updates)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|||||||
49
build.gradle.kts
Normal file
49
build.gradle.kts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
java
|
||||||
|
id("com.gradleup.shadow") version "9.4.1" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
val projectVersion: String by project
|
||||||
|
val projectGroup: String by project
|
||||||
|
val javaVersion: String by project
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
group = projectGroup
|
||||||
|
version = projectVersion
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven("https://repo.papermc.io/repository/maven-public/")
|
||||||
|
maven("https://repo.extendedclip.com/releases/") // PlaceholderAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subprojects {
|
||||||
|
apply(plugin = "java")
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(javaVersion.toInt()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.release.set(javaVersion.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val junitVersion: String by project
|
||||||
|
val mockitoVersion: String by project
|
||||||
|
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitVersion")
|
||||||
|
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junitVersion")
|
||||||
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
|
testImplementation("org.mockito:mockito-core:$mockitoVersion")
|
||||||
|
testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion")
|
||||||
|
}
|
||||||
|
}
|
||||||
30
gradle.properties
Normal file
30
gradle.properties
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Project
|
||||||
|
projectVersion=1.0.4
|
||||||
|
projectGroup=com.nickcore
|
||||||
|
|
||||||
|
# Minecraft / Paper
|
||||||
|
paperApiVersion=1.21.11-R0.1-SNAPSHOT
|
||||||
|
minecraftVersion=1.21.11
|
||||||
|
|
||||||
|
# Velocity
|
||||||
|
velocityApiVersion=3.5.0-SNAPSHOT
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
hikariVersion=7.0.2
|
||||||
|
luckpermsVersion=5.5
|
||||||
|
placeholderApiVersion=2.11.6
|
||||||
|
adventureVersion=4.17.0
|
||||||
|
minimessageVersion=4.17.0
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
junitVersion=5.11.0
|
||||||
|
mockitoVersion=5.14.0
|
||||||
|
|
||||||
|
# Build
|
||||||
|
shadowVersion=8.1.1
|
||||||
|
javaVersion=25
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
77
gradlew.bat
vendored
Normal file
77
gradlew.bat
vendored
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem Licensed under the Apache License, Version 2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %OS%==Windows_NT endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
6
nickcore-common/build.gradle.kts
Normal file
6
nickcore-common/build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
dependencies {
|
||||||
|
val adventureVersion: String by project
|
||||||
|
|
||||||
|
compileOnly("net.kyori:adventure-api:$adventureVersion")
|
||||||
|
compileOnly("net.kyori:adventure-text-minimessage:$adventureVersion")
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.nickcore.common.config;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared configuration model for settings used across both Paper and Velocity modules.
|
||||||
|
*
|
||||||
|
* <p>Immutable record — loaded once from YAML and swapped atomically on reload.</p>
|
||||||
|
*/
|
||||||
|
public record NickCoreConfig(
|
||||||
|
GeneralSettings general,
|
||||||
|
DatabaseSettings database,
|
||||||
|
SecuritySettings security,
|
||||||
|
VelocitySettings velocity
|
||||||
|
) {
|
||||||
|
public record GeneralSettings(
|
||||||
|
String prefix,
|
||||||
|
String locale,
|
||||||
|
boolean debug,
|
||||||
|
boolean allowCustomNames,
|
||||||
|
boolean allowDuplicateNicks,
|
||||||
|
int maxNickLength,
|
||||||
|
int minNickLength
|
||||||
|
) {
|
||||||
|
public GeneralSettings {
|
||||||
|
if (prefix == null) prefix = "<gradient:#00BFFF:#1E90FF>[NickCore]</gradient>";
|
||||||
|
if (locale == null) locale = "en_US";
|
||||||
|
if (maxNickLength <= 0) maxNickLength = 16;
|
||||||
|
if (minNickLength <= 0) minNickLength = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record DatabaseSettings(
|
||||||
|
String type,
|
||||||
|
String host,
|
||||||
|
int port,
|
||||||
|
String database,
|
||||||
|
String username,
|
||||||
|
String password,
|
||||||
|
int maxPoolSize,
|
||||||
|
int minIdle,
|
||||||
|
long connectionTimeoutMs,
|
||||||
|
long idleTimeoutMs,
|
||||||
|
long maxLifetimeMs,
|
||||||
|
String tablePrefix
|
||||||
|
) {
|
||||||
|
public DatabaseSettings {
|
||||||
|
if (type == null) type = "sqlite";
|
||||||
|
if (host == null) host = "localhost";
|
||||||
|
if (port <= 0) port = 3306;
|
||||||
|
if (database == null) database = "nickcore";
|
||||||
|
if (username == null) username = "root";
|
||||||
|
if (password == null) password = "";
|
||||||
|
if (maxPoolSize <= 0) maxPoolSize = 10;
|
||||||
|
if (minIdle <= 0) minIdle = 2;
|
||||||
|
if (connectionTimeoutMs <= 0) connectionTimeoutMs = 5000;
|
||||||
|
if (idleTimeoutMs <= 0) idleTimeoutMs = 300000;
|
||||||
|
if (maxLifetimeMs <= 0) maxLifetimeMs = 600000;
|
||||||
|
if (tablePrefix == null) tablePrefix = "nickcore_";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SecuritySettings(
|
||||||
|
String allowedCharactersRegex,
|
||||||
|
boolean blockHomoglyphs,
|
||||||
|
boolean blockInvisibleChars,
|
||||||
|
boolean preventImpersonation,
|
||||||
|
int impersonationThreshold,
|
||||||
|
List<String> reservedNames,
|
||||||
|
List<String> protectedNames
|
||||||
|
) {
|
||||||
|
public SecuritySettings {
|
||||||
|
if (allowedCharactersRegex == null) allowedCharactersRegex = "^[a-zA-Z0-9_]+$";
|
||||||
|
if (impersonationThreshold <= 0) impersonationThreshold = 2;
|
||||||
|
if (reservedNames == null) reservedNames = List.of("Server", "Console", "Admin", "Moderator", "Owner");
|
||||||
|
if (protectedNames == null) protectedNames = List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record VelocitySettings(
|
||||||
|
boolean enabled,
|
||||||
|
String serverName
|
||||||
|
) {
|
||||||
|
public VelocitySettings {
|
||||||
|
if (serverName == null) serverName = "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package com.nickcore.common.dto;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataInputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network transfer object for synchronizing nick state across Velocity.
|
||||||
|
*
|
||||||
|
* <p>Supports serialization to/from byte arrays for plugin messaging channels.
|
||||||
|
* Includes a protocol version header for forward compatibility.</p>
|
||||||
|
*
|
||||||
|
* <p>Immutable and thread-safe.</p>
|
||||||
|
*/
|
||||||
|
public record NickSyncMessage(
|
||||||
|
int protocolVersion,
|
||||||
|
Action action,
|
||||||
|
UUID playerUuid,
|
||||||
|
String nickname,
|
||||||
|
String rankId,
|
||||||
|
String skinId,
|
||||||
|
String skinValue,
|
||||||
|
String skinSignature,
|
||||||
|
String sourceServer
|
||||||
|
) {
|
||||||
|
/** Current protocol version for encoding/decoding. */
|
||||||
|
public static final int CURRENT_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/** Plugin messaging channel identifier. */
|
||||||
|
public static final String CHANNEL = "nickcore:sync";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actions that can be synchronized across the network.
|
||||||
|
*/
|
||||||
|
public enum Action {
|
||||||
|
NICK_UPDATE,
|
||||||
|
RANK_UPDATE,
|
||||||
|
SKIN_UPDATE,
|
||||||
|
FULL_SYNC,
|
||||||
|
RESET,
|
||||||
|
CACHE_INVALIDATE,
|
||||||
|
PLAYER_JOIN,
|
||||||
|
PLAYER_QUIT
|
||||||
|
}
|
||||||
|
|
||||||
|
public NickSyncMessage {
|
||||||
|
Objects.requireNonNull(action, "action must not be null");
|
||||||
|
Objects.requireNonNull(playerUuid, "playerUuid must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sync message from a NickProfile.
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage fullSync(NickProfile profile, String sourceServer) {
|
||||||
|
return new NickSyncMessage(
|
||||||
|
CURRENT_PROTOCOL_VERSION,
|
||||||
|
Action.FULL_SYNC,
|
||||||
|
profile.uuid(),
|
||||||
|
profile.nickname(),
|
||||||
|
profile.rankId(),
|
||||||
|
profile.skinId(),
|
||||||
|
profile.skinValue(),
|
||||||
|
profile.skinSignature(),
|
||||||
|
sourceServer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a nick update message.
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage nickUpdate(UUID uuid, String nickname, String sourceServer) {
|
||||||
|
return new NickSyncMessage(
|
||||||
|
CURRENT_PROTOCOL_VERSION, Action.NICK_UPDATE, uuid,
|
||||||
|
nickname, null, null, null, null, sourceServer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a rank update message.
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage rankUpdate(UUID uuid, String rankId, String sourceServer) {
|
||||||
|
return new NickSyncMessage(
|
||||||
|
CURRENT_PROTOCOL_VERSION, Action.RANK_UPDATE, uuid,
|
||||||
|
null, rankId, null, null, null, sourceServer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reset message.
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage reset(UUID uuid, String sourceServer) {
|
||||||
|
return new NickSyncMessage(
|
||||||
|
CURRENT_PROTOCOL_VERSION, Action.RESET, uuid,
|
||||||
|
null, null, null, null, null, sourceServer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a cache invalidation message.
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage cacheInvalidate(UUID uuid, String sourceServer) {
|
||||||
|
return new NickSyncMessage(
|
||||||
|
CURRENT_PROTOCOL_VERSION, Action.CACHE_INVALIDATE, uuid,
|
||||||
|
null, null, null, null, null, sourceServer
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes this message to a byte array for plugin messaging.
|
||||||
|
*/
|
||||||
|
public byte[] encode() {
|
||||||
|
try (var baos = new ByteArrayOutputStream();
|
||||||
|
var dos = new DataOutputStream(baos)) {
|
||||||
|
|
||||||
|
dos.writeInt(protocolVersion);
|
||||||
|
dos.writeUTF(action.name());
|
||||||
|
dos.writeLong(playerUuid.getMostSignificantBits());
|
||||||
|
dos.writeLong(playerUuid.getLeastSignificantBits());
|
||||||
|
writeNullableString(dos, nickname);
|
||||||
|
writeNullableString(dos, rankId);
|
||||||
|
writeNullableString(dos, skinId);
|
||||||
|
writeNullableString(dos, skinValue);
|
||||||
|
writeNullableString(dos, skinSignature);
|
||||||
|
writeNullableString(dos, sourceServer);
|
||||||
|
|
||||||
|
dos.flush();
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to encode NickSyncMessage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserializes a message from a byte array.
|
||||||
|
*
|
||||||
|
* @param data the raw bytes from the plugin messaging channel
|
||||||
|
* @return the decoded message
|
||||||
|
* @throws IllegalArgumentException if the protocol version is unsupported
|
||||||
|
*/
|
||||||
|
public static NickSyncMessage decode(byte[] data) {
|
||||||
|
try (var bais = new ByteArrayInputStream(data);
|
||||||
|
var dis = new DataInputStream(bais)) {
|
||||||
|
|
||||||
|
int version = dis.readInt();
|
||||||
|
if (version != CURRENT_PROTOCOL_VERSION) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Unsupported protocol version: " + version + " (expected " + CURRENT_PROTOCOL_VERSION + ")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Action action = Action.valueOf(dis.readUTF());
|
||||||
|
UUID uuid = new UUID(dis.readLong(), dis.readLong());
|
||||||
|
String nickname = readNullableString(dis);
|
||||||
|
String rankId = readNullableString(dis);
|
||||||
|
String skinId = readNullableString(dis);
|
||||||
|
String skinValue = readNullableString(dis);
|
||||||
|
String skinSignature = readNullableString(dis);
|
||||||
|
String sourceServer = readNullableString(dis);
|
||||||
|
|
||||||
|
return new NickSyncMessage(version, action, uuid, nickname, rankId,
|
||||||
|
skinId, skinValue, skinSignature, sourceServer);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to decode NickSyncMessage", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeNullableString(DataOutputStream dos, String value) throws IOException {
|
||||||
|
if (value == null) {
|
||||||
|
dos.writeBoolean(false);
|
||||||
|
} else {
|
||||||
|
dos.writeBoolean(true);
|
||||||
|
dos.writeUTF(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readNullableString(DataInputStream dis) throws IOException {
|
||||||
|
if (dis.readBoolean()) {
|
||||||
|
return dis.readUTF();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.nickcore.common.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable audit record for a nickname/rank change event.
|
||||||
|
*
|
||||||
|
* <p>Used for admin review and audit logging. Every nick, rank, or skin change
|
||||||
|
* produces a history entry.</p>
|
||||||
|
*/
|
||||||
|
public record NickHistory(
|
||||||
|
long id,
|
||||||
|
UUID uuid,
|
||||||
|
String oldNickname,
|
||||||
|
String newNickname,
|
||||||
|
String oldRank,
|
||||||
|
String newRank,
|
||||||
|
UUID changedBy,
|
||||||
|
String reason,
|
||||||
|
Instant timestamp
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Canonical constructor with null-safe timestamp.
|
||||||
|
*/
|
||||||
|
public NickHistory {
|
||||||
|
Objects.requireNonNull(uuid, "uuid must not be null");
|
||||||
|
if (timestamp == null) timestamp = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new history entry for a nickname change.
|
||||||
|
*/
|
||||||
|
public static NickHistory nickChange(UUID uuid, String oldNick, String newNick, UUID changedBy, String reason) {
|
||||||
|
return new NickHistory(0, uuid, oldNick, newNick, null, null, changedBy, reason, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new history entry for a rank change.
|
||||||
|
*/
|
||||||
|
public static NickHistory rankChange(UUID uuid, String oldRank, String newRank, UUID changedBy, String reason) {
|
||||||
|
return new NickHistory(0, uuid, null, null, oldRank, newRank, changedBy, reason, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new history entry for a full reset.
|
||||||
|
*/
|
||||||
|
public static NickHistory reset(UUID uuid, String oldNick, String oldRank, UUID changedBy) {
|
||||||
|
return new NickHistory(0, uuid, oldNick, null, oldRank, null, changedBy, "Reset", Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether this change was made by an admin (not self).
|
||||||
|
*/
|
||||||
|
public boolean isAdminAction() {
|
||||||
|
return changedBy != null && !changedBy.equals(uuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.nickcore.common.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable representation of a player's nick profile.
|
||||||
|
* Contains all identity overrides: nickname, visual rank, and skin.
|
||||||
|
*
|
||||||
|
* <p>Thread-safe by design — all fields are final and the record is immutable.</p>
|
||||||
|
*/
|
||||||
|
public record NickProfile(
|
||||||
|
UUID uuid,
|
||||||
|
String nickname,
|
||||||
|
String rankId,
|
||||||
|
String skinId,
|
||||||
|
String skinValue,
|
||||||
|
String skinSignature,
|
||||||
|
boolean nicked,
|
||||||
|
Instant createdAt,
|
||||||
|
Instant updatedAt
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Canonical constructor with null safety on timestamps.
|
||||||
|
*/
|
||||||
|
public NickProfile {
|
||||||
|
Objects.requireNonNull(uuid, "uuid must not be null");
|
||||||
|
if (createdAt == null) createdAt = Instant.now();
|
||||||
|
if (updatedAt == null) updatedAt = Instant.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new empty profile for a player with no active nick.
|
||||||
|
*/
|
||||||
|
public static NickProfile empty(UUID uuid) {
|
||||||
|
return new NickProfile(uuid, null, null, null, null, null, false, Instant.now(), Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the nickname updated.
|
||||||
|
*/
|
||||||
|
public NickProfile withNickname(String nickname) {
|
||||||
|
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||||
|
nickname != null, createdAt, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the rank updated.
|
||||||
|
*/
|
||||||
|
public NickProfile withRank(String rankId) {
|
||||||
|
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||||
|
nicked, createdAt, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the skin updated.
|
||||||
|
*/
|
||||||
|
public NickProfile withSkin(String skinId, String skinValue, String skinSignature) {
|
||||||
|
return new NickProfile(uuid, nickname, rankId, skinId, skinValue, skinSignature,
|
||||||
|
nicked, createdAt, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a fully reset profile (all overrides cleared).
|
||||||
|
*/
|
||||||
|
public NickProfile reset() {
|
||||||
|
return new NickProfile(uuid, null, null, null, null, null, false, createdAt, Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the effective display name: nickname if set, otherwise null (caller uses real name).
|
||||||
|
*/
|
||||||
|
public String effectiveDisplayName() {
|
||||||
|
return nicked && nickname != null ? nickname : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether this profile has an active skin override.
|
||||||
|
*/
|
||||||
|
public boolean hasSkinOverride() {
|
||||||
|
return skinValue != null && skinSignature != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.nickcore.common.model;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable definition of a visual rank that can be applied to players.
|
||||||
|
*
|
||||||
|
* <p>Ranks affect chat prefixes/suffixes, TAB display, nametags, and placeholder output.
|
||||||
|
* Each rank requires a specific permission node for selection.</p>
|
||||||
|
*/
|
||||||
|
public record RankDefinition(
|
||||||
|
String id,
|
||||||
|
String displayName,
|
||||||
|
String prefix,
|
||||||
|
String suffix,
|
||||||
|
int weight,
|
||||||
|
int priority,
|
||||||
|
String color,
|
||||||
|
String permission
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Canonical constructor with validation.
|
||||||
|
*/
|
||||||
|
public RankDefinition {
|
||||||
|
Objects.requireNonNull(id, "id must not be null");
|
||||||
|
Objects.requireNonNull(displayName, "displayName must not be null");
|
||||||
|
if (prefix == null) prefix = "";
|
||||||
|
if (suffix == null) suffix = "";
|
||||||
|
if (color == null) color = "white";
|
||||||
|
if (permission == null) permission = "nickcore.rank." + id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the MiniMessage formatted prefix string.
|
||||||
|
*/
|
||||||
|
public String formattedPrefix() {
|
||||||
|
if (prefix.isEmpty()) return "";
|
||||||
|
return prefix + " ";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the MiniMessage formatted suffix string.
|
||||||
|
*/
|
||||||
|
public String formattedSuffix() {
|
||||||
|
if (suffix.isEmpty()) return "";
|
||||||
|
return " " + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.nickcore.common.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable container for Mojang-compatible skin texture data.
|
||||||
|
*
|
||||||
|
* <p>The {@code textureValue} and {@code textureSignature} are Base64-encoded
|
||||||
|
* strings obtained from the Mojang Session API. Both are required for the
|
||||||
|
* client to accept and render the skin.</p>
|
||||||
|
*/
|
||||||
|
public record SkinData(
|
||||||
|
String name,
|
||||||
|
String textureValue,
|
||||||
|
String textureSignature,
|
||||||
|
Instant fetchedAt,
|
||||||
|
Instant expiresAt
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Canonical constructor with validation.
|
||||||
|
*/
|
||||||
|
public SkinData {
|
||||||
|
Objects.requireNonNull(name, "name must not be null");
|
||||||
|
Objects.requireNonNull(textureValue, "textureValue must not be null");
|
||||||
|
Objects.requireNonNull(textureSignature, "textureSignature must not be null");
|
||||||
|
if (fetchedAt == null) fetchedAt = Instant.now();
|
||||||
|
if (expiresAt == null) expiresAt = fetchedAt.plusSeconds(3600); // 1 hour default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether this skin data has expired and should be re-fetched.
|
||||||
|
*/
|
||||||
|
public boolean isExpired() {
|
||||||
|
return Instant.now().isAfter(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with a new expiration time.
|
||||||
|
*/
|
||||||
|
public SkinData withExpiration(Instant expiresAt) {
|
||||||
|
return new SkinData(name, textureValue, textureSignature, fetchedAt, expiresAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.nickcore.common.validation;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input sanitization utilities for all user-provided strings.
|
||||||
|
*
|
||||||
|
* <p>Prevents MiniMessage injection, control character abuse, and other
|
||||||
|
* text-based exploits. Should be applied to all user input before processing.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread-safe: stateless utility class.</p>
|
||||||
|
*/
|
||||||
|
public final class InputSanitizer {
|
||||||
|
|
||||||
|
/** ASCII control characters (0x00-0x1F except tab, newline, carriage return). */
|
||||||
|
private static final Pattern CONTROL_CHARS = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]");
|
||||||
|
|
||||||
|
/** MiniMessage tags that could be injected by malicious input. */
|
||||||
|
private static final Pattern MINIMESSAGE_TAGS = Pattern.compile("<[^>]+>");
|
||||||
|
|
||||||
|
/** Legacy Minecraft color codes (§ and & prefixed). */
|
||||||
|
private static final Pattern LEGACY_COLOR_CODES = Pattern.compile("[§&][0-9a-fk-orA-FK-OR]");
|
||||||
|
|
||||||
|
/** Consecutive whitespace normalization. */
|
||||||
|
private static final Pattern MULTI_WHITESPACE = Pattern.compile("\\s{2,}");
|
||||||
|
|
||||||
|
private InputSanitizer() {
|
||||||
|
throw new AssertionError("Utility class");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs full sanitization of user input.
|
||||||
|
*
|
||||||
|
* <p>Strips control characters, MiniMessage tags, legacy color codes,
|
||||||
|
* and normalizes whitespace.</p>
|
||||||
|
*
|
||||||
|
* @param input the raw user input
|
||||||
|
* @return sanitized string, or empty string if input is null
|
||||||
|
*/
|
||||||
|
public static String sanitize(String input) {
|
||||||
|
if (input == null) return "";
|
||||||
|
|
||||||
|
String result = input;
|
||||||
|
result = CONTROL_CHARS.matcher(result).replaceAll("");
|
||||||
|
result = MINIMESSAGE_TAGS.matcher(result).replaceAll("");
|
||||||
|
result = LEGACY_COLOR_CODES.matcher(result).replaceAll("");
|
||||||
|
result = MULTI_WHITESPACE.matcher(result).replaceAll(" ");
|
||||||
|
result = result.trim();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes MiniMessage tags in user input so they render as literal text
|
||||||
|
* rather than being parsed as formatting.
|
||||||
|
*
|
||||||
|
* <p>Use this when embedding user input into a MiniMessage template
|
||||||
|
* to prevent injection attacks.</p>
|
||||||
|
*
|
||||||
|
* @param input the raw user input
|
||||||
|
* @return escaped string safe for MiniMessage embedding
|
||||||
|
*/
|
||||||
|
public static String escapeMiniMessage(String input) {
|
||||||
|
if (input == null) return "";
|
||||||
|
|
||||||
|
return input
|
||||||
|
.replace("\\", "\\\\")
|
||||||
|
.replace("<", "\\<")
|
||||||
|
.replace(">", "\\>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips all color and formatting codes from a string, returning plain text.
|
||||||
|
*
|
||||||
|
* @param input the formatted string
|
||||||
|
* @return plain text without any formatting
|
||||||
|
*/
|
||||||
|
public static String stripFormatting(String input) {
|
||||||
|
if (input == null) return "";
|
||||||
|
|
||||||
|
String result = MINIMESSAGE_TAGS.matcher(input).replaceAll("");
|
||||||
|
result = LEGACY_COLOR_CODES.matcher(result).replaceAll("");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a string contains only safe characters for use as an identifier.
|
||||||
|
*
|
||||||
|
* @param input the string to validate
|
||||||
|
* @return true if the string is safe for use as an identifier
|
||||||
|
*/
|
||||||
|
public static boolean isSafeIdentifier(String input) {
|
||||||
|
if (input == null || input.isEmpty()) return false;
|
||||||
|
return input.matches("^[a-zA-Z0-9_-]+$");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package com.nickcore.common.validation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comprehensive nickname validation engine.
|
||||||
|
*
|
||||||
|
* <p>Validates nicknames against configurable rules to prevent abuse including
|
||||||
|
* unicode exploits, invisible characters, formatting injection, impersonation,
|
||||||
|
* and reserved name violations.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread-safe: all state is effectively immutable after construction.</p>
|
||||||
|
*/
|
||||||
|
public final class NicknameValidator {
|
||||||
|
|
||||||
|
/** Default regex allowing alphanumeric characters and underscores. */
|
||||||
|
private static final Pattern DEFAULT_ALLOWED_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+$");
|
||||||
|
|
||||||
|
/** Zero-width and invisible Unicode characters. */
|
||||||
|
private static final Pattern INVISIBLE_CHARS = Pattern.compile(
|
||||||
|
"[\\u200B\\u200C\\u200D\\u200E\\u200F\\u2060\\u2061\\u2062\\u2063\\u2064"
|
||||||
|
+ "\\uFEFF\\u00AD\\u034F\\u061C\\u115F\\u1160\\u17B4\\u17B5"
|
||||||
|
+ "\\u180E\\u2000-\\u200A\\u202A-\\u202E\\u2066-\\u2069"
|
||||||
|
+ "\\u00A0\\u1680\\u2028\\u2029\\u202F\\u205F\\u3000]"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Common homoglyph patterns (Cyrillic/Greek chars that look like Latin). */
|
||||||
|
private static final Pattern HOMOGLYPHS = Pattern.compile(
|
||||||
|
"[\\u0400-\\u04FF\\u0370-\\u03FF\\u0500-\\u052F\\u2DE0-\\u2DFF"
|
||||||
|
+ "\\uA640-\\uA69F\\u1C80-\\u1C8F\\uFF01-\\uFF5E]"
|
||||||
|
);
|
||||||
|
|
||||||
|
/** MiniMessage/legacy color code patterns that could be used for injection. */
|
||||||
|
private static final Pattern FORMAT_CODES = Pattern.compile(
|
||||||
|
"(<[^>]+>)|([§&][0-9a-fk-or])"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final int minLength;
|
||||||
|
private final int maxLength;
|
||||||
|
private final Pattern allowedPattern;
|
||||||
|
private final Set<String> reservedNames;
|
||||||
|
private final Set<String> protectedNames;
|
||||||
|
private final boolean blockHomoglyphs;
|
||||||
|
private final boolean blockInvisibleChars;
|
||||||
|
|
||||||
|
private NicknameValidator(Builder builder) {
|
||||||
|
this.minLength = builder.minLength;
|
||||||
|
this.maxLength = builder.maxLength;
|
||||||
|
this.allowedPattern = builder.allowedPattern;
|
||||||
|
this.reservedNames = Set.copyOf(builder.reservedNames);
|
||||||
|
this.protectedNames = Set.copyOf(builder.protectedNames);
|
||||||
|
this.blockHomoglyphs = builder.blockHomoglyphs;
|
||||||
|
this.blockInvisibleChars = builder.blockInvisibleChars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a nickname and returns a result containing all violations found.
|
||||||
|
*
|
||||||
|
* @param nickname the nickname to validate
|
||||||
|
* @return validation result (never null)
|
||||||
|
*/
|
||||||
|
public ValidationResult validate(String nickname) {
|
||||||
|
List<String> violations = new ArrayList<>();
|
||||||
|
|
||||||
|
if (nickname == null || nickname.isBlank()) {
|
||||||
|
violations.add("Nickname cannot be empty");
|
||||||
|
return new ValidationResult(false, List.copyOf(violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = nickname.trim();
|
||||||
|
|
||||||
|
// Length checks
|
||||||
|
if (trimmed.length() < minLength) {
|
||||||
|
violations.add("Nickname must be at least " + minLength + " characters");
|
||||||
|
}
|
||||||
|
if (trimmed.length() > maxLength) {
|
||||||
|
violations.add("Nickname must be at most " + maxLength + " characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character whitelist check
|
||||||
|
if (!allowedPattern.matcher(trimmed).matches()) {
|
||||||
|
violations.add("Nickname contains invalid characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invisible character check
|
||||||
|
if (blockInvisibleChars && INVISIBLE_CHARS.matcher(trimmed).find()) {
|
||||||
|
violations.add("Nickname contains invisible characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Homoglyph check
|
||||||
|
if (blockHomoglyphs && HOMOGLYPHS.matcher(trimmed).find()) {
|
||||||
|
violations.add("Nickname contains look-alike characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format code injection check
|
||||||
|
if (FORMAT_CODES.matcher(trimmed).find()) {
|
||||||
|
violations.add("Nickname contains formatting codes");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved name check (case-insensitive)
|
||||||
|
if (reservedNames.stream().anyMatch(r -> r.equalsIgnoreCase(trimmed))) {
|
||||||
|
violations.add("This nickname is reserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected name check (staff impersonation)
|
||||||
|
if (protectedNames.stream().anyMatch(p -> p.equalsIgnoreCase(trimmed))) {
|
||||||
|
violations.add("This nickname belongs to a protected player");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ValidationResult(violations.isEmpty(), List.copyOf(violations));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs an impersonation check using Levenshtein distance.
|
||||||
|
*
|
||||||
|
* @param nickname the proposed nickname
|
||||||
|
* @param onlineNames names of currently online players
|
||||||
|
* @param threshold maximum edit distance to flag (typically 1-2)
|
||||||
|
* @return list of similar player names, empty if none
|
||||||
|
*/
|
||||||
|
public List<String> findSimilarNames(String nickname, Set<String> onlineNames, int threshold) {
|
||||||
|
Objects.requireNonNull(nickname);
|
||||||
|
if (onlineNames == null || onlineNames.isEmpty()) return List.of();
|
||||||
|
|
||||||
|
String lower = nickname.toLowerCase();
|
||||||
|
List<String> similar = new ArrayList<>();
|
||||||
|
|
||||||
|
for (String online : onlineNames) {
|
||||||
|
if (online.equalsIgnoreCase(nickname)) {
|
||||||
|
similar.add(online);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (levenshteinDistance(lower, online.toLowerCase()) <= threshold) {
|
||||||
|
similar.add(online);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.copyOf(similar);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the Levenshtein edit distance between two strings.
|
||||||
|
* Uses an optimized single-row DP approach for minimal allocation.
|
||||||
|
*/
|
||||||
|
private static int levenshteinDistance(String a, String b) {
|
||||||
|
int lenA = a.length();
|
||||||
|
int lenB = b.length();
|
||||||
|
|
||||||
|
if (lenA == 0) return lenB;
|
||||||
|
if (lenB == 0) return lenA;
|
||||||
|
|
||||||
|
int[] prev = new int[lenB + 1];
|
||||||
|
int[] curr = new int[lenB + 1];
|
||||||
|
|
||||||
|
for (int j = 0; j <= lenB; j++) {
|
||||||
|
prev[j] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 1; i <= lenA; i++) {
|
||||||
|
curr[0] = i;
|
||||||
|
for (int j = 1; j <= lenB; j++) {
|
||||||
|
int cost = (a.charAt(i - 1) == b.charAt(j - 1)) ? 0 : 1;
|
||||||
|
curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||||
|
}
|
||||||
|
int[] temp = prev;
|
||||||
|
prev = curr;
|
||||||
|
curr = temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev[lenB];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new builder with default settings.
|
||||||
|
*/
|
||||||
|
public static Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result container.
|
||||||
|
*/
|
||||||
|
public record ValidationResult(boolean valid, List<String> violations) {
|
||||||
|
public ValidationResult {
|
||||||
|
violations = violations != null ? violations : List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builder for configuring the validator.
|
||||||
|
*/
|
||||||
|
public static final class Builder {
|
||||||
|
private int minLength = 3;
|
||||||
|
private int maxLength = 16;
|
||||||
|
private Pattern allowedPattern = DEFAULT_ALLOWED_PATTERN;
|
||||||
|
private Set<String> reservedNames = Set.of();
|
||||||
|
private Set<String> protectedNames = Set.of();
|
||||||
|
private boolean blockHomoglyphs = true;
|
||||||
|
private boolean blockInvisibleChars = true;
|
||||||
|
|
||||||
|
private Builder() {}
|
||||||
|
|
||||||
|
public Builder minLength(int minLength) {
|
||||||
|
this.minLength = minLength;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder maxLength(int maxLength) {
|
||||||
|
this.maxLength = maxLength;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder allowedPattern(String regex) {
|
||||||
|
this.allowedPattern = Pattern.compile(regex);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder reservedNames(Set<String> names) {
|
||||||
|
this.reservedNames = names;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder protectedNames(Set<String> names) {
|
||||||
|
this.protectedNames = names;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder blockHomoglyphs(boolean block) {
|
||||||
|
this.blockHomoglyphs = block;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder blockInvisibleChars(boolean block) {
|
||||||
|
this.blockInvisibleChars = block;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NicknameValidator build() {
|
||||||
|
return new NicknameValidator(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.nickcore.common.dto;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for NickSyncMessage encode/decode.
|
||||||
|
*/
|
||||||
|
class NickSyncMessageTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Full sync message round-trips correctly")
|
||||||
|
void fullSyncRoundTrip() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
var original = new NickSyncMessage(
|
||||||
|
NickSyncMessage.CURRENT_PROTOCOL_VERSION,
|
||||||
|
NickSyncMessage.Action.FULL_SYNC,
|
||||||
|
uuid, "ShadowWolf", "vip", "steve",
|
||||||
|
"textureValue123", "textureSig456", "lobby"
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] encoded = original.encode();
|
||||||
|
var decoded = NickSyncMessage.decode(encoded);
|
||||||
|
|
||||||
|
assertEquals(original.protocolVersion(), decoded.protocolVersion());
|
||||||
|
assertEquals(original.action(), decoded.action());
|
||||||
|
assertEquals(original.playerUuid(), decoded.playerUuid());
|
||||||
|
assertEquals(original.nickname(), decoded.nickname());
|
||||||
|
assertEquals(original.rankId(), decoded.rankId());
|
||||||
|
assertEquals(original.skinId(), decoded.skinId());
|
||||||
|
assertEquals(original.skinValue(), decoded.skinValue());
|
||||||
|
assertEquals(original.skinSignature(), decoded.skinSignature());
|
||||||
|
assertEquals(original.sourceServer(), decoded.sourceServer());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Null fields round-trip correctly")
|
||||||
|
void nullFieldsRoundTrip() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
var original = NickSyncMessage.reset(uuid, "lobby");
|
||||||
|
|
||||||
|
byte[] encoded = original.encode();
|
||||||
|
var decoded = NickSyncMessage.decode(encoded);
|
||||||
|
|
||||||
|
assertEquals(NickSyncMessage.Action.RESET, decoded.action());
|
||||||
|
assertEquals(uuid, decoded.playerUuid());
|
||||||
|
assertNull(decoded.nickname());
|
||||||
|
assertNull(decoded.rankId());
|
||||||
|
assertNull(decoded.skinId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Invalid protocol version throws exception")
|
||||||
|
void invalidProtocolVersion() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
var msg = new NickSyncMessage(
|
||||||
|
999, NickSyncMessage.Action.RESET, uuid,
|
||||||
|
null, null, null, null, null, null
|
||||||
|
);
|
||||||
|
|
||||||
|
byte[] encoded = msg.encode();
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> NickSyncMessage.decode(encoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Factory methods create correct actions")
|
||||||
|
void factoryMethods() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
|
||||||
|
assertEquals(NickSyncMessage.Action.NICK_UPDATE,
|
||||||
|
NickSyncMessage.nickUpdate(uuid, "Test", "s1").action());
|
||||||
|
assertEquals(NickSyncMessage.Action.RANK_UPDATE,
|
||||||
|
NickSyncMessage.rankUpdate(uuid, "vip", "s1").action());
|
||||||
|
assertEquals(NickSyncMessage.Action.RESET,
|
||||||
|
NickSyncMessage.reset(uuid, "s1").action());
|
||||||
|
assertEquals(NickSyncMessage.Action.CACHE_INVALIDATE,
|
||||||
|
NickSyncMessage.cacheInvalidate(uuid, "s1").action());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.nickcore.common.model;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for NickProfile immutability and behavior.
|
||||||
|
*/
|
||||||
|
class NickProfileTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Empty profile has correct defaults")
|
||||||
|
void emptyProfile() {
|
||||||
|
UUID uuid = UUID.randomUUID();
|
||||||
|
NickProfile profile = NickProfile.empty(uuid);
|
||||||
|
|
||||||
|
assertEquals(uuid, profile.uuid());
|
||||||
|
assertNull(profile.nickname());
|
||||||
|
assertNull(profile.rankId());
|
||||||
|
assertNull(profile.skinId());
|
||||||
|
assertFalse(profile.nicked());
|
||||||
|
assertNotNull(profile.createdAt());
|
||||||
|
assertNotNull(profile.updatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("withNickname creates new profile with nick")
|
||||||
|
void withNickname() {
|
||||||
|
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||||
|
NickProfile nicked = original.withNickname("TestNick");
|
||||||
|
|
||||||
|
assertEquals("TestNick", nicked.nickname());
|
||||||
|
assertTrue(nicked.nicked());
|
||||||
|
assertNull(original.nickname()); // original unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("withRank creates new profile with rank")
|
||||||
|
void withRank() {
|
||||||
|
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||||
|
NickProfile ranked = original.withRank("vip");
|
||||||
|
|
||||||
|
assertEquals("vip", ranked.rankId());
|
||||||
|
assertNull(original.rankId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("withSkin creates new profile with skin")
|
||||||
|
void withSkin() {
|
||||||
|
NickProfile original = NickProfile.empty(UUID.randomUUID());
|
||||||
|
NickProfile skinned = original.withSkin("steve", "val", "sig");
|
||||||
|
|
||||||
|
assertEquals("steve", skinned.skinId());
|
||||||
|
assertTrue(skinned.hasSkinOverride());
|
||||||
|
assertFalse(original.hasSkinOverride());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("reset clears all overrides")
|
||||||
|
void reset() {
|
||||||
|
NickProfile profile = NickProfile.empty(UUID.randomUUID())
|
||||||
|
.withNickname("Test").withRank("vip").withSkin("steve", "v", "s");
|
||||||
|
NickProfile resetProfile = profile.reset();
|
||||||
|
|
||||||
|
assertNull(resetProfile.nickname());
|
||||||
|
assertNull(resetProfile.rankId());
|
||||||
|
assertNull(resetProfile.skinId());
|
||||||
|
assertFalse(resetProfile.nicked());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("effectiveDisplayName returns nick when nicked")
|
||||||
|
void effectiveDisplayName() {
|
||||||
|
NickProfile nicked = NickProfile.empty(UUID.randomUUID()).withNickname("TestNick");
|
||||||
|
assertEquals("TestNick", nicked.effectiveDisplayName());
|
||||||
|
|
||||||
|
NickProfile notNicked = NickProfile.empty(UUID.randomUUID());
|
||||||
|
assertNull(notNicked.effectiveDisplayName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.nickcore.common.validation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for InputSanitizer.
|
||||||
|
*/
|
||||||
|
class InputSanitizerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Null input returns empty string")
|
||||||
|
void nullInput() {
|
||||||
|
assertEquals("", InputSanitizer.sanitize(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Clean input passes through unchanged")
|
||||||
|
void cleanInput() {
|
||||||
|
assertEquals("HelloWorld", InputSanitizer.sanitize("HelloWorld"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Control characters are stripped")
|
||||||
|
void controlChars() {
|
||||||
|
assertEquals("Hello", InputSanitizer.sanitize("He\u0000ll\u0001o"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MiniMessage tags are stripped")
|
||||||
|
void miniMessageStrip() {
|
||||||
|
assertEquals("Hello", InputSanitizer.sanitize("<red>Hello</red>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Legacy color codes are stripped")
|
||||||
|
void legacyColorStrip() {
|
||||||
|
assertEquals("Hello", InputSanitizer.sanitize("§cHello"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Multiple whitespace is normalized")
|
||||||
|
void whitespaceNormalization() {
|
||||||
|
assertEquals("Hello World", InputSanitizer.sanitize("Hello World"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MiniMessage escaping works")
|
||||||
|
void miniMessageEscape() {
|
||||||
|
assertEquals("\\<red\\>test\\</red\\>",
|
||||||
|
InputSanitizer.escapeMiniMessage("<red>test</red>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Strip formatting removes all codes")
|
||||||
|
void stripFormatting() {
|
||||||
|
assertEquals("Hello World", InputSanitizer.stripFormatting("<gold>Hello</gold> §aWorld"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Safe identifier validation")
|
||||||
|
void safeIdentifier() {
|
||||||
|
assertTrue(InputSanitizer.isSafeIdentifier("hello-world_123"));
|
||||||
|
assertFalse(InputSanitizer.isSafeIdentifier("hello world"));
|
||||||
|
assertFalse(InputSanitizer.isSafeIdentifier(""));
|
||||||
|
assertFalse(InputSanitizer.isSafeIdentifier(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.nickcore.common.validation;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for NicknameValidator.
|
||||||
|
*/
|
||||||
|
class NicknameValidatorTest {
|
||||||
|
|
||||||
|
private NicknameValidator validator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
validator = NicknameValidator.builder()
|
||||||
|
.minLength(3)
|
||||||
|
.maxLength(16)
|
||||||
|
.reservedNames(Set.of("Admin", "Server", "Console"))
|
||||||
|
.protectedNames(Set.of("Notch", "jeb_"))
|
||||||
|
.blockHomoglyphs(true)
|
||||||
|
.blockInvisibleChars(true)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Valid alphanumeric nickname should pass")
|
||||||
|
void validNickname() {
|
||||||
|
var result = validator.validate("ShadowWolf");
|
||||||
|
assertTrue(result.valid());
|
||||||
|
assertTrue(result.violations().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nickname with underscore should pass")
|
||||||
|
void nicknameWithUnderscore() {
|
||||||
|
var result = validator.validate("Shadow_Wolf");
|
||||||
|
assertTrue(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Null nickname should fail")
|
||||||
|
void nullNickname() {
|
||||||
|
var result = validator.validate(null);
|
||||||
|
assertFalse(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Empty nickname should fail")
|
||||||
|
void emptyNickname() {
|
||||||
|
var result = validator.validate("");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Too short nickname should fail")
|
||||||
|
void tooShort() {
|
||||||
|
var result = validator.validate("AB");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertTrue(result.violations().stream().anyMatch(v -> v.contains("at least")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Too long nickname should fail")
|
||||||
|
void tooLong() {
|
||||||
|
var result = validator.validate("ThisNameIsWayTooLongForMinecraft");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertTrue(result.violations().stream().anyMatch(v -> v.contains("at most")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nickname with special characters should fail")
|
||||||
|
void specialCharacters() {
|
||||||
|
var result = validator.validate("Shadow@Wolf!");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertTrue(result.violations().stream().anyMatch(v -> v.contains("invalid characters")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Reserved name should fail")
|
||||||
|
void reservedName() {
|
||||||
|
var result = validator.validate("Admin");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertTrue(result.violations().stream().anyMatch(v -> v.contains("reserved")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Reserved name case-insensitive should fail")
|
||||||
|
void reservedNameCaseInsensitive() {
|
||||||
|
var result = validator.validate("admin");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Protected name should fail")
|
||||||
|
void protectedName() {
|
||||||
|
var result = validator.validate("Notch");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertTrue(result.violations().stream().anyMatch(v -> v.contains("protected")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("MiniMessage tags should be rejected")
|
||||||
|
void miniMessageInjection() {
|
||||||
|
var result = validator.validate("<red>Hacker</red>");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Legacy color codes should be rejected")
|
||||||
|
void legacyColorCodes() {
|
||||||
|
var result = validator.validate("§cHacker");
|
||||||
|
assertFalse(result.valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Levenshtein distance detection works")
|
||||||
|
void impersonationDetection() {
|
||||||
|
Set<String> onlineNames = Set.of("PlayerOne", "SomeGuy");
|
||||||
|
var similar = validator.findSimilarNames("PlayerOme", onlineNames, 1);
|
||||||
|
assertEquals(1, similar.size());
|
||||||
|
assertEquals("PlayerOne", similar.getFirst());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Exact match detected in impersonation check")
|
||||||
|
void exactMatchImpersonation() {
|
||||||
|
Set<String> onlineNames = Set.of("ExactName");
|
||||||
|
var similar = validator.findSimilarNames("ExactName", onlineNames, 2);
|
||||||
|
assertFalse(similar.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("No impersonation with dissimilar names")
|
||||||
|
void noImpersonation() {
|
||||||
|
Set<String> onlineNames = Set.of("CompletelyDifferent");
|
||||||
|
var similar = validator.findSimilarNames("ShadowWolf", onlineNames, 2);
|
||||||
|
assertTrue(similar.isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
32
nickcore-paper/build.gradle.kts
Normal file
32
nickcore-paper/build.gradle.kts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.gradleup.shadow")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val paperApiVersion: String by project
|
||||||
|
val hikariVersion: String by project
|
||||||
|
val luckpermsVersion: String by project
|
||||||
|
val placeholderApiVersion: String by project
|
||||||
|
|
||||||
|
implementation(project(":nickcore-common"))
|
||||||
|
|
||||||
|
compileOnly("io.papermc.paper:paper-api:$paperApiVersion")
|
||||||
|
|
||||||
|
implementation("com.zaxxer:HikariCP:$hikariVersion")
|
||||||
|
|
||||||
|
compileOnly("net.luckperms:api:$luckpermsVersion")
|
||||||
|
compileOnly("me.clip:placeholderapi:$placeholderApiVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.shadowJar {
|
||||||
|
archiveClassifier.set("")
|
||||||
|
archiveBaseName.set("NickCore-Paper")
|
||||||
|
|
||||||
|
relocate("com.zaxxer.hikari", "com.nickcore.lib.hikari")
|
||||||
|
|
||||||
|
minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.build {
|
||||||
|
dependsOn(tasks.shadowJar)
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
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.CommandPreprocessListener;
|
||||||
|
import com.nickcore.paper.listener.PlayerConnectionListener;
|
||||||
|
import com.nickcore.paper.listener.TabCompleteListener;
|
||||||
|
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 NameOverrideService nameOverrideService;
|
||||||
|
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());
|
||||||
|
nameOverrideService = new NameOverrideService(profileCache);
|
||||||
|
|
||||||
|
// 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, nameOverrideService, 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, nameOverrideService,
|
||||||
|
scheduler, getLogger()), this);
|
||||||
|
Bukkit.getPluginManager().registerEvents(chatService, this);
|
||||||
|
Bukkit.getPluginManager().registerEvents(guiManager, this);
|
||||||
|
Bukkit.getPluginManager().registerEvents(
|
||||||
|
new CommandPreprocessListener(nameOverrideService, getLogger()), this);
|
||||||
|
Bukkit.getPluginManager().registerEvents(
|
||||||
|
new TabCompleteListener(nameOverrideService), 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 (nameOverrideService != null) nameOverrideService.clear();
|
||||||
|
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, nameOverrideService,
|
||||||
|
scheduler, getLogger(), config.velocity().serverName());
|
||||||
|
velocityMessaging.register();
|
||||||
|
// Let NickService auto-sync changes to other servers
|
||||||
|
nickService.setVelocityMessaging(velocityMessaging);
|
||||||
|
getLogger().info("Velocity messaging enabled (server: " + config.velocity().serverName() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveDefaultConfigs() {
|
||||||
|
saveDefaultConfig();
|
||||||
|
String[] files = {"database.yml", "messages.yml", "ranks.yml", "skins.yml", "names.yml"};
|
||||||
|
for (String file : files) {
|
||||||
|
if (!new File(getDataFolder(), file).exists()) {
|
||||||
|
saveResource(file, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFolia() {
|
||||||
|
try {
|
||||||
|
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||||
|
return true;
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
97
nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java
vendored
Normal file
97
nickcore-paper/src/main/java/com/nickcore/paper/cache/ProfileCache.java
vendored
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package com.nickcore.paper.cache;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe in-memory cache for active player nick profiles.
|
||||||
|
*
|
||||||
|
* <p>Profiles are loaded on player join and evicted on player quit.
|
||||||
|
* All placeholder resolution and display name lookups read from this cache
|
||||||
|
* to avoid database roundtrips during hot paths.</p>
|
||||||
|
*
|
||||||
|
* <p>Designed for 500+ concurrent players. Uses {@link ConcurrentHashMap}
|
||||||
|
* for lock-free concurrent reads with minimal contention on writes.</p>
|
||||||
|
*/
|
||||||
|
public final class ProfileCache {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<UUID, NickProfile> profiles = new ConcurrentHashMap<>(512, 0.75f, 4);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores or updates a profile in the cache.
|
||||||
|
*
|
||||||
|
* @param profile the profile to cache (must not be null)
|
||||||
|
*/
|
||||||
|
public void put(NickProfile profile) {
|
||||||
|
Objects.requireNonNull(profile, "profile must not be null");
|
||||||
|
profiles.put(profile.uuid(), profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a cached profile.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
* @return the cached profile, or empty if not cached
|
||||||
|
*/
|
||||||
|
public Optional<NickProfile> get(UUID uuid) {
|
||||||
|
return Optional.ofNullable(profiles.get(uuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a profile from the cache.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
* @return the removed profile, or null if not cached
|
||||||
|
*/
|
||||||
|
public NickProfile remove(UUID uuid) {
|
||||||
|
return profiles.remove(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a nickname is currently in use in the cache.
|
||||||
|
*
|
||||||
|
* @param nickname the nickname to check
|
||||||
|
* @param excludeUuid UUID to exclude from the check
|
||||||
|
* @return true if the nickname is in use
|
||||||
|
*/
|
||||||
|
public boolean isNicknameInUse(String nickname, UUID excludeUuid) {
|
||||||
|
if (nickname == null) return false;
|
||||||
|
|
||||||
|
for (Map.Entry<UUID, NickProfile> entry : profiles.entrySet()) {
|
||||||
|
if (entry.getKey().equals(excludeUuid)) continue;
|
||||||
|
NickProfile profile = entry.getValue();
|
||||||
|
if (profile.nicked() && nickname.equalsIgnoreCase(profile.nickname())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a snapshot of all cached profiles.
|
||||||
|
*
|
||||||
|
* @return unmodifiable map of all cached profiles
|
||||||
|
*/
|
||||||
|
public Map<UUID, NickProfile> getAll() {
|
||||||
|
return Map.copyOf(profiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the number of cached profiles.
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return profiles.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all cached profiles. Called during plugin shutdown.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
profiles.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
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.NameOverrideService;
|
||||||
|
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 NameOverrideService nameOverrideService;
|
||||||
|
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, NameOverrideService nameOverrideService,
|
||||||
|
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.nameOverrideService = nameOverrideService;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Save rank to DB and sync to Velocity
|
||||||
|
nickService.saveRankChange(player.getUniqueId(), rankId).thenRun(() -> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Resolve by nick name first, then real name
|
||||||
|
Player target = nameOverrideService.resolvePlayer(args[1]).orElse(null);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Resolve by nick name first, then real name
|
||||||
|
Player target = nameOverrideService.resolvePlayer(args[1]).orElse(null);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
// Resolve by nick name first, then real name
|
||||||
|
Player target = nameOverrideService.resolvePlayer(args[1]).orElse(null);
|
||||||
|
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"),
|
||||||
|
() -> {
|
||||||
|
// Apply full name override (display, player list, custom name, lookup maps)
|
||||||
|
nameOverrideService.applyFullOverride(player);
|
||||||
|
nametagService.updateNametag(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendMessage(Player player, String miniMessageText) {
|
||||||
|
player.sendMessage(mm.deserialize(miniMessageText));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
|
||||||
|
if (!(sender instanceof Player player)) return List.of();
|
||||||
|
|
||||||
|
if (args.length == 1) {
|
||||||
|
List<String> subs = new ArrayList<>();
|
||||||
|
subs.add("gui");
|
||||||
|
if (player.hasPermission(PERM_RANDOM)) subs.add("random");
|
||||||
|
if (player.hasPermission(PERM_RESET)) subs.add("reset");
|
||||||
|
if (player.hasPermission(PERM_RANK)) subs.add("rank");
|
||||||
|
if (player.hasPermission(PERM_SKIN)) subs.add("skin");
|
||||||
|
if (player.hasPermission(PERM_RELOAD)) subs.add("reload");
|
||||||
|
if (player.hasPermission(PERM_INFO)) subs.add("info");
|
||||||
|
if (player.hasPermission(PERM_FORCE)) subs.add("force");
|
||||||
|
if (player.hasPermission(PERM_CLEAR)) subs.add("clear");
|
||||||
|
return filterCompletions(subs, args[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length == 2) {
|
||||||
|
return switch (args[0].toLowerCase()) {
|
||||||
|
case "rank" -> filterCompletions(
|
||||||
|
rankService.getAvailableRanks(player).stream()
|
||||||
|
.map(r -> r.id()).toList(), args[1]);
|
||||||
|
case "skin" -> filterCompletions(skinService.getConfiguredSkinNames(), args[1]);
|
||||||
|
case "info", "force", "clear" -> filterCompletions(
|
||||||
|
Bukkit.getOnlinePlayers().stream()
|
||||||
|
.map(Player::getName).toList(), args[1]);
|
||||||
|
default -> List.of();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> filterCompletions(List<String> options, String input) {
|
||||||
|
String lower = input.toLowerCase();
|
||||||
|
return options.stream().filter(s -> s.toLowerCase().startsWith(lower)).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.nickcore.paper.gui;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.inventory.Inventory;
|
||||||
|
import org.bukkit.inventory.InventoryHolder;
|
||||||
|
import org.bukkit.inventory.ItemStack;
|
||||||
|
import org.bukkit.inventory.meta.ItemMeta;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for all NickCore GUI menus.
|
||||||
|
* Handles item placement, click routing, and pagination.
|
||||||
|
*/
|
||||||
|
public abstract class AbstractGui implements InventoryHolder {
|
||||||
|
|
||||||
|
protected final Player viewer;
|
||||||
|
protected final int size;
|
||||||
|
protected final Component title;
|
||||||
|
protected Inventory inventory;
|
||||||
|
|
||||||
|
private final Map<Integer, Consumer<InventoryClickEvent>> clickHandlers = new HashMap<>();
|
||||||
|
private long lastClickTime = 0;
|
||||||
|
private static final long CLICK_COOLDOWN_MS = 200;
|
||||||
|
|
||||||
|
protected AbstractGui(Player viewer, int size, Component title) {
|
||||||
|
this.viewer = viewer;
|
||||||
|
this.size = size;
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void open() {
|
||||||
|
this.inventory = Bukkit.createInventory(this, size, title);
|
||||||
|
build();
|
||||||
|
viewer.openInventory(inventory);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract void build();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Inventory getInventory() { return inventory; }
|
||||||
|
|
||||||
|
public void handleClick(InventoryClickEvent event) {
|
||||||
|
event.setCancelled(true);
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
if (now - lastClickTime < CLICK_COOLDOWN_MS) return;
|
||||||
|
lastClickTime = now;
|
||||||
|
|
||||||
|
Consumer<InventoryClickEvent> handler = clickHandlers.get(event.getRawSlot());
|
||||||
|
if (handler != null) handler.accept(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void setItem(int slot, ItemStack item, Consumer<InventoryClickEvent> onClick) {
|
||||||
|
inventory.setItem(slot, item);
|
||||||
|
if (onClick != null) clickHandlers.put(slot, onClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void setItem(int slot, ItemStack item) {
|
||||||
|
setItem(slot, item, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ItemStack createItem(Material material, Component name, List<Component> lore) {
|
||||||
|
ItemStack item = new ItemStack(material);
|
||||||
|
ItemMeta meta = item.getItemMeta();
|
||||||
|
meta.displayName(name);
|
||||||
|
if (lore != null) meta.lore(lore);
|
||||||
|
meta.setMaxStackSize(99);
|
||||||
|
item.setItemMeta(meta);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected ItemStack createItem(Material material, Component name) {
|
||||||
|
return createItem(material, name, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void fillBorder(Material material) {
|
||||||
|
ItemStack filler = createItem(material, Component.empty());
|
||||||
|
for (int i = 0; i < size; i++) {
|
||||||
|
if (i < 9 || i >= size - 9 || i % 9 == 0 || i % 9 == 8) {
|
||||||
|
if (inventory.getItem(i) == null) setItem(i, filler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Player getViewer() { return viewer; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.nickcore.paper.gui;
|
||||||
|
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
import com.nickcore.paper.service.*;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.inventory.InventoryClickEvent;
|
||||||
|
import org.bukkit.event.inventory.InventoryCloseEvent;
|
||||||
|
import org.bukkit.event.player.PlayerQuitEvent;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages all GUI instances and routes inventory events.
|
||||||
|
* Uses ConcurrentHashMap to prevent player reference leaks.
|
||||||
|
*/
|
||||||
|
public final class GuiManager implements Listener {
|
||||||
|
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final NickService nickService;
|
||||||
|
private final RankService rankService;
|
||||||
|
private final SkinService skinService;
|
||||||
|
private final PlaceholderService placeholderService;
|
||||||
|
private final NametagService nametagService;
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final ConcurrentHashMap<UUID, AbstractGui> openGuis = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public GuiManager(Plugin plugin, NickService nickService, RankService rankService,
|
||||||
|
SkinService skinService, PlaceholderService placeholderService,
|
||||||
|
NametagService nametagService, ProfileCache profileCache) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.nickService = nickService;
|
||||||
|
this.rankService = rankService;
|
||||||
|
this.skinService = skinService;
|
||||||
|
this.placeholderService = placeholderService;
|
||||||
|
this.nametagService = nametagService;
|
||||||
|
this.profileCache = profileCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openMainMenu(Player player) {
|
||||||
|
MainMenuGui gui = new MainMenuGui(player, this);
|
||||||
|
openGuis.put(player.getUniqueId(), gui);
|
||||||
|
gui.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openNicknameMenu(Player player) {
|
||||||
|
NicknameMenuGui gui = new NicknameMenuGui(player, this, nickService);
|
||||||
|
openGuis.put(player.getUniqueId(), gui);
|
||||||
|
gui.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openRankMenu(Player player) {
|
||||||
|
RankMenuGui gui = new RankMenuGui(player, this, nickService, rankService,
|
||||||
|
profileCache, placeholderService, nametagService);
|
||||||
|
openGuis.put(player.getUniqueId(), gui);
|
||||||
|
gui.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void openSkinMenu(Player player) {
|
||||||
|
SkinMenuGui gui = new SkinMenuGui(player, this, skinService, profileCache);
|
||||||
|
openGuis.put(player.getUniqueId(), gui);
|
||||||
|
gui.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClick(InventoryClickEvent event) {
|
||||||
|
if (!(event.getWhoClicked() instanceof Player player)) return;
|
||||||
|
if (!(event.getInventory().getHolder() instanceof AbstractGui gui)) return;
|
||||||
|
gui.handleClick(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onInventoryClose(InventoryCloseEvent event) {
|
||||||
|
if (event.getPlayer() instanceof Player player) {
|
||||||
|
openGuis.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onPlayerQuit(PlayerQuitEvent event) {
|
||||||
|
openGuis.remove(event.getPlayer().getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Plugin getPlugin() { return plugin; }
|
||||||
|
public NickService getNickService() { return nickService; }
|
||||||
|
public ProfileCache getProfileCache() { return profileCache; }
|
||||||
|
public PlaceholderService getPlaceholderService() { return placeholderService; }
|
||||||
|
public NametagService getNametagService() { return nametagService; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.nickcore.paper.gui;
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main menu GUI — entry point for all nick operations.
|
||||||
|
*/
|
||||||
|
public final class MainMenuGui extends AbstractGui {
|
||||||
|
|
||||||
|
private final GuiManager guiManager;
|
||||||
|
|
||||||
|
public MainMenuGui(Player viewer, GuiManager guiManager) {
|
||||||
|
super(viewer, 27, Component.text("NickCore", NamedTextColor.DARK_PURPLE, TextDecoration.BOLD));
|
||||||
|
this.guiManager = guiManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void build() {
|
||||||
|
fillBorder(Material.BLACK_STAINED_GLASS_PANE);
|
||||||
|
|
||||||
|
// Nickname
|
||||||
|
if (viewer.hasPermission("nickcore.use")) {
|
||||||
|
setItem(10, createItem(Material.NAME_TAG,
|
||||||
|
Component.text("Change Nickname", NamedTextColor.GREEN, TextDecoration.BOLD),
|
||||||
|
List.of(Component.text("Select or generate a nickname", NamedTextColor.GRAY))),
|
||||||
|
e -> guiManager.openNicknameMenu(viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rank
|
||||||
|
if (viewer.hasPermission("nickcore.rank.select")) {
|
||||||
|
setItem(12, createItem(Material.GOLDEN_HELMET,
|
||||||
|
Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD),
|
||||||
|
List.of(Component.text("Choose a visual rank", NamedTextColor.GRAY))),
|
||||||
|
e -> guiManager.openRankMenu(viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skin
|
||||||
|
if (viewer.hasPermission("nickcore.skin.select")) {
|
||||||
|
setItem(14, createItem(Material.PLAYER_HEAD,
|
||||||
|
Component.text("Change Skin", NamedTextColor.AQUA, TextDecoration.BOLD),
|
||||||
|
List.of(Component.text("Apply a different skin", NamedTextColor.GRAY))),
|
||||||
|
e -> guiManager.openSkinMenu(viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
if (viewer.hasPermission("nickcore.reset")) {
|
||||||
|
setItem(16, createItem(Material.BARRIER,
|
||||||
|
Component.text("Reset Identity", NamedTextColor.RED, TextDecoration.BOLD),
|
||||||
|
List.of(Component.text("Return to your real identity", NamedTextColor.GRAY))),
|
||||||
|
e -> {
|
||||||
|
viewer.closeInventory();
|
||||||
|
guiManager.getNickService().resetNickname(viewer, null).thenAccept(r -> {
|
||||||
|
if (r.success()) {
|
||||||
|
viewer.sendMessage(Component.text("Identity restored.", NamedTextColor.GREEN));
|
||||||
|
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||||
|
guiManager.getNametagService().updateNametag(viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.nickcore.paper.gui;
|
||||||
|
|
||||||
|
import com.nickcore.common.validation.InputSanitizer;
|
||||||
|
import com.nickcore.paper.service.NickService;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nickname selection GUI with paginated name pool and random option.
|
||||||
|
*/
|
||||||
|
public final class NicknameMenuGui extends AbstractGui {
|
||||||
|
|
||||||
|
private final GuiManager guiManager;
|
||||||
|
private final NickService nickService;
|
||||||
|
private int page = 0;
|
||||||
|
|
||||||
|
public NicknameMenuGui(Player viewer, GuiManager guiManager, NickService nickService) {
|
||||||
|
super(viewer, 54, Component.text("Select Nickname", NamedTextColor.GREEN, TextDecoration.BOLD));
|
||||||
|
this.guiManager = guiManager;
|
||||||
|
this.nickService = nickService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void build() {
|
||||||
|
fillBorder(Material.GREEN_STAINED_GLASS_PANE);
|
||||||
|
populateNames();
|
||||||
|
|
||||||
|
// Random button
|
||||||
|
if (viewer.hasPermission("nickcore.name.random")) {
|
||||||
|
setItem(49, createItem(Material.ENDER_PEARL,
|
||||||
|
Component.text("Random Nickname", NamedTextColor.LIGHT_PURPLE, TextDecoration.BOLD),
|
||||||
|
List.of(Component.text("Get a random nickname", NamedTextColor.GRAY))),
|
||||||
|
e -> {
|
||||||
|
viewer.closeInventory();
|
||||||
|
nickService.applyRandomNickname(viewer).thenAccept(r -> {
|
||||||
|
if (r.success()) {
|
||||||
|
viewer.sendMessage(Component.text("Nickname: " + r.profile().nickname(), NamedTextColor.GREEN));
|
||||||
|
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||||
|
guiManager.getNametagService().updateNametag(viewer);
|
||||||
|
} else {
|
||||||
|
viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
setItem(45, createItem(Material.ARROW,
|
||||||
|
Component.text("Back", NamedTextColor.RED)),
|
||||||
|
e -> guiManager.openMainMenu(viewer));
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
if (page > 0) {
|
||||||
|
setItem(48, createItem(Material.ARROW, Component.text("Previous Page", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page--; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> pool = nickService.getNamePool();
|
||||||
|
int totalPages = (int) Math.ceil(pool.size() / 28.0);
|
||||||
|
if (page < totalPages - 1) {
|
||||||
|
setItem(50, createItem(Material.ARROW, Component.text("Next Page", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page++; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateNames() {
|
||||||
|
List<String> pool = nickService.getNamePool();
|
||||||
|
int start = page * 28;
|
||||||
|
int[] contentSlots = getContentSlots();
|
||||||
|
|
||||||
|
for (int i = 0; i < contentSlots.length && (start + i) < pool.size(); i++) {
|
||||||
|
String name = pool.get(start + i);
|
||||||
|
setItem(contentSlots[i], createItem(Material.PAPER,
|
||||||
|
Component.text(name, NamedTextColor.WHITE)),
|
||||||
|
e -> {
|
||||||
|
viewer.closeInventory();
|
||||||
|
nickService.applyNickname(viewer, name, null).thenAccept(r -> {
|
||||||
|
if (r.success()) {
|
||||||
|
viewer.sendMessage(Component.text("Nickname set to " + name, NamedTextColor.GREEN));
|
||||||
|
guiManager.getPlaceholderService().invalidate(viewer.getUniqueId(), viewer.getName());
|
||||||
|
guiManager.getNametagService().updateNametag(viewer);
|
||||||
|
} else {
|
||||||
|
viewer.sendMessage(Component.text(r.message(), NamedTextColor.RED));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] getContentSlots() {
|
||||||
|
int[] slots = new int[28];
|
||||||
|
int idx = 0;
|
||||||
|
for (int row = 1; row <= 4; row++) {
|
||||||
|
for (int col = 1; col <= 7; col++) {
|
||||||
|
slots[idx++] = row * 9 + col;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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.NickService;
|
||||||
|
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 NickService nickService;
|
||||||
|
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, NickService nickService,
|
||||||
|
RankService rankService, ProfileCache profileCache,
|
||||||
|
PlaceholderService placeholderService, NametagService nametagService) {
|
||||||
|
super(viewer, 54, Component.text("Select Rank", NamedTextColor.GOLD, TextDecoration.BOLD));
|
||||||
|
this.guiManager = guiManager;
|
||||||
|
this.nickService = nickService;
|
||||||
|
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 -> {
|
||||||
|
// Save to DB and sync via Velocity
|
||||||
|
nickService.saveRankChange(viewer.getUniqueId(), rank.id()).thenRun(() -> {
|
||||||
|
viewer.closeInventory();
|
||||||
|
viewer.sendMessage(Component.text("Rank set to ", NamedTextColor.GREEN)
|
||||||
|
.append(mm.deserialize(rank.displayName())));
|
||||||
|
placeholderService.invalidate(viewer.getUniqueId(), viewer.getName());
|
||||||
|
nametagService.updateNametag(viewer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)),
|
||||||
|
e -> guiManager.openMainMenu(viewer));
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
int totalPages = (int) Math.ceil(available.size() / 28.0);
|
||||||
|
if (page > 0) {
|
||||||
|
setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page--; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
if (page < totalPages - 1) {
|
||||||
|
setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page++; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] getContentSlots() {
|
||||||
|
int[] slots = new int[28];
|
||||||
|
int idx = 0;
|
||||||
|
for (int row = 1; row <= 4; row++)
|
||||||
|
for (int col = 1; col <= 7; col++)
|
||||||
|
slots[idx++] = row * 9 + col;
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.nickcore.paper.gui;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.SkinData;
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
import com.nickcore.paper.service.SkinService;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Material;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skin selection GUI with configured skins and pagination.
|
||||||
|
*/
|
||||||
|
public final class SkinMenuGui extends AbstractGui {
|
||||||
|
|
||||||
|
private final GuiManager guiManager;
|
||||||
|
private final SkinService skinService;
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private int page = 0;
|
||||||
|
|
||||||
|
public SkinMenuGui(Player viewer, GuiManager guiManager,
|
||||||
|
SkinService skinService, ProfileCache profileCache) {
|
||||||
|
super(viewer, 54, Component.text("Select Skin", NamedTextColor.AQUA, TextDecoration.BOLD));
|
||||||
|
this.guiManager = guiManager;
|
||||||
|
this.skinService = skinService;
|
||||||
|
this.profileCache = profileCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void build() {
|
||||||
|
fillBorder(Material.LIGHT_BLUE_STAINED_GLASS_PANE);
|
||||||
|
|
||||||
|
List<String> skinNames = skinService.getConfiguredSkinNames();
|
||||||
|
int start = page * 28;
|
||||||
|
int[] slots = getContentSlots();
|
||||||
|
|
||||||
|
for (int i = 0; i < slots.length && (start + i) < skinNames.size(); i++) {
|
||||||
|
String skinName = skinNames.get(start + i);
|
||||||
|
setItem(slots[i], createItem(Material.PLAYER_HEAD,
|
||||||
|
Component.text(skinName, NamedTextColor.WHITE),
|
||||||
|
List.of(Component.text("Click to apply", NamedTextColor.YELLOW))),
|
||||||
|
e -> {
|
||||||
|
viewer.closeInventory();
|
||||||
|
viewer.sendMessage(Component.text("Applying skin...", NamedTextColor.GRAY));
|
||||||
|
|
||||||
|
skinService.getConfiguredSkin(skinName).or(() -> {
|
||||||
|
// Fetch from Mojang if not configured
|
||||||
|
return java.util.Optional.empty();
|
||||||
|
}).ifPresentOrElse(
|
||||||
|
skin -> applySkin(skin),
|
||||||
|
() -> skinService.fetchSkin(skinName).thenAccept(opt ->
|
||||||
|
opt.ifPresentOrElse(
|
||||||
|
this::applySkin,
|
||||||
|
() -> viewer.sendMessage(Component.text("Skin not found.", NamedTextColor.RED))
|
||||||
|
))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep current skin button
|
||||||
|
if (viewer.hasPermission("nickcore.skin.keep")) {
|
||||||
|
setItem(49, createItem(Material.ARMOR_STAND,
|
||||||
|
Component.text("Keep Current Skin", NamedTextColor.GREEN),
|
||||||
|
List.of(Component.text("Don't change your skin", NamedTextColor.GRAY))),
|
||||||
|
e -> viewer.closeInventory());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back button
|
||||||
|
setItem(45, createItem(Material.ARROW, Component.text("Back", NamedTextColor.RED)),
|
||||||
|
e -> guiManager.openMainMenu(viewer));
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
int totalPages = Math.max(1, (int) Math.ceil(skinNames.size() / 28.0));
|
||||||
|
if (page > 0) {
|
||||||
|
setItem(48, createItem(Material.ARROW, Component.text("Previous", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page--; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
if (page < totalPages - 1) {
|
||||||
|
setItem(50, createItem(Material.ARROW, Component.text("Next", NamedTextColor.YELLOW)),
|
||||||
|
e -> { page++; inventory.clear(); build(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applySkin(SkinData skin) {
|
||||||
|
NickProfile profile = profileCache.get(viewer.getUniqueId())
|
||||||
|
.orElse(NickProfile.empty(viewer.getUniqueId()));
|
||||||
|
profileCache.put(profile.withSkin(skin.name(), skin.textureValue(), skin.textureSignature()));
|
||||||
|
|
||||||
|
Bukkit.getScheduler().runTask(guiManager.getPlugin(), () -> {
|
||||||
|
skinService.applySkin(viewer, skin);
|
||||||
|
viewer.sendMessage(Component.text("Skin applied!", NamedTextColor.GREEN));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int[] getContentSlots() {
|
||||||
|
int[] slots = new int[28];
|
||||||
|
int idx = 0;
|
||||||
|
for (int row = 1; row <= 4; row++)
|
||||||
|
for (int col = 1; col <= 7; col++)
|
||||||
|
slots[idx++] = row * 9 + col;
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.nickcore.paper.integration;
|
||||||
|
|
||||||
|
import com.nickcore.paper.service.PlaceholderService;
|
||||||
|
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
|
||||||
|
import org.bukkit.OfflinePlayer;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlaceholderAPI expansion for NickCore.
|
||||||
|
* All placeholder resolution reads from the in-memory PlaceholderService cache (zero DB hits).
|
||||||
|
*/
|
||||||
|
public final class NickCoreExpansion extends PlaceholderExpansion {
|
||||||
|
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final PlaceholderService placeholderService;
|
||||||
|
|
||||||
|
public NickCoreExpansion(Plugin plugin, PlaceholderService placeholderService) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.placeholderService = placeholderService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull String getIdentifier() { return "nickcore"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull String getAuthor() { return "NickCore Team"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull String getVersion() { return plugin.getPluginMeta().getVersion(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean persist() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean canRegister() { return true; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) {
|
||||||
|
if (player == null || player.getUniqueId() == null) return "";
|
||||||
|
|
||||||
|
return switch (params.toLowerCase()) {
|
||||||
|
case "nick" -> placeholderService.resolve(player.getUniqueId(), "nick");
|
||||||
|
case "real_name" -> placeholderService.resolve(player.getUniqueId(), "real_name");
|
||||||
|
case "rank" -> placeholderService.resolve(player.getUniqueId(), "rank");
|
||||||
|
case "rank_prefix" -> placeholderService.resolve(player.getUniqueId(), "rank_prefix");
|
||||||
|
case "rank_suffix" -> placeholderService.resolve(player.getUniqueId(), "rank_suffix");
|
||||||
|
case "skin" -> placeholderService.resolve(player.getUniqueId(), "skin");
|
||||||
|
case "is_nicked" -> placeholderService.resolve(player.getUniqueId(), "is_nicked");
|
||||||
|
case "display_name" -> placeholderService.resolve(player.getUniqueId(), "display_name");
|
||||||
|
case "original_name" -> placeholderService.resolve(player.getUniqueId(), "original_name");
|
||||||
|
case "nick_history_count" -> placeholderService.resolve(player.getUniqueId(), "nick_history_count");
|
||||||
|
case "server" -> placeholderService.resolve(player.getUniqueId(), "server");
|
||||||
|
case "network_rank" -> placeholderService.resolve(player.getUniqueId(), "network_rank");
|
||||||
|
case "network_nick" -> placeholderService.resolve(player.getUniqueId(), "network_nick");
|
||||||
|
default -> null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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.NameOverrideService;
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* <p>When a player switches servers (hub → survival), the Velocity proxy forwards
|
||||||
|
* their profile to the new server. This handler receives that sync and applies
|
||||||
|
* the full name override so the nick persists across servers.</p>
|
||||||
|
*/
|
||||||
|
public final class VelocityMessaging implements PluginMessageListener {
|
||||||
|
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final PlaceholderService placeholderService;
|
||||||
|
private final NametagService nametagService;
|
||||||
|
private final NameOverrideService nameOverrideService;
|
||||||
|
private final SchedulerAdapter scheduler;
|
||||||
|
private final Logger logger;
|
||||||
|
private final String serverName;
|
||||||
|
|
||||||
|
public VelocityMessaging(Plugin plugin, ProfileCache profileCache,
|
||||||
|
PlaceholderService placeholderService, NametagService nametagService,
|
||||||
|
NameOverrideService nameOverrideService, SchedulerAdapter scheduler,
|
||||||
|
Logger logger, String serverName) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.profileCache = profileCache;
|
||||||
|
this.placeholderService = placeholderService;
|
||||||
|
this.nametagService = nametagService;
|
||||||
|
this.nameOverrideService = nameOverrideService;
|
||||||
|
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 to Velocity for cross-server propagation.
|
||||||
|
* Called every time a nick/rank/skin changes on this server.
|
||||||
|
*/
|
||||||
|
public void syncProfile(NickProfile profile) {
|
||||||
|
sendSync(NickSyncMessage.fullSync(profile, serverName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a nick update to Velocity.
|
||||||
|
*/
|
||||||
|
public void syncNickUpdate(java.util.UUID uuid, String nickname) {
|
||||||
|
sendSync(NickSyncMessage.nickUpdate(uuid, nickname, serverName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a rank update to Velocity.
|
||||||
|
*/
|
||||||
|
public void syncRankUpdate(java.util.UUID uuid, String rankId) {
|
||||||
|
sendSync(NickSyncMessage.rankUpdate(uuid, rankId, serverName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a reset to Velocity.
|
||||||
|
*/
|
||||||
|
public void syncReset(java.util.UUID uuid) {
|
||||||
|
sendSync(NickSyncMessage.reset(uuid, 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, () -> {
|
||||||
|
// Apply full name override so the nick appears everywhere on this server
|
||||||
|
nameOverrideService.applyFullOverride(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());
|
||||||
|
Player player = Bukkit.getPlayer(msg.playerUuid());
|
||||||
|
if (player != null) {
|
||||||
|
scheduler.runAtEntity(plugin, player, () -> {
|
||||||
|
nameOverrideService.clearMapping(player);
|
||||||
|
nameOverrideService.applyFullOverride(player);
|
||||||
|
nametagService.updateNametag(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshPlayer(java.util.UUID uuid) {
|
||||||
|
Player player = Bukkit.getPlayer(uuid);
|
||||||
|
if (player != null) {
|
||||||
|
placeholderService.invalidate(uuid, player.getName());
|
||||||
|
scheduler.runAtEntity(plugin, player, () -> {
|
||||||
|
nameOverrideService.applyFullOverride(player);
|
||||||
|
nametagService.updateNametag(player);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.nickcore.paper.listener;
|
||||||
|
|
||||||
|
import com.nickcore.paper.service.NameOverrideService;
|
||||||
|
|
||||||
|
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.PlayerCommandPreprocessEvent;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepts player commands and replaces nick names with real names before dispatch.
|
||||||
|
*
|
||||||
|
* <p>This ensures that commands like /tpa, /msg, /tp, /tell, /w, /pay, /trade,
|
||||||
|
* /party, /friend, etc. work correctly when players use nicked names as arguments.</p>
|
||||||
|
*
|
||||||
|
* <p>How it works: scans command arguments for known nick names and replaces them
|
||||||
|
* with the corresponding real player name so the target plugin can resolve them.</p>
|
||||||
|
*/
|
||||||
|
public final class CommandPreprocessListener implements Listener {
|
||||||
|
|
||||||
|
private final NameOverrideService nameOverrideService;
|
||||||
|
private final Logger logger;
|
||||||
|
|
||||||
|
/** Pattern to split command into parts while preserving structure. */
|
||||||
|
private static final Pattern WORD_BOUNDARY = Pattern.compile("\\s+");
|
||||||
|
|
||||||
|
public CommandPreprocessListener(NameOverrideService nameOverrideService, Logger logger) {
|
||||||
|
this.nameOverrideService = Objects.requireNonNull(nameOverrideService);
|
||||||
|
this.logger = Objects.requireNonNull(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
|
||||||
|
public void onCommandPreprocess(PlayerCommandPreprocessEvent event) {
|
||||||
|
String message = event.getMessage();
|
||||||
|
if (message.length() < 2) return;
|
||||||
|
|
||||||
|
// Split: "/command arg1 arg2 ..."
|
||||||
|
String[] parts = message.split("\\s+");
|
||||||
|
if (parts.length < 2) return; // No arguments to process
|
||||||
|
|
||||||
|
boolean modified = false;
|
||||||
|
StringBuilder rebuilt = new StringBuilder(parts[0]); // Keep the command itself
|
||||||
|
|
||||||
|
for (int i = 1; i < parts.length; i++) {
|
||||||
|
rebuilt.append(' ');
|
||||||
|
String arg = parts[i];
|
||||||
|
|
||||||
|
// Check if this argument is a nick name
|
||||||
|
var resolved = nameOverrideService.resolvePlayer(arg);
|
||||||
|
if (resolved.isPresent() && nameOverrideService.isNickName(arg)) {
|
||||||
|
// Replace nick with real name so the command can find the player
|
||||||
|
rebuilt.append(resolved.get().getName());
|
||||||
|
modified = true;
|
||||||
|
} else {
|
||||||
|
rebuilt.append(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
event.setMessage(rebuilt.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 NameOverrideService nameOverrideService;
|
||||||
|
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, NameOverrideService nameOverrideService,
|
||||||
|
SchedulerAdapter scheduler, Logger logger) {
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.nickService = nickService;
|
||||||
|
this.skinService = skinService;
|
||||||
|
this.placeholderService = placeholderService;
|
||||||
|
this.nametagService = nametagService;
|
||||||
|
this.nameOverrideService = nameOverrideService;
|
||||||
|
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 full name override (display name, player list, custom name, lookup maps)
|
||||||
|
nameOverrideService.applyFullOverride(player);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
nameOverrideService.clearMapping(player);
|
||||||
|
nickService.unloadProfile(player.getUniqueId());
|
||||||
|
placeholderService.remove(player.getUniqueId());
|
||||||
|
nametagService.removeNametag(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.nickcore.paper.listener;
|
||||||
|
|
||||||
|
import com.nickcore.paper.service.NameOverrideService;
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.event.server.AsyncTabCompleteEvent;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces real player names with nick names in tab completions.
|
||||||
|
*
|
||||||
|
* <p>When a player presses TAB to autocomplete a command argument, this listener
|
||||||
|
* intercepts the completion results and swaps real names for nicked names.
|
||||||
|
* This means if someone types "/tpa Sha" and a player is nicked as "ShadowWolf",
|
||||||
|
* "ShadowWolf" will appear in the completions instead of their real name.</p>
|
||||||
|
*/
|
||||||
|
public final class TabCompleteListener implements Listener {
|
||||||
|
|
||||||
|
private final NameOverrideService nameOverrideService;
|
||||||
|
|
||||||
|
public TabCompleteListener(NameOverrideService nameOverrideService) {
|
||||||
|
this.nameOverrideService = Objects.requireNonNull(nameOverrideService);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
|
||||||
|
public void onTabComplete(AsyncTabCompleteEvent event) {
|
||||||
|
if (event.getCompletions().isEmpty()) return;
|
||||||
|
|
||||||
|
List<String> original = event.getCompletions();
|
||||||
|
List<String> replaced = new ArrayList<>(original.size());
|
||||||
|
boolean modified = false;
|
||||||
|
|
||||||
|
for (String completion : original) {
|
||||||
|
// Check if this completion is a real player name that has a nick
|
||||||
|
Player player = Bukkit.getPlayerExact(completion);
|
||||||
|
if (player != null) {
|
||||||
|
String display = nameOverrideService.getNickOrReal(player.getName());
|
||||||
|
if (!display.equals(player.getName())) {
|
||||||
|
replaced.add(display);
|
||||||
|
modified = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replaced.add(completion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
event.setCompletions(replaced);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.nickcore.paper.repository;
|
||||||
|
|
||||||
|
import com.nickcore.common.config.NickCoreConfig;
|
||||||
|
import com.zaxxer.hikari.HikariConfig;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Database connection pool manager using HikariCP.
|
||||||
|
*
|
||||||
|
* <p>Supports SQLite, MySQL, and MariaDB. Handles schema creation and
|
||||||
|
* migration on startup. All connections are pooled for optimal performance.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread-safe: HikariDataSource is inherently thread-safe.</p>
|
||||||
|
*/
|
||||||
|
public final class DatabaseManager implements AutoCloseable {
|
||||||
|
|
||||||
|
private static final int CURRENT_SCHEMA_VERSION = 1;
|
||||||
|
|
||||||
|
private final Logger logger;
|
||||||
|
private final HikariDataSource dataSource;
|
||||||
|
private final boolean isSqlite;
|
||||||
|
private final String tablePrefix;
|
||||||
|
|
||||||
|
public DatabaseManager(NickCoreConfig.DatabaseSettings config, File dataFolder, Logger logger) {
|
||||||
|
this.logger = logger;
|
||||||
|
this.isSqlite = "sqlite".equalsIgnoreCase(config.type());
|
||||||
|
this.tablePrefix = config.tablePrefix();
|
||||||
|
|
||||||
|
HikariConfig hikariConfig = new HikariConfig();
|
||||||
|
|
||||||
|
if (isSqlite) {
|
||||||
|
File dbFile = new File(dataFolder, config.database() + ".db");
|
||||||
|
hikariConfig.setJdbcUrl("jdbc:sqlite:" + dbFile.getAbsolutePath());
|
||||||
|
hikariConfig.setDriverClassName("org.sqlite.JDBC");
|
||||||
|
// SQLite only supports single writer
|
||||||
|
hikariConfig.setMaximumPoolSize(1);
|
||||||
|
hikariConfig.setMinimumIdle(1);
|
||||||
|
} else {
|
||||||
|
String jdbcUrl = String.format("jdbc:%s://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&autoReconnect=true",
|
||||||
|
"mariadb".equalsIgnoreCase(config.type()) ? "mariadb" : "mysql",
|
||||||
|
config.host(), config.port(), config.database());
|
||||||
|
hikariConfig.setJdbcUrl(jdbcUrl);
|
||||||
|
hikariConfig.setUsername(config.username());
|
||||||
|
hikariConfig.setPassword(config.password());
|
||||||
|
hikariConfig.setMaximumPoolSize(config.maxPoolSize());
|
||||||
|
hikariConfig.setMinimumIdle(config.minIdle());
|
||||||
|
}
|
||||||
|
|
||||||
|
hikariConfig.setConnectionTimeout(config.connectionTimeoutMs());
|
||||||
|
hikariConfig.setIdleTimeout(config.idleTimeoutMs());
|
||||||
|
hikariConfig.setMaxLifetime(config.maxLifetimeMs());
|
||||||
|
hikariConfig.setPoolName("NickCore-HikariPool");
|
||||||
|
|
||||||
|
// Performance optimizations
|
||||||
|
hikariConfig.addDataSourceProperty("cachePrepStmts", "true");
|
||||||
|
hikariConfig.addDataSourceProperty("prepStmtCacheSize", "250");
|
||||||
|
hikariConfig.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");
|
||||||
|
hikariConfig.addDataSourceProperty("useServerPrepStmts", "true");
|
||||||
|
|
||||||
|
this.dataSource = new HikariDataSource(hikariConfig);
|
||||||
|
logger.info("Database connection pool initialized (" + config.type() + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a connection from the pool.
|
||||||
|
*
|
||||||
|
* <p>Callers MUST close the connection in a try-with-resources block.</p>
|
||||||
|
*/
|
||||||
|
public Connection getConnection() throws SQLException {
|
||||||
|
return dataSource.getConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether this is a SQLite database.
|
||||||
|
*/
|
||||||
|
public boolean isSqlite() {
|
||||||
|
return isSqlite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configured table prefix.
|
||||||
|
*/
|
||||||
|
public String getTablePrefix() {
|
||||||
|
return tablePrefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the database schema, creating tables and running migrations.
|
||||||
|
*/
|
||||||
|
public void initialize() {
|
||||||
|
try (Connection conn = getConnection()) {
|
||||||
|
createSchemaTable(conn);
|
||||||
|
int currentVersion = getSchemaVersion(conn);
|
||||||
|
|
||||||
|
if (currentVersion < CURRENT_SCHEMA_VERSION) {
|
||||||
|
runMigrations(conn, currentVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Database schema is up to date (version " + CURRENT_SCHEMA_VERSION + ")");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to initialize database schema", e);
|
||||||
|
throw new RuntimeException("Database initialization failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createSchemaTable(Connection conn) throws SQLException {
|
||||||
|
String sql = "CREATE TABLE IF NOT EXISTS " + tablePrefix + "schema_version (" +
|
||||||
|
"version INTEGER PRIMARY KEY, " +
|
||||||
|
"applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)";
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int getSchemaVersion(Connection conn) throws SQLException {
|
||||||
|
String sql = "SELECT COALESCE(MAX(version), 0) FROM " + tablePrefix + "schema_version";
|
||||||
|
try (Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery(sql)) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return rs.getInt(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runMigrations(Connection conn, int fromVersion) throws SQLException {
|
||||||
|
logger.info("Running database migrations from version " + fromVersion + " to " + CURRENT_SCHEMA_VERSION);
|
||||||
|
|
||||||
|
boolean autoCommit = conn.getAutoCommit();
|
||||||
|
conn.setAutoCommit(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (int version = fromVersion + 1; version <= CURRENT_SCHEMA_VERSION; version++) {
|
||||||
|
runMigration(conn, version);
|
||||||
|
recordSchemaVersion(conn, version);
|
||||||
|
logger.info("Applied migration v" + version);
|
||||||
|
}
|
||||||
|
conn.commit();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
conn.rollback();
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
conn.setAutoCommit(autoCommit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runMigration(Connection conn, int version) throws SQLException {
|
||||||
|
switch (version) {
|
||||||
|
case 1 -> applyMigrationV1(conn);
|
||||||
|
default -> throw new SQLException("Unknown migration version: " + version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyMigrationV1(Connection conn) throws SQLException {
|
||||||
|
String autoInc = isSqlite ? "AUTOINCREMENT" : "AUTO_INCREMENT";
|
||||||
|
|
||||||
|
// Profiles table
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "profiles (" +
|
||||||
|
"uuid CHAR(36) PRIMARY KEY, " +
|
||||||
|
"nickname VARCHAR(16) NULL, " +
|
||||||
|
"rank_id VARCHAR(32) NULL, " +
|
||||||
|
"skin_id VARCHAR(64) NULL, " +
|
||||||
|
"skin_value TEXT NULL, " +
|
||||||
|
"skin_signature TEXT NULL, " +
|
||||||
|
"is_nicked BOOLEAN NOT NULL DEFAULT 0, " +
|
||||||
|
"created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
|
||||||
|
"updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// History table
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "history (" +
|
||||||
|
"id INTEGER PRIMARY KEY " + autoInc + ", " +
|
||||||
|
"uuid CHAR(36) NOT NULL, " +
|
||||||
|
"old_nickname VARCHAR(16) NULL, " +
|
||||||
|
"new_nickname VARCHAR(16) NULL, " +
|
||||||
|
"old_rank VARCHAR(32) NULL, " +
|
||||||
|
"new_rank VARCHAR(32) NULL, " +
|
||||||
|
"changed_by CHAR(36) NULL, " +
|
||||||
|
"reason VARCHAR(255) NULL, " +
|
||||||
|
"timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// History indexes
|
||||||
|
if (!isSqlite) {
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_uuid ON " +
|
||||||
|
tablePrefix + "history (uuid)");
|
||||||
|
}
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE INDEX IF NOT EXISTS idx_" + tablePrefix + "history_timestamp ON " +
|
||||||
|
tablePrefix + "history (timestamp)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skin cache table
|
||||||
|
try (Statement stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("CREATE TABLE IF NOT EXISTS " + tablePrefix + "skin_cache (" +
|
||||||
|
"skin_name VARCHAR(64) PRIMARY KEY, " +
|
||||||
|
"texture_value TEXT NOT NULL, " +
|
||||||
|
"texture_signature TEXT NOT NULL, " +
|
||||||
|
"fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, " +
|
||||||
|
"expires_at TIMESTAMP NOT NULL)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordSchemaVersion(Connection conn, int version) throws SQLException {
|
||||||
|
String sql = "INSERT INTO " + tablePrefix + "schema_version (version) VALUES (?)";
|
||||||
|
try (PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
ps.setInt(1, version);
|
||||||
|
ps.executeUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
if (dataSource != null && !dataSource.isClosed()) {
|
||||||
|
dataSource.close();
|
||||||
|
logger.info("Database connection pool closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.nickcore.paper.repository;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickHistory;
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.SkinData;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository interface for nick profile persistence.
|
||||||
|
*
|
||||||
|
* <p>All operations return {@link CompletableFuture} to enforce async execution.
|
||||||
|
* Implementations MUST NOT perform any blocking I/O on the caller's thread.</p>
|
||||||
|
*/
|
||||||
|
public interface NickRepository {
|
||||||
|
|
||||||
|
// ── Profile Operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a player's nick profile by UUID.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
* @return the profile, or empty if no profile exists
|
||||||
|
*/
|
||||||
|
CompletableFuture<Optional<NickProfile>> findByUuid(UUID uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves or updates a player's nick profile.
|
||||||
|
*
|
||||||
|
* @param profile the profile to save (upsert)
|
||||||
|
*/
|
||||||
|
CompletableFuture<Void> save(NickProfile profile);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a player's nick profile.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
*/
|
||||||
|
CompletableFuture<Void> delete(UUID uuid);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a nickname is currently in use by another player.
|
||||||
|
*
|
||||||
|
* @param nickname the nickname to check
|
||||||
|
* @param excludeUuid UUID to exclude from the check (the requesting player)
|
||||||
|
* @return true if the nickname is taken
|
||||||
|
*/
|
||||||
|
CompletableFuture<Boolean> isNicknameTaken(String nickname, UUID excludeUuid);
|
||||||
|
|
||||||
|
// ── History Operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a change in the history audit log.
|
||||||
|
*
|
||||||
|
* @param history the history entry to record
|
||||||
|
*/
|
||||||
|
CompletableFuture<Void> recordHistory(NickHistory history);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the nick history for a player.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
* @param limit maximum number of entries to return
|
||||||
|
* @return list of history entries, most recent first
|
||||||
|
*/
|
||||||
|
CompletableFuture<List<NickHistory>> getHistory(UUID uuid, int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the total number of nick changes for a player.
|
||||||
|
*
|
||||||
|
* @param uuid the player's UUID
|
||||||
|
* @return the count
|
||||||
|
*/
|
||||||
|
CompletableFuture<Integer> getHistoryCount(UUID uuid);
|
||||||
|
|
||||||
|
// ── Skin Cache Operations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves cached skin data by name.
|
||||||
|
*
|
||||||
|
* @param skinName the skin identifier
|
||||||
|
* @return the cached skin data, or empty if not cached or expired
|
||||||
|
*/
|
||||||
|
CompletableFuture<Optional<SkinData>> getCachedSkin(String skinName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches skin data for future use.
|
||||||
|
*
|
||||||
|
* @param skin the skin data to cache
|
||||||
|
*/
|
||||||
|
CompletableFuture<Void> cacheSkin(SkinData skin);
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package com.nickcore.paper.repository;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickHistory;
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.SkinData;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL-based implementation of {@link NickRepository}.
|
||||||
|
*
|
||||||
|
* <p>All operations execute asynchronously on a dedicated thread pool to prevent
|
||||||
|
* blocking server threads. Uses prepared statements exclusively to prevent
|
||||||
|
* SQL injection.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread-safe: all state is accessed through the thread-safe HikariCP pool
|
||||||
|
* and the dedicated executor service.</p>
|
||||||
|
*/
|
||||||
|
public final class SqlNickRepository implements NickRepository, AutoCloseable {
|
||||||
|
|
||||||
|
private final DatabaseManager databaseManager;
|
||||||
|
private final Logger logger;
|
||||||
|
private final String prefix;
|
||||||
|
private final ExecutorService executor;
|
||||||
|
|
||||||
|
public SqlNickRepository(DatabaseManager databaseManager, Logger logger) {
|
||||||
|
this.databaseManager = databaseManager;
|
||||||
|
this.logger = logger;
|
||||||
|
this.prefix = databaseManager.getTablePrefix();
|
||||||
|
// Dedicated thread pool for DB operations — sized to match connection pool
|
||||||
|
this.executor = Executors.newFixedThreadPool(
|
||||||
|
Math.max(2, Runtime.getRuntime().availableProcessors()),
|
||||||
|
Thread.ofVirtual().name("nickcore-db-", 0).factory()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profile Operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Optional<NickProfile>> findByUuid(UUID uuid) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
String sql = "SELECT * FROM " + prefix + "profiles WHERE uuid = ?";
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, uuid.toString());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(mapProfile(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to find profile for " + uuid, e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> save(NickProfile profile) {
|
||||||
|
return CompletableFuture.runAsync(() -> {
|
||||||
|
String sql = databaseManager.isSqlite()
|
||||||
|
? "INSERT OR REPLACE INTO " + prefix + "profiles " +
|
||||||
|
"(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
: "INSERT INTO " + prefix + "profiles " +
|
||||||
|
"(uuid, nickname, rank_id, skin_id, skin_value, skin_signature, is_nicked, created_at, updated_at) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE nickname=?, rank_id=?, skin_id=?, skin_value=?, skin_signature=?, " +
|
||||||
|
"is_nicked=?, updated_at=?";
|
||||||
|
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
// INSERT values
|
||||||
|
ps.setString(1, profile.uuid().toString());
|
||||||
|
ps.setString(2, profile.nickname());
|
||||||
|
ps.setString(3, profile.rankId());
|
||||||
|
ps.setString(4, profile.skinId());
|
||||||
|
ps.setString(5, profile.skinValue());
|
||||||
|
ps.setString(6, profile.skinSignature());
|
||||||
|
ps.setBoolean(7, profile.nicked());
|
||||||
|
ps.setTimestamp(8, Timestamp.from(profile.createdAt()));
|
||||||
|
ps.setTimestamp(9, Timestamp.from(profile.updatedAt()));
|
||||||
|
|
||||||
|
if (!databaseManager.isSqlite()) {
|
||||||
|
// ON DUPLICATE KEY UPDATE values
|
||||||
|
ps.setString(10, profile.nickname());
|
||||||
|
ps.setString(11, profile.rankId());
|
||||||
|
ps.setString(12, profile.skinId());
|
||||||
|
ps.setString(13, profile.skinValue());
|
||||||
|
ps.setString(14, profile.skinSignature());
|
||||||
|
ps.setBoolean(15, profile.nicked());
|
||||||
|
ps.setTimestamp(16, Timestamp.from(profile.updatedAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to save profile for " + profile.uuid(), e);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> delete(UUID uuid) {
|
||||||
|
return CompletableFuture.runAsync(() -> {
|
||||||
|
String sql = "DELETE FROM " + prefix + "profiles WHERE uuid = ?";
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, uuid.toString());
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to delete profile for " + uuid, e);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Boolean> isNicknameTaken(String nickname, UUID excludeUuid) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
String sql = "SELECT COUNT(*) FROM " + prefix + "profiles WHERE LOWER(nickname) = LOWER(?) AND uuid != ?";
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, nickname);
|
||||||
|
ps.setString(2, excludeUuid.toString());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return rs.getInt(1) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to check nickname availability: " + nickname, e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History Operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> recordHistory(NickHistory history) {
|
||||||
|
return CompletableFuture.runAsync(() -> {
|
||||||
|
String sql = "INSERT INTO " + prefix + "history " +
|
||||||
|
"(uuid, old_nickname, new_nickname, old_rank, new_rank, changed_by, reason, timestamp) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
||||||
|
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, history.uuid().toString());
|
||||||
|
ps.setString(2, history.oldNickname());
|
||||||
|
ps.setString(3, history.newNickname());
|
||||||
|
ps.setString(4, history.oldRank());
|
||||||
|
ps.setString(5, history.newRank());
|
||||||
|
ps.setString(6, history.changedBy() != null ? history.changedBy().toString() : null);
|
||||||
|
ps.setString(7, history.reason());
|
||||||
|
ps.setTimestamp(8, Timestamp.from(history.timestamp()));
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to record history for " + history.uuid(), e);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<List<NickHistory>> getHistory(UUID uuid, int limit) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
String sql = "SELECT * FROM " + prefix + "history WHERE uuid = ? ORDER BY timestamp DESC LIMIT ?";
|
||||||
|
List<NickHistory> history = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, uuid.toString());
|
||||||
|
ps.setInt(2, limit);
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
while (rs.next()) {
|
||||||
|
history.add(mapHistory(rs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to get history for " + uuid, e);
|
||||||
|
}
|
||||||
|
return List.copyOf(history);
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Integer> getHistoryCount(UUID uuid) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
String sql = "SELECT COUNT(*) FROM " + prefix + "history WHERE uuid = ?";
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, uuid.toString());
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) return rs.getInt(1);
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to count history for " + uuid, e);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Skin Cache Operations ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Optional<SkinData>> getCachedSkin(String skinName) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
String sql = "SELECT * FROM " + prefix + "skin_cache WHERE skin_name = ? AND expires_at > ?";
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, skinName);
|
||||||
|
ps.setTimestamp(2, Timestamp.from(Instant.now()));
|
||||||
|
try (ResultSet rs = ps.executeQuery()) {
|
||||||
|
if (rs.next()) {
|
||||||
|
return Optional.of(new SkinData(
|
||||||
|
rs.getString("skin_name"),
|
||||||
|
rs.getString("texture_value"),
|
||||||
|
rs.getString("texture_signature"),
|
||||||
|
rs.getTimestamp("fetched_at").toInstant(),
|
||||||
|
rs.getTimestamp("expires_at").toInstant()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to get cached skin: " + skinName, e);
|
||||||
|
}
|
||||||
|
return Optional.empty();
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CompletableFuture<Void> cacheSkin(SkinData skin) {
|
||||||
|
return CompletableFuture.runAsync(() -> {
|
||||||
|
String sql = databaseManager.isSqlite()
|
||||||
|
? "INSERT OR REPLACE INTO " + prefix + "skin_cache " +
|
||||||
|
"(skin_name, texture_value, texture_signature, fetched_at, expires_at) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?)"
|
||||||
|
: "INSERT INTO " + prefix + "skin_cache " +
|
||||||
|
"(skin_name, texture_value, texture_signature, fetched_at, expires_at) " +
|
||||||
|
"VALUES (?, ?, ?, ?, ?) " +
|
||||||
|
"ON DUPLICATE KEY UPDATE texture_value=?, texture_signature=?, fetched_at=?, expires_at=?";
|
||||||
|
|
||||||
|
try (Connection conn = databaseManager.getConnection();
|
||||||
|
PreparedStatement ps = conn.prepareStatement(sql)) {
|
||||||
|
|
||||||
|
ps.setString(1, skin.name());
|
||||||
|
ps.setString(2, skin.textureValue());
|
||||||
|
ps.setString(3, skin.textureSignature());
|
||||||
|
ps.setTimestamp(4, Timestamp.from(skin.fetchedAt()));
|
||||||
|
ps.setTimestamp(5, Timestamp.from(skin.expiresAt()));
|
||||||
|
|
||||||
|
if (!databaseManager.isSqlite()) {
|
||||||
|
ps.setString(6, skin.textureValue());
|
||||||
|
ps.setString(7, skin.textureSignature());
|
||||||
|
ps.setTimestamp(8, Timestamp.from(skin.fetchedAt()));
|
||||||
|
ps.setTimestamp(9, Timestamp.from(skin.expiresAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
ps.executeUpdate();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
logger.log(Level.SEVERE, "Failed to cache skin: " + skin.name(), e);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private NickProfile mapProfile(ResultSet rs) throws SQLException {
|
||||||
|
return new NickProfile(
|
||||||
|
UUID.fromString(rs.getString("uuid")),
|
||||||
|
rs.getString("nickname"),
|
||||||
|
rs.getString("rank_id"),
|
||||||
|
rs.getString("skin_id"),
|
||||||
|
rs.getString("skin_value"),
|
||||||
|
rs.getString("skin_signature"),
|
||||||
|
rs.getBoolean("is_nicked"),
|
||||||
|
rs.getTimestamp("created_at").toInstant(),
|
||||||
|
rs.getTimestamp("updated_at").toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NickHistory mapHistory(ResultSet rs) throws SQLException {
|
||||||
|
String changedByStr = rs.getString("changed_by");
|
||||||
|
UUID changedBy = changedByStr != null ? UUID.fromString(changedByStr) : null;
|
||||||
|
|
||||||
|
return new NickHistory(
|
||||||
|
rs.getLong("id"),
|
||||||
|
UUID.fromString(rs.getString("uuid")),
|
||||||
|
rs.getString("old_nickname"),
|
||||||
|
rs.getString("new_nickname"),
|
||||||
|
rs.getString("old_rank"),
|
||||||
|
rs.getString("new_rank"),
|
||||||
|
changedBy,
|
||||||
|
rs.getString("reason"),
|
||||||
|
rs.getTimestamp("timestamp").toInstant()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
executor.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.nickcore.paper.scheduler;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folia-compliant scheduler using RegionScheduler and EntityScheduler.
|
||||||
|
* All player interactions are dispatched through the entity's owning region.
|
||||||
|
*/
|
||||||
|
public final class FoliaSchedulerAdapter implements SchedulerAdapter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runSync(Plugin plugin, Runnable task) {
|
||||||
|
Bukkit.getGlobalRegionScheduler().run(plugin, scheduledTask -> task.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAsync(Plugin plugin, Runnable task) {
|
||||||
|
Bukkit.getAsyncScheduler().runNow(plugin, scheduledTask -> task.run());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runLater(Plugin plugin, Runnable task, long delayTicks) {
|
||||||
|
Bukkit.getGlobalRegionScheduler().runDelayed(plugin, scheduledTask -> task.run(), delayTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) {
|
||||||
|
Bukkit.getGlobalRegionScheduler().runAtFixedRate(plugin,
|
||||||
|
scheduledTask -> task.run(), delayTicks, periodTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAtEntity(Plugin plugin, Entity entity, Runnable task) {
|
||||||
|
entity.getScheduler().run(plugin, scheduledTask -> task.run(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAtLocation(Plugin plugin, Location location, Runnable task) {
|
||||||
|
Bukkit.getRegionScheduler().run(plugin, location, scheduledTask -> task.run());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.nickcore.paper.scheduler;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard Paper scheduler implementation using BukkitScheduler.
|
||||||
|
*/
|
||||||
|
public final class PaperSchedulerAdapter implements SchedulerAdapter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runSync(Plugin plugin, Runnable task) {
|
||||||
|
if (Bukkit.isPrimaryThread()) {
|
||||||
|
task.run();
|
||||||
|
} else {
|
||||||
|
Bukkit.getScheduler().runTask(plugin, task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAsync(Plugin plugin, Runnable task) {
|
||||||
|
Bukkit.getScheduler().runTaskAsynchronously(plugin, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runLater(Plugin plugin, Runnable task, long delayTicks) {
|
||||||
|
Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) {
|
||||||
|
Bukkit.getScheduler().runTaskTimer(plugin, task, delayTicks, periodTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAtEntity(Plugin plugin, Entity entity, Runnable task) {
|
||||||
|
runSync(plugin, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void runAtLocation(Plugin plugin, Location location, Runnable task) {
|
||||||
|
runSync(plugin, task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.nickcore.paper.scheduler;
|
||||||
|
|
||||||
|
import org.bukkit.Location;
|
||||||
|
import org.bukkit.entity.Entity;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction over Paper/Folia scheduler APIs.
|
||||||
|
* Implementations are selected at startup based on Folia detection.
|
||||||
|
*/
|
||||||
|
public interface SchedulerAdapter {
|
||||||
|
|
||||||
|
void runSync(Plugin plugin, Runnable task);
|
||||||
|
void runAsync(Plugin plugin, Runnable task);
|
||||||
|
void runLater(Plugin plugin, Runnable task, long delayTicks);
|
||||||
|
void runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks);
|
||||||
|
void runAtEntity(Plugin plugin, Entity entity, Runnable task);
|
||||||
|
void runAtLocation(Plugin plugin, Location location, Runnable task);
|
||||||
|
|
||||||
|
static SchedulerAdapter create(Plugin plugin) {
|
||||||
|
if (isFolia()) return new FoliaSchedulerAdapter();
|
||||||
|
return new PaperSchedulerAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isFolia() {
|
||||||
|
try {
|
||||||
|
Class.forName("io.papermc.paper.threadedregions.RegionizedServer");
|
||||||
|
return true;
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.RankDefinition;
|
||||||
|
import com.nickcore.common.validation.InputSanitizer;
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
import io.papermc.paper.event.player.AsyncChatEvent;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.EventPriority;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat formatting service using AsyncChatEvent and Adventure API.
|
||||||
|
* Uses ViewerUnaware renderer for performance (single render per message).
|
||||||
|
*/
|
||||||
|
public final class ChatService implements Listener {
|
||||||
|
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final RankService rankService;
|
||||||
|
private final Logger logger;
|
||||||
|
private final MiniMessage miniMessage;
|
||||||
|
|
||||||
|
/** Chat format template — configurable, uses MiniMessage. */
|
||||||
|
private volatile String chatFormat = "<prefix><display_name><suffix><gray>: </gray><message>";
|
||||||
|
|
||||||
|
public ChatService(ProfileCache profileCache, RankService rankService, Logger logger) {
|
||||||
|
this.profileCache = Objects.requireNonNull(profileCache);
|
||||||
|
this.rankService = Objects.requireNonNull(rankService);
|
||||||
|
this.logger = Objects.requireNonNull(logger);
|
||||||
|
this.miniMessage = MiniMessage.miniMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChatFormat(String format) {
|
||||||
|
this.chatFormat = format;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
|
||||||
|
public void onChat(AsyncChatEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||||
|
|
||||||
|
String displayName = (profile != null && profile.effectiveDisplayName() != null)
|
||||||
|
? profile.effectiveDisplayName() : player.getName();
|
||||||
|
|
||||||
|
RankDefinition rank = (profile != null && profile.rankId() != null)
|
||||||
|
? rankService.getRank(profile.rankId()).orElse(null) : null;
|
||||||
|
|
||||||
|
String prefix = rank != null ? rank.formattedPrefix() : "";
|
||||||
|
String suffix = rank != null ? rank.formattedSuffix() : "";
|
||||||
|
|
||||||
|
// Escape display name to prevent MiniMessage injection
|
||||||
|
String safeDisplayName = InputSanitizer.escapeMiniMessage(displayName);
|
||||||
|
|
||||||
|
event.renderer((source, sourceDisplayName, message, viewer) -> {
|
||||||
|
String formatted = chatFormat
|
||||||
|
.replace("<prefix>", prefix)
|
||||||
|
.replace("<display_name>", safeDisplayName)
|
||||||
|
.replace("<suffix>", suffix)
|
||||||
|
.replace("<message>", miniMessage.serialize(message));
|
||||||
|
|
||||||
|
return miniMessage.deserialize(formatted);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
|
||||||
|
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.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages full name override so that nicked names replace real names everywhere:
|
||||||
|
* tab completion, /tpa, /msg, player list, nametags, and any plugin that resolves
|
||||||
|
* players by display name.
|
||||||
|
*
|
||||||
|
* <p>Maintains a bidirectional mapping between nick names and real names so that
|
||||||
|
* commands referencing a nick name can be resolved back to the actual player.</p>
|
||||||
|
*
|
||||||
|
* <p>Thread-safe: all maps are ConcurrentHashMap.</p>
|
||||||
|
*/
|
||||||
|
public final class NameOverrideService {
|
||||||
|
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final MiniMessage miniMessage = MiniMessage.miniMessage();
|
||||||
|
|
||||||
|
/** nick (lowercase) -> real player UUID */
|
||||||
|
private final ConcurrentHashMap<String, UUID> nickToUuid = new ConcurrentHashMap<>(512);
|
||||||
|
|
||||||
|
/** real name (lowercase) -> nick name */
|
||||||
|
private final ConcurrentHashMap<String, String> realToNick = new ConcurrentHashMap<>(512);
|
||||||
|
|
||||||
|
public NameOverrideService(ProfileCache profileCache) {
|
||||||
|
this.profileCache = Objects.requireNonNull(profileCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a full name override to a player: display name, player list name,
|
||||||
|
* custom name, and registers the nick in lookup maps.
|
||||||
|
*/
|
||||||
|
public void applyFullOverride(Player player) {
|
||||||
|
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||||
|
|
||||||
|
// Clear old mapping for this player
|
||||||
|
clearMapping(player);
|
||||||
|
|
||||||
|
if (profile == null || !profile.nicked() || profile.nickname() == null) {
|
||||||
|
// Not nicked — restore real identity
|
||||||
|
player.displayName(Component.text(player.getName()));
|
||||||
|
player.playerListName(Component.text(player.getName()));
|
||||||
|
player.customName(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String nick = profile.nickname();
|
||||||
|
Component nickComponent = Component.text(nick);
|
||||||
|
|
||||||
|
// Set all name surfaces
|
||||||
|
player.displayName(nickComponent);
|
||||||
|
player.playerListName(nickComponent);
|
||||||
|
player.customName(nickComponent);
|
||||||
|
|
||||||
|
// Register bidirectional mapping
|
||||||
|
nickToUuid.put(nick.toLowerCase(), player.getUniqueId());
|
||||||
|
realToNick.put(player.getName().toLowerCase(), nick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all name mappings for a player. Called on quit or reset.
|
||||||
|
*/
|
||||||
|
public void clearMapping(Player player) {
|
||||||
|
String realLower = player.getName().toLowerCase();
|
||||||
|
String oldNick = realToNick.remove(realLower);
|
||||||
|
if (oldNick != null) {
|
||||||
|
nickToUuid.remove(oldNick.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a player by either their nick name or real name.
|
||||||
|
* This is the key method that allows /tpa, /msg, etc. to work with nicks.
|
||||||
|
*
|
||||||
|
* @param name the name to look up (nick or real)
|
||||||
|
* @return the player if found and online
|
||||||
|
*/
|
||||||
|
public Optional<Player> resolvePlayer(String name) {
|
||||||
|
if (name == null) return Optional.empty();
|
||||||
|
String lower = name.toLowerCase();
|
||||||
|
|
||||||
|
// 1. Check if it's a nick name
|
||||||
|
UUID uuid = nickToUuid.get(lower);
|
||||||
|
if (uuid != null) {
|
||||||
|
Player player = Bukkit.getPlayer(uuid);
|
||||||
|
if (player != null && player.isOnline()) return Optional.of(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fall back to real name
|
||||||
|
Player player = Bukkit.getPlayerExact(name);
|
||||||
|
return Optional.ofNullable(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for a player (nick if active, real name otherwise).
|
||||||
|
*/
|
||||||
|
public String getDisplayName(Player player) {
|
||||||
|
String nick = realToNick.get(player.getName().toLowerCase());
|
||||||
|
return nick != null ? nick : player.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the display name for a player by UUID.
|
||||||
|
*/
|
||||||
|
public String getDisplayName(UUID uuid) {
|
||||||
|
Player player = Bukkit.getPlayer(uuid);
|
||||||
|
if (player == null) return null;
|
||||||
|
return getDisplayName(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the nick name for a real name, or the real name if not nicked.
|
||||||
|
* Used by tab completion to replace real names with nicks.
|
||||||
|
*/
|
||||||
|
public String getNickOrReal(String realName) {
|
||||||
|
String nick = realToNick.get(realName.toLowerCase());
|
||||||
|
return nick != null ? nick : realName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a name is a registered nick.
|
||||||
|
*/
|
||||||
|
public boolean isNickName(String name) {
|
||||||
|
return nickToUuid.containsKey(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a snapshot of all active nick-to-UUID mappings.
|
||||||
|
*/
|
||||||
|
public Map<String, UUID> getNickMappings() {
|
||||||
|
return Map.copyOf(nickToUuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all mappings. Called on plugin shutdown.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
nickToUuid.clear();
|
||||||
|
realToNick.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.RankDefinition;
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.scoreboard.Scoreboard;
|
||||||
|
import org.bukkit.scoreboard.Team;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nametag management via scoreboard teams.
|
||||||
|
* Efficient updates with caching to prevent scoreboard spam.
|
||||||
|
*/
|
||||||
|
public final class NametagService {
|
||||||
|
|
||||||
|
private static final String TEAM_PREFIX = "nc_";
|
||||||
|
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final RankService rankService;
|
||||||
|
private final Logger logger;
|
||||||
|
private final MiniMessage miniMessage;
|
||||||
|
|
||||||
|
/** Tracks current team assignments to avoid redundant updates. */
|
||||||
|
private final ConcurrentHashMap<UUID, String> teamAssignments = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public NametagService(ProfileCache profileCache, RankService rankService, Logger logger) {
|
||||||
|
this.profileCache = Objects.requireNonNull(profileCache);
|
||||||
|
this.rankService = Objects.requireNonNull(rankService);
|
||||||
|
this.logger = Objects.requireNonNull(logger);
|
||||||
|
this.miniMessage = MiniMessage.miniMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the nametag for a player based on their current nick profile.
|
||||||
|
*/
|
||||||
|
public void updateNametag(Player player) {
|
||||||
|
NickProfile profile = profileCache.get(player.getUniqueId()).orElse(null);
|
||||||
|
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||||
|
|
||||||
|
String rankId = profile != null ? profile.rankId() : null;
|
||||||
|
RankDefinition rank = rankId != null ? rankService.getRank(rankId).orElse(null) : null;
|
||||||
|
|
||||||
|
// Determine team name based on rank weight for sorting
|
||||||
|
int weight = rank != null ? 999 - rank.weight() : 999;
|
||||||
|
String teamName = TEAM_PREFIX + String.format("%03d", weight) + "_" + player.getName();
|
||||||
|
if (teamName.length() > 16) teamName = teamName.substring(0, 16);
|
||||||
|
|
||||||
|
// Check if update is needed
|
||||||
|
String currentTeam = teamAssignments.get(player.getUniqueId());
|
||||||
|
if (teamName.equals(currentTeam)) return;
|
||||||
|
|
||||||
|
// Remove from old team
|
||||||
|
if (currentTeam != null) {
|
||||||
|
Team oldTeam = scoreboard.getTeam(currentTeam);
|
||||||
|
if (oldTeam != null) {
|
||||||
|
oldTeam.removeEntry(player.getName());
|
||||||
|
if (oldTeam.getEntries().isEmpty()) oldTeam.unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create/update team
|
||||||
|
Team team = scoreboard.getTeam(teamName);
|
||||||
|
if (team == null) team = scoreboard.registerNewTeam(teamName);
|
||||||
|
|
||||||
|
if (rank != null) {
|
||||||
|
if (!rank.prefix().isEmpty()) {
|
||||||
|
team.prefix(miniMessage.deserialize(rank.formattedPrefix()));
|
||||||
|
}
|
||||||
|
if (!rank.suffix().isEmpty()) {
|
||||||
|
team.suffix(miniMessage.deserialize(rank.formattedSuffix()));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
team.color(NamedTextColor.NAMES.value(rank.color()));
|
||||||
|
} catch (Exception ignored) { /* invalid color name */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
team.addEntry(player.getName());
|
||||||
|
teamAssignments.put(player.getUniqueId(), teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a player's nametag team. Called on quit.
|
||||||
|
*/
|
||||||
|
public void removeNametag(Player player) {
|
||||||
|
String teamName = teamAssignments.remove(player.getUniqueId());
|
||||||
|
if (teamName != null) {
|
||||||
|
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||||
|
Team team = scoreboard.getTeam(teamName);
|
||||||
|
if (team != null) {
|
||||||
|
team.removeEntry(player.getName());
|
||||||
|
if (team.getEntries().isEmpty()) team.unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all NickCore-managed teams from the scoreboard.
|
||||||
|
*/
|
||||||
|
public void clearAllNametags() {
|
||||||
|
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
|
||||||
|
for (Team team : scoreboard.getTeams()) {
|
||||||
|
if (team.getName().startsWith(TEAM_PREFIX)) {
|
||||||
|
team.unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
teamAssignments.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
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.integration.VelocityMessaging;
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Optional: set when Velocity is enabled for cross-server sync
|
||||||
|
private volatile VelocityMessaging velocityMessaging;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Sync to other servers via Velocity
|
||||||
|
if (velocityMessaging != null) {
|
||||||
|
velocityMessaging.syncProfile(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);
|
||||||
|
|
||||||
|
// Sync reset to other servers
|
||||||
|
if (velocityMessaging != null) {
|
||||||
|
velocityMessaging.syncReset(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the VelocityMessaging instance for cross-server sync.
|
||||||
|
*/
|
||||||
|
public void setVelocityMessaging(VelocityMessaging velocityMessaging) {
|
||||||
|
this.velocityMessaging = velocityMessaging;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a rank change to the database and syncs to Velocity.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Void> saveRankChange(java.util.UUID uuid, String rankId) {
|
||||||
|
NickProfile profile = cache.get(uuid).orElse(NickProfile.empty(uuid));
|
||||||
|
NickProfile updated = profile.withRank(rankId);
|
||||||
|
cache.put(updated);
|
||||||
|
|
||||||
|
if (velocityMessaging != null) {
|
||||||
|
velocityMessaging.syncRankUpdate(uuid, rankId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return repository.save(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result Container ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a nick operation.
|
||||||
|
*/
|
||||||
|
public record NickResult(boolean success, String message, NickProfile profile) {
|
||||||
|
public static NickResult success(NickProfile profile) {
|
||||||
|
return new NickResult(true, null, profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static NickResult failure(String message) {
|
||||||
|
return new NickResult(false, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.nickcore.common.model.RankDefinition;
|
||||||
|
import com.nickcore.paper.cache.ProfileCache;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast placeholder resolution service.
|
||||||
|
* Pre-computes placeholder values on state changes, serves from memory cache.
|
||||||
|
* Zero DB hits during placeholder resolution.
|
||||||
|
*/
|
||||||
|
public final class PlaceholderService {
|
||||||
|
|
||||||
|
private final ProfileCache profileCache;
|
||||||
|
private final RankService rankService;
|
||||||
|
private final ConcurrentHashMap<UUID, Map<String, String>> placeholderCache = new ConcurrentHashMap<>(512);
|
||||||
|
|
||||||
|
public PlaceholderService(ProfileCache profileCache, RankService rankService) {
|
||||||
|
this.profileCache = Objects.requireNonNull(profileCache);
|
||||||
|
this.rankService = Objects.requireNonNull(rankService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds all placeholder values for a player. Called on nick/rank/skin change.
|
||||||
|
*/
|
||||||
|
public void invalidate(UUID uuid, String realName) {
|
||||||
|
NickProfile profile = profileCache.get(uuid).orElse(null);
|
||||||
|
if (profile == null) {
|
||||||
|
placeholderCache.remove(uuid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RankDefinition rank = profile.rankId() != null
|
||||||
|
? rankService.getRank(profile.rankId()).orElse(null) : null;
|
||||||
|
|
||||||
|
Map<String, String> values = Map.ofEntries(
|
||||||
|
Map.entry("nick", profile.nickname() != null ? profile.nickname() : realName),
|
||||||
|
Map.entry("real_name", realName),
|
||||||
|
Map.entry("rank", rank != null ? rank.displayName() : ""),
|
||||||
|
Map.entry("rank_prefix", rank != null ? rank.prefix() : ""),
|
||||||
|
Map.entry("rank_suffix", rank != null ? rank.suffix() : ""),
|
||||||
|
Map.entry("skin", profile.skinId() != null ? profile.skinId() : ""),
|
||||||
|
Map.entry("is_nicked", String.valueOf(profile.nicked())),
|
||||||
|
Map.entry("display_name", profile.effectiveDisplayName() != null
|
||||||
|
? profile.effectiveDisplayName() : realName),
|
||||||
|
Map.entry("original_name", realName)
|
||||||
|
);
|
||||||
|
|
||||||
|
placeholderCache.put(uuid, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a placeholder for a player. Returns from cache (zero DB hits).
|
||||||
|
*/
|
||||||
|
public String resolve(UUID uuid, String placeholder) {
|
||||||
|
Map<String, String> values = placeholderCache.get(uuid);
|
||||||
|
if (values == null) return "";
|
||||||
|
return values.getOrDefault(placeholder, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all cached placeholders for a player.
|
||||||
|
*/
|
||||||
|
public void remove(UUID uuid) {
|
||||||
|
placeholderCache.remove(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all placeholder caches.
|
||||||
|
*/
|
||||||
|
public void clear() {
|
||||||
|
placeholderCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.RankDefinition;
|
||||||
|
import net.kyori.adventure.text.Component;
|
||||||
|
import net.kyori.adventure.text.minimessage.MiniMessage;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing visual rank definitions and player rank assignments.
|
||||||
|
*/
|
||||||
|
public final class RankService {
|
||||||
|
|
||||||
|
private final Logger logger;
|
||||||
|
private final MiniMessage miniMessage;
|
||||||
|
private volatile Map<String, RankDefinition> ranks = Map.of();
|
||||||
|
private volatile List<RankDefinition> sortedRanks = List.of();
|
||||||
|
|
||||||
|
public RankService(Logger logger) {
|
||||||
|
this.logger = Objects.requireNonNull(logger);
|
||||||
|
this.miniMessage = MiniMessage.miniMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadRanks(Collection<RankDefinition> definitions) {
|
||||||
|
Map<String, RankDefinition> newRanks = new LinkedHashMap<>();
|
||||||
|
for (RankDefinition rank : definitions) newRanks.put(rank.id(), rank);
|
||||||
|
this.ranks = Map.copyOf(newRanks);
|
||||||
|
this.sortedRanks = definitions.stream()
|
||||||
|
.sorted(Comparator.comparingInt(RankDefinition::weight).reversed()).toList();
|
||||||
|
logger.info("Loaded " + definitions.size() + " rank definitions");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<RankDefinition> getRank(String rankId) {
|
||||||
|
return rankId == null ? Optional.empty() : Optional.ofNullable(ranks.get(rankId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<RankDefinition> getSortedRanks() { return sortedRanks; }
|
||||||
|
|
||||||
|
public List<RankDefinition> getAvailableRanks(Player player) {
|
||||||
|
return sortedRanks.stream().filter(r -> player.hasPermission(r.permission())).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasRankPermission(Player player, String rankId) {
|
||||||
|
return getRank(rankId).map(r -> player.hasPermission(r.permission())).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Component renderPrefix(RankDefinition rank) {
|
||||||
|
if (rank == null || rank.prefix().isEmpty()) return Component.empty();
|
||||||
|
return miniMessage.deserialize(rank.formattedPrefix());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Component renderSuffix(RankDefinition rank) {
|
||||||
|
if (rank == null || rank.suffix().isEmpty()) return Component.empty();
|
||||||
|
return miniMessage.deserialize(rank.formattedSuffix());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Component renderDisplayName(String displayName, RankDefinition rank) {
|
||||||
|
return Component.empty().append(renderPrefix(rank))
|
||||||
|
.append(miniMessage.deserialize(displayName)).append(renderSuffix(rank));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRankCount() { return ranks.size(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package com.nickcore.paper.service;
|
||||||
|
|
||||||
|
import com.nickcore.common.model.SkinData;
|
||||||
|
import com.nickcore.paper.repository.NickRepository;
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.plugin.Plugin;
|
||||||
|
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.*;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skin fetching and caching service.
|
||||||
|
* Multi-tier cache: memory -> database -> Mojang API.
|
||||||
|
* Rate-limited, deduplicating, fully async.
|
||||||
|
*/
|
||||||
|
public final class SkinService {
|
||||||
|
|
||||||
|
private static final String PROFILE_URL = "https://sessionserver.mojang.com/session/minecraft/profile/%s?unsigned=false";
|
||||||
|
private static final String UUID_URL = "https://api.mojang.com/users/profiles/minecraft/%s";
|
||||||
|
private static final Duration CACHE_DURATION = Duration.ofHours(6);
|
||||||
|
|
||||||
|
private final NickRepository repository;
|
||||||
|
private final Plugin plugin;
|
||||||
|
private final Logger logger;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
/** Memory cache for skin data. */
|
||||||
|
private final ConcurrentHashMap<String, SkinData> memoryCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Deduplication map: prevents duplicate concurrent requests for the same skin. */
|
||||||
|
private final ConcurrentHashMap<String, CompletableFuture<Optional<SkinData>>> inflightRequests = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
/** Rate limiter: semaphore limiting concurrent Mojang API requests. */
|
||||||
|
private final Semaphore rateLimiter = new Semaphore(2);
|
||||||
|
|
||||||
|
/** Configured skins loaded from skins.yml. */
|
||||||
|
private volatile Map<String, SkinData> configuredSkins = Map.of();
|
||||||
|
|
||||||
|
public SkinService(NickRepository repository, Plugin plugin, Logger logger) {
|
||||||
|
this.repository = Objects.requireNonNull(repository);
|
||||||
|
this.plugin = Objects.requireNonNull(plugin);
|
||||||
|
this.logger = Objects.requireNonNull(logger);
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads configured skins from config. Atomic swap.
|
||||||
|
*/
|
||||||
|
public void loadConfiguredSkins(Map<String, SkinData> skins) {
|
||||||
|
this.configuredSkins = Map.copyOf(skins);
|
||||||
|
logger.info("Loaded " + skins.size() + " configured skins");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all configured skin names.
|
||||||
|
*/
|
||||||
|
public List<String> getConfiguredSkinNames() {
|
||||||
|
return List.copyOf(configuredSkins.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a configured skin by name.
|
||||||
|
*/
|
||||||
|
public Optional<SkinData> getConfiguredSkin(String name) {
|
||||||
|
return Optional.ofNullable(configuredSkins.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches skin data with multi-tier caching and deduplication.
|
||||||
|
* Memory -> DB cache -> Mojang API.
|
||||||
|
*/
|
||||||
|
public CompletableFuture<Optional<SkinData>> fetchSkin(String playerName) {
|
||||||
|
String key = playerName.toLowerCase();
|
||||||
|
|
||||||
|
// Tier 1: Memory cache
|
||||||
|
SkinData cached = memoryCache.get(key);
|
||||||
|
if (cached != null && !cached.isExpired()) {
|
||||||
|
return CompletableFuture.completedFuture(Optional.of(cached));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate: if there's already a request in flight, return same future
|
||||||
|
return inflightRequests.computeIfAbsent(key, k -> fetchSkinInternal(k, playerName)
|
||||||
|
.whenComplete((result, ex) -> inflightRequests.remove(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Optional<SkinData>> fetchSkinInternal(String key, String playerName) {
|
||||||
|
// Tier 2: DB cache
|
||||||
|
return repository.getCachedSkin(key).thenCompose(dbCached -> {
|
||||||
|
if (dbCached.isPresent() && !dbCached.get().isExpired()) {
|
||||||
|
memoryCache.put(key, dbCached.get());
|
||||||
|
return CompletableFuture.completedFuture(dbCached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3: Mojang API
|
||||||
|
return fetchFromMojang(playerName).thenApply(skinOpt -> {
|
||||||
|
skinOpt.ifPresent(skin -> {
|
||||||
|
memoryCache.put(key, skin);
|
||||||
|
repository.cacheSkin(skin); // fire-and-forget
|
||||||
|
});
|
||||||
|
return skinOpt;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private CompletableFuture<Optional<SkinData>> fetchFromMojang(String playerName) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
rateLimiter.acquire();
|
||||||
|
try {
|
||||||
|
// Step 1: Get UUID
|
||||||
|
String uuidUrl = String.format(UUID_URL, playerName);
|
||||||
|
HttpResponse<String> uuidResp = httpClient.send(
|
||||||
|
HttpRequest.newBuilder(URI.create(uuidUrl)).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (uuidResp.statusCode() != 200) return Optional.<SkinData>empty();
|
||||||
|
|
||||||
|
String uuid = parseJsonField(uuidResp.body(), "id");
|
||||||
|
if (uuid == null) return Optional.<SkinData>empty();
|
||||||
|
|
||||||
|
// Step 2: Get profile with textures
|
||||||
|
String profileUrl = String.format(PROFILE_URL, uuid);
|
||||||
|
HttpResponse<String> profileResp = httpClient.send(
|
||||||
|
HttpRequest.newBuilder(URI.create(profileUrl)).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (profileResp.statusCode() != 200) return Optional.<SkinData>empty();
|
||||||
|
|
||||||
|
String value = parseTextureValue(profileResp.body());
|
||||||
|
String signature = parseTextureSignature(profileResp.body());
|
||||||
|
|
||||||
|
if (value == null || signature == null) return Optional.<SkinData>empty();
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
return Optional.of(new SkinData(
|
||||||
|
playerName.toLowerCase(), value, signature,
|
||||||
|
now, now.plus(CACHE_DURATION)));
|
||||||
|
} finally {
|
||||||
|
rateLimiter.release();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.log(Level.WARNING, "Failed to fetch skin for " + playerName, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a skin to a player by cycling visibility.
|
||||||
|
* Must be called from the appropriate scheduler context.
|
||||||
|
*/
|
||||||
|
public void applySkin(Player player, SkinData skin) {
|
||||||
|
if (skin == null || !player.isOnline()) return;
|
||||||
|
|
||||||
|
// Apply skin via player profile
|
||||||
|
var profile = player.getPlayerProfile();
|
||||||
|
var textures = profile.getProperties();
|
||||||
|
textures.removeIf(p -> p.getName().equals("textures"));
|
||||||
|
textures.add(new com.destroystokyo.paper.profile.ProfileProperty(
|
||||||
|
"textures", skin.textureValue(), skin.textureSignature()));
|
||||||
|
player.setPlayerProfile(profile);
|
||||||
|
|
||||||
|
// Refresh visibility for all online players
|
||||||
|
for (Player other : Bukkit.getOnlinePlayers()) {
|
||||||
|
if (other.equals(player)) continue;
|
||||||
|
if (other.canSee(player)) {
|
||||||
|
other.hidePlayer(plugin, player);
|
||||||
|
other.showPlayer(plugin, player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the memory cache for a specific skin.
|
||||||
|
*/
|
||||||
|
public void invalidateCache(String skinName) {
|
||||||
|
memoryCache.remove(skinName.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all memory caches.
|
||||||
|
*/
|
||||||
|
public void clearCache() {
|
||||||
|
memoryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple JSON field parsers (avoids GSON dependency for minimal fields)
|
||||||
|
private static String parseJsonField(String json, String field) {
|
||||||
|
String key = "\"" + field + "\"";
|
||||||
|
int idx = json.indexOf(key);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
int valStart = json.indexOf("\"", idx + key.length() + 1);
|
||||||
|
if (valStart < 0) return null;
|
||||||
|
int valEnd = json.indexOf("\"", valStart + 1);
|
||||||
|
if (valEnd < 0) return null;
|
||||||
|
return json.substring(valStart + 1, valEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseTextureValue(String json) {
|
||||||
|
int propIdx = json.indexOf("\"properties\"");
|
||||||
|
if (propIdx < 0) return null;
|
||||||
|
String sub = json.substring(propIdx);
|
||||||
|
return parseJsonField(sub, "value");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String parseTextureSignature(String json) {
|
||||||
|
int propIdx = json.indexOf("\"properties\"");
|
||||||
|
if (propIdx < 0) return null;
|
||||||
|
String sub = json.substring(propIdx);
|
||||||
|
return parseJsonField(sub, "signature");
|
||||||
|
}
|
||||||
|
}
|
||||||
61
nickcore-paper/src/main/resources/config.yml
Normal file
61
nickcore-paper/src/main/resources/config.yml
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Configuration ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
# Plugin prefix (MiniMessage format)
|
||||||
|
prefix: "<gradient:#00BFFF:#1E90FF>[NickCore]</gradient> "
|
||||||
|
|
||||||
|
# Language/locale
|
||||||
|
locale: en_US
|
||||||
|
|
||||||
|
# Enable debug logging
|
||||||
|
debug: false
|
||||||
|
|
||||||
|
# Allow players with nickcore.name.custom to enter any nickname
|
||||||
|
allow-custom-names: false
|
||||||
|
|
||||||
|
# Allow multiple players to use the same nickname
|
||||||
|
allow-duplicate-nicks: false
|
||||||
|
|
||||||
|
# Nickname length limits
|
||||||
|
min-nick-length: 3
|
||||||
|
max-nick-length: 16
|
||||||
|
|
||||||
|
# ── Chat Configuration ───────────────────────────────────────
|
||||||
|
chat:
|
||||||
|
# Enable chat formatting (disable if using another chat plugin)
|
||||||
|
enabled: true
|
||||||
|
# Chat format (MiniMessage)
|
||||||
|
# Placeholders: <prefix>, <display_name>, <suffix>, <message>
|
||||||
|
format: "<prefix><display_name><suffix><gray>: </gray><message>"
|
||||||
|
|
||||||
|
# ── Security Configuration ───────────────────────────────────
|
||||||
|
security:
|
||||||
|
# Regex for allowed nickname characters
|
||||||
|
allowed-characters: "^[a-zA-Z0-9_]+$"
|
||||||
|
# Block Unicode homoglyphs (Cyrillic/Greek lookalikes)
|
||||||
|
block-homoglyphs: true
|
||||||
|
# Block invisible/zero-width characters
|
||||||
|
block-invisible-chars: true
|
||||||
|
# Prevent names similar to online players
|
||||||
|
prevent-impersonation: true
|
||||||
|
# Levenshtein distance threshold for impersonation detection
|
||||||
|
impersonation-threshold: 2
|
||||||
|
# Names that cannot be used as nicknames
|
||||||
|
reserved-names:
|
||||||
|
- Server
|
||||||
|
- Console
|
||||||
|
- Admin
|
||||||
|
- Moderator
|
||||||
|
- Owner
|
||||||
|
- Staff
|
||||||
|
- Helper
|
||||||
|
# Real player names that are protected from impersonation
|
||||||
|
protected-names: []
|
||||||
|
|
||||||
|
# ── Velocity Configuration ───────────────────────────────────
|
||||||
|
velocity:
|
||||||
|
# Enable Velocity plugin messaging
|
||||||
|
enabled: false
|
||||||
|
# This server's name (must match Velocity config)
|
||||||
|
server-name: "lobby"
|
||||||
24
nickcore-paper/src/main/resources/database.yml
Normal file
24
nickcore-paper/src/main/resources/database.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Database Configuration ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
# Database type: sqlite, mysql, or mariadb
|
||||||
|
type: sqlite
|
||||||
|
|
||||||
|
# MySQL/MariaDB connection settings (ignored for SQLite)
|
||||||
|
host: localhost
|
||||||
|
port: 3306
|
||||||
|
database: nickcore
|
||||||
|
username: root
|
||||||
|
password: ""
|
||||||
|
|
||||||
|
# Table prefix
|
||||||
|
table-prefix: "nickcore_"
|
||||||
|
|
||||||
|
# Connection pool settings
|
||||||
|
pool:
|
||||||
|
max-size: 10
|
||||||
|
min-idle: 2
|
||||||
|
connection-timeout: 5000 # ms
|
||||||
|
idle-timeout: 300000 # ms (5 minutes)
|
||||||
|
max-lifetime: 600000 # ms (10 minutes)
|
||||||
53
nickcore-paper/src/main/resources/messages.yml
Normal file
53
nickcore-paper/src/main/resources/messages.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Messages ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
# All messages use MiniMessage format.
|
||||||
|
# See: https://docs.advntr.dev/minimessage/format.html
|
||||||
|
|
||||||
|
messages:
|
||||||
|
# General
|
||||||
|
no-permission: "<red>You don't have permission to do that."
|
||||||
|
player-only: "<red>Only players can use this command."
|
||||||
|
player-not-found: "<red>Player not found."
|
||||||
|
reload-success: "<green>Configuration reloaded successfully."
|
||||||
|
reload-failed: "<red>Failed to reload configuration: <message>"
|
||||||
|
|
||||||
|
# Nickname
|
||||||
|
nick-set: "<green>Nickname set to <white><nickname>"
|
||||||
|
nick-reset: "<green>Your identity has been restored."
|
||||||
|
nick-already-active: "<red>You already have that nickname."
|
||||||
|
nick-taken: "<red>That nickname is already in use."
|
||||||
|
nick-invalid: "<red>Invalid nickname: <reason>"
|
||||||
|
nick-similar: "<red>Nickname too similar to online player: <names>"
|
||||||
|
nick-applying: "<gray>Applying nickname..."
|
||||||
|
|
||||||
|
# Random
|
||||||
|
random-success: "<green>Random nickname: <white><nickname>"
|
||||||
|
random-failed: "<red>Could not find an available random nickname."
|
||||||
|
random-no-pool: "<red>No names available in the name pool."
|
||||||
|
|
||||||
|
# Rank
|
||||||
|
rank-set: "<green>Rank set to <rank>"
|
||||||
|
rank-no-permission: "<red>You don't have permission for that rank."
|
||||||
|
rank-not-found: "<red>Rank not found."
|
||||||
|
|
||||||
|
# Skin
|
||||||
|
skin-applying: "<gray>Applying skin..."
|
||||||
|
skin-applied: "<green>Skin applied!"
|
||||||
|
skin-not-found: "<red>Skin not found."
|
||||||
|
skin-failed: "<red>Failed to apply skin."
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
admin-force-success: "<green>Forced nickname on <player>."
|
||||||
|
admin-force-target: "<yellow>An admin set your nickname to <white><nickname>"
|
||||||
|
admin-clear-success: "<green>Cleared nickname for <player>."
|
||||||
|
admin-clear-target: "<yellow>Your nickname has been cleared by an admin."
|
||||||
|
|
||||||
|
# Info
|
||||||
|
info-header: "<gold>===== Player Info ====="
|
||||||
|
info-real-name: "<yellow>Real Name: <white><name>"
|
||||||
|
info-nickname: "<yellow>Nickname: <white><nickname>"
|
||||||
|
info-rank: "<yellow>Rank: <white><rank>"
|
||||||
|
info-skin: "<yellow>Skin: <white><skin>"
|
||||||
|
info-nicked: "<yellow>Nicked: <white><status>"
|
||||||
|
info-no-nick: "<gray>No active nickname."
|
||||||
57
nickcore-paper/src/main/resources/names.yml
Normal file
57
nickcore-paper/src/main/resources/names.yml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Name Pool ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
# Approved nicknames for random selection and the nickname menu.
|
||||||
|
# Players without nickcore.name.custom must choose from this list.
|
||||||
|
|
||||||
|
names:
|
||||||
|
- ShadowWolf
|
||||||
|
- FrostByte
|
||||||
|
- CrimsonKnight
|
||||||
|
- Eclipse
|
||||||
|
- Phantom
|
||||||
|
- NightShade
|
||||||
|
- StormBringer
|
||||||
|
- IronVeil
|
||||||
|
- LunarFox
|
||||||
|
- EmberStrike
|
||||||
|
- ArcticBlaze
|
||||||
|
- SilverHawk
|
||||||
|
- ThunderVolt
|
||||||
|
- MysticRune
|
||||||
|
- ObsidianEdge
|
||||||
|
- CelestialWing
|
||||||
|
- VenomFang
|
||||||
|
- GhostReaper
|
||||||
|
- BlazingComet
|
||||||
|
- DarkMatter
|
||||||
|
- AuroraHunter
|
||||||
|
- CrystalForge
|
||||||
|
- SteelNova
|
||||||
|
- VoidWalker
|
||||||
|
- TitanFist
|
||||||
|
- PhoenixRise
|
||||||
|
- NeonDrift
|
||||||
|
- ShadowMerge
|
||||||
|
- FrostFire
|
||||||
|
- StormClaw
|
||||||
|
- IronPulse
|
||||||
|
- MoonStriker
|
||||||
|
- ThunderWing
|
||||||
|
- CrimsonVeil
|
||||||
|
- ArcticHunter
|
||||||
|
- EmberShade
|
||||||
|
- NightProwler
|
||||||
|
- SilverStorm
|
||||||
|
- VenomTide
|
||||||
|
- BlazeShard
|
||||||
|
- DarkVoyager
|
||||||
|
- CelestialDawn
|
||||||
|
- GhostFlame
|
||||||
|
- CrystalBlade
|
||||||
|
- SteelWraith
|
||||||
|
- VoidForge
|
||||||
|
- TitanEdge
|
||||||
|
- PhoenixStar
|
||||||
|
- NeonBlaze
|
||||||
|
- ShadowBorne
|
||||||
64
nickcore-paper/src/main/resources/plugin.yml
Normal file
64
nickcore-paper/src/main/resources/plugin.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
name: NickCore
|
||||||
|
version: '1.0.4'
|
||||||
|
main: com.nickcore.paper.NickCorePlugin
|
||||||
|
api-version: '1.21'
|
||||||
|
description: Enterprise Minecraft Nickname Plugin
|
||||||
|
author: BaileyCodes
|
||||||
|
website: https://nickcore.com
|
||||||
|
folia-supported: true
|
||||||
|
|
||||||
|
softdepend:
|
||||||
|
- PlaceholderAPI
|
||||||
|
- LuckPerms
|
||||||
|
- TAB
|
||||||
|
- Essentials
|
||||||
|
|
||||||
|
commands:
|
||||||
|
nick:
|
||||||
|
description: NickCore main command
|
||||||
|
usage: /nick [subcommand]
|
||||||
|
aliases: [nickname, disguise]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
nickcore.use:
|
||||||
|
description: Access /nick command and GUI
|
||||||
|
default: true
|
||||||
|
nickcore.name.custom:
|
||||||
|
description: Enter custom nicknames
|
||||||
|
default: op
|
||||||
|
nickcore.name.select:
|
||||||
|
description: Select from approved name list
|
||||||
|
default: true
|
||||||
|
nickcore.name.random:
|
||||||
|
description: Generate random nicknames
|
||||||
|
default: true
|
||||||
|
nickcore.rank.select:
|
||||||
|
description: Access rank selection
|
||||||
|
default: false
|
||||||
|
nickcore.skin.select:
|
||||||
|
description: Access skin selection
|
||||||
|
default: false
|
||||||
|
nickcore.skin.keep:
|
||||||
|
description: Keep current skin option
|
||||||
|
default: true
|
||||||
|
nickcore.reset:
|
||||||
|
description: Reset identity
|
||||||
|
default: true
|
||||||
|
nickcore.admin:
|
||||||
|
description: Admin features
|
||||||
|
default: op
|
||||||
|
nickcore.reload:
|
||||||
|
description: Reload configuration
|
||||||
|
default: op
|
||||||
|
nickcore.bypass:
|
||||||
|
description: Bypass restrictions
|
||||||
|
default: op
|
||||||
|
nickcore.force:
|
||||||
|
description: Force nicknames on players
|
||||||
|
default: op
|
||||||
|
nickcore.info:
|
||||||
|
description: View player nick info
|
||||||
|
default: op
|
||||||
|
nickcore.clear:
|
||||||
|
description: Clear player nicknames
|
||||||
|
default: op
|
||||||
78
nickcore-paper/src/main/resources/ranks.yml
Normal file
78
nickcore-paper/src/main/resources/ranks.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Ranks ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
# Visual ranks for the nickname system.
|
||||||
|
# MiniMessage formatting is supported for prefix/suffix.
|
||||||
|
|
||||||
|
ranks:
|
||||||
|
vip:
|
||||||
|
display-name: "VIP"
|
||||||
|
prefix: "<gold>[VIP]</gold>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 10
|
||||||
|
priority: 10
|
||||||
|
color: "gold"
|
||||||
|
permission: "nickcore.rank.vip"
|
||||||
|
|
||||||
|
mvp:
|
||||||
|
display-name: "MVP"
|
||||||
|
prefix: "<aqua>[MVP]</aqua>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 20
|
||||||
|
priority: 20
|
||||||
|
color: "aqua"
|
||||||
|
permission: "nickcore.rank.mvp"
|
||||||
|
|
||||||
|
mvpplus:
|
||||||
|
display-name: "MVP+"
|
||||||
|
prefix: "<light_purple>[MVP+]</light_purple>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 30
|
||||||
|
priority: 30
|
||||||
|
color: "light_purple"
|
||||||
|
permission: "nickcore.rank.mvpplus"
|
||||||
|
|
||||||
|
elite:
|
||||||
|
display-name: "Elite"
|
||||||
|
prefix: "<gradient:#FFD700:#FFA500>[Elite]</gradient>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 40
|
||||||
|
priority: 40
|
||||||
|
color: "yellow"
|
||||||
|
permission: "nickcore.rank.elite"
|
||||||
|
|
||||||
|
legend:
|
||||||
|
display-name: "Legend"
|
||||||
|
prefix: "<gradient:#FF6B6B:#EE5A24>[Legend]</gradient>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 50
|
||||||
|
priority: 50
|
||||||
|
color: "red"
|
||||||
|
permission: "nickcore.rank.legend"
|
||||||
|
|
||||||
|
builder:
|
||||||
|
display-name: "Builder"
|
||||||
|
prefix: "<green>[Builder]</green>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 60
|
||||||
|
priority: 60
|
||||||
|
color: "green"
|
||||||
|
permission: "nickcore.rank.builder"
|
||||||
|
|
||||||
|
moderator:
|
||||||
|
display-name: "Moderator"
|
||||||
|
prefix: "<dark_green>[Mod]</dark_green>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 70
|
||||||
|
priority: 70
|
||||||
|
color: "dark_green"
|
||||||
|
permission: "nickcore.rank.moderator"
|
||||||
|
|
||||||
|
admin:
|
||||||
|
display-name: "Admin"
|
||||||
|
prefix: "<dark_red>[Admin]</dark_red>"
|
||||||
|
suffix: ""
|
||||||
|
weight: 80
|
||||||
|
priority: 80
|
||||||
|
color: "dark_red"
|
||||||
|
permission: "nickcore.rank.admin"
|
||||||
14
nickcore-paper/src/main/resources/skins.yml
Normal file
14
nickcore-paper/src/main/resources/skins.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ NickCore Skins ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
# Pre-configured skins. Provide Mojang texture value and signature.
|
||||||
|
# You can get these from https://mineskin.org
|
||||||
|
|
||||||
|
skins:
|
||||||
|
# Example skin — replace with real texture data
|
||||||
|
# steve:
|
||||||
|
# value: "eyJ0aW1lc3RhbXAiOjE2..."
|
||||||
|
# signature: "dGVzdHNpZ25hdHVyZQ..."
|
||||||
|
# alex:
|
||||||
|
# value: "eyJ0aW1lc3RhbXAiOjE2..."
|
||||||
|
# signature: "dGVzdHNpZ25hdHVyZQ..."
|
||||||
22
nickcore-velocity/build.gradle.kts
Normal file
22
nickcore-velocity/build.gradle.kts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.gradleup.shadow")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
val velocityApiVersion: String by project
|
||||||
|
|
||||||
|
implementation(project(":nickcore-common"))
|
||||||
|
|
||||||
|
compileOnly("com.velocitypowered:velocity-api:$velocityApiVersion")
|
||||||
|
annotationProcessor("com.velocitypowered:velocity-api:$velocityApiVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.shadowJar {
|
||||||
|
archiveClassifier.set("")
|
||||||
|
archiveBaseName.set("NickCore-Velocity")
|
||||||
|
minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.build {
|
||||||
|
dependsOn(tasks.shadowJar)
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package com.nickcore.velocity;
|
||||||
|
|
||||||
|
import com.nickcore.common.dto.NickSyncMessage;
|
||||||
|
import com.nickcore.common.model.NickProfile;
|
||||||
|
import com.velocitypowered.api.event.Subscribe;
|
||||||
|
import com.velocitypowered.api.event.connection.DisconnectEvent;
|
||||||
|
import com.velocitypowered.api.event.player.ServerConnectedEvent;
|
||||||
|
import com.velocitypowered.api.event.connection.PluginMessageEvent;
|
||||||
|
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
|
||||||
|
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
|
||||||
|
import com.velocitypowered.api.plugin.Plugin;
|
||||||
|
import com.velocitypowered.api.proxy.Player;
|
||||||
|
import com.velocitypowered.api.proxy.ProxyServer;
|
||||||
|
import com.velocitypowered.api.proxy.ServerConnection;
|
||||||
|
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NickCore Velocity proxy plugin.
|
||||||
|
* Maintains a cross-server nick profile cache and routes sync messages.
|
||||||
|
*/
|
||||||
|
@Plugin(id = "nickcore", name = "NickCore", version = "1.0.4",
|
||||||
|
authors = {"BaileyCodes"}, description = "Enterprise nickname system - Velocity module")
|
||||||
|
public final class NickCoreVelocity {
|
||||||
|
|
||||||
|
private static final MinecraftChannelIdentifier CHANNEL =
|
||||||
|
MinecraftChannelIdentifier.from(NickSyncMessage.CHANNEL);
|
||||||
|
|
||||||
|
private final ProxyServer proxy;
|
||||||
|
private final Logger logger;
|
||||||
|
|
||||||
|
/** Cross-server nick profile cache. */
|
||||||
|
private final ConcurrentHashMap<UUID, NickProfile> proxyCache = new ConcurrentHashMap<>(512);
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public NickCoreVelocity(ProxyServer proxy, Logger logger) {
|
||||||
|
this.proxy = proxy;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onProxyInit(ProxyInitializeEvent event) {
|
||||||
|
proxy.getChannelRegistrar().register(CHANNEL);
|
||||||
|
logger.info("NickCore Velocity module initialized");
|
||||||
|
logger.info("Sync channel registered: " + NickSyncMessage.CHANNEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onProxyShutdown(ProxyShutdownEvent event) {
|
||||||
|
proxyCache.clear();
|
||||||
|
proxy.getChannelRegistrar().unregister(CHANNEL);
|
||||||
|
logger.info("NickCore Velocity module disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onServerConnected(ServerConnectedEvent event) {
|
||||||
|
Player player = event.getPlayer();
|
||||||
|
NickProfile cached = proxyCache.get(player.getUniqueId());
|
||||||
|
|
||||||
|
if (cached != null && cached.nicked()) {
|
||||||
|
// Forward profile to the new server
|
||||||
|
NickSyncMessage msg = NickSyncMessage.fullSync(cached, "velocity-proxy");
|
||||||
|
event.getServer().sendPluginMessage(CHANNEL, msg.encode());
|
||||||
|
logger.info("Forwarded nick profile for " + player.getUsername() +
|
||||||
|
" to " + event.getServer().getServerInfo().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onDisconnect(DisconnectEvent event) {
|
||||||
|
UUID uuid = event.getPlayer().getUniqueId();
|
||||||
|
proxyCache.remove(uuid);
|
||||||
|
|
||||||
|
// Broadcast cache invalidation to all servers
|
||||||
|
NickSyncMessage invalidate = NickSyncMessage.cacheInvalidate(uuid, "velocity-proxy");
|
||||||
|
broadcastToAllServers(invalidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Subscribe
|
||||||
|
public void onPluginMessage(PluginMessageEvent event) {
|
||||||
|
if (!CHANNEL.equals(event.getIdentifier())) return;
|
||||||
|
|
||||||
|
// Consume the message so it doesn't get forwarded raw
|
||||||
|
event.setResult(PluginMessageEvent.ForwardResult.handled());
|
||||||
|
|
||||||
|
try {
|
||||||
|
NickSyncMessage message = NickSyncMessage.decode(event.getData());
|
||||||
|
|
||||||
|
// Update proxy cache
|
||||||
|
switch (message.action()) {
|
||||||
|
case FULL_SYNC -> {
|
||||||
|
NickProfile profile = new NickProfile(
|
||||||
|
message.playerUuid(), message.nickname(), message.rankId(),
|
||||||
|
message.skinId(), message.skinValue(), message.skinSignature(),
|
||||||
|
message.nickname() != null, null, null
|
||||||
|
);
|
||||||
|
proxyCache.put(message.playerUuid(), profile);
|
||||||
|
}
|
||||||
|
case NICK_UPDATE -> proxyCache.computeIfPresent(message.playerUuid(),
|
||||||
|
(uuid, p) -> p.withNickname(message.nickname()));
|
||||||
|
case RANK_UPDATE -> proxyCache.computeIfPresent(message.playerUuid(),
|
||||||
|
(uuid, p) -> p.withRank(message.rankId()));
|
||||||
|
case RESET -> proxyCache.computeIfPresent(message.playerUuid(),
|
||||||
|
(uuid, p) -> p.reset());
|
||||||
|
case CACHE_INVALIDATE -> proxyCache.remove(message.playerUuid());
|
||||||
|
default -> { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to all OTHER servers
|
||||||
|
broadcastToOtherServers(message, event);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Failed to process sync message: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcastToAllServers(NickSyncMessage message) {
|
||||||
|
byte[] data = message.encode();
|
||||||
|
for (var server : proxy.getAllServers()) {
|
||||||
|
if (!server.getPlayersConnected().isEmpty()) {
|
||||||
|
server.sendPluginMessage(CHANNEL, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void broadcastToOtherServers(NickSyncMessage message, PluginMessageEvent sourceEvent) {
|
||||||
|
byte[] data = message.encode();
|
||||||
|
ServerConnection sourceConn = sourceEvent.getSource() instanceof ServerConnection sc ? sc : null;
|
||||||
|
|
||||||
|
for (var server : proxy.getAllServers()) {
|
||||||
|
// Skip the source server
|
||||||
|
if (sourceConn != null &&
|
||||||
|
server.getServerInfo().equals(sourceConn.getServerInfo())) continue;
|
||||||
|
|
||||||
|
if (!server.getPlayersConnected().isEmpty()) {
|
||||||
|
server.sendPluginMessage(CHANNEL, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the proxy-level cache for external access.
|
||||||
|
*/
|
||||||
|
public Map<UUID, NickProfile> getProxyCache() {
|
||||||
|
return Map.copyOf(proxyCache);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
settings.gradle.kts
Normal file
12
settings.gradle.kts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven("https://repo.papermc.io/repository/maven-public/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "nickcore"
|
||||||
|
|
||||||
|
include("nickcore-common")
|
||||||
|
include("nickcore-paper")
|
||||||
|
include("nickcore-velocity")
|
||||||
Reference in New Issue
Block a user