From 5e1c43ed6747ff924164db3f8d629e84d04dd9e7 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:40:33 +0200 Subject: [PATCH 1/2] Add comprehensive Magic admin value commands --- ADMIN-COMMANDS.md | 80 ++++++ README.md | 2 + .../magic/command/HeldValuesCommand.java | 178 +++++++++++++ .../magic/command/MagicCommand.java | 69 +++-- .../magic/command/PlayerValuesCommand.java | 124 +++++++++ .../magic/command/ValueAction.java | 42 +++ .../magic/profile/MagicProfileService.java | 8 + .../magic/profile/MagicProfileStore.java | 13 +- src/main/resources/messages.yml | 21 +- src/main/resources/plugin.yml | 4 +- .../tfminecraft/magic/AdminValuesTest.java | 246 ++++++++++++++++++ .../net/tfminecraft/magic/CommandTest.java | 10 +- .../magic/CompletionRegressionTest.java | 4 +- .../tfminecraft/magic/SessionServiceTest.java | 6 + .../magic/command/ValueActionTest.java | 13 + tools/dev-admin-values.cjs | 198 ++++++++++++++ 16 files changed, 982 insertions(+), 36 deletions(-) create mode 100644 ADMIN-COMMANDS.md create mode 100644 src/main/java/net/tfminecraft/magic/command/HeldValuesCommand.java create mode 100644 src/main/java/net/tfminecraft/magic/command/PlayerValuesCommand.java create mode 100644 src/main/java/net/tfminecraft/magic/command/ValueAction.java create mode 100644 src/test/java/net/tfminecraft/magic/AdminValuesTest.java create mode 100644 src/test/java/net/tfminecraft/magic/command/ValueActionTest.java create mode 100644 tools/dev-admin-values.cjs diff --git a/ADMIN-COMMANDS.md b/ADMIN-COMMANDS.md new file mode 100644 index 0000000..b770b8a --- /dev/null +++ b/ADMIN-COMMANDS.md @@ -0,0 +1,80 @@ +# Magic administration + +These commands require `magic.admin` (the existing `magic.admin.reload` alias +also grants administration). Character targets must be online and have a loaded +active character. Commands affect that character only, save immediately, and +resync spell modifiers. If saving fails, the edit is rolled back and the command +reports failure. Console may use character commands; held-item commands +must be used by a player. + +```text +/magic player get +/magic player reset +/magic resonance get +/magic resonance +/magic resonance reset [element|all] +/magic equilibrium [amount] +/magic castmode [flow|surge] +``` + +Amounts are required for set/add/remove and omitted for get/reset. `add` accepts +signed deltas; `remove` takes a nonnegative magnitude. Resonance is clamped to +zero through the configured school maximum; equilibrium uses the configured +minimum and maximum. Both keep two decimals. Positive equilibrium is tranquility, +negative is corruption, and zero is neutral. For example, `equilibrium set Alice +-30` gives 30 corruption; `equilibrium add Alice 10` leaves 20 corruption. + +`player get` shows character ID, casting mode, equilibrium, Focus and all school +resonances. `player reset` resets **only Magic's character values** to configured +defaults; Focus is unchanged. Resetting a single value also restores its configured +default. Fractional passive drift is cleared when its value is edited. + +Focus belongs to RPCharacters; use `/focus get`, `/focus set`, `/focus add`, +`/focus remove`, `/focus reset` or `/focus restore` with `rpchar.focus.admin`. +Mana and cooldowns belong to their provider plugins. Spell modifiers are derived +from resonance, equilibrium and gear, so are recalculated rather than edited. +School unlocks remain permissions. + +## Held artifact and weapon values + +```text +/magic artifact inspect +/magic artifact fill [amount] +/magic artifact muffle [percent] +/magic artifact wear [amount] +/magic gear inspect +/magic gear rift [percent] +/magic gear durability [points] +``` + +Edits require exactly one appropriate item in the main hand. Artifacts must have +an ID. Fill targets existing aura slots and clamps to each slot's capacity; +reset empties it. Muffle is a percentage from zero to 100; reset clears it and +editing re-anchors the care clock. Wear is accumulated lifetime wear, not remaining +integrity: reset repairs a still-existing artifact; reaching its wear budget +crumbles it into the normal spent husk. A husk cannot be repaired by these commands. +Wear edits are unavailable while wear is disabled. Artifact inspection also shows +its ID, aura capacities, wear budget and active sharing count. + +Rift is the weapon's permanent Rift, clamped to the configured cap. Temporary +casting mismatch is derived from equipment and character state. Durability means +remaining points, uses custom max durability when present, and stops at one point +to preserve MMOItems weapons. Reset repairs to maximum. Rift/durability require +whole numbers; unbreakable or nondamageable weapons have no editable durability. +Gear inspection also shows elemental attunement. Edits preserve item identity, +parts and sockets and update affected lore. They are logged to Magic's aura log +when logging is enabled. Artifact fill edits preserve capacity, rarity, sharing +history and identity. + +Existing `/magic artifact setfill`, `setmuffle`, `unmuffle`, `/magic weapon give`, +`/magic shrine fill`, `/magic artifact create` and `/magic refresh` still work. +Tab completion lists the new routes and arguments for permitted senders. + +## Validation + +Run `mvn -B --no-transfer-progress clean verify` with Java 21 and the pinned +dependencies. The existing durable gear-storage tests require Linux filesystem +semantics. `tools/dev-admin-values.cjs` exercises both this plugin and the +RPCharacters Focus commands on TFMCDev01 through its existing Dev bot helpers. +It uses only the dedicated `AdminValueProbe` character, removes its temporary +operator status, resets its test values and clears its test inventory afterward. diff --git a/README.md b/README.md index aef1f57..553b27f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ Magic keeps resonance profiles tied to roleplay characters through RPCharacters. ## Documentation +[Staff value commands](ADMIN-COMMANDS.md) + [Project documentation](https://github.com/TF-Minecraft/Docs/blob/main/projects/Magic/README.md) Technical documentation is maintained in [TF-Minecraft/Docs](https://github.com/TF-Minecraft/Docs). diff --git a/src/main/java/net/tfminecraft/magic/command/HeldValuesCommand.java b/src/main/java/net/tfminecraft/magic/command/HeldValuesCommand.java new file mode 100644 index 0000000..0fa6466 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/command/HeldValuesCommand.java @@ -0,0 +1,178 @@ +package net.tfminecraft.magic.command; + +import java.util.List; +import java.util.Locale; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.bukkit.persistence.PersistentDataType; + +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.artifact.Artifact; +import net.tfminecraft.magic.artifact.ArtifactCareStore; +import net.tfminecraft.magic.artifact.ArtifactIds; +import net.tfminecraft.magic.artifact.ArtifactKeys; +import net.tfminecraft.magic.artifact.ArtifactLore; +import net.tfminecraft.magic.artifact.ArtifactWear; +import net.tfminecraft.magic.attunement.AuraLog; +import net.tfminecraft.magic.gear.GearProvenance; +import net.tfminecraft.magic.gear.WeaponLore; +import net.tfminecraft.magic.gear.WeaponRequirement; +import net.tfminecraft.magic.gear.WeaponRift; +import net.tfminecraft.magic.gear.WeaponWear; +import net.tfminecraft.magic.gear.orb.OrbCache; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.util.MagicNumbers; + +final class HeldValuesCommand { + private HeldValuesCommand() {} + + static final List ARTIFACT = List.of("inspect", "fill", "muffle", "wear"); + static final List GEAR = List.of("inspect", "rift", "durability"); + + static boolean execute(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Messages.get("values.players_only")); + return true; + } + boolean gear = args[0].equalsIgnoreCase("gear"); + String field = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : ""; + boolean inspect = field.equals("inspect"); + ValueAction action = args.length > 2 ? ValueAction.parse(args[2]) : null; + boolean fill = field.equals("fill"); + int amountIndex = fill ? 4 : 3; + if (!(gear ? GEAR : ARTIFACT).contains(field) + || (inspect ? args.length != 2 : action == null + || args.length != amountIndex + (action.needsAmount() ? 1 : 0))) { + sender.sendMessage(Messages.get("values.item_usage")); + return true; + } + ItemStack item = player.getInventory().getItemInMainHand().clone(); + Artifact artifact = gear ? null : Artifact.fromItem(item); + if (gear ? !GearProvenance.isGear(item) : artifact == null || ArtifactIds.read(item) == null) { + sender.sendMessage(Messages.get("values.wrong_item")); + return true; + } + if (item.getAmount() != 1 && !inspect && action != ValueAction.GET) { + sender.sendMessage(Messages.get("values.single_item")); + return true; + } + if (inspect) { + if (gear) { + report(sender, "rift", WeaponRift.get(item)); + report(sender, "durability", WeaponWear.remaining(item)); + var aura = WeaponRequirement.fromItem(item).aura(); + for (String element : aura.getCappedElementIds()) { + report(sender, "attunement." + element, aura.getFill(element)); + } + } else { + sender.sendMessage(Messages.get("values.item_id", "id", ArtifactIds.read(item).toString())); + for (String element : artifact.getCappedElementIds()) reportFill(sender, artifact, element); + report(sender, "muffle", ArtifactCareStore.readMuffle(item) * 100); + report(sender, "wear", ArtifactWear.readWear(item)); + report(sender, "wear_budget", ArtifactWear.budget(artifact)); + report(sender, "active_users", ArtifactCareStore.activeUserCount(item, System.currentTimeMillis())); + } + return true; + } + Double amount = action.needsAmount() ? ValueAction.amount(args[amountIndex]) : Double.valueOf(0.0); + if (amount == null || (action == ValueAction.REMOVE && amount < 0) + || (gear && (amount != Math.rint(amount) || Math.abs(amount) > Integer.MAX_VALUE))) { + sender.sendMessage(Messages.get("values.invalid_amount")); + return true; + } + if (fill) { + String element = args[3].toLowerCase(Locale.ROOT); + if (!element.equals("all") && (!ElementRegistry.contains(element) || artifact.getCap(element) <= 0)) { + sender.sendMessage(Messages.get("artifact.setfill.no_cap", "element", element)); + return true; + } + for (String id : element.equals("all") ? artifact.getCappedElementIds() : List.of(element)) { + double before = artifact.getFill(id); + if (action != ValueAction.GET) artifact.setFill(id, action.apply(before, amount, 0)); + reportFill(sender, artifact, id); + } + if (action != ValueAction.GET) artifact.write(item); + } else { + double before; + double max; + double reset = 0; + switch (field) { + case "muffle" -> { before = ArtifactCareStore.readMuffle(item) * 100; max = 100; } + case "wear" -> { + if (!ArtifactWear.enabled()) { + sender.sendMessage(Messages.get("values.wear_disabled")); + return true; + } + before = ArtifactWear.readWear(item); + max = ArtifactWear.budget(artifact); + } + case "rift" -> { before = WeaponRift.get(item); max = OrbCache.riftCap; } + default -> { + before = WeaponWear.remaining(item); + if (before < 0) { + sender.sendMessage(Messages.get("values.no_durability")); + return true; + } + Damageable meta = (Damageable) item.getItemMeta(); + max = meta.hasMaxDamage() ? meta.getMaxDamage() : item.getType().getMaxDurability(); + reset = max; + } + } + // Durability stops at one: MMOItems may delete a zero-durability weapon. + double next = action == ValueAction.GET ? before : MagicNumbers.clamp( + action.apply(before, amount, reset), field.equals("durability") ? 1 : 0, max); + if (action != ValueAction.GET) { + switch (field) { + case "muffle" -> ArtifactCareStore.setMuffle(item, next / 100, System.currentTimeMillis()); + case "wear" -> { + if (next >= max) ArtifactWear.crumble(item); + else { + var meta = item.getItemMeta(); + meta.getPersistentDataContainer().set(ArtifactKeys.artifactWear(), PersistentDataType.DOUBLE, next); + item.setItemMeta(meta); + ArtifactLore.refreshAttune(item); + } + } + case "rift" -> { WeaponRift.set(item, (int) next); WeaponLore.apply(item); } + default -> { + Damageable meta = (Damageable) item.getItemMeta(); + meta.setDamage((int) (max - next)); + item.setItemMeta(meta); + } + } + } + report(sender, field, next); + } + if (action != ValueAction.GET) { + player.getInventory().setItemInMainHand(item); + AuraLog.append("admin-item player=%s field=%s action=%s amount=%s", player.getName(), field, action, AuraLog.n(amount)); + if (gear) Magic.plugin.syncSpellModifiers(player, + Magic.plugin.getResonanceGuiManager().getSessionManager().get(player)); + } + return true; + } + + private static void report(CommandSender sender, String field, double value) { + sender.sendMessage(Messages.get("values.item", "field", field, "value", MagicNumbers.format(value))); + } + + private static void reportFill(CommandSender sender, Artifact artifact, String element) { + sender.sendMessage(Messages.get("values.fill", "element", element, + "value", MagicNumbers.format(artifact.getFill(element)), "max", MagicNumbers.format(artifact.getCap(element)))); + } + + static List complete(String[] args) { + if (args.length == 2) return GEAR; + if (args.length == 3 && !args[1].equalsIgnoreCase("inspect")) return ValueAction.NAMES; + if (args.length == 4 && args[1].equalsIgnoreCase("fill")) { + var options = new java.util.ArrayList<>(ElementRegistry.getAllIds()); + options.add("all"); + return options; + } + return List.of(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java index 81e80b5..91dd691 100644 --- a/src/main/java/net/tfminecraft/magic/command/MagicCommand.java +++ b/src/main/java/net/tfminecraft/magic/command/MagicCommand.java @@ -40,7 +40,7 @@ import net.tfminecraft.magic.gear.GearRefresher; import net.tfminecraft.magic.gear.RuneKeybind; import net.tfminecraft.magic.model.ElementDef; -import net.tfminecraft.magic.profile.MagicProfileService; +import net.tfminecraft.magic.profile.MagicProfile; import net.tfminecraft.magic.registry.ElementRegistry; import net.tfminecraft.magic.session.ResonanceSession; import net.tfminecraft.magic.util.MagicNumbers; @@ -48,7 +48,8 @@ public final class MagicCommand implements CommandExecutor, TabCompleter { private static final List SUBCOMMANDS = List.of( - "reload", "open", "artifact", "fillchest", "resonance", "shrine", "refresh"); + "reload", "open", "artifact", "fillchest", "resonance", "shrine", "refresh", + "player", "equilibrium", "castmode", "gear"); @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { @@ -78,6 +79,13 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } + if (PlayerValuesCommand.handles(args[0])) { + return PlayerValuesCommand.execute(sender, args); + } + if ("gear".equalsIgnoreCase(args[0])) { + return HeldValuesCommand.execute(sender, args); + } + if ("reload".equalsIgnoreCase(args[0])) { boolean ok = Magic.plugin.reloadAll(); sender.sendMessage(ok ? Messages.get("reload.success") : Messages.get("reload.failed")); @@ -176,6 +184,9 @@ private static boolean handleArtifact(CommandSender sender, String[] args) { sender.sendMessage(Messages.get("artifact.usage")); return true; } + if (HeldValuesCommand.ARTIFACT.contains(args[1].toLowerCase(Locale.ROOT))) { + return HeldValuesCommand.execute(sender, args); + } if ("roll".equalsIgnoreCase(args[1])) { return handleArtifactRoll(sender, args); } @@ -229,6 +240,13 @@ private static boolean handleResonance(CommandSender sender, String[] args) { return true; } String action = args[1].toLowerCase(Locale.ROOT); + ValueAction parsed = ValueAction.parse(action); + if (parsed == null || (parsed == ValueAction.GET && args.length != 3) + || (parsed == ValueAction.RESET && args.length > 4) + || (parsed.needsAmount() && args.length != 5)) { + sender.sendMessage(Messages.get("resonance.admin.usage")); + return true; + } Player target = Bukkit.getPlayerExact(args[2]); if (target == null) { sender.sendMessage(Messages.get("artifact.give.unknown_player", "player", args[2])); @@ -240,6 +258,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { sender.sendMessage(Messages.get("resonance.admin.no_character", "player", target.getName())); return true; } + MagicProfile before = MagicProfile.fromSession(characterId, target.getUniqueId(), session); if ("get".equals(action)) { sender.sendMessage(Messages.get( "resonance.admin.get_header", @@ -259,17 +278,13 @@ private static boolean handleResonance(CommandSender sender, String[] args) { if (!applyResonance(target, session, elementArg, Cache.defaultResonance, false, sender)) { return true; } - persistResonance(target, session); + if (!PlayerValuesCommand.persist(sender, target, session, before)) return true; sender.sendMessage(Messages.get( "resonance.admin.reset_ok", "player", target.getName(), "element", elementArg.toLowerCase(Locale.ROOT))); return true; } - if (args.length < 5) { - sender.sendMessage(Messages.get("resonance.admin.usage")); - return true; - } String elementArg = args[3]; Double amount = parseAmount(args[4]); if (amount == null) { @@ -280,7 +295,7 @@ private static boolean handleResonance(CommandSender sender, String[] args) { if (!applyResonance(target, session, elementArg, amount, false, sender)) { return true; } - persistResonance(target, session); + if (!PlayerValuesCommand.persist(sender, target, session, before)) return true; sender.sendMessage(Messages.get( "resonance.admin.set_ok", "player", target.getName(), @@ -288,11 +303,18 @@ private static boolean handleResonance(CommandSender sender, String[] args) { "value", MagicNumbers.format(amount))); return true; } - if ("add".equals(action)) { + { + if ("remove".equals(action)) { + if (amount < 0) { + sender.sendMessage(Messages.get("resonance.admin.invalid_amount")); + return true; + } + amount = -amount; + } if (!applyResonance(target, session, elementArg, amount, true, sender)) { return true; } - persistResonance(target, session); + if (!PlayerValuesCommand.persist(sender, target, session, before)) return true; sender.sendMessage(Messages.get( "resonance.admin.add_ok", "player", target.getName(), @@ -300,8 +322,6 @@ private static boolean handleResonance(CommandSender sender, String[] args) { "delta", MagicNumbers.format(amount))); return true; } - sender.sendMessage(Messages.get("resonance.admin.usage")); - return true; } private static boolean handleShrine(CommandSender sender, String[] args) { @@ -356,6 +376,7 @@ private static boolean applyResonance( double current = session.getResonance(element.getId()); double next = add ? current + amount : amount; session.setResonance(element.getId(), next); + session.clearResonanceDrift(element.getId()); } return true; } @@ -367,17 +388,10 @@ private static boolean applyResonance( double current = session.getResonance(element.getId()); double next = add ? current + amount : amount; session.setResonance(element.getId(), next); + session.clearResonanceDrift(element.getId()); return true; } - private static void persistResonance(Player target, ResonanceSession session) { - MagicProfileService profiles = Magic.plugin.getProfileService(); - if (profiles != null) { - profiles.savePlayer(target); - } - Magic.plugin.syncSpellModifiers(target, session); - } - private static Double parseAmount(String raw) { try { double value = Double.parseDouble(raw.trim()); @@ -683,7 +697,7 @@ public List onTabComplete(CommandSender sender, Command command, String options.add("rune"); } if (admin) { - options.addAll(isPlayer ? SUBCOMMANDS : List.of("reload", "artifact", "resonance")); + options.addAll(isPlayer ? SUBCOMMANDS : List.of("reload", "artifact", "resonance", "player", "equilibrium", "castmode")); } return filterPrefix(options, args[0]); } @@ -708,6 +722,12 @@ public List onTabComplete(CommandSender sender, Command command, String if (!admin) { return Collections.emptyList(); } + if (PlayerValuesCommand.handles(args[0])) { + return filterPrefix(PlayerValuesCommand.complete(args), args[args.length - 1]); + } + if ("gear".equalsIgnoreCase(args[0])) { + return isPlayer ? filterPrefix(HeldValuesCommand.complete(args), args[args.length - 1]) : Collections.emptyList(); + } if (!isPlayer && List.of("open", "refresh", "rune", "fillchest", "shrine").contains(args[0].toLowerCase(Locale.ROOT))) { return Collections.emptyList(); } @@ -727,7 +747,7 @@ public List onTabComplete(CommandSender sender, Command command, String } if ("resonance".equalsIgnoreCase(args[0])) { if (args.length == 2) { - return filterPrefix(List.of("get", "set", "add", "reset"), args[1]); + return filterPrefix(ValueAction.NAMES, args[1]); } if (args.length == 3) { List names = new ArrayList<>(); @@ -757,13 +777,16 @@ public List onTabComplete(CommandSender sender, Command command, String } if (args.length == 2) { return filterPrefix( - isPlayer ? List.of("roll", "give", "create", "path", "setfill", "setmuffle", "unmuffle") + isPlayer ? List.of("roll", "give", "create", "path", "setfill", "setmuffle", "unmuffle", "inspect", "fill", "muffle", "wear") : List.of("roll", "give", "path"), args[1]); } if (!isPlayer && !List.of("roll", "give", "path").contains(args[1].toLowerCase(Locale.ROOT))) { return Collections.emptyList(); } + if (HeldValuesCommand.ARTIFACT.contains(args[1].toLowerCase(Locale.ROOT))) { + return filterPrefix(HeldValuesCommand.complete(args), args[args.length - 1]); + } if ("path".equalsIgnoreCase(args[1])) { return args.length == 3 ? ArtifactPathCompletion.complete(args[2]) : Collections.emptyList(); } diff --git a/src/main/java/net/tfminecraft/magic/command/PlayerValuesCommand.java b/src/main/java/net/tfminecraft/magic/command/PlayerValuesCommand.java new file mode 100644 index 0000000..5ff44ca --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/command/PlayerValuesCommand.java @@ -0,0 +1,124 @@ +package net.tfminecraft.magic.command; + +import java.util.List; +import java.util.Locale; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import net.tfminecraft.magic.Cache; +import net.tfminecraft.magic.GuiCache; +import net.tfminecraft.magic.Magic; +import net.tfminecraft.magic.Messages; +import net.tfminecraft.magic.profile.MagicProfile; +import net.tfminecraft.magic.registry.ElementRegistry; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.magic.util.MagicNumbers; +import net.tfminecraft.rpcharacters.RPCharacters; + +final class PlayerValuesCommand { + private PlayerValuesCommand() {} + + static boolean handles(String root) { + return List.of("player", "equilibrium", "castmode").contains(root.toLowerCase(Locale.ROOT)); + } + + static boolean execute(CommandSender sender, String[] args) { + String root = args[0].toLowerCase(Locale.ROOT); + ValueAction action = args.length >= 2 ? ValueAction.parse(args[1]) : null; + boolean numeric = root.equals("equilibrium"); + boolean mode = root.equals("castmode"); + if (action == null || (!numeric && !List.of(ValueAction.GET, ValueAction.RESET, ValueAction.SET).contains(action)) + || (root.equals("player") && action == ValueAction.SET) + || args.length != (action.needsAmount() ? 4 : 3)) { + sender.sendMessage(Messages.get("values.player_usage")); + return true; + } + Player target = Bukkit.getPlayerExact(args[2]); + if (target == null) { + sender.sendMessage(Messages.get("artifact.give.unknown_player", "player", args[2])); + return true; + } + var sessions = Magic.plugin.getResonanceGuiManager().getSessionManager(); + ResonanceSession session = sessions.get(target); + String character = sessions.getLoadedCharacterId(target); + if (session == null || character == null) { + sender.sendMessage(Messages.get("resonance.admin.no_character", "player", target.getName())); + return true; + } + if (action != ValueAction.GET) { + MagicProfile before = MagicProfile.fromSession(character, target.getUniqueId(), session); + if (numeric) { + Double amount = action.needsAmount() ? ValueAction.amount(args[3]) : Double.valueOf(0.0); + if (amount == null || (action == ValueAction.REMOVE && amount < 0)) { + sender.sendMessage(Messages.get("values.invalid_amount")); + return true; + } + session.setEquilibrium(action.apply(session.getEquilibrium(), amount, Cache.defaultEquilibrium)); + session.clearEquilibriumDrift(); + } else if (mode) { + String value = action == ValueAction.RESET ? Cache.defaultCastMode : args[3]; + if (!castModes().contains(value.toLowerCase(Locale.ROOT))) { + sender.sendMessage(Messages.get("values.invalid_mode")); + return true; + } + session.setCastModeId(value); + } else { + session.setCastModeId(Cache.defaultCastMode); + session.setEquilibrium(Cache.defaultEquilibrium); + session.clearEquilibriumDrift(); + for (String element : ElementRegistry.getAllIds()) { + session.setResonance(element, Cache.defaultResonance); + session.clearResonanceDrift(element); + } + } + if (!persist(sender, target, session, before)) return true; + } + sender.sendMessage(Messages.get("values.player", "player", target.getName(), "character", character, + "equilibrium", MagicNumbers.format(session.getEquilibrium()), "mode", session.getCastModeId())); + if (root.equals("player")) { + var focus = RPCharacters.getFocusService(); + sender.sendMessage(Messages.get("values.focus", "value", focus == null ? "unavailable" + : focus.getPoints(target) + "/" + focus.getMax())); + for (String element : ElementRegistry.getAllIds()) { + sender.sendMessage(Messages.get("resonance.admin.get_line", "element", element, + "value", MagicNumbers.format(session.getResonance(element)), + "max", MagicNumbers.format(ElementRegistry.getById(element).getMaxResonance()))); + } + } + return true; + } + + static boolean persist(CommandSender sender, Player target, ResonanceSession session, MagicProfile before) { + var profiles = Magic.plugin.getProfileService(); + if (profiles == null || !profiles.savePlayerChecked(target)) { + before.applyTo(session); + sender.sendMessage(Messages.get("values.save_failed")); + return false; + } + Magic.plugin.syncSpellModifiers(target, session); + return true; + } + + static List castModes() { + return List.of(GuiCache.castModeLeft.getId(), GuiCache.castModeRight.getId()); + } + + static List complete(String[] args) { + if (args.length == 2) { + return switch (args[0].toLowerCase(Locale.ROOT)) { + case "player" -> List.of("get", "reset"); + case "castmode" -> List.of("get", "set", "reset"); + default -> ValueAction.NAMES; + }; + } + if (args.length == 3) { + return Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(); + } + if (args.length == 4 && args[0].equalsIgnoreCase("castmode") && args[1].equalsIgnoreCase("set")) { + return castModes(); + } + return List.of(); + } +} diff --git a/src/main/java/net/tfminecraft/magic/command/ValueAction.java b/src/main/java/net/tfminecraft/magic/command/ValueAction.java new file mode 100644 index 0000000..7e0a6e1 --- /dev/null +++ b/src/main/java/net/tfminecraft/magic/command/ValueAction.java @@ -0,0 +1,42 @@ +package net.tfminecraft.magic.command; + +import java.util.List; +import java.util.Locale; + +/** Shared numeric command semantics. Removal takes a nonnegative magnitude. */ +enum ValueAction { + GET, SET, ADD, REMOVE, RESET; + + static final List NAMES = List.of("get", "set", "add", "remove", "reset"); + + static ValueAction parse(String raw) { + try { + return valueOf(raw.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return null; + } + } + + boolean needsAmount() { + return this == SET || this == ADD || this == REMOVE; + } + + double apply(double current, double amount, double reset) { + return switch (this) { + case GET -> current; + case SET -> amount; + case ADD -> current + amount; + case REMOVE -> current - amount; + case RESET -> reset; + }; + } + + static Double amount(String raw) { + try { + double value = Double.parseDouble(raw); + return Double.isFinite(value) ? value : null; + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java b/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java index 6272cff..ff0f07c 100644 --- a/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java +++ b/src/main/java/net/tfminecraft/magic/profile/MagicProfileService.java @@ -75,6 +75,14 @@ public void savePlayer(Player player) { }); } + public boolean savePlayerChecked(Player player) { + return Characters.currentSession(player).map(identity -> { + ResonanceSession session = sessionManager.getSession(identity); + return session != null && store.saveChecked(MagicProfile.fromSession( + identity.ref().characterId().toString(), identity.ref().ownerId(), session)); + }).orElse(false); + } + private void save(CharacterSession identity, ResonanceSession session) { // End events invalidate the token before delivery. Flush the captured value directly. store.save(MagicProfile.fromSession(identity.ref().characterId().toString(), diff --git a/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java b/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java index efd50d7..7379bb1 100644 --- a/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java +++ b/src/main/java/net/tfminecraft/magic/profile/MagicProfileStore.java @@ -76,13 +76,18 @@ public MagicProfile load(String characterId) { } public void save(MagicProfile profile) { + saveChecked(profile); + } + + /** Reports whether the profile reached disk, for synchronous administrative edits. */ + public boolean saveChecked(MagicProfile profile) { if (profile == null || profile.getCharacterId() == null || profile.getCharacterId().isBlank()) { - return; + return false; } if (unreadableProfiles.contains(profile.getCharacterId())) { Magic.plugin.getLogger().severe("[Magic] Refusing to overwrite unreadable profile " + profile.getCharacterId()); - return; + return false; } if (!folder.exists()) { folder.mkdirs(); @@ -95,14 +100,16 @@ public void save(MagicProfile profile) { } catch (IOException ex) { Magic.plugin.getLogger().severe("[Magic] Failed to write profile " + profile.getCharacterId() + ": " + ex.getMessage()); - return; + return false; } try { Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); } catch (IOException ex) { Magic.plugin.getLogger().severe("[Magic] Failed to replace profile " + profile.getCharacterId() + ": " + ex.getMessage()); + return false; } + return true; } private File fileFor(String characterId) { diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 51e353e..649c80d 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -20,7 +20,24 @@ admin: no_permission: "#ff5555You do not have permission to use this command." - usage: "#aaaaaaUsage: /magic rune keybind #555555| #aaaaaa/magic reload #555555| #aaaaaa/magic open #555555| #aaaaaa/magic resonance #555555| #aaaaaa/magic artifact roll #555555| #aaaaaa/magic artifact give #555555| #aaaaaa/magic artifact create #555555| #aaaaaa/magic artifact setfill #555555| #aaaaaa/magic artifact setmuffle #555555| #aaaaaa/magic artifact unmuffle #555555| #aaaaaa/magic fillchest #555555| #aaaaaa/magic shrine fill" + usage: "#aaaaaaUsage: /magic " + +values: + save_failed: "#ff5555Could not save this Magic edit. The character values were restored; check console." + player_usage: "#aaaaaaUsage: /magic player | /magic equilibrium [amount] | /magic castmode [mode]" + item_usage: "#aaaaaaUsage: /magic artifact inspect | /magic artifact fill [amount] | /magic artifact [amount] | /magic gear inspect | /magic gear [amount]" + players_only: "#ff5555This command edits the item in a player's main hand." + wrong_item: "#ff5555Hold an identified artifact or a Magic weapon, as appropriate. Spent husks cannot be edited." + single_item: "#ff5555Hold exactly one item before editing it." + invalid_amount: "#ff5555Use a finite amount. Removal needs a nonnegative magnitude; gear values need whole numbers within the integer range." + invalid_mode: "#ff5555Unknown casting mode. Use a mode from tab completion." + player: "#aaaaaa{player} (character {character}): equilibrium {equilibrium}, casting {mode}." + focus: "#aaaaaaFocus: {value} (managed with /focus)." + item_id: "#aaaaaaArtifact: {id}" + item: "#aaaaaa{field}: {value}" + fill: "#aaaaaa{element} aura: {value}/{max}" + no_durability: "#ff5555This weapon has no editable durability." + wear_disabled: "#ff5555Artifact wear is disabled in config." rune: @@ -100,7 +117,7 @@ resonance: admin: - usage: "#aaaaaaUsage: /magic resonance get #555555| #aaaaaa/magic resonance set #555555| #aaaaaa/magic resonance add #555555| #aaaaaa/magic resonance reset [element|all]" + usage: "#aaaaaaUsage: /magic resonance get #555555| #aaaaaa/magic resonance #555555| #aaaaaa/magic resonance reset [element|all]" no_character: "#ff5555{player} has no loaded character." diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2f44a20..64ac9ac 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -14,7 +14,7 @@ commands: permission: magic.use magic: description: Magic plugin commands - usage: /magic + usage: /magic permissions: magic.use: @@ -24,7 +24,7 @@ permissions: description: Allows /magic rune keybind on a held rune default: true magic.admin: - description: Allows /magic admin commands (reload, open) + description: Allows Magic administration, character values and held-item editing default: op children: magic.admin.reload: true diff --git a/src/test/java/net/tfminecraft/magic/AdminValuesTest.java b/src/test/java/net/tfminecraft/magic/AdminValuesTest.java new file mode 100644 index 0000000..95dd066 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/AdminValuesTest.java @@ -0,0 +1,246 @@ +package net.tfminecraft.magic; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import net.tfminecraft.magic.artifact.*; +import net.tfminecraft.magic.gear.*; +import net.tfminecraft.magic.gear.orb.OrbCache; +import net.tfminecraft.magic.session.ResonanceSession; +import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.focus.FocusService; +import org.bukkit.Material; +import org.bukkit.command.CommandSender; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.Damageable; +import org.junit.jupiter.api.*; + +class AdminValuesTest { + CommandTest f = new CommandTest(); + ResonanceSession session; + CharacterTestSupport identities; + double defaultEq, wearMultiplier; + String defaultMode; + int riftCap; + + @BeforeEach void setup() { + f.setup(); + defaultEq = Cache.defaultEquilibrium; defaultMode = Cache.defaultCastMode; + wearMultiplier = Cache.artifactWearMultiplier; riftCap = OrbCache.riftCap; + Cache.defaultEquilibrium = 0; Cache.defaultCastMode = "flow"; + GuiCache.equilibriumMin = -100; GuiCache.equilibriumMax = 100; + Cache.artifactWearMultiplier = 1; OrbCache.riftCap = 100; + identities = new CharacterTestSupport(); + var identity = identities.start(f.player, "admin-values"); + f.sessions.start(identities.plugin, null); + session = new ResonanceSession(); f.sessions.put(identity, session); + } + + @AfterEach void cleanup() { + Cache.defaultEquilibrium = defaultEq; Cache.defaultCastMode = defaultMode; + Cache.artifactWearMultiplier = wearMultiplier; OrbCache.riftCap = riftCap; + f.cleanup(); + } + + void run(String... args) { f.run(f.player, args); while (f.player.nextMessage() != null) {} } + ItemStack held() { return f.player.getInventory().getItemInMainHand(); } + void hold(ItemStack item) { f.player.getInventory().setItemInMainHand(item); } + ItemStack gear() { + var item = new ItemStack(Material.DIAMOND_SWORD); + GearProvenance.stamp(item, GearType.SWORD, List.of()); + return item; + } + + @Test void equilibriumEditsClampRoundPersistAndRemovePendingDrift() { + f.expect("values.player", "equilibrium", "get", "Mage"); + f.expect("values.player", "equilibrium", "set", "Mage", "-30"); + assertEquals(-30, session.getEquilibrium()); + session.driftEquilibrium(-0.009, -100); + run("equilibrium", "add", "Mage", "10.125"); + assertEquals(-19.88, session.getEquilibrium()); + session.driftEquilibrium(-0.002, -100); + assertEquals(-19.88, session.getEquilibrium()); + run("equilibrium", "remove", "Mage", "10"); assertEquals(-29.88, session.getEquilibrium()); + run("equilibrium", "set", "Mage", "1e300"); assertEquals(100, session.getEquilibrium()); + run("equilibrium", "add", "Mage", "-1e300"); assertEquals(-100, session.getEquilibrium()); + run("equilibrium", "reset", "Mage"); assertEquals(0, session.getEquilibrium()); + verify(Magic.plugin.getProfileService(), times(6)).savePlayerChecked(f.player); + verify(Magic.plugin, times(6)).syncSpellModifiers(f.player, session); + for (String bad : List.of("NaN", "Infinity", "oops")) + f.expect("values.invalid_amount", "equilibrium", "set", "Mage", bad); + f.expect("values.invalid_amount", "equilibrium", "remove", "Mage", "-1"); + } + + @Test void modesAndPlayerResetRespectConfiguredDefaultsAndShowFocus() { + run("castmode", "set", "Mage", "SURGE"); assertEquals("surge", session.getCastModeId()); + f.expect("values.invalid_mode", "castmode", "set", "Mage", "bogus"); + run("castmode", "get", "Mage"); + run("castmode", "reset", "Mage"); assertEquals("flow", session.getCastModeId()); + Cache.defaultResonance = 2; Cache.defaultEquilibrium = 3; + session.setResonance("fire", 20); session.setResonance("water", 30); + session.driftResonance("fire", -0.009); session.driftEquilibrium(-0.009, -100); + try (var rpc = mockStatic(RPCharacters.class)) { + run("player", "get", "Mage"); + assertEquals(20, session.getResonance("fire")); + var focus = mock(FocusService.class); + rpc.when(RPCharacters::getFocusService).thenReturn(focus); + when(focus.getPoints(f.player)).thenReturn(22); when(focus.getMax()).thenReturn(150); + run("player", "reset", "Mage"); + verifyNoInteractions(Magic.plugin.getArtifactCreateGuiManager()); + verify(focus, never()).restore(any()); + } + assertEquals(2, session.getResonance("fire")); assertEquals(2, session.getResonance("water")); + assertEquals(3, session.getEquilibrium()); assertEquals("flow", session.getCastModeId()); + session.driftResonance("fire", -0.002); session.driftEquilibrium(-0.002, -100); + assertEquals(2, session.getResonance("fire")); assertEquals(3, session.getEquilibrium()); + } + + @Test void playerValidationRejectsBadSyntaxUnavailableTargetsAndStaleCharacters() { + for (String[] args : List.of(new String[]{"player"}, new String[]{"player", "oops", "Mage"}, + new String[]{"player", "set", "Mage", "5"}, new String[]{"castmode", "add", "Mage", "1"}, + new String[]{"equilibrium", "get", "Mage", "extra"}, new String[]{"equilibrium", "set", "Mage"})) + f.expect("values.player_usage", args); + f.expect("artifact.give.unknown_player", "player", "get", "Missing"); + var unbound = f.server.addPlayer("Unbound"); + f.expect("resonance.admin.no_character", "equilibrium", "set", "Unbound", "1"); + f.sessions.getOrCreate(unbound); + f.expect("resonance.admin.no_character", "equilibrium", "set", "Unbound", "1"); + identities.start(f.player, "other-character"); + f.expect("resonance.admin.no_character", "equilibrium", "set", "Mage", "1"); + assertEquals(0, session.getEquilibrium()); + f.player.setOp(false); + for (String root : List.of("player", "equilibrium", "castmode", "gear")) { + f.expect("admin.no_permission", root, "reset", "Mage"); + assertTrue(f.tab(root, "").isEmpty()); + } + } + + @Test void failedSavesRollBackEveryCharacterValueAndDoNotResync() { + session.setEquilibrium(12); session.setResonance("fire", 25); session.setCastModeId("surge"); + when(Magic.plugin.getProfileService().savePlayerChecked(f.player)).thenReturn(false); + for (String[] args : List.of(new String[]{"player", "reset", "Mage"}, + new String[]{"equilibrium", "set", "Mage", "99"}, new String[]{"castmode", "set", "Mage", "flow"}, + new String[]{"resonance", "set", "Mage", "all", "0"}, + new String[]{"resonance", "add", "Mage", "fire", "3"}, + new String[]{"resonance", "reset", "Mage"})) { + f.expect("values.save_failed", args); + assertEquals(12, session.getEquilibrium()); assertEquals(25, session.getResonance("fire")); + assertEquals("surge", session.getCastModeId()); + } + verify(Magic.plugin, never()).syncSpellModifiers(any(), any()); + } + + @Test void resonanceArityIsValidatedBeforeAnyMutation() { + for (String[] args : List.of(new String[]{"resonance", "get", "Mage", "extra"}, + new String[]{"resonance", "reset", "Mage", "all", "extra"}, + new String[]{"resonance", "set", "Mage", "fire", "1", "extra"})) + f.expect("resonance.admin.usage", args); + identities.unregister(); + f.sessions.getOrCreate(f.player); + f.expect("resonance.admin.no_character", "equilibrium", "get", "Mage"); + assertTrue(f.tab("equilibrium", "set", "Mage", "1", "").isEmpty()); + } + + @Test void resonanceRemovalClampsAndClearsFractionalDrift() { + session.setResonance("fire", 30); session.driftResonance("fire", -0.009); + f.expect("resonance.admin.add_ok", "resonance", "remove", "Mage", "fire", "10"); + assertEquals(20, session.getResonance("fire")); + session.driftResonance("fire", -0.002); assertEquals(20, session.getResonance("fire")); + f.expect("resonance.admin.invalid_amount", "resonance", "remove", "Mage", "fire", "-10"); + run("resonance", "remove", "Mage", "all", "100"); assertEquals(0, session.getResonance("fire")); + assertTrue(f.tab("resonance", "").contains("remove")); + } + + @Test void artifactFillClampsUpdatesLoreAndPreservesIdentity() { + var item = f.artifact(); var id = ArtifactIds.read(item); hold(item); + run("artifact", "fill", "get", "fire"); assertEquals(4, Artifact.fromItem(held()).getFill("fire")); + run("artifact", "fill", "set", "fire", "8"); assertEquals(8, Artifact.fromItem(held()).getFill("fire")); + run("artifact", "fill", "add", "all", "20"); assertEquals(10, Artifact.fromItem(held()).getFill("fire")); + run("artifact", "fill", "remove", "fire", "3"); assertEquals(7, Artifact.fromItem(held()).getFill("fire")); + run("artifact", "fill", "reset", "all"); assertEquals(0, Artifact.fromItem(held()).getFill("fire")); + assertEquals(id, ArtifactIds.read(held())); + f.expect("artifact.setfill.no_cap", "artifact", "fill", "set", "water", "2"); + f.expect("artifact.setfill.no_cap", "artifact", "fill", "get", "bogus"); + run("artifact", "inspect"); + assertNotNull(held().getItemMeta().getLore()); + } + + @Test void artifactMufflingAndWearCanBeRepairedOrSpent() { + hold(f.artifact()); + run("artifact", "muffle", "set", "25"); assertEquals(.25, ArtifactCareStore.readMuffle(held())); + run("artifact", "muffle", "add", "100"); assertEquals(1, ArtifactCareStore.readMuffle(held())); + run("artifact", "muffle", "remove", "25"); assertEquals(.75, ArtifactCareStore.readMuffle(held())); + run("artifact", "muffle", "get"); + run("artifact", "muffle", "reset"); assertEquals(0, ArtifactCareStore.readMuffle(held())); + run("artifact", "wear", "set", "3"); assertEquals(3, ArtifactWear.readWear(held())); + run("artifact", "wear", "get"); + run("artifact", "wear", "remove", "1"); assertEquals(2, ArtifactWear.readWear(held())); + run("artifact", "wear", "reset"); assertEquals(0, ArtifactWear.readWear(held())); + Cache.artifactWearMultiplier = 0; + f.expect("values.wear_disabled", "artifact", "wear", "set", "1"); + Cache.artifactWearMultiplier = 1; + run("artifact", "wear", "add", "10"); assertTrue(ArtifactWear.isHusk(held())); + f.expect("values.wrong_item", "artifact", "wear", "reset"); + } + + @Test void gearRiftAndDurabilityEditsPreserveLastPointAndCustomMaximum() { + var item = gear(); var req = WeaponRequirement.empty(); + req.aura().setCap("fire", 30); req.aura().setFill("fire", 20); req.persist(item); hold(item); + run("gear", "rift", "set", "25"); assertEquals(25, WeaponRift.get(held())); + run("gear", "rift", "get"); run("gear", "inspect"); + run("gear", "rift", "add", "2147483647"); assertEquals(100, WeaponRift.get(held())); + run("gear", "rift", "remove", "2147483647"); assertEquals(0, WeaponRift.get(held())); + run("gear", "rift", "reset"); + run("gear", "durability", "set", "20"); assertEquals(20, WeaponWear.remaining(held())); + run("gear", "durability", "remove", "100"); assertEquals(1, WeaponWear.remaining(held())); + run("gear", "durability", "get"); run("gear", "durability", "reset"); + assertEquals(Material.DIAMOND_SWORD.getMaxDurability(), WeaponWear.remaining(held())); + item = held(); var meta = (Damageable)item.getItemMeta(); meta.setMaxDamage(200); item.setItemMeta(meta); hold(item); + run("gear", "durability", "reset"); assertEquals(200, WeaponWear.remaining(held())); + item = held(); meta = (Damageable)item.getItemMeta(); meta.setUnbreakable(true); item.setItemMeta(meta); hold(item); + f.expect("values.no_durability", "gear", "durability", "set", "2"); + for (String bad : List.of("1.5", "2147483648", "NaN", "bad")) + f.expect("values.invalid_amount", "gear", "rift", "set", bad); + f.expect("values.invalid_amount", "gear", "rift", "remove", "-1"); + } + + @Test void heldItemValidationDoesNotModifyWrongItemsStacksOrMalformedRequests() { + var console = mock(CommandSender.class); when(console.hasPermission(anyString())).thenReturn(true); + f.run(console, "gear", "inspect"); verify(console).sendMessage("values.players_only"); + for (String[] args : List.of(new String[]{"gear"}, new String[]{"gear", "bogus"}, + new String[]{"gear", "inspect", "extra"}, new String[]{"gear", "rift"}, + new String[]{"artifact", "fill", "bogus", "fire"}, new String[]{"artifact", "muffle", "set"})) + f.expect("values.item_usage", args); + f.expect("values.wrong_item", "gear", "inspect"); + f.expect("values.wrong_item", "artifact", "inspect"); + hold(new ItemStack(Material.STONE)); f.expect("values.wrong_item", "artifact", "inspect"); + var item = f.artifact(); var meta = item.getItemMeta(); meta.getPersistentDataContainer().remove(ArtifactKeys.artifactId()); + item.setItemMeta(meta); hold(item); f.expect("values.wrong_item", "artifact", "inspect"); + item = f.artifact(); item.setAmount(2); hold(item); + f.expect("values.single_item", "artifact", "fill", "set", "fire", "1"); + run("artifact", "fill", "get", "all"); run("artifact", "inspect"); + hold(f.artifact()); f.expect("values.invalid_amount", "artifact", "muffle", "set", "NaN"); + f.expect("values.invalid_amount", "artifact", "fill", "remove", "fire", "-1"); + } + + @Test void completionCoversNewRoutesAndRespectsConsoleAndPermissionBoundaries() { + assertTrue(f.tab("").containsAll(List.of("player", "equilibrium", "castmode", "gear"))); + assertEquals(List.of("get", "reset"), f.tab("player", "")); + assertEquals(List.of("get", "set", "reset"), f.tab("castmode", "")); + assertTrue(f.tab("equilibrium", "").containsAll(List.of("get", "set", "add", "remove", "reset"))); + assertEquals(List.of("Mage"), f.tab("equilibrium", "set", "M")); + assertEquals(List.of("flow"), f.tab("castmode", "set", "Mage", "f")); + assertTrue(f.tab("castmode", "reset", "Mage", "").isEmpty()); + assertTrue(f.tab("equilibrium", "set", "Mage", "").isEmpty()); + assertTrue(f.tab("gear", "").contains("rift")); assertTrue(f.tab("gear", "rift", "").contains("remove")); + assertTrue(f.tab("artifact", "").contains("inspect")); + assertTrue(f.tab("artifact", "fill", "").contains("get")); + assertTrue(f.tab("artifact", "fill", "get", "").containsAll(List.of("fire", "all"))); + assertTrue(f.tab("artifact", "inspect", "").isEmpty()); + assertTrue(f.tab("gear", "rift", "set", "").isEmpty()); + var console = mock(CommandSender.class); when(console.hasPermission(anyString())).thenReturn(true); + assertTrue(f.command.onTabComplete(console, null, "magic", new String[]{""}).contains("equilibrium")); + assertTrue(f.command.onTabComplete(console, null, "magic", new String[]{"gear", ""}).isEmpty()); + } +} diff --git a/src/test/java/net/tfminecraft/magic/CommandTest.java b/src/test/java/net/tfminecraft/magic/CommandTest.java index 47c4e32..a1e7244 100644 --- a/src/test/java/net/tfminecraft/magic/CommandTest.java +++ b/src/test/java/net/tfminecraft/magic/CommandTest.java @@ -39,6 +39,7 @@ void setup() { when(Magic.plugin.namespace()).thenReturn("magic"); sessions = new ResonanceSessionManager(); when(Magic.plugin.getResonanceGuiManager().getSessionManager()).thenReturn(sessions); + when(Magic.plugin.getProfileService().savePlayerChecked(any())).thenReturn(true); messages = mockStatic(Messages.class, i -> i.getArgument(0)); ElementRegistry.clear(); ElementRegistry.register(DomainTest.element("fire")); @@ -170,8 +171,9 @@ void resonanceCommandsValidateTargetsMutateAndPersist() { assertEquals("resonance.admin.get_line", player.nextMessage()); expect("resonance.admin.usage", "resonance", "set", "Mage"); expect("resonance.admin.invalid_amount", "resonance", "set", "Mage", "fire", "oops"); - for (String action : List.of("set", "add", "reset")) + for (String action : List.of("set", "add")) expect("resonance.admin.unknown_element", "resonance", action, "Mage", "missing", "2"); + expect("resonance.admin.unknown_element", "resonance", "reset", "Mage", "missing"); expect("resonance.admin.set_ok", "resonance", "set", "Mage", "fire", "12"); assertEquals(12, s.getResonance("fire")); expect("resonance.admin.add_ok", "resonance", "add", "Mage", "fire", "3"); @@ -182,7 +184,7 @@ void resonanceCommandsValidateTargetsMutateAndPersist() { expect("resonance.admin.reset_ok", "resonance", "reset", "Mage"); assertEquals(0, s.getResonance("fire")); when(Magic.plugin.getProfileService()).thenReturn(null); - expect("resonance.admin.reset_ok", "resonance", "reset", "Mage", "fire"); + expect("values.save_failed", "resonance", "reset", "Mage", "fire"); expect("resonance.admin.usage", "resonance", "bogus", "Mage", "fire", "2"); } @@ -326,14 +328,14 @@ void chestModesAndTabSuggestionsRespectPermissionsEnabledTypesAndPositions() { assertTrue(tab("rune", "keybind", "", "extra").isEmpty()); assertEquals(List.of("random", "all", "fire"), tab("fillchest", "")); assertTrue(tab("fillchest", "all", "").isEmpty()); - assertEquals(List.of("get", "set", "add", "reset"), tab("resonance", "")); + assertEquals(List.of("get", "set", "add", "remove", "reset"), tab("resonance", "")); assertEquals(List.of("Mage"), tab("resonance", "get", "")); assertTrue(tab("resonance", "set", "Mage", "").containsAll(List.of("all", "fire", "water"))); assertTrue(tab("resonance", "get", "Mage", "").isEmpty()); assertEquals(List.of("fill"), tab("shrine", "")); assertEquals(2, tab("shrine", "fill", "").size()); assertTrue(tab("shrine", "other", "").isEmpty()); - assertEquals(7, tab("artifact", "").size()); + assertEquals(11, tab("artifact", "").size()); assertEquals(2, tab("artifact", "setfill", "").size()); assertTrue(tab("artifact", "setfill", "fire", "").isEmpty()); assertEquals(List.of("random", "fire"), tab("artifact", "roll", "")); diff --git a/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java b/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java index 654987c..7d1a090 100644 --- a/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java +++ b/src/test/java/net/tfminecraft/magic/CompletionRegressionTest.java @@ -24,7 +24,7 @@ List console(String... args) { } @Test void consoleOnlySeesExecutableRoutesAtEveryDepth() { - assertEquals(List.of("weapon", "reload", "artifact", "resonance"), console("")); + assertEquals(List.of("weapon", "reload", "artifact", "resonance", "player", "equilibrium", "castmode"), console("")); assertEquals(List.of("reload", "resonance"), console("RE")); assertEquals(List.of("roll", "give", "path"), console("artifact", "")); for (String root : List.of("open", "refresh", "rune", "fillchest", "shrine")) { @@ -38,7 +38,7 @@ List console(String... args) { assertEquals(List.of("Mage"), console("artifact", "give", "")); assertEquals(List.of("magic.artifact"), console("artifact", "path", "magic.a")); assertEquals(List.of("give"), console("weapon", "")); - assertEquals(List.of("get", "set", "add", "reset"), console("resonance", "")); + assertEquals(List.of("get", "set", "add", "remove", "reset"), console("resonance", "")); assertTrue(fixture.tab("artifact", "").contains("create")); } diff --git a/src/test/java/net/tfminecraft/magic/SessionServiceTest.java b/src/test/java/net/tfminecraft/magic/SessionServiceTest.java index d2be71b..a071278 100644 --- a/src/test/java/net/tfminecraft/magic/SessionServiceTest.java +++ b/src/test/java/net/tfminecraft/magic/SessionServiceTest.java @@ -283,10 +283,16 @@ void savePlayerSavesOnlyTheCurrentBoundTokenNeverLatePreviousValuesOrPendingProf clearInvocations(store); service.savePlayer(player); service.savePlayer(empty); + assertFalse(service.savePlayerChecked(player)); + assertFalse(service.savePlayerChecked(empty)); verify(store, never()).save(any()); service.activate(player, bob); sessions.get(player).setResonance("fire", 34); oldValue.setResonance("fire", 99); + assertTrue(service.savePlayerChecked(player)); + doReturn(false).when(store).saveChecked(any()); + assertFalse(service.savePlayerChecked(player)); + doCallRealMethod().when(store).saveChecked(any()); service.savePlayer(player); assertEquals(34, store.load(CharacterTestSupport.id("bob")).getResonance().get("fire")); assertEquals(12, store.load(CharacterTestSupport.id("alice")).getResonance().get("fire")); diff --git a/src/test/java/net/tfminecraft/magic/command/ValueActionTest.java b/src/test/java/net/tfminecraft/magic/command/ValueActionTest.java new file mode 100644 index 0000000..39ae355 --- /dev/null +++ b/src/test/java/net/tfminecraft/magic/command/ValueActionTest.java @@ -0,0 +1,13 @@ +package net.tfminecraft.magic.command; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class ValueActionTest { + @Test void actionArithmeticAndValidation() { + assertEquals(2, ValueAction.GET.apply(2, 3, 4)); + assertNull(ValueAction.amount("-Infinity")); + assertNull(ValueAction.parse("bogus")); + assertFalse(ValueAction.GET.needsAmount()); + } +} diff --git a/tools/dev-admin-values.cjs b/tools/dev-admin-values.cjs new file mode 100644 index 0000000..e1df0a1 --- /dev/null +++ b/tools/dev-admin-values.cjs @@ -0,0 +1,198 @@ +// TFMCDev01 integration checks. Run on the Dev host after installing both PR jars: +// node /path/to/Magic/tools/dev-admin-values.cjs +// Uses the existing Dev-only bot/console helpers and a dedicated test character. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const { joinAsCharacter } = require('/home/fran/bot-lib/character.js') +const consoleCmd = require('/home/fran/bot-lib/console.js') +const B = 'AdminValueProbe' +const MC = '/home/fran/work/amp/TFMCDev01/Minecraft' +const LOG = `${MC}/logs/latest.log` +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +const readSince = start => fs.readFileSync(LOG).subarray(start).toString('utf8') +const results = [] +let bot, character +const chat = [] + +async function consoleRun(command, delay = 500) { + consoleCmd(command) + await sleep(delay) +} +async function command(command, expected) { + const start = chat.length + bot.chat('/' + command) + for (let i = 0; i < 40; i++) { + await sleep(100) + const messages = chat.slice(start) + if (messages.some(line => expected.test(line))) return messages.join('\n') + } + throw new Error(`/${command}: expected ${expected}, received ${JSON.stringify(chat.slice(start))}`) +} +function profile() { + return JSON.parse(fs.readFileSync(`${MC}/plugins/Magic/data/characters/${character}.json`, 'utf8')) +} +function focus() { + return JSON.parse(fs.readFileSync(`${MC}/plugins/RPCharacters/data/focus/${character}.json`, 'utf8')) +} +async function step(name, fn) { + await fn() + results.push(name) + console.log('PASS ' + name) +} +async function hold(pattern) { + const slot = bot.inventory.slots.findIndex((item, i) => i >= 36 && item && pattern.test(JSON.stringify(item.components ?? item.nbt))) + assert.ok(slot >= 36, 'expected test item in hotbar: ' + pattern) + bot.setQuickBarSlot(slot - 36) + await sleep(400) +} + +;(async () => { + const logStart = fs.statSync(LOG).size + try { + bot = await joinAsCharacter(B, { class: /Mage/ }) + bot.on('messagestr', line => { chat.push(line); if (!/❤|^\s*$/.test(line)) console.log('CHAT ' + line) }) + await sleep(2000) + await consoleRun(`deop ${B}`) + await step('unprivileged character cannot change Magic or Focus', async () => { + await command(`magic equilibrium set ${B} 10`, /do not have permission/i) + await command(`focus set ${B} 10`, /do not have permission/i) + }) + await consoleRun(`op ${B}`) + await step('player inspection identifies the active character', async () => { + const reply = await command(`magic player get ${B}`, /character [0-9a-f-]+/i) + character = reply.match(/character ([0-9a-f-]+)/i)[1] + assert.ok(profile()) + }) + await step('equilibrium set/add/remove clamp, persist and reject invalid numbers', async () => { + await command(`magic equilibrium set ${B} -30`, /equilibrium -30/) + assert.equal(profile().equilibrium, -30) + await command(`magic equilibrium add ${B} 10`, /equilibrium -20/) + await command(`magic equilibrium remove ${B} 5`, /equilibrium -25/) + await command(`magic equilibrium set ${B} 1e300`, /equilibrium 100/) + for (const amount of ['NaN', 'Infinity', 'bad']) + await command(`magic equilibrium set ${B} ${amount}`, /finite amount/i) + await command(`magic equilibrium remove ${B} -1`, /finite amount/i) + await command(`magic equilibrium reset ${B}`, /equilibrium 0/) + }) + await step('cast mode set/get/reset and invalid-mode validation', async () => { + await command(`magic castmode set ${B} surge`, /casting surge/) + assert.equal(profile().castMode, 'surge') + await command(`magic castmode get ${B}`, /casting surge/) + await command(`magic castmode set ${B} bogus`, /Unknown casting mode/) + await command(`magic castmode reset ${B}`, /casting flow/) + }) + await step('resonance explicit removal, all-school edits and bounds', async () => { + await command(`magic resonance set ${B} cerrith 30`, /resonance/i) + assert.equal(profile().resonance.cerrith, 30) + await command(`magic resonance remove ${B} cerrith 12`, /resonance/i) + assert.equal(profile().resonance.cerrith, 18) + await command(`magic resonance remove ${B} cerrith -1`, /amount|number/i) + await command(`magic resonance add ${B} all 1000`, /resonance/i) + assert.equal(profile().resonance.cerrith, 100) + await command(`magic resonance reset ${B}`, /reset/i) + assert.equal(profile().resonance.cerrith ?? 0, 0) + await command(`magic resonance set ${B} cerrith 10 extra`, /Usage:/) + assert.equal(profile().resonance.cerrith ?? 0, 0) + }) + await step('Focus set/add/remove/reset persists actual clamped balance', async () => { + await command(`focus set ${B} 30`, /30\//) + assert.equal(focus().points, 30) + await command(`focus add ${B} -10`, /20\//) + await command(`focus remove ${B} 5`, /15\//) + assert.equal(focus().points, 15) + await command(`focus add ${B} 2147483647`, /Focus for/) + assert.ok(focus().points > 0) + await command(`focus remove ${B} 2147483647`, /0\//) + for (const amount of ['1.5', '2147483648']) await command(`focus set ${B} ${amount}`, /whole points/) + await command(`focus remove ${B} -1`, /whole points/) + await command(`focus reset ${B}`, /Focus for/) + }) + await step('Magic player reset preserves Focus', async () => { + await command(`focus set ${B} 42`, /42\//) + await command(`magic equilibrium set ${B} 50`, /equilibrium 50/) + await command(`magic player reset ${B}`, /equilibrium 0/) + assert.equal(focus().points, 42) + assert.equal(profile().castMode, 'flow') + }) + await step('console can edit the loaded player and rejects missing targets', async () => { + const start = fs.statSync(LOG).size + await consoleRun(`magic equilibrium set ${B} -12`) + assert.equal(profile().equilibrium, -12) + await consoleRun('magic player get NonexistentProbe') + assert.match(readSince(start), /not found|unknown player|find player/i) + await command(`magic equilibrium reset ${B}`, /equilibrium 0/) + }) + await consoleRun(`clear ${B}`) + await step('held-item commands reject empty hands', async () => { + await command('magic artifact inspect', /Hold an identified artifact/) + await command('magic gear inspect', /Hold an identified artifact/) + }) + await consoleRun(`magic artifact give ${B} cerrith common`, 1200) + await hold(/artifact_id/) + await step('artifact fill and muffle support get/set/add/remove/reset', async () => { + await command('magic artifact inspect', /Artifact:/) + await command('magic artifact fill set cerrith 8', /cerrith aura: 8\//) + await command('magic artifact fill remove cerrith 3', /cerrith aura: 5\//) + await command('magic artifact fill add all 1', /cerrith aura: 6\//) + await command('magic artifact fill reset all', /cerrith aura: 0\//) + await command('magic artifact muffle set 25', /muffle: 25/) + await command('magic artifact muffle remove 5', /muffle: 20/) + await command('magic artifact muffle add 10', /muffle: 30/) + await command('magic artifact muffle get', /muffle: 30/) + await command('magic artifact muffle reset', /muffle: 0/) + }) + await step('artifact wear can be repaired and then crumbled', async () => { + await command('magic artifact wear set 3', /wear: 3/) + await command('magic artifact wear remove 1', /wear: 2/) + await command('magic artifact wear reset', /wear: 0/) + await command('magic artifact wear add 100000', /wear:/) + await command('magic artifact inspect', /Hold an identified artifact/) + }) + await consoleRun(`clear ${B}`) + await consoleRun(`magic weapon give ${B} wand mitlan 70 iron_wand_core oak_handle`, 1500) + await hold(/gear_archetype/) + await step('weapon Rift and remaining durability edit without losing the weapon', async () => { + await command('magic gear inspect', /rift:/) + await command('magic gear rift set 10', /rift: 10/) + await command('magic gear rift add 5', /rift: 15/) + await command('magic gear rift remove 2', /rift: 13/) + await command('magic gear rift reset', /rift: 0/) + await command('magic gear durability set 20', /durability: 20/) + await command('magic gear durability remove 100000', /durability: 1\b/) + await command('magic gear durability reset', /durability: (?:[2-9]|[1-9]\d+)\b/) + await command('magic gear rift set 1.5', /whole numbers/) + await command('magic gear inspect', /attunement.mitlan:/) + }) + await step('edits survive logout and rejoin', async () => { + await command(`magic resonance set ${B} cerrith 23`, /resonance/i) + await command(`magic castmode set ${B} surge`, /casting surge/) + await command(`focus set ${B} 37`, /37\//) + bot.quit(); await sleep(2000) + bot = await joinAsCharacter(B, { class: /Mage/ }) + bot.on('messagestr', line => chat.push(line)) + await command(`magic player get ${B}`, /casting surge/) + assert.equal(profile().resonance.cerrith, 23) + await command(`focus get ${B}`, /37\//) + }) + await step('Dev log contains no command exceptions', async () => { + assert.doesNotMatch(readSince(logStart), /Unhandled exception executing command|Could not pass event.*(?:Magic|RPCharacters)|\[Magic\].*(?:Failed to|Exception)/) + }) + fs.writeFileSync(__dirname + '/dev-admin-values-results.json', JSON.stringify({ server: 'TFMCDev01', character, passed: results }, null, 2)) + console.log(`PASS ${results.length} integration groups`) + } catch (error) { + console.error('FAIL ' + error.stack) + process.exitCode = 1 + } finally { + if (bot && character) { + try { + await consoleRun(`magic player reset ${B}`) + await consoleRun(`focus restore ${B}`) + await consoleRun(`clear ${B}`) + } catch (error) { console.error('Cleanup: ' + error.message); process.exitCode = 1 } + } + try { await consoleRun(`deop ${B}`) } catch (error) { console.error('Deop cleanup: ' + error.message); process.exitCode = 1 } + if (bot) bot.quit() + await sleep(1000) + process.exit(process.exitCode || 0) + } +})() From 009b56ced39c443ed38e05d6732d82a8015afe9d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:47:10 +0200 Subject: [PATCH 2/2] Match Dev permission and offline-player responses in smoke tests --- tools/dev-admin-values.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/dev-admin-values.cjs b/tools/dev-admin-values.cjs index e1df0a1..21116a9 100644 --- a/tools/dev-admin-values.cjs +++ b/tools/dev-admin-values.cjs @@ -55,7 +55,7 @@ async function hold(pattern) { await consoleRun(`deop ${B}`) await step('unprivileged character cannot change Magic or Focus', async () => { await command(`magic equilibrium set ${B} 10`, /do not have permission/i) - await command(`focus set ${B} 10`, /do not have permission/i) + await command(`focus set ${B} 10`, /do not have permission|Unknown or incomplete command/i) }) await consoleRun(`op ${B}`) await step('player inspection identifies the active character', async () => { @@ -119,7 +119,7 @@ async function hold(pattern) { await consoleRun(`magic equilibrium set ${B} -12`) assert.equal(profile().equilibrium, -12) await consoleRun('magic player get NonexistentProbe') - assert.match(readSince(start), /not found|unknown player|find player/i) + assert.match(readSince(start), /not found|not online|unknown player|find player/i) await command(`magic equilibrium reset ${B}`, /equilibrium 0/) }) await consoleRun(`clear ${B}`)