diff --git a/.github/workflows/dev_build_release.yml b/.github/workflows/dev_build_release.yml new file mode 100644 index 0000000..60181e0 --- /dev/null +++ b/.github/workflows/dev_build_release.yml @@ -0,0 +1,62 @@ +name: Dev Build Release + +on: + push: + branches: + - main + - dev + workflow_dispatch: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout OumProfile + uses: actions/checkout@v4 + + - name: Checkout OumLib + uses: actions/checkout@v4 + with: + repository: sun-mc-dev/oumlib + path: oumlib + ref: dev + token: ${{ secrets.GH_PAT || github.token }} + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'maven' + + - name: Build and Install OumLib + run: mvn clean install -DskipTests -f oumlib/pom.xml + + - name: Build OumProfile + run: | + mvn clean package -DskipTests + cp target/oumprofile-*.jar target/OumProfile-dev.jar + + - name: Delete Old Release + run: | + if gh release view dev-build >/dev/null 2>&1; then + gh release delete dev-build --cleanup-tag --yes + fi + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Re-create Tag + run: | + git tag -d dev-build || true + git tag dev-build + git push origin dev-build + + - name: Publish Dev-Build Release + run: | + gh release create dev-build target/OumProfile-dev.jar --title "Development Build" --notes "This is an automated development build of the main branch." --prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml new file mode 100644 index 0000000..6d6a49d --- /dev/null +++ b/.github/workflows/qodana_code_quality.yml @@ -0,0 +1,35 @@ +#-------------------------------------------------------------------------------# +# Discover additional configuration options in our documentation # +# https://www.jetbrains.com/help/qodana/github.html # +#-------------------------------------------------------------------------------# + +name: Qodana +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - dev + +jobs: + qodana: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2026.1 + with: + pr-mode: false + use-caches: true + post-pr-comment: true + use-annotations: true + upload-result: false + push-fixes: 'none' \ No newline at end of file diff --git a/README.md b/README.md index 4fb6d2d..ad99b81 100644 --- a/README.md +++ b/README.md @@ -71,36 +71,40 @@ The following placeholders are supported under the `oumprofile` namespace: ### Player Commands -| Command | Description | Permission | Default | -|:-------------------------|:-------------------------------------------|:-------------------------|:--------| -| `/profile` | Opens the profile selection GUI. | `profiles.use` | True | -| `/profile list` | Lists all created profiles in chat. | `profiles.use` | True | -| `/profile current` | Displays the active profile name. | `profiles.use` | True | -| `/profile create ` | Creates a new profile with the given name. | `profiles.create.` | OP | -| `/profile switch ` | Switches to the specified profile. | `profiles.use` | True | -| `/profile delete ` | Deletes the specified profile. | `profiles.use` | True | -| `/profile alerts` | Toggles receiving admin profile alerts. | `profiles.alerts` | OP | +| Command | Description | Permission | Default | +|:-----------------------------------|:-------------------------------------------|:-------------------------|:--------| +| `/profile` | Opens the profile selection GUI. | `profiles.use` | True | +| `/profile list` | Lists all created profiles in chat. | `profiles.use` | True | +| `/profile current` | Displays the active profile name. | `profiles.use` | True | +| `/profile create ` | Creates a new profile with the given name. | `profiles.create.` | OP | +| `/profile switch ` | Switches to the specified profile. | `profiles.use` | True | +| `/profile delete ` | Deletes the specified profile. | `profiles.use` | True | +| `/profile rename ` | Renames a profile. | `profiles.use` | True | +| `/profile alerts` | Toggles receiving admin profile alerts. | `profiles.alerts` | OP | *Aliases: `/profiles`, `/prof`* ### Bypass and Administrative Permissions -| Command / Permission | Description | Default | -|:-------------------------------------------|:-------------------------------------------------------------|:-------------------------------| -| `/profile admin-open ` | Opens the profile GUI for the specified player. | OP (requires `profiles.admin`) | -| `/profile admin-list ` | Lists all profiles of the specified player. | OP (requires `profiles.admin`) | -| `/profile admin-create ` | Creates a profile for the specified player. | OP (requires `profiles.admin`) | -| `/profile admin-switch ` | Forces the specified player to switch to a profile. | OP (requires `profiles.admin`) | -| `/profile admin-delete ` | Deletes a profile for the specified player. | OP (requires `profiles.admin`) | -| `/profile debug` | Toggles debug logging in console. | OP (requires `profiles.admin`) | -| `profiles.admin` | Access to administrative commands and config reload. | OP | -| `profiles.create.*` | Permission to create profiles with any name. | OP | -| `profiles.max.unlimited` | Bypasses all profile slot limits. | OP | -| `profiles.max.` | Sets the maximum profile slot limit (e.g. `profiles.max.5`). | False | -| `profiles.bypass.combat` | Allows switching profiles while tagged in combat. | OP | -| `profiles.bypass.warmup` | Bypasses the switch countdown warmup. | OP | -| `profiles.bypass.cooldown` | Bypasses the switch cooldown. | OP | -| `profiles.alerts` | Receives administrative alerts for profile actions. | OP | +| Command / Permission | Description | Default | +|:--------------------------------------------------|:-------------------------------------------------------------|:-------------------------------| +| `/profile admin open ` | Opens the profile GUI for the specified player. | OP (requires `profiles.admin`) | +| `/profile admin list ` | Lists all profiles of the specified player. | OP (requires `profiles.admin`) | +| `/profile admin create ` | Creates a profile for the specified player. | OP (requires `profiles.admin`) | +| `/profile admin switch ` | Forces the specified player to switch to a profile. | OP (requires `profiles.admin`) | +| `/profile admin delete ` | Deletes a profile for the specified player. | OP (requires `profiles.admin`) | +| `/profile admin rename ` | Renames a profile for the specified player. | OP (requires `profiles.admin`) | +| `/profile admin export ` | Exports a player's profile to a JSON file. | OP (requires `profiles.admin`) | +| `/profile admin import ` | Imports a profile from a JSON file for a player. | OP (requires `profiles.admin`) | +| `/profile debug` | Toggles debug logging in console. | OP (requires `profiles.admin`) | +| `profiles.admin` | Access to administrative commands and config reload. | OP | +| `profiles.create.*` | Permission to create profiles with any name. | OP | +| `profiles.max.unlimited` | Bypasses all profile slot limits. | OP | +| `profiles.max.` | Sets the maximum profile slot limit (e.g. `profiles.max.5`). | False | +| `profiles.bypass.combat` | Allows switching profiles while tagged in combat. | OP | +| `profiles.bypass.warmup` | Bypasses the switch countdown warmup. | OP | +| `profiles.bypass.cooldown` | Bypasses the switch cooldown. | OP | +| `profiles.alerts` | Receives administrative alerts for profile actions. | OP | --- @@ -211,6 +215,18 @@ messages: warmupCancelledGeneric: "Profile switch cancelled." debugEnabled: "Debug mode enabled." debugDisabled: "Debug mode disabled." + renameSuccess: "Renamed profile to ." + renameFail: "Could not rename profile ''." + cannotRenameDefault: "You cannot rename the default profile." + adminRenameSuccess: "Renamed profile to for ." + adminRenameFail: "Failed to rename profile for player." + adminAlertRename: "ALERT | renamed profile to " + profileNameTooLong: "Profile name must be at most characters." + profileNameInvalidChars: "Profile name contains invalid characters. Only letters, numbers, hyphens and underscores are allowed." + exportSuccess: "Exported profile to file." + importSuccess: "Imported profile for ." + importFail: "Failed to import profile from file." + # GUI menus and chat input settings gui: @@ -340,19 +356,27 @@ dateFormat: "yyyy-MM-dd HH:mm" # Enable administrative alerts when players switch, create, or delete profiles adminAlertsEnabled: true + +# Maximum character length for profile names +profileNameMaxLength: 16 + +# Regex pattern for valid profile names +profileNameRegex: "[a-zA-Z0-9_-]+" ``` ### Configuration Options #### Global Settings -| Option | Type | Default | Description | -|:---------------------|:--------------|:-------------------|:-----------------------------------------------------------------------------------| -| `debug` | Boolean | `false` | Enable detailed debug logging in the server console. | -| `defaultProfileName` | String | `default` | Name of the initial profile created automatically when a player first joins. | -| `dateFormat` | String | `yyyy-MM-dd HH:mm` | Date format used for displaying profile creation and last used timestamps. | -| `adminAlertsEnabled` | Boolean | `true` | Broadcast profile actions (create, delete, switch) to administrators. | -| `limitTiers` | List | `[1, 3, 5, 10]` | Profile slot limit thresholds based on permission nodes (e.g. `oumprofile.max.5`). | +| Option | Type | Default | Description | +|:-----------------------|:--------------|:-------------------|:-----------------------------------------------------------------------------------| +| `debug` | Boolean | `false` | Enable detailed debug logging in the server console. | +| `defaultProfileName` | String | `default` | Name of the initial profile created automatically when a player first joins. | +| `dateFormat` | String | `yyyy-MM-dd HH:mm` | Date format used for displaying profile creation and last used timestamps. | +| `adminAlertsEnabled` | Boolean | `true` | Broadcast profile actions (create, delete, switch, rename) to administrators. | +| `limitTiers` | List | `[1, 3, 5, 10]` | Profile slot limit thresholds based on permission nodes (e.g. `oumprofile.max.5`). | +| `profileNameMaxLength` | Integer | `16` | Maximum character length allowed for profile names. | +| `profileNameRegex` | String | `[a-zA-Z0-9_-]+` | Regex pattern that profile names must match. | #### Switching Settings (`switching`) @@ -456,10 +480,11 @@ public class OumProfileAPIExample { boolean hasPvp = ProfileAPI.hasProfile(uuid, "pvp"); int maxSlots = ProfileAPI.getMaxProfiles(player); - // Programmatically create, delete, or switch profiles + // Programmatically create, delete, switch, or rename profiles ProfileAPI.createProfile(player, "pvp"); ProfileAPI.deleteProfile(player, "pvp"); ProfileAPI.switchProfile(player, "pvp"); + ProfileAPI.renameProfile(player, "pvp", "factions"); // Manage switch warmup states boolean isWarmupActive = ProfileAPI.hasPendingWarmup(uuid); @@ -485,6 +510,7 @@ public class OumProfileAPIExample { * **`ProfileLoadEvent`**: Fired when a player's profiles are loaded on join. * **`ProfileCreateEvent`** *(Cancellable)*: Fired before a profile is created. * **`ProfileDeleteEvent`** *(Cancellable)*: Fired before a profile is deleted. +* **`ProfileRenameEvent`** *(Cancellable)*: Fired before a profile is renamed. * **`ProfileSwitchEvent`** *(Cancellable)*: Fired when a profile switch is requested, and right before the switch takes place. * **`ProfilePostSwitchEvent`**: Fired after a profile switch completes. diff --git a/pom.xml b/pom.xml index c66b08f..9e60001 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ dev.oum oumprofile - 1.1-RELEASE + 1.2-SNAPSHOT jar @@ -43,7 +43,7 @@ dev.oum oumlib-core - 1.0.6 + 1.0.8 compile diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..b4e0d19 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,48 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "21" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Specify Qodana linter for analysis (Applied in CI/CD pipeline) +linter: jetbrains/qodana-jvm-community:2026.1 diff --git a/src/main/java/dev/oum/profile/api/ProfileAPI.java b/src/main/java/dev/oum/profile/api/ProfileAPI.java index 8f5047e..a0dbca4 100644 --- a/src/main/java/dev/oum/profile/api/ProfileAPI.java +++ b/src/main/java/dev/oum/profile/api/ProfileAPI.java @@ -258,4 +258,16 @@ public static long getProfilePlaytimeSeconds(@NonNull UUID uuid, @NonNull String Long pt = data.state().playtimeSeconds(); return pt != null ? pt : 0L; } + + /** + * Renames a profile for a player. + * + * @param player The player. + * @param oldName The current profile name. + * @param newName The new profile name. + * @return True if rename succeeded, false otherwise. + */ + public static boolean renameProfile(@NonNull Player player, @NonNull String oldName, @NonNull String newName) { + return manager().renameProfile(player, oldName, newName); + } } \ No newline at end of file diff --git a/src/main/java/dev/oum/profile/api/event/ProfileRenameEvent.java b/src/main/java/dev/oum/profile/api/event/ProfileRenameEvent.java new file mode 100644 index 0000000..c9bac21 --- /dev/null +++ b/src/main/java/dev/oum/profile/api/event/ProfileRenameEvent.java @@ -0,0 +1,49 @@ +package dev.oum.profile.api.event; + +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.jspecify.annotations.NonNull; + +@SuppressWarnings("unused") +public class ProfileRenameEvent extends PlayerEvent implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + private final String oldName; + private final String newName; + private boolean cancelled; + + public ProfileRenameEvent(@NonNull Player player, @NonNull String oldName, @NonNull String newName) { + super(player); + this.oldName = oldName; + this.newName = newName; + } + + public static @NonNull HandlerList getHandlerList() { + return HANDLERS; + } + + public @NonNull String getOldName() { + return oldName; + } + + public @NonNull String getNewName() { + return newName; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + + @Override + public @NonNull HandlerList getHandlers() { + return HANDLERS; + } +} diff --git a/src/main/java/dev/oum/profile/command/ProfileCommand.java b/src/main/java/dev/oum/profile/command/ProfileCommand.java index 95a152c..65dd0d4 100644 --- a/src/main/java/dev/oum/profile/command/ProfileCommand.java +++ b/src/main/java/dev/oum/profile/command/ProfileCommand.java @@ -9,12 +9,15 @@ import dev.oum.profile.model.ProfileData; import dev.oum.profile.profile.ProfileManager; import dev.oum.profile.profile.ProfileMenu; +import dev.oum.profile.util.ProfileIO; import org.bukkit.entity.Player; import org.jspecify.annotations.NonNull; +import java.io.File; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -43,6 +46,10 @@ public void register() { .subcommand(s -> s.label("delete") .argument(Arguments.word("name").suggests(this::suggestOwnProfiles)) .executes(this::onDelete)) + .subcommand(s -> s.label("rename") + .argument(Arguments.word("old").suggests(this::suggestOwnProfiles)) + .argument(Arguments.word("new")) + .executes(this::onRename)) .subcommand(s -> s.label("reload") .permission(Permissions.ADMIN) .executes(this::onReload)) @@ -53,44 +60,68 @@ public void register() { .permission(Permissions.ADMIN) .executes(this::onDebug)) .subcommand(s -> s.label("help").executes(this::onHelp)) - .subcommand(s -> { - Argument targetArg = Arguments.player("target"); - s.label("admin-open") - .permission(Permissions.ADMIN) - .argument(targetArg) - .executes(ctx -> this.onAdminOpen(ctx, targetArg)); - }) - .subcommand(s -> { - Argument targetArg = Arguments.player("target"); - s.label("admin-list") - .permission(Permissions.ADMIN) - .argument(targetArg) - .executes(ctx -> this.onAdminList(ctx, targetArg)); - }) - .subcommand(s -> { - Argument targetArg = Arguments.player("target"); - s.label("admin-create") - .permission(Permissions.ADMIN) - .argument(targetArg) - .argument(Arguments.word("profile")) - .executes(ctx -> this.onAdminCreate(ctx, targetArg)); - }) - .subcommand(s -> { - Argument targetArg = Arguments.player("target"); - s.label("admin-switch") - .permission(Permissions.ADMIN) - .argument(targetArg) - .argument(Arguments.word("profile").suggests(ctx -> this.suggestTargetProfiles(ctx, targetArg))) - .executes(ctx -> this.onAdminSwitch(ctx, targetArg)); - }) - .subcommand(s -> { - Argument targetArg = Arguments.player("target"); - s.label("admin-delete") - .permission(Permissions.ADMIN) - .argument(targetArg) - .argument(Arguments.word("profile").suggests(ctx -> this.suggestTargetProfiles(ctx, targetArg))) - .executes(ctx -> this.onAdminDelete(ctx, targetArg)); - }) + .subcommand(admin -> admin.label("admin") + .permission(Permissions.ADMIN) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("open") + .argument(targetArg) + .executes(ctx -> this.onAdminOpen(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("list") + .argument(targetArg) + .executes(ctx -> this.onAdminList(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("create") + .argument(targetArg) + .argument(Arguments.word("profile")) + .executes(ctx -> this.onAdminCreate(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("switch") + .argument(targetArg) + .argument(Arguments.word("profile").suggests(ctx + -> this.suggestTargetProfiles(ctx, targetArg))) + .executes(ctx -> this.onAdminSwitch(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("delete") + .argument(targetArg) + .argument(Arguments.word("profile").suggests(ctx + -> this.suggestTargetProfiles(ctx, targetArg))) + .executes(ctx -> this.onAdminDelete(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("rename") + .argument(targetArg) + .argument(Arguments.word("old").suggests(ctx + -> this.suggestTargetProfiles(ctx, targetArg))) + .argument(Arguments.word("new")) + .executes(ctx -> this.onAdminRename(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("export") + .argument(targetArg) + .argument(Arguments.word("profile").suggests(ctx + -> this.suggestTargetProfiles(ctx, targetArg))) + .executes(ctx -> this.onAdminExport(ctx, targetArg)); + }) + .subcommand(s -> { + Argument targetArg = Arguments.player("target"); + s.label("import") + .argument(targetArg) + .argument(Arguments.word("file").suggests(ctx -> this.suggestExportFiles())) + .executes(ctx -> this.onAdminImport(ctx, targetArg)); + }) + ) .executes(this::onGui) .register(); } @@ -101,6 +132,17 @@ public void register() { return manager.getProfiles(player.getUniqueId()).keySet(); } + private @NonNull Collection suggestExportFiles() { + File dir = ProfileIO.getExportsDir(); + File[] files = dir.listFiles((d, name) -> name.endsWith(".json")); + if (files == null) return List.of(); + List names = new ArrayList<>(); + for (File f : files) { + names.add(f.getName()); + } + return names; + } + private @NonNull DateTimeFormatter dateFormatter() { try { return DateTimeFormatter.ofPattern(manager.configManager().get().dateFormat()) @@ -161,19 +203,39 @@ private void onCreate(@NonNull CommandContext ctx) { } Player player = ctx.playerOrThrow(); String name = ctx.args().getString("name"); + var msg = manager.configManager().get().messages(); + + ProfileManager.NameValidation validation = manager.validateProfileName(name); + switch (validation) { + case EMPTY -> { + Text.send(player, msg.invalidProfileName()); + return; + } + case TOO_LONG -> { + Text.send(player, msg.profileNameTooLong(), "max", + String.valueOf(manager.configManager().get().profileNameMaxLength())); + return; + } + case INVALID_CHARS -> { + Text.send(player, msg.profileNameInvalidChars()); + return; + } + default -> { + } + } if (!player.hasPermission(Permissions.CREATE_PREFIX + name) && !player.hasPermission(Permissions.CREATE_ALL)) { - Text.send(player, manager.configManager().get().messages().noPermission(), "name", name); + Text.send(player, msg.noPermission(), "name", name); return; } boolean created = manager.createProfile(player, name); if (!created) { - Text.send(player, manager.configManager().get().messages().createFail(), "name", name); + Text.send(player, msg.createFail(), "name", name); return; } - Text.send(player, manager.configManager().get().messages().createSuccess(), "name", name); + Text.send(player, msg.createSuccess(), "name", name); } private void onSwitch(@NonNull CommandContext ctx) { @@ -208,6 +270,48 @@ private void onDelete(@NonNull CommandContext ctx) { Text.send(player, manager.configManager().get().messages().deleteSuccess(), "name", name); } + private void onRename(@NonNull CommandContext ctx) { + if (!ctx.isPlayer()) { + Text.send(ctx.sender(), manager.configManager().get().messages().playerOnly()); + return; + } + Player player = ctx.playerOrThrow(); + String oldName = ctx.args().getString("old"); + String newName = ctx.args().getString("new"); + var msg = manager.configManager().get().messages(); + + if (oldName.equalsIgnoreCase(manager.configManager().get().defaultProfileName())) { + Text.send(player, msg.cannotRenameDefault()); + return; + } + + ProfileManager.NameValidation validation = manager.validateProfileName(newName); + switch (validation) { + case EMPTY -> { + Text.send(player, msg.invalidProfileName()); + return; + } + case TOO_LONG -> { + Text.send(player, msg.profileNameTooLong(), "max", + String.valueOf(manager.configManager().get().profileNameMaxLength())); + return; + } + case INVALID_CHARS -> { + Text.send(player, msg.profileNameInvalidChars()); + return; + } + default -> { + } + } + + boolean renamed = manager.renameProfile(player, oldName, newName); + if (renamed) { + Text.send(player, msg.renameSuccess(), "old", oldName, "new", newName); + } else { + Text.send(player, msg.renameFail(), "old", oldName); + } + } + private void onReload(@NonNull CommandContext ctx) { manager.configManager().reload(); Text.send(ctx.sender(), manager.configManager().get().messages().reloadSuccess()); @@ -276,9 +380,24 @@ private void onAdminCreate(@NonNull CommandContext ctx, @NonNull Argument tar return; } String profileName = ctx.args().getString("profile"); - if (profileName.isEmpty() || profileName.contains(" ")) { - Text.send(ctx.sender(), msg.invalidProfileName()); - return; + + ProfileManager.NameValidation validation = manager.validateProfileName(profileName); + switch (validation) { + case EMPTY -> { + Text.send(ctx.sender(), msg.invalidProfileName()); + return; + } + case TOO_LONG -> { + Text.send(ctx.sender(), msg.profileNameTooLong(), "max", + String.valueOf(manager.configManager().get().profileNameMaxLength())); + return; + } + case INVALID_CHARS -> { + Text.send(ctx.sender(), msg.profileNameInvalidChars()); + return; + } + default -> { + } } boolean created = manager.createProfile(target, profileName); @@ -323,6 +442,115 @@ private void onAdminDelete(@NonNull CommandContext ctx, @NonNull Argument tar } } + private void onAdminRename(@NonNull CommandContext ctx, @NonNull Argument targetArg) { + Player target = (Player) ctx.args().get(targetArg); + var msg = manager.configManager().get().messages(); + if (target == null) { + Text.send(ctx.sender(), msg.playerNotFound()); + return; + } + String oldName = ctx.args().getString("old"); + String newName = ctx.args().getString("new"); + + if (oldName.equalsIgnoreCase(manager.configManager().get().defaultProfileName())) { + Text.send(ctx.sender(), msg.cannotRenameDefault()); + return; + } + + ProfileManager.NameValidation validation = manager.validateProfileName(newName); + switch (validation) { + case EMPTY -> { + Text.send(ctx.sender(), msg.invalidProfileName()); + return; + } + case TOO_LONG -> { + Text.send(ctx.sender(), msg.profileNameTooLong(), "max", + String.valueOf(manager.configManager().get().profileNameMaxLength())); + return; + } + case INVALID_CHARS -> { + Text.send(ctx.sender(), msg.profileNameInvalidChars()); + return; + } + default -> { + } + } + + boolean renamed = manager.renameProfile(target, oldName, newName); + if (renamed) { + Text.send(ctx.sender(), msg.adminRenameSuccess(), "old", oldName, "new", newName, "target", target.getName()); + } else { + Text.send(ctx.sender(), msg.adminRenameFail()); + } + } + + private void onAdminExport(@NonNull CommandContext ctx, @NonNull Argument targetArg) { + Player target = (Player) ctx.args().get(targetArg); + var msg = manager.configManager().get().messages(); + if (target == null) { + Text.send(ctx.sender(), msg.playerNotFound()); + return; + } + String profileName = ctx.args().getString("profile"); + var profiles = manager.getProfiles(target.getUniqueId()); + ProfileData data = profiles.get(profileName); + if (data == null) { + Text.send(ctx.sender(), msg.profileNotFound(), "target", profileName); + return; + } + + File exportFile = new File(ProfileIO.getExportsDir(), + target.getUniqueId() + "_" + profileName + ".json"); + boolean success = ProfileIO.exportToFile(data, exportFile); + if (success) { + Text.send(ctx.sender(), msg.exportSuccess(), "name", profileName); + } else { + Text.send(ctx.sender(), msg.importFail()); + } + } + + private void onAdminImport(@NonNull CommandContext ctx, @NonNull Argument targetArg) { + Player target = (Player) ctx.args().get(targetArg); + var msg = manager.configManager().get().messages(); + if (target == null) { + Text.send(ctx.sender(), msg.playerNotFound()); + return; + } + String fileName = ctx.args().getString("file"); + File importFile = new File(ProfileIO.getExportsDir(), fileName); + if (!importFile.exists()) { + Text.send(ctx.sender(), msg.importFail()); + return; + } + + ProfileData imported = ProfileIO.importFromFile(importFile); + if (imported == null) { + Text.send(ctx.sender(), msg.importFail()); + return; + } + + if (manager.hasProfile(target.getUniqueId(), imported.name())) { + Text.send(ctx.sender(), msg.adminCreateFail()); + return; + } + + if (manager.getProfiles(target.getUniqueId()).size() >= manager.getMaxProfiles(target)) { + Text.send(ctx.sender(), msg.adminCreateFail()); + return; + } + + boolean created = manager.createProfile(target, imported.name()); + if (!created) { + Text.send(ctx.sender(), msg.importFail()); + return; + } + + var profiles = manager.getProfiles(target.getUniqueId()); + profiles.put(imported.name(), imported); + manager.storage().save(target.getUniqueId(), imported); + Text.send(ctx.sender(), msg.importSuccess(), "name", imported.name(), "target", target.getName()); + } + private void onAlerts(@NonNull CommandContext ctx) { if (!ctx.isPlayer()) { Text.send(ctx.sender(), manager.configManager().get().messages().playerOnly()); diff --git a/src/main/java/dev/oum/profile/config/ProfileConfig.java b/src/main/java/dev/oum/profile/config/ProfileConfig.java index 3574b59..27fc985 100644 --- a/src/main/java/dev/oum/profile/config/ProfileConfig.java +++ b/src/main/java/dev/oum/profile/config/ProfileConfig.java @@ -87,7 +87,13 @@ public record ProfileConfig( SkillSection skills, @Comment("Vanilla statistics synchronization settings") - StatisticsSection statistics + StatisticsSection statistics, + + @Comment("Maximum character length for profile names") + int profileNameMaxLength, + + @Comment("Regex pattern for valid profile names") + String profileNameRegex ) implements ConfigSection { @Contract(" -> new") @@ -112,7 +118,7 @@ public record ProfileConfig( "Created profile .", "Could not delete profile '' (active, last remaining, or not found).", "Deleted profile .", - "OumProfile » /profile ", + "OumProfile » /profile ", "Your profiles ():", " Active", " Last used ", @@ -146,7 +152,18 @@ public record ProfileConfig( "Profile switch cancelled because you took damage.", "Profile switch cancelled.", "Debug mode enabled.", - "Debug mode disabled." + "Debug mode disabled.", + "Renamed profile to .", + "Could not rename profile ''.", + "You cannot rename the default profile.", + "Renamed profile to for .", + "Failed to rename profile for player.", + "ALERT | renamed profile to ", + "Profile name must be at most characters.", + "Profile name contains invalid characters. Only letters, numbers, hyphens and underscores are allowed.", + "Exported profile to file.", + "Imported profile for .", + "Failed to import profile from file." ), new GuiSection( "Select a Profile", @@ -275,7 +292,9 @@ public record ProfileConfig( true, new EconomySection(true, List.of("vault", "playerpoints")), new SkillSection(true, true, true), - new StatisticsSection(true, List.of("MOB_KILLS", "DEATHS", "JUMP")) + new StatisticsSection(true, List.of("MOB_KILLS", "DEATHS", "JUMP")), + 16, + "[a-zA-Z0-9_-]+" ); } @@ -407,7 +426,18 @@ public record MessagesSection( String warmupCancelledDamage, String warmupCancelledGeneric, String debugEnabled, - String debugDisabled + String debugDisabled, + String renameSuccess, + String renameFail, + String cannotRenameDefault, + String adminRenameSuccess, + String adminRenameFail, + String adminAlertRename, + String profileNameTooLong, + String profileNameInvalidChars, + String exportSuccess, + String importSuccess, + String importFail ) implements ConfigSection { } diff --git a/src/main/java/dev/oum/profile/config/ProfileStorage.java b/src/main/java/dev/oum/profile/config/ProfileStorage.java index 81534d7..54ecbf3 100644 --- a/src/main/java/dev/oum/profile/config/ProfileStorage.java +++ b/src/main/java/dev/oum/profile/config/ProfileStorage.java @@ -21,17 +21,21 @@ public ProfileStorage(ProfileConfig.@NonNull StorageSection cfg) { this.mysql = cfg.type().equalsIgnoreCase("mysql"); if (mysql) { this.db = Database.mysql(cfg.host(), cfg.port(), cfg.database(), cfg.username(), cfg.password()); - db.runMigrations(ProfileStorage.class, "migrations/mysql/V1__init.sql"); + db.runMigrations(ProfileStorage.class, + "migrations/mysql/V1__init.sql", + "migrations/mysql/V2__add_active_column.sql"); } else { String filename = cfg.database().endsWith(".db") ? cfg.database() : cfg.database() + ".db"; this.db = Database.sqlite(new File(OumLib.getDataFolder(), filename)); - db.runMigrations(ProfileStorage.class, "migrations/sqlite/V1__init.sql"); + db.runMigrations(ProfileStorage.class, + "migrations/sqlite/V1__init.sql", + "migrations/sqlite/V2__add_active_column.sql"); } } public @NonNull Promise> loadAll(@NonNull UUID uuid) { return db.executeQuery( - "SELECT name, created_at, last_used, state_json, balance, primary_group, groups_json FROM oum_profiles WHERE uuid = ?", + "SELECT name, created_at, last_used, state_json, balance, primary_group, groups_json, active FROM oum_profiles WHERE uuid = ?", rs -> new ProfileData( rs.getString("name"), rs.getLong("created_at"), @@ -39,7 +43,8 @@ public ProfileStorage(ProfileConfig.@NonNull StorageSection cfg) { PlayerState.fromJson(rs.getString("state_json")), rs.getDouble("balance"), rs.getString("primary_group"), - rs.getString("groups_json") + rs.getString("groups_json"), + rs.getInt("active") == 1 ), uuid.toString().toLowerCase(Locale.ROOT) ); @@ -48,19 +53,35 @@ public ProfileStorage(ProfileConfig.@NonNull StorageSection cfg) { public @NonNull Promise save(@NonNull UUID uuid, @NonNull ProfileData data) { String id = uuid.toString().toLowerCase(Locale.ROOT); String sql = mysql - ? "INSERT INTO oum_profiles (uuid, name, created_at, last_used, state_json, balance, primary_group, groups_json) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + ? "INSERT INTO oum_profiles (uuid, name, created_at, last_used, state_json, balance, primary_group, groups_json, active) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON DUPLICATE KEY UPDATE last_used = VALUES(last_used), state_json = VALUES(state_json), " + - "balance = VALUES(balance), primary_group = VALUES(primary_group), groups_json = VALUES(groups_json)" - : "INSERT INTO oum_profiles (uuid, name, created_at, last_used, state_json, balance, primary_group, groups_json) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + "balance = VALUES(balance), primary_group = VALUES(primary_group), groups_json = VALUES(groups_json), active = VALUES(active)" + : "INSERT INTO oum_profiles (uuid, name, created_at, last_used, state_json, balance, primary_group, groups_json, active) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT (uuid, name) DO UPDATE SET " + "last_used = excluded.last_used, state_json = excluded.state_json, balance = excluded.balance, " + - "primary_group = excluded.primary_group, groups_json = excluded.groups_json"; + "primary_group = excluded.primary_group, groups_json = excluded.groups_json, active = excluded.active"; return db.executeUpdate( sql, id, data.name(), data.createdAt(), data.lastUsed(), data.state().toJson(), - data.balance(), data.primaryGroup(), data.groupsJson() + data.balance(), data.primaryGroup(), data.groupsJson(), data.active() ? 1 : 0 + ).map(rows -> null); + } + + public @NonNull Promise setActive(@NonNull UUID uuid, @NonNull String name) { + String id = uuid.toString().toLowerCase(Locale.ROOT); + return db.executeUpdate( + "UPDATE oum_profiles SET active = CASE WHEN name = ? THEN 1 ELSE 0 END WHERE uuid = ?", + name, id + ).map(rows -> null); + } + + public @NonNull Promise rename(@NonNull UUID uuid, @NonNull String oldName, @NonNull String newName) { + String id = uuid.toString().toLowerCase(Locale.ROOT); + return db.executeUpdate( + "UPDATE oum_profiles SET name = ? WHERE uuid = ? AND name = ?", + newName, id, oldName ).map(rows -> null); } diff --git a/src/main/java/dev/oum/profile/model/PlayerState.java b/src/main/java/dev/oum/profile/model/PlayerState.java index 8c7cab0..4f3dadd 100644 --- a/src/main/java/dev/oum/profile/model/PlayerState.java +++ b/src/main/java/dev/oum/profile/model/PlayerState.java @@ -1,22 +1,25 @@ package dev.oum.profile.model; import com.google.gson.Gson; +import dev.oum.oumlib.bridge.StatisticsBridge; import dev.oum.oumlib.bridge.economy.EconomyBridge; import dev.oum.oumlib.util.ItemSerializer; +import dev.oum.oumlib.util.Locations; +import dev.oum.oumlib.util.PotionSerializer; import dev.oum.profile.config.ProfileConfig; import dev.oum.profile.integration.IntegrationManager; import dev.oum.profile.integration.SkillData; -import org.bukkit.*; +import org.bukkit.GameMode; +import org.bukkit.Location; import org.bukkit.attribute.Attribute; -import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.potion.PotionEffect; -import org.bukkit.potion.PotionEffectType; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; -import java.util.*; +import java.util.HashMap; +import java.util.Map; public record PlayerState( String inventory, @@ -30,13 +33,13 @@ public record PlayerState( int xpLevel, float xpProgress, String gameMode, - List potionEffects, + String potionEffects, float fallDistance, int fireTicks, int remainingAir, boolean allowFlight, boolean isFlying, - @Nullable LocationEntry location, + @Nullable String location, @Nullable Map mcmmo, @Nullable Map auraskills, @@ -56,7 +59,7 @@ public record PlayerState( ItemSerializer.serializeArray(new ItemStack[27]), 20.0, 20.0, 20, 5.0f, 0, 0.0f, GameMode.SURVIVAL.name(), - new ArrayList<>(), + "", 0.0f, 0, 300, @@ -79,23 +82,11 @@ public record PlayerState( var maxHpAttr = player.getAttribute(Attribute.MAX_HEALTH); double max = maxHpAttr != null ? maxHpAttr.getValue() : 20.0; - List effects = new ArrayList<>(); - for (PotionEffect e : player.getActivePotionEffects()) { - effects.add(new PotionEffectEntry(e.getType().getKey().toString(), e.getDuration(), e.getAmplifier(), - e.isAmbient(), e.hasParticles(), e.hasIcon())); - } + String effects = PotionSerializer.serialize(player.getActivePotionEffects()); - LocationEntry loc = null; + String loc = null; if (saveLocation) { - var location = player.getLocation(); - loc = new LocationEntry( - location.getWorld().getName(), - location.getX(), - location.getY(), - location.getZ(), - location.getYaw(), - location.getPitch() - ); + loc = Locations.serialize(player.getLocation()); } Map currencies = new HashMap<>(); @@ -109,27 +100,9 @@ public record PlayerState( } } - Map statistics = new HashMap<>(); + Map statistics = null; if (config.statistics() != null && config.statistics().enabled() && config.statistics().tracked() != null) { - for (String entry : config.statistics().tracked()) { - try { - String[] parts = entry.split(":", 2); - Statistic statistic = Statistic.valueOf(parts[0].toUpperCase(Locale.ROOT)); - if (parts.length == 1) { - statistics.put(entry, player.getStatistic(statistic)); - } else { - String param = parts[1].toUpperCase(Locale.ROOT); - if (statistic.getType() == Statistic.Type.BLOCK || statistic.getType() == Statistic.Type.ITEM) { - Material mat = Material.valueOf(param); - statistics.put(entry, player.getStatistic(statistic, mat)); - } else if (statistic.getType() == Statistic.Type.ENTITY) { - EntityType entityType = EntityType.valueOf(param); - statistics.put(entry, player.getStatistic(statistic, entityType)); - } - } - } catch (Throwable ignored) { - } - } + statistics = StatisticsBridge.capture(player, config.statistics().tracked()); } Map mcmmoData = null; @@ -202,17 +175,8 @@ public void apply(@NonNull Player player, boolean restoreLocation, @NonNull Prof } player.getActivePotionEffects().forEach(e -> player.removePotionEffect(e.getType())); - for (PotionEffectEntry entry : potionEffects) { - String typeStr = entry.type(); - NamespacedKey key = typeStr.contains(":") - ? NamespacedKey.fromString(typeStr) - : NamespacedKey.minecraft(typeStr.toLowerCase(Locale.ROOT)); - - PotionEffectType type = key != null ? Registry.MOB_EFFECT.get(key) : null; - if (type != null) { - player.addPotionEffect(new PotionEffect(type, entry.duration(), entry.amplifier(), - entry.ambient(), entry.particles(), entry.icon())); - } + for (PotionEffect effect : PotionSerializer.deserialize(potionEffects)) { + player.addPotionEffect(effect); } player.setFallDistance(fallDistance); @@ -222,33 +186,14 @@ public void apply(@NonNull Player player, boolean restoreLocation, @NonNull Prof player.setFlying(allowFlight && isFlying); if (restoreLocation && location != null) { - var world = Bukkit.getWorld(location.world()); - if (world != null) { - player.teleport(new Location(world, location.x(), location.y(), location.z(), location.yaw(), location.pitch())); + Location loc = Locations.deserialize(location); + if (loc != null) { + player.teleportAsync(loc); } } if (config.statistics() != null && config.statistics().enabled() && statistics != null) { - for (String entry : config.statistics().tracked()) { - try { - String[] parts = entry.split(":", 2); - Statistic statistic = Statistic.valueOf(parts[0].toUpperCase(Locale.ROOT)); - int value = statistics.getOrDefault(entry, 0); - if (parts.length == 1) { - player.setStatistic(statistic, value); - } else { - String param = parts[1].toUpperCase(Locale.ROOT); - if (statistic.getType() == Statistic.Type.BLOCK || statistic.getType() == Statistic.Type.ITEM) { - Material mat = Material.valueOf(param); - player.setStatistic(statistic, mat, value); - } else if (statistic.getType() == Statistic.Type.ENTITY) { - EntityType entityType = EntityType.valueOf(param); - player.setStatistic(statistic, entityType, value); - } - } - } catch (Throwable ignored) { - } - } + StatisticsBridge.apply(player, config.statistics().tracked(), statistics); } if (config.skills() != null && config.skills().mcmmoEnabled() && mcmmo != null) { @@ -267,18 +212,4 @@ public void apply(@NonNull Player player, boolean restoreLocation, @NonNull Prof public @NonNull String toJson() { return GSON.toJson(this); } - - public record PotionEffectEntry(String type, int duration, int amplifier, boolean ambient, boolean particles, - boolean icon) { - } - - public record LocationEntry( - String world, - double x, - double y, - double z, - float yaw, - float pitch - ) { - } } \ No newline at end of file diff --git a/src/main/java/dev/oum/profile/model/ProfileData.java b/src/main/java/dev/oum/profile/model/ProfileData.java index 9327d13..69b20bb 100644 --- a/src/main/java/dev/oum/profile/model/ProfileData.java +++ b/src/main/java/dev/oum/profile/model/ProfileData.java @@ -12,9 +12,15 @@ public final class ProfileData { private double balance; private String primaryGroup; private String groupsJson; + private boolean active; public ProfileData(@NonNull String name, long createdAt, long lastUsed, @NonNull PlayerState state, double balance, @Nullable String primaryGroup, @Nullable String groupsJson) { + this(name, createdAt, lastUsed, state, balance, primaryGroup, groupsJson, false); + } + + public ProfileData(@NonNull String name, long createdAt, long lastUsed, @NonNull PlayerState state, + double balance, @Nullable String primaryGroup, @Nullable String groupsJson, boolean active) { this.name = name; this.createdAt = createdAt; this.lastUsed = lastUsed; @@ -22,11 +28,12 @@ public ProfileData(@NonNull String name, long createdAt, long lastUsed, @NonNull this.balance = balance; this.primaryGroup = primaryGroup; this.groupsJson = groupsJson; + this.active = active; } public static @NonNull ProfileData fresh(@NonNull String name) { long now = System.currentTimeMillis(); - return new ProfileData(name, now, now, PlayerState.fresh(), 0.0, "default", "[\"default\"]"); + return new ProfileData(name, now, now, PlayerState.fresh(), 0.0, "default", "[\"default\"]", false); } public @NonNull String name() { @@ -57,6 +64,10 @@ public double balance() { return groupsJson; } + public boolean active() { + return active; + } + public void setLastUsed(long lastUsed) { this.lastUsed = lastUsed; } @@ -76,4 +87,8 @@ public void setPrimaryGroup(@Nullable String primaryGroup) { public void setGroupsJson(@Nullable String groupsJson) { this.groupsJson = groupsJson; } + + public void setActive(boolean active) { + this.active = active; + } } \ No newline at end of file diff --git a/src/main/java/dev/oum/profile/profile/ConfirmMenu.java b/src/main/java/dev/oum/profile/profile/ConfirmMenu.java index 18d19b3..b79627c 100644 --- a/src/main/java/dev/oum/profile/profile/ConfirmMenu.java +++ b/src/main/java/dev/oum/profile/profile/ConfirmMenu.java @@ -1,9 +1,9 @@ package dev.oum.profile.profile; +import dev.oum.oumlib.bridge.item.ItemBridge; import dev.oum.oumlib.inventory.ChestMenu; import dev.oum.oumlib.inventory.ItemBuilder; import dev.oum.oumlib.scheduler.Scheduler; -import dev.oum.oumlib.bridge.item.ItemBridge; import dev.oum.profile.config.ProfileConfig.ConfirmGuiSection; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -27,6 +27,14 @@ public ConfirmMenu(@NonNull ConfirmGuiSection config, @NonNull String targetName this.onDeny = onDeny; } + private static @NonNull ItemBuilder resolveItem(@NonNull String input, @NonNull Material fallback) { + if (input.startsWith("head:") || input.startsWith("skull:")) { + String texture = input.substring(input.indexOf(':') + 1); + return ItemBuilder.of(Material.PLAYER_HEAD).skull(texture); + } + return ItemBuilder.of(ItemBridge.getItem(input).orElseGet(() -> new ItemStack(fallback))); + } + public void open(@NonNull Player player) { String resolvedTitle = config.title() .replace("", targetName) @@ -91,12 +99,4 @@ public void open(@NonNull Player player) { builder.build().open(player); } - - private static @NonNull ItemBuilder resolveItem(@NonNull String input, @NonNull Material fallback) { - if (input.startsWith("head:") || input.startsWith("skull:")) { - String texture = input.substring(input.indexOf(':') + 1); - return ItemBuilder.of(Material.PLAYER_HEAD).skull(texture); - } - return ItemBuilder.of(ItemBridge.getItem(input).orElseGet(() -> new ItemStack(fallback))); - } } diff --git a/src/main/java/dev/oum/profile/profile/ProfileManager.java b/src/main/java/dev/oum/profile/profile/ProfileManager.java index 7ad95e8..f177a7a 100644 --- a/src/main/java/dev/oum/profile/profile/ProfileManager.java +++ b/src/main/java/dev/oum/profile/profile/ProfileManager.java @@ -27,7 +27,9 @@ import java.lang.reflect.Type; import java.time.Duration; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; public final class ProfileManager { @@ -56,6 +58,23 @@ public ProfileManager(@NonNull ConfigManager configManager, @NonN }); } + public @NonNull NameValidation validateProfileName(@NonNull String name) { + if (name.isEmpty() || name.contains(" ")) return NameValidation.EMPTY; + + int maxLen = configManager.get().profileNameMaxLength(); + if (maxLen > 0 && name.length() > maxLen) return NameValidation.TOO_LONG; + + String regex = configManager.get().profileNameRegex(); + if (regex != null && !regex.isEmpty()) { + try { + if (!Pattern.matches(regex, name)) return NameValidation.INVALID_CHARS; + } catch (Exception ignored) { + } + } + + return NameValidation.VALID; + } + public boolean toggleAlerts(@NonNull UUID uuid) { if (mutedAlerts.contains(uuid)) { mutedAlerts.remove(uuid); @@ -83,6 +102,10 @@ public void sendAlert(@NonNull String alertMessage, Object... placeholders) { return configManager; } + public @NonNull ProfileStorage storage() { + return storage; + } + public long getElapsedSessionSeconds(@NonNull UUID uuid) { Long start = sessionStarts.get(uuid); if (start == null) return 0; @@ -105,30 +128,45 @@ public void loadPlayer(@NonNull Player player) { if (map.isEmpty()) { OumLib.logDebug("No profiles found for " + player.getName() + ". Creating default profile: " + defaultName); ProfileData def = ProfileData.fresh(defaultName); + def.setActive(true); map.put(defaultName, def); storage.save(uuid, def); } cache.put(uuid, map); String activeName = null; - long maxLastUsed = -1; for (ProfileData data : map.values()) { - if (data.lastUsed() > maxLastUsed) { - maxLastUsed = data.lastUsed(); + if (data.active()) { activeName = data.name(); + break; + } + } + if (activeName == null) { + long maxLastUsed = -1; + for (ProfileData data : map.values()) { + if (data.lastUsed() > maxLastUsed) { + maxLastUsed = data.lastUsed(); + activeName = data.name(); + } } } if (activeName == null) { activeName = defaultName; } + active.put(uuid, activeName); OumLib.logDebug("Loaded " + map.size() + " profiles for " + player.getName() + ". Active profile: " + activeName); + + for (ProfileData data : map.values()) { + data.setActive(data.name().equals(activeName)); + } + storage.setActive(uuid, activeName); + ProfileData toApply = map.get(activeName); player.closeInventory(); toApply.state().apply(player, configManager.get().switching().saveLocation(), configManager.get()); applyEconomies(player, toApply); sessionStarts.put(uuid, System.currentTimeMillis()); - // register dynamic placeholders for loaded profiles for (ProfileData data : map.values()) { PlayerState state = data.state(); if (state.mcmmo() != null) { @@ -184,6 +222,8 @@ public void shutdown() { for (UUID uuid : warmups.keySet()) { cancelWarmup(uuid); } + + List> futures = new ArrayList<>(); for (Player player : Bukkit.getOnlinePlayers()) { UUID uuid = player.getUniqueId(); Map map = cache.get(uuid); @@ -193,14 +233,24 @@ public void shutdown() { if (data != null) { try { captureLiveState(player, data, configManager.get().switching().saveLocation()); - storage.save(uuid, data).toCompletableFuture().join(); - OumLib.logDebug("Saved active profile '" + activeName + "' for player " + player.getName() + " on shutdown."); + futures.add(storage.save(uuid, data).toCompletableFuture()); + OumLib.logDebug("Queued save for active profile '" + activeName + "' for player " + player.getName() + " on shutdown."); } catch (Exception e) { - OumLib.logError("Failed to save active profile for player " + player.getName() + " on shutdown", e); + OumLib.logError("Failed to capture state for player " + player.getName() + " on shutdown", e); } } } } + + if (!futures.isEmpty()) { + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + OumLib.logDebug("All " + futures.size() + " profile saves completed on shutdown."); + } catch (Exception e) { + OumLib.logError("Error during batch profile save on shutdown", e); + } + } + cache.clear(); active.clear(); mutedAlerts.clear(); @@ -231,7 +281,8 @@ private void captureLiveState(@NonNull Player player, @NonNull ProfileData data, data.setGroupsJson(GSON.toJson(groups)); } } - OumLib.logDebug("Captured state for player " + player.getName() + " on profile " + data.name() + " (Balance: " + data.balance() + ", Group: " + data.primaryGroup() + ", Playtime: " + totalPlaytime + "s)"); + OumLib.logDebug("Captured state for player " + player.getName() + " on profile " + data.name() + + " (Balance: " + data.balance() + ", Group: " + data.primaryGroup() + ", Playtime: " + totalPlaytime + "s)"); } public @NonNull Map getProfiles(@NonNull UUID uuid) { @@ -265,6 +316,13 @@ public int getMaxProfiles(@NonNull Player player) { public boolean createProfile(@NonNull Player player, @NonNull String name) { UUID uuid = player.getUniqueId(); OumLib.logDebug("Attempting to create profile '" + name + "' for player " + player.getName()); + + NameValidation validation = validateProfileName(name); + if (validation != NameValidation.VALID) { + OumLib.logDebug("Profile creation failed: Name validation failed (" + validation + ") for '" + name + "'"); + return false; + } + Map map = cache.computeIfAbsent(uuid, k -> new ConcurrentHashMap<>()); if (map.containsKey(name)) { OumLib.logDebug("Profile creation failed: Profile '" + name + "' already exists for " + player.getName()); @@ -325,6 +383,50 @@ public boolean deleteProfile(@NonNull Player player, @NonNull String name) { return true; } + public boolean renameProfile(@NonNull Player player, @NonNull String oldName, @NonNull String newName) { + UUID uuid = player.getUniqueId(); + OumLib.logDebug("Attempting to rename profile '" + oldName + "' to '" + newName + "' for player " + player.getName()); + + NameValidation validation = validateProfileName(newName); + if (validation != NameValidation.VALID) { + OumLib.logDebug("Profile rename failed: New name validation failed (" + validation + ") for '" + newName + "'"); + return false; + } + + Map map = cache.get(uuid); + if (map == null || !map.containsKey(oldName)) { + OumLib.logDebug("Profile rename failed: Profile '" + oldName + "' not found for " + player.getName()); + return false; + } + if (map.containsKey(newName)) { + OumLib.logDebug("Profile rename failed: Profile '" + newName + "' already exists for " + player.getName()); + return false; + } + if (oldName.equalsIgnoreCase(configManager.get().defaultProfileName())) { + OumLib.logDebug("Profile rename failed: Cannot rename default profile '" + oldName + "' for " + player.getName()); + return false; + } + + ProfileRenameEvent renameEvent = new ProfileRenameEvent(player, oldName, newName); + Bukkit.getPluginManager().callEvent(renameEvent); + if (renameEvent.isCancelled()) { + OumLib.logDebug("Profile rename cancelled by API event handler."); + return false; + } + + ProfileData data = map.remove(oldName); + map.put(newName, data); + + if (oldName.equals(active.get(uuid))) { + active.put(uuid, newName); + } + + storage.rename(uuid, oldName, newName); + OumLib.logDebug("Profile '" + oldName + "' renamed to '" + newName + "' for player " + player.getName()); + sendAlert(configManager.get().messages().adminAlertRename(), "player", player.getName(), "old", oldName, "new", newName); + return true; + } + public void requestSwitch(@NonNull Player player, @NonNull String target) { UUID uuid = player.getUniqueId(); String currentName = active.get(uuid); @@ -343,7 +445,8 @@ public void requestSwitch(@NonNull Player player, @NonNull String target) { Text.send(player, configManager.get().messages().switchCooldown(), "seconds", seconds); return; } - if (configManager.get().switching().cancelInCombat() && combatCooldown.isOnCooldown(uuid) && !player.hasPermission(Permissions.BYPASS_COMBAT)) { + if (configManager.get().switching().cancelInCombat() && combatCooldown.isOnCooldown(uuid) + && !player.hasPermission(Permissions.BYPASS_COMBAT)) { Text.send(player, configManager.get().messages().combatBlock()); return; } @@ -480,22 +583,26 @@ public void performSwitch(@NonNull Player player, @NonNull String target) { player.closeInventory(); OumLib.logDebug("Saving current live data for player " + player.getName() + " on profile " + currentName); captureLiveState(player, current, configManager.get().switching().saveLocation()); + current.setActive(false); storage.save(uuid, current); OumLib.logDebug("Applying profile '" + target + "' data state to player " + player.getName()); targetData.state().apply(player, configManager.get().switching().saveLocation(), configManager.get()); targetData.setLastUsed(System.currentTimeMillis()); + targetData.setActive(true); applyEconomies(player, targetData); if (PermissionBridge.isAvailable() && targetData.groupsJson() != null) { List groups = GSON.fromJson(targetData.groupsJson(), STRING_LIST); PermissionBridge.setGroups(uuid, groups, targetData.primaryGroup()); - OumLib.logDebug("Synchronized permission groups for player " + player.getName() + ": " + groups + " (primary=" + targetData.primaryGroup() + ")"); + OumLib.logDebug("Synchronized permission groups for player " + player.getName() + ": " + groups + + " (primary=" + targetData.primaryGroup() + ")"); } active.put(uuid, target); storage.save(uuid, targetData); + storage.setActive(uuid, target); switchCooldown.set(uuid); Text.send(player, configManager.get().messages().switchSuccess(), "target", target); @@ -543,4 +650,8 @@ private void applyEconomies(@NonNull Player player, @NonNull ProfileData targetD } } } + + public enum NameValidation { + VALID, EMPTY, TOO_LONG, INVALID_CHARS + } } \ No newline at end of file diff --git a/src/main/java/dev/oum/profile/profile/ProfileMenu.java b/src/main/java/dev/oum/profile/profile/ProfileMenu.java index 4b8755a..9423943 100644 --- a/src/main/java/dev/oum/profile/profile/ProfileMenu.java +++ b/src/main/java/dev/oum/profile/profile/ProfileMenu.java @@ -1,11 +1,11 @@ package dev.oum.profile.profile; +import dev.oum.oumlib.bridge.economy.EconomyBridge; +import dev.oum.oumlib.bridge.item.ItemBridge; +import dev.oum.oumlib.bridge.permission.PermissionBridge; import dev.oum.oumlib.inventory.ChestMenu; import dev.oum.oumlib.inventory.ClickAction; -import dev.oum.oumlib.bridge.item.ItemBridge; import dev.oum.oumlib.inventory.ItemBuilder; -import dev.oum.oumlib.bridge.economy.EconomyBridge; -import dev.oum.oumlib.bridge.permission.PermissionBridge; import dev.oum.oumlib.inventory.Layout; import dev.oum.oumlib.scheduler.Scheduler; import dev.oum.oumlib.scheduler.TaskHandle; @@ -26,7 +26,6 @@ import org.jspecify.annotations.NonNull; import java.time.Duration; -import java.util.concurrent.atomic.AtomicReference; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @@ -34,6 +33,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; @SuppressWarnings("PatternValidation") public final class ProfileMenu { @@ -44,6 +44,14 @@ public ProfileMenu(@NonNull ProfileManager manager) { this.manager = manager; } + private static @NonNull ItemBuilder resolveItem(@NonNull String input, @NonNull Material fallback) { + if (input.startsWith("head:") || input.startsWith("skull:")) { + String texture = input.substring(input.indexOf(':') + 1); + return ItemBuilder.of(Material.PLAYER_HEAD).skull(texture); + } + return ItemBuilder.of(ItemBridge.getItem(input).orElseGet(() -> new ItemStack(fallback))); + } + private @NonNull DateTimeFormatter dateFormatter() { try { return DateTimeFormatter.ofPattern(manager.configManager().get().dateFormat()) @@ -287,9 +295,22 @@ private void openCreationInput(@NonNull Player player) { .cancelWord(cfg.cancelWord()) .onInput((p, text) -> { String clean = text.trim(); - if (clean.isEmpty() || clean.contains(" ")) { - Text.send(p, mainConfig.messages().invalidProfileName()); - return false; + ProfileManager.NameValidation validation = manager.validateProfileName(clean); + switch (validation) { + case EMPTY -> { + Text.send(p, mainConfig.messages().invalidProfileName()); + return false; + } + case TOO_LONG -> { + Text.send(p, mainConfig.messages().profileNameTooLong(), "max", String.valueOf(mainConfig.profileNameMaxLength())); + return false; + } + case INVALID_CHARS -> { + Text.send(p, mainConfig.messages().profileNameInvalidChars()); + return false; + } + default -> { + } } if (!p.hasPermission(Permissions.CREATE_PREFIX + clean) && !p.hasPermission(Permissions.CREATE_ALL)) { Text.send(p, mainConfig.messages().noPermission(), "name", clean); @@ -380,12 +401,4 @@ private void openCreationInput(@NonNull Player player) { } return String.join(", ", list); } - - private static @NonNull ItemBuilder resolveItem(@NonNull String input, @NonNull Material fallback) { - if (input.startsWith("head:") || input.startsWith("skull:")) { - String texture = input.substring(input.indexOf(':') + 1); - return ItemBuilder.of(Material.PLAYER_HEAD).skull(texture); - } - return ItemBuilder.of(ItemBridge.getItem(input).orElseGet(() -> new ItemStack(fallback))); - } } diff --git a/src/main/java/dev/oum/profile/util/ProfileIO.java b/src/main/java/dev/oum/profile/util/ProfileIO.java new file mode 100644 index 0000000..1939c47 --- /dev/null +++ b/src/main/java/dev/oum/profile/util/ProfileIO.java @@ -0,0 +1,92 @@ +package dev.oum.profile.util; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import dev.oum.oumlib.OumLib; +import dev.oum.profile.model.PlayerState; +import dev.oum.profile.model.ProfileData; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import java.io.*; +import java.nio.charset.StandardCharsets; + +public final class ProfileIO { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + private ProfileIO() { + } + + public static boolean exportToFile(@NonNull ProfileData data, @NonNull File file) { + try { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + ExportWrapper wrapper = new ExportWrapper( + data.name(), + data.createdAt(), + data.lastUsed(), + data.state().toJson(), + data.balance(), + data.primaryGroup(), + data.groupsJson() + ); + + try (Writer writer = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { + GSON.toJson(wrapper, writer); + } + OumLib.logDebug("Exported profile '" + data.name() + "' to " + file.getAbsolutePath()); + return true; + } catch (Exception e) { + OumLib.logError("Failed to export profile '" + data.name() + "' to file", e); + return false; + } + } + + public static @Nullable ProfileData importFromFile(@NonNull File file) { + try (Reader reader = new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8)) { + ExportWrapper wrapper = GSON.fromJson(reader, ExportWrapper.class); + if (wrapper == null || wrapper.name == null || wrapper.stateJson == null) { + OumLib.logError("Invalid profile export file: " + file.getName(), null); + return null; + } + + ProfileData data = new ProfileData( + wrapper.name, + wrapper.createdAt, + wrapper.lastUsed, + PlayerState.fromJson(wrapper.stateJson), + wrapper.balance, + wrapper.primaryGroup, + wrapper.groupsJson + ); + OumLib.logDebug("Imported profile '" + data.name() + "' from " + file.getAbsolutePath()); + return data; + } catch (Exception e) { + OumLib.logError("Failed to import profile from file: " + file.getName(), e); + return null; + } + } + + public static @NonNull File getExportsDir() { + File dir = new File(OumLib.getDataFolder(), "exports"); + if (!dir.exists()) { + dir.mkdirs(); + } + return dir; + } + + private record ExportWrapper( + String name, + long createdAt, + long lastUsed, + String stateJson, + double balance, + String primaryGroup, + String groupsJson + ) { + } +} diff --git a/src/main/resources/migrations/mysql/V1__init.sql b/src/main/resources/migrations/mysql/V1__init.sql index 282f1e3..c8a8c85 100644 --- a/src/main/resources/migrations/mysql/V1__init.sql +++ b/src/main/resources/migrations/mysql/V1__init.sql @@ -1,30 +1,12 @@ -CREATE TABLE IF NOT EXISTS oum_profiles -( - uuid - VARCHAR -( - 36 -) NOT NULL, - name VARCHAR -( - 32 -) NOT NULL, +CREATE TABLE IF NOT EXISTS oum_profiles ( + uuid VARCHAR(36) NOT NULL, + name VARCHAR(32) NOT NULL, created_at BIGINT NOT NULL, last_used BIGINT NOT NULL, state_json LONGTEXT NOT NULL, balance DOUBLE NOT NULL DEFAULT 0, - primary_group VARCHAR -( - 64 -), + primary_group VARCHAR(64), groups_json TEXT, - PRIMARY KEY -( - uuid, - name -), - INDEX idx_oum_profiles_uuid -( - uuid -) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file + PRIMARY KEY (uuid, name), + INDEX idx_oum_profiles_uuid (uuid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; \ No newline at end of file diff --git a/src/main/resources/migrations/mysql/V2__add_active_column.sql b/src/main/resources/migrations/mysql/V2__add_active_column.sql new file mode 100644 index 0000000..3e48cef --- /dev/null +++ b/src/main/resources/migrations/mysql/V2__add_active_column.sql @@ -0,0 +1 @@ +ALTER TABLE oum_profiles ADD COLUMN active TINYINT NOT NULL DEFAULT 0; diff --git a/src/main/resources/migrations/sqlite/V1__init.sql b/src/main/resources/migrations/sqlite/V1__init.sql index f53728c..8fd5673 100644 --- a/src/main/resources/migrations/sqlite/V1__init.sql +++ b/src/main/resources/migrations/sqlite/V1__init.sql @@ -1,41 +1,13 @@ -CREATE TABLE IF NOT EXISTS oum_profiles -( - uuid - TEXT - NOT - NULL, - name - TEXT - NOT - NULL, - created_at - INTEGER - NOT - NULL, - last_used - INTEGER - NOT - NULL, - state_json - TEXT - NOT - NULL, - balance - REAL - NOT - NULL - DEFAULT - 0, - primary_group - TEXT, - groups_json - TEXT, - PRIMARY - KEY -( - uuid, - name -) - ); +CREATE TABLE IF NOT EXISTS oum_profiles ( + uuid TEXT NOT NULL, + name TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_used INTEGER NOT NULL, + state_json TEXT NOT NULL, + balance REAL NOT NULL DEFAULT 0, + primary_group TEXT, + groups_json TEXT, + PRIMARY KEY (uuid, name) +); CREATE INDEX IF NOT EXISTS idx_oum_profiles_uuid ON oum_profiles (uuid); \ No newline at end of file diff --git a/src/main/resources/migrations/sqlite/V2__add_active_column.sql b/src/main/resources/migrations/sqlite/V2__add_active_column.sql new file mode 100644 index 0000000..46fd7d0 --- /dev/null +++ b/src/main/resources/migrations/sqlite/V2__add_active_column.sql @@ -0,0 +1 @@ +ALTER TABLE oum_profiles ADD COLUMN active INTEGER NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 4423149..dee1c22 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -1,5 +1,5 @@ name: OumProfile -version: 1.1-RELEASE +version: 1.2-SNAPSHOT main: dev.oum.profile.OumProfile api-version: '1.21' folia-supported: true